Compare commits

..

2 Commits

Author SHA1 Message Date
ef5a0dc078 seasons can be added now 2026-02-02 19:46:14 +11:00
9c70458b76 scaffolding for new seasons 2026-02-02 19:12:14 +11:00
23 changed files with 1374 additions and 41 deletions

View File

@@ -15,7 +15,7 @@ import (
"git.haelnorr.com/h/oslstats/internal/store" "git.haelnorr.com/h/oslstats/internal/store"
) )
func setupHttpServer( func setupHTTPServer(
staticFS *fs.FS, staticFS *fs.FS,
cfg *config.Config, cfg *config.Config,
logger *hlog.Logger, logger *hlog.Logger,

View File

@@ -72,6 +72,21 @@ func addRoutes(
Method: hws.MethodPOST, Method: hws.MethodPOST,
Handler: handlers.SeasonsList(s, conn), Handler: handlers.SeasonsList(s, conn),
}, },
{
Path: "/seasons/new",
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,
Handler: handlers.SeasonPage(s, conn),
},
} }
htmxRoutes := []hws.Route{ htmxRoutes := []hws.Route{
@@ -80,6 +95,16 @@ func addRoutes(
Method: hws.MethodPOST, Method: hws.MethodPOST,
Handler: handlers.IsUsernameUnique(s, conn, cfg, store), Handler: handlers.IsUsernameUnique(s, conn, cfg, store),
}, },
{
Path: "/htmx/isseasonnameunique",
Method: hws.MethodPOST,
Handler: handlers.IsSeasonNameUnique(s, conn),
},
{
Path: "/htmx/isseasonshortnameunique",
Method: hws.MethodPOST,
Handler: handlers.IsSeasonShortNameUnique(s, conn),
},
} }
wsRoutes := []hws.Route{ wsRoutes := []hws.Route{

View File

@@ -29,7 +29,6 @@ func run(ctx context.Context, logger *hlog.Logger, cfg *config.Config) error {
if err != nil { if err != nil {
return errors.Wrap(err, "setupDBConn") return errors.Wrap(err, "setupDBConn")
} }
defer closedb()
// Setup embedded files // Setup embedded files
logger.Debug().Msg("Getting embedded files") logger.Debug().Msg("Getting embedded files")
@@ -50,7 +49,7 @@ func run(ctx context.Context, logger *hlog.Logger, cfg *config.Config) error {
} }
logger.Debug().Msg("Setting up HTTP server") logger.Debug().Msg("Setting up HTTP server")
httpServer, err := setupHttpServer(&staticFS, cfg, logger, bun, store, discordAPI) httpServer, err := setupHTTPServer(&staticFS, cfg, logger, bun, store, discordAPI)
if err != nil { if err != nil {
return errors.Wrap(err, "setupHttpServer") return errors.Wrap(err, "setupHttpServer")
} }
@@ -72,7 +71,11 @@ func run(ctx context.Context, logger *hlog.Logger, cfg *config.Config) error {
logger.Info().Msg("Shut down requested, waiting 60 seconds...") logger.Info().Msg("Shut down requested, waiting 60 seconds...")
err := httpServer.Shutdown(shutdownCtx) err := httpServer.Shutdown(shutdownCtx)
if err != nil { if err != nil {
logger.Error().Err(err).Str("stacktrace", fmt.Sprintf("%+v", errors.Wrap(err, "httpServer.Shutdown"))).Msg("Graceful shutdown failed") logger.Error().Err(err).Str("stacktrace", fmt.Sprintf("%+v", errors.Wrap(err, "httpServer.Shutdown"))).Msg("Error during HTTP server shutdown")
}
err = closedb()
if err != nil {
logger.Error().Err(err).Str("stacktrace", fmt.Sprintf("%+v", errors.Wrap(err, "closedb"))).Msg("Error during database close")
} }
}) })
wg.Wait() wg.Wait()

4
go.sum
View File

@@ -6,12 +6,8 @@ git.haelnorr.com/h/golib/ezconf v0.1.1 h1:4euTSDb9jvuQQkVq+x5gHoYPYyUZPWxoOSlWCI
git.haelnorr.com/h/golib/ezconf v0.1.1/go.mod h1:rETDcjpcEyyeBgCiZSU617wc0XycwZSC5+IAOtXmwP8= git.haelnorr.com/h/golib/ezconf v0.1.1/go.mod h1:rETDcjpcEyyeBgCiZSU617wc0XycwZSC5+IAOtXmwP8=
git.haelnorr.com/h/golib/hlog v0.10.4 h1:vpCsV/OddjIYx8F48U66WxojjmhEbeLGQAOBG4ViSRQ= git.haelnorr.com/h/golib/hlog v0.10.4 h1:vpCsV/OddjIYx8F48U66WxojjmhEbeLGQAOBG4ViSRQ=
git.haelnorr.com/h/golib/hlog v0.10.4/go.mod h1:+wJ8vecQY/JITTXKmI3JfkHiUGyMs7N6wooj2wuWZbc= git.haelnorr.com/h/golib/hlog v0.10.4/go.mod h1:+wJ8vecQY/JITTXKmI3JfkHiUGyMs7N6wooj2wuWZbc=
git.haelnorr.com/h/golib/hws v0.4.3 h1:rpqe0Dcbm3b5XZ/Bfy0LUhph6RR7+bmANrSA/W81l0A=
git.haelnorr.com/h/golib/hws v0.4.3/go.mod h1:UqB83p9lGjidDkk0pWRqxxOFrCkg8t+9J6uGtBOjNLo=
git.haelnorr.com/h/golib/hws v0.4.4 h1:tV9UjZ4q96UlOdJKsC7b3kDV+bpQYqKVPQuaV1n3U3k= git.haelnorr.com/h/golib/hws v0.4.4 h1:tV9UjZ4q96UlOdJKsC7b3kDV+bpQYqKVPQuaV1n3U3k=
git.haelnorr.com/h/golib/hws v0.4.4/go.mod h1:dxAbbGGNzqLXhZXwgt091QsvsPBdrS+1YsNQNldNVoM= git.haelnorr.com/h/golib/hws v0.4.4/go.mod h1:dxAbbGGNzqLXhZXwgt091QsvsPBdrS+1YsNQNldNVoM=
git.haelnorr.com/h/golib/hwsauth v0.5.3 h1:Vgw8khDQZJRCc3m7z9QlbL9CYPyFB9JXUC3+omKRZPc=
git.haelnorr.com/h/golib/hwsauth v0.5.3/go.mod h1:NOonrVU/lX8lzuV77eDEiTwBjn7RrzYVcSdXUJWeHmQ=
git.haelnorr.com/h/golib/hwsauth v0.5.4 h1:nuaiVpJHHXgKVRPoQSE/v3CJHSkivViK5h3SVhEcbbM= git.haelnorr.com/h/golib/hwsauth v0.5.4 h1:nuaiVpJHHXgKVRPoQSE/v3CJHSkivViK5h3SVhEcbbM=
git.haelnorr.com/h/golib/hwsauth v0.5.4/go.mod h1:eIjRPeGycvxRWERkxCoRVMEEhHuUdiPDvjpzzZOhQ0w= git.haelnorr.com/h/golib/hwsauth v0.5.4/go.mod h1:eIjRPeGycvxRWERkxCoRVMEEhHuUdiPDvjpzzZOhQ0w=
git.haelnorr.com/h/golib/jwt v0.10.1 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI= git.haelnorr.com/h/golib/jwt v0.10.1 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI=

2
internal/db/doc.go Normal file
View File

@@ -0,0 +1,2 @@
// Package db is an internal package for all the database models and related methods
package db

View File

@@ -3,6 +3,8 @@ package db
import ( import (
"context" "context"
"database/sql" "database/sql"
"strings"
"time"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/uptrace/bun" "github.com/uptrace/bun"
@@ -11,9 +13,13 @@ import (
type Season struct { type Season struct {
bun.BaseModel `bun:"table:seasons,alias:s"` bun.BaseModel `bun:"table:seasons,alias:s"`
ID int `bun:"id,pk,autoincrement"` ID int `bun:"id,pk,autoincrement"`
Name string `bun:"name,unique"` Name string `bun:"name,unique"`
ShortName string `bun:"short_name,unique"` ShortName string `bun:"short_name,unique"`
StartDate time.Time `bun:"start_date,notnull"`
EndDate bun.NullTime `bun:"end_date"`
FinalsStartDate bun.NullTime `bun:"finals_start_date"`
FinalsEndDate bun.NullTime `bun:"finals_end_date"`
} }
type SeasonList struct { type SeasonList struct {
@@ -22,7 +28,7 @@ type SeasonList struct {
PageOpts PageOpts PageOpts PageOpts
} }
func NewSeason(ctx context.Context, tx bun.Tx, name, shortname string) (*Season, error) { func NewSeason(ctx context.Context, tx bun.Tx, name, shortname string, start time.Time) (*Season, error) {
if name == "" { if name == "" {
return nil, errors.New("name cannot be empty") return nil, errors.New("name cannot be empty")
} }
@@ -31,7 +37,8 @@ func NewSeason(ctx context.Context, tx bun.Tx, name, shortname string) (*Season,
} }
season := &Season{ season := &Season{
Name: name, Name: name,
ShortName: shortname, ShortName: strings.ToUpper(shortname),
StartDate: start.Truncate(time.Hour * 24),
} }
_, err := tx.NewInsert(). _, err := tx.NewInsert().
Model(season). Model(season).
@@ -81,3 +88,38 @@ func ListSeasons(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*SeasonLis
} }
return sl, nil return sl, nil
} }
func GetSeason(ctx context.Context, tx bun.Tx, shortname string) (*Season, error) {
season := new(Season)
err := tx.NewSelect().
Model(season).
Where("short_name = ?", strings.ToUpper(shortname)).
Limit(1).
Scan(ctx)
if err != nil && err != sql.ErrNoRows {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return season, nil
}
func IsSeasonNameUnique(ctx context.Context, tx bun.Tx, name string) (bool, error) {
count, err := tx.NewSelect().
Model((*Season)(nil)).
Where("name = ?", name).
Count(ctx)
if err != nil {
return false, errors.Wrap(err, "tx.NewSelect")
}
return count == 0, nil
}
func IsSeasonShortNameUnique(ctx context.Context, tx bun.Tx, shortname string) (bool, error) {
count, err := tx.NewSelect().
Model((*Season)(nil)).
Where("short_name = ?", shortname).
Count(ctx)
if err != nil {
return false, errors.Wrap(err, "tx.NewSelect")
}
return count == 0, nil
}

View File

@@ -26,20 +26,6 @@ func (user *User) GetID() int {
return user.ID return user.ID
} }
// Change the user's username
func (user *User) ChangeUsername(ctx context.Context, tx bun.Tx, newUsername string) error {
_, err := tx.NewUpdate().
Model(user).
Set("username = ?", newUsername).
Where("id = ?", user.ID).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "tx.NewUpdate")
}
user.Username = newUsername
return nil
}
// CreateUser creates a new user with the given username and password // CreateUser creates a new user with the given username and password
func CreateUser(ctx context.Context, tx bun.Tx, username string, discorduser *discordgo.User) (*User, error) { func CreateUser(ctx context.Context, tx bun.Tx, username string, discorduser *discordgo.User) (*User, error) {
if discorduser == nil { if discorduser == nil {

2
internal/handlers/doc.go Normal file
View File

@@ -0,0 +1,2 @@
// Package handlers contains all the functions for handling http requests and serving content
package handlers

View File

@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hws"
"github.com/a-h/templ"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@@ -166,3 +167,10 @@ func parseErrorDetails(details string) (int, string) {
return errDetails.Code, errDetails.Stacktrace return errDetails.Code, errDetails.Stacktrace
} }
func renderSafely(page templ.Component, s *hws.Server, r *http.Request, w http.ResponseWriter) {
err := page.Render(r.Context(), w)
if err != nil {
throwInternalServiceError(s, w, r, "Failed to render page", errors.Wrap(err, "page."))
}
}

View File

@@ -8,15 +8,15 @@ import (
"git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hws"
) )
// Handles responses to the / path. Also serves a 404 Page for paths that // Index handles responses to the / path. Also serves a 404 Page for paths that
// don't have explicit handlers // don't have explicit handlers
func Index(server *hws.Server) http.Handler { func Index(s *hws.Server) http.Handler {
return http.HandlerFunc( return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" { if r.URL.Path != "/" {
throwNotFound(server, w, r, r.URL.Path) throwNotFound(s, w, r, r.URL.Path)
} }
page.Index().Render(r.Context(), w) renderSafely(page.Index(), s, r, w)
}, },
) )
} }

View File

@@ -0,0 +1,278 @@
package handlers
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/view/page"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
func NewSeason(
s *hws.Server,
conn *bun.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
renderSafely(page.NewSeason(), s, r, w)
return
}
})
}
func NewSeasonSubmit(
s *hws.Server,
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,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
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()
// 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"))
if err != nil {
throwInternalServiceError(s, w, r, "Error notifying client", err)
}
return
}
tx.Commit()
if !unique {
w.WriteHeader(http.StatusConflict)
return
}
w.WriteHeader(http.StatusOK)
})
}
func IsSeasonShortNameUnique(
s *hws.Server,
conn *bun.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
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()
// 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"))
if err != nil {
throwInternalServiceError(s, w, r, "Error notifying client", err)
}
return
}
tx.Commit()
if !unique {
w.WriteHeader(http.StatusConflict)
return
}
w.WriteHeader(http.StatusOK)
})
}

View File

@@ -76,7 +76,7 @@ func Register(
method := r.Method method := r.Method
if method == "GET" { if method == "GET" {
tx.Commit() tx.Commit()
page.Register(details.DiscordUser.Username).Render(r.Context(), w) renderSafely(page.Register(details.DiscordUser.Username), s, r, w)
return return
} }
if method == "POST" { if method == "POST" {

View File

@@ -0,0 +1,41 @@
package handlers
import (
"context"
"net/http"
"time"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/view/page"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
func SeasonPage(
s *hws.Server,
conn *bun.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "conn.BeginTx"))
return
}
defer tx.Rollback()
seasonStr := r.PathValue("season_short_name")
season, err := db.GetSeason(ctx, tx, seasonStr)
if err != nil {
throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "db.GetSeason"))
return
}
tx.Commit()
if season == nil {
throwNotFound(s, w, r, r.URL.Path)
return
}
renderSafely(page.SeasonPage(season), s, r, w)
})
}

View File

@@ -58,7 +58,7 @@ func SeasonsPage(
return return
} }
tx.Commit() tx.Commit()
page.SeasonsPage(seasons).Render(r.Context(), w) renderSafely(page.SeasonsList(seasons), s, r, w)
}) })
} }
@@ -123,6 +123,6 @@ func SeasonsList(
tx.Commit() tx.Commit()
// Return only the list component (hx-push-url handles URL update client-side) // Return only the list component (hx-push-url handles URL update client-side)
page.SeasonsList(seasons).Render(r.Context(), w) renderSafely(page.SeasonsList(seasons), s, r, w)
}) })
} }

View File

@@ -1,13 +1,14 @@
package handlers package handlers
import ( import (
"git.haelnorr.com/h/golib/hws"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strings" "strings"
"git.haelnorr.com/h/golib/hws"
) )
// Handles requests for static files, without allowing access to the // StaticFS handles requests for static files, without allowing access to the
// directory viewer and returning 404 if an exact file is not found // directory viewer and returning 404 if an exact file is not found
func StaticFS(staticFS *http.FileSystem, server *hws.Server) http.Handler { func StaticFS(staticFS *http.FileSystem, server *hws.Server) http.Handler {
// Create the file server once, not on every request // Create the file server once, not on every request

View File

@@ -9,7 +9,7 @@ import (
"git.haelnorr.com/h/oslstats/internal/view/page" "git.haelnorr.com/h/oslstats/internal/view/page"
) )
// Handles responses to the / path. Also serves a 404 Page for paths that // NotifyTester handles responses to the / path. Also serves a 404 Page for paths that
// don't have explicit handlers // don't have explicit handlers
func NotifyTester(s *hws.Server) http.Handler { func NotifyTester(s *hws.Server) http.Handler {
return http.HandlerFunc( return http.HandlerFunc(
@@ -22,9 +22,9 @@ func NotifyTester(s *hws.Server) http.Handler {
// Error: testErr, // Error: testErr,
// }) // })
// page.Render(r.Context(), w) // page.Render(r.Context(), w)
page.Test().Render(r.Context(), w) renderSafely(page.Test(), s, r, w)
} else { } else {
r.ParseForm() _ = r.ParseForm()
// target := r.Form.Get("target") // target := r.Form.Get("target")
title := r.Form.Get("title") title := r.Form.Get("title")
level := r.Form.Get("type") level := r.Form.Get("type")

View File

@@ -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
<form x-data="{
dateIsEmpty: true,
dateError: '',
canSubmit: false,
updateCanSubmit() {
this.canSubmit = !this.dateIsEmpty;
}
}">
@datepicker.DatePicker(
"event_date",
"event_date",
"Event Date",
"DD/MM/YYYY",
true,
"dateIsEmpty = $el.value === ''; updateCanSubmit();"
)
<!-- Error message -->
<p x-show="dateError" x-text="dateError"></p>
<!-- Submit button -->
<button x-bind:disabled="!canSubmit">Submit</button>
</form>
```
## 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.

View File

@@ -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) {
<div
x-data="{
showPicker: false,
selectedDate: '',
displayDate: '',
tempYear: new Date().getFullYear(),
tempMonth: new Date().getMonth(),
months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
init() {
// Ensure dropdowns are initialized with current month/year
this.tempYear = new Date().getFullYear();
this.tempMonth = new Date().getMonth();
},
getDaysInMonth(year, month) {
return new Date(year, month + 1, 0).getDate();
},
getFirstDayOfMonth(year, month) {
return new Date(year, month, 1).getDay();
},
selectDate(day) {
const d = String(day).padStart(2, '0');
const m = String(this.tempMonth + 1).padStart(2, '0');
const y = this.tempYear;
this.displayDate = d + '/' + m + '/' + y;
this.selectedDate = this.displayDate;
$refs.dateInput.value = this.displayDate;
this.showPicker = false;
// Manually trigger input event so Alpine.js @input handler fires
$refs.dateInput.dispatchEvent(new Event('input', { bubbles: true }));
},
prevMonth() {
if (this.tempMonth === 0) {
this.tempMonth = 11;
this.tempYear--;
} else {
this.tempMonth--;
}
},
nextMonth() {
if (this.tempMonth === 11) {
this.tempMonth = 0;
this.tempYear++;
} else {
this.tempMonth++;
}
},
isToday(day) {
const today = new Date();
return day === today.getDate() &&
this.tempMonth === today.getMonth() &&
this.tempYear === today.getFullYear();
}
}"
>
<label for={ id } class="block text-sm font-medium mb-2">{ label }</label>
<div class="relative">
<input
type="text"
id={ id }
name={ name }
x-ref="dateInput"
class="py-3 px-4 pr-10 block w-full rounded-lg text-sm bg-base border-2 border-overlay0 focus:border-blue outline-none"
placeholder={ placeholder }
pattern="\d{2}/\d{2}/\d{4}"
if required {
required
}
readonly
x-model="displayDate"
@click="showPicker = !showPicker"
if onChange != "" {
@input={ onChange }
}
/>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-subtext0 hover:text-text"
@click="showPicker = !showPicker"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
</button>
<!-- Date Picker Dropdown -->
<div
x-show="showPicker"
x-cloak
@click.away="showPicker = false"
class="absolute z-50 mt-2 bg-mantle border-2 border-surface1 rounded-lg shadow-lg p-4 w-80"
>
<!-- Month/Year Navigation -->
<div class="flex items-center justify-between gap-2 mb-4">
<button type="button" @click="prevMonth()" class="p-1 hover:bg-surface0 rounded shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
</button>
<div class="flex gap-2 flex-1">
<select
x-model.number="tempMonth"
class="flex-1 px-2 py-1 text-sm bg-surface0 border border-surface1 rounded outline-none focus:border-blue"
>
<option value="0">January</option>
<option value="1">February</option>
<option value="2">March</option>
<option value="3">April</option>
<option value="4">May</option>
<option value="5">June</option>
<option value="6">July</option>
<option value="7">August</option>
<option value="8">September</option>
<option value="9">October</option>
<option value="10">November</option>
<option value="11">December</option>
</select>
<select
x-model.number="tempYear"
class="px-2 py-1 text-sm bg-surface0 border border-surface1 rounded outline-none focus:border-blue"
>
<option value="2001">2001</option>
<option value="2002">2002</option>
<option value="2003">2003</option>
<option value="2004">2004</option>
<option value="2005">2005</option>
<option value="2006">2006</option>
<option value="2007">2007</option>
<option value="2008">2008</option>
<option value="2009">2009</option>
<option value="2010">2010</option>
<option value="2011">2011</option>
<option value="2012">2012</option>
<option value="2013">2013</option>
<option value="2014">2014</option>
<option value="2015">2015</option>
<option value="2016">2016</option>
<option value="2017">2017</option>
<option value="2018">2018</option>
<option value="2019">2019</option>
<option value="2020">2020</option>
<option value="2021">2021</option>
<option value="2022">2022</option>
<option value="2023">2023</option>
<option value="2024">2024</option>
<option value="2025">2025</option>
<option value="2026">2026</option>
<option value="2027">2027</option>
<option value="2028">2028</option>
<option value="2029">2029</option>
<option value="2030">2030</option>
<option value="2031">2031</option>
<option value="2032">2032</option>
<option value="2033">2033</option>
<option value="2034">2034</option>
<option value="2035">2035</option>
<option value="2036">2036</option>
<option value="2037">2037</option>
<option value="2038">2038</option>
<option value="2039">2039</option>
<option value="2040">2040</option>
<option value="2041">2041</option>
<option value="2042">2042</option>
<option value="2043">2043</option>
<option value="2044">2044</option>
<option value="2045">2045</option>
<option value="2046">2046</option>
<option value="2047">2047</option>
<option value="2048">2048</option>
<option value="2049">2049</option>
<option value="2050">2050</option>
<option value="2051">2051</option>
</select>
</div>
<button type="button" @click="nextMonth()" class="p-1 hover:bg-surface0 rounded shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</button>
</div>
<!-- Day Headers -->
<div class="grid grid-cols-7 gap-1 mb-2">
<div class="text-center text-xs font-medium text-subtext0 py-1">Su</div>
<div class="text-center text-xs font-medium text-subtext0 py-1">Mo</div>
<div class="text-center text-xs font-medium text-subtext0 py-1">Tu</div>
<div class="text-center text-xs font-medium text-subtext0 py-1">We</div>
<div class="text-center text-xs font-medium text-subtext0 py-1">Th</div>
<div class="text-center text-xs font-medium text-subtext0 py-1">Fr</div>
<div class="text-center text-xs font-medium text-subtext0 py-1">Sa</div>
</div>
<!-- Calendar Days -->
<div class="grid grid-cols-7 gap-1">
<template x-for="blank in getFirstDayOfMonth(tempYear, tempMonth)" :key="'blank-' + blank">
<div class="text-center py-2"></div>
</template>
<template x-for="day in getDaysInMonth(tempYear, tempMonth)" :key="'day-' + day">
<button
type="button"
@click="selectDate(day)"
class="text-center py-2 rounded hover:bg-surface0 transition"
:class="{
'bg-blue text-mantle font-bold': isToday(day),
'hover:bg-surface1': !isToday(day)
}"
x-text="day"
></button>
</template>
</div>
</div>
</div>
</div>
}

View File

@@ -0,0 +1,177 @@
package form
import "git.haelnorr.com/h/oslstats/internal/view/component/datepicker"
templ NewSeason() {
<form
hx-post="/seasons/new"
hx-swap="none"
x-data={ templ.JSFuncCall("newSeasonFormData").CallInline }
@submit="handleSubmit()"
@htmx:after-request="if(submitTimeout) clearTimeout(submitTimeout); const redirect = $event.detail.xhr.getResponseHeader('HX-Redirect'); if(redirect) return; if(!$event.detail.successful) { isSubmitting=false; buttonText='Create Season'; generalError='An error occurred. Please try again.'; }"
>
<script>
function newSeasonFormData() {
return {
canSubmit: false,
buttonText: "Create Season",
// Name validation state
nameError: "",
nameIsChecking: false,
nameIsUnique: false,
nameIsEmpty: true,
// Short name validation state
shortNameError: "",
shortNameIsChecking: false,
shortNameIsUnique: false,
shortNameIsEmpty: true,
// Date validation state
dateError: "",
dateIsEmpty: true,
// Form state
isSubmitting: false,
generalError: "",
submitTimeout: null,
// Reset name errors
resetNameErr() {
this.nameError = "";
this.nameIsChecking = false;
this.nameIsUnique = false;
},
// Reset short name errors
resetShortNameErr() {
this.shortNameError = "";
this.shortNameIsChecking = false;
this.shortNameIsUnique = false;
},
// Reset date errors
resetDateErr() {
this.dateError = "";
},
// Check if form can be submitted
updateCanSubmit() {
this.canSubmit = !this.nameIsEmpty && this.nameIsUnique && !this.nameIsChecking &&
!this.shortNameIsEmpty && this.shortNameIsUnique && !this.shortNameIsChecking &&
!this.dateIsEmpty;
},
// Handle form submission
handleSubmit() {
this.isSubmitting = true;
this.buttonText = 'Creating...';
this.generalError = '';
// Set timeout for 10 seconds
this.submitTimeout = setTimeout(() => {
this.isSubmitting = false;
this.buttonText = 'Create Season';
this.generalError = 'Request timed out. Please try again.';
}, 10000);
}
};
}
</script>
<div class="grid gap-y-5">
<!-- Name Field -->
<div>
<label for="name" class="block text-sm font-medium mb-2">Season Name</label>
<div class="relative">
<input
type="text"
id="name"
name="name"
maxlength="20"
x-bind:class="{
'py-3 px-4 block w-full rounded-lg text-sm bg-base disabled:opacity-50 disabled:pointer-events-none border-2 outline-none': true,
'border-overlay0 focus:border-blue': !nameIsUnique && !nameError,
'border-green focus:border-green': nameIsUnique && !nameIsChecking && !nameError,
'border-red focus:border-red': nameError && !nameIsChecking && !isSubmitting
}"
required
placeholder="e.g. Season 1"
@input="resetNameErr(); nameIsEmpty = $el.value.trim() === ''; if(nameIsEmpty) { nameError='Season name is required'; nameIsUnique=false; } updateCanSubmit();"
hx-post="/htmx/isseasonnameunique"
hx-trigger="input changed delay:500ms"
hx-swap="none"
@htmx:before-request="if($el.value.trim() === '') { nameIsEmpty=true; return; } nameIsEmpty=false; nameIsChecking=true; nameIsUnique=false; nameError=''; updateCanSubmit();"
@htmx:after-request="nameIsChecking=false; if($event.detail.successful) { nameIsUnique=true; } else if($event.detail.xhr.status === 409) { nameError='This season name is already taken'; nameIsUnique=false; } updateCanSubmit();"
/>
<p class="text-xs text-subtext1 mt-1">Maximum 20 characters</p>
</div>
<p
class="text-center text-xs text-red mt-2"
x-show="nameError && !isSubmitting"
x-cloak
x-text="nameError"
></p>
</div>
<!-- Short Name Field -->
<div>
<label for="short_name" class="block text-sm font-medium mb-2">Short Name</label>
<div class="relative">
<input
type="text"
id="short_name"
name="short_name"
maxlength="6"
x-bind:class="{
'py-3 px-4 block w-full rounded-lg text-sm bg-base disabled:opacity-50 disabled:pointer-events-none border-2 outline-none uppercase': true,
'border-overlay0 focus:border-blue': !shortNameIsUnique && !shortNameError,
'border-green focus:border-green': shortNameIsUnique && !shortNameIsChecking && !shortNameError,
'border-red focus:border-red': shortNameError && !shortNameIsChecking && !isSubmitting
}"
required
placeholder="e.g. S1"
pattern="[A-Z0-9]+"
@input="
let val = $el.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
$el.value = val;
resetShortNameErr();
shortNameIsEmpty = val.trim() === '';
if(shortNameIsEmpty) {
shortNameError='Short name is required';
shortNameIsUnique=false;
}
updateCanSubmit();
"
hx-post="/htmx/isseasonshortnameunique"
hx-trigger="input changed delay:500ms"
hx-swap="none"
@htmx:before-request="if($el.value.trim() === '') { shortNameIsEmpty=true; return; } shortNameIsEmpty=false; shortNameIsChecking=true; shortNameIsUnique=false; shortNameError=''; updateCanSubmit();"
@htmx:after-request="shortNameIsChecking=false; if($event.detail.successful) { shortNameIsUnique=true; } else if($event.detail.xhr.status === 409) { shortNameError='This short name is already taken'; shortNameIsUnique=false; } updateCanSubmit();"
/>
<p class="text-xs text-subtext1 mt-1">Maximum 6 characters, alphanumeric only (auto-capitalized)</p>
</div>
<p
class="text-center text-xs text-red mt-2"
x-show="shortNameError && !isSubmitting"
x-cloak
x-text="shortNameError"
></p>
</div>
<!-- Start Date Field -->
@datepicker.DatePicker("start_date", "start_date", "Start Date", "DD/MM/YYYY", true, "dateIsEmpty = $el.value === ''; resetDateErr(); if(dateIsEmpty) { dateError='Start date is required'; } updateCanSubmit();")
<p
class="text-center text-xs text-red mt-2"
x-show="dateError && !isSubmitting"
x-cloak
x-text="dateError"
></p>
<!-- General Error Message -->
<p
class="text-center text-sm text-red"
x-show="generalError"
x-cloak
x-text="generalError"
></p>
<!-- Submit Button -->
<button
x-bind:disabled="!canSubmit || isSubmitting"
x-text="buttonText"
type="submit"
class="w-full py-3 px-4 inline-flex justify-center items-center
gap-x-2 rounded-lg border border-transparent transition text-base font-semibold
bg-blue hover:bg-blue/75 text-mantle hover:cursor-pointer
disabled:bg-blue/40 disabled:cursor-not-allowed"
></button>
</div>
</form>
}

View File

@@ -0,0 +1,24 @@
package page
import "git.haelnorr.com/h/oslstats/internal/view/component/form"
import "git.haelnorr.com/h/oslstats/internal/view/layout"
templ NewSeason() {
@layout.Global("New Season") {
<div class="max-w-screen-lg mx-auto px-4 py-8">
<div class="bg-mantle border border-surface1 rounded-xl">
<div class="p-6 sm:p-8">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-text">Create New Season</h1>
<p class="mt-2 text-sm text-subtext0">
Add a new season to the system. All fields are required.
</p>
</div>
<div class="max-w-md mx-auto">
@form.NewSeason()
</div>
</div>
</div>
</div>
}
}

View File

@@ -0,0 +1,202 @@
package page
import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/view/layout"
import "time"
import "strconv"
templ SeasonPage(season *db.Season) {
@layout.Global(season.Name) {
<div class="max-w-screen-2xl mx-auto px-4 py-8">
@SeasonDetails(season)
</div>
}
}
templ SeasonDetails(season *db.Season) {
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
<!-- Header Section -->
<div class="bg-surface0 border-b border-surface1 px-6 py-8">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 class="text-4xl font-bold text-text mb-2">{ season.Name }</h1>
<span class="inline-block bg-blue px-3 py-1 rounded-full text-sm font-semibold text-mantle">
{ season.ShortName }
</span>
</div>
<div class="flex gap-2">
<a
href={ templ.SafeURL("/seasons/" + season.ShortName + "/edit") }
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
bg-blue hover:bg-blue/75 text-mantle transition"
>
Edit
</a>
<a
href="/seasons"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
bg-surface1 hover:bg-surface2 text-text transition"
>
Back to Seasons
</a>
</div>
</div>
</div>
<!-- Information Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 p-6">
<!-- Regular Season Section -->
<div class="bg-surface0 border border-surface1 rounded-lg p-6">
<h2 class="text-2xl font-bold text-text mb-4 flex items-center gap-2">
<span class="text-blue">●</span>
Regular Season
</h2>
<div class="space-y-3">
<div class="flex justify-between items-center">
<span class="text-subtext0">Start Date:</span>
<span class="text-text font-semibold">
{ formatDate(season.StartDate) }
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-subtext0">End Date:</span>
<span class="text-text font-semibold">
if !season.EndDate.IsZero() {
{ formatDate(season.EndDate.Time) }
} else {
<span class="text-subtext1 italic">Not set</span>
}
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-subtext0">Duration:</span>
<span class="text-text font-semibold">
if !season.EndDate.IsZero() {
{ formatDuration(season.StartDate, season.EndDate.Time) }
} else {
<span class="text-subtext1 italic">Ongoing</span>
}
</span>
</div>
</div>
</div>
<!-- Finals Section -->
<div class="bg-surface0 border border-surface1 rounded-lg p-6">
<h2 class="text-2xl font-bold text-text mb-4 flex items-center gap-2">
<span class="text-yellow">★</span>
Finals
</h2>
<div class="space-y-3">
<div class="flex justify-between items-center">
<span class="text-subtext0">Start Date:</span>
<span class="text-text font-semibold">
if !season.FinalsStartDate.IsZero() {
{ formatDate(season.FinalsStartDate.Time) }
} else {
<span class="text-subtext1 italic">Not set</span>
}
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-subtext0">End Date:</span>
<span class="text-text font-semibold">
if !season.FinalsEndDate.IsZero() {
{ formatDate(season.FinalsEndDate.Time) }
} else {
<span class="text-subtext1 italic">Not set</span>
}
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-subtext0">Duration:</span>
<span class="text-text font-semibold">
if !season.FinalsStartDate.IsZero() && !season.FinalsEndDate.IsZero() {
{ formatDuration(season.FinalsStartDate.Time, season.FinalsEndDate.Time) }
} else {
<span class="text-subtext1 italic">Not scheduled</span>
}
</span>
</div>
</div>
</div>
</div>
<!-- Status Section -->
<div class="px-6 pb-6">
<div class="bg-surface0 border border-surface1 rounded-lg p-6">
<h2 class="text-2xl font-bold text-text mb-4">Status</h2>
<div class="flex items-center gap-3">
@SeasonStatus(season)
</div>
</div>
</div>
</div>
}
templ SeasonStatus(season *db.Season) {
{{
now := time.Now()
status := ""
statusColor := ""
statusBg := ""
if now.Before(season.StartDate) {
status = "Upcoming"
statusColor = "text-blue"
statusBg = "bg-blue/10 border-blue"
} else if !season.EndDate.IsZero() && now.After(season.EndDate.Time) {
status = "Completed"
statusColor = "text-green"
statusBg = "bg-green/10 border-green"
} else if !season.FinalsStartDate.IsZero() && now.After(season.FinalsStartDate.Time) {
if !season.FinalsEndDate.IsZero() && now.After(season.FinalsEndDate.Time) {
status = "Completed"
statusColor = "text-green"
statusBg = "bg-green/10 border-green"
} else {
status = "Finals in Progress"
statusColor = "text-yellow"
statusBg = "bg-yellow/10 border-yellow"
}
} else {
status = "In Progress"
statusColor = "text-green"
statusBg = "bg-green/10 border-green"
}
}}
<div class={ "px-4 py-2 rounded-lg border-2 inline-flex items-center gap-2 " + statusBg }>
<span class={ "text-2xl " + statusColor }>●</span>
<span class={ "text-lg font-semibold " + statusColor }>{ status }</span>
</div>
}
func formatDate(t time.Time) string {
return t.Format("January 2, 2006")
}
func formatDuration(start, end time.Time) string {
days := int(end.Sub(start).Hours() / 24)
if days == 0 {
return "Same day"
} else if days == 1 {
return "1 day"
} else if days < 7 {
return strconv.Itoa(days) + " days"
} else if days < 30 {
weeks := days / 7
if weeks == 1 {
return "1 week"
}
return strconv.Itoa(weeks) + " weeks"
} else if days < 365 {
months := days / 30
if months == 1 {
return "1 month"
}
return strconv.Itoa(months) + " months"
} else {
years := days / 365
if years == 1 {
return "1 year"
}
return strconv.Itoa(years) + " years"
}
}

View File

@@ -55,8 +55,15 @@ templ SeasonsList(seasons *db.SeasonList) {
seasons.PageOpts.OrderBy).CallInline } seasons.PageOpts.OrderBy).CallInline }
> >
<!-- Header with title and sort controls --> <!-- Header with title and sort controls -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6"> <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6 px-4">
<h1 class="text-3xl font-bold">Seasons</h1> <div class="flex gap-4 items-center">
<span class="text-3xl font-bold">Seasons</span>
<a
href="/seasons/new"
class="rounded-lg px-2 py-1 hover:cursor-pointer text-center text-sm
bg-green hover:bg-green/75 text-mantle transition"
>Add season</a>
</div>
@sort.Dropdown(seasons.PageOpts, sortOpts) @sort.Dropdown(seasons.PageOpts, sortOpts)
</div> </div>
<!-- Results section --> <!-- Results section -->

View File

@@ -9,6 +9,7 @@
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace; monospace;
--spacing: 0.25rem; --spacing: 0.25rem;
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem; --breakpoint-xl: 80rem;
--breakpoint-2xl: 96rem; --breakpoint-2xl: 96rem;
--container-sm: 24rem; --container-sm: 24rem;
@@ -33,6 +34,7 @@
--text-6xl--line-height: 1; --text-6xl--line-height: 1;
--text-9xl: 8rem; --text-9xl: 8rem;
--text-9xl--line-height: 1; --text-9xl--line-height: 1;
--font-weight-medium: 500;
--font-weight-semibold: 600; --font-weight-semibold: 600;
--font-weight-bold: 700; --font-weight-bold: 700;
--tracking-tight: -0.025em; --tracking-tight: -0.025em;
@@ -241,6 +243,9 @@
.top-0 { .top-0 {
top: calc(var(--spacing) * 0); top: calc(var(--spacing) * 0);
} }
.top-1\/2 {
top: calc(1/2 * 100%);
}
.top-4 { .top-4 {
top: calc(var(--spacing) * 4); top: calc(var(--spacing) * 4);
} }
@@ -250,6 +255,9 @@
.right-0 { .right-0 {
right: calc(var(--spacing) * 0); right: calc(var(--spacing) * 0);
} }
.right-3 {
right: calc(var(--spacing) * 3);
}
.right-5 { .right-5 {
right: calc(var(--spacing) * 5); right: calc(var(--spacing) * 5);
} }
@@ -310,6 +318,9 @@
.mb-2 { .mb-2 {
margin-bottom: calc(var(--spacing) * 2); margin-bottom: calc(var(--spacing) * 2);
} }
.mb-4 {
margin-bottom: calc(var(--spacing) * 4);
}
.mb-6 { .mb-6 {
margin-bottom: calc(var(--spacing) * 6); margin-bottom: calc(var(--spacing) * 6);
} }
@@ -343,6 +354,9 @@
.inline-flex { .inline-flex {
display: inline-flex; display: inline-flex;
} }
.table {
display: table;
}
.size-5 { .size-5 {
width: calc(var(--spacing) * 5); width: calc(var(--spacing) * 5);
height: calc(var(--spacing) * 5); height: calc(var(--spacing) * 5);
@@ -350,6 +364,9 @@
.h-1 { .h-1 {
height: calc(var(--spacing) * 1); height: calc(var(--spacing) * 1);
} }
.h-5 {
height: calc(var(--spacing) * 5);
}
.h-16 { .h-16 {
height: calc(var(--spacing) * 16); height: calc(var(--spacing) * 16);
} }
@@ -368,12 +385,18 @@
.min-h-\[calc\(100vh-200px\)\] { .min-h-\[calc\(100vh-200px\)\] {
min-height: calc(100vh - 200px); min-height: calc(100vh - 200px);
} }
.w-5 {
width: calc(var(--spacing) * 5);
}
.w-26 { .w-26 {
width: calc(var(--spacing) * 26); width: calc(var(--spacing) * 26);
} }
.w-36 { .w-36 {
width: calc(var(--spacing) * 36); width: calc(var(--spacing) * 36);
} }
.w-80 {
width: calc(var(--spacing) * 80);
}
.w-fit { .w-fit {
width: fit-content; width: fit-content;
} }
@@ -401,6 +424,9 @@
.max-w-screen-2xl { .max-w-screen-2xl {
max-width: var(--breakpoint-2xl); max-width: var(--breakpoint-2xl);
} }
.max-w-screen-lg {
max-width: var(--breakpoint-lg);
}
.max-w-screen-xl { .max-w-screen-xl {
max-width: var(--breakpoint-xl); max-width: var(--breakpoint-xl);
} }
@@ -413,9 +439,16 @@
.flex-1 { .flex-1 {
flex: 1; flex: 1;
} }
.flex-shrink-0 {
flex-shrink: 0;
}
.shrink-0 { .shrink-0 {
flex-shrink: 0; flex-shrink: 0;
} }
.-translate-y-1\/2 {
--tw-translate-y: calc(calc(1/2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.transform { .transform {
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
} }
@@ -431,6 +464,9 @@
.grid-cols-1 { .grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr)); grid-template-columns: repeat(1, minmax(0, 1fr));
} }
.grid-cols-7 {
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.flex-col { .flex-col {
flex-direction: column; flex-direction: column;
} }
@@ -455,6 +491,9 @@
.justify-end { .justify-end {
justify-content: flex-end; justify-content: flex-end;
} }
.gap-1 {
gap: calc(var(--spacing) * 1);
}
.gap-2 { .gap-2 {
gap: calc(var(--spacing) * 2); gap: calc(var(--spacing) * 2);
} }
@@ -477,12 +516,22 @@
margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));
} }
} }
.space-y-3 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)));
}
}
.gap-x-2 { .gap-x-2 {
column-gap: calc(var(--spacing) * 2); column-gap: calc(var(--spacing) * 2);
} }
.gap-y-4 { .gap-y-4 {
row-gap: calc(var(--spacing) * 4); row-gap: calc(var(--spacing) * 4);
} }
.gap-y-5 {
row-gap: calc(var(--spacing) * 5);
}
.divide-y { .divide-y {
:where(& > :not(:last-child)) { :where(& > :not(:last-child)) {
--tw-divide-y-reverse: 0; --tw-divide-y-reverse: 0;
@@ -529,6 +578,10 @@
border-style: var(--tw-border-style); border-style: var(--tw-border-style);
border-width: 2px; border-width: 2px;
} }
.border-b {
border-bottom-style: var(--tw-border-style);
border-bottom-width: 1px;
}
.border-blue { .border-blue {
border-color: var(--blue); border-color: var(--blue);
} }
@@ -556,6 +609,12 @@
.bg-blue { .bg-blue {
background-color: var(--blue); background-color: var(--blue);
} }
.bg-blue\/10 {
background-color: var(--blue);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--blue) 10%, transparent);
}
}
.bg-crust { .bg-crust {
background-color: var(--crust); background-color: var(--crust);
} }
@@ -577,6 +636,12 @@
.bg-green { .bg-green {
background-color: var(--green); background-color: var(--green);
} }
.bg-green\/10 {
background-color: var(--green);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--green) 10%, transparent);
}
}
.bg-mantle { .bg-mantle {
background-color: var(--mantle); background-color: var(--mantle);
} }
@@ -589,12 +654,24 @@
.bg-surface0 { .bg-surface0 {
background-color: var(--surface0); background-color: var(--surface0);
} }
.bg-surface1 {
background-color: var(--surface1);
}
.bg-teal { .bg-teal {
background-color: var(--teal); background-color: var(--teal);
} }
.bg-yellow { .bg-yellow {
background-color: var(--yellow); background-color: var(--yellow);
} }
.bg-yellow\/10 {
background-color: var(--yellow);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--yellow) 10%, transparent);
}
}
.p-1 {
padding: calc(var(--spacing) * 1);
}
.p-2 { .p-2 {
padding: calc(var(--spacing) * 2); padding: calc(var(--spacing) * 2);
} }
@@ -640,6 +717,9 @@
.py-8 { .py-8 {
padding-block: calc(var(--spacing) * 8); padding-block: calc(var(--spacing) * 8);
} }
.pr-10 {
padding-right: calc(var(--spacing) * 10);
}
.pb-6 { .pb-6 {
padding-bottom: calc(var(--spacing) * 6); padding-bottom: calc(var(--spacing) * 6);
} }
@@ -695,6 +775,10 @@
--tw-font-weight: var(--font-weight-bold); --tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
} }
.font-medium {
--tw-font-weight: var(--font-weight-medium);
font-weight: var(--font-weight-medium);
}
.font-semibold { .font-semibold {
--tw-font-weight: var(--font-weight-semibold); --tw-font-weight: var(--font-weight-semibold);
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
@@ -712,6 +796,9 @@
.whitespace-pre-wrap { .whitespace-pre-wrap {
white-space: pre-wrap; white-space: pre-wrap;
} }
.text-base {
color: var(--base);
}
.text-blue { .text-blue {
color: var(--blue); color: var(--blue);
} }
@@ -742,6 +829,12 @@
.text-yellow { .text-yellow {
color: var(--yellow); color: var(--yellow);
} }
.uppercase {
text-transform: uppercase;
}
.italic {
font-style: italic;
}
.opacity-50 { .opacity-50 {
opacity: 50%; opacity: 50%;
} }
@@ -789,6 +882,16 @@
} }
} }
} }
.hover\:bg-blue\/75 {
&:hover {
@media (hover: hover) {
background-color: var(--blue);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--blue) 75%, transparent);
}
}
}
}
.hover\:bg-crust { .hover\:bg-crust {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -843,6 +946,13 @@
} }
} }
} }
.hover\:bg-surface1 {
&:hover {
@media (hover: hover) {
background-color: var(--surface1);
}
}
}
.hover\:bg-surface2 { .hover\:bg-surface2 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -933,6 +1043,19 @@
cursor: default; cursor: default;
} }
} }
.disabled\:cursor-not-allowed {
&:disabled {
cursor: not-allowed;
}
}
.disabled\:bg-blue\/40 {
&:disabled {
background-color: var(--blue);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--blue) 40%, transparent);
}
}
}
.disabled\:bg-green\/60 { .disabled\:bg-green\/60 {
&:disabled { &:disabled {
background-color: var(--green); background-color: var(--green);
@@ -996,6 +1119,11 @@
padding: calc(var(--spacing) * 7); padding: calc(var(--spacing) * 7);
} }
} }
.sm\:p-8 {
@media (width >= 40rem) {
padding: calc(var(--spacing) * 8);
}
}
.sm\:px-6 { .sm\:px-6 {
@media (width >= 40rem) { @media (width >= 40rem) {
padding-inline: calc(var(--spacing) * 6); padding-inline: calc(var(--spacing) * 6);
@@ -1176,6 +1304,21 @@
font-weight: 700; font-weight: 700;
font-style: italic; font-style: italic;
} }
@property --tw-translate-x {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-translate-y {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-translate-z {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-rotate-x { @property --tw-rotate-x {
syntax: "*"; syntax: "*";
inherits: false; inherits: false;
@@ -1291,6 +1434,9 @@
@layer properties { @layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop { *, ::before, ::after, ::backdrop {
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-translate-z: 0;
--tw-rotate-x: initial; --tw-rotate-x: initial;
--tw-rotate-y: initial; --tw-rotate-y: initial;
--tw-rotate-z: initial; --tw-rotate-z: initial;