diff --git a/app/components/scoring/BracketTreeView.tsx b/app/components/scoring/BracketTreeView.tsx index 3aae37d..bdb74d0 100644 --- a/app/components/scoring/BracketTreeView.tsx +++ b/app/components/scoring/BracketTreeView.tsx @@ -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(); + 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; userParticipantIds: Set; bracketHeight: number; + transitionDuration?: number; } -function TreeColumns({ +export function TreeColumns({ visibleRounds, matchesByRound, ownershipMap, userParticipantIds, bracketHeight, + transitionDuration, }: TreeColumnsProps) { + const tr = transitionDuration ? `${transitionDuration}ms ease` : undefined; return ( -
+
{visibleRounds.map((round, ri) => { const roundMatches = matchesByRound.get(round) ?? []; const slotHeight = bracketHeight / Math.max(roundMatches.length, 1); @@ -345,7 +366,7 @@ function TreeColumns({ {round}
-
+
{roundMatches.map((match, matchIdx) => (
(null); + const stripRef = useRef(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 (
- {/* Round tab navigation */} -
- {rounds.map((round, ri) => { - const isActive = ri >= pageStart && ri < pageStart + 2; - return ( - - ); - })} -
- - {/* Bracket columns */} -
- -
- - {/* Prev / Next navigation */} -
+
- - - {visibleRounds[0]} - {visibleRounds[1] ? ` → ${visibleRounds[1]}` : ""} + + {label} -
+ +
+
+
+ +
+ {anim?.phase === "sliding" && ( +
+ +
+ )} +
+
); } diff --git a/app/components/scoring/NbaBracketLayout.tsx b/app/components/scoring/NbaBracketLayout.tsx new file mode 100644 index 0000000..a10b841 --- /dev/null +++ b/app/components/scoring/NbaBracketLayout.tsx @@ -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; + ownershipMap: Map; + userParticipantIds: Set; + conferenceGroups: ConferenceGroup[]; + scoringRoundIdx: number; +} + +const DESIRED_CARD_HEIGHT = 112; +const CARD_GAP = 14; + +function splitMatchesByConference( + matchesByRound: Map, + group: ConferenceGroup +): Map { + const result = new Map(); + 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, 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 ── */} +
+ {conferenceGroups.map((group, gi) => { + const confRounds = conferenceRounds[gi]; + const confMatches = splitMatchesByConference(matchesByRound, group); + const height = bracketHeight(confMatches, confRounds); + + return ( +
+

+ {group.name} +

+ +
+ ); + })} + + {sharedRounds.length > 0 && ( +
+ +
+ )} +
+ + {/* ── Mobile: paginated across all rounds ── */} +
+ +
+ + ); +} diff --git a/app/components/scoring/PlayoffBracket.tsx b/app/components/scoring/PlayoffBracket.tsx index 7d9cc93..9281ed0 100644 --- a/app/components/scoring/PlayoffBracket.tsx +++ b/app/components/scoring/PlayoffBracket.tsx @@ -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, 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, rounds: string[]): number { for (let i = 0; i < rounds.length; i++) { @@ -195,6 +185,7 @@ function firstScoringRoundIdx(matchesByRound: Map, 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>(); @@ -300,36 +280,10 @@ export function PlayoffBracket({ return (
{/* Header */} -
-
-

{title}

- {description && ( -

{description}

- )} -
- - {/* View toggle — desktop only, when tree view is supported */} - {supportsTreeView && ( -
- - -
+
+

{title}

+ {description && ( +

{description}

)}
@@ -342,53 +296,48 @@ export function PlayoffBracket({
) : (
- {/* ── Desktop: visual tree or card list ── */} - {supportsTreeView && viewMode === "tree" ? ( -
- } - ownershipMap={ownershipMap as Map} - userParticipantIds={userParticipantSet} - /> -
+ {template?.phases ? ( + } + ownershipMap={ownershipMap as Map} + userParticipantIds={userParticipantSet} + phases={template.phases} + scoringRoundIdx={scoringRoundIdx} + /> + ) : template?.conferenceGroups ? ( + } + ownershipMap={ownershipMap as Map} + userParticipantIds={userParticipantSet} + conferenceGroups={template.conferenceGroups} + scoringRoundIdx={scoringRoundIdx} + /> ) : ( -
- + {/* ── Desktop ── */} +
+ } + ownershipMap={ownershipMap as Map} + userParticipantIds={userParticipantSet} + /> +
- ownershipMap={ownershipMap} - userParticipantSet={userParticipantSet} - showOwnership={showOwnership} - getTbdLabel={getTbdLabel} - /> -
- )} - - {/* ── Mobile: paginated tree or card list ── */} - {supportsTreeView ? ( -
- } - ownershipMap={ownershipMap as Map} - userParticipantIds={userParticipantSet} - firstScoringRoundIdx={scoringRoundIdx} - /> -
- ) : ( -
- -
+ {/* ── Mobile ── */} +
+ } + ownershipMap={ownershipMap as Map} + userParticipantIds={userParticipantSet} + firstScoringRoundIdx={scoringRoundIdx} + /> +
+ )} {/* ── In Contention ── */} @@ -589,176 +538,3 @@ export function PlayoffBracket({ ); } -// ─── Card list view (existing style, used as fallback) ─────────────────────── - -interface CardListViewProps { - rounds: string[]; - matchesByRound: Map; - ownershipMap: Map; - userParticipantSet: Set; - showOwnership: boolean; - getTbdLabel: (round: string, matchNumber: number, slot: "p1" | "p2") => string; -} - -function CardListView({ - rounds, - matchesByRound, - ownershipMap, - userParticipantSet, - showOwnership, - getTbdLabel, -}: CardListViewProps) { - return ( -
- {rounds.map((round) => { - const roundMatches = matchesByRound.get(round) || []; - if (roundMatches.length === 0) return null; - - return ( -
-
-

- {round} -

-
- - {roundMatches.length}{" "} - {roundMatches.length === 1 ? "match" : "matches"} - -
- -
- {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 ( -
- {/* Corona layer — outer overflow-hidden handles all corner rounding */} -
- {/* Inner card — only mr-1 to expose corona on right */} -
-
- - M{match.matchNumber} - - {match.isComplete && ( - Done - )} -
- -
- {/* Participant 1 */} -
-
- {p1IsOwned && !p1IsWinner && ( - - )} - {p1IsWinner && ( - - )} - - {p1Name} - -
- {!p1IsTbd && p1Ownership && ( -
- -
- )} -
- - {/* Scores */} -
- {match.participant1Score ? ( - <> - - {parseFloat(match.participant1Score)} - - vs - - {match.participant2Score ? parseFloat(match.participant2Score) : "—"} - - - ) : ( - vs - )} -
- - {/* Participant 2 */} -
-
- - {p2Name} - - {p2IsWinner && ( - - )} - {p2IsOwned && !p2IsWinner && ( - - )} -
- {!p2IsTbd && p2Ownership && ( -
- -
- )} -
-
-
-
- ); - })} -
-
- ); - })} -
- ); -} diff --git a/app/components/scoring/SportSeasonDisplay.tsx b/app/components/scoring/SportSeasonDisplay.tsx index aaed3ab..44b71e7 100644 --- a/app/components/scoring/SportSeasonDisplay.tsx +++ b/app/components/scoring/SportSeasonDisplay.tsx @@ -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({ ; + ownershipMap: Map; + userParticipantIds: Set; + phases: BracketPhase[]; + scoringRoundIdx: number; +} + +const CARD_H = 112; +const CARD_GAP = 14; + +function groupMatches( + matchesByRound: Map, + group: ConferenceGroup +): Map { + const out = new Map(); + 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, 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 ( +
+
+ {phases.map((p, i) => ( + + ))} +
+ + {/* Desktop */} +
+ {phase.groups ? ( +
+ {phase.groups.map((group) => { + const gMatches = groupMatches(matchesByRound, group); + const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined); + return ( +
+

+ {group.name} +

+ +
+ ); + })} + {sharedRounds.length > 0 && ( + + )} +
+ ) : ( + + )} +
+ + {/* Mobile */} +
+ = 0 ? phaseFirstScoringIdx : undefined} + /> +
+
+ ); +} diff --git a/app/lib/bracket-templates.ts b/app/lib/bracket-templates.ts index 6deb0b6..889af49 100644 --- a/app/lib/bracket-templates.ts +++ b/app/lib/bracket-templates.ts @@ -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; +} + +/** + * 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 (1–16) */ @@ -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"], + }, + ], }; /** diff --git a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts index 80e7e03..cdf5118 100644 --- a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts +++ b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts @@ -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, }; } diff --git a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx index 9746f77..2dad264 100644 --- a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx +++ b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx @@ -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}