playoff visual fixes

This commit is contained in:
2026-03-09 13:01:28 +11:00
parent 723a213be3
commit 3666870111
11 changed files with 528 additions and 215 deletions

2
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.env .env
*.env
*.db* *.db*
.logs/ .logs/
server.log server.log
@@ -10,6 +11,7 @@ internal/view/**/*_templ.go
internal/view/**/*_templ.txt internal/view/**/*_templ.txt
cmd/test/* cmd/test/*
.opencode .opencode
prod-export.sql
# Database backups (compressed) # Database backups (compressed)
backups/*.sql.gz backups/*.sql.gz

View File

@@ -336,17 +336,19 @@ func generate7to9Bracket(
// generate10to15Bracket creates a finals bracket for 10-15 teams: // 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 // QF1: 1st vs 4th
// QF2: 2nd vs 3rd // QF2: 2nd vs 3rd
// QF3: 5th vs 8th
// QF4: 6th vs 7th
// //
// Semi Finals: // Elimination Finals: Single elimination
// SF1: Loser(QF1) vs Winner(QF4) — loser eliminated // EF1: 5th vs 8th
// SF2: Loser(QF2) vs Winner(QF3) — loser eliminated // 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) // PF1: Winner(QF1) vs Winner(SF2)
// PF2: Winner(QF2) vs Winner(SF1) // PF2: Winner(QF2) vs Winner(SF1)
// //
@@ -412,7 +414,7 @@ func generate10to15Bracket(
// Semi Finals // Semi Finals
sf1, err := NewPlayoffSeries(ctx, tx, bracket, 5, sf1, err := NewPlayoffSeries(ctx, tx, bracket, 5,
"semi_final", "SF1", "semi_final", "SF1",
nil, nil, // Loser(QF1) vs Winner(EF2) nil, nil, // Loser(QF1) vs Winner(EF1)
nil, nil, nil, nil,
getMatchesToWin(roundFormats, "semi_final"), SeriesStatusPending) getMatchesToWin(roundFormats, "semi_final"), SeriesStatusPending)
if err != nil { if err != nil {
@@ -421,7 +423,7 @@ func generate10to15Bracket(
sf2, err := NewPlayoffSeries(ctx, tx, bracket, 6, sf2, err := NewPlayoffSeries(ctx, tx, bracket, 6,
"semi_final", "SF2", "semi_final", "SF2",
nil, nil, // Loser(QF2) vs Winner(EF1) nil, nil, // Loser(QF2) vs Winner(EF2)
nil, nil, nil, nil,
getMatchesToWin(roundFormats, "semi_final"), SeriesStatusPending) getMatchesToWin(roundFormats, "semi_final"), SeriesStatusPending)
if err != nil { if err != nil {
@@ -482,28 +484,28 @@ func generate10to15Bracket(
return errors.Wrap(err, "wire QF2") 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, err = SetSeriesAdvancement(ctx, tx, qf3.ID,
&sf2.ID, strPtr("team2"), nil, nil) &sf1.ID, strPtr("team2"), nil, nil)
if err != nil { if err != nil {
return errors.Wrap(err, "wire EF1") 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, err = SetSeriesAdvancement(ctx, tx, qf4.ID,
&sf1.ID, strPtr("team2"), nil, nil) &sf2.ID, strPtr("team2"), nil, nil)
if err != nil { if err != nil {
return errors.Wrap(err, "wire EF2") 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, err = SetSeriesAdvancement(ctx, tx, sf1.ID,
&pf2.ID, strPtr("team2"), nil, nil) &pf2.ID, strPtr("team2"), nil, nil)
if err != nil { if err != nil {
return errors.Wrap(err, "wire SF1") 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, err = SetSeriesAdvancement(ctx, tx, sf2.ID,
&pf1.ID, strPtr("team2"), nil, nil) &pf1.ID, strPtr("team2"), nil, nil)
if err != nil { if err != nil {

View File

@@ -142,7 +142,12 @@ type LeagueWithTeams struct {
Teams []*Team 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 { func (s *Season) GetStatus() SeasonStatus {
now := time.Now() now := time.Now()
@@ -150,20 +155,32 @@ func (s *Season) GetStatus() SeasonStatus {
return StatusUpcoming 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.FinalsStartDate.IsZero() {
if !s.FinalsEndDate.IsZero() && now.After(s.FinalsEndDate.Time) { if !s.FinalsEndDate.IsZero() && dayPassed(s.FinalsEndDate.Time) {
return StatusCompleted return StatusCompleted
} }
if now.After(s.FinalsStartDate.Time) { if dayStarted(s.FinalsStartDate.Time) {
return StatusFinals return StatusFinals
} }
if !s.EndDate.IsZero() && now.After(s.EndDate.Time) { if !s.EndDate.IsZero() && dayPassed(s.EndDate.Time) {
return StatusFinalsSoon return StatusFinalsSoon
} }
return StatusInProgress return StatusInProgress
} }
if !s.EndDate.IsZero() && now.After(s.EndDate.Time) { if !s.EndDate.IsZero() && dayPassed(s.EndDate.Time) {
return StatusCompleted return StatusCompleted
} }

View File

@@ -445,6 +445,9 @@
width: calc(var(--spacing) * 5); width: calc(var(--spacing) * 5);
height: calc(var(--spacing) * 5); height: calc(var(--spacing) * 5);
} }
.h-0 {
height: calc(var(--spacing) * 0);
}
.h-1 { .h-1 {
height: calc(var(--spacing) * 1); height: calc(var(--spacing) * 1);
} }
@@ -628,6 +631,12 @@
.min-w-0 { .min-w-0 {
min-width: calc(var(--spacing) * 0); min-width: calc(var(--spacing) * 0);
} }
.min-w-\[500px\] {
min-width: 500px;
}
.min-w-\[700px\] {
min-width: 700px;
}
.flex-1 { .flex-1 {
flex: 1; flex: 1;
} }
@@ -943,6 +952,10 @@
border-top-style: var(--tw-border-style); border-top-style: var(--tw-border-style);
border-top-width: 1px; border-top-width: 1px;
} }
.border-t-2 {
border-top-style: var(--tw-border-style);
border-top-width: 2px;
}
.border-b { .border-b {
border-bottom-style: var(--tw-border-style); border-bottom-style: var(--tw-border-style);
border-bottom-width: 1px; border-bottom-width: 1px;

View File

@@ -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;
})();

View File

@@ -21,6 +21,16 @@ templ FinalsSetupForm(
} else if len(leaderboard) >= 5 && len(leaderboard) <= 6 { } else if len(leaderboard) >= 5 && len(leaderboard) <= 6 {
defaultFormat = string(db.PlayoffFormat5to6) 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")
}
}} }}
<div class="max-w-3xl mx-auto"> <div class="max-w-3xl mx-auto">
<div <div
@@ -44,28 +54,30 @@ templ FinalsSetupForm(
> >
<!-- Date Fields --> <!-- Date Fields -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
@datepicker.DatePicker( @datepicker.DatePickerWithDefault(
"regular_season_end_date", "regular_season_end_date",
"regular_season_end_date", "regular_season_end_date",
"Regular Season End Date", "Regular Season End Date",
"DD/MM/YYYY", "DD/MM/YYYY",
true, true,
"", "",
) endDateDefault,
<p class="text-xs text-subtext0 mt-1">Games after this date will be forfeited</p> )
</div> <p class="text-xs text-subtext0 mt-1">Last day of the regular season (inclusive)</p>
<div> </div>
@datepicker.DatePicker( <div>
"finals_start_date", @datepicker.DatePickerWithDefault(
"finals_start_date", "finals_start_date",
"Finals Start Date", "finals_start_date",
"DD/MM/YYYY", "Finals Start Date",
true, "DD/MM/YYYY",
"", true,
) "",
<p class="text-xs text-subtext0 mt-1">First playoff matches begin on this date</p> finalsStartDefault,
</div> )
<p class="text-xs text-subtext0 mt-1">First playoff matches begin on this date</p>
</div>
</div> </div>
<!-- Format Selection --> <!-- Format Selection -->
<div> <div>

View File

@@ -115,29 +115,28 @@ templ SeasonsList(seasons *db.List[db.Season]) {
} }
</div> </div>
} }
<!-- Date Info --> <!-- Date Info -->
{{ {{
now := time.Now() listStatus := s.GetStatus()
}} }}
<div class="text-xs text-subtext1 mt-auto"> <div class="text-xs text-subtext1 mt-auto">
if now.Before(s.StartDate) { switch listStatus {
case db.StatusUpcoming:
Starts: { formatDate(s.StartDate) } Starts: { formatDate(s.StartDate) }
} else if !s.FinalsStartDate.IsZero() { case db.StatusCompleted:
// Finals are scheduled if !s.FinalsEndDate.IsZero() {
if !s.FinalsEndDate.IsZero() && now.After(s.FinalsEndDate.Time) {
Completed: { formatDate(s.FinalsEndDate.Time) } Completed: { formatDate(s.FinalsEndDate.Time) }
} else if now.After(s.FinalsStartDate.Time) { } else if !s.EndDate.IsZero() {
Finals Started: { formatDate(s.FinalsStartDate.Time) } Completed: { formatDate(s.EndDate.Time) }
} else {
Finals Start: { formatDate(s.FinalsStartDate.Time) }
} }
} else if !s.EndDate.IsZero() && now.After(s.EndDate.Time) { case db.StatusFinals:
// No finals scheduled and regular season ended Finals Started: { formatDate(s.FinalsStartDate.Time) }
Completed: { formatDate(s.EndDate.Time) } case db.StatusFinalsSoon:
} else { Finals Start: { formatDate(s.FinalsStartDate.Time) }
default:
Started: { formatDate(s.StartDate) } Started: { formatDate(s.StartDate) }
} }
</div> </div>
</a> </a>
} }
</div> </div>

View File

@@ -22,42 +22,181 @@ templ PlayoffBracketView(season *db.Season, league *db.League, bracket *db.Playo
@playoffStatusBadge(bracket.Status) @playoffStatusBadge(bracket.Status)
</div> </div>
</div> </div>
<!-- Bracket Series List --> <!-- Bracket Display -->
<div class="space-y-4"> switch bracket.Format {
@bracketRounds(season, league, bracket) case db.PlayoffFormat5to6:
@bracket5to6(season, league, bracket)
case db.PlayoffFormat7to9:
@bracket7to9(season, league, bracket)
case db.PlayoffFormat10to15:
@bracket10to15(season, league, bracket)
}
<!-- Legend -->
<div class="flex items-center gap-6 text-xs text-subtext0">
<div class="flex items-center gap-2">
<div class="w-8 h-0 border-t-2 border-green"></div>
<span>Winner</span>
</div>
<div class="flex items-center gap-2">
<div class="w-8 h-0 border-t-2 border-red border-dashed"></div>
<span>Loser</span>
</div>
</div>
</div>
<script src="/static/js/bracket-lines.js"></script>
}
// ──────────────────────────────────────────────
// 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)
}}
<div class="overflow-x-auto">
<div class="relative min-w-[500px]" data-bracket-lines data-connections={ conns }>
<svg data-bracket-svg class="absolute inset-0 w-full h-full pointer-events-none" style="z-index: 0;"></svg>
<div class="relative" style="z-index: 1;">
<div class="grid grid-cols-2 gap-4">
@seriesCard(season, league, s[1])
@seriesCard(season, league, s[2])
</div>
<div class="h-16"></div>
<div class="grid grid-cols-2 gap-4">
@seriesCard(season, league, s[3])
@seriesCard(season, league, s[4])
</div>
<div class="h-16"></div>
<div class="max-w-md mx-auto">
@seriesCard(season, league, s[5])
</div>
</div>
</div> </div>
</div> </div>
} }
// 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 s := seriesByNumber(bracket.Series)
rounds := groupSeriesByRound(bracket.Series) conns := connectionsJSON(bracket.Series)
roundOrder := getRoundOrder(bracket.Format)
}} }}
for _, roundName := range roundOrder { <div class="overflow-x-auto">
if series, ok := rounds[roundName]; ok { <div class="relative min-w-[500px]" data-bracket-lines data-connections={ conns }>
<div class="space-y-3"> <svg data-bracket-svg class="absolute inset-0 w-full h-full pointer-events-none" style="z-index: 0;"></svg>
<h3 class="text-sm font-semibold text-subtext0 uppercase tracking-wider"> <div class="relative" style="z-index: 1;">
{ formatRoundName(roundName) } <div class="grid grid-cols-2 gap-4">
</h3> @seriesCard(season, league, s[1])
<div class="grid grid-cols-1 md:grid-cols-2 gap-3"> @seriesCard(season, league, s[2])
for _, s := range series { </div>
@seriesCard(season, league, s) <div class="h-16"></div>
} <div class="grid grid-cols-2 gap-4">
@seriesCard(season, league, s[3])
@seriesCard(season, league, s[4])
</div>
<div class="h-16"></div>
<div class="grid grid-cols-2 gap-4">
@seriesCard(season, league, s[5])
@seriesCard(season, league, s[6])
</div> </div>
</div> </div>
} </div>
} </div>
} }
// ──────────────────────────────────────────────
// 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)
}}
<div class="overflow-x-auto">
<div class="relative min-w-[700px]" data-bracket-lines data-connections={ conns }>
<svg data-bracket-svg class="absolute inset-0 w-full h-full pointer-events-none" style="z-index: 0;"></svg>
<div class="relative" style="z-index: 1;">
<!-- Row 1: EF1(c2) EF2(c3) -->
<div class="grid grid-cols-4 gap-4">
<div></div>
@seriesCard(season, league, s[3])
@seriesCard(season, league, s[4])
<div></div>
</div>
<div class="h-16"></div>
<!-- Row 2: QF1(c1) QF2(c4) -->
<div class="grid grid-cols-4 gap-4">
@seriesCard(season, league, s[1])
<div></div>
<div></div>
@seriesCard(season, league, s[2])
</div>
<div class="h-16"></div>
<!-- Row 3: SF1(c2) SF2(c3) -->
<div class="grid grid-cols-4 gap-4">
<div></div>
@seriesCard(season, league, s[5])
@seriesCard(season, league, s[6])
<div></div>
</div>
<div class="h-16"></div>
<!-- Row 4: PF1(c2) PF2(c3) -->
<div class="grid grid-cols-4 gap-4">
<div></div>
@seriesCard(season, league, s[7])
@seriesCard(season, league, s[8])
<div></div>
</div>
<div class="h-16"></div>
<!-- Row 5: 3rd Place(c2) -->
<div class="grid grid-cols-4 gap-4">
<div></div>
@seriesCard(season, league, s[9])
<div></div>
<div></div>
</div>
<div class="h-16"></div>
<!-- Row 6: Grand Final(c3) -->
<div class="grid grid-cols-4 gap-4">
<div></div>
<div></div>
@seriesCard(season, league, s[10])
<div></div>
</div>
</div>
</div>
</div>
}
// ──────────────────────────────────────────────
// SHARED COMPONENTS
// ──────────────────────────────────────────────
templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries) { templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries) {
<div class={ "bg-surface0 border rounded-lg overflow-hidden", <div
templ.KV("border-blue/50", series.Status == db.SeriesStatusInProgress), data-series={ fmt.Sprint(series.SeriesNumber) }
templ.KV("border-surface1", series.Status != db.SeriesStatusInProgress) }> class={ "bg-surface0 border rounded-lg overflow-hidden",
templ.KV("border-blue/50", series.Status == db.SeriesStatusInProgress),
templ.KV("border-surface1", series.Status != db.SeriesStatusInProgress) }
>
<!-- Series Header --> <!-- Series Header -->
<div class="bg-mantle px-4 py-2 flex items-center justify-between border-b border-surface1"> <div class="bg-mantle px-3 py-1.5 flex items-center justify-between border-b border-surface1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-xs font-semibold text-subtext0">{ series.Label }</span> <span class="text-xs font-semibold text-subtext0">{ series.Label }</span>
@seriesFormatBadge(series.MatchesToWin) @seriesFormatBadge(series.MatchesToWin)
@@ -73,7 +212,7 @@ templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries)
</div> </div>
<!-- Series Score --> <!-- Series Score -->
if series.MatchesToWin > 1 { if series.MatchesToWin > 1 {
<div class="bg-mantle px-4 py-1.5 text-center text-xs text-subtext0 border-t border-surface1"> <div class="bg-mantle px-3 py-1 text-center text-xs text-subtext0 border-t border-surface1">
{ fmt.Sprint(series.Team1Wins) } - { fmt.Sprint(series.Team2Wins) } { fmt.Sprint(series.Team1Wins) } - { fmt.Sprint(series.Team2Wins) }
</div> </div>
} }
@@ -88,27 +227,29 @@ templ seriesTeamRow(season *db.Season, league *db.League, team *db.Team, seed *i
} }
isTBD := team == nil isTBD := team == nil
}} }}
<div class={ "flex items-center justify-between px-4 py-2.5", <div class={ "flex items-center justify-between px-3 py-2",
templ.KV("bg-green/5", isWinner) }> templ.KV("bg-green/5", isWinner) }>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 min-w-0">
if seed != nil { if seed != nil {
<span class="text-xs font-mono text-subtext0 w-5 text-right"> <span class="text-xs font-mono text-subtext0 w-4 text-right flex-shrink-0">
{ fmt.Sprint(*seed) } { fmt.Sprint(*seed) }
</span> </span>
} else { } else {
<span class="text-xs font-mono text-subtext0 w-5 text-right">-</span> <span class="text-xs font-mono text-subtext0 w-4 text-right flex-shrink-0">-</span>
} }
if isTBD { if isTBD {
<span class="text-sm text-subtext1 italic">TBD</span> <span class="text-sm text-subtext1 italic">TBD</span>
} else { } else {
@links.TeamLinkInSeason(team, season, league) <div class="truncate">
@links.TeamLinkInSeason(team, season, league)
</div>
if isWinner { if isWinner {
<span class="text-green text-xs">✓</span> <span class="text-green text-xs flex-shrink-0">✓</span>
} }
} }
</div> </div>
if matchesToWin > 1 { if matchesToWin > 1 {
<span class={ "text-sm font-mono", <span class={ "text-sm font-mono flex-shrink-0 ml-2",
templ.KV("text-text", !isWinner), templ.KV("text-text", !isWinner),
templ.KV("text-green font-bold", isWinner) }> templ.KV("text-green font-bold", isWinner) }>
{ fmt.Sprint(wins) } { fmt.Sprint(wins) }

View File

@@ -1,76 +1,18 @@
package seasonsview package seasonsview
import "git.haelnorr.com/h/oslstats/internal/db" import (
"encoding/json"
// groupSeriesByRound groups playoff series by their round field "git.haelnorr.com/h/oslstats/internal/db"
func groupSeriesByRound(series []*db.PlayoffSeries) map[string][]*db.PlayoffSeries { )
grouped := make(map[string][]*db.PlayoffSeries)
// 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 { for _, s := range series {
grouped[s.Round] = append(grouped[s.Round], s) m[s.SeriesNumber] = 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
} }
return m
} }
// formatLabel returns a human-readable format description // formatLabel returns a human-readable format description
@@ -86,3 +28,59 @@ func formatLabel(format db.PlayoffFormat) string {
return string(format) 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)
}

View File

@@ -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) { templ SeasonLeagueFinals(season *db.Season, league *db.League, bracket *db.PlayoffBracket) {
{{ {{
status := season.GetStatus()
permCache := contexts.Permissions(ctx) permCache := contexts.Permissions(ctx)
canManagePlayoffs := permCache != nil && permCache.HasPermission(permissions.PlayoffsManage) canManagePlayoffs := permCache != nil && permCache.HasPermission(permissions.PlayoffsManage)
}} }}
<div id="finals-content"> <div id="finals-content">
if bracket != nil { if bracket != nil {
@PlayoffBracketView(season, league, bracket) @PlayoffBracketView(season, league, bracket)
} else if status == db.StatusInProgress || status == db.StatusUpcoming { } else if canManagePlayoffs {
@finalsRegularSeasonInProgress(season, league, canManagePlayoffs) @finalsNotYetConfigured(season, league)
} else { } else {
@finalsNotConfigured() @finalsNotConfigured()
} }
</div> </div>
} }
templ finalsRegularSeasonInProgress(season *db.Season, league *db.League, canManagePlayoffs bool) { templ finalsNotYetConfigured(season *db.Season, league *db.League) {
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center"> <div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<div class="mb-4"> <div class="mb-4">
<svg class="w-12 h-12 mx-auto text-subtext0 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> <svg class="w-12 h-12 mx-auto text-subtext0 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 18.75h-9m9 0a3 3 0 013 3h-15a3 3 0 013-3m9 0v-3.375c0-.621-.503-1.125-1.125-1.125h-.871M7.5 18.75v-3.375c0-.621.504-1.125 1.125-1.125h.872m5.007 0H9.497m5.007 0a7.454 7.454 0 01-.982-3.172M9.497 14.25a7.454 7.454 0 00.981-3.172M5.25 4.236c-.982.143-1.954.317-2.916.52A6.003 6.003 0 007.73 9.728M5.25 4.236V4.5c0 2.108.966 3.99 2.48 5.228M5.25 4.236V2.721C7.456 2.41 9.71 2.25 12 2.25c2.291 0 4.545.16 6.75.47v1.516M18.75 4.236c.982.143 1.954.317 2.916.52A6.003 6.003 0 0016.27 9.728M18.75 4.236V4.5c0 2.108-.966 3.99-2.48 5.228m0 0a6.003 6.003 0 01-2.77.836 6.003 6.003 0 01-2.77-.836"></path> <path stroke-linecap="round" stroke-linejoin="round" d="M16.5 18.75h-9m9 0a3 3 0 013 3h-15a3 3 0 013-3m9 0v-3.375c0-.621-.503-1.125-1.125-1.125h-.871M7.5 18.75v-3.375c0-.621.504-1.125 1.125-1.125h.872m5.007 0H9.497m5.007 0a7.454 7.454 0 01-.982-3.172M9.497 14.25a7.454 7.454 0 00.981-3.172M5.25 4.236c-.982.143-1.954.317-2.916.52A6.003 6.003 0 007.73 9.728M5.25 4.236V4.5c0 2.108.966 3.99 2.48 5.228M5.25 4.236V2.721C7.456 2.41 9.71 2.25 12 2.25c2.291 0 4.545.16 6.75.47v1.516M18.75 4.236c.982.143 1.954.317 2.916.52A6.003 6.003 0 0016.27 9.728M18.75 4.236V4.5c0 2.108-.966 3.99-2.48 5.228m0 0a6.003 6.003 0 01-2.77.836 6.003 6.003 0 01-2.77-.836"></path>
</svg> </svg>
</div> </div>
<p class="text-text text-lg font-semibold mb-2">Regular Season in Progress</p> <p class="text-text text-lg font-semibold mb-2">No Finals Configured</p>
<p class="text-subtext0 mb-6"> <p class="text-subtext0 mb-6">
Finals will be available once the regular season is complete. Set up the playoff bracket for this league.
</p> </p>
if canManagePlayoffs { <button
<button hx-get={ fmt.Sprintf("/seasons/%s/leagues/%s/finals/setup", season.ShortName, league.ShortName) }
hx-get={ fmt.Sprintf("/seasons/%s/leagues/%s/finals/setup", season.ShortName, league.ShortName) } hx-target="#finals-content"
hx-target="#finals-content" hx-swap="innerHTML"
hx-swap="innerHTML" class="px-6 py-3 bg-blue hover:bg-blue/80 text-mantle rounded-lg font-semibold transition hover:cursor-pointer"
class="px-6 py-3 bg-blue hover:bg-blue/80 text-mantle rounded-lg font-semibold transition hover:cursor-pointer" >
> Begin Finals
Begin Finals </button>
</button>
}
</div> </div>
} }

View File

@@ -1,7 +1,6 @@
package seasonsview package seasonsview
import "git.haelnorr.com/h/oslstats/internal/db" import "git.haelnorr.com/h/oslstats/internal/db"
import "time"
// StatusBadge renders a season status badge // StatusBadge renders a season status badge
// Parameters: // Parameters:
@@ -10,53 +9,34 @@ import "time"
// - useShortLabels: bool - true for "Active/Finals", false for "In Progress/Finals in Progress" // - useShortLabels: bool - true for "Active/Finals", false for "In Progress/Finals in Progress"
templ StatusBadge(season *db.Season, compact bool, useShortLabels bool) { templ StatusBadge(season *db.Season, compact bool, useShortLabels bool) {
{{ {{
now := time.Now() seasonStatus := season.GetStatus()
status := "" status := ""
statusBg := "" statusBg := ""
// Determine status based on dates switch seasonStatus {
if now.Before(season.StartDate) { case db.StatusUpcoming:
status = "Upcoming" status = "Upcoming"
statusBg = "bg-blue" statusBg = "bg-blue"
} else if !season.FinalsStartDate.IsZero() { case db.StatusInProgress:
// 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
if useShortLabels { if useShortLabels {
status = "Active" status = "Active"
} else { } else {
status = "In Progress" status = "In Progress"
} }
statusBg = "bg-green" 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"
} }
}} }}
<span class={ "inline-block px-3 py-1 rounded-full text-sm font-semibold text-mantle " + statusBg }> <span class={ "inline-block px-3 py-1 rounded-full text-sm font-semibold text-mantle " + statusBg }>