Bracket work.

This commit is contained in:
Chris Parsons 2026-04-21 13:39:43 -07:00
parent 207fc21d67
commit 4d3b214ce5
8 changed files with 583 additions and 344 deletions

View file

@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react"; import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@ -29,6 +29,7 @@ export interface BracketOwnership {
const COLUMN_WIDTH = 152; const COLUMN_WIDTH = 152;
const CONNECTOR_WIDTH = 24; const CONNECTOR_WIDTH = 24;
const SLOT_WIDTH = 2 * COLUMN_WIDTH + CONNECTOR_WIDTH; // viewport width for 2 columns
const LABEL_HEIGHT = 32; const LABEL_HEIGHT = 32;
const CARD_GAP = 14; // vertical gap between cards (split top/bottom) const CARD_GAP = 14; // vertical gap between cards (split top/bottom)
const DESIRED_CARD_HEIGHT = 112; // target card height — tall enough to show owner info const DESIRED_CARD_HEIGHT = 112; // target card height — tall enough to show owner info
@ -265,10 +266,12 @@ function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: Connect
const mid = CONNECTOR_WIDTH / 2; const mid = CONNECTOR_WIDTH / 2;
const paths: string[] = []; const paths: string[] = [];
if (nextMatches.length === Math.ceil(currentMatches.length / 2)) { const currentSlotH = bracketHeight / Math.max(currentMatches.length, 1);
const currentSlotH = bracketHeight / Math.max(currentMatches.length, 1); const nextSlotH = bracketHeight / Math.max(nextMatches.length, 1);
const nextSlotH = bracketHeight / Math.max(nextMatches.length, 1);
// Use halving U-shapes only when prev > 1 (avoids false-positive 1→1 side branches like 3PG→Finals)
if (nextMatches.length === Math.ceil(currentMatches.length / 2) && currentMatches.length > 1) {
// Standard single-elimination halving: U-shape connectors
for (let k = 0; k < nextMatches.length; k++) { for (let k = 0; k < nextMatches.length; k++) {
const topY = (2 * k) * currentSlotH + currentSlotH / 2; const topY = (2 * k) * currentSlotH + currentSlotH / 2;
const midY = k * nextSlotH + nextSlotH / 2; const midY = k * nextSlotH + nextSlotH / 2;
@ -276,14 +279,29 @@ function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: Connect
if (botIdx < currentMatches.length) { if (botIdx < currentMatches.length) {
const botY = botIdx * currentSlotH + currentSlotH / 2; const botY = botIdx * currentSlotH + currentSlotH / 2;
// U-shape from two source midpoints; horizontal out to next column
paths.push(`M 0 ${topY} H ${mid} V ${botY} H 0`); paths.push(`M 0 ${topY} H ${mid} V ${botY} H 0`);
paths.push(`M ${mid} ${midY} H ${CONNECTOR_WIDTH}`); paths.push(`M ${mid} ${midY} H ${CONNECTOR_WIDTH}`);
} else { } else {
// Odd last match: straight line through
paths.push(`M 0 ${topY} H ${CONNECTOR_WIDTH}`); paths.push(`M 0 ${topY} H ${CONNECTOR_WIDTH}`);
} }
} }
} else {
// Non-standard (byes, play-ins, etc.): trace winners by participantId
const winnerToIdx = new Map<string, number>();
currentMatches.forEach((m, idx) => {
if (m.winnerId) winnerToIdx.set(m.winnerId, idx);
});
nextMatches.forEach((nextMatch, nextIdx) => {
const destY = nextIdx * nextSlotH + nextSlotH / 2;
for (const pId of [nextMatch.participant1Id, nextMatch.participant2Id]) {
if (!pId) continue;
const srcIdx = winnerToIdx.get(pId);
if (srcIdx === undefined) continue;
const srcY = srcIdx * currentSlotH + currentSlotH / 2;
paths.push(`M 0 ${srcY} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`);
}
});
} }
return ( return (
@ -315,17 +333,20 @@ interface TreeColumnsProps {
ownershipMap: Map<string, BracketOwnership>; ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>; userParticipantIds: Set<string>;
bracketHeight: number; bracketHeight: number;
transitionDuration?: number;
} }
function TreeColumns({ export function TreeColumns({
visibleRounds, visibleRounds,
matchesByRound, matchesByRound,
ownershipMap, ownershipMap,
userParticipantIds, userParticipantIds,
bracketHeight, bracketHeight,
transitionDuration,
}: TreeColumnsProps) { }: TreeColumnsProps) {
const tr = transitionDuration ? `${transitionDuration}ms ease` : undefined;
return ( return (
<div style={{ display: "flex", width: "100%", height: bracketHeight + LABEL_HEIGHT }}> <div style={{ display: "flex", width: "100%", height: bracketHeight + LABEL_HEIGHT, transition: tr ? `height ${tr}` : undefined }}>
{visibleRounds.map((round, ri) => { {visibleRounds.map((round, ri) => {
const roundMatches = matchesByRound.get(round) ?? []; const roundMatches = matchesByRound.get(round) ?? [];
const slotHeight = bracketHeight / Math.max(roundMatches.length, 1); const slotHeight = bracketHeight / Math.max(roundMatches.length, 1);
@ -345,7 +366,7 @@ function TreeColumns({
{round} {round}
</div> </div>
<div style={{ position: "relative", height: bracketHeight }}> <div style={{ position: "relative", height: bracketHeight, transition: tr ? `height ${tr}` : undefined }}>
{roundMatches.map((match, matchIdx) => ( {roundMatches.map((match, matchIdx) => (
<div <div
key={match.id} key={match.id}
@ -355,6 +376,7 @@ function TreeColumns({
left: 0, left: 0,
right: 0, right: 0,
height: cardHeight, height: cardHeight,
transition: tr ? `top ${tr}, height ${tr}` : undefined,
}} }}
> >
<BracketMatchSlot <BracketMatchSlot
@ -431,6 +453,13 @@ interface BracketTreePaginatedProps {
firstScoringRoundIdx?: number; firstScoringRoundIdx?: number;
} }
interface AnimState {
fromPage: number;
toPage: number;
dir: "left" | "right";
phase: "sliding" | "settling";
}
export function BracketTreePaginated({ export function BracketTreePaginated({
rounds, rounds,
matchesByRound, matchesByRound,
@ -438,7 +467,6 @@ export function BracketTreePaginated({
userParticipantIds, userParticipantIds,
firstScoringRoundIdx, firstScoringRoundIdx,
}: BracketTreePaginatedProps) { }: BracketTreePaginatedProps) {
// Default to showing around the first scoring round
const defaultPage = Math.max( const defaultPage = Math.max(
0, 0,
Math.min( Math.min(
@ -448,78 +476,149 @@ export function BracketTreePaginated({
rounds.length - 2, rounds.length - 2,
), ),
); );
const [pageStart, setPageStart] = useState(defaultPage); const [page, setPage] = useState(defaultPage);
const [anim, setAnim] = useState<AnimState | null>(null);
const stripRef = useRef<HTMLDivElement>(null);
const visibleRounds = rounds.slice(pageStart, pageStart + 2); // Sliding phase: after React paints the strip at its initial offset, trigger the CSS transition
const canGoLeft = pageStart > 0; useEffect(() => {
const canGoRight = pageStart + 2 < rounds.length; if (!anim || anim.phase !== "sliding" || !stripRef.current) return;
const el = stripRef.current;
const finalX = anim.dir === "right" ? -SLOT_WIDTH : 0;
const raf = requestAnimationFrame(() => {
el.style.transition = "transform 200ms ease";
el.style.transform = `translateX(${finalX}px)`;
});
return () => cancelAnimationFrame(raf);
}, [anim]);
const maxMatchesInView = Math.max(...visibleRounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1); // Settling phase: slide done, cards now CSS-transition to toPage layout; clear after transitions
const bracketHeight = maxMatchesInView * (DESIRED_CARD_HEIGHT + CARD_GAP); useEffect(() => {
if (!anim || anim.phase !== "settling") return;
const timer = setTimeout(() => setAnim(null), 520);
return () => clearTimeout(timer);
}, [anim]);
const navigate = (newPage: number) => {
if (anim || newPage < 0 || newPage > rounds.length - 2) return;
setAnim({ fromPage: page, toPage: newPage, dir: newPage > page ? "right" : "left", phase: "sliding" });
};
const handleTransitionEnd = () => {
if (!anim || anim.phase !== "sliding") return;
if (stripRef.current) {
stripRef.current.style.transition = "none";
stripRef.current.style.transform = "translateX(0)";
}
setPage(anim.toPage);
setAnim(prev => prev ? { ...prev, phase: "settling" } : null);
};
const targetPage = anim ? anim.toPage : page;
const labelRounds = rounds.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 max = Math.max(...rs.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
};
const pageHeight = calcHeight(page);
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;
// 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
// Null: show current page
let leftRounds: string[];
let rightRounds: string[] = [];
let leftHeight: number;
let rightHeight = 0;
let settlingTransition = false;
if (anim?.phase === "sliding") {
leftRounds = anim.dir === "right" ? fromRounds : toRounds;
rightRounds = anim.dir === "right" ? toRounds : fromRounds;
leftHeight = anim.dir === "right" ? animFromHeight : animToHeight;
rightHeight = anim.dir === "right" ? animToHeight : animFromHeight;
} else if (anim?.phase === "settling") {
leftRounds = toRounds;
leftHeight = animToHeight;
settlingTransition = true;
} else {
leftRounds = visibleRounds;
leftHeight = pageHeight;
}
// During settling, minHeight transitions from animFromHeight to animToHeight in sync with cards
const containerMinHeight = anim?.phase === "settling" ? animToHeight : animFromHeight;
const initialX = anim?.phase === "sliding" && anim.dir === "left" ? -SLOT_WIDTH : 0;
return ( return (
<div> <div>
{/* Round tab navigation */} <div className="flex items-center gap-2 mb-3">
<div className="flex items-center gap-1 mb-3 overflow-x-auto pb-1">
{rounds.map((round, ri) => {
const isActive = ri >= pageStart && ri < pageStart + 2;
return (
<button
key={round}
onClick={() => setPageStart(Math.min(ri, rounds.length - 2))}
className={[
"shrink-0 px-2 py-1 rounded text-[10px] font-medium uppercase tracking-wide transition-colors",
isActive
? "bg-electric/15 text-electric border border-electric/30"
: "text-muted-foreground border border-transparent hover:text-foreground hover:border-border",
].join(" ")}
>
{round}
</button>
);
})}
</div>
{/* Bracket columns */}
<div style={{ minHeight: bracketHeight + LABEL_HEIGHT + 2 }}>
<TreeColumns
visibleRounds={visibleRounds}
matchesByRound={matchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
bracketHeight={bracketHeight}
/>
</div>
{/* Prev / Next navigation */}
<div className="flex items-center justify-between mt-3">
<Button <Button
variant="outline" variant="ghost"
size="sm" size="icon"
onClick={() => setPageStart((p) => Math.max(0, p - 1))} onClick={() => navigate(page - 1)}
disabled={!canGoLeft} disabled={page === 0 || !!anim}
className="gap-1 text-xs" className="h-7 w-7 shrink-0"
aria-label="Previous rounds"
> >
<ChevronLeft className="h-3 w-3" /> <ChevronLeft className="h-4 w-4" />
{canGoLeft ? rounds[pageStart - 1] : "—"}
</Button> </Button>
<span className="flex-1 text-center text-xs font-medium text-muted-foreground uppercase tracking-wide truncate">
<span className="text-xs text-muted-foreground"> {label}
{visibleRounds[0]}
{visibleRounds[1] ? `${visibleRounds[1]}` : ""}
</span> </span>
<Button <Button
variant="outline" variant="ghost"
size="sm" size="icon"
onClick={() => setPageStart((p) => Math.min(rounds.length - 2, p + 1))} onClick={() => navigate(page + 1)}
disabled={!canGoRight} disabled={page + 2 >= rounds.length || !!anim}
className="gap-1 text-xs" className="h-7 w-7 shrink-0"
aria-label="Next rounds"
> >
{canGoRight ? rounds[pageStart + 2] : "—"} <ChevronRight className="h-4 w-4" />
<ChevronRight className="h-3 w-3" />
</Button> </Button>
</div> </div>
<div style={{ width: SLOT_WIDTH, overflow: "hidden", minHeight: containerMinHeight + LABEL_HEIGHT + 2, transition: settlingTransition ? "min-height 500ms ease" : undefined }}>
<div
ref={anim?.phase === "sliding" ? stripRef : undefined}
style={{
display: "flex",
width: anim?.phase === "sliding" ? SLOT_WIDTH * 2 : SLOT_WIDTH,
transform: anim?.phase === "sliding" ? `translateX(${initialX}px)` : undefined,
}}
onTransitionEnd={handleTransitionEnd}
>
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
<TreeColumns
visibleRounds={leftRounds}
matchesByRound={matchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
bracketHeight={leftHeight}
transitionDuration={settlingTransition ? 500 : undefined}
/>
</div>
{anim?.phase === "sliding" && (
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
<TreeColumns
visibleRounds={rightRounds}
matchesByRound={matchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
bracketHeight={rightHeight}
/>
</div>
)}
</div>
</div>
</div> </div>
); );
} }

View file

@ -0,0 +1,116 @@
import type { ConferenceGroup } from "~/lib/bracket-templates";
import {
TreeColumns,
BracketTreePaginated,
type BracketMatch,
type BracketOwnership,
} from "./BracketTreeView";
interface NbaBracketLayoutProps {
matches: Array<{ round: string; matchNumber: number }>;
rounds: string[];
matchesByRound: Map<string, BracketMatch[]>;
ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>;
conferenceGroups: ConferenceGroup[];
scoringRoundIdx: number;
}
const DESIRED_CARD_HEIGHT = 112;
const CARD_GAP = 14;
function splitMatchesByConference(
matchesByRound: Map<string, BracketMatch[]>,
group: ConferenceGroup
): Map<string, BracketMatch[]> {
const result = new Map<string, BracketMatch[]>();
for (const [round, allowed] of Object.entries(group.roundMatchNumbers)) {
const roundMatches = matchesByRound.get(round) ?? [];
const filtered = roundMatches.filter((m) => allowed.includes(m.matchNumber));
if (filtered.length > 0) result.set(round, filtered);
}
return result;
}
function bracketHeight(matchesByRound: Map<string, BracketMatch[]>, rounds: string[]): number {
const max = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
}
export function NbaBracketLayout({
rounds,
matchesByRound,
ownershipMap,
userParticipantIds,
conferenceGroups,
scoringRoundIdx,
}: NbaBracketLayoutProps) {
// Rounds that belong to any conference group
const conferenceRoundSet = new Set(
conferenceGroups.flatMap((g) => Object.keys(g.roundMatchNumbers))
);
// Rounds not in any group are shared (e.g. NBA Finals)
const sharedRounds = rounds.filter((r) => !conferenceRoundSet.has(r));
// Per-conference round lists (preserving template order)
const conferenceRounds = conferenceGroups.map((g) =>
rounds.filter((r) => g.roundMatchNumbers[r] !== undefined)
);
// Shared rounds bracket height
const sharedMatches = new Map(
sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []])
);
const sharedHeight = bracketHeight(sharedMatches, sharedRounds);
return (
<>
{/* ── Desktop: two-conference stacked layout ── */}
<div className="hidden md:flex md:flex-col md:gap-8">
{conferenceGroups.map((group, gi) => {
const confRounds = conferenceRounds[gi];
const confMatches = splitMatchesByConference(matchesByRound, group);
const height = bracketHeight(confMatches, confRounds);
return (
<div key={group.name}>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
{group.name}
</p>
<TreeColumns
visibleRounds={confRounds}
matchesByRound={confMatches}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
bracketHeight={height}
/>
</div>
);
})}
{sharedRounds.length > 0 && (
<div>
<TreeColumns
visibleRounds={sharedRounds}
matchesByRound={sharedMatches}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
bracketHeight={sharedHeight}
/>
</div>
)}
</div>
{/* ── Mobile: paginated across all rounds ── */}
<div className="md:hidden">
<BracketTreePaginated
rounds={rounds}
matchesByRound={matchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
firstScoringRoundIdx={scoringRoundIdx}
/>
</div>
</>
);
}

View file

@ -1,5 +1,3 @@
import { useState } from "react";
import { Badge } from "~/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { import {
Table, Table,
@ -9,8 +7,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "~/components/ui/table"; } from "~/components/ui/table";
import { Button } from "~/components/ui/button"; import { Trophy, Star } from "lucide-react";
import { Trophy, Star, Columns2, LayoutList } from "lucide-react";
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge"; import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
import { import {
BracketTreeView, BracketTreeView,
@ -18,6 +15,9 @@ import {
type BracketMatch, type BracketMatch,
type BracketOwnership, type BracketOwnership,
} from "./BracketTreeView"; } from "./BracketTreeView";
import { getBracketTemplate } from "~/lib/bracket-templates";
import { NbaBracketLayout } from "./NbaBracketLayout";
import { TabbedBracketLayout } from "./TabbedBracketLayout";
interface Participant { interface Participant {
id: string; id: string;
@ -52,6 +52,7 @@ export interface TeamOwnership {
interface PlayoffBracketProps { interface PlayoffBracketProps {
matches: Match[]; matches: Match[];
rounds: string[]; // Ordered list of round names (earliest first) rounds: string[]; // Ordered list of round names (earliest first)
bracketTemplateId?: string | null;
preEliminatedParticipants?: { id: string; name: string }[]; preEliminatedParticipants?: { id: string; name: string }[];
participantPoints?: { participantId: string; points: number }[]; participantPoints?: { participantId: string; points: number }[];
partialScoreParticipantIds?: string[]; partialScoreParticipantIds?: string[];
@ -173,17 +174,6 @@ export function computeEliminatedByRound(
return result; return result;
} }
/** Returns true if every adjacent round pair has exactly ceil(prev/2) matches — pure single-elimination. */
function isSingleElimination(matchesByRound: Map<string, Match[]>, rounds: string[]): boolean {
if (rounds.length < 2) return true;
for (let i = 1; i < rounds.length; i++) {
const prev = matchesByRound.get(rounds[i - 1])?.length ?? 0;
const curr = matchesByRound.get(rounds[i])?.length ?? 0;
if (curr !== Math.ceil(prev / 2)) return false;
}
return true;
}
/** Find the index of the first round that has scoring matches. */ /** Find the index of the first round that has scoring matches. */
function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number { function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number {
for (let i = 0; i < rounds.length; i++) { for (let i = 0; i < rounds.length; i++) {
@ -195,6 +185,7 @@ function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: stri
export function PlayoffBracket({ export function PlayoffBracket({
matches, matches,
rounds, rounds,
bracketTemplateId = null,
preEliminatedParticipants = [], preEliminatedParticipants = [],
participantPoints = [], participantPoints = [],
partialScoreParticipantIds: _partialScoreParticipantIds = [], partialScoreParticipantIds: _partialScoreParticipantIds = [],
@ -210,19 +201,8 @@ export function PlayoffBracket({
const pointsMap = new Map(participantPoints.map((p) => [p.participantId, p.points])); const pointsMap = new Map(participantPoints.map((p) => [p.participantId, p.points]));
const matchesByRound = groupMatchesByRound(matches); const matchesByRound = groupMatchesByRound(matches);
const feederMap = buildFeederMap(matchesByRound, rounds);
// Determine if the bracket supports the visual tree view
const supportsTreeView = matches.length > 0 && isSingleElimination(matchesByRound, rounds);
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds); const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
const [viewMode, setViewMode] = useState<"tree" | "cards">("tree");
const getTbdLabel = (round: string, matchNumber: number, slot: "p1" | "p2") => {
const feeder = feederMap.get(`${round}:${matchNumber}:${slot}`);
if (!feeder) return "TBD";
return `Winner of ${feeder.round} M${feeder.matchNumber}`;
};
// 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 }>>();
@ -300,36 +280,10 @@ export function PlayoffBracket({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between gap-4"> <div>
<div> <h2 className="text-xl font-semibold">{title}</h2>
<h2 className="text-xl font-semibold">{title}</h2> {description && (
{description && ( <p className="text-sm text-muted-foreground mt-1">{description}</p>
<p className="text-sm text-muted-foreground mt-1">{description}</p>
)}
</div>
{/* View toggle — desktop only, when tree view is supported */}
{supportsTreeView && (
<div className="hidden md:flex items-center gap-1 rounded-md border border-border p-0.5">
<Button
variant="ghost"
size="sm"
className={`h-7 px-2 ${viewMode === "tree" ? "bg-muted" : ""}`}
onClick={() => setViewMode("tree")}
title="Bracket tree"
>
<Columns2 className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className={`h-7 px-2 ${viewMode === "cards" ? "bg-muted" : ""}`}
onClick={() => setViewMode("cards")}
title="Card list"
>
<LayoutList className="h-3.5 w-3.5" />
</Button>
</div>
)} )}
</div> </div>
@ -342,53 +296,48 @@ export function PlayoffBracket({
</div> </div>
) : ( ) : (
<div className="space-y-8"> <div className="space-y-8">
{/* ── Desktop: visual tree or card list ── */} {template?.phases ? (
{supportsTreeView && viewMode === "tree" ? ( <TabbedBracketLayout
<div className="hidden md:block"> rounds={rounds}
<BracketTreeView matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
rounds={rounds} ownershipMap={ownershipMap as Map<string, BracketOwnership>}
matchesByRound={matchesByRound as Map<string, BracketMatch[]>} userParticipantIds={userParticipantSet}
ownershipMap={ownershipMap as Map<string, BracketOwnership>} phases={template.phases}
userParticipantIds={userParticipantSet} scoringRoundIdx={scoringRoundIdx}
/> />
</div> ) : template?.conferenceGroups ? (
<NbaBracketLayout
matches={matches}
rounds={rounds}
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet}
conferenceGroups={template.conferenceGroups}
scoringRoundIdx={scoringRoundIdx}
/>
) : ( ) : (
<div className="hidden md:block"> <>
<CardListView {/* ── Desktop ── */}
rounds={rounds} <div className="hidden md:block">
matchesByRound={matchesByRound} <BracketTreeView
rounds={rounds}
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet}
/>
</div>
ownershipMap={ownershipMap} {/* ── Mobile ── */}
userParticipantSet={userParticipantSet} <div className="md:hidden">
showOwnership={showOwnership} <BracketTreePaginated
getTbdLabel={getTbdLabel} rounds={rounds}
/> matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
</div> ownershipMap={ownershipMap as Map<string, BracketOwnership>}
)} userParticipantIds={userParticipantSet}
firstScoringRoundIdx={scoringRoundIdx}
{/* ── Mobile: paginated tree or card list ── */} />
{supportsTreeView ? ( </div>
<div className="md:hidden"> </>
<BracketTreePaginated
rounds={rounds}
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet}
firstScoringRoundIdx={scoringRoundIdx}
/>
</div>
) : (
<div className="md:hidden">
<CardListView
rounds={rounds}
matchesByRound={matchesByRound}
ownershipMap={ownershipMap}
userParticipantSet={userParticipantSet}
showOwnership={showOwnership}
getTbdLabel={getTbdLabel}
/>
</div>
)} )}
{/* ── In Contention ── */} {/* ── In Contention ── */}
@ -589,176 +538,3 @@ export function PlayoffBracket({
); );
} }
// ─── Card list view (existing style, used as fallback) ───────────────────────
interface CardListViewProps {
rounds: string[];
matchesByRound: Map<string, Match[]>;
ownershipMap: Map<string, TeamOwnership>;
userParticipantSet: Set<string>;
showOwnership: boolean;
getTbdLabel: (round: string, matchNumber: number, slot: "p1" | "p2") => string;
}
function CardListView({
rounds,
matchesByRound,
ownershipMap,
userParticipantSet,
showOwnership,
getTbdLabel,
}: CardListViewProps) {
return (
<div className="space-y-8">
{rounds.map((round) => {
const roundMatches = matchesByRound.get(round) || [];
if (roundMatches.length === 0) return null;
return (
<div key={round}>
<div className="flex items-center gap-2 mb-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{round}
</h3>
<div className="flex-1 border-t border-border" />
<span className="text-xs text-muted-foreground">
{roundMatches.length}{" "}
{roundMatches.length === 1 ? "match" : "matches"}
</span>
</div>
<div className="grid md:grid-cols-2 gap-2">
{roundMatches.map((match) => {
const p1IsWinner = match.isComplete && match.winnerId === match.participant1Id;
const p2IsWinner = match.isComplete && match.winnerId === match.participant2Id;
const p1IsLoser = match.isComplete && match.loserId === match.participant1Id;
const p2IsLoser = match.isComplete && match.loserId === match.participant2Id;
const p1Name =
match.participant1?.name ||
(match.participant1Id ? "TBD" : getTbdLabel(round, match.matchNumber, "p1"));
const p2Name =
match.participant2?.name ||
(match.participant2Id ? "TBD" : getTbdLabel(round, match.matchNumber, "p2"));
const p1IsTbd = !match.participant1Id;
const p2IsTbd = !match.participant2Id;
const p1IsOwned = !p1IsTbd && userParticipantSet.has(match.participant1Id ?? "");
const p2IsOwned = !p2IsTbd && userParticipantSet.has(match.participant2Id ?? "");
const matchHasOwned = p1IsOwned || p2IsOwned;
const p1Ownership = showOwnership && match.participant1Id
? ownershipMap.get(match.participant1Id) || null
: null;
const p2Ownership = showOwnership && match.participant2Id
? ownershipMap.get(match.participant2Id) || null
: null;
// Corona: gradient for complete, electric for user's pick, subtle for pending
const coronaStyle: React.CSSProperties = match.isComplete
? { background: "linear-gradient(to bottom, #adf661, #2ce1c1)" }
: matchHasOwned
? { background: "rgba(44, 225, 193, 0.4)" }
: { background: "rgba(255, 255, 255, 0.07)" };
return (
<div key={match.id} className="relative overflow-hidden rounded-lg">
{/* Corona layer — outer overflow-hidden handles all corner rounding */}
<div className="absolute top-0.5 bottom-0.5 left-0 right-0" style={coronaStyle} />
{/* Inner card — only mr-1 to expose corona on right */}
<div className="relative z-10 bg-muted rounded-l-none rounded-r-[5px] px-3 py-2 mr-1">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-mono text-muted-foreground">
M{match.matchNumber}
</span>
{match.isComplete && (
<Badge variant="secondary" className="text-xs">Done</Badge>
)}
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
{/* Participant 1 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
{p1IsOwned && !p1IsWinner && (
<span className="inline-block w-1.5 h-1.5 rounded-full bg-electric shrink-0" />
)}
{p1IsWinner && (
<Trophy className="h-3.5 w-3.5 text-yellow-500 shrink-0" />
)}
<span
className={[
"text-sm font-medium truncate",
p1IsLoser ? "text-muted-foreground line-through" : "",
p1IsTbd ? "text-muted-foreground italic font-normal" : "",
p1IsWinner ? "font-semibold" : "",
p1IsOwned && !p1IsLoser ? "text-electric" : "",
].filter(Boolean).join(" ")}
>
{p1Name}
</span>
</div>
{!p1IsTbd && p1Ownership && (
<div className="mt-1">
<TeamOwnerBadge teamName={p1Ownership.teamName} ownerName={p1Ownership.ownerName} />
</div>
)}
</div>
{/* Scores */}
<div className="shrink-0 flex sm:flex-col items-center gap-1 sm:gap-0.5 min-w-[2.5rem]">
{match.participant1Score ? (
<>
<span className="text-xs tabular-nums font-medium">
{parseFloat(match.participant1Score)}
</span>
<span className="text-[10px] text-muted-foreground/60 uppercase tracking-wider">vs</span>
<span className="text-xs tabular-nums font-medium">
{match.participant2Score ? parseFloat(match.participant2Score) : "—"}
</span>
</>
) : (
<span className="text-xs text-muted-foreground">vs</span>
)}
</div>
{/* Participant 2 */}
<div className="flex-1 min-w-0 flex flex-col sm:items-end">
<div className="flex items-center gap-1 sm:justify-end">
<span
className={[
"text-sm font-medium truncate",
p2IsLoser ? "text-muted-foreground line-through" : "",
p2IsTbd ? "text-muted-foreground italic font-normal" : "",
p2IsWinner ? "font-semibold" : "",
p2IsOwned && !p2IsLoser ? "text-electric" : "",
].filter(Boolean).join(" ")}
>
{p2Name}
</span>
{p2IsWinner && (
<Trophy className="h-3.5 w-3.5 text-yellow-500 shrink-0" />
)}
{p2IsOwned && !p2IsWinner && (
<span className="inline-block w-1.5 h-1.5 rounded-full bg-electric shrink-0" />
)}
</div>
{!p2IsTbd && p2Ownership && (
<div className="mt-1">
<TeamOwnerBadge teamName={p2Ownership.teamName} ownerName={p2Ownership.ownerName} align="right" />
</div>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
}

View file

@ -96,6 +96,7 @@ interface SportSeasonDisplayProps {
// Playoff data // Playoff data
playoffMatches?: Match[]; playoffMatches?: Match[];
playoffRounds?: string[]; playoffRounds?: string[];
bracketTemplateId?: string | null;
preEliminatedParticipants?: { id: string; name: string }[]; preEliminatedParticipants?: { id: string; name: string }[];
participantPoints?: { participantId: string; points: number }[]; participantPoints?: { participantId: string; points: number }[];
partialScoreParticipantIds?: string[]; partialScoreParticipantIds?: string[];
@ -124,6 +125,7 @@ export function SportSeasonDisplay({
sportName: _sportName, sportName: _sportName,
playoffMatches = [], playoffMatches = [],
playoffRounds = [], playoffRounds = [],
bracketTemplateId = null,
preEliminatedParticipants = [], preEliminatedParticipants = [],
participantPoints = [], participantPoints = [],
partialScoreParticipantIds = [], partialScoreParticipantIds = [],
@ -147,6 +149,7 @@ export function SportSeasonDisplay({
<PlayoffBracket <PlayoffBracket
matches={playoffMatches} matches={playoffMatches}
rounds={playoffRounds} rounds={playoffRounds}
bracketTemplateId={bracketTemplateId}
preEliminatedParticipants={preEliminatedParticipants} preEliminatedParticipants={preEliminatedParticipants}
participantPoints={participantPoints} participantPoints={participantPoints}
partialScoreParticipantIds={partialScoreParticipantIds} partialScoreParticipantIds={partialScoreParticipantIds}

View file

@ -0,0 +1,140 @@
import { useState } from "react";
import { cn } from "~/lib/utils";
import type { BracketPhase, ConferenceGroup } from "~/lib/bracket-templates";
import {
TreeColumns,
BracketTreePaginated,
type BracketMatch,
type BracketOwnership,
} from "./BracketTreeView";
interface TabbedBracketLayoutProps {
rounds: string[];
matchesByRound: Map<string, BracketMatch[]>;
ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>;
phases: BracketPhase[];
scoringRoundIdx: number;
}
const CARD_H = 112;
const CARD_GAP = 14;
function groupMatches(
matchesByRound: Map<string, BracketMatch[]>,
group: ConferenceGroup
): Map<string, BracketMatch[]> {
const out = new Map<string, BracketMatch[]>();
for (const [round, allowed] of Object.entries(group.roundMatchNumbers)) {
const filtered = (matchesByRound.get(round) ?? []).filter((m) =>
allowed.includes(m.matchNumber)
);
if (filtered.length > 0) out.set(round, filtered);
}
return out;
}
function phaseHeight(matchesByRound: Map<string, BracketMatch[]>, rounds: string[]): number {
const max = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
return max * (CARD_H + CARD_GAP);
}
export function TabbedBracketLayout({
rounds,
matchesByRound,
ownershipMap,
userParticipantIds,
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 (
<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>
{/* Desktop */}
<div className="hidden md:block">
{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>
) : (
<TreeColumns
visibleRounds={phaseRounds}
matchesByRound={phaseMatchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
bracketHeight={phaseHeight(phaseMatchesByRound, phaseRounds)}
/>
)}
</div>
{/* Mobile */}
<div className="md:hidden">
<BracketTreePaginated
rounds={phaseRounds}
matchesByRound={phaseMatchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
/>
</div>
</div>
);
}

View file

@ -61,6 +61,35 @@ export interface BracketRegion {
playIns: BracketPlayIn[]; playIns: BracketPlayIn[];
} }
/**
* Defines a named conference or sub-bracket group within a bracket.
* Used to split matches into East/West (NBA) or regional groups (NCAA) for display.
*/
export interface ConferenceGroup {
/** Display name, e.g. "Eastern Conference" */
name: string;
/**
* Maps round name the match numbers (1-based) that belong to this conference.
* Rounds not listed here are either shared (Finals) or not applicable.
*/
roundMatchNumbers: Record<string, number[]>;
}
/**
* A named tab/section within a bracket display.
* Either shows a simple list of rounds, or a set of conference/regional sub-brackets.
*/
export interface BracketPhase {
/** Tab label */
name: string;
/** Simple ordered list of round names to show in this tab */
rounds?: string[];
/** Regional/conference sub-groups to show stacked in this tab */
groups?: ConferenceGroup[];
/** Rounds rendered after groups (e.g. Final Four, Championship) */
sharedRounds?: string[];
}
export interface BracketTemplate { export interface BracketTemplate {
/** Unique identifier for this template */ /** Unique identifier for this template */
id: string; id: string;
@ -88,6 +117,17 @@ export interface BracketTemplate {
* Length must equal totalTeams. * Length must equal totalTeams.
*/ */
participantLabels?: string[]; participantLabels?: string[];
/**
* Optional conference/group split for brackets with parallel sub-brackets (NBA).
* When present, the UI renders each group as a separate sub-bracket.
* Rounds not listed in any group's roundMatchNumbers are treated as shared (e.g., Finals).
*/
conferenceGroups?: ConferenceGroup[];
/**
* Optional tabbed phase display (NCAA).
* When present, the UI renders a tab switcher with each phase as a section.
*/
phases?: BracketPhase[];
} }
/** All seed numbers in a standard 16-team regional bracket (116) */ /** All seed numbers in a standard 16-team regional bracket (116) */
@ -474,6 +514,55 @@ export const NCAA_68: BracketTemplate = {
], ],
}, },
], ],
// Two-tab display: First Four → Bracket (4 regional sub-brackets + Final Four)
phases: [
{
name: "First Four",
rounds: ["First Four"],
},
{
name: "Bracket",
groups: [
{
name: "East Region",
roundMatchNumbers: {
"Round of 64": [1, 2, 3, 4, 5, 6, 7, 8],
"Round of 32": [1, 2, 3, 4],
"Sweet Sixteen": [1, 2],
"Elite Eight": [1],
},
},
{
name: "South Region",
roundMatchNumbers: {
"Round of 64": [9, 10, 11, 12, 13, 14, 15, 16],
"Round of 32": [5, 6, 7, 8],
"Sweet Sixteen": [3, 4],
"Elite Eight": [2],
},
},
{
name: "West Region",
roundMatchNumbers: {
"Round of 64": [17, 18, 19, 20, 21, 22, 23, 24],
"Round of 32": [9, 10, 11, 12],
"Sweet Sixteen": [5, 6],
"Elite Eight": [3],
},
},
{
name: "Midwest Region",
roundMatchNumbers: {
"Round of 64": [25, 26, 27, 28, 29, 30, 31, 32],
"Round of 32": [13, 14, 15, 16],
"Sweet Sixteen": [7, 8],
"Elite Eight": [4],
},
},
],
sharedRounds: ["Final Four", "Championship"],
},
],
}; };
/** /**
@ -767,6 +856,16 @@ export const NBA_20: BracketTemplate = {
"West 1", "West 2", "West 3", "West 4", "West 5", "West 1", "West 2", "West 3", "West 4", "West 5",
"West 6", "West 7", "West 8", "West 9", "West 10", "West 6", "West 7", "West 8", "West 9", "West 10",
], ],
phases: [
{
name: "Play-In",
rounds: ["Play-In Round 1", "Play-In Round 2"],
},
{
name: "Playoffs",
rounds: ["First Round", "Conference Semifinals", "Conference Finals", "NBA Finals"],
},
],
}; };
/** /**

View file

@ -166,6 +166,8 @@ export async function loader(args: Route.LoaderArgs) {
}; };
let groupStandings: GroupStandingData[] = []; let groupStandings: GroupStandingData[] = [];
let bracketTemplateId: string | null = null;
if (scoringPattern === "playoff_bracket") { if (scoringPattern === "playoff_bracket") {
// Fetch playoff matches via scoring events // Fetch playoff matches via scoring events
let templateId: string | undefined; let templateId: string | undefined;
@ -187,6 +189,7 @@ export async function loader(args: Route.LoaderArgs) {
playoffMatches = matches as PlayoffMatchWithRelations[]; playoffMatches = matches as PlayoffMatchWithRelations[];
templateId = events.find((e) => e.bracketTemplateId)?.bracketTemplateId ?? undefined; templateId = events.find((e) => e.bracketTemplateId)?.bracketTemplateId ?? undefined;
bracketTemplateId = templateId ?? null;
const template = templateId ? getBracketTemplate(templateId) : undefined; const template = templateId ? getBracketTemplate(templateId) : undefined;
playoffRounds = getOrderedRoundsFromMatches(matches, template); playoffRounds = getOrderedRoundsFromMatches(matches, template);
@ -338,5 +341,6 @@ export async function loader(args: Route.LoaderArgs) {
regularSeasonStandings, regularSeasonStandings,
participantEvs, participantEvs,
groupStandings, groupStandings,
bracketTemplateId,
}; };
} }

View file

@ -67,6 +67,7 @@ export default function SportSeasonDetail({
regularSeasonStandings, regularSeasonStandings,
participantEvs, participantEvs,
groupStandings, groupStandings,
bracketTemplateId,
} = loaderData; } = loaderData;
const hasBracket = playoffMatches && playoffMatches.length > 0; const hasBracket = playoffMatches && playoffMatches.length > 0;
@ -156,6 +157,7 @@ export default function SportSeasonDetail({
sportName={sportsSeason.sport.name} sportName={sportsSeason.sport.name}
playoffMatches={playoffMatches} playoffMatches={playoffMatches}
playoffRounds={playoffRounds} playoffRounds={playoffRounds}
bracketTemplateId={bracketTemplateId}
preEliminatedParticipants={preEliminatedParticipants} preEliminatedParticipants={preEliminatedParticipants}
participantPoints={participantPoints} participantPoints={participantPoints}
partialScoreParticipantIds={partialScoreParticipantIds} partialScoreParticipantIds={partialScoreParticipantIds}