Finish bracket page.

This commit is contained in:
Chris Parsons 2026-04-21 21:21:45 -07:00
parent 4d3b214ce5
commit 1309ab2ece
7 changed files with 540 additions and 381 deletions

View file

@ -91,24 +91,35 @@ function ParticipantRow({
> >
{showText && ( {showText && (
<div className={[ <div className={[
"flex items-center flex-1 min-w-0 gap-1 px-2 pl-[7px] ml-2", "flex items-center flex-1 min-w-0 gap-1.5 px-2 pl-[7px] ml-2",
isWinner && isOwned ? "border border-electric/50 bg-electric/8 rounded-md mr-2 my-0.5 self-stretch py-1" :
isWinner ? "border border-white/15 bg-white/5 rounded-md mr-2 my-0.5 self-stretch py-1" : "", isWinner ? "border border-white/15 bg-white/5 rounded-md mr-2 my-0.5 self-stretch py-1" : "",
].filter(Boolean).join(" ")}> ].filter(Boolean).join(" ")}>
{/* Electric dot for user's pick (when not winner) */} {/* Manager avatar — always present for alignment; empty box when unowned */}
{isOwned && !isWinner && showText && ( <div
<span className="shrink-0 rounded-[3px] flex items-center justify-center text-[8px] font-bold"
className="shrink-0 rounded-full" style={{
style={{ width: 5, height: 5, background: "#2ce1c1" }} width: 18,
/> height: 18,
)} background: ownership ? "#000" : "transparent",
color: ownership ? avatarColor(ownership.teamName) : undefined,
}}
>
{ownership &&
ownership.teamName
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase() ?? "")
.join("")}
</div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<span <span
className={[ className={[
"text-[13px] leading-tight block truncate", "text-[13px] leading-tight block truncate",
isTbd ? "text-muted-foreground/50 italic" : "", isTbd ? "text-muted-foreground/50 italic" : "",
isLoser && isOwned ? "text-electric/50 line-through" :
isLoser ? "text-muted-foreground line-through" : "", isLoser ? "text-muted-foreground line-through" : "",
isOwned && !isLoser ? "text-electric font-medium" : "",
isWinner ? "font-semibold" : "", isWinner ? "font-semibold" : "",
] ]
.filter(Boolean) .filter(Boolean)
@ -117,28 +128,11 @@ function ParticipantRow({
{name ?? "TBD"} {name ?? "TBD"}
</span> </span>
{/* Owner info row */} {/* Owner name below participant name */}
{showOwner && !isTbd && ownership && ( {showOwner && !isTbd && ownership && (
<div className="flex items-center gap-1 mt-0.5"> <span className="text-[10px] text-muted-foreground truncate block leading-none">
<div {ownership.ownerName ?? ownership.teamName}
className="shrink-0 rounded-[2px] flex items-center justify-center text-[8px] font-bold" </span>
style={{
width: 14,
height: 14,
background: "#000",
color: avatarColor(ownership.teamName),
}}
>
{ownership.teamName
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase() ?? "")
.join("")}
</div>
<span className="text-[10px] text-muted-foreground truncate">
{ownership.ownerName ?? ownership.teamName}
</span>
</div>
)} )}
</div> </div>
@ -169,7 +163,7 @@ interface BracketMatchSlotProps {
userParticipantIds: Set<string>; userParticipantIds: Set<string>;
} }
function BracketMatchSlot({ export function BracketMatchSlot({
match, match,
slotHeight, slotHeight,
ownershipMap, ownershipMap,
@ -229,7 +223,7 @@ function BracketMatchSlot({
isWinner={p1IsWinner} isWinner={p1IsWinner}
isLoser={p1IsLoser} isLoser={p1IsLoser}
isOwned={p1IsOwned} isOwned={p1IsOwned}
ownership={showOwner ? p1Ownership : null} ownership={p1Ownership}
score={match.participant1Score} score={match.participant1Score}
rowHeight={rowHeight - INSET} rowHeight={rowHeight - INSET}
showScore={showScore} showScore={showScore}
@ -242,7 +236,7 @@ function BracketMatchSlot({
isWinner={p2IsWinner} isWinner={p2IsWinner}
isLoser={p2IsLoser} isLoser={p2IsLoser}
isOwned={p2IsOwned} isOwned={p2IsOwned}
ownership={showOwner ? p2Ownership : null} ownership={p2Ownership}
score={match.participant2Score} score={match.participant2Score}
rowHeight={rowHeight - INSET} rowHeight={rowHeight - INSET}
showScore={showScore} showScore={showScore}
@ -318,7 +312,7 @@ function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: Connect
}} }}
> >
{paths.map((d) => ( {paths.map((d) => (
<path key={d} d={d} fill="none" stroke="hsl(var(--border))" strokeWidth={1} /> <path key={d} d={d} fill="none" stroke="rgb(255 255 255 / 22%)" strokeWidth={1.5} />
))} ))}
</svg> </svg>
</div> </div>
@ -412,6 +406,7 @@ interface BracketTreeViewProps {
matchesByRound: Map<string, BracketMatch[]>; matchesByRound: Map<string, BracketMatch[]>;
ownershipMap: Map<string, BracketOwnership>; ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>; userParticipantIds: Set<string>;
thirdPlaceRound?: string;
} }
export function BracketTreeView({ export function BracketTreeView({
@ -419,10 +414,14 @@ export function BracketTreeView({
matchesByRound, matchesByRound,
ownershipMap, ownershipMap,
userParticipantIds, userParticipantIds,
thirdPlaceRound,
}: BracketTreeViewProps) { }: 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 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 ( return (
<div <div
@ -431,12 +430,33 @@ export function BracketTreeView({
> >
<div style={{ minWidth }}> <div style={{ minWidth }}>
<TreeColumns <TreeColumns
visibleRounds={rounds} visibleRounds={mainRounds}
matchesByRound={matchesByRound} matchesByRound={matchesByRound}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
bracketHeight={bracketHeight} bracketHeight={bracketHeight}
/> />
{thirdPlaceMatch && (
<div style={{ display: "flex", paddingTop: 20 }}>
<div style={{ flex: 1 }} />
<div style={{ minWidth: COLUMN_WIDTH }}>
<div
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground text-center"
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
>
3rd Place
</div>
<div style={{ height: Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT) }}>
<BracketMatchSlot
match={thirdPlaceMatch}
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
/>
</div>
</div>
</div>
)}
</div> </div>
</div> </div>
); );
@ -451,6 +471,7 @@ interface BracketTreePaginatedProps {
userParticipantIds: Set<string>; userParticipantIds: Set<string>;
/** Index of the first scoring round — default page starts here */ /** Index of the first scoring round — default page starts here */
firstScoringRoundIdx?: number; firstScoringRoundIdx?: number;
thirdPlaceRound?: string;
} }
interface AnimState { interface AnimState {
@ -466,14 +487,18 @@ export function BracketTreePaginated({
ownershipMap, ownershipMap,
userParticipantIds, userParticipantIds,
firstScoringRoundIdx, firstScoringRoundIdx,
thirdPlaceRound,
}: BracketTreePaginatedProps) { }: BracketTreePaginatedProps) {
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
const defaultPage = Math.max( const defaultPage = Math.max(
0, 0,
Math.min( Math.min(
firstScoringRoundIdx !== undefined firstScoringRoundIdx !== undefined
? Math.max(0, firstScoringRoundIdx - 1) ? Math.max(0, firstScoringRoundIdx - 1)
: rounds.length - 2, : mainRounds.length - 2,
rounds.length - 2, mainRounds.length - 2,
), ),
); );
const [page, setPage] = useState(defaultPage); const [page, setPage] = useState(defaultPage);
@ -500,7 +525,7 @@ export function BracketTreePaginated({
}, [anim]); }, [anim]);
const navigate = (newPage: number) => { 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" }); 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 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 label = labelRounds[1] ? `${labelRounds[0]}${labelRounds[1]}` : labelRounds[0];
const calcHeight = (p: number) => { 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); const max = Math.max(...rs.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
return max * (DESIRED_CARD_HEIGHT + CARD_GAP); return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
}; };
@ -528,9 +553,9 @@ export function BracketTreePaginated({
const animFromHeight = anim ? calcHeight(anim.fromPage) : pageHeight; const animFromHeight = anim ? calcHeight(anim.fromPage) : pageHeight;
const animToHeight = anim ? calcHeight(anim.toPage) : pageHeight; const animToHeight = anim ? calcHeight(anim.toPage) : pageHeight;
const visibleRounds = rounds.slice(page, page + 2); const visibleRounds = mainRounds.slice(page, page + 2);
const fromRounds = anim ? rounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds; const fromRounds = anim ? mainRounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds;
const toRounds = anim ? rounds.slice(anim.toPage, anim.toPage + 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 // 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 // Settling: left slot shows toRounds at animToHeight with CSS transitions so cards animate smoothly
@ -578,7 +603,7 @@ export function BracketTreePaginated({
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => navigate(page + 1)} onClick={() => navigate(page + 1)}
disabled={page + 2 >= rounds.length || !!anim} disabled={page + 2 >= mainRounds.length || !!anim}
className="h-7 w-7 shrink-0" className="h-7 w-7 shrink-0"
aria-label="Next rounds" aria-label="Next rounds"
> >
@ -619,6 +644,23 @@ export function BracketTreePaginated({
)} )}
</div> </div>
</div> </div>
{thirdPlaceMatch && (
<div style={{ marginTop: 20, width: SLOT_WIDTH }}>
<div
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground text-center"
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
>
3rd Place
</div>
<BracketMatchSlot
match={thirdPlaceMatch}
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
/>
</div>
)}
</div> </div>
); );
} }

View file

@ -9,6 +9,8 @@ import {
} from "~/components/ui/table"; } from "~/components/ui/table";
import { Trophy, Star } from "lucide-react"; import { Trophy, Star } from "lucide-react";
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge"; import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
import { GradientIcon } from "~/components/ui/GradientIcon";
import { TeamAvatar } from "~/components/TeamAvatar";
import { import {
BracketTreeView, BracketTreeView,
BracketTreePaginated, BracketTreePaginated,
@ -61,6 +63,8 @@ interface PlayoffBracketProps {
showOwnership?: boolean; showOwnership?: boolean;
title?: string; title?: string;
description?: 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. */ /** Group matches by round, sorted by matchNumber, in one pass. */
@ -182,6 +186,17 @@ function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: stri
return 0; return 0;
} }
const RANKING_ROW_CLASSES: Record<number, string> = {
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({ export function PlayoffBracket({
matches, matches,
rounds, rounds,
@ -194,6 +209,7 @@ export function PlayoffBracket({
showOwnership = true, showOwnership = true,
title = "Playoff Bracket", title = "Playoff Bracket",
description, description,
mode = "bracket",
}: PlayoffBracketProps) { }: PlayoffBracketProps) {
const userParticipantSet = new Set(userParticipantIds); const userParticipantSet = new Set(userParticipantIds);
const ownershipMap = new Map<string, TeamOwnership>(); const ownershipMap = new Map<string, TeamOwnership>();
@ -204,6 +220,10 @@ export function PlayoffBracket({
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds); const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined; const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
const thirdPlaceRound = template?.rounds
.find((r) => template.rounds.some((other) => other.loserFeedsInto === r.name))
?.name;
// Build elimination rankings // Build elimination rankings
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>(); const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
let bracketWinner: Participant | null = null; let bracketWinner: Participant | null = null;
@ -296,7 +316,7 @@ export function PlayoffBracket({
</div> </div>
) : ( ) : (
<div className="space-y-8"> <div className="space-y-8">
{template?.phases ? ( {mode === "bracket" && (template?.phases ? (
<TabbedBracketLayout <TabbedBracketLayout
rounds={rounds} rounds={rounds}
matchesByRound={matchesByRound as Map<string, BracketMatch[]>} matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
@ -324,6 +344,7 @@ export function PlayoffBracket({
matchesByRound={matchesByRound as Map<string, BracketMatch[]>} matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
ownershipMap={ownershipMap as Map<string, BracketOwnership>} ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet} userParticipantIds={userParticipantSet}
thirdPlaceRound={thirdPlaceRound}
/> />
</div> </div>
@ -335,13 +356,14 @@ export function PlayoffBracket({
ownershipMap={ownershipMap as Map<string, BracketOwnership>} ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet} userParticipantIds={userParticipantSet}
firstScoringRoundIdx={scoringRoundIdx} firstScoringRoundIdx={scoringRoundIdx}
thirdPlaceRound={thirdPlaceRound}
/> />
</div> </div>
</> </>
)} ))}
{/* ── In Contention ── */} {/* ── In Contention ── */}
{activeParticipants.length > 0 && ( {mode === "bracket" && activeParticipants.length > 0 && (
<Card className="border-green-500/30"> <Card className="border-green-500/30">
<CardHeader> <CardHeader>
<CardTitle className="text-green-600 dark:text-green-400 flex items-center gap-2"> <CardTitle className="text-green-600 dark:text-green-400 flex items-center gap-2">
@ -402,133 +424,126 @@ export function PlayoffBracket({
)} )}
{/* ── Final Rankings ── */} {/* ── Final Rankings ── */}
{showRankings && ( {mode === "rankings" && showRankings && (
<Card className="border-electric/30"> <Card className="gap-2">
<CardHeader> <CardHeader className="px-3 sm:px-6 pb-2">
<CardTitle className="text-electric flex items-center gap-2"> <div className="flex items-center gap-2">
<Trophy className="h-5 w-5" /> <GradientIcon icon={Trophy} className="h-5 w-5 shrink-0" />
{bracketWinner ? "Final Rankings" : "Eliminated Teams"} <h2 className="text-xl font-bold leading-none tracking-tight">
</CardTitle> {bracketWinner ? "Final Rankings" : "Eliminated Teams"}
</h2>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="px-3 sm:px-6">
<Table> <div className="space-y-3">
<TableHeader> {bracketWinner && (
<TableRow> <div className={rankingRowCls(1)}>
<TableHead className="w-20">Rank</TableHead> <div className="flex items-center gap-3 flex-1 min-w-0">
<TableHead>Participant</TableHead> <TeamAvatar
{showOwnership && ( teamId={winnerOwnership?.teamId ?? bracketWinner.id}
<TableHead className="w-40 pl-6">Drafted By</TableHead> teamName={winnerOwnership?.teamName ?? bracketWinner.name}
)} />
{pointsMap.size > 0 && ( <div className="min-w-0">
<TableHead className="text-right w-16">Pts</TableHead> <p className={`font-medium text-sm leading-tight truncate${winnerIsOwned ? " text-electric" : ""}`}>
)} {bracketWinner.name}
</TableRow> {winnerIsOwned && <Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />}
</TableHeader> </p>
<TableBody> {winnerOwnership?.ownerName && (
{bracketWinner && ( <p className="text-xs text-muted-foreground truncate">{winnerOwnership.ownerName}</p>
<TableRow className={winnerIsOwned ? "bg-electric/8 border-l-2 border-l-electric" : "bg-yellow-50/30 dark:bg-yellow-950/10"}>
<TableCell className="font-semibold text-yellow-600 dark:text-yellow-400">
<span className="flex items-center gap-1">
<Trophy className="h-3 w-3" />
#1
</span>
</TableCell>
<TableCell className="font-semibold">
{bracketWinner.name}
{winnerIsOwned && (
<Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />
)} )}
</TableCell> </div>
{showOwnership && ( </div>
<TableCell className="pl-6"> <div className="flex items-center gap-4 w-full border-t border-border/30 pt-2 mt-1 sm:w-auto sm:border-0 sm:pt-0 sm:mt-0 sm:shrink-0">
{winnerOwnership ? ( <div className="text-right shrink-0 flex-1 sm:flex-none">
<TeamOwnerBadge teamName={winnerOwnership.teamName} ownerName={winnerOwnership.ownerName} /> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Rank</p>
) : ( <span className="text-2xl font-bold leading-none">1</span>
<span className="text-xs text-muted-foreground">-</span> </div>
)}
</TableCell>
)}
{pointsMap.size > 0 && ( {pointsMap.size > 0 && (
<TableCell className="text-right font-semibold text-electric"> <>
{winnerPts ?? "—"} <div className="h-8 w-px bg-border shrink-0 self-center" />
</TableCell> <div className="text-right shrink-0 flex-1 sm:flex-none">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Points</p>
<span className="text-2xl font-bold leading-none text-electric">{winnerPts ?? 0}</span>
</div>
</>
)} )}
</TableRow> </div>
)} </div>
)}
{rankedEntries.filter((entry) => isDraftedOrScoring(entry.participant.id)).map((entry) => { {rankedEntries.filter((entry) => isDraftedOrScoring(entry.participant.id)).map((entry) => {
const isOwned = userParticipantSet.has(entry.participant.id); const isOwned = userParticipantSet.has(entry.participant.id);
const pts = pointsMap.get(entry.participant.id); const pts = pointsMap.get(entry.participant.id);
return ( const numRank = parseInt(entry.rankLabel.replace("T", ""), 10);
<TableRow return (
key={entry.participant.id} <div key={entry.participant.id} className={rankingRowCls(numRank)}>
className={isOwned ? "bg-electric/5 border-l-2 border-l-electric opacity-80" : "opacity-60"} <div className="flex items-center gap-3 flex-1 min-w-0">
> <TeamAvatar
<TableCell className="font-mono text-xs text-muted-foreground"> teamId={entry.ownership?.teamId ?? entry.participant.id}
{entry.rankLabel} teamName={entry.ownership?.teamName ?? entry.participant.name}
</TableCell> />
<TableCell className={isOwned ? "text-electric font-medium" : "text-muted-foreground"}> <div className="min-w-0">
{entry.participant.name} <p className={`font-medium text-sm leading-tight truncate${isOwned ? " text-electric" : ""}`}>
{isOwned && ( {entry.participant.name}
<Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" /> {isOwned && <Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />}
</p>
{entry.ownership?.ownerName && (
<p className="text-xs text-muted-foreground truncate">{entry.ownership.ownerName}</p>
)} )}
</TableCell> </div>
{showOwnership && ( </div>
<TableCell className="pl-6"> <div className="flex items-center gap-4 w-full border-t border-border/30 pt-2 mt-1 sm:w-auto sm:border-0 sm:pt-0 sm:mt-0 sm:shrink-0">
{entry.ownership ? ( <div className="text-right shrink-0 flex-1 sm:flex-none">
<TeamOwnerBadge <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Rank</p>
teamName={entry.ownership.teamName} <span className="text-2xl font-bold leading-none">{entry.rankLabel}</span>
ownerName={entry.ownership.ownerName} </div>
/>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
)}
{pointsMap.size > 0 && ( {pointsMap.size > 0 && (
<TableCell className="text-right tabular-nums text-muted-foreground"> <>
{pts ?? "—"} <div className="h-8 w-px bg-border shrink-0 self-center" />
</TableCell> <div className="text-right shrink-0 flex-1 sm:flex-none">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Points</p>
<span className="text-2xl font-bold leading-none text-electric">{pts ?? 0}</span>
</div>
</>
)} )}
</TableRow> </div>
); </div>
})} );
})}
{filteredPreEliminated.filter((p) => isDraftedOrScoring(p.id)).map((p) => { {filteredPreEliminated.filter((p) => isDraftedOrScoring(p.id)).map((p) => {
const isOwned = userParticipantSet.has(p.id); const isOwned = userParticipantSet.has(p.id);
const ownership = showOwnership ? ownershipMap.get(p.id) || null : null; const ownership = ownershipMap.get(p.id);
const pts = pointsMap.get(p.id); const pts = pointsMap.get(p.id);
return ( return (
<TableRow <div key={p.id} className={rankingRowCls(undefined)}>
key={p.id} <div className="flex items-center gap-3 flex-1 min-w-0">
className={isOwned ? "bg-electric/5 border-l-2 border-l-electric opacity-60" : "opacity-40"} <TeamAvatar
> teamId={ownership?.teamId ?? p.id}
<TableCell className="font-mono text-xs text-muted-foreground"></TableCell> teamName={ownership?.teamName ?? p.name}
<TableCell className={isOwned ? "text-electric font-medium" : "text-muted-foreground"}> />
{p.name} <div className="min-w-0">
{isOwned && ( <p className={`font-medium text-sm leading-tight truncate${isOwned ? " text-electric" : ""}`}>
<Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" /> {p.name}
{isOwned && <Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />}
</p>
{ownership?.ownerName && (
<p className="text-xs text-muted-foreground truncate">{ownership.ownerName}</p>
)} )}
</TableCell> </div>
{showOwnership && ( </div>
<TableCell className="pl-6"> {pointsMap.size > 0 && (
{ownership ? ( <div className="flex items-center gap-4 w-full border-t border-border/30 pt-2 mt-1 sm:w-auto sm:border-0 sm:pt-0 sm:mt-0 sm:shrink-0">
<TeamOwnerBadge teamName={ownership.teamName} ownerName={ownership.ownerName} /> <div className="text-right shrink-0 flex-1 sm:flex-none">
) : ( <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Points</p>
<span className="text-xs text-muted-foreground">-</span> <span className="text-2xl font-bold leading-none text-electric">{pts ?? 0}</span>
)} </div>
</TableCell> </div>
)} )}
{pointsMap.size > 0 && ( </div>
<TableCell className="text-right tabular-nums text-muted-foreground"> );
{pts ?? "—"} })}
</TableCell> </div>
)}
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent> </CardContent>
</Card> </Card>
)} )}

View file

@ -92,6 +92,7 @@ interface SportSeasonDisplayProps {
scoringPattern: ScoringPattern; scoringPattern: ScoringPattern;
sportSeasonName: string; sportSeasonName: string;
sportName: string; sportName: string;
bracketMode?: "bracket" | "rankings";
// Playoff data // Playoff data
playoffMatches?: Match[]; playoffMatches?: Match[];
@ -123,6 +124,7 @@ export function SportSeasonDisplay({
scoringPattern, scoringPattern,
sportSeasonName, sportSeasonName,
sportName: _sportName, sportName: _sportName,
bracketMode = "bracket",
playoffMatches = [], playoffMatches = [],
playoffRounds = [], playoffRounds = [],
bracketTemplateId = null, bracketTemplateId = null,
@ -157,6 +159,7 @@ export function SportSeasonDisplay({
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
showOwnership={showOwnership} showOwnership={showOwnership}
title="Playoff Bracket" title="Playoff Bracket"
mode={bracketMode}
/> />
); );

View file

@ -1,9 +1,9 @@
import { useState } from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import type { BracketPhase, ConferenceGroup } from "~/lib/bracket-templates"; import type { BracketPhase, ConferenceGroup } from "~/lib/bracket-templates";
import { import {
TreeColumns, TreeColumns,
BracketTreePaginated, BracketTreePaginated,
BracketMatchSlot,
type BracketMatch, type BracketMatch,
type BracketOwnership, type BracketOwnership,
} from "./BracketTreeView"; } from "./BracketTreeView";
@ -39,6 +39,106 @@ function phaseHeight(matchesByRound: Map<string, BracketMatch[]>, rounds: string
return max * (CARD_H + CARD_GAP); return max * (CARD_H + CARD_GAP);
} }
// ─── Play-In Layout ───────────────────────────────────────────────────────────
interface PlayInColumnProps {
label: string;
description: string;
match: BracketMatch | undefined;
ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>;
}
function PlayInColumn({
label,
description,
match,
ownershipMap,
userParticipantIds,
}: PlayInColumnProps) {
return (
<div>
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground text-center mb-2">
{label}
</p>
{match ? (
<BracketMatchSlot
match={match}
slotHeight={CARD_H}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
/>
) : (
<div style={{ height: CARD_H }} />
)}
<p className="text-[10px] text-muted-foreground text-center mt-2">{description}</p>
</div>
);
}
interface PlayInLayoutProps {
matchesByRound: Map<string, BracketMatch[]>;
ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>;
}
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 (
<div className="space-y-6">
{conferences.map((conf) => (
<div key={conf.name}>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">
{conf.name}
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<PlayInColumn
label="7 VS 8"
description="Winner → 7 seed · Loser plays again"
match={conf.match78}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
/>
<PlayInColumn
label="9 VS 10"
description="Winner plays again · Loser eliminated"
match={conf.match910}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
/>
<PlayInColumn
label="For 8 Seed"
description="Winner → 8 seed · Loser eliminated"
match={conf.matchFor8}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
/>
</div>
</div>
))}
</div>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
export function TabbedBracketLayout({ export function TabbedBracketLayout({
rounds, rounds,
matchesByRound, matchesByRound,
@ -47,94 +147,100 @@ export function TabbedBracketLayout({
phases, phases,
scoringRoundIdx, scoringRoundIdx,
}: TabbedBracketLayoutProps) { }: 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 ( return (
<div className="space-y-4"> <div className="space-y-10">
<div className="flex gap-1 rounded-lg bg-muted p-1 w-fit"> {phases.map((phase) => {
{phases.map((p, i) => ( const groupRounds = phase.groups
<button ? rounds.filter((r) => phase.groups?.some((g) => g.roundMatchNumbers[r] !== undefined))
key={p.name} : [];
type="button" const sharedRounds = (phase.sharedRounds ?? []).filter((r) => rounds.includes(r));
onClick={() => setActiveIdx(i)} const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r));
className={cn(
"px-3 py-1.5 text-sm font-medium rounded-md transition-colors",
activeIdx === i
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
{p.name}
</button>
))}
</div>
{/* Desktop */} const phaseRounds = phase.groups ? [...groupRounds, ...sharedRounds] : simpleRounds;
<div className="hidden md:block"> const phaseMatchesByRound = new Map(phaseRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
{phase.groups ? ( const sharedMatchesByRound = new Map(sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
<div className="space-y-8">
{phase.groups.map((group) => { const phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx);
const gMatches = groupMatches(matchesByRound, group);
const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined); return (
return ( <div key={phase.name}>
<div key={group.name}> <p className={cn(
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2"> "text-sm font-semibold uppercase tracking-wider mb-4",
{group.name} phases.length > 1 ? "text-muted-foreground" : "hidden"
</p> )}>
<TreeColumns {phase.name}
visibleRounds={gRounds} </p>
matchesByRound={gMatches}
ownershipMap={ownershipMap} {/* Desktop */}
userParticipantIds={userParticipantIds} <div className="hidden md:block">
bracketHeight={phaseHeight(gMatches, gRounds)} {phase.layout === "play-in" ? (
/> <PlayInLayout
matchesByRound={phaseMatchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
/>
) : phase.groups ? (
<div className="space-y-8">
{phase.groups.map((group) => {
const gMatches = groupMatches(matchesByRound, group);
const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined);
return (
<div key={group.name}>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
{group.name}
</p>
<TreeColumns
visibleRounds={gRounds}
matchesByRound={gMatches}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
bracketHeight={phaseHeight(gMatches, gRounds)}
/>
</div>
);
})}
{sharedRounds.length > 0 && (
<TreeColumns
visibleRounds={sharedRounds}
matchesByRound={sharedMatchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
bracketHeight={phaseHeight(sharedMatchesByRound, sharedRounds)}
/>
)}
</div> </div>
); ) : (
})} <TreeColumns
{sharedRounds.length > 0 && ( visibleRounds={phaseRounds}
<TreeColumns matchesByRound={phaseMatchesByRound}
visibleRounds={sharedRounds} ownershipMap={ownershipMap}
matchesByRound={sharedMatchesByRound} userParticipantIds={userParticipantIds}
ownershipMap={ownershipMap} bracketHeight={phaseHeight(phaseMatchesByRound, phaseRounds)}
userParticipantIds={userParticipantIds} />
bracketHeight={phaseHeight(sharedMatchesByRound, sharedRounds)} )}
/> </div>
)}
</div>
) : (
<TreeColumns
visibleRounds={phaseRounds}
matchesByRound={phaseMatchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
bracketHeight={phaseHeight(phaseMatchesByRound, phaseRounds)}
/>
)}
</div>
{/* Mobile */} {/* Mobile */}
<div className="md:hidden"> <div className="md:hidden">
<BracketTreePaginated {phase.layout === "play-in" ? (
rounds={phaseRounds} <PlayInLayout
matchesByRound={phaseMatchesByRound} matchesByRound={phaseMatchesByRound}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined} />
/> ) : (
</div> <BracketTreePaginated
rounds={phaseRounds}
matchesByRound={phaseMatchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
/>
)}
</div>
</div>
);
})}
</div> </div>
); );
} }

View file

@ -41,7 +41,6 @@ interface Props {
teamOwnerships: Record<string, TeamOwnership>; teamOwnerships: Record<string, TeamOwnership>;
userParticipantIds: string[]; userParticipantIds: string[];
showOtLosses?: boolean; showOtLosses?: boolean;
participantEvs?: Record<string, string>;
/** How many teams per conference qualify for playoffs (flat mode only). 0 = no line. */ /** How many teams per conference qualify for playoffs (flat mode only). 0 = no line. */
playoffSpots?: number; 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. */ /** "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, teamOwnerships,
userParticipantIds, userParticipantIds,
showOtLosses, showOtLosses,
participantEvs,
hasEvs,
}: { }: {
sections: TableSection[]; sections: TableSection[];
teamOwnerships: Record<string, TeamOwnership>; teamOwnerships: Record<string, TeamOwnership>;
userParticipantIds: string[]; userParticipantIds: string[];
showOtLosses: boolean; showOtLosses: boolean;
participantEvs: Record<string, string>;
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) // 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 ( return (
<div className="overflow-x-auto -mx-6 px-6"> <div className="overflow-x-auto -mx-6 px-6">
@ -254,7 +249,6 @@ function StandingsTable({
<th className="text-right py-1.5 px-2 w-12">L10</th> <th className="text-right py-1.5 px-2 w-12">L10</th>
<th className="text-right py-1.5 px-2 w-12">STK</th> <th className="text-right py-1.5 px-2 w-12">STK</th>
<th className="text-right py-1.5 pl-4 w-40">Mgr</th> <th className="text-right py-1.5 pl-4 w-40">Mgr</th>
{hasEvs && <th className="text-right py-1.5 pl-2 w-14">PROJ</th>}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -299,7 +293,6 @@ function StandingsTable({
const isUserTeam = userParticipantIds.includes(row.participantId); const isUserTeam = userParticipantIds.includes(row.participantId);
const ownership = teamOwnerships[row.participantId]; const ownership = teamOwnerships[row.participantId];
const ev = participantEvs[row.participantId];
sectionRows.push( sectionRows.push(
<tr <tr
@ -369,17 +362,6 @@ function StandingsTable({
<span className="text-xs text-muted-foreground"></span> <span className="text-xs text-muted-foreground"></span>
)} )}
</td> </td>
{hasEvs && (
<td className="py-2 pl-2 text-right tabular-nums">
{ev !== null ? (
<span className="text-blue-500 dark:text-blue-400 font-medium">
{parseFloat(ev).toFixed(1)}
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
)}
</tr> </tr>
); );
}); });
@ -399,13 +381,11 @@ export function RegularSeasonStandings({
teamOwnerships, teamOwnerships,
userParticipantIds, userParticipantIds,
showOtLosses = false, showOtLosses = false,
participantEvs = {},
playoffSpots = 8, playoffSpots = 8,
displayMode = "flat", displayMode = "flat",
}: Props) { }: Props) {
if (standings.length === 0) return null; if (standings.length === 0) return null;
const hasEvs = Object.keys(participantEvs).length > 0;
const lastSyncedAt = standings const lastSyncedAt = standings
.map((s) => s.syncedAt) .map((s) => s.syncedAt)
.filter(Boolean) .filter(Boolean)
@ -419,7 +399,7 @@ export function RegularSeasonStandings({
? buildMlbSections(standings) ? buildMlbSections(standings)
: buildFlatSections(standings, playoffSpots); : buildFlatSections(standings, playoffSpots);
const tableProps = { teamOwnerships, userParticipantIds, showOtLosses, participantEvs, hasEvs }; const tableProps = { teamOwnerships, userParticipantIds, showOtLosses };
return ( return (
<Card> <Card>

View file

@ -88,6 +88,8 @@ export interface BracketPhase {
groups?: ConferenceGroup[]; groups?: ConferenceGroup[];
/** Rounds rendered after groups (e.g. Final Four, Championship) */ /** Rounds rendered after groups (e.g. Final Four, Championship) */
sharedRounds?: string[]; sharedRounds?: string[];
/** Custom rendering layout for special phases */
layout?: "play-in";
} }
export interface BracketTemplate { 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 * 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 * 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 * East 16 direct seeds, no play-ins
* South 15 direct seeds, 16-seed play-in * South 15 direct seeds, 16-seed play-in
* West 15 direct seeds, 11-seed play-in * West 15 direct seeds, 11-seed play-in
@ -860,6 +862,7 @@ export const NBA_20: BracketTemplate = {
{ {
name: "Play-In", name: "Play-In",
rounds: ["Play-In Round 1", "Play-In Round 2"], rounds: ["Play-In Round 1", "Play-In Round 2"],
layout: "play-in" as const,
}, },
{ {
name: "Playoffs", name: "Playoffs",

View file

@ -1,4 +1,5 @@
import { Link } from "react-router"; import { Link } from "react-router";
import { useState } from "react";
import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId"; import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId";
import { loader } from "./$leagueId.sports-seasons.$sportsSeasonId.server"; 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 { RegularSeasonStandings } from "~/components/sport-season/RegularSeasonStandings";
import { GroupStageStandings } from "~/components/sport-season/GroupStageStandings"; import { GroupStageStandings } from "~/components/sport-season/GroupStageStandings";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; import { cn } from "~/lib/utils";
import { ArrowLeft, CheckCircle2, Clock, Zap } from "lucide-react"; import { ArrowLeft } from "lucide-react";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `${data?.sportsSeason?.name ?? "Sport Season"}${data?.league?.name ?? "League"} - Brackt` }]; 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 }; export { loader };
function getStatusBadge(status: string) { type BracketView = "standings" | "playoffs" | "finished";
switch (status) {
case "active":
return (
<Badge variant="outline" className="bg-emerald-500/15 text-emerald-400 border-emerald-500/30">
<Zap className="mr-1 h-3 w-3" />
Active
</Badge>
);
case "upcoming":
return (
<Badge variant="outline" className="bg-electric/15 text-electric border-electric/30">
<Clock className="mr-1 h-3 w-3" />
Upcoming
</Badge>
);
case "completed":
return (
<Badge variant="outline" className="bg-muted text-muted-foreground border-border">
<CheckCircle2 className="mr-1 h-3 w-3" />
Completed
</Badge>
);
default:
return null;
}
}
export default function SportSeasonDetail({ export default function SportSeasonDetail({
loaderData, loaderData,
@ -65,7 +40,7 @@ export default function SportSeasonDetail({
recentEvents, recentEvents,
seasonIsFinalized, seasonIsFinalized,
regularSeasonStandings, regularSeasonStandings,
participantEvs,
groupStandings, groupStandings,
bracketTemplateId, bracketTemplateId,
} = loaderData; } = loaderData;
@ -79,14 +54,9 @@ export default function SportSeasonDetail({
simulatorType === "nhl_bracket" ? "nhl-divisions" : simulatorType === "nhl_bracket" ? "nhl-divisions" :
simulatorType === "mlb_bracket" ? "mlb-divisions" : simulatorType === "mlb_bracket" ? "mlb-divisions" :
"flat"; "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 = const playoffSpots =
simulatorType === "nba_bracket" || simulatorType === "afl_bracket" ? 10 : 8; simulatorType === "nba_bracket" || simulatorType === "afl_bracket" ? 10 : 8;
// Build ownership map for RegularSeasonStandings
const ownershipMap = Object.fromEntries( const ownershipMap = Object.fromEntries(
teamOwnerships.map((o) => [ teamOwnerships.map((o) => [
o.participantId, 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<BracketView>(() => {
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") => (
<SportSeasonDisplay
scoringPattern={scoringPattern as "playoff_bracket" | "season_standings" | "qualifying_points"}
sportSeasonName={sportsSeason.name}
sportName={sportsSeason.sport.name}
bracketMode={bracketMode}
playoffMatches={playoffMatches}
playoffRounds={playoffRounds}
bracketTemplateId={bracketTemplateId}
preEliminatedParticipants={preEliminatedParticipants}
participantPoints={participantPoints}
partialScoreParticipantIds={partialScoreParticipantIds}
seasonStandings={seasonStandings}
seasonIsFinalized={seasonIsFinalized}
qpStandings={qpStandings}
qpIsFinalized={sportsSeason.qualifyingPointsFinalized || false}
totalMajors={sportsSeason.totalMajors}
majorsCompleted={sportsSeason.majorsCompleted || 0}
canFinalize={
(sportsSeason.majorsCompleted || 0) >=
(sportsSeason.totalMajors || 0) &&
!sportsSeason.qualifyingPointsFinalized
}
teamOwnerships={teamOwnerships}
userParticipantIds={userParticipantIds}
scoringRules={season}
showOwnership={true}
/>
);
const standingsDisplay = (
<RegularSeasonStandings
standings={regularSeasonStandings}
teamOwnerships={ownershipMap}
userParticipantIds={userParticipantIds}
showOtLosses={showOtLosses}
displayMode={standingsDisplayMode as "flat" | "nhl-divisions" | "mlb-divisions"}
playoffSpots={playoffSpots}
/>
);
return ( return (
<div className="container mx-auto py-8 px-4"> <div className="container mx-auto py-8 px-4">
<div className="mb-6"> <div className="mb-6">
@ -104,7 +130,7 @@ export default function SportSeasonDetail({
</Link> </Link>
</Button> </Button>
<div className="flex items-start justify-between gap-4 mb-4"> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between mb-4">
<div> <div>
<h1 className="text-3xl font-bold mb-1"> <h1 className="text-3xl font-bold mb-1">
{sportsSeason.sport.name} {sportsSeason.sport.name}
@ -113,84 +139,68 @@ export default function SportSeasonDetail({
{sportsSeason.name} {league.name} {season.year} Season {sportsSeason.name} {league.name} {season.year} Season
</p> </p>
</div> </div>
{getStatusBadge(sportsSeason.status)}
{showToggle && (
<div className="flex gap-1 rounded-lg bg-muted p-1 sm:shrink-0">
{TOGGLE_VIEWS.map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => setView(value)}
className={cn(
"flex-1 sm:flex-none px-3 py-1.5 text-sm font-medium rounded-md transition-colors",
view === value
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
{label}
</button>
))}
</div>
)}
</div> </div>
</div> </div>
{/* Regular season standings — show above bracket when no bracket exists yet */} {showToggle ? (
{hasStandings && !hasBracket && ( <div>
<div className="mb-6"> {view === "standings" && standingsDisplay}
<RegularSeasonStandings {view === "playoffs" && bracketDisplay("bracket")}
standings={regularSeasonStandings} {view === "finished" && bracketDisplay("rankings")}
teamOwnerships={ownershipMap}
userParticipantIds={userParticipantIds}
showOtLosses={showOtLosses}
participantEvs={participantEvs}
displayMode={standingsDisplayMode as "flat" | "nhl-divisions" | "mlb-divisions"}
playoffSpots={playoffSpots}
/>
</div> </div>
)} ) : (
<>
{/* Regular season standings above bracket when no bracket exists yet */}
{hasStandings && !hasBracket && (
<div className="mb-6">{standingsDisplay}</div>
)}
{/* Event schedule - hidden for bracket sports (the bracket itself is the event) */} {/* Event schedule — hidden for bracket sports */}
{scoringPattern !== "playoff_bracket" && {scoringPattern !== "playoff_bracket" &&
(upcomingEvents.length > 0 || recentEvents.length > 0) && ( (upcomingEvents.length > 0 || recentEvents.length > 0) && (
<div className="mb-6"> <div className="mb-6">
<EventSchedule <EventSchedule
upcomingEvents={upcomingEvents} upcomingEvents={upcomingEvents}
recentEvents={recentEvents} recentEvents={recentEvents}
/> />
</div> </div>
)} )}
{/* Group stage standings — shown for FIFA-style tournaments before/during group phase */} {/* Group stage standings */}
{groupStandings.length > 0 && ( {groupStandings.length > 0 && (
<div className="mb-6"> <div className="mb-6">
<GroupStageStandings groups={groupStandings} ownershipMap={ownershipMap} showEmpty={false} /> <GroupStageStandings groups={groupStandings} ownershipMap={ownershipMap} showEmpty={false} />
</div> </div>
)} )}
{/* Hide bracket display when standings exist but no bracket matches have been set yet */} {/* Bracket — hidden when standings exist but bracket is empty */}
{!(scoringPattern === "playoff_bracket" && hasStandings && !hasBracket) && <SportSeasonDisplay {!(scoringPattern === "playoff_bracket" && hasStandings && !hasBracket) && bracketDisplay("bracket")}
scoringPattern={scoringPattern as "playoff_bracket" | "season_standings" | "qualifying_points"}
sportSeasonName={sportsSeason.name}
sportName={sportsSeason.sport.name}
playoffMatches={playoffMatches}
playoffRounds={playoffRounds}
bracketTemplateId={bracketTemplateId}
preEliminatedParticipants={preEliminatedParticipants}
participantPoints={participantPoints}
partialScoreParticipantIds={partialScoreParticipantIds}
seasonStandings={seasonStandings}
seasonIsFinalized={seasonIsFinalized}
qpStandings={qpStandings}
qpIsFinalized={sportsSeason.qualifyingPointsFinalized || false}
totalMajors={sportsSeason.totalMajors}
majorsCompleted={sportsSeason.majorsCompleted || 0}
canFinalize={
(sportsSeason.majorsCompleted || 0) >=
(sportsSeason.totalMajors || 0) &&
!sportsSeason.qualifyingPointsFinalized
}
teamOwnerships={teamOwnerships}
userParticipantIds={userParticipantIds}
scoringRules={season}
showOwnership={true}
/>}
{/* Regular season standings — show below bracket once bracket exists */} {/* Regular season standings below bracket once bracket exists */}
{hasStandings && hasBracket && ( {hasStandings && hasBracket && (
<div className="mt-8"> <div className="mt-8">{standingsDisplay}</div>
<RegularSeasonStandings )}
standings={regularSeasonStandings} </>
teamOwnerships={ownershipMap}
userParticipantIds={userParticipantIds}
showOtLosses={showOtLosses}
participantEvs={participantEvs}
displayMode={standingsDisplayMode as "flat" | "nhl-divisions" | "mlb-divisions"}
playoffSpots={playoffSpots}
/>
</div>
)} )}
</div> </div>
); );