playoff visual fixes
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
152
internal/embedfs/web/js/bracket-lines.js
Normal file
152
internal/embedfs/web/js/bracket-lines.js
Normal 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;
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }>
|
||||
|
||||
Reference in New Issue
Block a user