From ba0844048a3fc05a3f0e44ae980d6cf7e4a4d902 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Mon, 9 Mar 2026 13:01:28 +1100 Subject: [PATCH] playoff visual fixes --- .gitignore | 2 + internal/db/playoff_generation.go | 32 +-- internal/db/season.go | 27 ++- internal/embedfs/web/css/output.css | 13 ++ internal/embedfs/web/js/bracket-lines.js | 152 +++++++++++++ .../view/seasonsview/finals_setup_form.templ | 56 +++-- internal/view/seasonsview/list_page.templ | 37 ++-- .../view/seasonsview/playoff_bracket.templ | 205 +++++++++++++++--- internal/view/seasonsview/playoff_helpers.go | 134 ++++++------ .../seasonsview/season_league_finals.templ | 29 ++- internal/view/seasonsview/status_badge.templ | 56 ++--- 11 files changed, 528 insertions(+), 215 deletions(-) create mode 100644 internal/embedfs/web/js/bracket-lines.js diff --git a/.gitignore b/.gitignore index 7a77d4d..5220f0f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .env +*.env *.db* .logs/ server.log @@ -10,6 +11,7 @@ internal/view/**/*_templ.go internal/view/**/*_templ.txt cmd/test/* .opencode +prod-export.sql # Database backups (compressed) backups/*.sql.gz diff --git a/internal/db/playoff_generation.go b/internal/db/playoff_generation.go index 176cb55..e7a16b5 100644 --- a/internal/db/playoff_generation.go +++ b/internal/db/playoff_generation.go @@ -336,17 +336,19 @@ func generate7to9Bracket( // generate10to15Bracket creates a finals bracket for 10-15 teams: // -// Qualifying Finals (QF1-QF4): Top 4 get second chance +// Qualifying Finals: Top 4 get second chance // QF1: 1st vs 4th // QF2: 2nd vs 3rd -// QF3: 5th vs 8th -// QF4: 6th vs 7th // -// Semi Finals: -// SF1: Loser(QF1) vs Winner(QF4) — loser eliminated -// SF2: Loser(QF2) vs Winner(QF3) — loser eliminated +// Elimination Finals: Single elimination +// EF1: 5th vs 8th +// EF2: 6th vs 7th // -// Preliminary Finals: +// Semi Finals (same-side: QF loser faces same-side EF winner): +// SF1: Loser(QF1) vs Winner(EF1) — loser eliminated +// SF2: Loser(QF2) vs Winner(EF2) — loser eliminated +// +// Preliminary Finals (QF winner vs opposite SF winner): // PF1: Winner(QF1) vs Winner(SF2) // PF2: Winner(QF2) vs Winner(SF1) // @@ -412,7 +414,7 @@ func generate10to15Bracket( // Semi Finals sf1, err := NewPlayoffSeries(ctx, tx, bracket, 5, "semi_final", "SF1", - nil, nil, // Loser(QF1) vs Winner(EF2) + nil, nil, // Loser(QF1) vs Winner(EF1) nil, nil, getMatchesToWin(roundFormats, "semi_final"), SeriesStatusPending) if err != nil { @@ -421,7 +423,7 @@ func generate10to15Bracket( sf2, err := NewPlayoffSeries(ctx, tx, bracket, 6, "semi_final", "SF2", - nil, nil, // Loser(QF2) vs Winner(EF1) + nil, nil, // Loser(QF2) vs Winner(EF2) nil, nil, getMatchesToWin(roundFormats, "semi_final"), SeriesStatusPending) if err != nil { @@ -482,28 +484,28 @@ func generate10to15Bracket( return errors.Wrap(err, "wire QF2") } - // EF1 (QF3): Winner -> SF2 (team2), Loser eliminated + // EF1 (QF3): Winner -> SF1 (team2), Loser eliminated err = SetSeriesAdvancement(ctx, tx, qf3.ID, - &sf2.ID, strPtr("team2"), nil, nil) + &sf1.ID, strPtr("team2"), nil, nil) if err != nil { return errors.Wrap(err, "wire EF1") } - // EF2 (QF4): Winner -> SF1 (team2), Loser eliminated + // EF2 (QF4): Winner -> SF2 (team2), Loser eliminated err = SetSeriesAdvancement(ctx, tx, qf4.ID, - &sf1.ID, strPtr("team2"), nil, nil) + &sf2.ID, strPtr("team2"), nil, nil) if err != nil { return errors.Wrap(err, "wire EF2") } - // SF1: Winner -> PF2 (team2), Loser eliminated + // SF1: Winner -> PF2 (team2), Loser eliminated (crosses to face QF2 winner) err = SetSeriesAdvancement(ctx, tx, sf1.ID, &pf2.ID, strPtr("team2"), nil, nil) if err != nil { return errors.Wrap(err, "wire SF1") } - // SF2: Winner -> PF1 (team2), Loser eliminated + // SF2: Winner -> PF1 (team2), Loser eliminated (crosses to face QF1 winner) err = SetSeriesAdvancement(ctx, tx, sf2.ID, &pf1.ID, strPtr("team2"), nil, nil) if err != nil { diff --git a/internal/db/season.go b/internal/db/season.go index 612e170..e1039a1 100644 --- a/internal/db/season.go +++ b/internal/db/season.go @@ -142,7 +142,12 @@ type LeagueWithTeams struct { Teams []*Team } -// GetStatus returns the current status of the season based on dates +// GetStatus returns the current status of the season based on dates. +// Dates are treated as inclusive days: +// - StartDate: season is "in progress" from the start of this day +// - EndDate: season is "in progress" through the end of this day +// - FinalsStartDate: finals are active from the start of this day +// - FinalsEndDate: finals are active through the end of this day func (s *Season) GetStatus() SeasonStatus { now := time.Now() @@ -150,20 +155,32 @@ func (s *Season) GetStatus() SeasonStatus { return StatusUpcoming } + // dayPassed returns true if the entire calendar day of t has passed. + // e.g., if t is March 8, this returns true starting March 9 00:00:00. + dayPassed := func(t time.Time) bool { + return now.After(t.Truncate(time.Hour*24).AddDate(0, 0, 1)) + } + + // dayStarted returns true if the calendar day of t has started. + // e.g., if t is March 8, this returns true starting March 8 00:00:00. + dayStarted := func(t time.Time) bool { + return !now.Before(t.Truncate(time.Hour * 24)) + } + if !s.FinalsStartDate.IsZero() { - if !s.FinalsEndDate.IsZero() && now.After(s.FinalsEndDate.Time) { + if !s.FinalsEndDate.IsZero() && dayPassed(s.FinalsEndDate.Time) { return StatusCompleted } - if now.After(s.FinalsStartDate.Time) { + if dayStarted(s.FinalsStartDate.Time) { return StatusFinals } - if !s.EndDate.IsZero() && now.After(s.EndDate.Time) { + if !s.EndDate.IsZero() && dayPassed(s.EndDate.Time) { return StatusFinalsSoon } return StatusInProgress } - if !s.EndDate.IsZero() && now.After(s.EndDate.Time) { + if !s.EndDate.IsZero() && dayPassed(s.EndDate.Time) { return StatusCompleted } diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index 57c827f..6a84a71 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -445,6 +445,9 @@ width: calc(var(--spacing) * 5); height: calc(var(--spacing) * 5); } + .h-0 { + height: calc(var(--spacing) * 0); + } .h-1 { height: calc(var(--spacing) * 1); } @@ -628,6 +631,12 @@ .min-w-0 { min-width: calc(var(--spacing) * 0); } + .min-w-\[500px\] { + min-width: 500px; + } + .min-w-\[700px\] { + min-width: 700px; + } .flex-1 { flex: 1; } @@ -943,6 +952,10 @@ border-top-style: var(--tw-border-style); border-top-width: 1px; } + .border-t-2 { + border-top-style: var(--tw-border-style); + border-top-width: 2px; + } .border-b { border-bottom-style: var(--tw-border-style); border-bottom-width: 1px; diff --git a/internal/embedfs/web/js/bracket-lines.js b/internal/embedfs/web/js/bracket-lines.js new file mode 100644 index 0000000..e6e69be --- /dev/null +++ b/internal/embedfs/web/js/bracket-lines.js @@ -0,0 +1,152 @@ +// bracket-lines.js +// Draws smooth SVG bezier connector lines between series cards in a playoff bracket. +// Lines connect from the bottom-center of a source card to the top-center of a +// destination card. Winner paths are solid green, loser paths are dashed red. +// +// Usage: Add data-bracket-lines to a container element. Inside, series cards +// should have data-series="N" attributes. The container needs a data-connections +// attribute with a JSON array of connection objects: +// [{"from": 1, "to": 3, "type": "winner"}, ...] +// +// Optional toSide field ("left" or "right") makes the line arrive at the +// left or right edge (vertically centered) of the destination card instead +// of the top-center. +// +// An SVG element with data-bracket-svg inside the container is used for drawing. + +(function () { + var WINNER_COLOR = "var(--green)"; + var LOSER_COLOR = "var(--red)"; + var STROKE_WIDTH = 2; + var DASH_ARRAY = "6 3"; + + // Curvature control: how far the control points extend + // as a fraction of the total distance between cards + var CURVE_FACTOR = 0.4; + + function drawBracketLines(container) { + var svg = container.querySelector("[data-bracket-svg]"); + if (!svg) return; + + var connectionsAttr = container.getAttribute("data-connections"); + if (!connectionsAttr) return; + + var connections; + try { + connections = JSON.parse(connectionsAttr); + } catch (e) { + return; + } + + // Clear existing paths + while (svg.firstChild) { + svg.removeChild(svg.firstChild); + } + + // Get container position for relative coordinates + var containerRect = container.getBoundingClientRect(); + + // Size SVG to match container + svg.setAttribute("width", containerRect.width); + svg.setAttribute("height", containerRect.height); + + connections.forEach(function (conn) { + var fromCard = container.querySelector( + '[data-series="' + conn.from + '"]', + ); + var toCard = container.querySelector('[data-series="' + conn.to + '"]'); + if (!fromCard || !toCard) return; + + var fromRect = fromCard.getBoundingClientRect(); + var toRect = toCard.getBoundingClientRect(); + + // Start: bottom-center of source card + var x1 = fromRect.left + fromRect.width / 2 - containerRect.left; + var y1 = fromRect.bottom - containerRect.top; + + var x2, y2, d; + + if (conn.toSide === "left") { + // End: left edge, vertically centered + x2 = toRect.left - containerRect.left; + y2 = toRect.top + toRect.height / 2 - containerRect.top; + + // Bezier: go down first, then curve into the left side + var dy = y2 - y1; + var dx = x2 - x1; + d = + "M " + x1 + " " + y1 + + " C " + x1 + " " + (y1 + dy * 0.5) + + ", " + (x2 + dx * 0.2) + " " + y2 + + ", " + x2 + " " + y2; + } else if (conn.toSide === "right") { + // End: right edge, vertically centered + x2 = toRect.right - containerRect.left; + y2 = toRect.top + toRect.height / 2 - containerRect.top; + + // Bezier: go down first, then curve into the right side + var dy = y2 - y1; + var dx = x2 - x1; + d = + "M " + x1 + " " + y1 + + " C " + x1 + " " + (y1 + dy * 0.5) + + ", " + (x2 + dx * 0.2) + " " + y2 + + ", " + x2 + " " + y2; + } else { + // Default: end at top-center of destination card + x2 = toRect.left + toRect.width / 2 - containerRect.left; + y2 = toRect.top - containerRect.top; + + var dy = y2 - y1; + d = + "M " + x1 + " " + y1 + + " C " + x1 + " " + (y1 + dy * CURVE_FACTOR) + + ", " + x2 + " " + (y2 - dy * CURVE_FACTOR) + + ", " + x2 + " " + y2; + } + + var path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", d); + path.setAttribute("fill", "none"); + path.setAttribute("stroke-width", STROKE_WIDTH); + + if (conn.type === "winner") { + path.setAttribute("stroke", WINNER_COLOR); + } else { + path.setAttribute("stroke", LOSER_COLOR); + path.setAttribute("stroke-dasharray", DASH_ARRAY); + } + + svg.appendChild(path); + }); + } + + function drawAllBrackets() { + var containers = document.querySelectorAll("[data-bracket-lines]"); + containers.forEach(drawBracketLines); + } + + // Draw on initial load + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", drawAllBrackets); + } else { + // DOM already loaded (e.g. script loaded via HTMX swap) + drawAllBrackets(); + } + + // Redraw on window resize (debounced) + var resizeTimer; + window.addEventListener("resize", function () { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(drawAllBrackets, 100); + }); + + // Redraw after HTMX swaps + document.addEventListener("htmx:afterSwap", function () { + // Small delay to let the DOM settle + setTimeout(drawAllBrackets, 50); + }); + + // Expose for manual triggering if needed + window.drawBracketLines = drawAllBrackets; +})(); diff --git a/internal/view/seasonsview/finals_setup_form.templ b/internal/view/seasonsview/finals_setup_form.templ index a830970..b78fdca 100644 --- a/internal/view/seasonsview/finals_setup_form.templ +++ b/internal/view/seasonsview/finals_setup_form.templ @@ -21,6 +21,16 @@ templ FinalsSetupForm( } else if len(leaderboard) >= 5 && len(leaderboard) <= 6 { defaultFormat = string(db.PlayoffFormat5to6) } + + // Prefill dates from existing season values + endDateDefault := "" + if !season.EndDate.IsZero() { + endDateDefault = season.EndDate.Time.Format("02/01/2006") + } + finalsStartDefault := "" + if !season.FinalsStartDate.IsZero() { + finalsStartDefault = season.FinalsStartDate.Time.Format("02/01/2006") + } }}
-
- @datepicker.DatePicker( - "regular_season_end_date", - "regular_season_end_date", - "Regular Season End Date", - "DD/MM/YYYY", - true, - "", - ) -

Games after this date will be forfeited

-
-
- @datepicker.DatePicker( - "finals_start_date", - "finals_start_date", - "Finals Start Date", - "DD/MM/YYYY", - true, - "", - ) -

First playoff matches begin on this date

-
+
+ @datepicker.DatePickerWithDefault( + "regular_season_end_date", + "regular_season_end_date", + "Regular Season End Date", + "DD/MM/YYYY", + true, + "", + endDateDefault, + ) +

Last day of the regular season (inclusive)

+
+
+ @datepicker.DatePickerWithDefault( + "finals_start_date", + "finals_start_date", + "Finals Start Date", + "DD/MM/YYYY", + true, + "", + finalsStartDefault, + ) +

First playoff matches begin on this date

+
diff --git a/internal/view/seasonsview/list_page.templ b/internal/view/seasonsview/list_page.templ index bff68bb..8143b80 100644 --- a/internal/view/seasonsview/list_page.templ +++ b/internal/view/seasonsview/list_page.templ @@ -115,29 +115,28 @@ templ SeasonsList(seasons *db.List[db.Season]) { }
} - - {{ - now := time.Now() - }} -
- if now.Before(s.StartDate) { + + {{ + listStatus := s.GetStatus() + }} +
+ switch listStatus { + case db.StatusUpcoming: Starts: { formatDate(s.StartDate) } - } else if !s.FinalsStartDate.IsZero() { - // Finals are scheduled - if !s.FinalsEndDate.IsZero() && now.After(s.FinalsEndDate.Time) { + case db.StatusCompleted: + if !s.FinalsEndDate.IsZero() { 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() { + Completed: { formatDate(s.EndDate.Time) } } - } else if !s.EndDate.IsZero() && now.After(s.EndDate.Time) { - // No finals scheduled and regular season ended - Completed: { formatDate(s.EndDate.Time) } - } else { + case db.StatusFinals: + Finals Started: { formatDate(s.FinalsStartDate.Time) } + case db.StatusFinalsSoon: + Finals Start: { formatDate(s.FinalsStartDate.Time) } + default: Started: { formatDate(s.StartDate) } - } -
+ } +
}
diff --git a/internal/view/seasonsview/playoff_bracket.templ b/internal/view/seasonsview/playoff_bracket.templ index 5125487..fe33d3c 100644 --- a/internal/view/seasonsview/playoff_bracket.templ +++ b/internal/view/seasonsview/playoff_bracket.templ @@ -22,42 +22,181 @@ templ PlayoffBracketView(season *db.Season, league *db.League, bracket *db.Playo @playoffStatusBadge(bracket.Status)
- -
- @bracketRounds(season, league, bracket) + + switch bracket.Format { + case db.PlayoffFormat5to6: + @bracket5to6(season, league, bracket) + case db.PlayoffFormat7to9: + @bracket7to9(season, league, bracket) + case db.PlayoffFormat10to15: + @bracket10to15(season, league, bracket) + } + +
+
+ + Winner +
+
+ + Loser +
+
+
+ +} + +// ────────────────────────────────────────────── +// 5-6 TEAMS FORMAT +// ────────────────────────────────────────────── +// Round 1: [Upper Bracket] [Lower Bracket] +// Round 2: [Upper Final] [Lower Final] +// Round 3: [Grand Final] +templ bracket5to6(season *db.Season, league *db.League, bracket *db.PlayoffBracket) { + {{ + s := seriesByNumber(bracket.Series) + conns := connectionsJSON(bracket.Series) + }} +
+
+ +
+
+ @seriesCard(season, league, s[1]) + @seriesCard(season, league, s[2]) +
+
+
+ @seriesCard(season, league, s[3]) + @seriesCard(season, league, s[4]) +
+
+
+ @seriesCard(season, league, s[5]) +
+
} -// bracketRounds groups series by round and renders them -templ bracketRounds(season *db.Season, league *db.League, bracket *db.PlayoffBracket) { +// ────────────────────────────────────────────── +// 7-9 TEAMS FORMAT +// ────────────────────────────────────────────── +// Round 1 (Quarter Finals): [QF1] [QF2] +// Round 2 (Semi Finals): [SF1] [SF2] +// Round 3: [3rd Place] [Grand Final] +templ bracket7to9(season *db.Season, league *db.League, bracket *db.PlayoffBracket) { {{ - // Group series by round - rounds := groupSeriesByRound(bracket.Series) - roundOrder := getRoundOrder(bracket.Format) + s := seriesByNumber(bracket.Series) + conns := connectionsJSON(bracket.Series) }} - for _, roundName := range roundOrder { - if series, ok := rounds[roundName]; ok { -
-

- { formatRoundName(roundName) } -

-
- for _, s := range series { - @seriesCard(season, league, s) - } +
+
+ +
+
+ @seriesCard(season, league, s[1]) + @seriesCard(season, league, s[2]) +
+
+
+ @seriesCard(season, league, s[3]) + @seriesCard(season, league, s[4]) +
+
+
+ @seriesCard(season, league, s[5]) + @seriesCard(season, league, s[6])
- } - } +
+
} +// ────────────────────────────────────────────── +// 10-15 TEAMS FORMAT +// ────────────────────────────────────────────── +// 4 invisible columns, cards placed into specific cells: +// Row 1: EF1(col2) EF2(col3) +// Row 2: QF1(col1) QF2(col4) +// Row 3: SF1(col2) SF2(col3) +// Row 4: PF1(col2) PF2(col3) +// Row 5: 3rd(col2) +// Row 6: GF(col3) +templ bracket10to15(season *db.Season, league *db.League, bracket *db.PlayoffBracket) { + {{ + s := seriesByNumber(bracket.Series) + conns := connectionsJSON(bracket.Series) + }} +
+
+ +
+ +
+
+ @seriesCard(season, league, s[3]) + @seriesCard(season, league, s[4]) +
+
+
+ +
+ @seriesCard(season, league, s[1]) +
+
+ @seriesCard(season, league, s[2]) +
+
+ +
+
+ @seriesCard(season, league, s[5]) + @seriesCard(season, league, s[6]) +
+
+
+ +
+
+ @seriesCard(season, league, s[7]) + @seriesCard(season, league, s[8]) +
+
+
+ +
+
+ @seriesCard(season, league, s[9]) +
+
+
+
+ +
+
+
+ @seriesCard(season, league, s[10]) +
+
+
+
+
+} + +// ────────────────────────────────────────────── +// SHARED COMPONENTS +// ────────────────────────────────────────────── + templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries) { -
+
-
+
{ series.Label } @seriesFormatBadge(series.MatchesToWin) @@ -73,7 +212,7 @@ templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries)
if series.MatchesToWin > 1 { -
+
{ fmt.Sprint(series.Team1Wins) } - { fmt.Sprint(series.Team2Wins) }
} @@ -88,27 +227,29 @@ templ seriesTeamRow(season *db.Season, league *db.League, team *db.Team, seed *i } isTBD := team == nil }} -
-
+
if seed != nil { - + { fmt.Sprint(*seed) } } else { - - + - } if isTBD { TBD } else { - @links.TeamLinkInSeason(team, season, league) +
+ @links.TeamLinkInSeason(team, season, league) +
if isWinner { - + } }
if matchesToWin > 1 { - { fmt.Sprint(wins) } diff --git a/internal/view/seasonsview/playoff_helpers.go b/internal/view/seasonsview/playoff_helpers.go index 3dc7db9..79ae8ec 100644 --- a/internal/view/seasonsview/playoff_helpers.go +++ b/internal/view/seasonsview/playoff_helpers.go @@ -1,76 +1,18 @@ package seasonsview -import "git.haelnorr.com/h/oslstats/internal/db" +import ( + "encoding/json" -// groupSeriesByRound groups playoff series by their round field -func groupSeriesByRound(series []*db.PlayoffSeries) map[string][]*db.PlayoffSeries { - grouped := make(map[string][]*db.PlayoffSeries) + "git.haelnorr.com/h/oslstats/internal/db" +) + +// seriesByNumber returns a map of series_number -> *PlayoffSeries for quick lookup +func seriesByNumber(series []*db.PlayoffSeries) map[int]*db.PlayoffSeries { + m := make(map[int]*db.PlayoffSeries, len(series)) for _, s := range series { - grouped[s.Round] = append(grouped[s.Round], s) - } - return grouped -} - -// getRoundOrder returns the display order of rounds for a given format -func getRoundOrder(format db.PlayoffFormat) []string { - switch format { - case db.PlayoffFormat5to6: - return []string{ - "upper_bracket", - "lower_bracket", - "upper_final", - "lower_final", - "grand_final", - } - case db.PlayoffFormat7to9: - return []string{ - "quarter_final", - "semi_final", - "third_place", - "grand_final", - } - case db.PlayoffFormat10to15: - return []string{ - "qualifying_final", - "elimination_final", - "semi_final", - "preliminary_final", - "third_place", - "grand_final", - } - default: - return nil - } -} - -// formatRoundName converts a round slug to a human-readable name -func formatRoundName(round string) string { - switch round { - case "upper_bracket": - return "Upper Bracket" - case "lower_bracket": - return "Lower Bracket" - case "upper_final": - return "Upper Final" - case "lower_final": - return "Lower Final" - case "quarter_final": - return "Quarter Finals" - case "semi_final": - return "Semi Finals" - case "qualifying_final": - return "Qualifying Finals" - case "elimination_final": - return "Elimination Finals" - case "preliminary_final": - return "Preliminary Finals" - case "third_place": - return "Third Place Playoff" - case "grand_final": - return "Grand Final" - default: - return round + m[s.SeriesNumber] = s } + return m } // formatLabel returns a human-readable format description @@ -86,3 +28,59 @@ func formatLabel(format db.PlayoffFormat) string { return string(format) } } + +// bracketConnection represents a line to draw between two series cards +type bracketConnection struct { + From int `json:"from"` + To int `json:"to"` + Type string `json:"type"` // "winner" or "loser" + ToSide string `json:"toSide,omitempty"` // "left" or "right" — enters side of dest card +} + +// connectionsJSON returns a JSON string of connections for the bracket overlay JS. +// Connections are derived from the series advancement links stored in the DB. +// For the 10-15 format, QF winner lines enter PF cards from the side. +func connectionsJSON(series []*db.PlayoffSeries) string { + // Build a lookup of series ID → series for resolving advancement targets + byID := make(map[int]*db.PlayoffSeries, len(series)) + for _, s := range series { + byID[s.ID] = s + } + + var conns []bracketConnection + for _, s := range series { + if s.WinnerNextID != nil { + if target, ok := byID[*s.WinnerNextID]; ok { + conn := bracketConnection{ + From: s.SeriesNumber, + To: target.SeriesNumber, + Type: "winner", + } + // QF winners enter PF cards from the side in the 10-15 format + if s.Round == "qualifying_final" && target.Round == "preliminary_final" { + if s.SeriesNumber == 1 { + conn.ToSide = "left" + } else if s.SeriesNumber == 2 { + conn.ToSide = "right" + } + } + conns = append(conns, conn) + } + } + if s.LoserNextID != nil { + if target, ok := byID[*s.LoserNextID]; ok { + conns = append(conns, bracketConnection{ + From: s.SeriesNumber, + To: target.SeriesNumber, + Type: "loser", + }) + } + } + } + + b, err := json.Marshal(conns) + if err != nil { + return "[]" + } + return string(b) +} diff --git a/internal/view/seasonsview/season_league_finals.templ b/internal/view/seasonsview/season_league_finals.templ index 102f6a4..3742fbe 100644 --- a/internal/view/seasonsview/season_league_finals.templ +++ b/internal/view/seasonsview/season_league_finals.templ @@ -13,42 +13,39 @@ templ SeasonLeagueFinalsPage(season *db.Season, league *db.League, bracket *db.P templ SeasonLeagueFinals(season *db.Season, league *db.League, bracket *db.PlayoffBracket) { {{ - status := season.GetStatus() permCache := contexts.Permissions(ctx) canManagePlayoffs := permCache != nil && permCache.HasPermission(permissions.PlayoffsManage) }}
if bracket != nil { @PlayoffBracketView(season, league, bracket) - } else if status == db.StatusInProgress || status == db.StatusUpcoming { - @finalsRegularSeasonInProgress(season, league, canManagePlayoffs) + } else if canManagePlayoffs { + @finalsNotYetConfigured(season, league) } else { @finalsNotConfigured() }
} -templ finalsRegularSeasonInProgress(season *db.Season, league *db.League, canManagePlayoffs bool) { +templ finalsNotYetConfigured(season *db.Season, league *db.League) {
-

Regular Season in Progress

+

No Finals Configured

- Finals will be available once the regular season is complete. + Set up the playoff bracket for this league.

- if canManagePlayoffs { - - } +
} diff --git a/internal/view/seasonsview/status_badge.templ b/internal/view/seasonsview/status_badge.templ index 537e98a..d49d851 100644 --- a/internal/view/seasonsview/status_badge.templ +++ b/internal/view/seasonsview/status_badge.templ @@ -1,7 +1,6 @@ package seasonsview import "git.haelnorr.com/h/oslstats/internal/db" -import "time" // StatusBadge renders a season status badge // Parameters: @@ -10,53 +9,34 @@ import "time" // - useShortLabels: bool - true for "Active/Finals", false for "In Progress/Finals in Progress" templ StatusBadge(season *db.Season, compact bool, useShortLabels bool) { {{ - now := time.Now() + seasonStatus := season.GetStatus() status := "" statusBg := "" - - // Determine status based on dates - if now.Before(season.StartDate) { + + switch seasonStatus { + case db.StatusUpcoming: status = "Upcoming" 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" - statusBg = "bg-teal" - } else if now.After(season.FinalsStartDate.Time) { - // Finals are in progress - if useShortLabels { - status = "Finals" - } else { - status = "Finals in Progress" - } - 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 + case db.StatusInProgress: if useShortLabels { status = "Active" } else { status = "In Progress" } statusBg = "bg-green" + case db.StatusFinalsSoon: + status = "Finals Soon" + statusBg = "bg-peach" + case db.StatusFinals: + if useShortLabels { + status = "Finals" + } else { + status = "Finals in Progress" + } + statusBg = "bg-yellow" + case db.StatusCompleted: + status = "Completed" + statusBg = "bg-teal" } }}