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 { Button } from "~/components/ui/button";
@ -29,6 +29,7 @@ export interface BracketOwnership {
const COLUMN_WIDTH = 152;
const CONNECTOR_WIDTH = 24;
const SLOT_WIDTH = 2 * COLUMN_WIDTH + CONNECTOR_WIDTH; // viewport width for 2 columns
const LABEL_HEIGHT = 32;
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
@ -265,10 +266,12 @@ function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: Connect
const mid = CONNECTOR_WIDTH / 2;
const paths: string[] = [];
if (nextMatches.length === Math.ceil(currentMatches.length / 2)) {
const currentSlotH = bracketHeight / Math.max(currentMatches.length, 1);
const nextSlotH = bracketHeight / Math.max(nextMatches.length, 1);
const currentSlotH = bracketHeight / Math.max(currentMatches.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++) {
const topY = (2 * k) * currentSlotH + currentSlotH / 2;
const midY = k * nextSlotH + nextSlotH / 2;
@ -276,14 +279,29 @@ function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: Connect
if (botIdx < currentMatches.length) {
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 ${mid} ${midY} H ${CONNECTOR_WIDTH}`);
} else {
// Odd last match: straight line through
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 (
@ -315,17 +333,20 @@ interface TreeColumnsProps {
ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>;
bracketHeight: number;
transitionDuration?: number;
}
function TreeColumns({
export function TreeColumns({
visibleRounds,
matchesByRound,
ownershipMap,
userParticipantIds,
bracketHeight,
transitionDuration,
}: TreeColumnsProps) {
const tr = transitionDuration ? `${transitionDuration}ms ease` : undefined;
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) => {
const roundMatches = matchesByRound.get(round) ?? [];
const slotHeight = bracketHeight / Math.max(roundMatches.length, 1);
@ -345,7 +366,7 @@ function TreeColumns({
{round}
</div>
<div style={{ position: "relative", height: bracketHeight }}>
<div style={{ position: "relative", height: bracketHeight, transition: tr ? `height ${tr}` : undefined }}>
{roundMatches.map((match, matchIdx) => (
<div
key={match.id}
@ -355,6 +376,7 @@ function TreeColumns({
left: 0,
right: 0,
height: cardHeight,
transition: tr ? `top ${tr}, height ${tr}` : undefined,
}}
>
<BracketMatchSlot
@ -431,6 +453,13 @@ interface BracketTreePaginatedProps {
firstScoringRoundIdx?: number;
}
interface AnimState {
fromPage: number;
toPage: number;
dir: "left" | "right";
phase: "sliding" | "settling";
}
export function BracketTreePaginated({
rounds,
matchesByRound,
@ -438,7 +467,6 @@ export function BracketTreePaginated({
userParticipantIds,
firstScoringRoundIdx,
}: BracketTreePaginatedProps) {
// Default to showing around the first scoring round
const defaultPage = Math.max(
0,
Math.min(
@ -448,78 +476,149 @@ export function BracketTreePaginated({
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);
const canGoLeft = pageStart > 0;
const canGoRight = pageStart + 2 < rounds.length;
// Sliding phase: after React paints the strip at its initial offset, trigger the CSS transition
useEffect(() => {
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);
const bracketHeight = maxMatchesInView * (DESIRED_CARD_HEIGHT + CARD_GAP);
// Settling phase: slide done, cards now CSS-transition to toPage layout; clear after transitions
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 (
<div>
{/* Round tab navigation */}
<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">
<div className="flex items-center gap-2 mb-3">
<Button
variant="outline"
size="sm"
onClick={() => setPageStart((p) => Math.max(0, p - 1))}
disabled={!canGoLeft}
className="gap-1 text-xs"
variant="ghost"
size="icon"
onClick={() => navigate(page - 1)}
disabled={page === 0 || !!anim}
className="h-7 w-7 shrink-0"
aria-label="Previous rounds"
>
<ChevronLeft className="h-3 w-3" />
{canGoLeft ? rounds[pageStart - 1] : "—"}
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-xs text-muted-foreground">
{visibleRounds[0]}
{visibleRounds[1] ? `${visibleRounds[1]}` : ""}
<span className="flex-1 text-center text-xs font-medium text-muted-foreground uppercase tracking-wide truncate">
{label}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPageStart((p) => Math.min(rounds.length - 2, p + 1))}
disabled={!canGoRight}
className="gap-1 text-xs"
variant="ghost"
size="icon"
onClick={() => navigate(page + 1)}
disabled={page + 2 >= rounds.length || !!anim}
className="h-7 w-7 shrink-0"
aria-label="Next rounds"
>
{canGoRight ? rounds[pageStart + 2] : "—"}
<ChevronRight className="h-3 w-3" />
<ChevronRight className="h-4 w-4" />
</Button>
</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>
);
}

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 {
Table,
@ -9,8 +7,7 @@ import {
TableHeader,
TableRow,
} from "~/components/ui/table";
import { Button } from "~/components/ui/button";
import { Trophy, Star, Columns2, LayoutList } from "lucide-react";
import { Trophy, Star } from "lucide-react";
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
import {
BracketTreeView,
@ -18,6 +15,9 @@ import {
type BracketMatch,
type BracketOwnership,
} from "./BracketTreeView";
import { getBracketTemplate } from "~/lib/bracket-templates";
import { NbaBracketLayout } from "./NbaBracketLayout";
import { TabbedBracketLayout } from "./TabbedBracketLayout";
interface Participant {
id: string;
@ -52,6 +52,7 @@ export interface TeamOwnership {
interface PlayoffBracketProps {
matches: Match[];
rounds: string[]; // Ordered list of round names (earliest first)
bracketTemplateId?: string | null;
preEliminatedParticipants?: { id: string; name: string }[];
participantPoints?: { participantId: string; points: number }[];
partialScoreParticipantIds?: string[];
@ -173,17 +174,6 @@ export function computeEliminatedByRound(
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. */
function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number {
for (let i = 0; i < rounds.length; i++) {
@ -195,6 +185,7 @@ function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: stri
export function PlayoffBracket({
matches,
rounds,
bracketTemplateId = null,
preEliminatedParticipants = [],
participantPoints = [],
partialScoreParticipantIds: _partialScoreParticipantIds = [],
@ -210,19 +201,8 @@ export function PlayoffBracket({
const pointsMap = new Map(participantPoints.map((p) => [p.participantId, p.points]));
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 [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}`;
};
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
// Build elimination rankings
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
@ -300,36 +280,10 @@ export function PlayoffBracket({
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-xl font-semibold">{title}</h2>
{description && (
<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>
<h2 className="text-xl font-semibold">{title}</h2>
{description && (
<p className="text-sm text-muted-foreground mt-1">{description}</p>
)}
</div>
@ -342,53 +296,48 @@ export function PlayoffBracket({
</div>
) : (
<div className="space-y-8">
{/* ── Desktop: visual tree or card list ── */}
{supportsTreeView && viewMode === "tree" ? (
<div className="hidden md:block">
<BracketTreeView
rounds={rounds}
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet}
/>
</div>
{template?.phases ? (
<TabbedBracketLayout
rounds={rounds}
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet}
phases={template.phases}
scoringRoundIdx={scoringRoundIdx}
/>
) : 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
rounds={rounds}
matchesByRound={matchesByRound}
<>
{/* ── Desktop ── */}
<div className="hidden md:block">
<BracketTreeView
rounds={rounds}
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet}
/>
</div>
ownershipMap={ownershipMap}
userParticipantSet={userParticipantSet}
showOwnership={showOwnership}
getTbdLabel={getTbdLabel}
/>
</div>
)}
{/* ── Mobile: paginated tree or card list ── */}
{supportsTreeView ? (
<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>
{/* ── Mobile ── */}
<div className="md:hidden">
<BracketTreePaginated
rounds={rounds}
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet}
firstScoringRoundIdx={scoringRoundIdx}
/>
</div>
</>
)}
{/* ── 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
playoffMatches?: Match[];
playoffRounds?: string[];
bracketTemplateId?: string | null;
preEliminatedParticipants?: { id: string; name: string }[];
participantPoints?: { participantId: string; points: number }[];
partialScoreParticipantIds?: string[];
@ -124,6 +125,7 @@ export function SportSeasonDisplay({
sportName: _sportName,
playoffMatches = [],
playoffRounds = [],
bracketTemplateId = null,
preEliminatedParticipants = [],
participantPoints = [],
partialScoreParticipantIds = [],
@ -147,6 +149,7 @@ export function SportSeasonDisplay({
<PlayoffBracket
matches={playoffMatches}
rounds={playoffRounds}
bracketTemplateId={bracketTemplateId}
preEliminatedParticipants={preEliminatedParticipants}
participantPoints={participantPoints}
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[];
}
/**
* 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 {
/** Unique identifier for this template */
id: string;
@ -88,6 +117,17 @@ export interface BracketTemplate {
* Length must equal totalTeams.
*/
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) */
@ -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 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 bracketTemplateId: string | null = null;
if (scoringPattern === "playoff_bracket") {
// Fetch playoff matches via scoring events
let templateId: string | undefined;
@ -187,6 +189,7 @@ export async function loader(args: Route.LoaderArgs) {
playoffMatches = matches as PlayoffMatchWithRelations[];
templateId = events.find((e) => e.bracketTemplateId)?.bracketTemplateId ?? undefined;
bracketTemplateId = templateId ?? null;
const template = templateId ? getBracketTemplate(templateId) : undefined;
playoffRounds = getOrderedRoundsFromMatches(matches, template);
@ -338,5 +341,6 @@ export async function loader(args: Route.LoaderArgs) {
regularSeasonStandings,
participantEvs,
groupStandings,
bracketTemplateId,
};
}

View file

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