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 && (
<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" : "",
].filter(Boolean).join(" ")}>
{/* Electric dot for user's pick (when not winner) */}
{isOwned && !isWinner && showText && (
<span
className="shrink-0 rounded-full"
style={{ width: 5, height: 5, background: "#2ce1c1" }}
/>
)}
{/* Manager avatar — always present for alignment; empty box when unowned */}
<div
className="shrink-0 rounded-[3px] flex items-center justify-center text-[8px] font-bold"
style={{
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">
<span
className={[
"text-[13px] leading-tight block truncate",
isTbd ? "text-muted-foreground/50 italic" : "",
isLoser && isOwned ? "text-electric/50 line-through" :
isLoser ? "text-muted-foreground line-through" : "",
isOwned && !isLoser ? "text-electric font-medium" : "",
isWinner ? "font-semibold" : "",
]
.filter(Boolean)
@ -117,28 +128,11 @@ function ParticipantRow({
{name ?? "TBD"}
</span>
{/* Owner info row */}
{/* Owner name below participant name */}
{showOwner && !isTbd && ownership && (
<div className="flex items-center gap-1 mt-0.5">
<div
className="shrink-0 rounded-[2px] flex items-center justify-center text-[8px] font-bold"
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">
<span className="text-[10px] text-muted-foreground truncate block leading-none">
{ownership.ownerName ?? ownership.teamName}
</span>
</div>
)}
</div>
@ -169,7 +163,7 @@ interface BracketMatchSlotProps {
userParticipantIds: Set<string>;
}
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) => (
<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>
</div>
@ -412,6 +406,7 @@ interface BracketTreeViewProps {
matchesByRound: Map<string, BracketMatch[]>;
ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>;
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 (
<div
@ -431,12 +430,33 @@ export function BracketTreeView({
>
<div style={{ minWidth }}>
<TreeColumns
visibleRounds={rounds}
visibleRounds={mainRounds}
matchesByRound={matchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
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>
);
@ -451,6 +471,7 @@ interface BracketTreePaginatedProps {
userParticipantIds: Set<string>;
/** 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({
)}
</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>
);
}

View file

@ -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<string, Match[]>, rounds: stri
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({
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<string, TeamOwnership>();
@ -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<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
let bracketWinner: Participant | null = null;
@ -296,7 +316,7 @@ export function PlayoffBracket({
</div>
) : (
<div className="space-y-8">
{template?.phases ? (
{mode === "bracket" && (template?.phases ? (
<TabbedBracketLayout
rounds={rounds}
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
@ -324,6 +344,7 @@ export function PlayoffBracket({
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet}
thirdPlaceRound={thirdPlaceRound}
/>
</div>
@ -335,13 +356,14 @@ export function PlayoffBracket({
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet}
firstScoringRoundIdx={scoringRoundIdx}
thirdPlaceRound={thirdPlaceRound}
/>
</div>
</>
)}
))}
{/* ── In Contention ── */}
{activeParticipants.length > 0 && (
{mode === "bracket" && activeParticipants.length > 0 && (
<Card className="border-green-500/30">
<CardHeader>
<CardTitle className="text-green-600 dark:text-green-400 flex items-center gap-2">
@ -402,133 +424,126 @@ export function PlayoffBracket({
)}
{/* ── Final Rankings ── */}
{showRankings && (
<Card className="border-electric/30">
<CardHeader>
<CardTitle className="text-electric flex items-center gap-2">
<Trophy className="h-5 w-5" />
{mode === "rankings" && showRankings && (
<Card className="gap-2">
<CardHeader className="px-3 sm:px-6 pb-2">
<div className="flex items-center gap-2">
<GradientIcon icon={Trophy} className="h-5 w-5 shrink-0" />
<h2 className="text-xl font-bold leading-none tracking-tight">
{bracketWinner ? "Final Rankings" : "Eliminated Teams"}
</CardTitle>
</h2>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">Rank</TableHead>
<TableHead>Participant</TableHead>
{showOwnership && (
<TableHead className="w-40 pl-6">Drafted By</TableHead>
)}
{pointsMap.size > 0 && (
<TableHead className="text-right w-16">Pts</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
<CardContent className="px-3 sm:px-6">
<div className="space-y-3">
{bracketWinner && (
<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">
<div className={rankingRowCls(1)}>
<div className="flex items-center gap-3 flex-1 min-w-0">
<TeamAvatar
teamId={winnerOwnership?.teamId ?? bracketWinner.id}
teamName={winnerOwnership?.teamName ?? bracketWinner.name}
/>
<div className="min-w-0">
<p className={`font-medium text-sm leading-tight truncate${winnerIsOwned ? " text-electric" : ""}`}>
{bracketWinner.name}
{winnerIsOwned && (
<Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />
)}
</TableCell>
{showOwnership && (
<TableCell className="pl-6">
{winnerOwnership ? (
<TeamOwnerBadge teamName={winnerOwnership.teamName} ownerName={winnerOwnership.ownerName} />
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
{winnerIsOwned && <Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />}
</p>
{winnerOwnership?.ownerName && (
<p className="text-xs text-muted-foreground truncate">{winnerOwnership.ownerName}</p>
)}
</div>
</div>
<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">
<div className="text-right shrink-0 flex-1 sm:flex-none">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Rank</p>
<span className="text-2xl font-bold leading-none">1</span>
</div>
{pointsMap.size > 0 && (
<TableCell className="text-right font-semibold text-electric">
{winnerPts ?? "—"}
</TableCell>
<>
<div className="h-8 w-px bg-border shrink-0 self-center" />
<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) => {
const isOwned = userParticipantSet.has(entry.participant.id);
const pts = pointsMap.get(entry.participant.id);
const numRank = parseInt(entry.rankLabel.replace("T", ""), 10);
return (
<TableRow
key={entry.participant.id}
className={isOwned ? "bg-electric/5 border-l-2 border-l-electric opacity-80" : "opacity-60"}
>
<TableCell className="font-mono text-xs text-muted-foreground">
{entry.rankLabel}
</TableCell>
<TableCell className={isOwned ? "text-electric font-medium" : "text-muted-foreground"}>
{entry.participant.name}
{isOwned && (
<Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />
)}
</TableCell>
{showOwnership && (
<TableCell className="pl-6">
{entry.ownership ? (
<TeamOwnerBadge
teamName={entry.ownership.teamName}
ownerName={entry.ownership.ownerName}
<div key={entry.participant.id} className={rankingRowCls(numRank)}>
<div className="flex items-center gap-3 flex-1 min-w-0">
<TeamAvatar
teamId={entry.ownership?.teamId ?? entry.participant.id}
teamName={entry.ownership?.teamName ?? entry.participant.name}
/>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
<div className="min-w-0">
<p className={`font-medium text-sm leading-tight truncate${isOwned ? " text-electric" : ""}`}>
{entry.participant.name}
{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>
)}
</div>
</div>
<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">
<div className="text-right shrink-0 flex-1 sm:flex-none">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Rank</p>
<span className="text-2xl font-bold leading-none">{entry.rankLabel}</span>
</div>
{pointsMap.size > 0 && (
<TableCell className="text-right tabular-nums text-muted-foreground">
{pts ?? "—"}
</TableCell>
<>
<div className="h-8 w-px bg-border shrink-0 self-center" />
<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) => {
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);
return (
<TableRow
key={p.id}
className={isOwned ? "bg-electric/5 border-l-2 border-l-electric opacity-60" : "opacity-40"}
>
<TableCell className="font-mono text-xs text-muted-foreground"></TableCell>
<TableCell className={isOwned ? "text-electric font-medium" : "text-muted-foreground"}>
<div key={p.id} className={rankingRowCls(undefined)}>
<div className="flex items-center gap-3 flex-1 min-w-0">
<TeamAvatar
teamId={ownership?.teamId ?? p.id}
teamName={ownership?.teamName ?? p.name}
/>
<div className="min-w-0">
<p className={`font-medium text-sm leading-tight truncate${isOwned ? " text-electric" : ""}`}>
{p.name}
{isOwned && (
<Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />
)}
</TableCell>
{showOwnership && (
<TableCell className="pl-6">
{ownership ? (
<TeamOwnerBadge teamName={ownership.teamName} ownerName={ownership.ownerName} />
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
{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>
)}
</div>
</div>
{pointsMap.size > 0 && (
<TableCell className="text-right tabular-nums text-muted-foreground">
{pts ?? "—"}
</TableCell>
<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">
<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>
</div>
)}
</TableRow>
</div>
);
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}

View file

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

View file

@ -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<string, BracketMatch[]>, rounds: string
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({
rounds,
matchesByRound,
@ -47,9 +147,9 @@ export function TabbedBracketLayout({
phases,
scoringRoundIdx,
}: TabbedBracketLayoutProps) {
const [activeIdx, setActiveIdx] = useState(0);
const phase = phases[activeIdx];
return (
<div className="space-y-10">
{phases.map((phase) => {
const groupRounds = phase.groups
? rounds.filter((r) => phase.groups?.some((g) => g.roundMatchNumbers[r] !== undefined))
: [];
@ -63,28 +163,23 @@ export function TabbedBracketLayout({
const phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx);
return (
<div className="space-y-4">
<div className="flex gap-1 rounded-lg bg-muted p-1 w-fit">
{phases.map((p, i) => (
<button
key={p.name}
type="button"
onClick={() => setActiveIdx(i)}
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>
<div key={phase.name}>
<p className={cn(
"text-sm font-semibold uppercase tracking-wider mb-4",
phases.length > 1 ? "text-muted-foreground" : "hidden"
)}>
{phase.name}
</p>
{/* Desktop */}
<div className="hidden md:block">
{phase.groups ? (
{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);
@ -127,6 +222,13 @@ export function TabbedBracketLayout({
{/* Mobile */}
<div className="md:hidden">
{phase.layout === "play-in" ? (
<PlayInLayout
matchesByRound={phaseMatchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
/>
) : (
<BracketTreePaginated
rounds={phaseRounds}
matchesByRound={phaseMatchesByRound}
@ -134,7 +236,11 @@ export function TabbedBracketLayout({
userParticipantIds={userParticipantIds}
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
/>
)}
</div>
</div>
);
})}
</div>
);
}

View file

@ -41,7 +41,6 @@ interface Props {
teamOwnerships: Record<string, TeamOwnership>;
userParticipantIds: string[];
showOtLosses?: boolean;
participantEvs?: Record<string, string>;
/** 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<string, TeamOwnership>;
userParticipantIds: string[];
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)
const totalCols = 10 + (showOtLosses ? 2 : 0) + (hasEvs ? 1 : 0);
const totalCols = 10 + (showOtLosses ? 2 : 0);
return (
<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">STK</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>
</thead>
<tbody>
@ -299,7 +293,6 @@ function StandingsTable({
const isUserTeam = userParticipantIds.includes(row.participantId);
const ownership = teamOwnerships[row.participantId];
const ev = participantEvs[row.participantId];
sectionRows.push(
<tr
@ -369,17 +362,6 @@ function StandingsTable({
<span className="text-xs text-muted-foreground"></span>
)}
</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>
);
});
@ -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 (
<Card>

View file

@ -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",

View file

@ -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 (
<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;
}
}
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,67 +64,27 @@ export default function SportSeasonDetail({
])
);
return (
<div className="container mx-auto py-8 px-4">
<div className="mb-6">
<Button variant="ghost" size="sm" asChild className="mb-4">
<Link to={`/leagues/${league.id}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to League
</Link>
</Button>
// Show the 3-way toggle only for bracket sports that also have regular season standings
const showToggle = hasStandings && scoringPattern === "playoff_bracket";
<div className="flex items-start justify-between gap-4 mb-4">
<div>
<h1 className="text-3xl font-bold mb-1">
{sportsSeason.sport.name}
</h1>
<p className="text-muted-foreground">
{sportsSeason.name} {league.name} {season.year} Season
</p>
</div>
{getStatusBadge(sportsSeason.status)}
</div>
</div>
const [view, setView] = useState<BracketView>(() => {
if (!hasBracket) return "standings";
if (sportsSeason.status === "completed") return "finished";
return "playoffs";
});
{/* Regular season standings — show above bracket when no bracket exists yet */}
{hasStandings && !hasBracket && (
<div className="mb-6">
<RegularSeasonStandings
standings={regularSeasonStandings}
teamOwnerships={ownershipMap}
userParticipantIds={userParticipantIds}
showOtLosses={showOtLosses}
participantEvs={participantEvs}
displayMode={standingsDisplayMode as "flat" | "nhl-divisions" | "mlb-divisions"}
playoffSpots={playoffSpots}
/>
</div>
)}
const TOGGLE_VIEWS: { value: BracketView; label: string }[] = [
{ value: "standings", label: "Standings" },
{ value: "playoffs", label: "Playoffs" },
{ value: "finished", label: "Finished" },
];
{/* Event schedule - hidden for bracket sports (the bracket itself is the event) */}
{scoringPattern !== "playoff_bracket" &&
(upcomingEvents.length > 0 || recentEvents.length > 0) && (
<div className="mb-6">
<EventSchedule
upcomingEvents={upcomingEvents}
recentEvents={recentEvents}
/>
</div>
)}
{/* Group stage standings — shown for FIFA-style tournaments before/during group phase */}
{groupStandings.length > 0 && (
<div className="mb-6">
<GroupStageStandings groups={groupStandings} ownershipMap={ownershipMap} showEmpty={false} />
</div>
)}
{/* Hide bracket display when standings exist but no bracket matches have been set yet */}
{!(scoringPattern === "playoff_bracket" && hasStandings && !hasBracket) && <SportSeasonDisplay
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}
@ -176,21 +106,101 @@ export default function SportSeasonDetail({
userParticipantIds={userParticipantIds}
scoringRules={season}
showOwnership={true}
/>}
/>
);
{/* Regular season standings — show below bracket once bracket exists */}
{hasStandings && hasBracket && (
<div className="mt-8">
const standingsDisplay = (
<RegularSeasonStandings
standings={regularSeasonStandings}
teamOwnerships={ownershipMap}
userParticipantIds={userParticipantIds}
showOtLosses={showOtLosses}
participantEvs={participantEvs}
displayMode={standingsDisplayMode as "flat" | "nhl-divisions" | "mlb-divisions"}
playoffSpots={playoffSpots}
/>
);
return (
<div className="container mx-auto py-8 px-4">
<div className="mb-6">
<Button variant="ghost" size="sm" asChild className="mb-4">
<Link to={`/leagues/${league.id}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to League
</Link>
</Button>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between mb-4">
<div>
<h1 className="text-3xl font-bold mb-1">
{sportsSeason.sport.name}
</h1>
<p className="text-muted-foreground">
{sportsSeason.name} {league.name} {season.year} Season
</p>
</div>
{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>
{showToggle ? (
<div>
{view === "standings" && standingsDisplay}
{view === "playoffs" && bracketDisplay("bracket")}
{view === "finished" && bracketDisplay("rankings")}
</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 */}
{scoringPattern !== "playoff_bracket" &&
(upcomingEvents.length > 0 || recentEvents.length > 0) && (
<div className="mb-6">
<EventSchedule
upcomingEvents={upcomingEvents}
recentEvents={recentEvents}
/>
</div>
)}
{/* Group stage standings */}
{groupStandings.length > 0 && (
<div className="mb-6">
<GroupStageStandings groups={groupStandings} ownershipMap={ownershipMap} showEmpty={false} />
</div>
)}
{/* Bracket — hidden when standings exist but bracket is empty */}
{!(scoringPattern === "playoff_bracket" && hasStandings && !hasBracket) && bracketDisplay("bracket")}
{/* Regular season standings below bracket once bracket exists */}
{hasStandings && hasBracket && (
<div className="mt-8">{standingsDisplay}</div>
)}
</>
)}
</div>
);