diff --git a/cmd/oslstats/routes.go b/cmd/oslstats/routes.go index 90a4932..a37c837 100644 --- a/cmd/oslstats/routes.go +++ b/cmd/oslstats/routes.go @@ -74,9 +74,14 @@ func addRoutes( }, { Path: "/seasons/new", - Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, + Method: hws.MethodGET, Handler: handlers.NewSeason(s, conn), }, + { + Path: "/seasons/new", + Method: hws.MethodPOST, + Handler: handlers.NewSeasonSubmit(s, conn), + }, { Path: "/seasons/{season_short_name}", Method: hws.MethodGET, diff --git a/internal/db/season.go b/internal/db/season.go index a976b3d..6bc45d7 100644 --- a/internal/db/season.go +++ b/internal/db/season.go @@ -90,7 +90,7 @@ func ListSeasons(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*SeasonLis } func GetSeason(ctx context.Context, tx bun.Tx, shortname string) (*Season, error) { - var season *Season + season := new(Season) err := tx.NewSelect(). Model(season). Where("short_name = ?", strings.ToUpper(shortname)). diff --git a/internal/handlers/newseason.go b/internal/handlers/newseason.go index f019568..16a3c49 100644 --- a/internal/handlers/newseason.go +++ b/internal/handlers/newseason.go @@ -2,7 +2,9 @@ package handlers import ( "context" + "fmt" "net/http" + "strings" "time" "git.haelnorr.com/h/golib/hws" @@ -29,9 +31,162 @@ func NewSeasonSubmit( conn *bun.DB, ) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Parse form data + err := r.ParseForm() + if err != nil { + err = notifyWarn(s, r, "Invalid Form", "Please check your input and try again.", nil) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + + // Get form values + name := strings.TrimSpace(r.FormValue("name")) + shortName := strings.TrimSpace(strings.ToUpper(r.FormValue("short_name"))) + startDateStr := r.FormValue("start_date") + + // Validate required fields + if name == "" || shortName == "" || startDateStr == "" { + err = notifyWarn(s, r, "Missing Fields", "All fields are required.", nil) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + + // Validate field lengths + if len(name) > 20 { + err = notifyWarn(s, r, "Invalid Name", "Season name must be 20 characters or less.", nil) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + + if len(shortName) > 6 { + err = notifyWarn(s, r, "Invalid Short Name", "Short name must be 6 characters or less.", nil) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + + // Validate short name is alphanumeric only + if !isAlphanumeric(shortName) { + err = notifyWarn(s, r, "Invalid Short Name", "Short name must contain only letters and numbers.", nil) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + + // Parse start date (DD/MM/YYYY format) + startDate, err := time.Parse("02/01/2006", startDateStr) + if err != nil { + err = notifyWarn(s, r, "Invalid Date", "Please provide a valid start date in DD/MM/YYYY format.", nil) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + + // Begin database transaction + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "conn.BeginTx")) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + defer tx.Rollback() + + // Double-check uniqueness (race condition protection) + nameUnique, err := db.IsSeasonNameUnique(ctx, tx, name) + if err != nil { + err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "db.IsSeasonNameUnique")) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + + if !nameUnique { + err = notifyWarn(s, r, "Duplicate Name", "This season name is already taken.", nil) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + + shortNameUnique, err := db.IsSeasonShortNameUnique(ctx, tx, shortName) + if err != nil { + err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "db.IsSeasonShortNameUnique")) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + + if !shortNameUnique { + err = notifyWarn(s, r, "Duplicate Short Name", "This short name is already taken.", nil) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + + // Create the season + season, err := db.NewSeason(ctx, tx, name, shortName, startDate) + if err != nil { + err = notifyInternalServiceError(s, r, "Failed to create season", errors.Wrap(err, "db.NewSeason")) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + + // Commit transaction + err = tx.Commit() + if err != nil { + err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "tx.Commit")) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + + // Send success notification + err = notifySuccess(s, r, "Season Created", fmt.Sprintf("Successfully created season: %s", name), nil) + if err != nil { + // Log but don't fail the request + s.LogError(hws.HWSError{ + StatusCode: http.StatusInternalServerError, + Message: "Failed to send success notification", + Error: err, + }) + } + + // Redirect to the season detail page + w.Header().Set("HX-Redirect", fmt.Sprintf("/seasons/%s", season.ShortName)) + w.WriteHeader(http.StatusOK) }) } +// Helper function to validate alphanumeric strings +func isAlphanumeric(s string) bool { + for _, r := range s { + if !((r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) { + return false + } + } + return true +} + func IsSeasonNameUnique( s *hws.Server, conn *bun.DB, @@ -52,7 +207,11 @@ func IsSeasonNameUnique( } return } - name := r.FormValue("name") + defer tx.Rollback() + + // Trim whitespace for consistency + name := strings.TrimSpace(r.FormValue("name")) + unique, err := db.IsSeasonNameUnique(ctx, tx, name) if err != nil { err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "db.IsSeasonNameUnique")) @@ -61,10 +220,15 @@ func IsSeasonNameUnique( } return } + + tx.Commit() + if !unique { w.WriteHeader(http.StatusConflict) return } + + w.WriteHeader(http.StatusOK) }) } @@ -88,7 +252,11 @@ func IsSeasonShortNameUnique( } return } - shortname := r.FormValue("short_name") + defer tx.Rollback() + + // Get short name and convert to uppercase for consistency + shortname := strings.ToUpper(strings.TrimSpace(r.FormValue("short_name"))) + unique, err := db.IsSeasonShortNameUnique(ctx, tx, shortname) if err != nil { err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "db.IsSeasonShortNameUnique")) @@ -97,9 +265,14 @@ func IsSeasonShortNameUnique( } return } + + tx.Commit() + if !unique { w.WriteHeader(http.StatusConflict) return } + + w.WriteHeader(http.StatusOK) }) } diff --git a/internal/view/component/datepicker/README.md b/internal/view/component/datepicker/README.md new file mode 100644 index 0000000..451859d --- /dev/null +++ b/internal/view/component/datepicker/README.md @@ -0,0 +1,154 @@ +# DatePicker Component + +A reusable, standalone date picker component for oslstats forms. + +## Features + +✨ **Interactive Calendar** +- Click-to-open dropdown calendar +- Month and year dropdown selectors for easy navigation +- Previous/next month arrow buttons +- Today's date highlighted in blue + +📅 **Date Format** +- DD/MM/YYYY format (the sensible way) +- Pattern validation built-in +- Read-only input (date selection via picker only) + +🎨 **Design** +- Consistent with Catppuccin theme +- Responsive layout +- Hover effects and visual feedback +- Calendar icon in input field + +🔧 **Technical** +- Built with Alpine.js (already in project) +- No external dependencies +- Year range: 2001-2051 +- Submits in DD/MM/YYYY format + +## Usage + +### Basic Example + +```templ +import "git.haelnorr.com/h/oslstats/internal/view/component/datepicker" + +// Simple usage - required field, no custom onChange +@datepicker.DatePicker( + "event_date", // id + "event_date", // name + "Event Date", // label + "DD/MM/YYYY", // placeholder + true, // required + "" // onChange (empty = none) +) +``` + +### With Custom onChange Handler + +```templ +// With Alpine.js validation logic +@datepicker.DatePicker( + "start_date", + "start_date", + "Start Date", + "DD/MM/YYYY", + true, + "dateIsEmpty = false; validateForm();" +) +``` + +### Optional Field + +```templ +// Non-required field +@datepicker.DatePicker( + "end_date", + "end_date", + "End Date (Optional)", + "DD/MM/YYYY", + false, // not required + "" +) +``` + +## Parameters + +| Parameter | Type | Description | Example | +|-------------|--------|------------------------------------------------------|----------------------------------| +| `id` | string | Unique ID for the input element | `"start_date"` | +| `name` | string | Form field name (used in form submission) | `"start_date"` | +| `label` | string | Display label above the input | `"Start Date"` | +| `placeholder` | string | Placeholder text in the input | `"DD/MM/YYYY"` | +| `required` | bool | Whether the field is required | `true` or `false` | +| `onChange` | string | Alpine.js expression to run when date changes | `"updateForm();"` or `""` | + +## Server-Side Parsing + +The date picker submits dates in **DD/MM/YYYY** format. Parse on the server like this: + +```go +import "time" + +// Parse DD/MM/YYYY format +dateStr := r.FormValue("start_date") // e.g., "15/02/2026" +date, err := time.Parse("02/01/2006", dateStr) +if err != nil { + // Handle invalid date +} +``` + +**Important**: The format string is `"02/01/2006"` (DD/MM/YYYY), NOT `"01/02/2006"` (MM/DD/YYYY). + +## Integration with Alpine.js Forms + +The date picker works seamlessly with Alpine.js validation: + +```templ +
+``` + +## Styling + +The component uses Tailwind CSS classes and Catppuccin theme colors: +- `bg-mantle`, `bg-surface0`, `bg-surface1` - backgrounds +- `border-surface1` - borders +- `text-subtext0` - muted text +- `bg-blue` - accent color (today's date) +- `focus:border-blue` - focus states + +All styling is built-in; no additional CSS required. + +## Browser Compatibility + +Works in all modern browsers that support: +- Alpine.js 3.x +- ES6 JavaScript (arrow functions, template literals, etc.) +- CSS Grid + +## Examples in Codebase + +See `internal/view/component/form/new_season.templ` for a real-world usage example. diff --git a/internal/view/component/datepicker/datepicker.templ b/internal/view/component/datepicker/datepicker.templ new file mode 100644 index 0000000..e2f4112 --- /dev/null +++ b/internal/view/component/datepicker/datepicker.templ @@ -0,0 +1,239 @@ +package datepicker + +// DatePicker renders a reusable date picker component with DD/MM/YYYY format +// +// Features: +// - Interactive calendar dropdown with month/year selectors +// - DD/MM/YYYY format (proper date format, none of that American nonsense) +// - Year range: 2001-2051 +// - Today's date highlighted in blue +// - Click outside to close +// - Consistent with Catppuccin theme +// +// Parameters: +// - id: unique ID for the input field (e.g., "start_date") +// - name: form field name (e.g., "start_date") +// - label: display label (e.g., "Start Date") +// - placeholder: input placeholder (default: "DD/MM/YYYY") +// - required: whether the field is required (true/false) +// - onChange: Alpine.js expression to run when date changes (e.g., "dateIsEmpty = false; updateCanSubmit();") +// Set to empty string "" if no custom onChange handler is needed +// +// Usage Example: +// import "git.haelnorr.com/h/oslstats/internal/view/component/datepicker" +// +// @datepicker.DatePicker("birth_date", "birth_date", "Date of Birth", "DD/MM/YYYY", true, "handleDateChange();") +// +// The component submits the date in DD/MM/YYYY format. To parse on the server: +// date, err := time.Parse("02/01/2006", dateString) +templ DatePicker(id, name, label, placeholder string, required bool, onChange string) { ++ Add a new season to the system. All fields are required. +
+