From 59748b9bb93132c7e349013cecdcd64852c50cfb Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Wed, 18 Feb 2026 20:36:15 +1100 Subject: [PATCH] draft seasons get special treatment :) --- internal/handlers/draft_season_tabs.go | 96 ++++++++++++ internal/handlers/season_detail.go | 18 ++- internal/handlers/season_league_add_team.go | 6 +- internal/handlers/season_league_detail.go | 8 + internal/handlers/season_league_finals.go | 3 + internal/handlers/season_league_fixtures.go | 3 + internal/handlers/season_league_stats.go | 3 + internal/handlers/season_league_table.go | 3 + internal/handlers/season_league_teams.go | 3 + internal/handlers/season_leagues.go | 20 ++- internal/handlers/teams_new.go | 2 +- internal/rbac/preview_middleware.go | 8 +- internal/server/routes.go | 26 ++++ internal/view/seasonsview/detail_page.templ | 130 ++++++++++++++++ .../view/seasonsview/leagues_section.templ | 142 +++++++++--------- .../seasonsview/season_league_layout.templ | 23 +++ 16 files changed, 416 insertions(+), 78 deletions(-) create mode 100644 internal/handlers/draft_season_tabs.go diff --git a/internal/handlers/draft_season_tabs.go b/internal/handlers/draft_season_tabs.go new file mode 100644 index 0000000..2aefbf8 --- /dev/null +++ b/internal/handlers/draft_season_tabs.go @@ -0,0 +1,96 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/throw" + seasonsview "git.haelnorr.com/h/oslstats/internal/view/seasonsview" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// redirectDraftSeasonLeague checks if a season is a draft type and redirects +// GET requests from /seasons/{short}/leagues/Draft/{tab} to /seasons/{short}/{tab}. +// Returns true if a redirect was issued (caller should return early). +// POST requests are not redirected since they are HTMX partial content requests. +func redirectDraftSeasonLeague(season *db.Season, tab string, w http.ResponseWriter, r *http.Request) bool { + if r.Method == "GET" && season.Type == db.SeasonTypeDraft.String() { + redirectURL := fmt.Sprintf("/seasons/%s/%s", season.ShortName, tab) + http.Redirect(w, r, redirectURL, http.StatusSeeOther) + return true + } + return false +} + +// DraftSeasonTabPage handles GET requests for draft season tab pages at +// /seasons/{season_short_name}/{tab}. It renders the full DraftSeasonDetailPage +// with the appropriate tab content pre-rendered. +func DraftSeasonTabPage( + s *hws.Server, + conn *db.DB, + tab string, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seasonStr := r.PathValue("season_short_name") + + var season *db.Season + var league *db.League + var teams []*db.Team + var availableTeams []*db.Team + var fixtures []*db.Fixture + + if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + + // Verify this is a draft season + season, err = db.GetSeason(ctx, tx, seasonStr) + if err != nil { + if db.IsBadRequest(err) { + throw.NotFound(s, w, r, r.URL.Path) + return false, nil + } + return false, errors.Wrap(err, "db.GetSeason") + } + + if season.Type != db.SeasonTypeDraft.String() { + throw.NotFound(s, w, r, r.URL.Path) + return false, nil + } + + // Fetch the Draft league and teams + season, league, teams, err = db.GetSeasonLeague(ctx, tx, seasonStr, "Draft") + if err != nil { + return false, errors.Wrap(err, "db.GetSeasonLeague") + } + + // Fetch tab-specific data + switch tab { + case "teams": + availableTeams, err = db.GetList[db.Team](tx). + Join("LEFT JOIN team_participations tp ON tp.team_id = t.id"). + Where("NOT tp.season_id = ? OR tp.season_id IS NULL", season.ID). + GetAll(ctx) + if err != nil { + return false, errors.Wrap(err, "db.GetList[Team]") + } + case "fixtures": + season, league, fixtures, err = db.GetFixtures(ctx, tx, seasonStr, "Draft") + if err != nil { + return false, errors.Wrap(err, "db.GetFixtures") + } + } + + return true, nil + }); !ok { + return + } + + renderSafely(seasonsview.DraftSeasonDetailPage( + season, league, teams, availableTeams, fixtures, tab, + ), s, r, w) + }) +} diff --git a/internal/handlers/season_detail.go b/internal/handlers/season_detail.go index 541754f..f397035 100644 --- a/internal/handlers/season_detail.go +++ b/internal/handlers/season_detail.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "fmt" "net/http" "git.haelnorr.com/h/golib/hws" @@ -32,15 +33,26 @@ func SeasonPage( return false, errors.Wrap(err, "db.GetSeason") } - leaguesWithTeams, err = season.MapTeamsToLeagues(ctx, tx) - if err != nil { - return false, errors.Wrap(err, "season.MapTeamsToLeagues") + if season.Type != db.SeasonTypeDraft.String() { + leaguesWithTeams, err = season.MapTeamsToLeagues(ctx, tx) + if err != nil { + return false, errors.Wrap(err, "season.MapTeamsToLeagues") + } } return true, nil }); !ok { return } + + if season.Type == db.SeasonTypeDraft.String() { + // Redirect draft seasons to their default tab + defaultTab := season.GetDefaultTab() + redirectURL := fmt.Sprintf("/seasons/%s/%s", seasonStr, defaultTab) + http.Redirect(w, r, redirectURL, http.StatusSeeOther) + return + } + renderSafely(seasonsview.DetailPage(season, leaguesWithTeams), s, r, w) }) } diff --git a/internal/handlers/season_league_add_team.go b/internal/handlers/season_league_add_team.go index 88ead10..64d7a5c 100644 --- a/internal/handlers/season_league_add_team.go +++ b/internal/handlers/season_league_add_team.go @@ -50,7 +50,11 @@ func SeasonLeagueAddTeam( } // Redirect to refresh the page - w.Header().Set("HX-Redirect", fmt.Sprintf("/seasons/%s/leagues/%s/teams", season.ShortName, league.ShortName)) + redirectURL := fmt.Sprintf("/seasons/%s/leagues/%s/teams", season.ShortName, league.ShortName) + if season.Type == db.SeasonTypeDraft.String() { + redirectURL = fmt.Sprintf("/seasons/%s/teams", season.ShortName) + } + w.Header().Set("HX-Redirect", redirectURL) w.WriteHeader(http.StatusOK) notify.Success(s, w, r, "Team Added", fmt.Sprintf( "Successfully added '%s' to the league.", diff --git a/internal/handlers/season_league_detail.go b/internal/handlers/season_league_detail.go index 6e93f6b..ec5fcb4 100644 --- a/internal/handlers/season_league_detail.go +++ b/internal/handlers/season_league_detail.go @@ -39,6 +39,14 @@ func SeasonLeaguePage( } defaultTab := season.GetDefaultTab() + + // Draft seasons redirect to /seasons/{short}/{tab} instead + if season.Type == db.SeasonTypeDraft.String() { + redirectURL := fmt.Sprintf("/seasons/%s/%s", seasonStr, defaultTab) + http.Redirect(w, r, redirectURL, http.StatusSeeOther) + return + } + redirectURL := fmt.Sprintf( "/seasons/%s/leagues/%s/%s", seasonStr, leagueStr, defaultTab, diff --git a/internal/handlers/season_league_finals.go b/internal/handlers/season_league_finals.go index b8f0187..66cef39 100644 --- a/internal/handlers/season_league_finals.go +++ b/internal/handlers/season_league_finals.go @@ -39,6 +39,9 @@ func SeasonLeagueFinalsPage( return } + if redirectDraftSeasonLeague(season, "finals", w, r) { + return + } if r.Method == "GET" { renderSafely(seasonsview.SeasonLeagueFinalsPage(season, league), s, r, w) } else { diff --git a/internal/handlers/season_league_fixtures.go b/internal/handlers/season_league_fixtures.go index 417545b..b80e664 100644 --- a/internal/handlers/season_league_fixtures.go +++ b/internal/handlers/season_league_fixtures.go @@ -41,6 +41,9 @@ func SeasonLeagueFixturesPage( return } + if redirectDraftSeasonLeague(season, "fixtures", w, r) { + return + } if r.Method == "GET" { renderSafely(seasonsview.SeasonLeagueFixturesPage(season, league, fixtures), s, r, w) } else { diff --git a/internal/handlers/season_league_stats.go b/internal/handlers/season_league_stats.go index 80b1c2b..76dc2ec 100644 --- a/internal/handlers/season_league_stats.go +++ b/internal/handlers/season_league_stats.go @@ -39,6 +39,9 @@ func SeasonLeagueStatsPage( return } + if redirectDraftSeasonLeague(season, "stats", w, r) { + return + } if r.Method == "GET" { renderSafely(seasonsview.SeasonLeagueStatsPage(season, league), s, r, w) } else { diff --git a/internal/handlers/season_league_table.go b/internal/handlers/season_league_table.go index a061032..0e434c7 100644 --- a/internal/handlers/season_league_table.go +++ b/internal/handlers/season_league_table.go @@ -38,6 +38,9 @@ func SeasonLeagueTablePage( }); !ok { return } + if redirectDraftSeasonLeague(season, "table", w, r) { + return + } if r.Method == "GET" { renderSafely(seasonsview.SeasonLeagueTablePage(season, league), s, r, w) } else { diff --git a/internal/handlers/season_league_teams.go b/internal/handlers/season_league_teams.go index bd93bc3..cc69cdd 100644 --- a/internal/handlers/season_league_teams.go +++ b/internal/handlers/season_league_teams.go @@ -50,6 +50,9 @@ func SeasonLeagueTeamsPage( return } + if redirectDraftSeasonLeague(season, "teams", w, r) { + return + } if r.Method == "GET" { renderSafely(seasonsview.SeasonLeagueTeamsPage(season, league, teams, available), s, r, w) } else { diff --git a/internal/handlers/season_leagues.go b/internal/handlers/season_leagues.go index 3361427..50ab849 100644 --- a/internal/handlers/season_leagues.go +++ b/internal/handlers/season_leagues.go @@ -25,7 +25,21 @@ func SeasonAddLeague( var season *db.Season var allLeagues []*db.League if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { - err := db.NewSeasonLeague(ctx, tx, seasonShortName, leagueShortName, db.NewAuditFromRequest(r)) + // Check if season is a draft season + seasonCheck, err := db.GetSeason(ctx, tx, seasonShortName) + if err != nil { + if db.IsBadRequest(err) { + respond.NotFound(w, err) + return false, nil + } + return false, errors.Wrap(err, "db.GetSeason") + } + if seasonCheck.Type == db.SeasonTypeDraft.String() { + respond.BadRequest(w, errors.New("cannot manually manage leagues for draft seasons")) + return false, nil + } + + err = db.NewSeasonLeague(ctx, tx, seasonShortName, leagueShortName, db.NewAuditFromRequest(r)) if err != nil { if db.IsBadRequest(err) { respond.BadRequest(w, err) @@ -75,6 +89,10 @@ func SeasonRemoveLeague( } return false, errors.Wrap(err, "db.GetSeason") } + if season.Type == db.SeasonTypeDraft.String() { + respond.BadRequest(w, errors.New("cannot manually manage leagues for draft seasons")) + return false, nil + } err = season.RemoveLeague(ctx, tx, leagueStr, db.NewAuditFromRequest(r)) if err != nil { if db.IsBadRequest(err) { diff --git a/internal/handlers/teams_new.go b/internal/handlers/teams_new.go index 629406c..ca4c1e5 100644 --- a/internal/handlers/teams_new.go +++ b/internal/handlers/teams_new.go @@ -35,7 +35,7 @@ func NewTeamSubmit( } name := getter.String("name"). TrimSpace().Required(). - MaxLength(25).MinLength(3).Value + MaxLength(50).MinLength(3).Value shortName := getter.String("short_name"). TrimSpace().Required().ToUpper(). MaxLength(3).MinLength(3).Value diff --git a/internal/rbac/preview_middleware.go b/internal/rbac/preview_middleware.go index 9944763..6631432 100644 --- a/internal/rbac/preview_middleware.go +++ b/internal/rbac/preview_middleware.go @@ -2,6 +2,7 @@ package rbac import ( "context" + "fmt" "net/http" "strconv" @@ -28,8 +29,11 @@ func LoadPreviewRoleMiddleware(s *hws.Server, conn *db.DB) func(http.Handler) ht user := db.CurrentUser(r.Context()) if user == nil { - // User not logged in, - ClearPreviewRoleCookie(w) + fmt.Println(user) + // User not logged in + // Auth middleware skips on certain routes like CSS files so even + // if user IS logged in, this will trigger on those routes, + // so we just pass the request on and do nothing. next.ServeHTTP(w, r) return } diff --git a/internal/server/routes.go b/internal/server/routes.go index 6ad3750..79d1b1a 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -97,6 +97,32 @@ func addRoutes( Method: hws.MethodPOST, Handler: perms.RequirePermission(s, permissions.SeasonsUpdate)(handlers.SeasonEditSubmit(s, conn)), }, + // Draft season tab pages (must be before league routes to avoid conflicts) + { + Path: "/seasons/{season_short_name}/table", + Method: hws.MethodGET, + Handler: handlers.DraftSeasonTabPage(s, conn, "table"), + }, + { + Path: "/seasons/{season_short_name}/fixtures", + Method: hws.MethodGET, + Handler: handlers.DraftSeasonTabPage(s, conn, "fixtures"), + }, + { + Path: "/seasons/{season_short_name}/teams", + Method: hws.MethodGET, + Handler: handlers.DraftSeasonTabPage(s, conn, "teams"), + }, + { + Path: "/seasons/{season_short_name}/stats", + Method: hws.MethodGET, + Handler: handlers.DraftSeasonTabPage(s, conn, "stats"), + }, + { + Path: "/seasons/{season_short_name}/finals", + Method: hws.MethodGET, + Handler: handlers.DraftSeasonTabPage(s, conn, "finals"), + }, { Path: "/seasons/{season_short_name}/leagues/{league_short_name}", Method: hws.MethodGET, diff --git a/internal/view/seasonsview/detail_page.templ b/internal/view/seasonsview/detail_page.templ index cdc2791..4807538 100644 --- a/internal/view/seasonsview/detail_page.templ +++ b/internal/view/seasonsview/detail_page.templ @@ -15,6 +15,136 @@ templ DetailPage(season *db.Season, leaguesWithTeams []db.LeagueWithTeams) { } } +templ DraftSeasonDetailPage(season *db.Season, league *db.League, teams []*db.Team, available []*db.Team, fixtures []*db.Fixture, defaultTab string) { + @baseview.Layout(season.Name) { +
+ @DraftSeasonDetail(season, league, teams, available, fixtures, defaultTab) +
+ + } +} + +templ DraftSeasonDetail(season *db.Season, league *db.League, teams []*db.Team, available []*db.Team, fixtures []*db.Fixture, defaultTab string) { + {{ + permCache := contexts.Permissions(ctx) + canEditSeason := permCache.HasPermission(permissions.SeasonsUpdate) + }} +
+ +
+
+
+
+

{ season.Name }

+ { season.ShortName } +
+
+ @SeasonTypeBadge(season.Type) + @SlapVersionBadge(season.SlapVersion) + @StatusBadge(season, false, false) +
+
+
+ if canEditSeason { + + Edit + + } + + Back to Seasons + +
+
+ +
+
+ +
+

+ + Regular Season +

+
+
+
Start
+
{ formatDateLong(season.StartDate) }
+
+
+
Finish
+ if !season.EndDate.IsZero() { +
{ formatDateLong(season.EndDate.Time) }
+ } else { +
Not set
+ } +
+
+
+ +
+

+ + Finals +

+
+
+
Start
+ if !season.FinalsStartDate.IsZero() { +
{ formatDateLong(season.FinalsStartDate.Time) }
+ } else { +
Not set
+ } +
+
+
Finish
+ if !season.FinalsEndDate.IsZero() { +
{ formatDateLong(season.FinalsEndDate.Time) }
+ } else { +
Not set
+ } +
+
+
+
+
+
+ + + +
+ switch defaultTab { + case "table": + @SeasonLeagueTable() + case "fixtures": + @SeasonLeagueFixtures(season, league, fixtures) + case "teams": + @SeasonLeagueTeams(season, league, teams, available) + case "stats": + @SeasonLeagueStats() + case "finals": + @SeasonLeagueFinals() + } +
+
+} + + + templ SeasonDetails(season *db.Season, leaguesWithTeams []db.LeagueWithTeams) { {{ permCache := contexts.Permissions(ctx) diff --git a/internal/view/seasonsview/leagues_section.templ b/internal/view/seasonsview/leagues_section.templ index f15bc2f..0c65916 100644 --- a/internal/view/seasonsview/leagues_section.templ +++ b/internal/view/seasonsview/leagues_section.templ @@ -5,84 +5,86 @@ 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) + if season.Type != db.SeasonTypeDraft.String() { + {{ + 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

+ // 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 availableLeagues { - + 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/season_league_layout.templ b/internal/view/seasonsview/season_league_layout.templ index 7fb6b96..14bb146 100644 --- a/internal/view/seasonsview/season_league_layout.templ +++ b/internal/view/seasonsview/season_league_layout.templ @@ -125,3 +125,26 @@ templ leagueNavItem(section string, label string, activeSection string, season * } + +templ draftNavItem(section string, label string, activeSection string, season *db.Season, league *db.League) { + {{ + isActive := section == activeSection + baseClasses := "inline-block px-6 py-3 transition-colors cursor-pointer border-b-2" + activeClasses := "border-blue text-blue font-semibold" + inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2" + displayURL := fmt.Sprintf("/seasons/%s/%s", season.ShortName, section) + postURL := fmt.Sprintf("/seasons/%s/leagues/%s/%s", season.ShortName, league.ShortName, section) + }} +
  • + + { label } + +
  • +}