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)
+ }
+
+
+
+
+}
+
+// ──────────────────────────────────────────────
+// 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 {
-
- Begin Finals
-
- }
+
+ Begin Finals
+
}
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"
}
}}