From 89ceee432af52c0d8cc57deddfd9b79dc37125f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 17:50:59 +0000 Subject: [PATCH 1/2] Lay out brackets from the feeder graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LLWS bracket didn't read as a bracket: cards sat above games that don't feed them, connectors joined the wrong pairs, and several games had no line at all. The stored data was correct — LLWS_ADVANCEMENT already matches the official 2026 LLBWS bracket game for game. The renderer was the problem. TreeColumns placed cards at `index * (height / roundSize)` and ConnectorColumn assumed matches 2k and 2k+1 feed match k, which holds only for an exact halving. The LLWS winners bracket is not one: two of the four Opening Round games skip Winners Round 2 and go straight to the semifinals, so those two got stranded in column one with nothing beside them, and the halving branch drew confident, wrong connectors for the rest. Lay out from the graph instead. app/lib/bracket-layout.ts inverts a template's advancement into "what fills each slot", then assigns columns by depth from the group's final, orders each column by the parent's slot order, and centres each card on its feeders. Counting back from the final is what makes a printed bracket line up: a team entering late is drawn in the column where it actually plays. This reproduces the official International bracket exactly, and fixes Elimination Round 3, where the official bracket prints the later game on top but match-number sort put it below. Because column is depth, every in-group edge spans exactly one gutter, so connectors now draw for unplayed games too. Cards also take a fixed height rather than stretching to fill their column, which is what made a lone final tower over the rest. Empty slots name their source — "Loser of Winners SF 1" rather than "TBD". That is the only way to show the feeds crossing between the winners and elimination brackets, which render as separate trees. Also: - Move the LLWS routing table to app/lib/llws-bracket.ts so the renderer can import it without pulling the database context into the browser bundle; models/playoff-match re-exports it. - Page the mobile view one group at a time, matching desktop. A whole double-elimination phase is a DAG, not a tree, so its columns would be arbitrary. - Add a clear-bracket admin action. Nothing else could rewrite a match's participants, so a mis-seeded bracket had no repair path at all. - Lift the PDF transcription into app/test/fixtures/llws-bracket.ts so the routing and layout tests check against one copy of the official bracket. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HnzbrCHoM8ESbtbDamaqFb --- .../scoring/BracketTreePaginated.tsx | 96 +++-- app/components/scoring/BracketTreeView.tsx | 241 +++++++---- app/components/scoring/NbaBracketLayout.tsx | 39 +- app/components/scoring/PlayoffBracket.tsx | 48 +-- .../scoring/TabbedBracketLayout.tsx | 96 +++-- .../scoring/__tests__/PlayoffBracket.test.tsx | 133 +++--- app/lib/__tests__/bracket-layout.test.ts | 377 ++++++++++++++++++ app/lib/bracket-layout.ts | 351 ++++++++++++++++ app/lib/llws-bracket.ts | 194 +++++++++ app/models/__tests__/llws-20-bracket.test.ts | 122 +----- app/models/playoff-match.ts | 193 +-------- ...admin.sports-seasons.bracket.clear.test.ts | 118 ++++++ ...sons.$id.events.$eventId.bracket.server.ts | 38 ++ ...ts-seasons.$id.events.$eventId.bracket.tsx | 36 ++ app/test/fixtures/llws-bracket.ts | 122 ++++++ tsconfig.node.json | 1 + 16 files changed, 1643 insertions(+), 562 deletions(-) create mode 100644 app/lib/__tests__/bracket-layout.test.ts create mode 100644 app/lib/bracket-layout.ts create mode 100644 app/lib/llws-bracket.ts create mode 100644 app/routes/__tests__/admin.sports-seasons.bracket.clear.test.ts create mode 100644 app/test/fixtures/llws-bracket.ts 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..f1bd782 --- /dev/null +++ b/app/lib/__tests__/bracket-layout.test.ts @@ -0,0 +1,377 @@ +/** + * 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, + 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; +} + +/** 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("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..49f5a09 --- /dev/null +++ b/app/lib/bracket-layout.ts @@ -0,0 +1,351 @@ +/** + * 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" }; + +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; + } + + for (let ri = 1; ri < template.rounds.length; ri++) { + const round = template.rounds[ri]; + const prev = template.rounds[ri - 1]; + for (let n = 1; n <= round.matchCount; n++) { + const pair = slots(matchKey(round.name, n)); + for (const [slotIdx, source] of [ + [0, 2 * n - 1], + [1, 2 * n], + ] as const) { + if (source > prev.matchCount) continue; + pair[slotIdx] = { + kind: "match", + ref: { round: prev.name, matchNumber: source }, + 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; +} + +/** + * 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: one column per round, matches spread evenly, no edges. + * Used when a group's shape can't be resolved into a single tree. + */ +function fallbackLayout( + visibleRounds: string[], + matchesByRound: Map +): BracketLayout { + const leafCount = Math.max( + ...visibleRounds.map((r) => matchesByRound.get(r)?.length ?? 0), + 1 + ); + const columns = visibleRounds.map((round) => { + const matches = matchesByRound.get(round) ?? []; + const span = leafCount / Math.max(matches.length, 1); + return { + label: round, + matches: matches.map((match, i) => ({ match, center: (i + 0.5) * span })), + }; + }); + 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..4fb255a --- /dev/null +++ b/app/routes/__tests__/admin.sports-seasons.bracket.clear.test.ts @@ -0,0 +1,118 @@ +/** + * 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 and the placements derived from them", 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"); + expect(deleteParticipantResultsBySportsSeasonId).toHaveBeenCalledWith("season-1"); + }); + + 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(); + expect(deleteParticipantResultsBySportsSeasonId).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..a91a3db 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,43 @@ 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 were derived from the matches just deleted; leaving them behind would + // keep stale points on the standings. + await deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId); + await recalculateAffectedLeagues(event.sportsSeasonId, database(), { skipDiscord: true }); + + return { + success: `Bracket cleared (${existing.length} match(es) removed). Set it up again below.`, + }; + } 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..7be75fa 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,42 @@ 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. This also + clears the season's placements and the points derived from them. + + + +
{ + if ( + !confirm( + `Delete all ${matches.length} match(es) in this bracket? Recorded results and placements will be lost.` + ) + ) { + e.preventDefault(); + } + }} + > + + + +
+
+
+ )} + {/* ====== 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" ], From 569081fe29cc95193d160f85dff79f1e5fa54ee1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 18:18:05 +0000 Subject: [PATCH 2/2] Only derive feeders where the halving rule actually holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught that the generic feeder rule was being applied to templates that route by their own logic. It was harmless as dead code; driving the renderer with it made several brackets worse than before. The rule pairs rounds by array order and assumes match n is fed by 2n-1 and 2n. That describes advanceWinnerTemplate, not every bracket: - afl_10's Wildcard Round feeds the Elimination Finals, skipping the round listed next to it, so array order fabricated the entire chain and drew ten wrong connectors contradicting advanceAFLWinner. - fifa_48's Third Place Game sits between the Semifinals and the Finals, so the Finals came out fed by the third place game. Once BracketTreeView filtered the consolation round out, the group had three roots and the whole World Cup bracket rendered with no connectors at all. - ncaa_68 labelled Round of 64 #1/#2 with First Four feeds that advanceFirstFourWinner doesn't use. - nba_20's play-in halves in size but pairs the 7v8 loser with the 9v10 winner. Follow each round's declared feedsInto, and derive edges only where the round halves exactly — the condition under which the generic ceil(n/2) mapping is true. Bespoke transitions that happen to halve are named explicitly. Slots left without a feeder read TBD, which is honest. Dropping those edges sends the group to the fallback, so the fallback now has to keep drawing what those brackets already drew: halving U-shapes by round size, and winner tracing through irregular shapes. Previously it drew nothing, which also silently removed every connector from brackets with no bracketTemplateId. Also from review: - clear-bracket deleted seasonParticipantResults for the entire sports season with no rebuild. That table is keyed by season, not event, so it wiped placements for every other event in the season — permanently zeroing standings on a finalized qualifying season. Delete only the matches and point the admin at Reprocess Bracket, which rebuilds placements correctly. - The clear-bracket form sent confirm=true from a hidden field, making the server's completed-match guard unreachable. It's a checkbox now, so the guard is real, including without JS. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HnzbrCHoM8ESbtbDamaqFb --- app/lib/__tests__/bracket-layout.test.ts | 156 ++++++++++++++++++ app/lib/bracket-layout.ts | 107 +++++++++--- ...admin.sports-seasons.bracket.clear.test.ts | 18 +- ...sons.$id.events.$eventId.bracket.server.ts | 17 +- ...ts-seasons.$id.events.$eventId.bracket.tsx | 26 ++- 5 files changed, 293 insertions(+), 31 deletions(-) diff --git a/app/lib/__tests__/bracket-layout.test.ts b/app/lib/__tests__/bracket-layout.test.ts index f1bd782..70968b0 100644 --- a/app/lib/__tests__/bracket-layout.test.ts +++ b/app/lib/__tests__/bracket-layout.test.ts @@ -12,6 +12,7 @@ import { LLWS_20, SIMPLE_16, NFL_14, + BRACKET_TEMPLATES, getBracketTemplate, type BracketTemplate, type ConferenceGroup, @@ -28,6 +29,10 @@ 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. */ @@ -311,6 +316,157 @@ describe("computeGroupLayout — standard brackets are unchanged", () => { }); }); +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] => { diff --git a/app/lib/bracket-layout.ts b/app/lib/bracket-layout.ts index 49f5a09..e63027e 100644 --- a/app/lib/bracket-layout.ts +++ b/app/lib/bracket-layout.ts @@ -47,6 +47,16 @@ 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}`; } @@ -94,22 +104,34 @@ export function buildFeederMap(template: BracketTemplate | undefined): FeederMap return feeders; } - for (let ri = 1; ri < template.rounds.length; ri++) { - const round = template.rounds[ri]; - const prev = template.rounds[ri - 1]; + // 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)); - for (const [slotIdx, source] of [ - [0, 2 * n - 1], - [1, 2 * n], - ] as const) { - if (source > prev.matchCount) continue; - pair[slotIdx] = { - kind: "match", - ref: { round: prev.name, matchNumber: source }, - result: "winner", - }; - } + 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", + }; } } @@ -195,6 +217,10 @@ export interface BracketLayout { 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; } /** @@ -328,8 +354,10 @@ export function computeGroupLayout( } /** - * The previous behaviour: one column per round, matches spread evenly, no edges. - * Used when a group's shape can't be resolved into a single tree. + * 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[], @@ -339,13 +367,54 @@ function fallbackLayout( ...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 span = leafCount / Math.max(matches.length, 1); + const centers = centersFor(matches); return { label: round, - matches: matches.map((match, i) => ({ match, center: (i + 0.5) * span })), + matches: matches.map((match, i) => ({ match, center: centers[i] })), }; }); - return { columns, leafCount, edges: [] }; + + 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/routes/__tests__/admin.sports-seasons.bracket.clear.test.ts b/app/routes/__tests__/admin.sports-seasons.bracket.clear.test.ts index 4fb255a..c4e8e12 100644 --- a/app/routes/__tests__/admin.sports-seasons.bracket.clear.test.ts +++ b/app/routes/__tests__/admin.sports-seasons.bracket.clear.test.ts @@ -68,7 +68,7 @@ describe("clear-bracket", () => { ); }); - it("deletes the matches and the placements derived from them", async () => { + it("deletes the matches", async () => { vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([ match(false), match(false), @@ -78,7 +78,20 @@ describe("clear-bracket", () => { expect(result.success).toContain("2 match(es) removed"); expect(deletePlayoffMatchesByEventId).toHaveBeenCalledWith("event-1"); - expect(deleteParticipantResultsBySportsSeasonId).toHaveBeenCalledWith("season-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 () => { @@ -91,7 +104,6 @@ describe("clear-bracket", () => { expect(result.error).toContain("1 completed match(es)"); expect(deletePlayoffMatchesByEventId).not.toHaveBeenCalled(); - expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled(); }); it("discards completed matches once confirmed", async () => { 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 a91a3db..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 @@ -310,13 +310,20 @@ export async function action({ request, params }: Route.ActionArgs) { } await deletePlayoffMatchesByEventId(params.eventId); - // Placements were derived from the matches just deleted; leaving them behind would - // keep stale points on the standings. - await deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId); - await recalculateAffectedLeagues(event.sportsSeasonId, database(), { skipDiscord: true }); + + // 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.`, + success: `Bracket cleared (${existing.length} match(es) removed). Set it up again below.${note}`, }; } catch (error) { logger.error("Error clearing bracket:", error); 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 7be75fa..6442f20 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx @@ -622,17 +622,19 @@ export default function EventBracket({ 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. This also - clears the season's placements and the points derived from them. + 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 and placements will be lost.` + `Delete all ${matches.length} match(es) in this bracket? Recorded results will be lost.` ) ) { e.preventDefault(); @@ -640,7 +642,23 @@ export default function EventBracket({ }} > - + {/* 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) && ( +
+ + +
+ )}