diff --git a/app/components/scoring/BracketTreePaginated.tsx b/app/components/scoring/BracketTreePaginated.tsx index 87acd97..6dad4a5 100644 --- a/app/components/scoring/BracketTreePaginated.tsx +++ b/app/components/scoring/BracketTreePaginated.tsx @@ -1,13 +1,16 @@ import { ChevronLeft, ChevronRight } from "lucide-react"; import { Button } from "~/components/ui/button"; import { useRoundTransition } from "~/hooks/useRoundTransition"; +import type { FeederMap } from "~/lib/bracket-layout"; +import type { BracketTemplate } from "~/lib/bracket-templates"; import { TreeColumns, BracketMatchSlot, + bracketGeometry, + windowGeometry, SLOT_WIDTH, LABEL_HEIGHT, DESIRED_CARD_HEIGHT, - CARD_GAP, MAX_CARD_HEIGHT, type BracketMatch, type BracketOwnership, @@ -21,6 +24,8 @@ interface BracketTreePaginatedProps { /** Index of the first scoring round — default page starts here */ firstScoringRoundIdx?: number; thirdPlaceRound?: string; + feeders?: FeederMap; + template?: BracketTemplate; } export function BracketTreePaginated({ @@ -30,63 +35,68 @@ export function BracketTreePaginated({ userParticipantIds, firstScoringRoundIdx, thirdPlaceRound, + feeders, + template, }: BracketTreePaginatedProps) { const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds; const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined; + // Pages are pairs of layout columns, not pairs of rounds: a column can mix rounds + // when teams enter the bracket at different points (see computeGroupLayout). + const geometry = bracketGeometry( + mainRounds, + matchesByRound, + feeders, + template?.rounds.map((r) => r.name) ?? mainRounds + ); + const columns = geometry.layout.columns; + const lastPage = Math.max(columns.length - 2, 0); + const defaultPage = Math.max( 0, Math.min( - firstScoringRoundIdx !== undefined - ? Math.max(0, firstScoringRoundIdx - 1) - : mainRounds.length - 2, - mainRounds.length - 2, + firstScoringRoundIdx !== undefined ? Math.max(0, firstScoringRoundIdx - 1) : lastPage, + lastPage, ), ); const { page, anim, stripRef, navigate, handleTransitionEnd } = useRoundTransition( - mainRounds.length - 2, + lastPage, defaultPage, ); - const targetPage = anim ? anim.toPage : page; - const labelRounds = mainRounds.slice(targetPage, targetPage + 2); - const label = labelRounds[1] ? `${labelRounds[0]} → ${labelRounds[1]}` : labelRounds[0]; - - const calcHeight = (p: number) => { - const rs = mainRounds.slice(p, p + 2); - const max = Math.max(...rs.map((r) => matchesByRound.get(r)?.length ?? 0), 1); - return max * (DESIRED_CARD_HEIGHT + CARD_GAP); + const pageGeometry = (p: number) => windowGeometry(geometry, p, p + 1); + const labelFor = (p: number) => { + const [a, b] = [columns[p]?.label, columns[p + 1]?.label]; + return b ? `${a} → ${b}` : (a ?? ""); }; - const pageHeight = calcHeight(page); - const animFromHeight = anim ? calcHeight(anim.fromPage) : pageHeight; - const animToHeight = anim ? calcHeight(anim.toPage) : pageHeight; + const label = labelFor(anim ? anim.toPage : page); - const visibleRounds = mainRounds.slice(page, page + 2); - const fromRounds = anim ? mainRounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds; - const toRounds = anim ? mainRounds.slice(anim.toPage, anim.toPage + 2) : visibleRounds; + const pageG = pageGeometry(page); + const animFromG = anim ? pageGeometry(anim.fromPage) : pageG; + const animToG = anim ? pageGeometry(anim.toPage) : pageG; - let leftRounds: string[]; - let rightRounds: string[] = []; - let leftHeight: number; - let rightHeight = 0; + let leftPage: number; + let rightPage: number | null = null; + let leftG = pageG; + let rightG = pageG; 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; + leftPage = anim.dir === "right" ? anim.fromPage : anim.toPage; + rightPage = anim.dir === "right" ? anim.toPage : anim.fromPage; + leftG = anim.dir === "right" ? animFromG : animToG; + rightG = anim.dir === "right" ? animToG : animFromG; } else if (anim?.phase === "settling") { - leftRounds = toRounds; - leftHeight = animToHeight; + leftPage = anim.toPage; + leftG = animToG; settlingTransition = true; } else { - leftRounds = visibleRounds; - leftHeight = pageHeight; + leftPage = page; } - const containerMinHeight = anim?.phase === "settling" ? animToHeight : animFromHeight; + const containerMinHeight = + anim?.phase === "settling" ? animToG.bracketHeight : animFromG.bracketHeight; const initialX = anim?.phase === "sliding" && anim.dir === "left" ? -SLOT_WIDTH : 0; return ( @@ -109,7 +119,7 @@ export function BracketTreePaginated({ variant="ghost" size="icon" onClick={() => navigate(page + 1)} - disabled={page + 2 >= mainRounds.length || !!anim} + disabled={page >= lastPage || !!anim} className="h-7 w-7 shrink-0" aria-label="Next rounds" > @@ -129,22 +139,24 @@ export function BracketTreePaginated({ >
- {anim?.phase === "sliding" && ( + {anim?.phase === "sliding" && rightPage !== null && (
)} @@ -164,6 +176,8 @@ export function BracketTreePaginated({ slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)} ownershipMap={ownershipMap} userParticipantIds={userParticipantIds} + feeders={feeders} + template={template} /> )} diff --git a/app/components/scoring/BracketTreeView.tsx b/app/components/scoring/BracketTreeView.tsx index 98e4f35..623049f 100644 --- a/app/components/scoring/BracketTreeView.tsx +++ b/app/components/scoring/BracketTreeView.tsx @@ -1,5 +1,13 @@ import { avatarColor } from "~/lib/avatar-colors"; import { BRACKT_GRADIENT } from "~/lib/brand"; +import { + computeGroupLayout, + describeSlotSource, + matchKey, + type BracketLayout, + type FeederMap, +} from "~/lib/bracket-layout"; +import type { BracketTemplate } from "~/lib/bracket-templates"; export interface BracketMatch { id: string; @@ -46,6 +54,8 @@ function formatScore(score: string | null): string | null { interface ParticipantRowProps { name: string | null; + /** What fills this slot when it's still empty, e.g. "Winner of Winners SF 2". */ + feedLabel?: string | null; isTbd: boolean; isWinner: boolean; isLoser: boolean; @@ -60,6 +70,7 @@ interface ParticipantRowProps { function ParticipantRow({ name, + feedLabel, isTbd, isWinner, isLoser, @@ -114,7 +125,7 @@ function ParticipantRow({ .filter(Boolean) .join(" ")} > - {name ?? "TBD"} + {name ?? feedLabel ?? "TBD"} {/* Owner name below participant name */} @@ -150,6 +161,8 @@ interface BracketMatchSlotProps { slotHeight: number; ownershipMap: Map; userParticipantIds: Set; + feeders?: FeederMap; + template?: BracketTemplate; } export function BracketMatchSlot({ @@ -157,6 +170,8 @@ export function BracketMatchSlot({ slotHeight, ownershipMap, userParticipantIds, + feeders, + template, }: BracketMatchSlotProps) { const rowHeight = slotHeight / 2; const showText = rowHeight >= 10; @@ -187,6 +202,13 @@ export function BracketMatchSlot({ const INSET = Math.max(1, Math.min(2, Math.floor(slotHeight / 20))); + // An empty slot reads better as "Loser of Winners SF 2" than "TBD" — especially for + // the feeds that cross between the winners and elimination brackets, which render as + // separate trees and so can never be joined by a line. + const slotSources = feeders?.get(matchKey(match.round, match.matchNumber)); + const feed1 = describeSlotSource(slotSources?.[0], template); + const feed2 = describeSlotSource(slotSources?.[1], template); + return (
{/* Corona layer — left-2 matches card borderRadius to prevent gradient bleed at left corners */} @@ -208,6 +230,7 @@ export function BracketMatchSlot({ > (); + for (const { fromCenter, toCenter } of edges) { + const sources = byTarget.get(toCenter) ?? []; + sources.push(fromCenter); + byTarget.set(toCenter, sources); + } + const paths: string[] = []; - - 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; - const botIdx = 2 * k + 1; - - if (botIdx < currentMatches.length) { - const botY = botIdx * currentSlotH + currentSlotH / 2; - paths.push(`M 0 ${topY} H ${mid} V ${botY} H 0`); - paths.push(`M ${mid} ${midY} H ${CONNECTOR_WIDTH}`); - } else { - paths.push(`M 0 ${topY} H ${CONNECTOR_WIDTH}`); - } + for (const [toCenter, sources] of byTarget) { + const destY = toCenter * rowHeight - offset; + const ys = sources.map((c) => c * rowHeight - offset).toSorted((a, b) => a - b); + if (ys.length === 1) { + paths.push(`M 0 ${ys[0]} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`); + continue; } - } 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}`); - } - }); + paths.push(`M 0 ${ys[0]} H ${mid} V ${ys[ys.length - 1]} H 0`); + for (const y of ys.slice(1, -1)) paths.push(`M 0 ${y} H ${mid}`); + paths.push(`M ${mid} ${destY} H ${CONNECTOR_WIDTH}`); } return ( @@ -310,52 +325,131 @@ function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: Connect // ─── Tree columns (shared by full + paginated) ─────────────────────────────── +export interface BracketGeometry { + layout: BracketLayout; + /** Height of one leaf row. */ + rowHeight: number; + /** Height of the card area, excluding the round labels. */ + bracketHeight: number; + /** Narrowest the columns and gutters can be drawn without overlapping. */ + minWidth: number; + /** Pixels trimmed off the top, non-zero only for a cropped column window. */ + offset: number; +} + +/** + * Lay out a group's matches from the feeder graph and derive its pixel geometry. + * + * Height comes from the number of leaf rows rather than the largest round, so a bracket + * whose widest column isn't its first still gets the room it needs. + */ +export function bracketGeometry( + visibleRounds: string[], + matchesByRound: Map, + feeders: FeederMap | undefined, + templateRoundOrder: string[] +): BracketGeometry { + const layout = computeGroupLayout( + visibleRounds, + matchesByRound, + feeders ?? new Map(), + templateRoundOrder + ); + const rowHeight = DESIRED_CARD_HEIGHT + CARD_GAP; + const columnCount = Math.max(layout.columns.length, 1); + return { + layout, + rowHeight, + bracketHeight: Math.max(layout.leafCount, 1) * rowHeight, + minWidth: columnCount * COLUMN_WIDTH + (columnCount - 1) * CONNECTOR_WIDTH, + offset: 0, + }; +} + +/** + * Crop a layout to a window of columns, as the mobile pager does. + * + * Card positions are absolute within the whole bracket, so showing a slice of columns + * means trimming the empty space above them rather than re-flowing — otherwise a later + * page would render its two columns stranded at the bottom of a full-height bracket. + */ +export function windowGeometry( + geometry: BracketGeometry, + firstColumn: number, + lastColumn: number +): BracketGeometry { + const centers = geometry.layout.columns + .slice(firstColumn, lastColumn + 1) + .flatMap((c) => c.matches.map((m) => m.center)); + if (centers.length === 0) return geometry; + + const min = Math.min(...centers); + const max = Math.max(...centers); + return { + ...geometry, + bracketHeight: (max - min + 1) * geometry.rowHeight, + offset: (min - 0.5) * geometry.rowHeight, + }; +} + interface TreeColumnsProps { - visibleRounds: string[]; - matchesByRound: Map; + geometry: BracketGeometry; ownershipMap: Map; userParticipantIds: Set; - bracketHeight: number; transitionDuration?: number; + feeders?: FeederMap; + template?: BracketTemplate; + /** Restrict rendering to a window of columns (used by the mobile pager). */ + columnRange?: [number, number]; } export function TreeColumns({ - visibleRounds, - matchesByRound, + geometry, ownershipMap, userParticipantIds, - bracketHeight, transitionDuration, + feeders, + template, + columnRange, }: TreeColumnsProps) { const tr = transitionDuration ? `${transitionDuration}ms ease` : undefined; + const { layout, rowHeight, bracketHeight, offset } = geometry; + + const [firstColumn, lastColumn] = columnRange ?? [0, layout.columns.length - 1]; + const visible = layout.columns.slice(firstColumn, lastColumn + 1); + + // Cards keep a fixed height regardless of how many share a column — stretching a + // lone final to fill its column is what made it tower over the rest of the bracket. + const cardHeight = Math.min( + Math.max(rowHeight - CARD_GAP, 1), + MAX_CARD_HEIGHT + ); + return (
- {visibleRounds.map((round, ri) => { - const roundMatches = matchesByRound.get(round) ?? []; - const slotHeight = bracketHeight / Math.max(roundMatches.length, 1); - const cardHeight = Math.min(slotHeight - CARD_GAP, MAX_CARD_HEIGHT); - const cardTop = (slotHeight - cardHeight) / 2; - const nextRound = ri < visibleRounds.length - 1 ? visibleRounds[ri + 1] : null; - const nextMatches = nextRound ? (matchesByRound.get(nextRound) ?? []) : []; + {visible.map((column, vi) => { + const ci = firstColumn + vi; + const gutterEdges = layout.edges.filter((e) => e.fromColumn === ci); return ( -
+
{/* Round column */}
- {round} + {column.label}
- {roundMatches.map((match, matchIdx) => ( + {column.matches.map(({ match, center }) => (
))} @@ -374,10 +470,11 @@ export function TreeColumns({
{/* Connector between this column and the next */} - {nextRound && ( + {vi < visible.length - 1 && ( )} @@ -396,6 +493,8 @@ interface BracketTreeViewProps { ownershipMap: Map; userParticipantIds: Set; thirdPlaceRound?: string; + feeders?: FeederMap; + template?: BracketTemplate; } export function BracketTreeView({ @@ -404,13 +503,19 @@ export function BracketTreeView({ ownershipMap, userParticipantIds, thirdPlaceRound, + feeders, + template, }: BracketTreeViewProps) { const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds; const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined; - const maxMatches = Math.max(...mainRounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1); - const bracketHeight = maxMatches * (DESIRED_CARD_HEIGHT + CARD_GAP); - const minWidth = mainRounds.length * COLUMN_WIDTH + Math.max(0, mainRounds.length - 1) * CONNECTOR_WIDTH; + const geometry = bracketGeometry( + mainRounds, + matchesByRound, + feeders, + template?.rounds.map((r) => r.name) ?? mainRounds + ); + const { bracketHeight, minWidth } = geometry; return (
{thirdPlaceMatch && (
@@ -441,6 +546,8 @@ export function BracketTreeView({ slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)} ownershipMap={ownershipMap} userParticipantIds={userParticipantIds} + feeders={feeders} + template={template} />
diff --git a/app/components/scoring/NbaBracketLayout.tsx b/app/components/scoring/NbaBracketLayout.tsx index 86e7c70..f446ab8 100644 --- a/app/components/scoring/NbaBracketLayout.tsx +++ b/app/components/scoring/NbaBracketLayout.tsx @@ -1,5 +1,11 @@ -import type { ConferenceGroup } from "~/lib/bracket-templates"; -import { TreeColumns, type BracketMatch, type BracketOwnership } from "./BracketTreeView"; +import type { BracketTemplate, ConferenceGroup } from "~/lib/bracket-templates"; +import type { FeederMap } from "~/lib/bracket-layout"; +import { + TreeColumns, + bracketGeometry, + type BracketMatch, + type BracketOwnership, +} from "./BracketTreeView"; import { BracketTreePaginated } from "./BracketTreePaginated"; interface NbaBracketLayoutProps { @@ -10,11 +16,10 @@ interface NbaBracketLayoutProps { userParticipantIds: Set; conferenceGroups: ConferenceGroup[]; scoringRoundIdx: number; + feeders?: FeederMap; + template?: BracketTemplate; } -const DESIRED_CARD_HEIGHT = 112; -const CARD_GAP = 14; - function splitMatchesByConference( matchesByRound: Map, group: ConferenceGroup @@ -28,11 +33,6 @@ function splitMatchesByConference( 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, @@ -40,7 +40,10 @@ export function NbaBracketLayout({ userParticipantIds, conferenceGroups, scoringRoundIdx, + feeders, + template, }: NbaBracketLayoutProps) { + const roundOrder = template?.rounds.map((r) => r.name) ?? rounds; // Rounds that belong to any conference group const conferenceRoundSet = new Set( conferenceGroups.flatMap((g) => Object.keys(g.roundMatchNumbers)) @@ -57,7 +60,7 @@ export function NbaBracketLayout({ const sharedMatches = new Map( sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []]) ); - const sharedHeight = bracketHeight(sharedMatches, sharedRounds); + const sharedGeometry = bracketGeometry(sharedRounds, sharedMatches, feeders, roundOrder); return ( <> @@ -66,7 +69,7 @@ export function NbaBracketLayout({ {conferenceGroups.map((group, gi) => { const confRounds = conferenceRounds[gi]; const confMatches = splitMatchesByConference(matchesByRound, group); - const height = bracketHeight(confMatches, confRounds); + const geometry = bracketGeometry(confRounds, confMatches, feeders, roundOrder); return (
@@ -74,11 +77,11 @@ export function NbaBracketLayout({ {group.name}

); @@ -87,11 +90,11 @@ export function NbaBracketLayout({ {sharedRounds.length > 0 && (
)} diff --git a/app/components/scoring/PlayoffBracket.tsx b/app/components/scoring/PlayoffBracket.tsx index 17cfc40..e20317d 100644 --- a/app/components/scoring/PlayoffBracket.tsx +++ b/app/components/scoring/PlayoffBracket.tsx @@ -14,6 +14,7 @@ import { RankingsRow } from "./RankingsRow"; import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView"; import { BracketTreePaginated } from "./BracketTreePaginated"; import { getBracketTemplate, type BracketTemplate } from "~/lib/bracket-templates"; +import { buildFeederMap } from "~/lib/bracket-layout"; import { NbaBracketLayout } from "./NbaBracketLayout"; import { TabbedBracketLayout } from "./TabbedBracketLayout"; @@ -76,43 +77,6 @@ export function groupMatchesByRound(matches: Match[]): Map { return byRound; } -/** - * For a standard single-elimination bracket, slot p1 of match N in round R - * comes from match (2N-1) in the previous round, and slot p2 from match 2N. - */ -export function buildFeederMap( - matchesByRound: Map, - orderedRounds: string[] -): Map { - const feederMap = new Map(); - - for (let ri = 1; ri < orderedRounds.length; ri++) { - const currentRound = orderedRounds[ri]; - const prevRound = orderedRounds[ri - 1]; - const prevMatchNums = new Set( - (matchesByRound.get(prevRound) || []).map((m) => m.matchNumber) - ); - for (const match of matchesByRound.get(currentRound) || []) { - const p1Src = 2 * (match.matchNumber - 1) + 1; - const p2Src = 2 * (match.matchNumber - 1) + 2; - if (prevMatchNums.has(p1Src)) { - feederMap.set(`${currentRound}:${match.matchNumber}:p1`, { - round: prevRound, - matchNumber: p1Src, - }); - } - if (prevMatchNums.has(p2Src)) { - feederMap.set(`${currentRound}:${match.matchNumber}:p2`, { - round: prevRound, - matchNumber: p2Src, - }); - } - } - } - - return feederMap; -} - interface EliminatedEntry { participant: Participant; score: string | null; @@ -391,6 +355,8 @@ export function PlayoffBracket({ const matchesByRound = groupMatchesByRound(matches); const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds); const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined; + // What fills each slot, used for both card placement and naming empty slots. + const feeders = buildFeederMap(template); const consolation = findConsolationRound(template); const thirdPlaceRound = consolation?.round; @@ -478,6 +444,8 @@ export function PlayoffBracket({ userParticipantIds={userParticipantSet} phases={template.phases} scoringRoundIdx={scoringRoundIdx} + feeders={feeders} + template={template} /> ) : template?.conferenceGroups ? ( ) : ( <> @@ -499,6 +469,8 @@ export function PlayoffBracket({ ownershipMap={ownershipMap as Map} userParticipantIds={userParticipantSet} thirdPlaceRound={thirdPlaceRound} + feeders={feeders} + template={template} />
@@ -511,6 +483,8 @@ export function PlayoffBracket({ userParticipantIds={userParticipantSet} firstScoringRoundIdx={scoringRoundIdx} thirdPlaceRound={thirdPlaceRound} + feeders={feeders} + template={template} />
diff --git a/app/components/scoring/TabbedBracketLayout.tsx b/app/components/scoring/TabbedBracketLayout.tsx index 5697465..40446af 100644 --- a/app/components/scoring/TabbedBracketLayout.tsx +++ b/app/components/scoring/TabbedBracketLayout.tsx @@ -1,8 +1,18 @@ import { cn } from "~/lib/utils"; -import type { BracketPhase, ConferenceGroup } from "~/lib/bracket-templates"; -import { TreeColumns, BracketMatchSlot, type BracketMatch, type BracketOwnership } from "./BracketTreeView"; +import type { BracketPhase, BracketTemplate, ConferenceGroup } from "~/lib/bracket-templates"; +import type { FeederMap } from "~/lib/bracket-layout"; +import { + TreeColumns, + BracketMatchSlot, + bracketGeometry, + type BracketMatch, + type BracketOwnership, +} from "./BracketTreeView"; import { BracketTreePaginated } from "./BracketTreePaginated"; +/** Card height for the play-in columns, which lay themselves out rather than via TreeColumns. */ +const CARD_H = 112; + interface TabbedBracketLayoutProps { rounds: string[]; matchesByRound: Map; @@ -10,11 +20,10 @@ interface TabbedBracketLayoutProps { userParticipantIds: Set; phases: BracketPhase[]; scoringRoundIdx: number; + feeders?: FeederMap; + template?: BracketTemplate; } -const CARD_H = 112; -const CARD_GAP = 14; - function groupMatches( matchesByRound: Map, group: ConferenceGroup @@ -29,11 +38,6 @@ function groupMatches( 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); -} - // ─── Play-In Layout ─────────────────────────────────────────────────────────── interface PlayInColumnProps { @@ -141,7 +145,10 @@ export function TabbedBracketLayout({ userParticipantIds, phases, scoringRoundIdx, + feeders, + template, }: TabbedBracketLayoutProps) { + const roundOrder = template?.rounds.map((r) => r.name) ?? rounds; return (
{phases.map((phase) => { @@ -194,50 +201,87 @@ export function TabbedBracketLayout({ {phase.groups.map((group) => { const gMatches = groupMatches(matchesByRound, group); const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined); + const geometry = bracketGeometry(gRounds, gMatches, feeders, roundOrder); return (

{group.name}

- +
+
+ +
+
); })} {sharedRounds.length > 0 && ( )}
) : ( )}
- {/* Mobile */} -
+ {/* Mobile — paged one group at a time, matching the desktop split. Paging a + whole phase would merge the winners and elimination brackets into one + tree, and a double-elimination phase is a DAG rather than a tree: the + same game feeds forward and sideways, so its column placement would be + arbitrary. */} +
{phase.layout === "play-in" ? ( + ) : phase.groups ? ( + <> + {phase.groups.map((group) => ( +
+

+ {group.name} +

+ group.roundMatchNumbers[r] !== undefined)} + matchesByRound={groupMatches(matchesByRound, group)} + ownershipMap={ownershipMap} + userParticipantIds={userParticipantIds} + feeders={feeders} + template={template} + /> +
+ ))} + {sharedRounds.length > 0 && ( + + )} + ) : ( = 0 ? phaseFirstScoringIdx : undefined} + feeders={feeders} + template={template} /> )}
diff --git a/app/components/scoring/__tests__/PlayoffBracket.test.tsx b/app/components/scoring/__tests__/PlayoffBracket.test.tsx index a4df24f..e0fc986 100644 --- a/app/components/scoring/__tests__/PlayoffBracket.test.tsx +++ b/app/components/scoring/__tests__/PlayoffBracket.test.tsx @@ -2,7 +2,6 @@ import { describe, it, expect } from "vitest"; import { render, screen, within } from "@testing-library/react"; import { PlayoffBracket, - buildFeederMap, groupMatchesByRound, computeEliminatedByRound, computeRankedEntries, @@ -64,88 +63,72 @@ describe("groupMatchesByRound", () => { }); // --------------------------------------------------------------------------- -// buildFeederMap +// Rendered LLWS bracket — geometry and empty-slot labels // --------------------------------------------------------------------------- -describe("buildFeederMap", () => { - it("returns an empty map when there is only one round", () => { - const matches = [makeMatch("Finals", 1)]; - const map = buildFeederMap(groupMatchesByRound(matches), ["Finals"]); - expect(map.size).toBe(0); +describe("PlayoffBracket — rendered LLWS bracket", () => { + const LLWS_ROUNDS = (getBracketTemplate("llws_20")?.rounds ?? []).map((r) => r.name); + + /** Every LLWS match, all unplayed, so each slot shows what will fill it. */ + function emptyLlwsMatches(): Match[] { + const template = getBracketTemplate("llws_20"); + const matches: Match[] = []; + for (const round of template?.rounds ?? []) { + for (let n = 1; n <= round.matchCount; n++) { + matches.push({ + ...makeMatch(round.name, n, { participant1Id: null, participant2Id: null }), + participant1: null, + participant2: null, + }); + } + } + return matches; + } + + it("names empty slots after the game that feeds them", () => { + render( + + ); + + // A winners-bracket loss drops into the elimination bracket — an edge that spans + // two separately rendered trees, so the label is the only way to show it. + expect(screen.getAllByText("Loser of Winners SF 1").length).toBeGreaterThan(0); + expect(screen.getAllByText("Winner of Opening 1").length).toBeGreaterThan(0); }); - it("maps SF slots to the correct QF matches for an 8-team bracket", () => { - const rounds = ["Quarterfinals", "Semifinals", "Finals"]; - const matches = [ - makeMatch("Quarterfinals", 1), - makeMatch("Quarterfinals", 2), - makeMatch("Quarterfinals", 3), - makeMatch("Quarterfinals", 4), - makeMatch("Semifinals", 1), - makeMatch("Semifinals", 2), - makeMatch("Finals", 1), - ]; + it("still shows TBD for a directly seeded slot", () => { + render( + + ); - const map = buildFeederMap(groupMatchesByRound(matches), rounds); - - // SF Match 1, slot p1 ← QF Match 1 - expect(map.get("Semifinals:1:p1")).toEqual({ round: "Quarterfinals", matchNumber: 1 }); - // SF Match 1, slot p2 ← QF Match 2 - expect(map.get("Semifinals:1:p2")).toEqual({ round: "Quarterfinals", matchNumber: 2 }); - // SF Match 2, slot p1 ← QF Match 3 - expect(map.get("Semifinals:2:p1")).toEqual({ round: "Quarterfinals", matchNumber: 3 }); - // SF Match 2, slot p2 ← QF Match 4 - expect(map.get("Semifinals:2:p2")).toEqual({ round: "Quarterfinals", matchNumber: 4 }); + // The opening round is seeded, not fed, so it has nothing to name. + expect(screen.getAllByText("TBD").length).toBeGreaterThan(0); }); - it("maps Finals slots to the correct SF matches", () => { - const rounds = ["Quarterfinals", "Semifinals", "Finals"]; - const matches = [ - makeMatch("Quarterfinals", 1), - makeMatch("Quarterfinals", 2), - makeMatch("Quarterfinals", 3), - makeMatch("Quarterfinals", 4), - makeMatch("Semifinals", 1), - makeMatch("Semifinals", 2), - makeMatch("Finals", 1), - ]; + it("gives every card the same height, including a lone final", () => { + const { container } = render( + + ); - const map = buildFeederMap(groupMatchesByRound(matches), rounds); - - expect(map.get("Finals:1:p1")).toEqual({ round: "Semifinals", matchNumber: 1 }); - expect(map.get("Finals:1:p2")).toEqual({ round: "Semifinals", matchNumber: 2 }); - }); - - it("does not add an entry when the source match does not exist in the previous round", () => { - const rounds = ["Quarterfinals", "Finals"]; - const matches = [ - makeMatch("Quarterfinals", 1), - makeMatch("Quarterfinals", 2), - makeMatch("Finals", 1), - ]; - - const map = buildFeederMap(groupMatchesByRound(matches), rounds); - - expect(map.get("Finals:1:p1")).toEqual({ round: "Quarterfinals", matchNumber: 1 }); - expect(map.get("Finals:1:p2")).toEqual({ round: "Quarterfinals", matchNumber: 2 }); - expect(map.has("Finals:2:p1")).toBe(false); - }); - - it("handles a 16-team bracket correctly for Round of 16 → Quarterfinals", () => { - const rounds = ["Round of 16", "Quarterfinals", "Semifinals", "Finals"]; - const matches = [ - ...[1, 2, 3, 4, 5, 6, 7, 8].map((n) => makeMatch("Round of 16", n)), - ...[1, 2, 3, 4].map((n) => makeMatch("Quarterfinals", n)), - ...[1, 2].map((n) => makeMatch("Semifinals", n)), - makeMatch("Finals", 1), - ]; - - const map = buildFeederMap(groupMatchesByRound(matches), rounds); - - expect(map.get("Quarterfinals:1:p1")).toEqual({ round: "Round of 16", matchNumber: 1 }); - expect(map.get("Quarterfinals:1:p2")).toEqual({ round: "Round of 16", matchNumber: 2 }); - expect(map.get("Quarterfinals:4:p1")).toEqual({ round: "Round of 16", matchNumber: 7 }); - expect(map.get("Quarterfinals:4:p2")).toEqual({ round: "Round of 16", matchNumber: 8 }); + const heights = new Set( + [...container.querySelectorAll("[data-match-id]")].map( + (el) => el.style.height + ) + ); + // Previously a one-match column stretched its card to fill the bracket height. + expect(heights.size).toBe(1); }); }); diff --git a/app/lib/__tests__/bracket-layout.test.ts b/app/lib/__tests__/bracket-layout.test.ts new file mode 100644 index 0000000..70968b0 --- /dev/null +++ b/app/lib/__tests__/bracket-layout.test.ts @@ -0,0 +1,533 @@ +/** + * Bracket layout tests. + * + * The load-bearing assertions check the LLWS geometry against the official 2026 LLBWS + * bracket, in the PDF's own game numbers. A bracket "lines up" when each card sits level + * with the game that feeds it, so these tests assert column membership, top-to-bottom + * order, and vertical alignment — not just that a layout was produced. + */ + +import { describe, it, expect } from "vitest"; +import { + LLWS_20, + SIMPLE_16, + NFL_14, + BRACKET_TEMPLATES, + getBracketTemplate, + type BracketTemplate, + type ConferenceGroup, +} from "~/lib/bracket-templates"; +import { + buildFeederMap, + computeGroupLayout, + describeSlotSource, + matchKey, + type SlotSource, +} from "~/lib/bracket-layout"; +import { GAME_TO_MATCH, MATCH_TO_GAME } from "~/test/fixtures/llws-bracket"; + +interface TestMatch { + round: string; + matchNumber: number; + /** Only the fallback reads these, to trace edges through an unrecognised shape. */ + winnerId?: string | null; + participant1Id?: string | null; + participant2Id?: string | null; +} + +/** Every match a template defines, as the renderer would receive them. */ +function allMatches(template: BracketTemplate): Map { + const byRound = new Map(); + for (const round of template.rounds) { + byRound.set( + round.name, + Array.from({ length: round.matchCount }, (_, i) => ({ + round: round.name, + matchNumber: i + 1, + })) + ); + } + return byRound; +} + +/** The matches of one phase group, filtered the way TabbedBracketLayout filters them. */ +function groupMatches(group: ConferenceGroup): Map { + const byRound = new Map(); + for (const [round, nums] of Object.entries(group.roundMatchNumbers)) { + byRound.set( + round, + nums.map((matchNumber) => ({ round, matchNumber })) + ); + } + return byRound; +} + +function findGroup(name: string): ConferenceGroup { + for (const phase of LLWS_20.phases ?? []) { + for (const group of phase.groups ?? []) { + if (group.name === name) return group; + } + } + throw new Error(`No LLWS group named ${name}`); +} + +/** Lay out one LLWS group and describe it in PDF game numbers. */ +function layOutLLWSGroup(name: string) { + const group = findGroup(name); + const byRound = groupMatches(group); + const roundOrder = LLWS_20.rounds.map((r) => r.name); + const rounds = roundOrder.filter((r) => byRound.has(r)); + + const layout = computeGroupLayout(rounds, byRound, buildFeederMap(LLWS_20), roundOrder); + + const game = (m: TestMatch) => { + const n = MATCH_TO_GAME.get(`${m.round}#${m.matchNumber}`); + if (n === undefined) throw new Error(`No PDF game for ${m.round} #${m.matchNumber}`); + return n; + }; + + return { + layout, + labels: layout.columns.map((c) => c.label), + /** Column contents, top to bottom, as PDF game numbers. */ + columns: layout.columns.map((c) => c.matches.map((m) => game(m.match))), + /** Vertical centre of a game's card, in leaf-row units. */ + centerOf(gameNumber: number): number { + const target = GAME_TO_MATCH[gameNumber]; + for (const column of layout.columns) { + for (const { match, center } of column.matches) { + if (match.round === target.round && match.matchNumber === target.matchNumber) { + return center; + } + } + } + throw new Error(`G${gameNumber} is not in this group`); + }, + }; +} + +describe("computeGroupLayout — LLWS winners brackets", () => { + // The International side is the one in the reported screenshot. Under the old index + // math, G5 and G7 were stranded in the first column: they skip Winners Round 2 and go + // straight to the semifinals, so nothing in column two lined up with them. + it("puts the International winners bracket in the printed bracket's columns", () => { + const { columns } = layOutLLWSGroup("International Winner's Bracket"); + expect(columns).toEqual([ + [1, 3], + [5, 9, 11, 7], + [18, 20], + [29], + ]); + }); + + it("mirrors that layout on the U.S. side", () => { + const { columns } = layOutLLWSGroup("U.S. Winner's Bracket"); + expect(columns).toEqual([ + [2, 4], + [6, 10, 12, 8], + [17, 19], + [30], + ]); + }); + + it("names a mixed column for the latest round it holds", () => { + // Column two holds two Opening Round games (G5, G7) alongside Winners Round 2. + const { labels } = layOutLLWSGroup("International Winner's Bracket"); + expect(labels).toEqual([ + "Opening Round", + "Winners Round 2", + "Winners Semifinals", + "Winners Final", + ]); + }); + + it("levels each card with the game that feeds it", () => { + const { centerOf } = layOutLLWSGroup("International Winner's Bracket"); + + // G1's winner fills a slot of G9, so the two sit at the same height. + expect(centerOf(1)).toBe(centerOf(9)); + expect(centerOf(3)).toBe(centerOf(11)); + + // G18 = W5 v W9, so it sits midway between them. + expect(centerOf(18)).toBe((centerOf(5) + centerOf(9)) / 2); + expect(centerOf(20)).toBe((centerOf(11) + centerOf(7)) / 2); + expect(centerOf(29)).toBe((centerOf(18) + centerOf(20)) / 2); + }); + + it("draws an edge for every in-group feed, played or not", () => { + const { layout } = layOutLLWSGroup("International Winner's Bracket"); + // G9←G1, G11←G3, G18←{G5,G9}, G20←{G11,G7}, G29←{G18,G20}: 8 in-group edges. + expect(layout.edges).toHaveLength(8); + // Every edge crosses exactly one gutter, which is what makes them drawable. + for (const edge of layout.edges) { + expect(edge.fromColumn).toBeGreaterThanOrEqual(0); + expect(edge.fromColumn).toBeLessThan(layout.columns.length - 1); + } + }); +}); + +describe("computeGroupLayout — LLWS elimination brackets", () => { + it("orders Elimination Round 3 the way the printed bracket does", () => { + // G31 = W27 v W25, so the later game is printed on top — the reverse of match + // number order, which is how the old index-based sort got it wrong. + const { columns } = layOutLLWSGroup("International Elimination Bracket"); + expect(columns).toEqual([ + [13, 15], + [21, 23], + [27, 25], + [31], + [33], + ]); + }); + + it("orders the U.S. elimination bracket the same way", () => { + const { columns } = layOutLLWSGroup("U.S. Elimination Bracket"); + expect(columns).toEqual([ + [14, 16], + [22, 24], + [28, 26], + [32], + [34], + ]); + }); + + it("ignores feeds arriving from the winners bracket", () => { + // G21 = L9 v W13. L9 is in the winners bracket group, so only W13 is an edge here. + const { layout, centerOf } = layOutLLWSGroup("International Elimination Bracket"); + expect(centerOf(21)).toBe(centerOf(13)); + expect(layout.edges).toHaveLength(7); + }); +}); + +describe("buildFeederMap", () => { + it("routes LLWS winners and losers to the slots the printed bracket shows", () => { + const feeders = buildFeederMap(LLWS_20); + + // G18 = W5 v W9. + const g18 = GAME_TO_MATCH[18]; + expect(feeders.get(matchKey(g18.round, g18.matchNumber))).toEqual([ + { kind: "match", ref: GAME_TO_MATCH[5], result: "winner" }, + { kind: "match", ref: GAME_TO_MATCH[9], result: "winner" }, + ]); + + // G13 = L3 v L5 — a winners-bracket loss drops into the elimination bracket. + const g13 = GAME_TO_MATCH[13]; + expect(feeders.get(matchKey(g13.round, g13.matchNumber))).toEqual([ + { kind: "match", ref: GAME_TO_MATCH[3], result: "loser" }, + { kind: "match", ref: GAME_TO_MATCH[5], result: "loser" }, + ]); + }); + + it("marks directly seeded slots as seeds", () => { + const feeders = buildFeederMap(LLWS_20); + // G9 = a bye team v W1: slot one is seeded, slot two is fed. + const g9 = GAME_TO_MATCH[9]; + const [p1, p2] = feeders.get(matchKey(g9.round, g9.matchNumber)) ?? []; + expect(p1).toEqual({ kind: "seed" }); + expect(p2).toEqual({ kind: "match", ref: GAME_TO_MATCH[1], result: "winner" }); + }); + + it("applies the standard halving rule to other templates", () => { + const feeders = buildFeederMap(SIMPLE_16); + expect(feeders.get(matchKey("Quarterfinals", 1))).toEqual([ + { kind: "match", ref: { round: "Round of 16", matchNumber: 1 }, result: "winner" }, + { kind: "match", ref: { round: "Round of 16", matchNumber: 2 }, result: "winner" }, + ]); + expect(feeders.get(matchKey("Quarterfinals", 4))).toEqual([ + { kind: "match", ref: { round: "Round of 16", matchNumber: 7 }, result: "winner" }, + { kind: "match", ref: { round: "Round of 16", matchNumber: 8 }, result: "winner" }, + ]); + // The first round is seeded, not fed. + expect(feeders.get(matchKey("Round of 16", 1))).toEqual([ + { kind: "seed" }, + { kind: "seed" }, + ]); + }); + + it("returns an empty map without a template", () => { + expect(buildFeederMap(undefined).size).toBe(0); + }); +}); + +describe("computeGroupLayout — standard brackets are unchanged", () => { + it("halves a 16-team bracket evenly, first round in seeded order", () => { + const byRound = allMatches(SIMPLE_16); + const roundOrder = SIMPLE_16.rounds.map((r) => r.name); + const layout = computeGroupLayout( + roundOrder, + byRound, + buildFeederMap(SIMPLE_16), + roundOrder + ); + + expect(layout.leafCount).toBe(8); + expect(layout.columns.map((c) => c.label)).toEqual(roundOrder); + expect(layout.columns.map((c) => c.matches.map((m) => m.match.matchNumber))).toEqual([ + [1, 2, 3, 4, 5, 6, 7, 8], + [1, 2, 3, 4], + [1, 2], + [1], + ]); + // Evenly spread, exactly as the previous index math placed them. + expect(layout.columns[0].matches.map((m) => m.center)).toEqual([ + 0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, + ]); + expect(layout.columns[3].matches[0].center).toBe(4); + }); + + it("handles byes, placing a seeded team level with the round it enters", () => { + // The NFL bracket's top seeds skip the wild card round. + const byRound = allMatches(NFL_14); + const roundOrder = NFL_14.rounds.map((r) => r.name); + const layout = computeGroupLayout(roundOrder, byRound, buildFeederMap(NFL_14), roundOrder); + expect(layout.columns.length).toBeGreaterThan(0); + for (const column of layout.columns) { + expect(column.matches.length).toBeGreaterThan(0); + } + }); + + it("falls back to even spreading when a group has no single root", () => { + // Two finals and no way to join them — the shape can't resolve to one tree. + const byRound = new Map([ + ["Semifinals", [{ round: "Semifinals", matchNumber: 1 }]], + [ + "Finals", + [ + { round: "Finals", matchNumber: 1 }, + { round: "Finals", matchNumber: 2 }, + ], + ], + ]); + const layout = computeGroupLayout( + ["Semifinals", "Finals"], + byRound, + new Map(), + ["Semifinals", "Finals"] + ); + expect(layout.columns.map((c) => c.label)).toEqual(["Semifinals", "Finals"]); + expect(layout.edges).toEqual([]); + expect(layout.columns[0].matches[0].center).toBe(1); + expect(layout.columns[1].matches.map((m) => m.center)).toEqual([0.5, 1.5]); + }); + + it("returns nothing for an empty group", () => { + const layout = computeGroupLayout([], new Map(), new Map(), []); + expect(layout).toEqual({ columns: [], leafCount: 0, edges: [] }); + }); +}); + +describe("buildFeederMap — templates with routing of their own", () => { + // The halving rule describes advanceWinnerTemplate, not every bracket. Inventing it + // where it doesn't hold draws confident, wrong connectors and mislabels slots, which + // is worse than drawing nothing. + + it("follows feedsInto rather than the order rounds are listed in", () => { + // AFL's Wildcard Round feeds the Elimination Finals, skipping the round printed + // next to it, so array order would fabricate the whole chain. + const afl = BRACKET_TEMPLATES.afl_10; + const feeders = buildFeederMap(afl); + const fed = [...feeders.entries()].filter(([, pair]) => + pair.some((s) => s.kind === "match") + ); + // Only Preliminary Finals → Grand Final actually halves. + expect(fed.map(([key]) => key)).toEqual(["Grand Final#1"]); + }); + + it("leaves a bye round's slots seeded rather than inventing feeds", () => { + // CFP's First Round (4) feeds the Quarterfinals (4) — the top seeds have byes. + const feeders = buildFeederMap(BRACKET_TEMPLATES.cfp_12); + expect(feeders.get(matchKey("Quarterfinals", 1))).toEqual([ + { kind: "seed" }, + { kind: "seed" }, + ]); + }); + + it("leaves the First Four out of the Round of 64", () => { + // advanceFirstFourWinner puts each winner in a specific seed slot, not games 1-2. + const feeders = buildFeederMap(BRACKET_TEMPLATES.ncaa_68); + expect(feeders.get(matchKey("Round of 64", 1))).toEqual([ + { kind: "seed" }, + { kind: "seed" }, + ]); + }); + + it("leaves the NBA play-in alone, where a loser feeds forward", () => { + // Play-In Round 2 pairs the 7v8 loser with the 9v10 winner, so the round sizes + // halve but the winners-only rule still doesn't describe it. + const feeders = buildFeederMap(BRACKET_TEMPLATES.nba_20); + expect(feeders.get(matchKey("Play-In Round 2", 1))).toEqual([ + { kind: "seed" }, + { kind: "seed" }, + ]); + }); + + it("does not route the FIFA final through the third place game", () => { + // Third Place Game sits between Semifinals and Finals in round order, so array + // order made it the Finals' feeder and left the Finals' second slot empty. + const feeders = buildFeederMap(BRACKET_TEMPLATES.fifa_48); + expect(feeders.get(matchKey("Finals", 1))).toEqual([ + { kind: "match", ref: { round: "Semifinals", matchNumber: 1 }, result: "winner" }, + { kind: "match", ref: { round: "Semifinals", matchNumber: 2 }, result: "winner" }, + ]); + }); +}); + +describe("computeGroupLayout — every template still draws connectors", () => { + /** Lay a whole template out the way BracketTreeView would. */ + function layOut(template: BracketTemplate) { + const byRound = allMatches(template); + const order = template.rounds.map((r) => r.name); + // BracketTreeView renders a third place game outside the tree. + const rounds = order.filter((r) => r !== "Third Place Game"); + return computeGroupLayout(rounds, byRound, buildFeederMap(template), order); + } + + // A gutter joining a column to one exactly half its size is a plain bracket join and + // must always be drawn. Where the sizes don't halve — a bye round, a play-in, the + // First Four — the routing is bespoke and nothing is drawn until the games decide it, + // which is what these brackets did before. + it.each(Object.keys(BRACKET_TEMPLATES).filter((id) => id !== "llws_20"))( + "%s draws every gutter that halves", + (id) => { + const layout = layOut(BRACKET_TEMPLATES[id]); + const gutters = new Set(layout.edges.map((e) => e.fromColumn)); + let halvingGutters = 0; + for (let ci = 0; ci < layout.columns.length - 1; ci++) { + const from = layout.columns[ci].matches.length; + const to = layout.columns[ci + 1].matches.length; + if (from !== to * 2) continue; + halvingGutters += 1; + expect(gutters).toContain(ci); + } + // Every template has at least one, so a template that lost all its lines fails. + expect(halvingGutters).toBeGreaterThan(0); + } + ); + + it("keeps the FIFA bracket a single tree once the third place game is set aside", () => { + const layout = layOut(BRACKET_TEMPLATES.fifa_48); + expect(layout.columns.map((c) => c.label)).toEqual([ + "Round of 32", + "Round of 16", + "Quarterfinals", + "Semifinals", + "Finals", + ]); + expect(layout.edges).toHaveLength(30); + }); + + // llws_20 is excluded above because both sides in one group is genuinely not a tree; + // it renders per side, which the tests further up cover. +}); + +describe("computeGroupLayout — fallback keeps the old connectors", () => { + const rounds = ["Quarterfinals", "Semifinals", "Finals"]; + const byRound = new Map([ + ["Quarterfinals", [1, 2, 3, 4].map((n) => ({ round: "Quarterfinals", matchNumber: n }))], + ["Semifinals", [1, 2].map((n) => ({ round: "Semifinals", matchNumber: n }))], + ["Finals", [{ round: "Finals", matchNumber: 1 }]], + ]); + + it("infers halving edges when there is no feeder map at all", () => { + // A bracket with no template id, which SportSeasonDisplay renders. + const layout = computeGroupLayout(rounds, byRound, new Map(), rounds); + expect(layout.edges).toHaveLength(6); + // Quarterfinals 1 and 2 both join Semifinal 1. + const intoFirstSemi = layout.edges.filter((e) => e.toCenter === 1); + expect(intoFirstSemi.map((e) => e.fromCenter)).toEqual([0.5, 1.5]); + }); + + it("traces played winners when the shape is not a halving", () => { + const irregular = new Map([ + [ + "Wildcard", + [ + { round: "Wildcard", matchNumber: 1, winnerId: "a" }, + { round: "Wildcard", matchNumber: 2, winnerId: "b" }, + ], + ], + [ + "Semifinals", + [ + { round: "Semifinals", matchNumber: 1, participant1Id: "seeded", participant2Id: "b" }, + { round: "Semifinals", matchNumber: 2, participant1Id: "seeded2", participant2Id: "a" }, + ], + ], + ]); + const layout = computeGroupLayout( + ["Wildcard", "Semifinals"], + irregular, + new Map(), + ["Wildcard", "Semifinals"] + ); + // b won Wildcard 2 (centre 1.5) and plays Semifinal 1 (centre 0.5) — a crossing + // edge that only the actual result can reveal. + expect(layout.edges).toContainEqual({ fromColumn: 0, fromCenter: 1.5, toCenter: 0.5 }); + expect(layout.edges).toContainEqual({ fromColumn: 0, fromCenter: 0.5, toCenter: 1.5 }); + }); +}); + +describe("describeSlotSource", () => { + const feeders = buildFeederMap(LLWS_20); + const sourcesFor = (game: number): [SlotSource, SlotSource] => { + const m = GAME_TO_MATCH[game]; + const pair = feeders.get(matchKey(m.round, m.matchNumber)); + if (!pair) throw new Error(`No feeders for G${game}`); + return pair; + }; + + it("names a winner feed", () => { + // G18 = W5 v W9; G5 is Opening Round match 3 on the International side. + expect(describeSlotSource(sourcesFor(18)[0], LLWS_20)).toBe("Winner of Opening 3"); + }); + + it("names a loser feed, which is the one no line can show", () => { + // G21 = L9 v W13; G9 is Winners Round 2 match 1 on the International side. + expect(describeSlotSource(sourcesFor(21)[0], LLWS_20)).toBe("Loser of Winners R2 1"); + // G25 = L18 v W23; G18 is International semifinal 1. + expect(describeSlotSource(sourcesFor(25)[0], LLWS_20)).toBe("Loser of Winners SF 1"); + }); + + it("uses side-local numbers, as the printed bracket does", () => { + // G27 = L20 v W21. G20 is Winners Semifinals match 4 globally, but International + // semifinal 2 — the number the printed bracket uses. + expect(describeSlotSource(sourcesFor(27)[0], LLWS_20)).toBe("Loser of Winners SF 2"); + }); + + it("names the side where each side plays only one such game", () => { + // G37 = L36 v L35: both feeds are Bracket Championship losers, one per side, so a + // number would say nothing and the side is the only thing that tells them apart. + const [p1, p2] = sourcesFor(37); + expect(describeSlotSource(p1, LLWS_20)).toBe("Loser of U.S. Bracket Final"); + expect(describeSlotSource(p2, LLWS_20)).toBe("Loser of Intl Bracket Final"); + + // Same rule inside a side bracket: G34 = L30 v W32. + const [elimP1, elimP2] = sourcesFor(34); + expect(describeSlotSource(elimP1, LLWS_20)).toBe("Loser of U.S. Winners Final"); + expect(describeSlotSource(elimP2, LLWS_20)).toBe("Winner of U.S. Elim R4"); + }); + + it("drops both number and side for the shared final games", () => { + // The two sides meet here, so there is only one of each game in the whole bracket. + const wc = describeSlotSource( + { kind: "match", ref: GAME_TO_MATCH[38], result: "winner" }, + LLWS_20 + ); + expect(wc).toBe("Winner of World Championship"); + }); + + it("returns null for a seeded slot so the caller can render TBD", () => { + expect(describeSlotSource({ kind: "seed" }, LLWS_20)).toBeNull(); + expect(describeSlotSource(undefined, LLWS_20)).toBeNull(); + }); + + it("uses plain round names for non-LLWS templates", () => { + const template = getBracketTemplate("simple_16"); + const source: SlotSource = { + kind: "match", + ref: { round: "Quarterfinals", matchNumber: 3 }, + result: "winner", + }; + expect(describeSlotSource(source, template)).toBe("Winner of Quarterfinals 3"); + }); +}); diff --git a/app/lib/bracket-layout.ts b/app/lib/bracket-layout.ts new file mode 100644 index 0000000..e63027e --- /dev/null +++ b/app/lib/bracket-layout.ts @@ -0,0 +1,420 @@ +/** + * Bracket geometry, derived from the real feeder graph. + * + * The renderer used to place cards by index within a round — match i at + * `i * (height / roundSize)` — and drew connectors assuming matches 2k and 2k+1 feed + * match k. That holds only when each round is an exact halving of the previous one. + * + * The LLWS winners bracket is not a halving: two of the four Opening Round games skip + * Winners Round 2 entirely and go straight to the semifinals (see LLWS_ADVANCEMENT). + * Under index math those games get pulled to the bottom of column one with nothing + * above them in column two, and the connectors confidently join the wrong pairs. + * + * So lay out from the graph instead: + * column = depth from the group's final, counted backwards + * vertical order = the parent's slot order (participant1 above participant2) + * connectors = actual feeder edges + * + * Counting columns back from the final is what makes a printed bracket line up: a team + * entering late sits in the column where it actually plays, not the column its round + * name suggests. For the LLWS International side this reproduces the official bracket + * exactly, including putting the Australia/Mexico game alongside Winners Round 2. + * + * Pure — no React, no DB — so the geometry can be asserted against the printed bracket + * in tests. + */ + +import { + llwsSideAndLocal, + type BracketTemplate, +} from "~/lib/bracket-templates"; +import { resolveLLWSAdvancement } from "~/lib/llws-bracket"; + +// ── Feeder graph ────────────────────────────────────────────────────────────── + +export interface MatchRef { + round: string; + matchNumber: number; +} + +/** What fills one participant slot of a match. */ +export type SlotSource = + | { kind: "match"; ref: MatchRef; result: "winner" | "loser" } + | { kind: "seed" }; + +/** Keyed by `${round}#${matchNumber}`; the pair is [participant1, participant2]. */ +export type FeederMap = Map; + +const SEED: SlotSource = { kind: "seed" }; + +/** + * `template.id:roundName` for transitions routed by a dedicated advancement function + * rather than advanceWinnerTemplate's ceil(n/2) rule, and whose round sizes happen to + * halve so the check in buildFeederMap can't rule them out on shape alone. + * + * The NBA play-in is the case: Play-In Round 2 pairs the 7v8 *loser* with the 9v10 + * winner (advanceNBAPlayInWinner), which no winners-only halving describes. + */ +const BESPOKE_TRANSITIONS = new Set(["nba_20:Play-In Round 1"]); + +export function matchKey(round: string, matchNumber: number): string { + return `${round}#${matchNumber}`; +} + +/** + * Invert a template's advancement rules into "what fills each slot". + * + * `llws_20` has an explicit, hand-verified routing table with deliberate cross-overs, so + * it is inverted from that. Everything else follows the standard rule: slot p1 of match N + * is the winner of match 2N-1 in the previous round, slot p2 the winner of match 2N. + */ +export function buildFeederMap(template: BracketTemplate | undefined): FeederMap { + const feeders: FeederMap = new Map(); + if (!template) return feeders; + + const slots = (key: string): [SlotSource, SlotSource] => { + let pair = feeders.get(key); + if (!pair) { + pair = [SEED, SEED]; + feeders.set(key, pair); + } + return pair; + }; + + // Seed every match in the template so unfed slots read as directly seeded. + for (const round of template.rounds) { + for (let n = 1; n <= round.matchCount; n++) slots(matchKey(round.name, n)); + } + + if (template.id === "llws_20") { + for (const round of template.rounds) { + for (let n = 1; n <= round.matchCount; n++) { + const { winner, loser } = resolveLLWSAdvancement(round.name, n); + const ref: MatchRef = { round: round.name, matchNumber: n }; + for (const [destination, result] of [ + [winner, "winner"], + [loser, "loser"], + ] as const) { + if (!destination) continue; + const pair = slots(matchKey(destination.round, destination.matchNumber)); + pair[destination.slot === "participant1Id" ? 0 : 1] = { kind: "match", ref, result }; + } + } + } + return feeders; + } + + // Follow each round's declared `feedsInto` rather than array order — AFL's Wildcard + // Round feeds the Elimination Finals, skipping the round printed next to it. + for (const prev of template.rounds) { + if (!prev.feedsInto) continue; + const round = template.rounds.find((r) => r.name === prev.feedsInto); + if (!round) continue; + + // advanceWinnerTemplate sends match n to ceil(n/2) in the next round, slot by + // parity. That describes the bracket only where the round halves exactly; a + // play-in, a bye round, or a First Four routes by rules of its own, and inventing + // a halving there would draw connectors and slot labels that are simply wrong. + // Leaving those edges out drops the group to computeGroupLayout's fallback, which + // is the geometry these brackets already had. + if (prev.matchCount !== round.matchCount * 2) continue; + if (BESPOKE_TRANSITIONS.has(`${template.id}:${prev.name}`)) continue; + + for (let n = 1; n <= round.matchCount; n++) { + const pair = slots(matchKey(round.name, n)); + pair[0] = { + kind: "match", + ref: { round: prev.name, matchNumber: 2 * n - 1 }, + result: "winner", + }; + pair[1] = { + kind: "match", + ref: { round: prev.name, matchNumber: 2 * n }, + result: "winner", + }; + } + } + + return feeders; +} + +// ── Slot labels ─────────────────────────────────────────────────────────────── + +/** + * Round names as they read inside a card, where there is room for about twenty + * characters. Anything not listed keeps its full name. + */ +const SHORT_ROUND_NAMES: Record = { + "Opening Round": "Opening", + "Winners Round 2": "Winners R2", + "Winners Semifinals": "Winners SF", + "Winners Final": "Winners Final", + "Elimination Round 1": "Elim R1", + "Elimination Round 2": "Elim R2", + "Elimination Round 3": "Elim R3", + "Elimination Round 4": "Elim R4", + "Elimination Final": "Elim Final", + "Bracket Championship": "Bracket Final", +}; + +/** + * How an empty slot should read: "Winner of Winners SF 2" rather than "TBD". + * + * Returns null for a directly seeded slot, which the caller renders as "TBD". + * + * The cross-bracket feeds matter most here — a winners-bracket loser dropping into the + * elimination bracket is a real edge that no line can show, because the two sides render + * as separate trees. + */ +export function describeSlotSource( + source: SlotSource | undefined, + template: BracketTemplate | undefined +): string | null { + if (!source || source.kind !== "match") return null; + + const { round, matchNumber } = source.ref; + const name = SHORT_ROUND_NAMES[round] ?? round; + const verb = source.result === "winner" ? "Winner" : "Loser"; + const roundMatchCount = template?.rounds.find((r) => r.name === round)?.matchCount ?? 0; + + if (template?.id !== "llws_20") { + return `${verb} of ${name}${roundMatchCount <= 1 ? "" : ` ${matchNumber}`}`; + } + + // LLWS numbers matches globally across both sides, so semifinal 4 is International + // semifinal 2. Name it the way the printed bracket does — by side-local number, or by + // side where each side plays only one such game and the number would say nothing. + const { side, localMatch } = llwsSideAndLocal(round, matchNumber); + const isShared = round === "Consolation Third Place" || round === "World Championship"; + const perSideCount = isShared ? roundMatchCount : roundMatchCount / 2; + + if (perSideCount > 1) return `${verb} of ${name} ${localMatch}`; + if (isShared) return `${verb} of ${name}`; + return `${verb} of ${side === 0 ? "U.S." : "Intl"} ${name}`; +} + +// ── Layout ──────────────────────────────────────────────────────────────────── + +export interface LaidOutMatch { + match: M; + /** Centre of the card, in slot units (1 unit = one leaf row). */ + center: number; +} + +export interface LayoutColumn { + label: string; + matches: LaidOutMatch[]; +} + +export interface BracketLayout { + columns: LayoutColumn[]; + /** Number of leaf rows; multiply by row height for the pixel height of the bracket. */ + leafCount: number; + /** Edges to draw, as (column index of the source, source centre, target centre). */ + edges: { fromColumn: number; fromCenter: number; toCenter: number }[]; +} + +interface PositionedMatch { + round: string; + matchNumber: number; + /** Only read by the fallback, to trace edges through an unrecognised shape. */ + winnerId?: string | null; + participant1Id?: string | null; + participant2Id?: string | null; +} + +/** + * Lay out one rendered group — a winners bracket, an elimination bracket, a region. + * + * `matchesByRound` should already be filtered to the group; cross-group feeds are + * dropped, matching the printed bracket, which labels those slots rather than drawing + * lines to another tree. + * + * Falls back to the previous index-based geometry when the group has no single root + * (disjoint or unrecognised shapes), so no existing template can regress to a blank + * column. + */ +export function computeGroupLayout( + visibleRounds: string[], + matchesByRound: Map, + feeders: FeederMap, + templateRoundOrder: string[] +): BracketLayout { + const nodes = new Map(); + const roundOf = new Map(); + for (const round of visibleRounds) { + for (const match of matchesByRound.get(round) ?? []) { + const key = matchKey(match.round, match.matchNumber); + nodes.set(key, match); + roundOf.set(key, round); + } + } + if (nodes.size === 0) return { columns: [], leafCount: 0, edges: [] }; + + // In-group children, in slot order. A slot fed from outside the group has no card + // here, so it contributes no edge. + const childrenOf = new Map(); + const hasParent = new Set(); + for (const key of nodes.keys()) { + const pair = feeders.get(key); + const kids: string[] = []; + for (const source of pair ?? []) { + if (source.kind !== "match") continue; + const childKey = matchKey(source.ref.round, source.ref.matchNumber); + if (!nodes.has(childKey) || kids.includes(childKey)) continue; + kids.push(childKey); + hasParent.add(childKey); + } + childrenOf.set(key, kids); + } + + const roots = [...nodes.keys()].filter((k) => !hasParent.has(k)); + if (roots.length !== 1) { + return fallbackLayout(visibleRounds, matchesByRound); + } + const [root] = roots; + + // Depth from the root, then flip so leaves are column 0 and the final is last. + // + // Take the longest path, not the first one found: in a double-elimination bracket a + // match feeds two places (its winner forward, its loser into the elimination side), so + // the graph is a DAG and a node can be reached at several depths. The longest path is + // the one that leaves room for every game on the way. + const depth = new Map(); + const assignDepth = (key: string, d: number) => { + const known = depth.get(key); + if (known !== undefined && known >= d) return; + depth.set(key, d); + for (const child of childrenOf.get(key) ?? []) assignDepth(child, d + 1); + }; + assignDepth(root, 0); + if (depth.size !== nodes.size) { + return fallbackLayout(visibleRounds, matchesByRound); + } + const maxDepth = Math.max(...depth.values()); + const columnOf = (key: string) => maxDepth - (depth.get(key) ?? 0); + + // Vertical order comes from a depth-first walk in slot order: participant1's feeder + // sits above participant2's. This is why the elimination bracket's later game ends up + // on top, as the printed bracket has it. + const center = new Map(); + let leafCount = 0; + const place = (key: string): number => { + const already = center.get(key); + if (already !== undefined) return already; + const kids = childrenOf.get(key) ?? []; + if (kids.length === 0) { + const y = leafCount + 0.5; + leafCount += 1; + center.set(key, y); + return y; + } + const kidCenters = kids.map(place); + const y = kidCenters.reduce((sum, c) => sum + c, 0) / kidCenters.length; + center.set(key, y); + return y; + }; + place(root); + + const columns: LayoutColumn[] = Array.from({ length: maxDepth + 1 }, () => ({ + label: "", + matches: [], + })); + for (const [key, match] of nodes) { + columns[columnOf(key)].matches.push({ match, center: center.get(key) ?? 0 }); + } + for (const column of columns) { + column.matches.sort((a, b) => a.center - b.center); + } + + // A column can mix rounds — the LLWS second column holds two Opening Round games + // alongside Winners Round 2. Name it for the latest round it contains, which is how + // the printed bracket labels that column. + for (let ci = 0; ci < columns.length; ci++) { + const rounds = columns[ci].matches.map((m) => m.match.round); + columns[ci].label = rounds.reduce((latest, r) => + templateRoundOrder.indexOf(r) > templateRoundOrder.indexOf(latest) ? r : latest + ); + } + + // Connectors live in the single gutter between adjacent columns, so only edges that + // span exactly one gutter can be drawn. In a tree every edge does; in the DAG case a + // feed can reach further back, and a line that stopped short would be worse than none. + const edges: BracketLayout["edges"] = []; + for (const [key] of nodes) { + const toCenter = center.get(key) ?? 0; + for (const child of childrenOf.get(key) ?? []) { + const fromColumn = columnOf(child); + if (fromColumn !== columnOf(key) - 1) continue; + edges.push({ fromColumn, fromCenter: center.get(child) ?? 0, toCenter }); + } + } + + return { columns, leafCount, edges }; +} + +/** + * The previous behaviour, kept for groups whose shape can't be resolved into a single + * tree: one column per round, matches spread evenly over it, and edges inferred from the + * round sizes. Brackets with bespoke routing (AFL, CFP byes, a bracket with no template) + * land here, so it has to keep drawing what they drew before rather than nothing. + */ +function fallbackLayout( + visibleRounds: string[], + matchesByRound: Map +): BracketLayout { + const leafCount = Math.max( + ...visibleRounds.map((r) => matchesByRound.get(r)?.length ?? 0), + 1 + ); + const centersFor = (matches: M[]) => { + const span = leafCount / Math.max(matches.length, 1); + return matches.map((_, i) => (i + 0.5) * span); + }; + + const columns = visibleRounds.map((round) => { + const matches = matchesByRound.get(round) ?? []; + const centers = centersFor(matches); + return { + label: round, + matches: matches.map((match, i) => ({ match, center: centers[i] })), + }; + }); + + const edges: BracketLayout["edges"] = []; + for (let ci = 0; ci < columns.length - 1; ci++) { + const from = columns[ci].matches; + const to = columns[ci + 1].matches; + + if (to.length === Math.ceil(from.length / 2) && from.length > 1) { + // A halving: matches 2k and 2k+1 feed match k. + for (let k = 0; k < to.length; k++) { + for (const idx of [2 * k, 2 * k + 1]) { + if (idx >= from.length) continue; + edges.push({ + fromColumn: ci, + fromCenter: from[idx].center, + toCenter: to[k].center, + }); + } + } + continue; + } + + // Otherwise the only thing that can be known is where a winner actually went, so + // nothing is drawn until the games are played. + const winnerToCenter = new Map(); + for (const { match, center } of from) { + if (match.winnerId) winnerToCenter.set(match.winnerId, center); + } + for (const { match, center } of to) { + for (const id of [match.participant1Id, match.participant2Id]) { + const fromCenter = id ? winnerToCenter.get(id) : undefined; + if (fromCenter === undefined) continue; + edges.push({ fromColumn: ci, fromCenter, toCenter: center }); + } + } + } + + return { columns, leafCount, edges }; +} diff --git a/app/lib/llws-bracket.ts b/app/lib/llws-bracket.ts new file mode 100644 index 0000000..ddaab70 --- /dev/null +++ b/app/lib/llws-bracket.ts @@ -0,0 +1,194 @@ +/** + * LLWS 20-team double-elimination routing — the pure half of the bracket. + * + * Lives in lib/ rather than models/ because the renderer needs it: models/playoff-match + * pulls in the database context and drizzle, which must not reach the browser bundle. + * models/playoff-match re-exports everything here, so server-side callers are unchanged. + */ + +import { llwsMatchNumber, llwsSideAndLocal } from "~/lib/bracket-templates"; + +/** + * Where one participant goes after an LLWS match: a round, a side-local match number, + * and which slot to fill. `null` means eliminated (or, for winners, no further game). + */ +interface LLWSDestination { + round: string; + localMatch: number; + slot: "participant1Id" | "participant2Id"; +} + +/** + * LLWS advancement map, in SIDE-LOCAL match numbers. + * + * Keyed by round, then by the local match number of the completed game. Each entry + * says where the winner goes and where the loser goes (null = eliminated). + * + * Verified game-by-game against the official 2026 LLBWS bracket. Note the deliberate + * cross-overs — the elimination bracket does NOT feed straight across: + * Elim R1: L(Opening m2) v L(Opening m3) and L(Opening m1) v L(Opening m4) + * Elim R3: L(Semi m1) v W(Elim R2 m2) and L(Semi m2) v W(Elim R2 m1) + * Elim R4: W(Elim R3 m1) v W(Elim R3 m2) + * + * A loss in the winners bracket routes into the elimination bracket rather than + * eliminating the team; a loss in the elimination bracket is final. + */ +const LLWS_ADVANCEMENT: Record< + string, + Record +> = { + "Opening Round": { + 1: { + winner: { round: "Winners Round 2", localMatch: 1, slot: "participant2Id" }, + loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant1Id" }, + }, + 2: { + winner: { round: "Winners Round 2", localMatch: 2, slot: "participant2Id" }, + loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant1Id" }, + }, + 3: { + winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant1Id" }, + loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant2Id" }, + }, + 4: { + winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant2Id" }, + loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant2Id" }, + }, + }, + "Winners Round 2": { + 1: { + winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant2Id" }, + loser: { round: "Elimination Round 2", localMatch: 1, slot: "participant1Id" }, + }, + 2: { + winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant1Id" }, + loser: { round: "Elimination Round 2", localMatch: 2, slot: "participant1Id" }, + }, + }, + "Winners Semifinals": { + 1: { + winner: { round: "Winners Final", localMatch: 1, slot: "participant1Id" }, + loser: { round: "Elimination Round 3", localMatch: 1, slot: "participant1Id" }, + }, + 2: { + winner: { round: "Winners Final", localMatch: 1, slot: "participant2Id" }, + loser: { round: "Elimination Round 3", localMatch: 2, slot: "participant1Id" }, + }, + }, + "Winners Final": { + 1: { + winner: { round: "Bracket Championship", localMatch: 1, slot: "participant1Id" }, + // A winners-bracket final loss is not an elimination — it drops to the + // Elimination Final for a second chance at the side championship. + loser: { round: "Elimination Final", localMatch: 1, slot: "participant1Id" }, + }, + }, + "Elimination Round 1": { + 1: { + winner: { round: "Elimination Round 2", localMatch: 1, slot: "participant2Id" }, + loser: null, + }, + 2: { + winner: { round: "Elimination Round 2", localMatch: 2, slot: "participant2Id" }, + loser: null, + }, + }, + "Elimination Round 2": { + // Cross-over: R2 m1's winner meets the OTHER semifinal loser. + 1: { + winner: { round: "Elimination Round 3", localMatch: 2, slot: "participant2Id" }, + loser: null, + }, + 2: { + winner: { round: "Elimination Round 3", localMatch: 1, slot: "participant2Id" }, + loser: null, + }, + }, + "Elimination Round 3": { + // The later game (m2) is printed on top: G32 = W28 v W26, G31 = W27 v W25. + 1: { + winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant2Id" }, + loser: null, + }, + 2: { + winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant1Id" }, + loser: null, + }, + }, + "Elimination Round 4": { + 1: { + winner: { round: "Elimination Final", localMatch: 1, slot: "participant2Id" }, + loser: null, + }, + }, + "Elimination Final": { + 1: { + winner: { round: "Bracket Championship", localMatch: 1, slot: "participant2Id" }, + loser: null, + }, + }, +}; + +/** Rounds whose losers drop into the elimination bracket instead of going out. */ +export const LLWS_LOSER_ADVANCES_ROUNDS = new Set([ + "Opening Round", + "Winners Round 2", + "Winners Semifinals", +]); + +/** A resolved LLWS destination, in global (not side-local) match numbers. */ +export interface LLWSResolvedDestination { + round: string; + matchNumber: number; + slot: "participant1Id" | "participant2Id"; +} + +/** + * Resolve where the winner and loser of a completed LLWS match go, in global match + * numbers. `null` means that participant has no further game (eliminated, or the + * tournament is over for them). + * + * Pure — no DB access — so the whole 38-game routing can be verified against the + * official bracket in tests. advanceLLWSWinner is a thin writer on top of this. + */ +export function resolveLLWSAdvancement( + round: string, + matchNumber: number +): { winner: LLWSResolvedDestination | null; loser: LLWSResolvedDestination | null } { + // Terminal rounds — nobody advances. + if (round === "Consolation Third Place" || round === "World Championship") { + return { winner: null, loser: null }; + } + + // Bracket Championship is the crossover: the winner goes to the World Championship + // and the loser to the Consolation game. The side fixes the slot in both (U.S. takes + // participant1, International participant2), so the two sides can't collide. + if (round === "Bracket Championship") { + const { side } = llwsSideAndLocal("Bracket Championship", matchNumber); + const slot: "participant1Id" | "participant2Id" = + side === 0 ? "participant1Id" : "participant2Id"; + return { + winner: { round: "World Championship", matchNumber: 1, slot }, + loser: { round: "Consolation Third Place", matchNumber: 1, slot }, + }; + } + + const roundMap = LLWS_ADVANCEMENT[round]; + if (!roundMap) { + throw new Error(`Round '${round}' is not part of the LLWS bracket`); + } + + const { side, localMatch } = llwsSideAndLocal(round, matchNumber); + const routes = roundMap[localMatch]; + if (!routes) { + throw new Error(`No LLWS advancement defined for ${round} match ${matchNumber}`); + } + + // Winner and loser stay on their own side, so the same side offset applies to both. + const toGlobal = (d: LLWSDestination | null): LLWSResolvedDestination | null => + d === null + ? null + : { round: d.round, matchNumber: llwsMatchNumber(d.round, side, d.localMatch), slot: d.slot }; + + return { winner: toGlobal(routes.winner), loser: toGlobal(routes.loser) }; +} diff --git a/app/models/__tests__/llws-20-bracket.test.ts b/app/models/__tests__/llws-20-bracket.test.ts index d489ac3..11a7ba2 100644 --- a/app/models/__tests__/llws-20-bracket.test.ts +++ b/app/models/__tests__/llws-20-bracket.test.ts @@ -27,6 +27,13 @@ import { calculateAveragedPoints, type ScoringRules, } from "../scoring-rules"; +import { + GAME_TO_MATCH, + EXPECTED_SLOTS, + gameNumberFor, + required, + destinationGame, +} from "~/test/fixtures/llws-bracket"; // generateBracketFromTemplate's only DB touch for llws_20 is the bulk insert, so a // minimal stub is enough to capture the generated rows. @@ -55,121 +62,6 @@ const DEFAULT_SCORING: ScoringRules = { pointsFor8th: 10, }; -// ── PDF game number ↔ (round, match number) ────────────────────────────────── -// -// Transcribed directly from the 2026 LLBWS bracket. U.S. games take the low match -// numbers in each round, International the high ones. -const GAME_TO_MATCH: Record = { - // Opening Round — U.S. G2,4,6,8 (M1–4); Intl G1,3,5,7 (M5–8) - 2: { round: "Opening Round", matchNumber: 1 }, - 4: { round: "Opening Round", matchNumber: 2 }, - 6: { round: "Opening Round", matchNumber: 3 }, - 8: { round: "Opening Round", matchNumber: 4 }, - 1: { round: "Opening Round", matchNumber: 5 }, - 3: { round: "Opening Round", matchNumber: 6 }, - 5: { round: "Opening Round", matchNumber: 7 }, - 7: { round: "Opening Round", matchNumber: 8 }, - // Winners Round 2 — U.S. G10,12; Intl G9,11 - 10: { round: "Winners Round 2", matchNumber: 1 }, - 12: { round: "Winners Round 2", matchNumber: 2 }, - 9: { round: "Winners Round 2", matchNumber: 3 }, - 11: { round: "Winners Round 2", matchNumber: 4 }, - // Elimination Round 1 — U.S. G14,16; Intl G13,15 - 14: { round: "Elimination Round 1", matchNumber: 1 }, - 16: { round: "Elimination Round 1", matchNumber: 2 }, - 13: { round: "Elimination Round 1", matchNumber: 3 }, - 15: { round: "Elimination Round 1", matchNumber: 4 }, - // Winners Semifinals — U.S. G17,19; Intl G18,20 - 17: { round: "Winners Semifinals", matchNumber: 1 }, - 19: { round: "Winners Semifinals", matchNumber: 2 }, - 18: { round: "Winners Semifinals", matchNumber: 3 }, - 20: { round: "Winners Semifinals", matchNumber: 4 }, - // Elimination Round 2 — U.S. G22,24; Intl G21,23 - 22: { round: "Elimination Round 2", matchNumber: 1 }, - 24: { round: "Elimination Round 2", matchNumber: 2 }, - 21: { round: "Elimination Round 2", matchNumber: 3 }, - 23: { round: "Elimination Round 2", matchNumber: 4 }, - // Elimination Round 3 — U.S. G26,28; Intl G25,27 - 26: { round: "Elimination Round 3", matchNumber: 1 }, - 28: { round: "Elimination Round 3", matchNumber: 2 }, - 25: { round: "Elimination Round 3", matchNumber: 3 }, - 27: { round: "Elimination Round 3", matchNumber: 4 }, - // Winners Final — U.S. G30; Intl G29 - 30: { round: "Winners Final", matchNumber: 1 }, - 29: { round: "Winners Final", matchNumber: 2 }, - // Elimination Round 4 — U.S. G32; Intl G31 - 32: { round: "Elimination Round 4", matchNumber: 1 }, - 31: { round: "Elimination Round 4", matchNumber: 2 }, - // Elimination Final — U.S. G34; Intl G33 - 34: { round: "Elimination Final", matchNumber: 1 }, - 33: { round: "Elimination Final", matchNumber: 2 }, - // Bracket Championship — U.S. G36; Intl G35 - 36: { round: "Bracket Championship", matchNumber: 1 }, - 35: { round: "Bracket Championship", matchNumber: 2 }, - // Finals - 37: { round: "Consolation Third Place", matchNumber: 1 }, - 38: { round: "World Championship", matchNumber: 1 }, -}; - -const MATCH_TO_GAME = new Map( - Object.entries(GAME_TO_MATCH).map(([game, m]) => [ - `${m.round}#${m.matchNumber}`, - Number(game), - ]) -); - -function gameNumberFor(round: string, matchNumber: number): number { - const game = MATCH_TO_GAME.get(`${round}#${matchNumber}`); - if (game === undefined) throw new Error(`No PDF game for ${round} #${matchNumber}`); - return game; -} - -/** Narrows a destination that the test expects to exist. */ -function required(destination: T | null): T { - if (destination === null) throw new Error("Expected a destination, got null"); - return destination; -} - -/** PDF game number a destination points at. */ -function destinationGame( - destination: { round: string; matchNumber: number } | null -): number { - const d = required(destination); - return gameNumberFor(d.round, d.matchNumber); -} - -/** - * The official bracket printed as feed labels: for each game, which prior game's - * winner (W) or loser (L) fills each slot. `null` = a team seeded in directly. - * - * Transcribed from the PDF. This is the source of truth the routing must reproduce. - */ -const EXPECTED_SLOTS: Record = { - // Opening Round — all directly seeded - 1: [null, null], 2: [null, null], 3: [null, null], 4: [null, null], - 5: [null, null], 6: [null, null], 7: [null, null], 8: [null, null], - // Winners Round 2 — bye team, then an Opening Round winner - 9: [null, "W1"], 10: [null, "W2"], 11: [null, "W3"], 12: [null, "W4"], - // Elimination Round 1 - 13: ["L3", "L5"], 14: ["L4", "L6"], 15: ["L1", "L7"], 16: ["L2", "L8"], - // Winners Semifinals - 17: ["W6", "W10"], 18: ["W5", "W9"], 19: ["W12", "W8"], 20: ["W11", "W7"], - // Elimination Round 2 - 21: ["L9", "W13"], 22: ["L10", "W14"], 23: ["L11", "W15"], 24: ["L12", "W16"], - // Elimination Round 3 — cross-over - 25: ["L18", "W23"], 26: ["L17", "W24"], 27: ["L20", "W21"], 28: ["L19", "W22"], - // Winners Final - 29: ["W18", "W20"], 30: ["W17", "W19"], - // Elimination Round 4 - 31: ["W27", "W25"], 32: ["W28", "W26"], - // Elimination Final - 33: ["L29", "W31"], 34: ["L30", "W32"], - // Bracket Championship - 35: ["W29", "W33"], 36: ["W30", "W34"], - // Finals - 37: ["L36", "L35"], 38: ["W36", "W35"], -}; - describe("LLWS 20 Bracket Template", () => { describe("Template structure", () => { it("has correct identity and size", () => { diff --git a/app/models/playoff-match.ts b/app/models/playoff-match.ts index 101daad..440f326 100644 --- a/app/models/playoff-match.ts +++ b/app/models/playoff-match.ts @@ -10,6 +10,11 @@ import { llwsSideAndLocal, STANDARD_BRACKET_SEEDING, } from "~/lib/bracket-templates"; +import { + LLWS_LOSER_ADVANCES_ROUNDS, + resolveLLWSAdvancement, + type LLWSResolvedDestination, +} from "~/lib/llws-bracket"; export type PlayoffMatch = typeof schema.playoffMatches.$inferSelect; export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert; @@ -1563,190 +1568,10 @@ async function advanceNBAPlayInWinner( // ── LLWS 20 (double elimination) ────────────────────────────────────────────── -/** - * Where one participant goes after an LLWS match: a round, a side-local match number, - * and which slot to fill. `null` means eliminated (or, for winners, no further game). - */ -interface LLWSDestination { - round: string; - localMatch: number; - slot: "participant1Id" | "participant2Id"; -} - -/** - * LLWS advancement map, in SIDE-LOCAL match numbers. - * - * Keyed by round, then by the local match number of the completed game. Each entry - * says where the winner goes and where the loser goes (null = eliminated). - * - * Verified game-by-game against the official 2026 LLBWS bracket. Note the deliberate - * cross-overs — the elimination bracket does NOT feed straight across: - * Elim R1: L(Opening m2) v L(Opening m3) and L(Opening m1) v L(Opening m4) - * Elim R3: L(Semi m1) v W(Elim R2 m2) and L(Semi m2) v W(Elim R2 m1) - * Elim R4: W(Elim R3 m1) v W(Elim R3 m2) - * - * A loss in the winners bracket routes into the elimination bracket rather than - * eliminating the team; a loss in the elimination bracket is final. - */ -const LLWS_ADVANCEMENT: Record< - string, - Record -> = { - "Opening Round": { - 1: { - winner: { round: "Winners Round 2", localMatch: 1, slot: "participant2Id" }, - loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant1Id" }, - }, - 2: { - winner: { round: "Winners Round 2", localMatch: 2, slot: "participant2Id" }, - loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant1Id" }, - }, - 3: { - winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant1Id" }, - loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant2Id" }, - }, - 4: { - winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant2Id" }, - loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant2Id" }, - }, - }, - "Winners Round 2": { - 1: { - winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant2Id" }, - loser: { round: "Elimination Round 2", localMatch: 1, slot: "participant1Id" }, - }, - 2: { - winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant1Id" }, - loser: { round: "Elimination Round 2", localMatch: 2, slot: "participant1Id" }, - }, - }, - "Winners Semifinals": { - 1: { - winner: { round: "Winners Final", localMatch: 1, slot: "participant1Id" }, - loser: { round: "Elimination Round 3", localMatch: 1, slot: "participant1Id" }, - }, - 2: { - winner: { round: "Winners Final", localMatch: 1, slot: "participant2Id" }, - loser: { round: "Elimination Round 3", localMatch: 2, slot: "participant1Id" }, - }, - }, - "Winners Final": { - 1: { - winner: { round: "Bracket Championship", localMatch: 1, slot: "participant1Id" }, - // A winners-bracket final loss is not an elimination — it drops to the - // Elimination Final for a second chance at the side championship. - loser: { round: "Elimination Final", localMatch: 1, slot: "participant1Id" }, - }, - }, - "Elimination Round 1": { - 1: { - winner: { round: "Elimination Round 2", localMatch: 1, slot: "participant2Id" }, - loser: null, - }, - 2: { - winner: { round: "Elimination Round 2", localMatch: 2, slot: "participant2Id" }, - loser: null, - }, - }, - "Elimination Round 2": { - // Cross-over: R2 m1's winner meets the OTHER semifinal loser. - 1: { - winner: { round: "Elimination Round 3", localMatch: 2, slot: "participant2Id" }, - loser: null, - }, - 2: { - winner: { round: "Elimination Round 3", localMatch: 1, slot: "participant2Id" }, - loser: null, - }, - }, - "Elimination Round 3": { - // The later game (m2) is printed on top: G32 = W28 v W26, G31 = W27 v W25. - 1: { - winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant2Id" }, - loser: null, - }, - 2: { - winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant1Id" }, - loser: null, - }, - }, - "Elimination Round 4": { - 1: { - winner: { round: "Elimination Final", localMatch: 1, slot: "participant2Id" }, - loser: null, - }, - }, - "Elimination Final": { - 1: { - winner: { round: "Bracket Championship", localMatch: 1, slot: "participant2Id" }, - loser: null, - }, - }, -}; - -/** Rounds whose losers drop into the elimination bracket instead of going out. */ -const LLWS_LOSER_ADVANCES_ROUNDS = new Set([ - "Opening Round", - "Winners Round 2", - "Winners Semifinals", -]); - -/** A resolved LLWS destination, in global (not side-local) match numbers. */ -export interface LLWSResolvedDestination { - round: string; - matchNumber: number; - slot: "participant1Id" | "participant2Id"; -} - -/** - * Resolve where the winner and loser of a completed LLWS match go, in global match - * numbers. `null` means that participant has no further game (eliminated, or the - * tournament is over for them). - * - * Pure — no DB access — so the whole 38-game routing can be verified against the - * official bracket in tests. advanceLLWSWinner is a thin writer on top of this. - */ -export function resolveLLWSAdvancement( - round: string, - matchNumber: number -): { winner: LLWSResolvedDestination | null; loser: LLWSResolvedDestination | null } { - // Terminal rounds — nobody advances. - if (round === "Consolation Third Place" || round === "World Championship") { - return { winner: null, loser: null }; - } - - // Bracket Championship is the crossover: the winner goes to the World Championship - // and the loser to the Consolation game. The side fixes the slot in both (U.S. takes - // participant1, International participant2), so the two sides can't collide. - if (round === "Bracket Championship") { - const { side } = llwsSideAndLocal("Bracket Championship", matchNumber); - const slot: "participant1Id" | "participant2Id" = - side === 0 ? "participant1Id" : "participant2Id"; - return { - winner: { round: "World Championship", matchNumber: 1, slot }, - loser: { round: "Consolation Third Place", matchNumber: 1, slot }, - }; - } - - const roundMap = LLWS_ADVANCEMENT[round]; - if (!roundMap) { - throw new Error(`Round '${round}' is not part of the LLWS bracket`); - } - - const { side, localMatch } = llwsSideAndLocal(round, matchNumber); - const routes = roundMap[localMatch]; - if (!routes) { - throw new Error(`No LLWS advancement defined for ${round} match ${matchNumber}`); - } - - // Winner and loser stay on their own side, so the same side offset applies to both. - const toGlobal = (d: LLWSDestination | null): LLWSResolvedDestination | null => - d === null - ? null - : { round: d.round, matchNumber: llwsMatchNumber(d.round, side, d.localMatch), slot: d.slot }; - - return { winner: toGlobal(routes.winner), loser: toGlobal(routes.loser) }; -} +// The routing table itself is pure and lives in lib/ so the renderer can import it +// without pulling the database context into the browser bundle. Re-exported here so +// existing server-side callers and tests keep their import path. +export { LLWS_LOSER_ADVANCES_ROUNDS, resolveLLWSAdvancement, type LLWSResolvedDestination }; /** * Generate the 20-team LLWS double-elimination bracket (38 matches). diff --git a/app/routes/__tests__/admin.sports-seasons.bracket.clear.test.ts b/app/routes/__tests__/admin.sports-seasons.bracket.clear.test.ts new file mode 100644 index 0000000..c4e8e12 --- /dev/null +++ b/app/routes/__tests__/admin.sports-seasons.bracket.clear.test.ts @@ -0,0 +1,130 @@ +/** + * clear-bracket is the only path that can tear down a bracket, so the guard around it + * matters: it discards recorded results and the placements derived from them. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + findPlayoffMatchesByEventId, + deletePlayoffMatchesByEventId, +} from "~/models/playoff-match"; +import { deleteParticipantResultsBySportsSeasonId } from "~/models/participant-result"; +import { recalculateAffectedLeagues } from "~/models/scoring-calculator"; +import { getScoringEventById } from "~/models/scoring-event"; +import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server"; + +vi.mock("~/models/scoring-event", async (importOriginal) => ({ + ...(await importOriginal()), + getScoringEventById: vi.fn(), + updateScoringEvent: vi.fn(), + isReadOnlySibling: vi.fn(() => false), +})); +vi.mock("~/models/playoff-match", async (importOriginal) => ({ + ...(await importOriginal()), + findPlayoffMatchesByEventId: vi.fn(), + deletePlayoffMatchesByEventId: vi.fn(), +})); +vi.mock("~/models/participant-result", async (importOriginal) => ({ + ...(await importOriginal()), + deleteParticipantResultsBySportsSeasonId: vi.fn(), +})); +vi.mock("~/models/scoring-calculator", async (importOriginal) => ({ + ...(await importOriginal()), + recalculateAffectedLeagues: vi.fn(), +})); + +const EVENT = { id: "event-1", sportsSeasonId: "season-1" }; +const params = { id: "season-1", eventId: "event-1" }; + +function clearRequest(confirm?: string): Request { + const body = new FormData(); + body.set("intent", "clear-bracket"); + if (confirm !== undefined) body.set("confirm", confirm); + return new Request("http://localhost/clear", { method: "POST", body }); +} + +function match(isComplete: boolean) { + return { id: `m-${Math.random()}`, isComplete }; +} + +// The action's real signature carries React Router's generated types; the clear path +// only reads request and params. +const run = (request: Request) => + (action as unknown as (args: { request: Request; params: typeof params }) => Promise<{ + error?: string; + success?: string; + }>)({ request, params }); + +describe("clear-bracket", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getScoringEventById).mockResolvedValue( + EVENT as unknown as Awaited> + ); + vi.mocked(deletePlayoffMatchesByEventId).mockResolvedValue(undefined); + vi.mocked(deleteParticipantResultsBySportsSeasonId).mockResolvedValue(undefined); + vi.mocked(recalculateAffectedLeagues).mockResolvedValue( + undefined as unknown as Awaited> + ); + }); + + it("deletes the matches", async () => { + vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([ + match(false), + match(false), + ] as unknown as Awaited>); + + const result = await run(clearRequest()); + + expect(result.success).toContain("2 match(es) removed"); + expect(deletePlayoffMatchesByEventId).toHaveBeenCalledWith("event-1"); + }); + + it("leaves placements alone — they belong to the whole season, not this event", async () => { + // seasonParticipantResults is keyed by sports season, so deleting here would wipe + // every other event's placements with nothing to rebuild them. Reprocess Bracket is + // the tool that rebuilds them correctly. + vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([ + match(true), + ] as unknown as Awaited>); + + const result = await run(clearRequest("true")); + + expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled(); + expect(result.success).toContain("Reprocess Bracket"); + }); + + it("refuses to discard completed matches without confirmation", async () => { + vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([ + match(true), + match(false), + ] as unknown as Awaited>); + + const result = await run(clearRequest()); + + expect(result.error).toContain("1 completed match(es)"); + expect(deletePlayoffMatchesByEventId).not.toHaveBeenCalled(); + }); + + it("discards completed matches once confirmed", async () => { + vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([ + match(true), + ] as unknown as Awaited>); + + const result = await run(clearRequest("true")); + + expect(result.success).toBeDefined(); + expect(deletePlayoffMatchesByEventId).toHaveBeenCalledWith("event-1"); + }); + + it("rejects an event with no bracket rather than reporting a no-op success", async () => { + vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue( + [] as unknown as Awaited> + ); + + const result = await run(clearRequest("true")); + + expect(result.error).toContain("no bracket to clear"); + expect(deletePlayoffMatchesByEventId).not.toHaveBeenCalled(); + }); +}); diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts index 56773d7..0ffcc73 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts @@ -9,6 +9,7 @@ import { import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event"; import { findPlayoffMatchesByEventId, + deletePlayoffMatchesByEventId, generateBracketFromTemplate, setMatchWinner, advanceWinnerTemplate, @@ -288,6 +289,50 @@ export async function action({ request, params }: Route.ActionArgs) { } } + // The only way to repair a mis-seeded bracket: nothing else can rewrite a match's + // participants. Clearing brings back the setup form, so the admin re-seeds from there. + if (intent === "clear-bracket") { + try { + const event = await getScoringEventById(params.eventId); + if (!event) return { error: "Event not found" }; + + const existing = await findPlayoffMatchesByEventId(params.eventId); + if (existing.length === 0) { + return { error: "This event has no bracket to clear" }; + } + // Clearing discards recorded results, so make the admin confirm once games have + // actually been played. + const completed = existing.filter((m) => m.isComplete).length; + if (completed > 0 && formData.get("confirm") !== "true") { + return { + error: `This bracket has ${completed} completed match(es). Confirm to discard those results.`, + }; + } + + await deletePlayoffMatchesByEventId(params.eventId); + + // Placements are deliberately left alone. seasonParticipantResults is keyed by + // sports season, not by event, so a season-wide delete here would wipe the + // placements of every other event in the season with nothing to rebuild them — + // and on a finalized qualifying season that means permanently zeroed standings. + // Reprocess Bracket already rebuilds placements correctly, qualifying path + // included, so point the admin at it once the new bracket is in place. + const note = + completed > 0 + ? " Run Reprocess Bracket after rebuilding to clear the placements those results produced." + : ""; + + return { + success: `Bracket cleared (${existing.length} match(es) removed). Set it up again below.${note}`, + }; + } catch (error) { + logger.error("Error clearing bracket:", error); + return { + error: error instanceof Error ? error.message : "Failed to clear bracket", + }; + } + } + if (intent === "generate-bracket") { const templateId = formData.get("templateId"); diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx index 154ba39..6442f20 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx @@ -613,6 +613,60 @@ export default function EventBracket({ )} + {/* Clear Bracket - the only escape hatch for a mis-seeded bracket. Nothing else + can rewrite a match's participants, so a wrong seeding has to be torn down + and rebuilt via the setup form below, which reappears once this runs. */} + {matches.length > 0 && ( + + + Clear Bracket + + Delete every match in this bracket so it can be set up again from + scratch. Use this when the wrong participants were seeded. Placements + are left alone — run Reprocess Bracket after rebuilding to clear any + that the discarded results produced. + + + +
{ + if ( + !confirm( + `Delete all ${matches.length} match(es) in this bracket? Recorded results will be lost.` + ) + ) { + e.preventDefault(); + } + }} + > + + {/* The server refuses to discard completed matches unless this is + checked. Sending it unconditionally from a hidden field would make + that guard unreachable, including for a submit without JS. */} + {matches.some((m: { isComplete: boolean }) => m.isComplete) && ( +
+ + +
+ )} + +
+
+
+ )} + {/* ====== SETUP PHASE ====== */} {showSetup && ( diff --git a/app/test/fixtures/llws-bracket.ts b/app/test/fixtures/llws-bracket.ts new file mode 100644 index 0000000..6d0e254 --- /dev/null +++ b/app/test/fixtures/llws-bracket.ts @@ -0,0 +1,122 @@ +/** + * The official 2026 LLBWS bracket (Williamsport, Aug 19–30), transcribed from the PDF. + * + * The printed bracket numbers its games 1–38. Both the routing tests and the layout + * tests check themselves against these numbers, so the transcription lives here rather + * than in either one. + */ + +// ── PDF game number ↔ (round, match number) ────────────────────────────────── +// +// Transcribed directly from the 2026 LLBWS bracket. U.S. games take the low match +// numbers in each round, International the high ones. +export const GAME_TO_MATCH: Record = { + // Opening Round — U.S. G2,4,6,8 (M1–4); Intl G1,3,5,7 (M5–8) + 2: { round: "Opening Round", matchNumber: 1 }, + 4: { round: "Opening Round", matchNumber: 2 }, + 6: { round: "Opening Round", matchNumber: 3 }, + 8: { round: "Opening Round", matchNumber: 4 }, + 1: { round: "Opening Round", matchNumber: 5 }, + 3: { round: "Opening Round", matchNumber: 6 }, + 5: { round: "Opening Round", matchNumber: 7 }, + 7: { round: "Opening Round", matchNumber: 8 }, + // Winners Round 2 — U.S. G10,12; Intl G9,11 + 10: { round: "Winners Round 2", matchNumber: 1 }, + 12: { round: "Winners Round 2", matchNumber: 2 }, + 9: { round: "Winners Round 2", matchNumber: 3 }, + 11: { round: "Winners Round 2", matchNumber: 4 }, + // Elimination Round 1 — U.S. G14,16; Intl G13,15 + 14: { round: "Elimination Round 1", matchNumber: 1 }, + 16: { round: "Elimination Round 1", matchNumber: 2 }, + 13: { round: "Elimination Round 1", matchNumber: 3 }, + 15: { round: "Elimination Round 1", matchNumber: 4 }, + // Winners Semifinals — U.S. G17,19; Intl G18,20 + 17: { round: "Winners Semifinals", matchNumber: 1 }, + 19: { round: "Winners Semifinals", matchNumber: 2 }, + 18: { round: "Winners Semifinals", matchNumber: 3 }, + 20: { round: "Winners Semifinals", matchNumber: 4 }, + // Elimination Round 2 — U.S. G22,24; Intl G21,23 + 22: { round: "Elimination Round 2", matchNumber: 1 }, + 24: { round: "Elimination Round 2", matchNumber: 2 }, + 21: { round: "Elimination Round 2", matchNumber: 3 }, + 23: { round: "Elimination Round 2", matchNumber: 4 }, + // Elimination Round 3 — U.S. G26,28; Intl G25,27 + 26: { round: "Elimination Round 3", matchNumber: 1 }, + 28: { round: "Elimination Round 3", matchNumber: 2 }, + 25: { round: "Elimination Round 3", matchNumber: 3 }, + 27: { round: "Elimination Round 3", matchNumber: 4 }, + // Winners Final — U.S. G30; Intl G29 + 30: { round: "Winners Final", matchNumber: 1 }, + 29: { round: "Winners Final", matchNumber: 2 }, + // Elimination Round 4 — U.S. G32; Intl G31 + 32: { round: "Elimination Round 4", matchNumber: 1 }, + 31: { round: "Elimination Round 4", matchNumber: 2 }, + // Elimination Final — U.S. G34; Intl G33 + 34: { round: "Elimination Final", matchNumber: 1 }, + 33: { round: "Elimination Final", matchNumber: 2 }, + // Bracket Championship — U.S. G36; Intl G35 + 36: { round: "Bracket Championship", matchNumber: 1 }, + 35: { round: "Bracket Championship", matchNumber: 2 }, + // Finals + 37: { round: "Consolation Third Place", matchNumber: 1 }, + 38: { round: "World Championship", matchNumber: 1 }, +}; + +export const MATCH_TO_GAME = new Map( + Object.entries(GAME_TO_MATCH).map(([game, m]) => [ + `${m.round}#${m.matchNumber}`, + Number(game), + ]) +); + +export function gameNumberFor(round: string, matchNumber: number): number { + const game = MATCH_TO_GAME.get(`${round}#${matchNumber}`); + if (game === undefined) throw new Error(`No PDF game for ${round} #${matchNumber}`); + return game; +} + +/** Narrows a destination that the test expects to exist. */ +export function required(destination: T | null): T { + if (destination === null) throw new Error("Expected a destination, got null"); + return destination; +} + +/** PDF game number a destination points at. */ +export function destinationGame( + destination: { round: string; matchNumber: number } | null +): number { + const d = required(destination); + return gameNumberFor(d.round, d.matchNumber); +} + +/** + * The official bracket printed as feed labels: for each game, which prior game's + * winner (W) or loser (L) fills each slot. `null` = a team seeded in directly. + * + * Transcribed from the PDF. This is the source of truth the routing must reproduce. + */ +export const EXPECTED_SLOTS: Record = { + // Opening Round — all directly seeded + 1: [null, null], 2: [null, null], 3: [null, null], 4: [null, null], + 5: [null, null], 6: [null, null], 7: [null, null], 8: [null, null], + // Winners Round 2 — bye team, then an Opening Round winner + 9: [null, "W1"], 10: [null, "W2"], 11: [null, "W3"], 12: [null, "W4"], + // Elimination Round 1 + 13: ["L3", "L5"], 14: ["L4", "L6"], 15: ["L1", "L7"], 16: ["L2", "L8"], + // Winners Semifinals + 17: ["W6", "W10"], 18: ["W5", "W9"], 19: ["W12", "W8"], 20: ["W11", "W7"], + // Elimination Round 2 + 21: ["L9", "W13"], 22: ["L10", "W14"], 23: ["L11", "W15"], 24: ["L12", "W16"], + // Elimination Round 3 — cross-over + 25: ["L18", "W23"], 26: ["L17", "W24"], 27: ["L20", "W21"], 28: ["L19", "W22"], + // Winners Final + 29: ["W18", "W20"], 30: ["W17", "W19"], + // Elimination Round 4 + 31: ["W27", "W25"], 32: ["W28", "W26"], + // Elimination Final + 33: ["L29", "W31"], 34: ["L30", "W32"], + // Bracket Championship + 35: ["W29", "W33"], 36: ["W30", "W34"], + // Finals + 37: ["L36", "L35"], 38: ["W36", "W35"], +}; diff --git a/tsconfig.node.json b/tsconfig.node.json index 549fec1..6f814ca 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -7,6 +7,7 @@ "app/models/**/*.ts", "app/services/**/*.ts", "app/lib/**/*.ts", + "app/test/fixtures/**/*.ts", "app/types/**/*.ts", "vite.config.ts" ],