From 1309ab2ece5e0e6b8da1d4a92e01f7f0f7e1b934 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Tue, 21 Apr 2026 21:21:45 -0700 Subject: [PATCH] Finish bracket page. --- app/components/scoring/BracketTreeView.tsx | 134 ++++++--- app/components/scoring/PlayoffBracket.tsx | 255 ++++++++-------- app/components/scoring/SportSeasonDisplay.tsx | 3 + .../scoring/TabbedBracketLayout.tsx | 276 ++++++++++++------ .../sport-season/RegularSeasonStandings.tsx | 26 +- app/lib/bracket-templates.ts | 5 +- ...eagueId.sports-seasons.$sportsSeasonId.tsx | 222 +++++++------- 7 files changed, 540 insertions(+), 381 deletions(-) diff --git a/app/components/scoring/BracketTreeView.tsx b/app/components/scoring/BracketTreeView.tsx index bdb74d0..2e75de3 100644 --- a/app/components/scoring/BracketTreeView.tsx +++ b/app/components/scoring/BracketTreeView.tsx @@ -91,24 +91,35 @@ function ParticipantRow({ > {showText && (
- {/* Electric dot for user's pick (when not winner) */} - {isOwned && !isWinner && showText && ( - - )} + {/* Manager avatar — always present for alignment; empty box when unowned */} +
+ {ownership && + ownership.teamName + .split(/\s+/) + .slice(0, 2) + .map((w) => w[0]?.toUpperCase() ?? "") + .join("")} +
- {/* Owner info row */} + {/* Owner name below participant name */} {showOwner && !isTbd && ownership && ( -
-
- {ownership.teamName - .split(/\s+/) - .slice(0, 2) - .map((w) => w[0]?.toUpperCase() ?? "") - .join("")} -
- - {ownership.ownerName ?? ownership.teamName} - -
+ + {ownership.ownerName ?? ownership.teamName} + )}
@@ -169,7 +163,7 @@ interface BracketMatchSlotProps { userParticipantIds: Set; } -function BracketMatchSlot({ +export function BracketMatchSlot({ match, slotHeight, ownershipMap, @@ -229,7 +223,7 @@ function BracketMatchSlot({ isWinner={p1IsWinner} isLoser={p1IsLoser} isOwned={p1IsOwned} - ownership={showOwner ? p1Ownership : null} + ownership={p1Ownership} score={match.participant1Score} rowHeight={rowHeight - INSET} showScore={showScore} @@ -242,7 +236,7 @@ function BracketMatchSlot({ isWinner={p2IsWinner} isLoser={p2IsLoser} isOwned={p2IsOwned} - ownership={showOwner ? p2Ownership : null} + ownership={p2Ownership} score={match.participant2Score} rowHeight={rowHeight - INSET} showScore={showScore} @@ -318,7 +312,7 @@ function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: Connect }} > {paths.map((d) => ( - + ))}
@@ -412,6 +406,7 @@ interface BracketTreeViewProps { matchesByRound: Map; ownershipMap: Map; userParticipantIds: Set; + thirdPlaceRound?: string; } export function BracketTreeView({ @@ -419,10 +414,14 @@ export function BracketTreeView({ matchesByRound, ownershipMap, userParticipantIds, + thirdPlaceRound, }: BracketTreeViewProps) { - const maxMatches = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1); + const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds; + const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined; + + const maxMatches = Math.max(...mainRounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1); const bracketHeight = maxMatches * (DESIRED_CARD_HEIGHT + CARD_GAP); - const minWidth = rounds.length * COLUMN_WIDTH + Math.max(0, rounds.length - 1) * CONNECTOR_WIDTH; + const minWidth = mainRounds.length * COLUMN_WIDTH + Math.max(0, mainRounds.length - 1) * CONNECTOR_WIDTH; return (
+ {thirdPlaceMatch && ( +
+
+
+
+ 3rd Place +
+
+ +
+
+
+ )}
); @@ -451,6 +471,7 @@ interface BracketTreePaginatedProps { userParticipantIds: Set; /** Index of the first scoring round — default page starts here */ firstScoringRoundIdx?: number; + thirdPlaceRound?: string; } interface AnimState { @@ -466,14 +487,18 @@ export function BracketTreePaginated({ ownershipMap, userParticipantIds, firstScoringRoundIdx, + thirdPlaceRound, }: BracketTreePaginatedProps) { + const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds; + const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined; + const defaultPage = Math.max( 0, Math.min( firstScoringRoundIdx !== undefined ? Math.max(0, firstScoringRoundIdx - 1) - : rounds.length - 2, - rounds.length - 2, + : mainRounds.length - 2, + mainRounds.length - 2, ), ); const [page, setPage] = useState(defaultPage); @@ -500,7 +525,7 @@ export function BracketTreePaginated({ }, [anim]); const navigate = (newPage: number) => { - if (anim || newPage < 0 || newPage > rounds.length - 2) return; + if (anim || newPage < 0 || newPage > mainRounds.length - 2) return; setAnim({ fromPage: page, toPage: newPage, dir: newPage > page ? "right" : "left", phase: "sliding" }); }; @@ -515,11 +540,11 @@ export function BracketTreePaginated({ }; const targetPage = anim ? anim.toPage : page; - const labelRounds = rounds.slice(targetPage, targetPage + 2); + const labelRounds = mainRounds.slice(targetPage, targetPage + 2); const label = labelRounds[1] ? `${labelRounds[0]} → ${labelRounds[1]}` : labelRounds[0]; const calcHeight = (p: number) => { - const rs = rounds.slice(p, p + 2); + const rs = mainRounds.slice(p, p + 2); const max = Math.max(...rs.map((r) => matchesByRound.get(r)?.length ?? 0), 1); return max * (DESIRED_CARD_HEIGHT + CARD_GAP); }; @@ -528,9 +553,9 @@ export function BracketTreePaginated({ const animFromHeight = anim ? calcHeight(anim.fromPage) : pageHeight; const animToHeight = anim ? calcHeight(anim.toPage) : pageHeight; - const visibleRounds = rounds.slice(page, page + 2); - const fromRounds = anim ? rounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds; - const toRounds = anim ? rounds.slice(anim.toPage, anim.toPage + 2) : visibleRounds; + const visibleRounds = mainRounds.slice(page, page + 2); + const fromRounds = anim ? mainRounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds; + const toRounds = anim ? mainRounds.slice(anim.toPage, anim.toPage + 2) : visibleRounds; // Sliding: show from/to slots side-by-side at their respective heights, no transitions // Settling: left slot shows toRounds at animToHeight with CSS transitions so cards animate smoothly @@ -578,7 +603,7 @@ export function BracketTreePaginated({ variant="ghost" size="icon" onClick={() => navigate(page + 1)} - disabled={page + 2 >= rounds.length || !!anim} + disabled={page + 2 >= mainRounds.length || !!anim} className="h-7 w-7 shrink-0" aria-label="Next rounds" > @@ -619,6 +644,23 @@ export function BracketTreePaginated({ )}
+ + {thirdPlaceMatch && ( +
+
+ 3rd Place +
+ +
+ )} ); } diff --git a/app/components/scoring/PlayoffBracket.tsx b/app/components/scoring/PlayoffBracket.tsx index 9281ed0..8dc1767 100644 --- a/app/components/scoring/PlayoffBracket.tsx +++ b/app/components/scoring/PlayoffBracket.tsx @@ -9,6 +9,8 @@ import { } from "~/components/ui/table"; import { Trophy, Star } from "lucide-react"; import { TeamOwnerBadge } from "~/components/ui/team-owner-badge"; +import { GradientIcon } from "~/components/ui/GradientIcon"; +import { TeamAvatar } from "~/components/TeamAvatar"; import { BracketTreeView, BracketTreePaginated, @@ -61,6 +63,8 @@ interface PlayoffBracketProps { showOwnership?: boolean; title?: string; description?: string; + /** "bracket" = full bracket + tables (default); "rankings" = final rankings table only */ + mode?: "bracket" | "rankings"; } /** Group matches by round, sorted by matchNumber, in one pass. */ @@ -182,6 +186,17 @@ function firstScoringRoundIdx(matchesByRound: Map, rounds: stri return 0; } +const RANKING_ROW_CLASSES: Record = { + 1: "bg-yellow-500/10", + 2: "bg-white/[0.14]", + 3: "bg-orange-600/[0.08]", +}; + +function rankingRowCls(currentRank: number | undefined): string { + const podium = currentRank !== undefined ? RANKING_ROW_CLASSES[currentRank] : undefined; + return `flex flex-col sm:flex-row sm:items-center gap-0 rounded-lg px-3 py-3 sm:px-5 sm:py-4 transition-colors ${podium ?? "bg-white/[0.04]"}`; +} + export function PlayoffBracket({ matches, rounds, @@ -194,6 +209,7 @@ export function PlayoffBracket({ showOwnership = true, title = "Playoff Bracket", description, + mode = "bracket", }: PlayoffBracketProps) { const userParticipantSet = new Set(userParticipantIds); const ownershipMap = new Map(); @@ -204,6 +220,10 @@ export function PlayoffBracket({ const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds); const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined; + const thirdPlaceRound = template?.rounds + .find((r) => template.rounds.some((other) => other.loserFeedsInto === r.name)) + ?.name; + // Build elimination rankings const losersByRound = new Map>(); let bracketWinner: Participant | null = null; @@ -296,7 +316,7 @@ export function PlayoffBracket({ ) : (
- {template?.phases ? ( + {mode === "bracket" && (template?.phases ? ( } @@ -324,6 +344,7 @@ export function PlayoffBracket({ matchesByRound={matchesByRound as Map} ownershipMap={ownershipMap as Map} userParticipantIds={userParticipantSet} + thirdPlaceRound={thirdPlaceRound} />
@@ -335,13 +356,14 @@ export function PlayoffBracket({ ownershipMap={ownershipMap as Map} userParticipantIds={userParticipantSet} firstScoringRoundIdx={scoringRoundIdx} + thirdPlaceRound={thirdPlaceRound} /> - )} + ))} {/* ── In Contention ── */} - {activeParticipants.length > 0 && ( + {mode === "bracket" && activeParticipants.length > 0 && ( @@ -402,133 +424,126 @@ export function PlayoffBracket({ )} {/* ── Final Rankings ── */} - {showRankings && ( - - - - - {bracketWinner ? "Final Rankings" : "Eliminated Teams"} - + {mode === "rankings" && showRankings && ( + + +
+ +

+ {bracketWinner ? "Final Rankings" : "Eliminated Teams"} +

+
- - - - - Rank - Participant - {showOwnership && ( - Drafted By - )} - {pointsMap.size > 0 && ( - Pts - )} - - - - {bracketWinner && ( - - - - - #1 - - - - {bracketWinner.name} - {winnerIsOwned && ( - + +
+ {bracketWinner && ( +
+
+ +
+

+ {bracketWinner.name} + {winnerIsOwned && } +

+ {winnerOwnership?.ownerName && ( +

{winnerOwnership.ownerName}

)} - - {showOwnership && ( - - {winnerOwnership ? ( - - ) : ( - - - )} - - )} +
+
+
+
+

Rank

+ 1 +
{pointsMap.size > 0 && ( - - {winnerPts ?? "—"} - + <> +
+
+

Points

+ {winnerPts ?? 0} +
+ )} - - )} +
+
+ )} - {rankedEntries.filter((entry) => isDraftedOrScoring(entry.participant.id)).map((entry) => { - const isOwned = userParticipantSet.has(entry.participant.id); - const pts = pointsMap.get(entry.participant.id); - return ( - - - {entry.rankLabel} - - - {entry.participant.name} - {isOwned && ( - + {rankedEntries.filter((entry) => isDraftedOrScoring(entry.participant.id)).map((entry) => { + const isOwned = userParticipantSet.has(entry.participant.id); + const pts = pointsMap.get(entry.participant.id); + const numRank = parseInt(entry.rankLabel.replace("T", ""), 10); + return ( +
+
+ +
+

+ {entry.participant.name} + {isOwned && } +

+ {entry.ownership?.ownerName && ( +

{entry.ownership.ownerName}

)} - - {showOwnership && ( - - {entry.ownership ? ( - - ) : ( - - - )} - - )} +
+
+
+
+

Rank

+ {entry.rankLabel} +
{pointsMap.size > 0 && ( - - {pts ?? "—"} - + <> +
+
+

Points

+ {pts ?? 0} +
+ )} - - ); - })} +
+
+ ); + })} - {filteredPreEliminated.filter((p) => isDraftedOrScoring(p.id)).map((p) => { - const isOwned = userParticipantSet.has(p.id); - const ownership = showOwnership ? ownershipMap.get(p.id) || null : null; - const pts = pointsMap.get(p.id); - return ( - - - - {p.name} - {isOwned && ( - + {filteredPreEliminated.filter((p) => isDraftedOrScoring(p.id)).map((p) => { + const isOwned = userParticipantSet.has(p.id); + const ownership = ownershipMap.get(p.id); + const pts = pointsMap.get(p.id); + return ( +
+
+ +
+

+ {p.name} + {isOwned && } +

+ {ownership?.ownerName && ( +

{ownership.ownerName}

)} - - {showOwnership && ( - - {ownership ? ( - - ) : ( - - - )} - - )} - {pointsMap.size > 0 && ( - - {pts ?? "—"} - - )} - - ); - })} - -
+ + + {pointsMap.size > 0 && ( +
+
+

Points

+ {pts ?? 0} +
+
+ )} + + ); + })} +
)} diff --git a/app/components/scoring/SportSeasonDisplay.tsx b/app/components/scoring/SportSeasonDisplay.tsx index 44b71e7..490194c 100644 --- a/app/components/scoring/SportSeasonDisplay.tsx +++ b/app/components/scoring/SportSeasonDisplay.tsx @@ -92,6 +92,7 @@ interface SportSeasonDisplayProps { scoringPattern: ScoringPattern; sportSeasonName: string; sportName: string; + bracketMode?: "bracket" | "rankings"; // Playoff data playoffMatches?: Match[]; @@ -123,6 +124,7 @@ export function SportSeasonDisplay({ scoringPattern, sportSeasonName, sportName: _sportName, + bracketMode = "bracket", playoffMatches = [], playoffRounds = [], bracketTemplateId = null, @@ -157,6 +159,7 @@ export function SportSeasonDisplay({ userParticipantIds={userParticipantIds} showOwnership={showOwnership} title="Playoff Bracket" + mode={bracketMode} /> ); diff --git a/app/components/scoring/TabbedBracketLayout.tsx b/app/components/scoring/TabbedBracketLayout.tsx index 211ca8c..45440f0 100644 --- a/app/components/scoring/TabbedBracketLayout.tsx +++ b/app/components/scoring/TabbedBracketLayout.tsx @@ -1,9 +1,9 @@ -import { useState } from "react"; import { cn } from "~/lib/utils"; import type { BracketPhase, ConferenceGroup } from "~/lib/bracket-templates"; import { TreeColumns, BracketTreePaginated, + BracketMatchSlot, type BracketMatch, type BracketOwnership, } from "./BracketTreeView"; @@ -39,6 +39,106 @@ function phaseHeight(matchesByRound: Map, rounds: string return max * (CARD_H + CARD_GAP); } +// ─── Play-In Layout ─────────────────────────────────────────────────────────── + +interface PlayInColumnProps { + label: string; + description: string; + match: BracketMatch | undefined; + ownershipMap: Map; + userParticipantIds: Set; +} + +function PlayInColumn({ + label, + description, + match, + ownershipMap, + userParticipantIds, +}: PlayInColumnProps) { + return ( +
+

+ {label} +

+ {match ? ( + + ) : ( +
+ )} +

{description}

+
+ ); +} + +interface PlayInLayoutProps { + matchesByRound: Map; + ownershipMap: Map; + userParticipantIds: Set; +} + +function PlayInLayout({ matchesByRound, ownershipMap, userParticipantIds }: PlayInLayoutProps) { + const pir1 = matchesByRound.get("Play-In Round 1") ?? []; + const pir2 = matchesByRound.get("Play-In Round 2") ?? []; + + const conferences = [ + { + name: "Eastern Conference", + match78: pir1.find((m) => m.matchNumber === 1), + match910: pir1.find((m) => m.matchNumber === 2), + matchFor8: pir2.find((m) => m.matchNumber === 1), + }, + { + name: "Western Conference", + match78: pir1.find((m) => m.matchNumber === 3), + match910: pir1.find((m) => m.matchNumber === 4), + matchFor8: pir2.find((m) => m.matchNumber === 2), + }, + ]; + + return ( +
+ {conferences.map((conf) => ( +
+

+ {conf.name} +

+
+ + + +
+
+ ))} +
+ ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + export function TabbedBracketLayout({ rounds, matchesByRound, @@ -47,94 +147,100 @@ export function TabbedBracketLayout({ phases, scoringRoundIdx, }: TabbedBracketLayoutProps) { - const [activeIdx, setActiveIdx] = useState(0); - const phase = phases[activeIdx]; - - const groupRounds = phase.groups - ? rounds.filter((r) => phase.groups?.some((g) => g.roundMatchNumbers[r] !== undefined)) - : []; - const sharedRounds = (phase.sharedRounds ?? []).filter((r) => rounds.includes(r)); - const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r)); - - const phaseRounds = phase.groups ? [...groupRounds, ...sharedRounds] : simpleRounds; - const phaseMatchesByRound = new Map(phaseRounds.map((r) => [r, matchesByRound.get(r) ?? []])); - const sharedMatchesByRound = new Map(sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []])); - - const phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx); - return ( -
-
- {phases.map((p, i) => ( - - ))} -
+
+ {phases.map((phase) => { + const groupRounds = phase.groups + ? rounds.filter((r) => phase.groups?.some((g) => g.roundMatchNumbers[r] !== undefined)) + : []; + const sharedRounds = (phase.sharedRounds ?? []).filter((r) => rounds.includes(r)); + const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r)); - {/* Desktop */} -
- {phase.groups ? ( -
- {phase.groups.map((group) => { - const gMatches = groupMatches(matchesByRound, group); - const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined); - return ( -
-

- {group.name} -

- + const phaseRounds = phase.groups ? [...groupRounds, ...sharedRounds] : simpleRounds; + const phaseMatchesByRound = new Map(phaseRounds.map((r) => [r, matchesByRound.get(r) ?? []])); + const sharedMatchesByRound = new Map(sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []])); + + const phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx); + + return ( +
+

1 ? "text-muted-foreground" : "hidden" + )}> + {phase.name} +

+ + {/* Desktop */} +
+ {phase.layout === "play-in" ? ( + + ) : phase.groups ? ( +
+ {phase.groups.map((group) => { + const gMatches = groupMatches(matchesByRound, group); + const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined); + return ( +
+

+ {group.name} +

+ +
+ ); + })} + {sharedRounds.length > 0 && ( + + )}
- ); - })} - {sharedRounds.length > 0 && ( - - )} -
- ) : ( - - )} -
+ ) : ( + + )} +
- {/* Mobile */} -
- = 0 ? phaseFirstScoringIdx : undefined} - /> -
+ {/* Mobile */} +
+ {phase.layout === "play-in" ? ( + + ) : ( + = 0 ? phaseFirstScoringIdx : undefined} + /> + )} +
+
+ ); + })}
); } diff --git a/app/components/sport-season/RegularSeasonStandings.tsx b/app/components/sport-season/RegularSeasonStandings.tsx index c941a85..202c136 100644 --- a/app/components/sport-season/RegularSeasonStandings.tsx +++ b/app/components/sport-season/RegularSeasonStandings.tsx @@ -41,7 +41,6 @@ interface Props { teamOwnerships: Record; userParticipantIds: string[]; showOtLosses?: boolean; - participantEvs?: Record; /** How many teams per conference qualify for playoffs (flat mode only). 0 = no line. */ playoffSpots?: number; /** "flat" = single ranked list per conference (NBA). "nhl-divisions" = div top-3 + wild card. "mlb-divisions" = same structure with 3 WC spots, uses "League" label. */ @@ -223,19 +222,15 @@ function StandingsTable({ teamOwnerships, userParticipantIds, showOtLosses, - participantEvs, - hasEvs, }: { sections: TableSection[]; teamOwnerships: Record; userParticipantIds: string[]; showOtLosses: boolean; - participantEvs: Record; - hasEvs: boolean; }) { - // # Team GP W L [OTL] [PTS] PCT GB L10 STK Mgr [PROJ] + // # Team GP W L [OTL] [PTS] PCT GB L10 STK Mgr // OTL and PTS are both shown for hockey (showOtLosses = true) - const totalCols = 10 + (showOtLosses ? 2 : 0) + (hasEvs ? 1 : 0); + const totalCols = 10 + (showOtLosses ? 2 : 0); return (
@@ -254,7 +249,6 @@ function StandingsTable({ L10 STK Mgr - {hasEvs && PROJ} @@ -299,7 +293,6 @@ function StandingsTable({ const isUserTeam = userParticipantIds.includes(row.participantId); const ownership = teamOwnerships[row.participantId]; - const ev = participantEvs[row.participantId]; sectionRows.push( — )} - {hasEvs && ( - - {ev !== null ? ( - - {parseFloat(ev).toFixed(1)} - - ) : ( - - )} - - )} ); }); @@ -399,13 +381,11 @@ export function RegularSeasonStandings({ teamOwnerships, userParticipantIds, showOtLosses = false, - participantEvs = {}, playoffSpots = 8, displayMode = "flat", }: Props) { if (standings.length === 0) return null; - const hasEvs = Object.keys(participantEvs).length > 0; const lastSyncedAt = standings .map((s) => s.syncedAt) .filter(Boolean) @@ -419,7 +399,7 @@ export function RegularSeasonStandings({ ? buildMlbSections(standings) : buildFlatSections(standings, playoffSpots); - const tableProps = { teamOwnerships, userParticipantIds, showOtLosses, participantEvs, hasEvs }; + const tableProps = { teamOwnerships, userParticipantIds, showOtLosses }; return ( diff --git a/app/lib/bracket-templates.ts b/app/lib/bracket-templates.ts index 889af49..eba2be8 100644 --- a/app/lib/bracket-templates.ts +++ b/app/lib/bracket-templates.ts @@ -88,6 +88,8 @@ export interface BracketPhase { groups?: ConferenceGroup[]; /** Rounds rendered after groups (e.g. Final Four, Championship) */ sharedRounds?: string[]; + /** Custom rendering layout for special phases */ + layout?: "play-in"; } export interface BracketTemplate { @@ -420,7 +422,7 @@ export const DARTS_128: BracketTemplate = { * First Four (play-in) → Round of 64 → Round of 32 → Sweet Sixteen → Elite Eight → Final Four → Championship * Only Elite Eight and beyond score points per Q18 * - * 2026 region config: + * Region config (year-specific — update each March when the First Four matchups are announced): * East — 16 direct seeds, no play-ins * South — 15 direct seeds, 16-seed play-in * West — 15 direct seeds, 11-seed play-in @@ -860,6 +862,7 @@ export const NBA_20: BracketTemplate = { { name: "Play-In", rounds: ["Play-In Round 1", "Play-In Round 2"], + layout: "play-in" as const, }, { name: "Playoffs", diff --git a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx index 2dad264..0ee8ce7 100644 --- a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx +++ b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx @@ -1,4 +1,5 @@ import { Link } from "react-router"; +import { useState } from "react"; import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId"; import { loader } from "./$leagueId.sports-seasons.$sportsSeasonId.server"; @@ -7,8 +8,8 @@ import { EventSchedule } from "~/components/sport-season/EventSchedule"; import { RegularSeasonStandings } from "~/components/sport-season/RegularSeasonStandings"; import { GroupStageStandings } from "~/components/sport-season/GroupStageStandings"; import { Button } from "~/components/ui/button"; -import { Badge } from "~/components/ui/badge"; -import { ArrowLeft, CheckCircle2, Clock, Zap } from "lucide-react"; +import { cn } from "~/lib/utils"; +import { ArrowLeft } from "lucide-react"; export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { return [{ title: `${data?.sportsSeason?.name ?? "Sport Season"} — ${data?.league?.name ?? "League"} - Brackt` }]; @@ -16,33 +17,7 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { export { loader }; -function getStatusBadge(status: string) { - switch (status) { - case "active": - return ( - - - Active - - ); - case "upcoming": - return ( - - - Upcoming - - ); - case "completed": - return ( - - - Completed - - ); - default: - return null; - } -} +type BracketView = "standings" | "playoffs" | "finished"; export default function SportSeasonDetail({ loaderData, @@ -65,7 +40,7 @@ export default function SportSeasonDetail({ recentEvents, seasonIsFinalized, regularSeasonStandings, - participantEvs, + groupStandings, bracketTemplateId, } = loaderData; @@ -79,14 +54,9 @@ export default function SportSeasonDetail({ simulatorType === "nhl_bracket" ? "nhl-divisions" : simulatorType === "mlb_bracket" ? "mlb-divisions" : "flat"; - // NBA: 10 teams make the postseason (6 auto-qualify + 4 play-in) - // AFL: top 10 advance to the finals series (Wildcard + QF/EF + SF + PF + GF) - // NHL: handled by nhl-divisions mode (3 per div + 2 wild cards) - // MLB: handled by mlb-divisions mode (1 per div + 3 wild cards) const playoffSpots = simulatorType === "nba_bracket" || simulatorType === "afl_bracket" ? 10 : 8; - // Build ownership map for RegularSeasonStandings const ownershipMap = Object.fromEntries( teamOwnerships.map((o) => [ o.participantId, @@ -94,6 +64,62 @@ export default function SportSeasonDetail({ ]) ); + // Show the 3-way toggle only for bracket sports that also have regular season standings + const showToggle = hasStandings && scoringPattern === "playoff_bracket"; + + const [view, setView] = useState(() => { + if (!hasBracket) return "standings"; + if (sportsSeason.status === "completed") return "finished"; + return "playoffs"; + }); + + const TOGGLE_VIEWS: { value: BracketView; label: string }[] = [ + { value: "standings", label: "Standings" }, + { value: "playoffs", label: "Playoffs" }, + { value: "finished", label: "Finished" }, + ]; + + const bracketDisplay = (bracketMode: "bracket" | "rankings") => ( + = + (sportsSeason.totalMajors || 0) && + !sportsSeason.qualifyingPointsFinalized + } + teamOwnerships={teamOwnerships} + userParticipantIds={userParticipantIds} + scoringRules={season} + showOwnership={true} + /> + ); + + const standingsDisplay = ( + + ); + return (
@@ -104,7 +130,7 @@ export default function SportSeasonDetail({ -
+

{sportsSeason.sport.name} @@ -113,84 +139,68 @@ export default function SportSeasonDetail({ {sportsSeason.name} • {league.name} • {season.year} Season

- {getStatusBadge(sportsSeason.status)} + + {showToggle && ( +
+ {TOGGLE_VIEWS.map(({ value, label }) => ( + + ))} +
+ )}
- {/* Regular season standings — show above bracket when no bracket exists yet */} - {hasStandings && !hasBracket && ( -
- + {showToggle ? ( +
+ {view === "standings" && standingsDisplay} + {view === "playoffs" && bracketDisplay("bracket")} + {view === "finished" && bracketDisplay("rankings")}
- )} + ) : ( + <> + {/* Regular season standings above bracket when no bracket exists yet */} + {hasStandings && !hasBracket && ( +
{standingsDisplay}
+ )} - {/* Event schedule - hidden for bracket sports (the bracket itself is the event) */} - {scoringPattern !== "playoff_bracket" && - (upcomingEvents.length > 0 || recentEvents.length > 0) && ( -
- -
- )} + {/* Event schedule — hidden for bracket sports */} + {scoringPattern !== "playoff_bracket" && + (upcomingEvents.length > 0 || recentEvents.length > 0) && ( +
+ +
+ )} - {/* Group stage standings — shown for FIFA-style tournaments before/during group phase */} - {groupStandings.length > 0 && ( -
- -
- )} + {/* Group stage standings */} + {groupStandings.length > 0 && ( +
+ +
+ )} - {/* Hide bracket display when standings exist but no bracket matches have been set yet */} - {!(scoringPattern === "playoff_bracket" && hasStandings && !hasBracket) && = - (sportsSeason.totalMajors || 0) && - !sportsSeason.qualifyingPointsFinalized - } - teamOwnerships={teamOwnerships} - userParticipantIds={userParticipantIds} - scoringRules={season} - showOwnership={true} - />} + {/* Bracket — hidden when standings exist but bracket is empty */} + {!(scoringPattern === "playoff_bracket" && hasStandings && !hasBracket) && bracketDisplay("bracket")} - {/* Regular season standings — show below bracket once bracket exists */} - {hasStandings && hasBracket && ( -
- -
+ {/* Regular season standings below bracket once bracket exists */} + {hasStandings && hasBracket && ( +
{standingsDisplay}
+ )} + )}
);