diff --git a/cmd/oslstats/db.go b/cmd/oslstats/db.go
index 96e075a..f952cf0 100644
--- a/cmd/oslstats/db.go
+++ b/cmd/oslstats/db.go
@@ -32,9 +32,11 @@ func registerDBModels(conn *bun.DB) []any {
models := []any{
(*db.RolePermission)(nil),
(*db.UserRole)(nil),
+ (*db.SeasonLeague)(nil),
(*db.User)(nil),
(*db.DiscordToken)(nil),
(*db.Season)(nil),
+ (*db.League)(nil),
(*db.Role)(nil),
(*db.Permission)(nil),
(*db.AuditLog)(nil),
diff --git a/cmd/oslstats/migrations/20260210182212_add_leagues.go b/cmd/oslstats/migrations/20260210182212_add_leagues.go
new file mode 100644
index 0000000..ec21071
--- /dev/null
+++ b/cmd/oslstats/migrations/20260210182212_add_leagues.go
@@ -0,0 +1,67 @@
+package migrations
+
+import (
+ "context"
+
+ "github.com/uptrace/bun"
+
+ "git.haelnorr.com/h/oslstats/internal/db"
+)
+
+func init() {
+ Migrations.MustRegister(
+ // UP migration
+ func(ctx context.Context, dbConn *bun.DB) error {
+ // Add slap_version column to seasons table
+ _, err := dbConn.NewAddColumn().
+ Model((*db.Season)(nil)).
+ ColumnExpr("slap_version VARCHAR NOT NULL DEFAULT 'rebound'").
+ IfNotExists().
+ Exec(ctx)
+ if err != nil {
+ return err
+ }
+
+ // Create leagues table
+ _, err = dbConn.NewCreateTable().
+ Model((*db.League)(nil)).
+ Exec(ctx)
+ if err != nil {
+ return err
+ }
+
+ // Create season_leagues join table
+ _, err = dbConn.NewCreateTable().
+ Model((*db.SeasonLeague)(nil)).
+ Exec(ctx)
+ return err
+ },
+ // DOWN migration
+ func(ctx context.Context, dbConn *bun.DB) error {
+ // Drop season_leagues join table first
+ _, err := dbConn.NewDropTable().
+ Model((*db.SeasonLeague)(nil)).
+ IfExists().
+ Exec(ctx)
+ if err != nil {
+ return err
+ }
+
+ // Drop leagues table
+ _, err = dbConn.NewDropTable().
+ Model((*db.League)(nil)).
+ IfExists().
+ Exec(ctx)
+ if err != nil {
+ return err
+ }
+
+ // Remove slap_version column from seasons table
+ _, err = dbConn.NewDropColumn().
+ Model((*db.Season)(nil)).
+ ColumnExpr("slap_version").
+ Exec(ctx)
+ return err
+ },
+ )
+}
diff --git a/cmd/oslstats/routes.go b/cmd/oslstats/routes.go
index 354a882..c9dbbdb 100644
--- a/cmd/oslstats/routes.go
+++ b/cmd/oslstats/routes.go
@@ -106,6 +106,31 @@ func addRoutes(
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.SeasonsUpdate)(handlers.SeasonEditSubmit(s, conn, audit)),
},
+ {
+ Path: "/seasons/{season_short_name}/leagues/{league_short_name}",
+ Method: hws.MethodPOST,
+ Handler: perms.RequirePermission(s, permissions.SeasonsAddLeague)(handlers.SeasonAddLeague(s, conn, audit)),
+ },
+ {
+ Path: "/seasons/{season_short_name}/leagues/{league_short_name}",
+ Method: hws.MethodDELETE,
+ Handler: perms.RequirePermission(s, permissions.SeasonsRemoveLeague)(handlers.SeasonRemoveLeague(s, conn, audit)),
+ },
+ {
+ Path: "/leagues",
+ Method: hws.MethodGET,
+ Handler: handlers.LeaguesList(s, conn),
+ },
+ {
+ Path: "/leagues/new",
+ Method: hws.MethodGET,
+ Handler: perms.RequirePermission(s, permissions.LeaguesCreate)(handlers.NewLeague(s, conn)),
+ },
+ {
+ Path: "/leagues/new",
+ Method: hws.MethodPOST,
+ Handler: perms.RequirePermission(s, permissions.LeaguesCreate)(handlers.NewLeagueSubmit(s, conn, audit)),
+ },
}
htmxRoutes := []hws.Route{
@@ -124,6 +149,16 @@ func addRoutes(
Method: hws.MethodPOST,
Handler: handlers.IsUnique(s, conn, (*db.Season)(nil), "short_name"),
},
+ {
+ Path: "/htmx/isleaguenameunique",
+ Method: hws.MethodPOST,
+ Handler: handlers.IsUnique(s, conn, (*db.League)(nil), "name"),
+ },
+ {
+ Path: "/htmx/isleagueshortnameunique",
+ Method: hws.MethodPOST,
+ Handler: handlers.IsUnique(s, conn, (*db.League)(nil), "short_name"),
+ },
}
wsRoutes := []hws.Route{
diff --git a/internal/db/league.go b/internal/db/league.go
new file mode 100644
index 0000000..9737354
--- /dev/null
+++ b/internal/db/league.go
@@ -0,0 +1,37 @@
+package db
+
+import (
+ "context"
+
+ "github.com/pkg/errors"
+ "github.com/uptrace/bun"
+)
+
+type League struct {
+ bun.BaseModel `bun:"table:leagues,alias:l"`
+
+ ID int `bun:"id,pk,autoincrement"`
+ Name string `bun:"name,unique,notnull"`
+ ShortName string `bun:"short_name,unique,notnull"`
+ Description string `bun:"description"`
+
+ Seasons []Season `bun:"m2m:season_leagues,join:League=Season"`
+}
+
+type SeasonLeague struct {
+ SeasonID int `bun:",pk"`
+ Season *Season `bun:"rel:belongs-to,join:season_id=id"`
+ LeagueID int `bun:",pk"`
+ League *League `bun:"rel:belongs-to,join:league_id=id"`
+}
+
+func GetLeagues(ctx context.Context, tx bun.Tx) ([]*League, error) {
+ return GetList[League](tx).Relation("Seasons").GetAll(ctx)
+}
+
+func GetLeague(ctx context.Context, tx bun.Tx, shortname string) (*League, error) {
+ if shortname == "" {
+ return nil, errors.New("shortname cannot be empty")
+ }
+ return GetByField[League](tx, "short_name", shortname).Relation("Seasons").Get(ctx)
+}
diff --git a/internal/db/season.go b/internal/db/season.go
index ae3d6e9..9f8e364 100644
--- a/internal/db/season.go
+++ b/internal/db/season.go
@@ -19,14 +19,18 @@ type Season struct {
EndDate bun.NullTime `bun:"end_date"`
FinalsStartDate bun.NullTime `bun:"finals_start_date"`
FinalsEndDate bun.NullTime `bun:"finals_end_date"`
+ SlapVersion string `bun:"slap_version,notnull,default:'rebound'"`
+
+ Leagues []League `bun:"m2m:season_leagues,join:Season=League"`
}
// NewSeason returns a new season. It does not add it to the database
-func NewSeason(name, shortname string, start time.Time) *Season {
+func NewSeason(name, version, shortname string, start time.Time) *Season {
season := &Season{
- Name: name,
- ShortName: strings.ToUpper(shortname),
- StartDate: start.Truncate(time.Hour * 24),
+ Name: name,
+ ShortName: strings.ToUpper(shortname),
+ StartDate: start.Truncate(time.Hour * 24),
+ SlapVersion: version,
}
return season
}
@@ -38,18 +42,19 @@ func ListSeasons(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*List[Seas
bun.OrderDesc,
"start_date",
}
- return GetList[Season](tx).GetPaged(ctx, pageOpts, defaults)
+ return GetList[Season](tx).Relation("Leagues").GetPaged(ctx, pageOpts, defaults)
}
func GetSeason(ctx context.Context, tx bun.Tx, shortname string) (*Season, error) {
if shortname == "" {
return nil, errors.New("short_name not provided")
}
- return GetByField[Season](tx, "short_name", shortname).Get(ctx)
+ return GetByField[Season](tx, "short_name", shortname).Relation("Leagues").Get(ctx)
}
// Update updates the season struct. It does not insert to the database
-func (s *Season) Update(start, end, finalsStart, finalsEnd time.Time) {
+func (s *Season) Update(version string, start, end, finalsStart, finalsEnd time.Time) {
+ s.SlapVersion = version
s.StartDate = start.Truncate(time.Hour * 24)
if !end.IsZero() {
s.EndDate.Time = end.Truncate(time.Hour * 24)
diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css
index f086ec1..fd20c43 100644
--- a/internal/embedfs/web/css/output.css
+++ b/internal/embedfs/web/css/output.css
@@ -12,9 +12,12 @@
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
--breakpoint-2xl: 96rem;
+ --container-xs: 20rem;
--container-sm: 24rem;
--container-md: 28rem;
+ --container-lg: 32rem;
--container-2xl: 42rem;
+ --container-5xl: 64rem;
--container-7xl: 80rem;
--text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.75);
@@ -41,10 +44,42 @@
--leading-relaxed: 1.625;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
+ --ease-in: cubic-bezier(0.4, 0, 1, 1);
+ --ease-out: cubic-bezier(0, 0, 0.2, 1);
--default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
--default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono);
+ --color-rosewater: var(--rosewater);
+ --color-flamingo: var(--flamingo);
+ --color-pink: var(--pink);
+ --color-mauve: var(--mauve);
+ --color-red: var(--red);
+ --color-dark-red: var(--dark-red);
+ --color-maroon: var(--maroon);
+ --color-peach: var(--peach);
+ --color-yellow: var(--yellow);
+ --color-dark-yellow: var(--dark-yellow);
+ --color-green: var(--green);
+ --color-dark-green: var(--dark-green);
+ --color-teal: var(--teal);
+ --color-sky: var(--sky);
+ --color-sapphire: var(--sapphire);
+ --color-blue: var(--blue);
+ --color-dark-blue: var(--dark-blue);
+ --color-lavender: var(--lavender);
+ --color-text: var(--text);
+ --color-subtext1: var(--subtext1);
+ --color-subtext0: var(--subtext0);
+ --color-overlay2: var(--overlay2);
+ --color-overlay1: var(--overlay1);
+ --color-overlay0: var(--overlay0);
+ --color-surface2: var(--surface2);
+ --color-surface1: var(--surface1);
+ --color-surface0: var(--surface0);
+ --color-base: var(--base);
+ --color-mantle: var(--mantle);
+ --color-crust: var(--crust);
}
}
@layer base {
@@ -243,6 +278,9 @@
.top-0 {
top: calc(var(--spacing) * 0);
}
+ .top-1 {
+ top: calc(var(--spacing) * 1);
+ }
.top-1\/2 {
top: calc(1/2 * 100%);
}
@@ -267,6 +305,12 @@
.left-0 {
left: calc(var(--spacing) * 0);
}
+ .z-4 {
+ z-index: 4;
+ }
+ .z-7 {
+ z-index: 7;
+ }
.z-10 {
z-index: 10;
}
@@ -276,6 +320,24 @@
.z-50 {
z-index: 50;
}
+ .container {
+ width: 100%;
+ @media (width >= 40rem) {
+ max-width: 40rem;
+ }
+ @media (width >= 48rem) {
+ max-width: 48rem;
+ }
+ @media (width >= 64rem) {
+ max-width: 64rem;
+ }
+ @media (width >= 80rem) {
+ max-width: 80rem;
+ }
+ @media (width >= 96rem) {
+ max-width: 96rem;
+ }
+ }
.mx-auto {
margin-inline: auto;
}
@@ -291,6 +353,9 @@
.mt-2 {
margin-top: calc(var(--spacing) * 2);
}
+ .mt-3 {
+ margin-top: calc(var(--spacing) * 3);
+ }
.mt-4 {
margin-top: calc(var(--spacing) * 4);
}
@@ -318,6 +383,12 @@
.mt-25 {
margin-top: calc(var(--spacing) * 25);
}
+ .mt-auto {
+ margin-top: auto;
+ }
+ .mb-1 {
+ margin-bottom: calc(var(--spacing) * 1);
+ }
.mb-2 {
margin-bottom: calc(var(--spacing) * 2);
}
@@ -336,9 +407,18 @@
.mb-auto {
margin-bottom: auto;
}
+ .ml-1 {
+ margin-left: calc(var(--spacing) * 1);
+ }
.ml-2 {
margin-left: calc(var(--spacing) * 2);
}
+ .line-clamp-2 {
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ }
.block {
display: block;
}
@@ -370,9 +450,21 @@
.h-1 {
height: calc(var(--spacing) * 1);
}
+ .h-2 {
+ height: calc(var(--spacing) * 2);
+ }
+ .h-4 {
+ height: calc(var(--spacing) * 4);
+ }
.h-5 {
height: calc(var(--spacing) * 5);
}
+ .h-6 {
+ height: calc(var(--spacing) * 6);
+ }
+ .h-12 {
+ height: calc(var(--spacing) * 12);
+ }
.h-16 {
height: calc(var(--spacing) * 16);
}
@@ -391,9 +483,21 @@
.min-h-\[calc\(100vh-200px\)\] {
min-height: calc(100vh - 200px);
}
+ .min-h-full {
+ min-height: 100%;
+ }
+ .w-4 {
+ width: calc(var(--spacing) * 4);
+ }
.w-5 {
width: calc(var(--spacing) * 5);
}
+ .w-6 {
+ width: calc(var(--spacing) * 6);
+ }
+ .w-12 {
+ width: calc(var(--spacing) * 12);
+ }
.w-26 {
width: calc(var(--spacing) * 26);
}
@@ -412,6 +516,9 @@
.max-w-2xl {
max-width: var(--container-2xl);
}
+ .max-w-5xl {
+ max-width: var(--container-5xl);
+ }
.max-w-7xl {
max-width: var(--container-7xl);
}
@@ -439,19 +546,49 @@
.max-w-sm {
max-width: var(--container-sm);
}
+ .max-w-xs {
+ max-width: var(--container-xs);
+ }
.min-w-0 {
min-width: calc(var(--spacing) * 0);
}
.flex-1 {
flex: 1;
}
+ .flex-shrink {
+ flex-shrink: 1;
+ }
+ .flex-shrink-0 {
+ flex-shrink: 0;
+ }
.shrink-0 {
flex-shrink: 0;
}
+ .flex-grow {
+ flex-grow: 1;
+ }
+ .grow {
+ flex-grow: 1;
+ }
+ .border-collapse {
+ border-collapse: collapse;
+ }
+ .-translate-y-1 {
+ --tw-translate-y: calc(var(--spacing) * -1);
+ translate: var(--tw-translate-x) var(--tw-translate-y);
+ }
.-translate-y-1\/2 {
--tw-translate-y: calc(calc(1/2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
+ .translate-y-0 {
+ --tw-translate-y: calc(var(--spacing) * 0);
+ translate: var(--tw-translate-x) var(--tw-translate-y);
+ }
+ .translate-y-4 {
+ --tw-translate-y: calc(var(--spacing) * 4);
+ translate: var(--tw-translate-x) var(--tw-translate-y);
+ }
.transform {
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
}
@@ -464,6 +601,12 @@
.resize {
resize: both;
}
+ .resize-none {
+ resize: none;
+ }
+ .appearance-none {
+ appearance: none;
+ }
.grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
@@ -556,6 +699,11 @@
border-color: var(--surface2);
}
}
+ .truncate {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
.overflow-hidden {
overflow: hidden;
}
@@ -620,6 +768,12 @@
.bg-base {
background-color: var(--base);
}
+ .bg-base\/75 {
+ background-color: var(--base);
+ @supports (color: color-mix(in lab, red, red)) {
+ background-color: color-mix(in oklab, var(--base) 75%, transparent);
+ }
+ }
.bg-blue {
background-color: var(--blue);
}
@@ -662,6 +816,12 @@
.bg-mauve {
background-color: var(--mauve);
}
+ .bg-peach {
+ background-color: var(--peach);
+ }
+ .bg-red {
+ background-color: var(--red);
+ }
.bg-red\/10 {
background-color: var(--red);
@supports (color: color-mix(in lab, red, red)) {
@@ -689,6 +849,9 @@
background-color: color-mix(in oklab, var(--yellow) 10%, transparent);
}
}
+ .bg-no-repeat {
+ background-repeat: no-repeat;
+ }
.p-1 {
padding: calc(var(--spacing) * 1);
}
@@ -722,6 +885,12 @@
.px-6 {
padding-inline: calc(var(--spacing) * 6);
}
+ .py-0 {
+ padding-block: calc(var(--spacing) * 0);
+ }
+ .py-0\.5 {
+ padding-block: calc(var(--spacing) * 0.5);
+ }
.py-1 {
padding-block: calc(var(--spacing) * 1);
}
@@ -737,12 +906,27 @@
.py-8 {
padding-block: calc(var(--spacing) * 8);
}
+ .pt-5 {
+ padding-top: calc(var(--spacing) * 5);
+ }
+ .pr-2 {
+ padding-right: calc(var(--spacing) * 2);
+ }
+ .pr-8 {
+ padding-right: calc(var(--spacing) * 8);
+ }
.pr-10 {
padding-right: calc(var(--spacing) * 10);
}
+ .pb-4 {
+ padding-bottom: calc(var(--spacing) * 4);
+ }
.pb-6 {
padding-bottom: calc(var(--spacing) * 6);
}
+ .pl-3 {
+ padding-left: calc(var(--spacing) * 3);
+ }
.text-center {
text-align: center;
}
@@ -787,6 +971,10 @@
font-size: var(--text-xs);
line-height: var(--tw-leading, var(--text-xs--line-height));
}
+ .leading-6 {
+ --tw-leading: calc(var(--spacing) * 6);
+ line-height: calc(var(--spacing) * 6);
+ }
.leading-relaxed {
--tw-leading: var(--leading-relaxed);
line-height: var(--leading-relaxed);
@@ -858,9 +1046,18 @@
.italic {
font-style: italic;
}
+ .underline {
+ text-decoration-line: underline;
+ }
+ .opacity-0 {
+ opacity: 0%;
+ }
.opacity-50 {
opacity: 50%;
}
+ .opacity-100 {
+ opacity: 100%;
+ }
.shadow-lg {
--tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
@@ -873,6 +1070,10 @@
--tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
+ .outline {
+ outline-style: var(--tw-outline-style);
+ outline-width: 1px;
+ }
.filter {
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
}
@@ -881,11 +1082,37 @@
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
+ .transition-all {
+ transition-property: all;
+ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
+ transition-duration: var(--tw-duration, var(--default-transition-duration));
+ }
.transition-colors {
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
+ .transition-opacity {
+ transition-property: opacity;
+ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
+ transition-duration: var(--tw-duration, var(--default-transition-duration));
+ }
+ .duration-200 {
+ --tw-duration: 200ms;
+ transition-duration: 200ms;
+ }
+ .duration-300 {
+ --tw-duration: 300ms;
+ transition-duration: 300ms;
+ }
+ .ease-in {
+ --tw-ease: var(--ease-in);
+ transition-timing-function: var(--ease-in);
+ }
+ .ease-out {
+ --tw-ease: var(--ease-out);
+ transition-timing-function: var(--ease-out);
+ }
.outline-none {
--tw-outline-style: none;
outline-style: none;
@@ -955,6 +1182,16 @@
}
}
}
+ .hover\:bg-red\/75 {
+ &:hover {
+ @media (hover: hover) {
+ background-color: var(--red);
+ @supports (color: color-mix(in lab, red, red)) {
+ background-color: color-mix(in oklab, var(--red) 75%, transparent);
+ }
+ }
+ }
+ }
.hover\:bg-sapphire\/75 {
&:hover {
@media (hover: hover) {
@@ -1013,6 +1250,16 @@
}
}
}
+ .hover\:text-red\/75 {
+ &:hover {
+ @media (hover: hover) {
+ color: var(--red);
+ @supports (color: color-mix(in lab, red, red)) {
+ color: color-mix(in oklab, var(--red) 75%, transparent);
+ }
+ }
+ }
+ }
.hover\:text-subtext1 {
&:hover {
@media (hover: hover) {
@@ -1100,6 +1347,21 @@
inset-inline-end: calc(var(--spacing) * 6);
}
}
+ .sm\:mx-0 {
+ @media (width >= 40rem) {
+ margin-inline: calc(var(--spacing) * 0);
+ }
+ }
+ .sm\:mt-0 {
+ @media (width >= 40rem) {
+ margin-top: calc(var(--spacing) * 0);
+ }
+ }
+ .sm\:ml-4 {
+ @media (width >= 40rem) {
+ margin-left: calc(var(--spacing) * 4);
+ }
+ }
.sm\:block {
@media (width >= 40rem) {
display: block;
@@ -1120,16 +1382,78 @@
display: inline;
}
}
+ .sm\:h-10 {
+ @media (width >= 40rem) {
+ height: calc(var(--spacing) * 10);
+ }
+ }
+ .sm\:w-10 {
+ @media (width >= 40rem) {
+ width: calc(var(--spacing) * 10);
+ }
+ }
+ .sm\:w-auto {
+ @media (width >= 40rem) {
+ width: auto;
+ }
+ }
+ .sm\:w-full {
+ @media (width >= 40rem) {
+ width: 100%;
+ }
+ }
+ .sm\:max-w-lg {
+ @media (width >= 40rem) {
+ max-width: var(--container-lg);
+ }
+ }
+ .sm\:translate-y-0 {
+ @media (width >= 40rem) {
+ --tw-translate-y: calc(var(--spacing) * 0);
+ translate: var(--tw-translate-x) var(--tw-translate-y);
+ }
+ }
+ .sm\:scale-95 {
+ @media (width >= 40rem) {
+ --tw-scale-x: 95%;
+ --tw-scale-y: 95%;
+ --tw-scale-z: 95%;
+ scale: var(--tw-scale-x) var(--tw-scale-y);
+ }
+ }
+ .sm\:scale-100 {
+ @media (width >= 40rem) {
+ --tw-scale-x: 100%;
+ --tw-scale-y: 100%;
+ --tw-scale-z: 100%;
+ scale: var(--tw-scale-x) var(--tw-scale-y);
+ }
+ }
+ .sm\:grid-cols-2 {
+ @media (width >= 40rem) {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ }
.sm\:flex-row {
@media (width >= 40rem) {
flex-direction: row;
}
}
+ .sm\:flex-row-reverse {
+ @media (width >= 40rem) {
+ flex-direction: row-reverse;
+ }
+ }
.sm\:items-center {
@media (width >= 40rem) {
align-items: center;
}
}
+ .sm\:items-start {
+ @media (width >= 40rem) {
+ align-items: flex-start;
+ }
+ }
.sm\:justify-between {
@media (width >= 40rem) {
justify-content: space-between;
@@ -1140,6 +1464,11 @@
gap: calc(var(--spacing) * 2);
}
}
+ .sm\:p-6 {
+ @media (width >= 40rem) {
+ padding: calc(var(--spacing) * 6);
+ }
+ }
.sm\:p-7 {
@media (width >= 40rem) {
padding: calc(var(--spacing) * 7);
@@ -1155,6 +1484,16 @@
padding-inline: calc(var(--spacing) * 6);
}
}
+ .sm\:pb-4 {
+ @media (width >= 40rem) {
+ padding-bottom: calc(var(--spacing) * 4);
+ }
+ }
+ .sm\:text-left {
+ @media (width >= 40rem) {
+ text-align: left;
+ }
+ }
.sm\:text-4xl {
@media (width >= 40rem) {
font-size: var(--text-4xl);
@@ -1166,6 +1505,11 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
+ .md\:grid-cols-3 {
+ @media (width >= 48rem) {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+ }
.md\:gap-8 {
@media (width >= 48rem) {
gap: calc(var(--spacing) * 8);
@@ -1206,6 +1550,11 @@
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
+ .lg\:grid-cols-4 {
+ @media (width >= 64rem) {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+ }
.lg\:items-end {
@media (width >= 64rem) {
align-items: flex-end;
@@ -1457,6 +1806,11 @@
inherits: false;
initial-value: 0 0 #0000;
}
+@property --tw-outline-style {
+ syntax: "*";
+ inherits: false;
+ initial-value: solid;
+}
@property --tw-blur {
syntax: "*";
inherits: false;
@@ -1510,6 +1864,29 @@
syntax: "*";
inherits: false;
}
+@property --tw-duration {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-ease {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-scale-x {
+ syntax: "*";
+ inherits: false;
+ initial-value: 1;
+}
+@property --tw-scale-y {
+ syntax: "*";
+ inherits: false;
+ initial-value: 1;
+}
+@property --tw-scale-z {
+ syntax: "*";
+ inherits: false;
+ initial-value: 1;
+}
@layer properties {
@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 {
@@ -1541,6 +1918,7 @@
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-offset-shadow: 0 0 #0000;
+ --tw-outline-style: solid;
--tw-blur: initial;
--tw-brightness: initial;
--tw-contrast: initial;
@@ -1554,6 +1932,11 @@
--tw-drop-shadow-color: initial;
--tw-drop-shadow-alpha: 100%;
--tw-drop-shadow-size: initial;
+ --tw-duration: initial;
+ --tw-ease: initial;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-scale-z: 1;
}
}
}
diff --git a/internal/handlers/leagues_list.go b/internal/handlers/leagues_list.go
new file mode 100644
index 0000000..489a238
--- /dev/null
+++ b/internal/handlers/leagues_list.go
@@ -0,0 +1,34 @@
+package handlers
+
+import (
+ "context"
+ "net/http"
+
+ "git.haelnorr.com/h/golib/hws"
+ "github.com/pkg/errors"
+ "github.com/uptrace/bun"
+
+ "git.haelnorr.com/h/oslstats/internal/db"
+ "git.haelnorr.com/h/oslstats/internal/view/leaguesview"
+)
+
+func LeaguesList(
+ s *hws.Server,
+ conn *bun.DB,
+) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var leagues []*db.League
+ if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
+ var err error
+ leagues, err = db.GetLeagues(ctx, tx)
+ if err != nil {
+ return false, errors.Wrap(err, "db.GetLeagues")
+ }
+ return true, nil
+ }); !ok {
+ return
+ }
+
+ renderSafely(leaguesview.ListPage(leagues), s, r, w)
+ })
+}
diff --git a/internal/handlers/leagues_new.go b/internal/handlers/leagues_new.go
new file mode 100644
index 0000000..967b515
--- /dev/null
+++ b/internal/handlers/leagues_new.go
@@ -0,0 +1,96 @@
+package handlers
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+
+ "git.haelnorr.com/h/golib/hws"
+ "github.com/pkg/errors"
+ "github.com/uptrace/bun"
+
+ "git.haelnorr.com/h/oslstats/internal/auditlog"
+ "git.haelnorr.com/h/oslstats/internal/db"
+ "git.haelnorr.com/h/oslstats/internal/notify"
+ "git.haelnorr.com/h/oslstats/internal/validation"
+ leaguesview "git.haelnorr.com/h/oslstats/internal/view/leaguesview"
+)
+
+func NewLeague(
+ s *hws.Server,
+ conn *bun.DB,
+) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == "GET" {
+ renderSafely(leaguesview.NewPage(), s, r, w)
+ return
+ }
+ })
+}
+
+func NewLeagueSubmit(
+ s *hws.Server,
+ conn *bun.DB,
+ audit *auditlog.Logger,
+) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ getter, ok := validation.ParseFormOrNotify(s, w, r)
+ if !ok {
+ return
+ }
+ name := getter.String("name").
+ TrimSpace().Required().
+ MaxLength(50).MinLength(3).Value
+ shortname := getter.String("short_name").
+ TrimSpace().Required().
+ MaxLength(10).MinLength(2).Value
+ description := getter.String("description").
+ TrimSpace().MaxLength(500).Value
+ if !getter.ValidateAndNotify(s, w, r) {
+ return
+ }
+
+ nameUnique := false
+ shortNameUnique := false
+ var league *db.League
+ if ok := db.WithNotifyTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
+ var err error
+ nameUnique, err = db.IsUnique(ctx, tx, (*db.League)(nil), "name", name)
+ if err != nil {
+ return false, errors.Wrap(err, "db.IsLeagueNameUnique")
+ }
+ shortNameUnique, err = db.IsUnique(ctx, tx, (*db.League)(nil), "short_name", shortname)
+ if err != nil {
+ return false, errors.Wrap(err, "db.IsLeagueShortNameUnique")
+ }
+ if !nameUnique || !shortNameUnique {
+ return true, nil
+ }
+ league = &db.League{
+ Name: name,
+ ShortName: shortname,
+ Description: description,
+ }
+ err = db.Insert(tx, league).WithAudit(r, audit.Callback()).Exec(ctx)
+ if err != nil {
+ return false, errors.Wrap(err, "db.Insert")
+ }
+ return true, nil
+ }); !ok {
+ return
+ }
+
+ if !nameUnique {
+ notify.Warn(s, w, r, "Duplicate Name", "This league name is already taken.", nil)
+ return
+ }
+
+ if !shortNameUnique {
+ notify.Warn(s, w, r, "Duplicate Short Name", "This short name is already taken.", nil)
+ return
+ }
+ w.Header().Set("HX-Redirect", fmt.Sprintf("/leagues/%s", league.ShortName))
+ w.WriteHeader(http.StatusOK)
+ notify.SuccessWithDelay(s, w, r, "League Created", fmt.Sprintf("Successfully created league: %s", name), nil)
+ })
+}
diff --git a/internal/handlers/notifswebsocket.go b/internal/handlers/notifswebsocket.go
index 34c8978..870a57f 100644
--- a/internal/handlers/notifswebsocket.go
+++ b/internal/handlers/notifswebsocket.go
@@ -93,6 +93,10 @@ func notifyLoop(ctx context.Context, c *hws.Client, ws *websocket.Conn) error {
// Parse error code and stacktrace from Details field
code, stacktrace := parseErrorDetails(nt.Details)
err = popup.ErrorModalWS(code, stacktrace, nt, count).Render(ctx, w)
+ case notify.LevelInfo:
+ err = popup.Toast(nt, count, 6000).Render(ctx, w)
+ case notify.LevelSuccess:
+ err = popup.Toast(nt, count, 3000).Render(ctx, w)
default:
err = popup.Toast(nt, count, 6000).Render(ctx, w)
}
diff --git a/internal/handlers/season_edit.go b/internal/handlers/season_edit.go
index 20be076..38c66ee 100644
--- a/internal/handlers/season_edit.go
+++ b/internal/handlers/season_edit.go
@@ -24,12 +24,17 @@ func SeasonEditPage(
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seasonStr := r.PathValue("season_short_name")
var season *db.Season
+ var allLeagues []*db.League
if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
season, err = db.GetSeason(ctx, tx, seasonStr)
if err != nil {
return false, errors.Wrap(err, "db.GetSeason")
}
+ allLeagues, err = db.GetLeagues(ctx, tx)
+ if err != nil {
+ return false, errors.Wrap(err, "db.GetLeagues")
+ }
return true, nil
}); !ok {
return
@@ -38,7 +43,7 @@ func SeasonEditPage(
throw.NotFound(s, w, r, r.URL.Path)
return
}
- renderSafely(seasonsview.EditPage(season), s, r, w)
+ renderSafely(seasonsview.EditPage(season, allLeagues), s, r, w)
})
}
@@ -60,10 +65,12 @@ func SeasonEditSubmit(
MonthNumeric2().Slash().
Year4().Build()
- startDate := getter.Time("start_date", format).Required().Value
- endDate := getter.Time("end_date", format).Value
- finalsStartDate := getter.Time("finals_start_date", format).Value
- finalsEndDate := getter.Time("finals_end_date", format).Value
+ version := getter.String("slap_version").
+ TrimSpace().Required().AllowedValues([]string{"rebound", "slapshot1"}).Value
+ start := getter.Time("start_date", format).Required().Value
+ end := getter.Time("end_date", format).Value
+ finalsStart := getter.Time("finals_start_date", format).Value
+ finalsEnd := getter.Time("finals_end_date", format).Value
if !getter.ValidateAndNotify(s, w, r) {
return
@@ -79,9 +86,9 @@ func SeasonEditSubmit(
if season == nil {
return false, errors.New("season does not exist")
}
- season.Update(startDate, endDate, finalsStartDate, finalsEndDate)
+ season.Update(version, start, end, finalsStart, finalsEnd)
err = db.Update(tx, season).WherePK().
- Column("start_date", "end_date", "finals_start_date", "finals_end_date").
+ Column("slap_version", "start_date", "end_date", "finals_start_date", "finals_end_date").
WithAudit(r, audit.Callback()).Exec(ctx)
if err != nil {
return false, errors.Wrap(err, "db.Update")
diff --git a/internal/handlers/season_leagues.go b/internal/handlers/season_leagues.go
new file mode 100644
index 0000000..0079e45
--- /dev/null
+++ b/internal/handlers/season_leagues.go
@@ -0,0 +1,134 @@
+package handlers
+
+import (
+ "context"
+ "net/http"
+
+ "git.haelnorr.com/h/golib/hws"
+ "github.com/pkg/errors"
+ "github.com/uptrace/bun"
+
+ "git.haelnorr.com/h/oslstats/internal/auditlog"
+ "git.haelnorr.com/h/oslstats/internal/db"
+ "git.haelnorr.com/h/oslstats/internal/notify"
+ "git.haelnorr.com/h/oslstats/internal/view/seasonsview"
+)
+
+func SeasonAddLeague(
+ s *hws.Server,
+ conn *bun.DB,
+ audit *auditlog.Logger,
+) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ seasonStr := r.PathValue("season_short_name")
+ leagueStr := r.PathValue("league_short_name")
+
+ var season *db.Season
+ var allLeagues []*db.League
+ if ok := db.WithNotifyTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
+ var err error
+ season, err = db.GetSeason(ctx, tx, seasonStr)
+ if err != nil {
+ return false, errors.Wrap(err, "db.GetSeason")
+ }
+ if season == nil {
+ return false, errors.New("season not found")
+ }
+
+ league, err := db.GetLeague(ctx, tx, leagueStr)
+ if err != nil {
+ return false, errors.Wrap(err, "db.GetLeague")
+ }
+ if league == nil {
+ return false, errors.New("league not found")
+ }
+
+ // Create the many-to-many relationship
+ seasonLeague := &db.SeasonLeague{
+ SeasonID: season.ID,
+ LeagueID: league.ID,
+ }
+ err = db.Insert(tx, seasonLeague).WithAudit(r, audit.Callback()).Exec(ctx)
+ if err != nil {
+ return false, errors.Wrap(err, "db.Insert")
+ }
+
+ // Reload season with updated leagues
+ season, err = db.GetSeason(ctx, tx, seasonStr)
+ if err != nil {
+ return false, errors.Wrap(err, "db.GetSeason")
+ }
+
+ allLeagues, err = db.GetLeagues(ctx, tx)
+ if err != nil {
+ return false, errors.Wrap(err, "db.GetLeagues")
+ }
+
+ return true, nil
+ }); !ok {
+ return
+ }
+
+ notify.Success(s, w, r, "League Added", "League successfully added to season", nil)
+ renderSafely(seasonsview.LeaguesSection(season, allLeagues), s, r, w)
+ })
+}
+
+func SeasonRemoveLeague(
+ s *hws.Server,
+ conn *bun.DB,
+ audit *auditlog.Logger,
+) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ seasonStr := r.PathValue("season_short_name")
+ leagueStr := r.PathValue("league_short_name")
+
+ var season *db.Season
+ var allLeagues []*db.League
+ if ok := db.WithNotifyTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
+ var err error
+ season, err = db.GetSeason(ctx, tx, seasonStr)
+ if err != nil {
+ return false, errors.Wrap(err, "db.GetSeason")
+ }
+ if season == nil {
+ return false, errors.New("season not found")
+ }
+
+ league, err := db.GetLeague(ctx, tx, leagueStr)
+ if err != nil {
+ return false, errors.Wrap(err, "db.GetLeague")
+ }
+ if league == nil {
+ return false, errors.New("league not found")
+ }
+
+ // Delete the many-to-many relationship
+ err = db.DeleteItem[db.SeasonLeague](tx).
+ Where("season_id = ? AND league_id = ?", season.ID, league.ID).
+ WithAudit(r, audit.Callback()).
+ Delete(ctx)
+ if err != nil {
+ return false, errors.Wrap(err, "db.DeleteItem")
+ }
+
+ // Reload season with updated leagues
+ season, err = db.GetSeason(ctx, tx, seasonStr)
+ if err != nil {
+ return false, errors.Wrap(err, "db.GetSeason")
+ }
+
+ allLeagues, err = db.GetLeagues(ctx, tx)
+ if err != nil {
+ return false, errors.Wrap(err, "db.GetLeagues")
+ }
+
+ return true, nil
+ }); !ok {
+ return
+ }
+
+ notify.Success(s, w, r, "League Removed", "League successfully removed from season", nil)
+ renderSafely(seasonsview.LeaguesSection(season, allLeagues), s, r, w)
+ })
+}
diff --git a/internal/handlers/seasons_list.go b/internal/handlers/seasons_list.go
index bedb0b4..fbf826d 100644
--- a/internal/handlers/seasons_list.go
+++ b/internal/handlers/seasons_list.go
@@ -11,6 +11,7 @@ import (
"github.com/uptrace/bun"
)
+// SeasonsPage renders the full page with the seasons list, for use with GET requests
func SeasonsPage(
s *hws.Server,
conn *bun.DB,
@@ -35,6 +36,7 @@ func SeasonsPage(
})
}
+// SeasonsList renders just the seasons list, for use with POST requests and HTMX
func SeasonsList(
s *hws.Server,
conn *bun.DB,
diff --git a/internal/handlers/seasons_new.go b/internal/handlers/seasons_new.go
index ed7eadb..e534ef1 100644
--- a/internal/handlers/seasons_new.go
+++ b/internal/handlers/seasons_new.go
@@ -41,14 +41,16 @@ func NewSeasonSubmit(
name := getter.String("name").
TrimSpace().Required().
MaxLength(20).MinLength(5).Value
- shortName := getter.String("short_name").
+ shortname := getter.String("short_name").
TrimSpace().ToUpper().Required().
MaxLength(6).MinLength(2).Value
+ version := getter.String("slap_version").
+ TrimSpace().Required().AllowedValues([]string{"rebound", "slapshot1"}).Value
format := timefmt.NewBuilder().
DayNumeric2().Slash().
MonthNumeric2().Slash().
Year4().Build()
- startDate := getter.Time("start_date", format).Required().Value
+ start := getter.Time("start_date", format).Required().Value
if !getter.ValidateAndNotify(s, w, r) {
return
}
@@ -62,14 +64,14 @@ func NewSeasonSubmit(
if err != nil {
return false, errors.Wrap(err, "db.IsSeasonNameUnique")
}
- shortNameUnique, err = db.IsUnique(ctx, tx, (*db.Season)(nil), "short_name", shortName)
+ shortNameUnique, err = db.IsUnique(ctx, tx, (*db.Season)(nil), "short_name", shortname)
if err != nil {
return false, errors.Wrap(err, "db.IsSeasonShortNameUnique")
}
if !nameUnique || !shortNameUnique {
return true, nil
}
- season = db.NewSeason(name, shortName, startDate)
+ season = db.NewSeason(name, version, shortname, start)
err = db.Insert(tx, season).WithAudit(r, audit.Callback()).Exec(ctx)
if err != nil {
return false, errors.Wrap(err, "db.Insert")
diff --git a/internal/permissions/constants.go b/internal/permissions/constants.go
index 8c474a0..e649c21 100644
--- a/internal/permissions/constants.go
+++ b/internal/permissions/constants.go
@@ -12,9 +12,16 @@ const (
Wildcard Permission = "*"
// Seasons permissions
- SeasonsCreate Permission = "seasons.create"
- SeasonsUpdate Permission = "seasons.update"
- SeasonsDelete Permission = "seasons.delete"
+ SeasonsCreate Permission = "seasons.create"
+ SeasonsUpdate Permission = "seasons.update"
+ SeasonsDelete Permission = "seasons.delete"
+ SeasonsAddLeague Permission = "seasons.add_league"
+ SeasonsRemoveLeague Permission = "seasons.remove_league"
+
+ // Leagues permissions
+ LeaguesCreate Permission = "leagues.create"
+ LeaguesUpdate Permission = "leagues.update"
+ LeaguesDelete Permission = "leagues.delete"
// Users permissions
UsersUpdate Permission = "users.update"
diff --git a/internal/view/baseview/layout.templ b/internal/view/baseview/layout.templ
index 40fdcf9..1901a04 100644
--- a/internal/view/baseview/layout.templ
+++ b/internal/view/baseview/layout.templ
@@ -37,6 +37,7 @@ templ Layout(title string) {
>
@popup.ErrorModalContainer()
@popup.ToastContainer()
+ @popup.ConfirmModal()
10) {
+ value = value.substring(0, 10);
+ }
+
+ this.displayDate = value;
+ event.target.value = value;
+
+ // Validate complete date
+ if (value.length === 10) {
+ const parts = value.split('/');
+ if (parts.length === 3) {
+ const day = parseInt(parts[0]);
+ const month = parseInt(parts[1]);
+ const year = parseInt(parts[2]);
+
+ // Basic validation
+ if (month >= 1 && month <= 12 && day >= 1 && day <= 31 && year >= 2001 && year <= 2051) {
+ this.selectedDate = value;
+ this.lastValidDate = value;
+ this.tempYear = year;
+ this.tempMonth = month - 1;
+ }
+ }
+ }
+ },
+ handleBackspace(event) {
+ const input = event.target;
+ const cursorPos = input.selectionStart;
+ const value = input.value;
+
+ // Check if we're about to delete a slash at position 2 or 5
+ if (cursorPos === 3 && value.charAt(2) === '/') {
+ // Delete both the slash and the character before it
+ event.preventDefault();
+ const newValue = value.substring(0, 1) + value.substring(3);
+ this.displayDate = newValue;
+ input.value = newValue;
+ this.$nextTick(() => {
+ input.setSelectionRange(1, 1);
+ });
+ } else if (cursorPos === 6 && value.charAt(5) === '/') {
+ // Delete both the slash and the character before it
+ event.preventDefault();
+ const newValue = value.substring(0, 4) + value.substring(6);
+ this.displayDate = newValue;
+ input.value = newValue;
+ this.$nextTick(() => {
+ input.setSelectionRange(4, 4);
+ });
+ }
+ // Otherwise, let default backspace behavior work
+ },
+ isValidDate(dateString) {
+ if (!dateString || dateString.length !== 10) return false;
+ const parts = dateString.split('/');
+ if (parts.length !== 3) return false;
+
+ const day = parseInt(parts[0]);
+ const month = parseInt(parts[1]);
+ const year = parseInt(parts[2]);
+
+ // Check ranges
+ if (month < 1 || month > 12) return false;
+ if (year < 2001 || year > 2051) return false;
+ if (day < 1 || day > 31) return false;
+
+ // Check valid day for month
+ const daysInMonth = new Date(year, month, 0).getDate();
+ if (day > daysInMonth) return false;
+
+ return true;
+ },
};
}
-
+
@@ -169,3 +185,15 @@ func formatDuration(start, end time.Time) string {
return strconv.Itoa(years) + " years"
}
}
+
+templ SlapVersionBadge(version string) {
+ if version == "rebound" {
+
+ Rebound
+
+ } else if version == "slapshot1" {
+
+ Slapshot 1
+
+ }
+}
diff --git a/internal/view/seasonsview/edit_form.templ b/internal/view/seasonsview/edit_form.templ
index 218e0ad..240d538 100644
--- a/internal/view/seasonsview/edit_form.templ
+++ b/internal/view/seasonsview/edit_form.templ
@@ -4,7 +4,7 @@ import "git.haelnorr.com/h/oslstats/internal/view/datepicker"
import "git.haelnorr.com/h/oslstats/internal/db"
import "time"
-templ EditForm(season *db.Season) {
+templ EditForm(season *db.Season, allLeagues []*db.League) {
{{
// Format dates for display (DD/MM/YYYY)
startDateStr := formatDateInput(season.StartDate)
@@ -86,9 +86,20 @@ templ EditForm(season *db.Season) {
Edit { season.Name }
-
- { season.ShortName }
-
+
+
+ { season.ShortName }
+
+
+
+
+ @LeaguesSection(season, allLeagues)
- @EditForm(season)
+ @EditForm(season, allLeagues)
}
}
diff --git a/internal/view/seasonsview/leagues_section.templ b/internal/view/seasonsview/leagues_section.templ
new file mode 100644
index 0000000..409e0bc
--- /dev/null
+++ b/internal/view/seasonsview/leagues_section.templ
@@ -0,0 +1,88 @@
+package seasonsview
+
+import "git.haelnorr.com/h/oslstats/internal/db"
+import "git.haelnorr.com/h/oslstats/internal/contexts"
+import "git.haelnorr.com/h/oslstats/internal/permissions"
+
+templ LeaguesSection(season *db.Season, allLeagues []*db.League) {
+ {{
+ permCache := contexts.Permissions(ctx)
+ canAddLeague := permCache.HasPermission(permissions.SeasonsAddLeague)
+ canRemoveLeague := permCache.HasPermission(permissions.SeasonsRemoveLeague)
+
+ // Create a map of assigned league IDs for quick lookup
+ assignedLeagueIDs := make(map[int]bool)
+ for _, league := range season.Leagues {
+ assignedLeagueIDs[league.ID] = true
+ }
+ }}
+ if canAddLeague || canRemoveLeague {
+
+
+
Leagues
+
+ if len(season.Leagues) > 0 {
+
+
Currently Assigned
+
+ for _, league := range season.Leagues {
+
+
{ league.Name }
+
({ league.ShortName })
+ if canRemoveLeague {
+
+ }
+
+ }
+
+
+ }
+
+ if canAddLeague && len(allLeagues) > 0 {
+ {{
+ // Filter out already assigned leagues
+ availableLeagues := []*db.League{}
+ for _, league := range allLeagues {
+ if !assignedLeagueIDs[league.ID] {
+ availableLeagues = append(availableLeagues, league)
+ }
+ }
+ }}
+ if len(availableLeagues) > 0 {
+
+
Add League
+
+ for _, league := range availableLeagues {
+
+ }
+
+
+ }
+ }
+
+
+ }
+}
diff --git a/internal/view/seasonsview/list_page.templ b/internal/view/seasonsview/list_page.templ
index 0de5bde..5623e67 100644
--- a/internal/view/seasonsview/list_page.templ
+++ b/internal/view/seasonsview/list_page.templ
@@ -83,7 +83,7 @@ templ SeasonsList(seasons *db.List[db.Season]) {
-
-
+
+
{ s.ShortName }
-
+ @SlapVersionBadge(s.SlapVersion)
+
+
+ if len(s.Leagues) > 0 {
+
+ for _, league := range s.Leagues {
+
+ { league.ShortName }
+
+ }
+
+ }
+
+ {{
+ now := time.Now()
+ }}
+
+ if now.Before(s.StartDate) {
+ Starts: { formatDate(s.StartDate) }
+ } else if !s.FinalsStartDate.IsZero() {
+ // Finals are scheduled
+ if !s.FinalsEndDate.IsZero() && now.After(s.FinalsEndDate.Time) {
+ Completed: { formatDate(s.FinalsEndDate.Time) }
+ } else if now.After(s.FinalsStartDate.Time) {
+ Finals Started: { formatDate(s.FinalsStartDate.Time) }
+ } else {
+ Finals Start: { formatDate(s.FinalsStartDate.Time) }
+ }
+ } else if !s.EndDate.IsZero() && now.After(s.EndDate.Time) {
+ // No finals scheduled and regular season ended
+ Completed: { formatDate(s.EndDate.Time) }
+ } else {
Started: { formatDate(s.StartDate) }
-
+ }
}
diff --git a/internal/view/seasonsview/new_form.templ b/internal/view/seasonsview/new_form.templ
index cd86807..00e9774 100644
--- a/internal/view/seasonsview/new_form.templ
+++ b/internal/view/seasonsview/new_form.templ
@@ -50,22 +50,27 @@ templ NewForm() {
},
// Check if form can be submitted
updateCanSubmit() {
- this.canSubmit = !this.nameIsEmpty && this.nameIsUnique && !this.nameIsChecking &&
- !this.shortNameIsEmpty && this.shortNameIsUnique && !this.shortNameIsChecking &&
+ 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 = '';
+ 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.';
+ this.buttonText = "Create Season";
+ this.generalError = "Request timed out. Please try again.";
}, 10000);
- }
+ },
};
}
@@ -147,6 +152,20 @@ templ NewForm() {
x-text="shortNameError"
>
+
+
+
+
+
Select the game version for this season
+
@datepicker.DatePicker("start_date", "start_date", "Start Date", "DD/MM/YYYY", true, "dateIsEmpty = $el.value === ''; resetDateErr(); if(dateIsEmpty) { dateError='Start date is required'; } updateCanSubmit();")
diff --git a/internal/view/seasonsview/status_badge.templ b/internal/view/seasonsview/status_badge.templ
index 7878dc8..537e98a 100644
--- a/internal/view/seasonsview/status_badge.templ
+++ b/internal/view/seasonsview/status_badge.templ
@@ -12,60 +12,54 @@ templ StatusBadge(season *db.Season, compact bool, useShortLabels bool) {
{{
now := time.Now()
status := ""
- statusColor := ""
statusBg := ""
// Determine status based on dates
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) {
+ statusBg = "bg-blue"
+ } else if !season.FinalsStartDate.IsZero() {
+ // Finals are scheduled
if !season.FinalsEndDate.IsZero() && now.After(season.FinalsEndDate.Time) {
+ // Finals have ended
status = "Completed"
- statusColor = "text-green"
- statusBg = "bg-green/10 border-green"
- } else {
+ statusBg = "bg-teal"
+ } else if now.After(season.FinalsStartDate.Time) {
+ // Finals are in progress
if useShortLabels {
status = "Finals"
} else {
status = "Finals in Progress"
}
- statusColor = "text-yellow"
- statusBg = "bg-yellow/10 border-yellow"
+ statusBg = "bg-yellow"
+ } else if !season.EndDate.IsZero() && now.After(season.EndDate.Time) {
+ // Regular season ended, finals upcoming
+ status = "Finals Soon"
+ statusBg = "bg-peach"
+ } else {
+ // Regular season active, finals scheduled for later
+ if useShortLabels {
+ status = "Active"
+ } else {
+ status = "In Progress"
+ }
+ statusBg = "bg-green"
}
+ } else if !season.EndDate.IsZero() && now.After(season.EndDate.Time) {
+ // No finals scheduled and regular season ended
+ status = "Completed"
+ statusBg = "bg-teal"
} else {
+ // Regular season active, no finals scheduled
if useShortLabels {
status = "Active"
} else {
status = "In Progress"
}
- statusColor = "text-green"
- statusBg = "bg-green/10 border-green"
- }
-
- // Determine size classes
- var sizeClasses string
- var textSize string
- var iconSize string
- if compact {
- sizeClasses = "px-2 py-1"
- textSize = "text-xs"
- iconSize = "text-sm"
- } else {
- sizeClasses = "px-4 py-2"
- textSize = "text-lg"
- iconSize = "text-2xl"
+ statusBg = "bg-green"
}
}}
-
- if !compact {
- ●
- }
- { status }
-
+
+ { status }
+
}