Merge pull request 'claude/llws-bracket-alignment-gworyh' (#140) from claude/llws-bracket-alignment-gworyh into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m9s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m24s
🚀 Deploy / 🐳 Build (push) Successful in 1m17s
🚀 Deploy / 🚀 Deploy (push) Successful in 24s

Reviewed-on: #140
This commit is contained in:
chrisp 2026-08-21 19:01:41 +00:00
commit 2356e37163
16 changed files with 1905 additions and 562 deletions

View file

@ -1,13 +1,16 @@
import { ChevronLeft, ChevronRight } from "lucide-react"; import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { useRoundTransition } from "~/hooks/useRoundTransition"; import { useRoundTransition } from "~/hooks/useRoundTransition";
import type { FeederMap } from "~/lib/bracket-layout";
import type { BracketTemplate } from "~/lib/bracket-templates";
import { import {
TreeColumns, TreeColumns,
BracketMatchSlot, BracketMatchSlot,
bracketGeometry,
windowGeometry,
SLOT_WIDTH, SLOT_WIDTH,
LABEL_HEIGHT, LABEL_HEIGHT,
DESIRED_CARD_HEIGHT, DESIRED_CARD_HEIGHT,
CARD_GAP,
MAX_CARD_HEIGHT, MAX_CARD_HEIGHT,
type BracketMatch, type BracketMatch,
type BracketOwnership, type BracketOwnership,
@ -21,6 +24,8 @@ interface BracketTreePaginatedProps {
/** Index of the first scoring round — default page starts here */ /** Index of the first scoring round — default page starts here */
firstScoringRoundIdx?: number; firstScoringRoundIdx?: number;
thirdPlaceRound?: string; thirdPlaceRound?: string;
feeders?: FeederMap;
template?: BracketTemplate;
} }
export function BracketTreePaginated({ export function BracketTreePaginated({
@ -30,63 +35,68 @@ export function BracketTreePaginated({
userParticipantIds, userParticipantIds,
firstScoringRoundIdx, firstScoringRoundIdx,
thirdPlaceRound, thirdPlaceRound,
feeders,
template,
}: BracketTreePaginatedProps) { }: BracketTreePaginatedProps) {
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds; const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined; 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( const defaultPage = Math.max(
0, 0,
Math.min( Math.min(
firstScoringRoundIdx !== undefined firstScoringRoundIdx !== undefined ? Math.max(0, firstScoringRoundIdx - 1) : lastPage,
? Math.max(0, firstScoringRoundIdx - 1) lastPage,
: mainRounds.length - 2,
mainRounds.length - 2,
), ),
); );
const { page, anim, stripRef, navigate, handleTransitionEnd } = useRoundTransition( const { page, anim, stripRef, navigate, handleTransitionEnd } = useRoundTransition(
mainRounds.length - 2, lastPage,
defaultPage, defaultPage,
); );
const targetPage = anim ? anim.toPage : page; const pageGeometry = (p: number) => windowGeometry(geometry, p, p + 1);
const labelRounds = mainRounds.slice(targetPage, targetPage + 2); const labelFor = (p: number) => {
const label = labelRounds[1] ? `${labelRounds[0]}${labelRounds[1]}` : labelRounds[0]; const [a, b] = [columns[p]?.label, columns[p + 1]?.label];
return b ? `${a}${b}` : (a ?? "");
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 pageHeight = calcHeight(page); const label = labelFor(anim ? anim.toPage : page);
const animFromHeight = anim ? calcHeight(anim.fromPage) : pageHeight;
const animToHeight = anim ? calcHeight(anim.toPage) : pageHeight;
const visibleRounds = mainRounds.slice(page, page + 2); const pageG = pageGeometry(page);
const fromRounds = anim ? mainRounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds; const animFromG = anim ? pageGeometry(anim.fromPage) : pageG;
const toRounds = anim ? mainRounds.slice(anim.toPage, anim.toPage + 2) : visibleRounds; const animToG = anim ? pageGeometry(anim.toPage) : pageG;
let leftRounds: string[]; let leftPage: number;
let rightRounds: string[] = []; let rightPage: number | null = null;
let leftHeight: number; let leftG = pageG;
let rightHeight = 0; let rightG = pageG;
let settlingTransition = false; let settlingTransition = false;
if (anim?.phase === "sliding") { if (anim?.phase === "sliding") {
leftRounds = anim.dir === "right" ? fromRounds : toRounds; leftPage = anim.dir === "right" ? anim.fromPage : anim.toPage;
rightRounds = anim.dir === "right" ? toRounds : fromRounds; rightPage = anim.dir === "right" ? anim.toPage : anim.fromPage;
leftHeight = anim.dir === "right" ? animFromHeight : animToHeight; leftG = anim.dir === "right" ? animFromG : animToG;
rightHeight = anim.dir === "right" ? animToHeight : animFromHeight; rightG = anim.dir === "right" ? animToG : animFromG;
} else if (anim?.phase === "settling") { } else if (anim?.phase === "settling") {
leftRounds = toRounds; leftPage = anim.toPage;
leftHeight = animToHeight; leftG = animToG;
settlingTransition = true; settlingTransition = true;
} else { } else {
leftRounds = visibleRounds; leftPage = page;
leftHeight = pageHeight;
} }
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; const initialX = anim?.phase === "sliding" && anim.dir === "left" ? -SLOT_WIDTH : 0;
return ( return (
@ -109,7 +119,7 @@ export function BracketTreePaginated({
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => navigate(page + 1)} onClick={() => navigate(page + 1)}
disabled={page + 2 >= mainRounds.length || !!anim} disabled={page >= lastPage || !!anim}
className="h-7 w-7 shrink-0" className="h-7 w-7 shrink-0"
aria-label="Next rounds" aria-label="Next rounds"
> >
@ -129,22 +139,24 @@ export function BracketTreePaginated({
> >
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}> <div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
<TreeColumns <TreeColumns
visibleRounds={leftRounds} geometry={leftG}
matchesByRound={matchesByRound} columnRange={[leftPage, leftPage + 1]}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
bracketHeight={leftHeight} feeders={feeders}
template={template}
transitionDuration={settlingTransition ? 500 : undefined} transitionDuration={settlingTransition ? 500 : undefined}
/> />
</div> </div>
{anim?.phase === "sliding" && ( {anim?.phase === "sliding" && rightPage !== null && (
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}> <div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
<TreeColumns <TreeColumns
visibleRounds={rightRounds} geometry={rightG}
matchesByRound={matchesByRound} columnRange={[rightPage, rightPage + 1]}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
bracketHeight={rightHeight} feeders={feeders}
template={template}
/> />
</div> </div>
)} )}
@ -164,6 +176,8 @@ export function BracketTreePaginated({
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)} slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
/> />
</div> </div>
)} )}

View file

@ -1,5 +1,13 @@
import { avatarColor } from "~/lib/avatar-colors"; import { avatarColor } from "~/lib/avatar-colors";
import { BRACKT_GRADIENT } from "~/lib/brand"; 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 { export interface BracketMatch {
id: string; id: string;
@ -46,6 +54,8 @@ function formatScore(score: string | null): string | null {
interface ParticipantRowProps { interface ParticipantRowProps {
name: string | null; name: string | null;
/** What fills this slot when it's still empty, e.g. "Winner of Winners SF 2". */
feedLabel?: string | null;
isTbd: boolean; isTbd: boolean;
isWinner: boolean; isWinner: boolean;
isLoser: boolean; isLoser: boolean;
@ -60,6 +70,7 @@ interface ParticipantRowProps {
function ParticipantRow({ function ParticipantRow({
name, name,
feedLabel,
isTbd, isTbd,
isWinner, isWinner,
isLoser, isLoser,
@ -114,7 +125,7 @@ function ParticipantRow({
.filter(Boolean) .filter(Boolean)
.join(" ")} .join(" ")}
> >
{name ?? "TBD"} {name ?? feedLabel ?? "TBD"}
</span> </span>
{/* Owner name below participant name */} {/* Owner name below participant name */}
@ -150,6 +161,8 @@ interface BracketMatchSlotProps {
slotHeight: number; slotHeight: number;
ownershipMap: Map<string, BracketOwnership>; ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>; userParticipantIds: Set<string>;
feeders?: FeederMap;
template?: BracketTemplate;
} }
export function BracketMatchSlot({ export function BracketMatchSlot({
@ -157,6 +170,8 @@ export function BracketMatchSlot({
slotHeight, slotHeight,
ownershipMap, ownershipMap,
userParticipantIds, userParticipantIds,
feeders,
template,
}: BracketMatchSlotProps) { }: BracketMatchSlotProps) {
const rowHeight = slotHeight / 2; const rowHeight = slotHeight / 2;
const showText = rowHeight >= 10; const showText = rowHeight >= 10;
@ -187,6 +202,13 @@ export function BracketMatchSlot({
const INSET = Math.max(1, Math.min(2, Math.floor(slotHeight / 20))); 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 ( return (
<div className="relative overflow-hidden" style={{ height: slotHeight }}> <div className="relative overflow-hidden" style={{ height: slotHeight }}>
{/* Corona layer — left-2 matches card borderRadius to prevent gradient bleed at left corners */} {/* Corona layer — left-2 matches card borderRadius to prevent gradient bleed at left corners */}
@ -208,6 +230,7 @@ export function BracketMatchSlot({
> >
<ParticipantRow <ParticipantRow
name={match.participant1?.name ?? null} name={match.participant1?.name ?? null}
feedLabel={feed1}
isTbd={isTbd1} isTbd={isTbd1}
isWinner={p1IsWinner} isWinner={p1IsWinner}
isLoser={p1IsLoser} isLoser={p1IsLoser}
@ -221,6 +244,7 @@ export function BracketMatchSlot({
/> />
<ParticipantRow <ParticipantRow
name={match.participant2?.name ?? null} name={match.participant2?.name ?? null}
feedLabel={feed2}
isTbd={isTbd2} isTbd={isTbd2}
isWinner={p2IsWinner} isWinner={p2IsWinner}
isLoser={p2IsLoser} isLoser={p2IsLoser}
@ -237,54 +261,45 @@ export function BracketMatchSlot({
); );
} }
// ─── Per-pair connector column ──────────────────────────────────────────────── // ─── Connector column ─────────────────────────────────────────────────────────
interface ConnectorColumnProps { interface ConnectorColumnProps {
currentMatches: BracketMatch[]; /** Edges crossing this gutter, in slot units. */
nextMatches: BracketMatch[]; edges: { fromCenter: number; toCenter: number }[];
rowHeight: number;
offset: number;
bracketHeight: number; bracketHeight: number;
} }
function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: ConnectorColumnProps) { /**
* Draws the feeder edges crossing one gutter. Because the layout assigns columns by
* depth from the final, every edge spans exactly one gutter so a card that enters the
* bracket late is drawn in the column where it actually plays, and there is never an
* edge to route across a skipped column.
*/
function ConnectorColumn({ edges, rowHeight, offset, bracketHeight }: ConnectorColumnProps) {
const mid = CONNECTOR_WIDTH / 2; const mid = CONNECTOR_WIDTH / 2;
// Merge the two edges feeding one card into a single elbow, so a pair reads as one
// bracket join rather than two overlapping lines.
const byTarget = new Map<number, number[]>();
for (const { fromCenter, toCenter } of edges) {
const sources = byTarget.get(toCenter) ?? [];
sources.push(fromCenter);
byTarget.set(toCenter, sources);
}
const paths: string[] = []; const paths: string[] = [];
for (const [toCenter, sources] of byTarget) {
const currentSlotH = bracketHeight / Math.max(currentMatches.length, 1); const destY = toCenter * rowHeight - offset;
const nextSlotH = bracketHeight / Math.max(nextMatches.length, 1); const ys = sources.map((c) => c * rowHeight - offset).toSorted((a, b) => a - b);
if (ys.length === 1) {
// Use halving U-shapes only when prev > 1 (avoids false-positive 1→1 side branches like 3PG→Finals) paths.push(`M 0 ${ys[0]} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`);
if (nextMatches.length === Math.ceil(currentMatches.length / 2) && currentMatches.length > 1) { continue;
// 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}`);
} }
} paths.push(`M 0 ${ys[0]} H ${mid} V ${ys[ys.length - 1]} H 0`);
} else { for (const y of ys.slice(1, -1)) paths.push(`M 0 ${y} H ${mid}`);
// Non-standard (byes, play-ins, etc.): trace winners by participantId paths.push(`M ${mid} ${destY} H ${CONNECTOR_WIDTH}`);
const winnerToIdx = new Map<string, number>();
currentMatches.forEach((m, idx) => {
if (m.winnerId) winnerToIdx.set(m.winnerId, idx);
});
nextMatches.forEach((nextMatch, nextIdx) => {
const destY = nextIdx * nextSlotH + nextSlotH / 2;
for (const pId of [nextMatch.participant1Id, nextMatch.participant2Id]) {
if (!pId) continue;
const srcIdx = winnerToIdx.get(pId);
if (srcIdx === undefined) continue;
const srcY = srcIdx * currentSlotH + currentSlotH / 2;
paths.push(`M 0 ${srcY} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`);
}
});
} }
return ( return (
@ -310,52 +325,131 @@ function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: Connect
// ─── Tree columns (shared by full + paginated) ─────────────────────────────── // ─── Tree columns (shared by full + paginated) ───────────────────────────────
export interface BracketGeometry {
layout: BracketLayout<BracketMatch>;
/** 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<string, BracketMatch[]>,
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 { interface TreeColumnsProps {
visibleRounds: string[]; geometry: BracketGeometry;
matchesByRound: Map<string, BracketMatch[]>;
ownershipMap: Map<string, BracketOwnership>; ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>; userParticipantIds: Set<string>;
bracketHeight: number;
transitionDuration?: 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({ export function TreeColumns({
visibleRounds, geometry,
matchesByRound,
ownershipMap, ownershipMap,
userParticipantIds, userParticipantIds,
bracketHeight,
transitionDuration, transitionDuration,
feeders,
template,
columnRange,
}: TreeColumnsProps) { }: TreeColumnsProps) {
const tr = transitionDuration ? `${transitionDuration}ms ease` : undefined; const tr = transitionDuration ? `${transitionDuration}ms ease` : undefined;
return ( const { layout, rowHeight, bracketHeight, offset } = geometry;
<div style={{ display: "flex", width: "100%", height: bracketHeight + LABEL_HEIGHT, transition: tr ? `height ${tr}` : undefined }}>
{visibleRounds.map((round, ri) => { const [firstColumn, lastColumn] = columnRange ?? [0, layout.columns.length - 1];
const roundMatches = matchesByRound.get(round) ?? []; const visible = layout.columns.slice(firstColumn, lastColumn + 1);
const slotHeight = bracketHeight / Math.max(roundMatches.length, 1);
const cardHeight = Math.min(slotHeight - CARD_GAP, MAX_CARD_HEIGHT); // Cards keep a fixed height regardless of how many share a column — stretching a
const cardTop = (slotHeight - cardHeight) / 2; // lone final to fill its column is what made it tower over the rest of the bracket.
const nextRound = ri < visibleRounds.length - 1 ? visibleRounds[ri + 1] : null; const cardHeight = Math.min(
const nextMatches = nextRound ? (matchesByRound.get(nextRound) ?? []) : []; Math.max(rowHeight - CARD_GAP, 1),
MAX_CARD_HEIGHT
);
return ( return (
<div key={round} style={{ display: "contents" }}> <div style={{ display: "flex", width: "100%", height: bracketHeight + LABEL_HEIGHT, transition: tr ? `height ${tr}` : undefined }}>
{visible.map((column, vi) => {
const ci = firstColumn + vi;
const gutterEdges = layout.edges.filter((e) => e.fromColumn === ci);
return (
<div key={column.label + ci} style={{ display: "contents" }}>
{/* Round column */} {/* Round column */}
<div style={{ flex: "1 1 0", minWidth: COLUMN_WIDTH, position: "relative" }}> <div style={{ flex: "1 1 0", minWidth: COLUMN_WIDTH, position: "relative" }}>
<div <div
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground truncate text-center" className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground truncate text-center"
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }} style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
> >
{round} {column.label}
</div> </div>
<div style={{ position: "relative", height: bracketHeight, transition: tr ? `height ${tr}` : undefined }}> <div style={{ position: "relative", height: bracketHeight, transition: tr ? `height ${tr}` : undefined }}>
{roundMatches.map((match, matchIdx) => ( {column.matches.map(({ match, center }) => (
<div <div
key={match.id} key={match.id}
data-match-id={match.id}
style={{ style={{
position: "absolute", position: "absolute",
top: matchIdx * slotHeight + cardTop, top: center * rowHeight - offset - cardHeight / 2,
left: 0, left: 0,
right: 0, right: 0,
height: cardHeight, height: cardHeight,
@ -367,6 +461,8 @@ export function TreeColumns({
slotHeight={cardHeight} slotHeight={cardHeight}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
/> />
</div> </div>
))} ))}
@ -374,10 +470,11 @@ export function TreeColumns({
</div> </div>
{/* Connector between this column and the next */} {/* Connector between this column and the next */}
{nextRound && ( {vi < visible.length - 1 && (
<ConnectorColumn <ConnectorColumn
currentMatches={roundMatches} edges={gutterEdges}
nextMatches={nextMatches} rowHeight={rowHeight}
offset={offset}
bracketHeight={bracketHeight} bracketHeight={bracketHeight}
/> />
)} )}
@ -396,6 +493,8 @@ interface BracketTreeViewProps {
ownershipMap: Map<string, BracketOwnership>; ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>; userParticipantIds: Set<string>;
thirdPlaceRound?: string; thirdPlaceRound?: string;
feeders?: FeederMap;
template?: BracketTemplate;
} }
export function BracketTreeView({ export function BracketTreeView({
@ -404,13 +503,19 @@ export function BracketTreeView({
ownershipMap, ownershipMap,
userParticipantIds, userParticipantIds,
thirdPlaceRound, thirdPlaceRound,
feeders,
template,
}: BracketTreeViewProps) { }: BracketTreeViewProps) {
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds; const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined; const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
const maxMatches = Math.max(...mainRounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1); const geometry = bracketGeometry(
const bracketHeight = maxMatches * (DESIRED_CARD_HEIGHT + CARD_GAP); mainRounds,
const minWidth = mainRounds.length * COLUMN_WIDTH + Math.max(0, mainRounds.length - 1) * CONNECTOR_WIDTH; matchesByRound,
feeders,
template?.rounds.map((r) => r.name) ?? mainRounds
);
const { bracketHeight, minWidth } = geometry;
return ( return (
<div <div
@ -419,11 +524,11 @@ export function BracketTreeView({
> >
<div style={{ minWidth }}> <div style={{ minWidth }}>
<TreeColumns <TreeColumns
visibleRounds={mainRounds} geometry={geometry}
matchesByRound={matchesByRound}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
bracketHeight={bracketHeight} feeders={feeders}
template={template}
/> />
{thirdPlaceMatch && ( {thirdPlaceMatch && (
<div style={{ display: "flex", paddingTop: 20 }}> <div style={{ display: "flex", paddingTop: 20 }}>
@ -441,6 +546,8 @@ export function BracketTreeView({
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)} slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
/> />
</div> </div>
</div> </div>

View file

@ -1,5 +1,11 @@
import type { ConferenceGroup } from "~/lib/bracket-templates"; import type { BracketTemplate, ConferenceGroup } from "~/lib/bracket-templates";
import { TreeColumns, type BracketMatch, type BracketOwnership } from "./BracketTreeView"; import type { FeederMap } from "~/lib/bracket-layout";
import {
TreeColumns,
bracketGeometry,
type BracketMatch,
type BracketOwnership,
} from "./BracketTreeView";
import { BracketTreePaginated } from "./BracketTreePaginated"; import { BracketTreePaginated } from "./BracketTreePaginated";
interface NbaBracketLayoutProps { interface NbaBracketLayoutProps {
@ -10,11 +16,10 @@ interface NbaBracketLayoutProps {
userParticipantIds: Set<string>; userParticipantIds: Set<string>;
conferenceGroups: ConferenceGroup[]; conferenceGroups: ConferenceGroup[];
scoringRoundIdx: number; scoringRoundIdx: number;
feeders?: FeederMap;
template?: BracketTemplate;
} }
const DESIRED_CARD_HEIGHT = 112;
const CARD_GAP = 14;
function splitMatchesByConference( function splitMatchesByConference(
matchesByRound: Map<string, BracketMatch[]>, matchesByRound: Map<string, BracketMatch[]>,
group: ConferenceGroup group: ConferenceGroup
@ -28,11 +33,6 @@ function splitMatchesByConference(
return result; return result;
} }
function bracketHeight(matchesByRound: Map<string, BracketMatch[]>, rounds: string[]): number {
const max = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
}
export function NbaBracketLayout({ export function NbaBracketLayout({
rounds, rounds,
matchesByRound, matchesByRound,
@ -40,7 +40,10 @@ export function NbaBracketLayout({
userParticipantIds, userParticipantIds,
conferenceGroups, conferenceGroups,
scoringRoundIdx, scoringRoundIdx,
feeders,
template,
}: NbaBracketLayoutProps) { }: NbaBracketLayoutProps) {
const roundOrder = template?.rounds.map((r) => r.name) ?? rounds;
// Rounds that belong to any conference group // Rounds that belong to any conference group
const conferenceRoundSet = new Set( const conferenceRoundSet = new Set(
conferenceGroups.flatMap((g) => Object.keys(g.roundMatchNumbers)) conferenceGroups.flatMap((g) => Object.keys(g.roundMatchNumbers))
@ -57,7 +60,7 @@ export function NbaBracketLayout({
const sharedMatches = new Map( const sharedMatches = new Map(
sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []]) sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []])
); );
const sharedHeight = bracketHeight(sharedMatches, sharedRounds); const sharedGeometry = bracketGeometry(sharedRounds, sharedMatches, feeders, roundOrder);
return ( return (
<> <>
@ -66,7 +69,7 @@ export function NbaBracketLayout({
{conferenceGroups.map((group, gi) => { {conferenceGroups.map((group, gi) => {
const confRounds = conferenceRounds[gi]; const confRounds = conferenceRounds[gi];
const confMatches = splitMatchesByConference(matchesByRound, group); const confMatches = splitMatchesByConference(matchesByRound, group);
const height = bracketHeight(confMatches, confRounds); const geometry = bracketGeometry(confRounds, confMatches, feeders, roundOrder);
return ( return (
<div key={group.name}> <div key={group.name}>
@ -74,11 +77,11 @@ export function NbaBracketLayout({
{group.name} {group.name}
</p> </p>
<TreeColumns <TreeColumns
visibleRounds={confRounds} geometry={geometry}
matchesByRound={confMatches}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
bracketHeight={height} feeders={feeders}
template={template}
/> />
</div> </div>
); );
@ -87,11 +90,11 @@ export function NbaBracketLayout({
{sharedRounds.length > 0 && ( {sharedRounds.length > 0 && (
<div> <div>
<TreeColumns <TreeColumns
visibleRounds={sharedRounds} geometry={sharedGeometry}
matchesByRound={sharedMatches}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
bracketHeight={sharedHeight} feeders={feeders}
template={template}
/> />
</div> </div>
)} )}

View file

@ -14,6 +14,7 @@ import { RankingsRow } from "./RankingsRow";
import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView"; import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
import { BracketTreePaginated } from "./BracketTreePaginated"; import { BracketTreePaginated } from "./BracketTreePaginated";
import { getBracketTemplate, type BracketTemplate } from "~/lib/bracket-templates"; import { getBracketTemplate, type BracketTemplate } from "~/lib/bracket-templates";
import { buildFeederMap } from "~/lib/bracket-layout";
import { NbaBracketLayout } from "./NbaBracketLayout"; import { NbaBracketLayout } from "./NbaBracketLayout";
import { TabbedBracketLayout } from "./TabbedBracketLayout"; import { TabbedBracketLayout } from "./TabbedBracketLayout";
@ -76,43 +77,6 @@ export function groupMatchesByRound(matches: Match[]): Map<string, Match[]> {
return byRound; 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<string, Match[]>,
orderedRounds: string[]
): Map<string, { round: string; matchNumber: number }> {
const feederMap = new Map<string, { round: string; matchNumber: number }>();
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 { interface EliminatedEntry {
participant: Participant; participant: Participant;
score: string | null; score: string | null;
@ -391,6 +355,8 @@ export function PlayoffBracket({
const matchesByRound = groupMatchesByRound(matches); const matchesByRound = groupMatchesByRound(matches);
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds); const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined; 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 consolation = findConsolationRound(template);
const thirdPlaceRound = consolation?.round; const thirdPlaceRound = consolation?.round;
@ -478,6 +444,8 @@ export function PlayoffBracket({
userParticipantIds={userParticipantSet} userParticipantIds={userParticipantSet}
phases={template.phases} phases={template.phases}
scoringRoundIdx={scoringRoundIdx} scoringRoundIdx={scoringRoundIdx}
feeders={feeders}
template={template}
/> />
) : template?.conferenceGroups ? ( ) : template?.conferenceGroups ? (
<NbaBracketLayout <NbaBracketLayout
@ -488,6 +456,8 @@ export function PlayoffBracket({
userParticipantIds={userParticipantSet} userParticipantIds={userParticipantSet}
conferenceGroups={template.conferenceGroups} conferenceGroups={template.conferenceGroups}
scoringRoundIdx={scoringRoundIdx} scoringRoundIdx={scoringRoundIdx}
feeders={feeders}
template={template}
/> />
) : ( ) : (
<> <>
@ -499,6 +469,8 @@ export function PlayoffBracket({
ownershipMap={ownershipMap as Map<string, BracketOwnership>} ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet} userParticipantIds={userParticipantSet}
thirdPlaceRound={thirdPlaceRound} thirdPlaceRound={thirdPlaceRound}
feeders={feeders}
template={template}
/> />
</div> </div>
@ -511,6 +483,8 @@ export function PlayoffBracket({
userParticipantIds={userParticipantSet} userParticipantIds={userParticipantSet}
firstScoringRoundIdx={scoringRoundIdx} firstScoringRoundIdx={scoringRoundIdx}
thirdPlaceRound={thirdPlaceRound} thirdPlaceRound={thirdPlaceRound}
feeders={feeders}
template={template}
/> />
</div> </div>
</> </>

View file

@ -1,8 +1,18 @@
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import type { BracketPhase, ConferenceGroup } from "~/lib/bracket-templates"; import type { BracketPhase, BracketTemplate, ConferenceGroup } from "~/lib/bracket-templates";
import { TreeColumns, BracketMatchSlot, type BracketMatch, type BracketOwnership } from "./BracketTreeView"; import type { FeederMap } from "~/lib/bracket-layout";
import {
TreeColumns,
BracketMatchSlot,
bracketGeometry,
type BracketMatch,
type BracketOwnership,
} from "./BracketTreeView";
import { BracketTreePaginated } from "./BracketTreePaginated"; 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 { interface TabbedBracketLayoutProps {
rounds: string[]; rounds: string[];
matchesByRound: Map<string, BracketMatch[]>; matchesByRound: Map<string, BracketMatch[]>;
@ -10,11 +20,10 @@ interface TabbedBracketLayoutProps {
userParticipantIds: Set<string>; userParticipantIds: Set<string>;
phases: BracketPhase[]; phases: BracketPhase[];
scoringRoundIdx: number; scoringRoundIdx: number;
feeders?: FeederMap;
template?: BracketTemplate;
} }
const CARD_H = 112;
const CARD_GAP = 14;
function groupMatches( function groupMatches(
matchesByRound: Map<string, BracketMatch[]>, matchesByRound: Map<string, BracketMatch[]>,
group: ConferenceGroup group: ConferenceGroup
@ -29,11 +38,6 @@ function groupMatches(
return out; return out;
} }
function phaseHeight(matchesByRound: Map<string, BracketMatch[]>, rounds: string[]): number {
const max = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
return max * (CARD_H + CARD_GAP);
}
// ─── Play-In Layout ─────────────────────────────────────────────────────────── // ─── Play-In Layout ───────────────────────────────────────────────────────────
interface PlayInColumnProps { interface PlayInColumnProps {
@ -141,7 +145,10 @@ export function TabbedBracketLayout({
userParticipantIds, userParticipantIds,
phases, phases,
scoringRoundIdx, scoringRoundIdx,
feeders,
template,
}: TabbedBracketLayoutProps) { }: TabbedBracketLayoutProps) {
const roundOrder = template?.rounds.map((r) => r.name) ?? rounds;
return ( return (
<div className="space-y-10"> <div className="space-y-10">
{phases.map((phase) => { {phases.map((phase) => {
@ -194,50 +201,87 @@ export function TabbedBracketLayout({
{phase.groups.map((group) => { {phase.groups.map((group) => {
const gMatches = groupMatches(matchesByRound, group); const gMatches = groupMatches(matchesByRound, group);
const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined); const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined);
const geometry = bracketGeometry(gRounds, gMatches, feeders, roundOrder);
return ( return (
<div key={group.name}> <div key={group.name}>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2"> <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
{group.name} {group.name}
</p> </p>
<div className="w-full overflow-x-auto">
<div style={{ minWidth: geometry.minWidth }}>
<TreeColumns <TreeColumns
visibleRounds={gRounds} geometry={geometry}
matchesByRound={gMatches}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
bracketHeight={phaseHeight(gMatches, gRounds)} feeders={feeders}
template={template}
/> />
</div> </div>
</div>
</div>
); );
})} })}
{sharedRounds.length > 0 && ( {sharedRounds.length > 0 && (
<TreeColumns <TreeColumns
visibleRounds={sharedRounds} geometry={bracketGeometry(sharedRounds, sharedMatchesByRound, feeders, roundOrder)}
matchesByRound={sharedMatchesByRound}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
bracketHeight={phaseHeight(sharedMatchesByRound, sharedRounds)} feeders={feeders}
template={template}
/> />
)} )}
</div> </div>
) : ( ) : (
<TreeColumns <TreeColumns
visibleRounds={phaseRounds} geometry={bracketGeometry(phaseRounds, phaseMatchesByRound, feeders, roundOrder)}
matchesByRound={phaseMatchesByRound}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
bracketHeight={phaseHeight(phaseMatchesByRound, phaseRounds)} feeders={feeders}
template={template}
/> />
)} )}
</div> </div>
{/* Mobile */} {/* Mobile paged one group at a time, matching the desktop split. Paging a
<div className="md:hidden"> 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. */}
<div className="md:hidden space-y-6">
{phase.layout === "play-in" ? ( {phase.layout === "play-in" ? (
<PlayInLayout <PlayInLayout
matchesByRound={phaseMatchesByRound} matchesByRound={phaseMatchesByRound}
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
/> />
) : phase.groups ? (
<>
{phase.groups.map((group) => (
<div key={group.name}>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
{group.name}
</p>
<BracketTreePaginated
rounds={groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined)}
matchesByRound={groupMatches(matchesByRound, group)}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
/>
</div>
))}
{sharedRounds.length > 0 && (
<BracketTreePaginated
rounds={sharedRounds}
matchesByRound={sharedMatchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
/>
)}
</>
) : ( ) : (
<BracketTreePaginated <BracketTreePaginated
rounds={phaseRounds} rounds={phaseRounds}
@ -245,6 +289,8 @@ export function TabbedBracketLayout({
ownershipMap={ownershipMap} ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds} userParticipantIds={userParticipantIds}
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined} firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
feeders={feeders}
template={template}
/> />
)} )}
</div> </div>

View file

@ -2,7 +2,6 @@ import { describe, it, expect } from "vitest";
import { render, screen, within } from "@testing-library/react"; import { render, screen, within } from "@testing-library/react";
import { import {
PlayoffBracket, PlayoffBracket,
buildFeederMap,
groupMatchesByRound, groupMatchesByRound,
computeEliminatedByRound, computeEliminatedByRound,
computeRankedEntries, computeRankedEntries,
@ -64,88 +63,72 @@ describe("groupMatchesByRound", () => {
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// buildFeederMap // Rendered LLWS bracket — geometry and empty-slot labels
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("buildFeederMap", () => { describe("PlayoffBracket — rendered LLWS bracket", () => {
it("returns an empty map when there is only one round", () => { const LLWS_ROUNDS = (getBracketTemplate("llws_20")?.rounds ?? []).map((r) => r.name);
const matches = [makeMatch("Finals", 1)];
const map = buildFeederMap(groupMatchesByRound(matches), ["Finals"]); /** Every LLWS match, all unplayed, so each slot shows what will fill it. */
expect(map.size).toBe(0); 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(
<PlayoffBracket
matches={emptyLlwsMatches()}
rounds={LLWS_ROUNDS}
bracketTemplateId="llws_20"
/>
);
// 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", () => { it("still shows TBD for a directly seeded slot", () => {
const rounds = ["Quarterfinals", "Semifinals", "Finals"]; render(
const matches = [ <PlayoffBracket
makeMatch("Quarterfinals", 1), matches={emptyLlwsMatches()}
makeMatch("Quarterfinals", 2), rounds={LLWS_ROUNDS}
makeMatch("Quarterfinals", 3), bracketTemplateId="llws_20"
makeMatch("Quarterfinals", 4), />
makeMatch("Semifinals", 1), );
makeMatch("Semifinals", 2),
makeMatch("Finals", 1),
];
const map = buildFeederMap(groupMatchesByRound(matches), rounds); // The opening round is seeded, not fed, so it has nothing to name.
expect(screen.getAllByText("TBD").length).toBeGreaterThan(0);
// 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 });
}); });
it("maps Finals slots to the correct SF matches", () => { it("gives every card the same height, including a lone final", () => {
const rounds = ["Quarterfinals", "Semifinals", "Finals"]; const { container } = render(
const matches = [ <PlayoffBracket
makeMatch("Quarterfinals", 1), matches={emptyLlwsMatches()}
makeMatch("Quarterfinals", 2), rounds={LLWS_ROUNDS}
makeMatch("Quarterfinals", 3), bracketTemplateId="llws_20"
makeMatch("Quarterfinals", 4), />
makeMatch("Semifinals", 1), );
makeMatch("Semifinals", 2),
makeMatch("Finals", 1),
];
const map = buildFeederMap(groupMatchesByRound(matches), rounds); const heights = new Set(
[...container.querySelectorAll<HTMLElement>("[data-match-id]")].map(
expect(map.get("Finals:1:p1")).toEqual({ round: "Semifinals", matchNumber: 1 }); (el) => el.style.height
expect(map.get("Finals:1:p2")).toEqual({ round: "Semifinals", matchNumber: 2 }); )
}); );
// Previously a one-match column stretched its card to fill the bracket height.
it("does not add an entry when the source match does not exist in the previous round", () => { expect(heights.size).toBe(1);
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 });
}); });
}); });

View file

@ -0,0 +1,533 @@
/**
* Bracket layout tests.
*
* The load-bearing assertions check the LLWS geometry against the official 2026 LLBWS
* bracket, in the PDF's own game numbers. A bracket "lines up" when each card sits level
* with the game that feeds it, so these tests assert column membership, top-to-bottom
* order, and vertical alignment not just that a layout was produced.
*/
import { describe, it, expect } from "vitest";
import {
LLWS_20,
SIMPLE_16,
NFL_14,
BRACKET_TEMPLATES,
getBracketTemplate,
type BracketTemplate,
type ConferenceGroup,
} from "~/lib/bracket-templates";
import {
buildFeederMap,
computeGroupLayout,
describeSlotSource,
matchKey,
type SlotSource,
} from "~/lib/bracket-layout";
import { GAME_TO_MATCH, MATCH_TO_GAME } from "~/test/fixtures/llws-bracket";
interface TestMatch {
round: string;
matchNumber: number;
/** Only the fallback reads these, to trace edges through an unrecognised shape. */
winnerId?: string | null;
participant1Id?: string | null;
participant2Id?: string | null;
}
/** Every match a template defines, as the renderer would receive them. */
function allMatches(template: BracketTemplate): Map<string, TestMatch[]> {
const byRound = new Map<string, TestMatch[]>();
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<string, TestMatch[]> {
const byRound = new Map<string, TestMatch[]>();
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<string, TestMatch[]>([
["Semifinals", [{ round: "Semifinals", matchNumber: 1 }]],
[
"Finals",
[
{ round: "Finals", matchNumber: 1 },
{ round: "Finals", matchNumber: 2 },
],
],
]);
const layout = computeGroupLayout(
["Semifinals", "Finals"],
byRound,
new Map(),
["Semifinals", "Finals"]
);
expect(layout.columns.map((c) => c.label)).toEqual(["Semifinals", "Finals"]);
expect(layout.edges).toEqual([]);
expect(layout.columns[0].matches[0].center).toBe(1);
expect(layout.columns[1].matches.map((m) => m.center)).toEqual([0.5, 1.5]);
});
it("returns nothing for an empty group", () => {
const layout = computeGroupLayout([], new Map(), new Map(), []);
expect(layout).toEqual({ columns: [], leafCount: 0, edges: [] });
});
});
describe("buildFeederMap — templates with routing of their own", () => {
// The halving rule describes advanceWinnerTemplate, not every bracket. Inventing it
// where it doesn't hold draws confident, wrong connectors and mislabels slots, which
// is worse than drawing nothing.
it("follows feedsInto rather than the order rounds are listed in", () => {
// AFL's Wildcard Round feeds the Elimination Finals, skipping the round printed
// next to it, so array order would fabricate the whole chain.
const afl = BRACKET_TEMPLATES.afl_10;
const feeders = buildFeederMap(afl);
const fed = [...feeders.entries()].filter(([, pair]) =>
pair.some((s) => s.kind === "match")
);
// Only Preliminary Finals → Grand Final actually halves.
expect(fed.map(([key]) => key)).toEqual(["Grand Final#1"]);
});
it("leaves a bye round's slots seeded rather than inventing feeds", () => {
// CFP's First Round (4) feeds the Quarterfinals (4) — the top seeds have byes.
const feeders = buildFeederMap(BRACKET_TEMPLATES.cfp_12);
expect(feeders.get(matchKey("Quarterfinals", 1))).toEqual([
{ kind: "seed" },
{ kind: "seed" },
]);
});
it("leaves the First Four out of the Round of 64", () => {
// advanceFirstFourWinner puts each winner in a specific seed slot, not games 1-2.
const feeders = buildFeederMap(BRACKET_TEMPLATES.ncaa_68);
expect(feeders.get(matchKey("Round of 64", 1))).toEqual([
{ kind: "seed" },
{ kind: "seed" },
]);
});
it("leaves the NBA play-in alone, where a loser feeds forward", () => {
// Play-In Round 2 pairs the 7v8 loser with the 9v10 winner, so the round sizes
// halve but the winners-only rule still doesn't describe it.
const feeders = buildFeederMap(BRACKET_TEMPLATES.nba_20);
expect(feeders.get(matchKey("Play-In Round 2", 1))).toEqual([
{ kind: "seed" },
{ kind: "seed" },
]);
});
it("does not route the FIFA final through the third place game", () => {
// Third Place Game sits between Semifinals and Finals in round order, so array
// order made it the Finals' feeder and left the Finals' second slot empty.
const feeders = buildFeederMap(BRACKET_TEMPLATES.fifa_48);
expect(feeders.get(matchKey("Finals", 1))).toEqual([
{ kind: "match", ref: { round: "Semifinals", matchNumber: 1 }, result: "winner" },
{ kind: "match", ref: { round: "Semifinals", matchNumber: 2 }, result: "winner" },
]);
});
});
describe("computeGroupLayout — every template still draws connectors", () => {
/** Lay a whole template out the way BracketTreeView would. */
function layOut(template: BracketTemplate) {
const byRound = allMatches(template);
const order = template.rounds.map((r) => r.name);
// BracketTreeView renders a third place game outside the tree.
const rounds = order.filter((r) => r !== "Third Place Game");
return computeGroupLayout(rounds, byRound, buildFeederMap(template), order);
}
// A gutter joining a column to one exactly half its size is a plain bracket join and
// must always be drawn. Where the sizes don't halve — a bye round, a play-in, the
// First Four — the routing is bespoke and nothing is drawn until the games decide it,
// which is what these brackets did before.
it.each(Object.keys(BRACKET_TEMPLATES).filter((id) => id !== "llws_20"))(
"%s draws every gutter that halves",
(id) => {
const layout = layOut(BRACKET_TEMPLATES[id]);
const gutters = new Set(layout.edges.map((e) => e.fromColumn));
let halvingGutters = 0;
for (let ci = 0; ci < layout.columns.length - 1; ci++) {
const from = layout.columns[ci].matches.length;
const to = layout.columns[ci + 1].matches.length;
if (from !== to * 2) continue;
halvingGutters += 1;
expect(gutters).toContain(ci);
}
// Every template has at least one, so a template that lost all its lines fails.
expect(halvingGutters).toBeGreaterThan(0);
}
);
it("keeps the FIFA bracket a single tree once the third place game is set aside", () => {
const layout = layOut(BRACKET_TEMPLATES.fifa_48);
expect(layout.columns.map((c) => c.label)).toEqual([
"Round of 32",
"Round of 16",
"Quarterfinals",
"Semifinals",
"Finals",
]);
expect(layout.edges).toHaveLength(30);
});
// llws_20 is excluded above because both sides in one group is genuinely not a tree;
// it renders per side, which the tests further up cover.
});
describe("computeGroupLayout — fallback keeps the old connectors", () => {
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
const byRound = new Map<string, TestMatch[]>([
["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<string, TestMatch[]>([
[
"Wildcard",
[
{ round: "Wildcard", matchNumber: 1, winnerId: "a" },
{ round: "Wildcard", matchNumber: 2, winnerId: "b" },
],
],
[
"Semifinals",
[
{ round: "Semifinals", matchNumber: 1, participant1Id: "seeded", participant2Id: "b" },
{ round: "Semifinals", matchNumber: 2, participant1Id: "seeded2", participant2Id: "a" },
],
],
]);
const layout = computeGroupLayout(
["Wildcard", "Semifinals"],
irregular,
new Map(),
["Wildcard", "Semifinals"]
);
// b won Wildcard 2 (centre 1.5) and plays Semifinal 1 (centre 0.5) — a crossing
// edge that only the actual result can reveal.
expect(layout.edges).toContainEqual({ fromColumn: 0, fromCenter: 1.5, toCenter: 0.5 });
expect(layout.edges).toContainEqual({ fromColumn: 0, fromCenter: 0.5, toCenter: 1.5 });
});
});
describe("describeSlotSource", () => {
const feeders = buildFeederMap(LLWS_20);
const sourcesFor = (game: number): [SlotSource, SlotSource] => {
const m = GAME_TO_MATCH[game];
const pair = feeders.get(matchKey(m.round, m.matchNumber));
if (!pair) throw new Error(`No feeders for G${game}`);
return pair;
};
it("names a winner feed", () => {
// G18 = W5 v W9; G5 is Opening Round match 3 on the International side.
expect(describeSlotSource(sourcesFor(18)[0], LLWS_20)).toBe("Winner of Opening 3");
});
it("names a loser feed, which is the one no line can show", () => {
// G21 = L9 v W13; G9 is Winners Round 2 match 1 on the International side.
expect(describeSlotSource(sourcesFor(21)[0], LLWS_20)).toBe("Loser of Winners R2 1");
// G25 = L18 v W23; G18 is International semifinal 1.
expect(describeSlotSource(sourcesFor(25)[0], LLWS_20)).toBe("Loser of Winners SF 1");
});
it("uses side-local numbers, as the printed bracket does", () => {
// G27 = L20 v W21. G20 is Winners Semifinals match 4 globally, but International
// semifinal 2 — the number the printed bracket uses.
expect(describeSlotSource(sourcesFor(27)[0], LLWS_20)).toBe("Loser of Winners SF 2");
});
it("names the side where each side plays only one such game", () => {
// G37 = L36 v L35: both feeds are Bracket Championship losers, one per side, so a
// number would say nothing and the side is the only thing that tells them apart.
const [p1, p2] = sourcesFor(37);
expect(describeSlotSource(p1, LLWS_20)).toBe("Loser of U.S. Bracket Final");
expect(describeSlotSource(p2, LLWS_20)).toBe("Loser of Intl Bracket Final");
// Same rule inside a side bracket: G34 = L30 v W32.
const [elimP1, elimP2] = sourcesFor(34);
expect(describeSlotSource(elimP1, LLWS_20)).toBe("Loser of U.S. Winners Final");
expect(describeSlotSource(elimP2, LLWS_20)).toBe("Winner of U.S. Elim R4");
});
it("drops both number and side for the shared final games", () => {
// The two sides meet here, so there is only one of each game in the whole bracket.
const wc = describeSlotSource(
{ kind: "match", ref: GAME_TO_MATCH[38], result: "winner" },
LLWS_20
);
expect(wc).toBe("Winner of World Championship");
});
it("returns null for a seeded slot so the caller can render TBD", () => {
expect(describeSlotSource({ kind: "seed" }, LLWS_20)).toBeNull();
expect(describeSlotSource(undefined, LLWS_20)).toBeNull();
});
it("uses plain round names for non-LLWS templates", () => {
const template = getBracketTemplate("simple_16");
const source: SlotSource = {
kind: "match",
ref: { round: "Quarterfinals", matchNumber: 3 },
result: "winner",
};
expect(describeSlotSource(source, template)).toBe("Winner of Quarterfinals 3");
});
});

420
app/lib/bracket-layout.ts Normal file
View file

@ -0,0 +1,420 @@
/**
* Bracket geometry, derived from the real feeder graph.
*
* The renderer used to place cards by index within a round match i at
* `i * (height / roundSize)` and drew connectors assuming matches 2k and 2k+1 feed
* match k. That holds only when each round is an exact halving of the previous one.
*
* The LLWS winners bracket is not a halving: two of the four Opening Round games skip
* Winners Round 2 entirely and go straight to the semifinals (see LLWS_ADVANCEMENT).
* Under index math those games get pulled to the bottom of column one with nothing
* above them in column two, and the connectors confidently join the wrong pairs.
*
* So lay out from the graph instead:
* column = depth from the group's final, counted backwards
* vertical order = the parent's slot order (participant1 above participant2)
* connectors = actual feeder edges
*
* Counting columns back from the final is what makes a printed bracket line up: a team
* entering late sits in the column where it actually plays, not the column its round
* name suggests. For the LLWS International side this reproduces the official bracket
* exactly, including putting the Australia/Mexico game alongside Winners Round 2.
*
* Pure no React, no DB so the geometry can be asserted against the printed bracket
* in tests.
*/
import {
llwsSideAndLocal,
type BracketTemplate,
} from "~/lib/bracket-templates";
import { resolveLLWSAdvancement } from "~/lib/llws-bracket";
// ── Feeder graph ──────────────────────────────────────────────────────────────
export interface MatchRef {
round: string;
matchNumber: number;
}
/** What fills one participant slot of a match. */
export type SlotSource =
| { kind: "match"; ref: MatchRef; result: "winner" | "loser" }
| { kind: "seed" };
/** Keyed by `${round}#${matchNumber}`; the pair is [participant1, participant2]. */
export type FeederMap = Map<string, [SlotSource, SlotSource]>;
const SEED: SlotSource = { kind: "seed" };
/**
* `template.id:roundName` for transitions routed by a dedicated advancement function
* rather than advanceWinnerTemplate's ceil(n/2) rule, and whose round sizes happen to
* halve so the check in buildFeederMap can't rule them out on shape alone.
*
* The NBA play-in is the case: Play-In Round 2 pairs the 7v8 *loser* with the 9v10
* winner (advanceNBAPlayInWinner), which no winners-only halving describes.
*/
const BESPOKE_TRANSITIONS = new Set(["nba_20:Play-In Round 1"]);
export function matchKey(round: string, matchNumber: number): string {
return `${round}#${matchNumber}`;
}
/**
* Invert a template's advancement rules into "what fills each slot".
*
* `llws_20` has an explicit, hand-verified routing table with deliberate cross-overs, so
* it is inverted from that. Everything else follows the standard rule: slot p1 of match N
* is the winner of match 2N-1 in the previous round, slot p2 the winner of match 2N.
*/
export function buildFeederMap(template: BracketTemplate | undefined): FeederMap {
const feeders: FeederMap = new Map();
if (!template) return feeders;
const slots = (key: string): [SlotSource, SlotSource] => {
let pair = feeders.get(key);
if (!pair) {
pair = [SEED, SEED];
feeders.set(key, pair);
}
return pair;
};
// Seed every match in the template so unfed slots read as directly seeded.
for (const round of template.rounds) {
for (let n = 1; n <= round.matchCount; n++) slots(matchKey(round.name, n));
}
if (template.id === "llws_20") {
for (const round of template.rounds) {
for (let n = 1; n <= round.matchCount; n++) {
const { winner, loser } = resolveLLWSAdvancement(round.name, n);
const ref: MatchRef = { round: round.name, matchNumber: n };
for (const [destination, result] of [
[winner, "winner"],
[loser, "loser"],
] as const) {
if (!destination) continue;
const pair = slots(matchKey(destination.round, destination.matchNumber));
pair[destination.slot === "participant1Id" ? 0 : 1] = { kind: "match", ref, result };
}
}
}
return feeders;
}
// Follow each round's declared `feedsInto` rather than array order — AFL's Wildcard
// Round feeds the Elimination Finals, skipping the round printed next to it.
for (const prev of template.rounds) {
if (!prev.feedsInto) continue;
const round = template.rounds.find((r) => r.name === prev.feedsInto);
if (!round) continue;
// advanceWinnerTemplate sends match n to ceil(n/2) in the next round, slot by
// parity. That describes the bracket only where the round halves exactly; a
// play-in, a bye round, or a First Four routes by rules of its own, and inventing
// a halving there would draw connectors and slot labels that are simply wrong.
// Leaving those edges out drops the group to computeGroupLayout's fallback, which
// is the geometry these brackets already had.
if (prev.matchCount !== round.matchCount * 2) continue;
if (BESPOKE_TRANSITIONS.has(`${template.id}:${prev.name}`)) continue;
for (let n = 1; n <= round.matchCount; n++) {
const pair = slots(matchKey(round.name, n));
pair[0] = {
kind: "match",
ref: { round: prev.name, matchNumber: 2 * n - 1 },
result: "winner",
};
pair[1] = {
kind: "match",
ref: { round: prev.name, matchNumber: 2 * n },
result: "winner",
};
}
}
return feeders;
}
// ── Slot labels ───────────────────────────────────────────────────────────────
/**
* Round names as they read inside a card, where there is room for about twenty
* characters. Anything not listed keeps its full name.
*/
const SHORT_ROUND_NAMES: Record<string, string> = {
"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<M> {
match: M;
/** Centre of the card, in slot units (1 unit = one leaf row). */
center: number;
}
export interface LayoutColumn<M> {
label: string;
matches: LaidOutMatch<M>[];
}
export interface BracketLayout<M> {
columns: LayoutColumn<M>[];
/** Number of leaf rows; multiply by row height for the pixel height of the bracket. */
leafCount: number;
/** Edges to draw, as (column index of the source, source centre, target centre). */
edges: { fromColumn: number; fromCenter: number; toCenter: number }[];
}
interface PositionedMatch {
round: string;
matchNumber: number;
/** Only read by the fallback, to trace edges through an unrecognised shape. */
winnerId?: string | null;
participant1Id?: string | null;
participant2Id?: string | null;
}
/**
* Lay out one rendered group a winners bracket, an elimination bracket, a region.
*
* `matchesByRound` should already be filtered to the group; cross-group feeds are
* dropped, matching the printed bracket, which labels those slots rather than drawing
* lines to another tree.
*
* Falls back to the previous index-based geometry when the group has no single root
* (disjoint or unrecognised shapes), so no existing template can regress to a blank
* column.
*/
export function computeGroupLayout<M extends PositionedMatch>(
visibleRounds: string[],
matchesByRound: Map<string, M[]>,
feeders: FeederMap,
templateRoundOrder: string[]
): BracketLayout<M> {
const nodes = new Map<string, M>();
const roundOf = new Map<string, string>();
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<string, string[]>();
const hasParent = new Set<string>();
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<string, number>();
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<string, number>();
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<M>[] = 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<M>["edges"] = [];
for (const [key] of nodes) {
const toCenter = center.get(key) ?? 0;
for (const child of childrenOf.get(key) ?? []) {
const fromColumn = columnOf(child);
if (fromColumn !== columnOf(key) - 1) continue;
edges.push({ fromColumn, fromCenter: center.get(child) ?? 0, toCenter });
}
}
return { columns, leafCount, edges };
}
/**
* The previous behaviour, kept for groups whose shape can't be resolved into a single
* tree: one column per round, matches spread evenly over it, and edges inferred from the
* round sizes. Brackets with bespoke routing (AFL, CFP byes, a bracket with no template)
* land here, so it has to keep drawing what they drew before rather than nothing.
*/
function fallbackLayout<M extends PositionedMatch>(
visibleRounds: string[],
matchesByRound: Map<string, M[]>
): BracketLayout<M> {
const leafCount = Math.max(
...visibleRounds.map((r) => matchesByRound.get(r)?.length ?? 0),
1
);
const centersFor = (matches: M[]) => {
const span = leafCount / Math.max(matches.length, 1);
return matches.map((_, i) => (i + 0.5) * span);
};
const columns = visibleRounds.map((round) => {
const matches = matchesByRound.get(round) ?? [];
const centers = centersFor(matches);
return {
label: round,
matches: matches.map((match, i) => ({ match, center: centers[i] })),
};
});
const edges: BracketLayout<M>["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<string, number>();
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 };
}

194
app/lib/llws-bracket.ts Normal file
View file

@ -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<number, { winner: LLWSDestination | null; loser: LLWSDestination | null }>
> = {
"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) };
}

View file

@ -27,6 +27,13 @@ import {
calculateAveragedPoints, calculateAveragedPoints,
type ScoringRules, type ScoringRules,
} from "../scoring-rules"; } 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 // generateBracketFromTemplate's only DB touch for llws_20 is the bulk insert, so a
// minimal stub is enough to capture the generated rows. // minimal stub is enough to capture the generated rows.
@ -55,121 +62,6 @@ const DEFAULT_SCORING: ScoringRules = {
pointsFor8th: 10, 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<number, { round: string; matchNumber: number }> = {
// Opening Round — U.S. G2,4,6,8 (M14); Intl G1,3,5,7 (M58)
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<string, number>(
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<T>(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<number, [string | null, string | null]> = {
// 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("LLWS 20 Bracket Template", () => {
describe("Template structure", () => { describe("Template structure", () => {
it("has correct identity and size", () => { it("has correct identity and size", () => {

View file

@ -10,6 +10,11 @@ import {
llwsSideAndLocal, llwsSideAndLocal,
STANDARD_BRACKET_SEEDING, STANDARD_BRACKET_SEEDING,
} from "~/lib/bracket-templates"; } 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 PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert; export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
@ -1563,190 +1568,10 @@ async function advanceNBAPlayInWinner(
// ── LLWS 20 (double elimination) ────────────────────────────────────────────── // ── LLWS 20 (double elimination) ──────────────────────────────────────────────
/** // The routing table itself is pure and lives in lib/ so the renderer can import it
* Where one participant goes after an LLWS match: a round, a side-local match number, // without pulling the database context into the browser bundle. Re-exported here so
* and which slot to fill. `null` means eliminated (or, for winners, no further game). // existing server-side callers and tests keep their import path.
*/ export { LLWS_LOSER_ADVANCES_ROUNDS, resolveLLWSAdvancement, type LLWSResolvedDestination };
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<number, { winner: LLWSDestination | null; loser: LLWSDestination | null }>
> = {
"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) };
}
/** /**
* Generate the 20-team LLWS double-elimination bracket (38 matches). * Generate the 20-team LLWS double-elimination bracket (38 matches).

View file

@ -0,0 +1,130 @@
/**
* clear-bracket is the only path that can tear down a bracket, so the guard around it
* matters: it discards recorded results and the placements derived from them.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
findPlayoffMatchesByEventId,
deletePlayoffMatchesByEventId,
} from "~/models/playoff-match";
import { deleteParticipantResultsBySportsSeasonId } from "~/models/participant-result";
import { recalculateAffectedLeagues } from "~/models/scoring-calculator";
import { getScoringEventById } from "~/models/scoring-event";
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
vi.mock("~/models/scoring-event", async (importOriginal) => ({
...(await importOriginal<object>()),
getScoringEventById: vi.fn(),
updateScoringEvent: vi.fn(),
isReadOnlySibling: vi.fn(() => false),
}));
vi.mock("~/models/playoff-match", async (importOriginal) => ({
...(await importOriginal<object>()),
findPlayoffMatchesByEventId: vi.fn(),
deletePlayoffMatchesByEventId: vi.fn(),
}));
vi.mock("~/models/participant-result", async (importOriginal) => ({
...(await importOriginal<object>()),
deleteParticipantResultsBySportsSeasonId: vi.fn(),
}));
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
...(await importOriginal<object>()),
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<ReturnType<typeof getScoringEventById>>
);
vi.mocked(deletePlayoffMatchesByEventId).mockResolvedValue(undefined);
vi.mocked(deleteParticipantResultsBySportsSeasonId).mockResolvedValue(undefined);
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(
undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>
);
});
it("deletes the matches", async () => {
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
match(false),
match(false),
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
const result = await run(clearRequest());
expect(result.success).toContain("2 match(es) removed");
expect(deletePlayoffMatchesByEventId).toHaveBeenCalledWith("event-1");
});
it("leaves placements alone — they belong to the whole season, not this event", async () => {
// seasonParticipantResults is keyed by sports season, so deleting here would wipe
// every other event's placements with nothing to rebuild them. Reprocess Bracket is
// the tool that rebuilds them correctly.
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
match(true),
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
const result = await run(clearRequest("true"));
expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled();
expect(result.success).toContain("Reprocess Bracket");
});
it("refuses to discard completed matches without confirmation", async () => {
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
match(true),
match(false),
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
const result = await run(clearRequest());
expect(result.error).toContain("1 completed match(es)");
expect(deletePlayoffMatchesByEventId).not.toHaveBeenCalled();
});
it("discards completed matches once confirmed", async () => {
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
match(true),
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
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<ReturnType<typeof findPlayoffMatchesByEventId>>
);
const result = await run(clearRequest("true"));
expect(result.error).toContain("no bracket to clear");
expect(deletePlayoffMatchesByEventId).not.toHaveBeenCalled();
});
});

View file

@ -9,6 +9,7 @@ import {
import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event"; import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
import { import {
findPlayoffMatchesByEventId, findPlayoffMatchesByEventId,
deletePlayoffMatchesByEventId,
generateBracketFromTemplate, generateBracketFromTemplate,
setMatchWinner, setMatchWinner,
advanceWinnerTemplate, advanceWinnerTemplate,
@ -288,6 +289,50 @@ export async function action({ request, params }: Route.ActionArgs) {
} }
} }
// The only way to repair a mis-seeded bracket: nothing else can rewrite a match's
// participants. Clearing brings back the setup form, so the admin re-seeds from there.
if (intent === "clear-bracket") {
try {
const event = await getScoringEventById(params.eventId);
if (!event) return { error: "Event not found" };
const existing = await findPlayoffMatchesByEventId(params.eventId);
if (existing.length === 0) {
return { error: "This event has no bracket to clear" };
}
// Clearing discards recorded results, so make the admin confirm once games have
// actually been played.
const completed = existing.filter((m) => m.isComplete).length;
if (completed > 0 && formData.get("confirm") !== "true") {
return {
error: `This bracket has ${completed} completed match(es). Confirm to discard those results.`,
};
}
await deletePlayoffMatchesByEventId(params.eventId);
// Placements are deliberately left alone. seasonParticipantResults is keyed by
// sports season, not by event, so a season-wide delete here would wipe the
// placements of every other event in the season with nothing to rebuild them —
// and on a finalized qualifying season that means permanently zeroed standings.
// Reprocess Bracket already rebuilds placements correctly, qualifying path
// included, so point the admin at it once the new bracket is in place.
const note =
completed > 0
? " Run Reprocess Bracket after rebuilding to clear the placements those results produced."
: "";
return {
success: `Bracket cleared (${existing.length} match(es) removed). Set it up again below.${note}`,
};
} catch (error) {
logger.error("Error clearing bracket:", error);
return {
error: error instanceof Error ? error.message : "Failed to clear bracket",
};
}
}
if (intent === "generate-bracket") { if (intent === "generate-bracket") {
const templateId = formData.get("templateId"); const templateId = formData.get("templateId");

View file

@ -613,6 +613,60 @@ export default function EventBracket({
</Card> </Card>
)} )}
{/* 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 && (
<Card className="border-destructive/40">
<CardHeader>
<CardTitle>Clear Bracket</CardTitle>
<CardDescription>
Delete every match in this bracket so it can be set up again from
scratch. Use this when the wrong participants were seeded. Placements
are left alone run Reprocess Bracket after rebuilding to clear any
that the discarded results produced.
</CardDescription>
</CardHeader>
<CardContent>
<Form
method="post"
className="space-y-3"
onSubmit={(e) => {
if (
!confirm(
`Delete all ${matches.length} match(es) in this bracket? Recorded results will be lost.`
)
) {
e.preventDefault();
}
}}
>
<input type="hidden" name="intent" value="clear-bracket" />
{/* 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) && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="confirm-clear-bracket"
name="confirm"
value="true"
className="h-4 w-4"
/>
<Label htmlFor="confirm-clear-bracket" className="font-normal">
Yes, discard the results already recorded in this bracket
</Label>
</div>
)}
<Button type="submit" variant="destructive">
Clear Bracket
</Button>
</Form>
</CardContent>
</Card>
)}
{/* ====== SETUP PHASE ====== */} {/* ====== SETUP PHASE ====== */}
{showSetup && ( {showSetup && (
<Card> <Card>

122
app/test/fixtures/llws-bracket.ts vendored Normal file
View file

@ -0,0 +1,122 @@
/**
* The official 2026 LLBWS bracket (Williamsport, Aug 1930), transcribed from the PDF.
*
* The printed bracket numbers its games 138. 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<number, { round: string; matchNumber: number }> = {
// Opening Round — U.S. G2,4,6,8 (M14); Intl G1,3,5,7 (M58)
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<string, number>(
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<T>(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<number, [string | null, string | null]> = {
// 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"],
};

View file

@ -7,6 +7,7 @@
"app/models/**/*.ts", "app/models/**/*.ts",
"app/services/**/*.ts", "app/services/**/*.ts",
"app/lib/**/*.ts", "app/lib/**/*.ts",
"app/test/fixtures/**/*.ts",
"app/types/**/*.ts", "app/types/**/*.ts",
"vite.config.ts" "vite.config.ts"
], ],