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
*.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

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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;

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 {
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
@@ -45,24 +55,26 @@ templ FinalsSetupForm(
<!-- Date Fields -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
@datepicker.DatePicker(
@datepicker.DatePickerWithDefault(
"regular_season_end_date",
"regular_season_end_date",
"Regular Season End Date",
"DD/MM/YYYY",
true,
"",
endDateDefault,
)
<p class="text-xs text-subtext0 mt-1">Games after this date will be forfeited</p>
<p class="text-xs text-subtext0 mt-1">Last day of the regular season (inclusive)</p>
</div>
<div>
@datepicker.DatePicker(
@datepicker.DatePickerWithDefault(
"finals_start_date",
"finals_start_date",
"Finals Start Date",
"DD/MM/YYYY",
true,
"",
finalsStartDefault,
)
<p class="text-xs text-subtext0 mt-1">First playoff matches begin on this date</p>
</div>

View File

@@ -117,24 +117,23 @@ templ SeasonsList(seasons *db.List[db.Season]) {
}
<!-- Date Info -->
{{
now := time.Now()
listStatus := s.GetStatus()
}}
<div class="text-xs text-subtext1 mt-auto">
if now.Before(s.StartDate) {
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() && now.After(s.EndDate.Time) {
// No finals scheduled and regular season ended
} else if !s.EndDate.IsZero() {
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) }
}
</div>

View File

@@ -22,42 +22,181 @@ templ PlayoffBracketView(season *db.Season, league *db.League, bracket *db.Playo
@playoffStatusBadge(bracket.Status)
</div>
</div>
<!-- Bracket Series List -->
<div class="space-y-4">
@bracketRounds(season, league, bracket)
<!-- Bracket Display -->
switch bracket.Format {
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>
}
// 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 {
<div class="space-y-3">
<h3 class="text-sm font-semibold text-subtext0 uppercase tracking-wider">
{ formatRoundName(roundName) }
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
for _, s := range series {
@seriesCard(season, league, s)
}
<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="grid grid-cols-2 gap-4">
@seriesCard(season, league, s[5])
@seriesCard(season, league, s[6])
</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) {
<div class={ "bg-surface0 border rounded-lg overflow-hidden",
<div
data-series={ fmt.Sprint(series.SeriesNumber) }
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) }>
templ.KV("border-surface1", series.Status != db.SeriesStatusInProgress) }
>
<!-- 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">
<span class="text-xs font-semibold text-subtext0">{ series.Label }</span>
@seriesFormatBadge(series.MatchesToWin)
@@ -73,7 +212,7 @@ templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries)
</div>
<!-- Series Score -->
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) }
</div>
}
@@ -88,27 +227,29 @@ templ seriesTeamRow(season *db.Season, league *db.League, team *db.Team, seed *i
}
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) }>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 min-w-0">
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) }
</span>
} 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 {
<span class="text-sm text-subtext1 italic">TBD</span>
} else {
<div class="truncate">
@links.TeamLinkInSeason(team, season, league)
</div>
if isWinner {
<span class="text-green text-xs">✓</span>
<span class="text-green text-xs flex-shrink-0">✓</span>
}
}
</div>
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-green font-bold", isWinner) }>
{ fmt.Sprint(wins) }

View File

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

View File

@@ -13,33 +13,31 @@ 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)
}}
<div id="finals-content">
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()
}
</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="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">
<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>
</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">
Finals will be available once the regular season is complete.
Set up the playoff bracket for this league.
</p>
if canManagePlayoffs {
<button
hx-get={ fmt.Sprintf("/seasons/%s/leagues/%s/finals/setup", season.ShortName, league.ShortName) }
hx-target="#finals-content"
@@ -48,7 +46,6 @@ templ finalsRegularSeasonInProgress(season *db.Season, league *db.League, canMan
>
Begin Finals
</button>
}
</div>
}

View File

@@ -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
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"
} 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
case db.StatusCompleted:
status = "Completed"
statusBg = "bg-teal"
} else {
// Regular season active, no finals scheduled
if useShortLabels {
status = "Active"
} else {
status = "In Progress"
}
statusBg = "bg-green"
}
}}
<span class={ "inline-block px-3 py-1 rounded-full text-sm font-semibold text-mantle " + statusBg }>