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

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

View File

@@ -115,29 +115,28 @@ templ SeasonsList(seasons *db.List[db.Season]) {
}
</div>
}
<!-- Date Info -->
{{
now := time.Now()
}}
<div class="text-xs text-subtext1 mt-auto">
if now.Before(s.StartDate) {
<!-- Date Info -->
{{
listStatus := s.GetStatus()
}}
<div class="text-xs text-subtext1 mt-auto">
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) }
}
</div>
}
</div>
</a>
}
</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",
templ.KV("border-blue/50", series.Status == db.SeriesStatusInProgress),
templ.KV("border-surface1", series.Status != db.SeriesStatusInProgress) }>
<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) }
>
<!-- 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 {
@links.TeamLinkInSeason(team, season, league)
<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,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)
}}
<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"
hx-swap="innerHTML"
class="px-6 py-3 bg-blue hover:bg-blue/80 text-mantle rounded-lg font-semibold transition hover:cursor-pointer"
>
Begin Finals
</button>
}
<button
hx-get={ fmt.Sprintf("/seasons/%s/leagues/%s/finals/setup", season.ShortName, league.ShortName) }
hx-target="#finals-content"
hx-swap="innerHTML"
class="px-6 py-3 bg-blue hover:bg-blue/80 text-mantle rounded-lg font-semibold transition hover:cursor-pointer"
>
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
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"
}
}}
<span class={ "inline-block px-3 py-1 rounded-full text-sm font-semibold text-mantle " + statusBg }>