Lay out brackets from the feeder graph
The LLWS bracket didn't read as a bracket: cards sat above games that don't feed them, connectors joined the wrong pairs, and several games had no line at all. The stored data was correct — LLWS_ADVANCEMENT already matches the official 2026 LLBWS bracket game for game. The renderer was the problem. TreeColumns placed cards at `index * (height / roundSize)` and ConnectorColumn assumed matches 2k and 2k+1 feed match k, which holds only for an exact halving. The LLWS winners bracket is not one: two of the four Opening Round games skip Winners Round 2 and go straight to the semifinals, so those two got stranded in column one with nothing beside them, and the halving branch drew confident, wrong connectors for the rest. Lay out from the graph instead. app/lib/bracket-layout.ts inverts a template's advancement into "what fills each slot", then assigns columns by depth from the group's final, orders each column by the parent's slot order, and centres each card on its feeders. Counting back from the final is what makes a printed bracket line up: a team entering late is drawn in the column where it actually plays. This reproduces the official International bracket exactly, and fixes Elimination Round 3, where the official bracket prints the later game on top but match-number sort put it below. Because column is depth, every in-group edge spans exactly one gutter, so connectors now draw for unplayed games too. Cards also take a fixed height rather than stretching to fill their column, which is what made a lone final tower over the rest. Empty slots name their source — "Loser of Winners SF 1" rather than "TBD". That is the only way to show the feeds crossing between the winners and elimination brackets, which render as separate trees. Also: - Move the LLWS routing table to app/lib/llws-bracket.ts so the renderer can import it without pulling the database context into the browser bundle; models/playoff-match re-exports it. - Page the mobile view one group at a time, matching desktop. A whole double-elimination phase is a DAG, not a tree, so its columns would be arbitrary. - Add a clear-bracket admin action. Nothing else could rewrite a match's participants, so a mis-seeded bracket had no repair path at all. - Lift the PDF transcription into app/test/fixtures/llws-bracket.ts so the routing and layout tests check against one copy of the official bracket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnzbrCHoM8ESbtbDamaqFb
This commit is contained in:
parent
8edb4293c5
commit
89ceee432a
16 changed files with 1643 additions and 562 deletions
|
|
@ -1,13 +1,16 @@
|
|||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { useRoundTransition } from "~/hooks/useRoundTransition";
|
||||
import type { FeederMap } from "~/lib/bracket-layout";
|
||||
import type { BracketTemplate } from "~/lib/bracket-templates";
|
||||
import {
|
||||
TreeColumns,
|
||||
BracketMatchSlot,
|
||||
bracketGeometry,
|
||||
windowGeometry,
|
||||
SLOT_WIDTH,
|
||||
LABEL_HEIGHT,
|
||||
DESIRED_CARD_HEIGHT,
|
||||
CARD_GAP,
|
||||
MAX_CARD_HEIGHT,
|
||||
type BracketMatch,
|
||||
type BracketOwnership,
|
||||
|
|
@ -21,6 +24,8 @@ interface BracketTreePaginatedProps {
|
|||
/** Index of the first scoring round — default page starts here */
|
||||
firstScoringRoundIdx?: number;
|
||||
thirdPlaceRound?: string;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
}
|
||||
|
||||
export function BracketTreePaginated({
|
||||
|
|
@ -30,63 +35,68 @@ export function BracketTreePaginated({
|
|||
userParticipantIds,
|
||||
firstScoringRoundIdx,
|
||||
thirdPlaceRound,
|
||||
feeders,
|
||||
template,
|
||||
}: BracketTreePaginatedProps) {
|
||||
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
||||
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
|
||||
|
||||
// Pages are pairs of layout columns, not pairs of rounds: a column can mix rounds
|
||||
// when teams enter the bracket at different points (see computeGroupLayout).
|
||||
const geometry = bracketGeometry(
|
||||
mainRounds,
|
||||
matchesByRound,
|
||||
feeders,
|
||||
template?.rounds.map((r) => r.name) ?? mainRounds
|
||||
);
|
||||
const columns = geometry.layout.columns;
|
||||
const lastPage = Math.max(columns.length - 2, 0);
|
||||
|
||||
const defaultPage = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
firstScoringRoundIdx !== undefined
|
||||
? Math.max(0, firstScoringRoundIdx - 1)
|
||||
: mainRounds.length - 2,
|
||||
mainRounds.length - 2,
|
||||
firstScoringRoundIdx !== undefined ? Math.max(0, firstScoringRoundIdx - 1) : lastPage,
|
||||
lastPage,
|
||||
),
|
||||
);
|
||||
|
||||
const { page, anim, stripRef, navigate, handleTransitionEnd } = useRoundTransition(
|
||||
mainRounds.length - 2,
|
||||
lastPage,
|
||||
defaultPage,
|
||||
);
|
||||
|
||||
const targetPage = anim ? anim.toPage : page;
|
||||
const labelRounds = mainRounds.slice(targetPage, targetPage + 2);
|
||||
const label = labelRounds[1] ? `${labelRounds[0]} → ${labelRounds[1]}` : labelRounds[0];
|
||||
|
||||
const calcHeight = (p: number) => {
|
||||
const rs = mainRounds.slice(p, p + 2);
|
||||
const max = Math.max(...rs.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||||
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
||||
const pageGeometry = (p: number) => windowGeometry(geometry, p, p + 1);
|
||||
const labelFor = (p: number) => {
|
||||
const [a, b] = [columns[p]?.label, columns[p + 1]?.label];
|
||||
return b ? `${a} → ${b}` : (a ?? "");
|
||||
};
|
||||
|
||||
const pageHeight = calcHeight(page);
|
||||
const animFromHeight = anim ? calcHeight(anim.fromPage) : pageHeight;
|
||||
const animToHeight = anim ? calcHeight(anim.toPage) : pageHeight;
|
||||
const label = labelFor(anim ? anim.toPage : page);
|
||||
|
||||
const visibleRounds = mainRounds.slice(page, page + 2);
|
||||
const fromRounds = anim ? mainRounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds;
|
||||
const toRounds = anim ? mainRounds.slice(anim.toPage, anim.toPage + 2) : visibleRounds;
|
||||
const pageG = pageGeometry(page);
|
||||
const animFromG = anim ? pageGeometry(anim.fromPage) : pageG;
|
||||
const animToG = anim ? pageGeometry(anim.toPage) : pageG;
|
||||
|
||||
let leftRounds: string[];
|
||||
let rightRounds: string[] = [];
|
||||
let leftHeight: number;
|
||||
let rightHeight = 0;
|
||||
let leftPage: number;
|
||||
let rightPage: number | null = null;
|
||||
let leftG = pageG;
|
||||
let rightG = pageG;
|
||||
let settlingTransition = false;
|
||||
if (anim?.phase === "sliding") {
|
||||
leftRounds = anim.dir === "right" ? fromRounds : toRounds;
|
||||
rightRounds = anim.dir === "right" ? toRounds : fromRounds;
|
||||
leftHeight = anim.dir === "right" ? animFromHeight : animToHeight;
|
||||
rightHeight = anim.dir === "right" ? animToHeight : animFromHeight;
|
||||
leftPage = anim.dir === "right" ? anim.fromPage : anim.toPage;
|
||||
rightPage = anim.dir === "right" ? anim.toPage : anim.fromPage;
|
||||
leftG = anim.dir === "right" ? animFromG : animToG;
|
||||
rightG = anim.dir === "right" ? animToG : animFromG;
|
||||
} else if (anim?.phase === "settling") {
|
||||
leftRounds = toRounds;
|
||||
leftHeight = animToHeight;
|
||||
leftPage = anim.toPage;
|
||||
leftG = animToG;
|
||||
settlingTransition = true;
|
||||
} else {
|
||||
leftRounds = visibleRounds;
|
||||
leftHeight = pageHeight;
|
||||
leftPage = page;
|
||||
}
|
||||
|
||||
const containerMinHeight = anim?.phase === "settling" ? animToHeight : animFromHeight;
|
||||
const containerMinHeight =
|
||||
anim?.phase === "settling" ? animToG.bracketHeight : animFromG.bracketHeight;
|
||||
const initialX = anim?.phase === "sliding" && anim.dir === "left" ? -SLOT_WIDTH : 0;
|
||||
|
||||
return (
|
||||
|
|
@ -109,7 +119,7 @@ export function BracketTreePaginated({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(page + 1)}
|
||||
disabled={page + 2 >= mainRounds.length || !!anim}
|
||||
disabled={page >= lastPage || !!anim}
|
||||
className="h-7 w-7 shrink-0"
|
||||
aria-label="Next rounds"
|
||||
>
|
||||
|
|
@ -129,22 +139,24 @@ export function BracketTreePaginated({
|
|||
>
|
||||
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||||
<TreeColumns
|
||||
visibleRounds={leftRounds}
|
||||
matchesByRound={matchesByRound}
|
||||
geometry={leftG}
|
||||
columnRange={[leftPage, leftPage + 1]}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={leftHeight}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
transitionDuration={settlingTransition ? 500 : undefined}
|
||||
/>
|
||||
</div>
|
||||
{anim?.phase === "sliding" && (
|
||||
{anim?.phase === "sliding" && rightPage !== null && (
|
||||
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||||
<TreeColumns
|
||||
visibleRounds={rightRounds}
|
||||
matchesByRound={matchesByRound}
|
||||
geometry={rightG}
|
||||
columnRange={[rightPage, rightPage + 1]}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={rightHeight}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -164,6 +176,8 @@ export function BracketTreePaginated({
|
|||
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import { avatarColor } from "~/lib/avatar-colors";
|
||||
import { BRACKT_GRADIENT } from "~/lib/brand";
|
||||
import {
|
||||
computeGroupLayout,
|
||||
describeSlotSource,
|
||||
matchKey,
|
||||
type BracketLayout,
|
||||
type FeederMap,
|
||||
} from "~/lib/bracket-layout";
|
||||
import type { BracketTemplate } from "~/lib/bracket-templates";
|
||||
|
||||
export interface BracketMatch {
|
||||
id: string;
|
||||
|
|
@ -46,6 +54,8 @@ function formatScore(score: string | null): string | null {
|
|||
|
||||
interface ParticipantRowProps {
|
||||
name: string | null;
|
||||
/** What fills this slot when it's still empty, e.g. "Winner of Winners SF 2". */
|
||||
feedLabel?: string | null;
|
||||
isTbd: boolean;
|
||||
isWinner: boolean;
|
||||
isLoser: boolean;
|
||||
|
|
@ -60,6 +70,7 @@ interface ParticipantRowProps {
|
|||
|
||||
function ParticipantRow({
|
||||
name,
|
||||
feedLabel,
|
||||
isTbd,
|
||||
isWinner,
|
||||
isLoser,
|
||||
|
|
@ -114,7 +125,7 @@ function ParticipantRow({
|
|||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{name ?? "TBD"}
|
||||
{name ?? feedLabel ?? "TBD"}
|
||||
</span>
|
||||
|
||||
{/* Owner name below participant name */}
|
||||
|
|
@ -150,6 +161,8 @@ interface BracketMatchSlotProps {
|
|||
slotHeight: number;
|
||||
ownershipMap: Map<string, BracketOwnership>;
|
||||
userParticipantIds: Set<string>;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
}
|
||||
|
||||
export function BracketMatchSlot({
|
||||
|
|
@ -157,6 +170,8 @@ export function BracketMatchSlot({
|
|||
slotHeight,
|
||||
ownershipMap,
|
||||
userParticipantIds,
|
||||
feeders,
|
||||
template,
|
||||
}: BracketMatchSlotProps) {
|
||||
const rowHeight = slotHeight / 2;
|
||||
const showText = rowHeight >= 10;
|
||||
|
|
@ -187,6 +202,13 @@ export function BracketMatchSlot({
|
|||
|
||||
const INSET = Math.max(1, Math.min(2, Math.floor(slotHeight / 20)));
|
||||
|
||||
// An empty slot reads better as "Loser of Winners SF 2" than "TBD" — especially for
|
||||
// the feeds that cross between the winners and elimination brackets, which render as
|
||||
// separate trees and so can never be joined by a line.
|
||||
const slotSources = feeders?.get(matchKey(match.round, match.matchNumber));
|
||||
const feed1 = describeSlotSource(slotSources?.[0], template);
|
||||
const feed2 = describeSlotSource(slotSources?.[1], template);
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden" style={{ height: slotHeight }}>
|
||||
{/* Corona layer — left-2 matches card borderRadius to prevent gradient bleed at left corners */}
|
||||
|
|
@ -208,6 +230,7 @@ export function BracketMatchSlot({
|
|||
>
|
||||
<ParticipantRow
|
||||
name={match.participant1?.name ?? null}
|
||||
feedLabel={feed1}
|
||||
isTbd={isTbd1}
|
||||
isWinner={p1IsWinner}
|
||||
isLoser={p1IsLoser}
|
||||
|
|
@ -221,6 +244,7 @@ export function BracketMatchSlot({
|
|||
/>
|
||||
<ParticipantRow
|
||||
name={match.participant2?.name ?? null}
|
||||
feedLabel={feed2}
|
||||
isTbd={isTbd2}
|
||||
isWinner={p2IsWinner}
|
||||
isLoser={p2IsLoser}
|
||||
|
|
@ -237,54 +261,45 @@ export function BracketMatchSlot({
|
|||
);
|
||||
}
|
||||
|
||||
// ─── Per-pair connector column ────────────────────────────────────────────────
|
||||
// ─── Connector column ─────────────────────────────────────────────────────────
|
||||
|
||||
interface ConnectorColumnProps {
|
||||
currentMatches: BracketMatch[];
|
||||
nextMatches: BracketMatch[];
|
||||
/** Edges crossing this gutter, in slot units. */
|
||||
edges: { fromCenter: number; toCenter: number }[];
|
||||
rowHeight: number;
|
||||
offset: 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;
|
||||
|
||||
// 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 currentSlotH = bracketHeight / Math.max(currentMatches.length, 1);
|
||||
const nextSlotH = bracketHeight / Math.max(nextMatches.length, 1);
|
||||
|
||||
// Use halving U-shapes only when prev > 1 (avoids false-positive 1→1 side branches like 3PG→Finals)
|
||||
if (nextMatches.length === Math.ceil(currentMatches.length / 2) && currentMatches.length > 1) {
|
||||
// Standard single-elimination halving: U-shape connectors
|
||||
for (let k = 0; k < nextMatches.length; k++) {
|
||||
const topY = (2 * k) * currentSlotH + currentSlotH / 2;
|
||||
const midY = k * nextSlotH + nextSlotH / 2;
|
||||
const botIdx = 2 * k + 1;
|
||||
|
||||
if (botIdx < currentMatches.length) {
|
||||
const botY = botIdx * currentSlotH + currentSlotH / 2;
|
||||
paths.push(`M 0 ${topY} H ${mid} V ${botY} H 0`);
|
||||
paths.push(`M ${mid} ${midY} H ${CONNECTOR_WIDTH}`);
|
||||
} else {
|
||||
paths.push(`M 0 ${topY} H ${CONNECTOR_WIDTH}`);
|
||||
for (const [toCenter, sources] of byTarget) {
|
||||
const destY = toCenter * rowHeight - offset;
|
||||
const ys = sources.map((c) => c * rowHeight - offset).toSorted((a, b) => a - b);
|
||||
if (ys.length === 1) {
|
||||
paths.push(`M 0 ${ys[0]} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Non-standard (byes, play-ins, etc.): trace winners by participantId
|
||||
const winnerToIdx = new Map<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}`);
|
||||
}
|
||||
});
|
||||
paths.push(`M 0 ${ys[0]} H ${mid} V ${ys[ys.length - 1]} H 0`);
|
||||
for (const y of ys.slice(1, -1)) paths.push(`M 0 ${y} H ${mid}`);
|
||||
paths.push(`M ${mid} ${destY} H ${CONNECTOR_WIDTH}`);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -310,52 +325,131 @@ function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: Connect
|
|||
|
||||
// ─── Tree columns (shared by full + paginated) ───────────────────────────────
|
||||
|
||||
export interface BracketGeometry {
|
||||
layout: BracketLayout<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 {
|
||||
visibleRounds: string[];
|
||||
matchesByRound: Map<string, BracketMatch[]>;
|
||||
geometry: BracketGeometry;
|
||||
ownershipMap: Map<string, BracketOwnership>;
|
||||
userParticipantIds: Set<string>;
|
||||
bracketHeight: number;
|
||||
transitionDuration?: number;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
/** Restrict rendering to a window of columns (used by the mobile pager). */
|
||||
columnRange?: [number, number];
|
||||
}
|
||||
|
||||
export function TreeColumns({
|
||||
visibleRounds,
|
||||
matchesByRound,
|
||||
geometry,
|
||||
ownershipMap,
|
||||
userParticipantIds,
|
||||
bracketHeight,
|
||||
transitionDuration,
|
||||
feeders,
|
||||
template,
|
||||
columnRange,
|
||||
}: TreeColumnsProps) {
|
||||
const tr = transitionDuration ? `${transitionDuration}ms ease` : undefined;
|
||||
return (
|
||||
<div style={{ display: "flex", width: "100%", height: bracketHeight + LABEL_HEIGHT, transition: tr ? `height ${tr}` : undefined }}>
|
||||
{visibleRounds.map((round, ri) => {
|
||||
const roundMatches = matchesByRound.get(round) ?? [];
|
||||
const slotHeight = bracketHeight / Math.max(roundMatches.length, 1);
|
||||
const cardHeight = Math.min(slotHeight - CARD_GAP, MAX_CARD_HEIGHT);
|
||||
const cardTop = (slotHeight - cardHeight) / 2;
|
||||
const nextRound = ri < visibleRounds.length - 1 ? visibleRounds[ri + 1] : null;
|
||||
const nextMatches = nextRound ? (matchesByRound.get(nextRound) ?? []) : [];
|
||||
const { layout, rowHeight, bracketHeight, offset } = geometry;
|
||||
|
||||
const [firstColumn, lastColumn] = columnRange ?? [0, layout.columns.length - 1];
|
||||
const visible = layout.columns.slice(firstColumn, lastColumn + 1);
|
||||
|
||||
// Cards keep a fixed height regardless of how many share a column — stretching a
|
||||
// lone final to fill its column is what made it tower over the rest of the bracket.
|
||||
const cardHeight = Math.min(
|
||||
Math.max(rowHeight - CARD_GAP, 1),
|
||||
MAX_CARD_HEIGHT
|
||||
);
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<div style={{ flex: "1 1 0", minWidth: COLUMN_WIDTH, position: "relative" }}>
|
||||
<div
|
||||
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground truncate text-center"
|
||||
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
{round}
|
||||
{column.label}
|
||||
</div>
|
||||
|
||||
<div style={{ position: "relative", height: bracketHeight, transition: tr ? `height ${tr}` : undefined }}>
|
||||
{roundMatches.map((match, matchIdx) => (
|
||||
{column.matches.map(({ match, center }) => (
|
||||
<div
|
||||
key={match.id}
|
||||
data-match-id={match.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: matchIdx * slotHeight + cardTop,
|
||||
top: center * rowHeight - offset - cardHeight / 2,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: cardHeight,
|
||||
|
|
@ -367,6 +461,8 @@ export function TreeColumns({
|
|||
slotHeight={cardHeight}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -374,10 +470,11 @@ export function TreeColumns({
|
|||
</div>
|
||||
|
||||
{/* Connector between this column and the next */}
|
||||
{nextRound && (
|
||||
{vi < visible.length - 1 && (
|
||||
<ConnectorColumn
|
||||
currentMatches={roundMatches}
|
||||
nextMatches={nextMatches}
|
||||
edges={gutterEdges}
|
||||
rowHeight={rowHeight}
|
||||
offset={offset}
|
||||
bracketHeight={bracketHeight}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -396,6 +493,8 @@ interface BracketTreeViewProps {
|
|||
ownershipMap: Map<string, BracketOwnership>;
|
||||
userParticipantIds: Set<string>;
|
||||
thirdPlaceRound?: string;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
}
|
||||
|
||||
export function BracketTreeView({
|
||||
|
|
@ -404,13 +503,19 @@ export function BracketTreeView({
|
|||
ownershipMap,
|
||||
userParticipantIds,
|
||||
thirdPlaceRound,
|
||||
feeders,
|
||||
template,
|
||||
}: BracketTreeViewProps) {
|
||||
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
||||
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
|
||||
|
||||
const maxMatches = Math.max(...mainRounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||||
const bracketHeight = maxMatches * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
||||
const minWidth = mainRounds.length * COLUMN_WIDTH + Math.max(0, mainRounds.length - 1) * CONNECTOR_WIDTH;
|
||||
const geometry = bracketGeometry(
|
||||
mainRounds,
|
||||
matchesByRound,
|
||||
feeders,
|
||||
template?.rounds.map((r) => r.name) ?? mainRounds
|
||||
);
|
||||
const { bracketHeight, minWidth } = geometry;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -419,11 +524,11 @@ export function BracketTreeView({
|
|||
>
|
||||
<div style={{ minWidth }}>
|
||||
<TreeColumns
|
||||
visibleRounds={mainRounds}
|
||||
matchesByRound={matchesByRound}
|
||||
geometry={geometry}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={bracketHeight}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
{thirdPlaceMatch && (
|
||||
<div style={{ display: "flex", paddingTop: 20 }}>
|
||||
|
|
@ -441,6 +546,8 @@ export function BracketTreeView({
|
|||
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import type { ConferenceGroup } from "~/lib/bracket-templates";
|
||||
import { TreeColumns, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
||||
import type { BracketTemplate, ConferenceGroup } from "~/lib/bracket-templates";
|
||||
import type { FeederMap } from "~/lib/bracket-layout";
|
||||
import {
|
||||
TreeColumns,
|
||||
bracketGeometry,
|
||||
type BracketMatch,
|
||||
type BracketOwnership,
|
||||
} from "./BracketTreeView";
|
||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
||||
|
||||
interface NbaBracketLayoutProps {
|
||||
|
|
@ -10,11 +16,10 @@ interface NbaBracketLayoutProps {
|
|||
userParticipantIds: Set<string>;
|
||||
conferenceGroups: ConferenceGroup[];
|
||||
scoringRoundIdx: number;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
}
|
||||
|
||||
const DESIRED_CARD_HEIGHT = 112;
|
||||
const CARD_GAP = 14;
|
||||
|
||||
function splitMatchesByConference(
|
||||
matchesByRound: Map<string, BracketMatch[]>,
|
||||
group: ConferenceGroup
|
||||
|
|
@ -28,11 +33,6 @@ function splitMatchesByConference(
|
|||
return result;
|
||||
}
|
||||
|
||||
function bracketHeight(matchesByRound: Map<string, BracketMatch[]>, rounds: string[]): number {
|
||||
const max = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||||
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
||||
}
|
||||
|
||||
export function NbaBracketLayout({
|
||||
rounds,
|
||||
matchesByRound,
|
||||
|
|
@ -40,7 +40,10 @@ export function NbaBracketLayout({
|
|||
userParticipantIds,
|
||||
conferenceGroups,
|
||||
scoringRoundIdx,
|
||||
feeders,
|
||||
template,
|
||||
}: NbaBracketLayoutProps) {
|
||||
const roundOrder = template?.rounds.map((r) => r.name) ?? rounds;
|
||||
// Rounds that belong to any conference group
|
||||
const conferenceRoundSet = new Set(
|
||||
conferenceGroups.flatMap((g) => Object.keys(g.roundMatchNumbers))
|
||||
|
|
@ -57,7 +60,7 @@ export function NbaBracketLayout({
|
|||
const sharedMatches = new Map(
|
||||
sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []])
|
||||
);
|
||||
const sharedHeight = bracketHeight(sharedMatches, sharedRounds);
|
||||
const sharedGeometry = bracketGeometry(sharedRounds, sharedMatches, feeders, roundOrder);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -66,7 +69,7 @@ export function NbaBracketLayout({
|
|||
{conferenceGroups.map((group, gi) => {
|
||||
const confRounds = conferenceRounds[gi];
|
||||
const confMatches = splitMatchesByConference(matchesByRound, group);
|
||||
const height = bracketHeight(confMatches, confRounds);
|
||||
const geometry = bracketGeometry(confRounds, confMatches, feeders, roundOrder);
|
||||
|
||||
return (
|
||||
<div key={group.name}>
|
||||
|
|
@ -74,11 +77,11 @@ export function NbaBracketLayout({
|
|||
{group.name}
|
||||
</p>
|
||||
<TreeColumns
|
||||
visibleRounds={confRounds}
|
||||
matchesByRound={confMatches}
|
||||
geometry={geometry}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={height}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -87,11 +90,11 @@ export function NbaBracketLayout({
|
|||
{sharedRounds.length > 0 && (
|
||||
<div>
|
||||
<TreeColumns
|
||||
visibleRounds={sharedRounds}
|
||||
matchesByRound={sharedMatches}
|
||||
geometry={sharedGeometry}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={sharedHeight}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { RankingsRow } from "./RankingsRow";
|
|||
import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
||||
import { getBracketTemplate, type BracketTemplate } from "~/lib/bracket-templates";
|
||||
import { buildFeederMap } from "~/lib/bracket-layout";
|
||||
import { NbaBracketLayout } from "./NbaBracketLayout";
|
||||
import { TabbedBracketLayout } from "./TabbedBracketLayout";
|
||||
|
||||
|
|
@ -76,43 +77,6 @@ export function groupMatchesByRound(matches: Match[]): Map<string, Match[]> {
|
|||
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 {
|
||||
participant: Participant;
|
||||
score: string | null;
|
||||
|
|
@ -391,6 +355,8 @@ export function PlayoffBracket({
|
|||
const matchesByRound = groupMatchesByRound(matches);
|
||||
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
|
||||
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
|
||||
// What fills each slot, used for both card placement and naming empty slots.
|
||||
const feeders = buildFeederMap(template);
|
||||
|
||||
const consolation = findConsolationRound(template);
|
||||
const thirdPlaceRound = consolation?.round;
|
||||
|
|
@ -478,6 +444,8 @@ export function PlayoffBracket({
|
|||
userParticipantIds={userParticipantSet}
|
||||
phases={template.phases}
|
||||
scoringRoundIdx={scoringRoundIdx}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
) : template?.conferenceGroups ? (
|
||||
<NbaBracketLayout
|
||||
|
|
@ -488,6 +456,8 @@ export function PlayoffBracket({
|
|||
userParticipantIds={userParticipantSet}
|
||||
conferenceGroups={template.conferenceGroups}
|
||||
scoringRoundIdx={scoringRoundIdx}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -499,6 +469,8 @@ export function PlayoffBracket({
|
|||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||||
userParticipantIds={userParticipantSet}
|
||||
thirdPlaceRound={thirdPlaceRound}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -511,6 +483,8 @@ export function PlayoffBracket({
|
|||
userParticipantIds={userParticipantSet}
|
||||
firstScoringRoundIdx={scoringRoundIdx}
|
||||
thirdPlaceRound={thirdPlaceRound}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
import type { BracketPhase, ConferenceGroup } from "~/lib/bracket-templates";
|
||||
import { TreeColumns, BracketMatchSlot, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
||||
import type { BracketPhase, BracketTemplate, ConferenceGroup } from "~/lib/bracket-templates";
|
||||
import type { FeederMap } from "~/lib/bracket-layout";
|
||||
import {
|
||||
TreeColumns,
|
||||
BracketMatchSlot,
|
||||
bracketGeometry,
|
||||
type BracketMatch,
|
||||
type BracketOwnership,
|
||||
} from "./BracketTreeView";
|
||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
||||
|
||||
/** Card height for the play-in columns, which lay themselves out rather than via TreeColumns. */
|
||||
const CARD_H = 112;
|
||||
|
||||
interface TabbedBracketLayoutProps {
|
||||
rounds: string[];
|
||||
matchesByRound: Map<string, BracketMatch[]>;
|
||||
|
|
@ -10,11 +20,10 @@ interface TabbedBracketLayoutProps {
|
|||
userParticipantIds: Set<string>;
|
||||
phases: BracketPhase[];
|
||||
scoringRoundIdx: number;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
}
|
||||
|
||||
const CARD_H = 112;
|
||||
const CARD_GAP = 14;
|
||||
|
||||
function groupMatches(
|
||||
matchesByRound: Map<string, BracketMatch[]>,
|
||||
group: ConferenceGroup
|
||||
|
|
@ -29,11 +38,6 @@ function groupMatches(
|
|||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
interface PlayInColumnProps {
|
||||
|
|
@ -141,7 +145,10 @@ export function TabbedBracketLayout({
|
|||
userParticipantIds,
|
||||
phases,
|
||||
scoringRoundIdx,
|
||||
feeders,
|
||||
template,
|
||||
}: TabbedBracketLayoutProps) {
|
||||
const roundOrder = template?.rounds.map((r) => r.name) ?? rounds;
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{phases.map((phase) => {
|
||||
|
|
@ -194,50 +201,87 @@ export function TabbedBracketLayout({
|
|||
{phase.groups.map((group) => {
|
||||
const gMatches = groupMatches(matchesByRound, group);
|
||||
const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined);
|
||||
const geometry = bracketGeometry(gRounds, gMatches, feeders, roundOrder);
|
||||
return (
|
||||
<div key={group.name}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
||||
{group.name}
|
||||
</p>
|
||||
<div className="w-full overflow-x-auto">
|
||||
<div style={{ minWidth: geometry.minWidth }}>
|
||||
<TreeColumns
|
||||
visibleRounds={gRounds}
|
||||
matchesByRound={gMatches}
|
||||
geometry={geometry}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={phaseHeight(gMatches, gRounds)}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sharedRounds.length > 0 && (
|
||||
<TreeColumns
|
||||
visibleRounds={sharedRounds}
|
||||
matchesByRound={sharedMatchesByRound}
|
||||
geometry={bracketGeometry(sharedRounds, sharedMatchesByRound, feeders, roundOrder)}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={phaseHeight(sharedMatchesByRound, sharedRounds)}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<TreeColumns
|
||||
visibleRounds={phaseRounds}
|
||||
matchesByRound={phaseMatchesByRound}
|
||||
geometry={bracketGeometry(phaseRounds, phaseMatchesByRound, feeders, roundOrder)}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={phaseHeight(phaseMatchesByRound, phaseRounds)}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile */}
|
||||
<div className="md:hidden">
|
||||
{/* Mobile — paged one group at a time, matching the desktop split. Paging a
|
||||
whole phase would merge the winners and elimination brackets into one
|
||||
tree, and a double-elimination phase is a DAG rather than a tree: the
|
||||
same game feeds forward and sideways, so its column placement would be
|
||||
arbitrary. */}
|
||||
<div className="md:hidden space-y-6">
|
||||
{phase.layout === "play-in" ? (
|
||||
<PlayInLayout
|
||||
matchesByRound={phaseMatchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
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
|
||||
rounds={phaseRounds}
|
||||
|
|
@ -245,6 +289,8 @@ export function TabbedBracketLayout({
|
|||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { describe, it, expect } from "vitest";
|
|||
import { render, screen, within } from "@testing-library/react";
|
||||
import {
|
||||
PlayoffBracket,
|
||||
buildFeederMap,
|
||||
groupMatchesByRound,
|
||||
computeEliminatedByRound,
|
||||
computeRankedEntries,
|
||||
|
|
@ -64,88 +63,72 @@ describe("groupMatchesByRound", () => {
|
|||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildFeederMap
|
||||
// Rendered LLWS bracket — geometry and empty-slot labels
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildFeederMap", () => {
|
||||
it("returns an empty map when there is only one round", () => {
|
||||
const matches = [makeMatch("Finals", 1)];
|
||||
const map = buildFeederMap(groupMatchesByRound(matches), ["Finals"]);
|
||||
expect(map.size).toBe(0);
|
||||
describe("PlayoffBracket — rendered LLWS bracket", () => {
|
||||
const LLWS_ROUNDS = (getBracketTemplate("llws_20")?.rounds ?? []).map((r) => r.name);
|
||||
|
||||
/** Every LLWS match, all unplayed, so each slot shows what will fill it. */
|
||||
function emptyLlwsMatches(): Match[] {
|
||||
const template = getBracketTemplate("llws_20");
|
||||
const matches: Match[] = [];
|
||||
for (const round of template?.rounds ?? []) {
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
matches.push({
|
||||
...makeMatch(round.name, n, { participant1Id: null, participant2Id: null }),
|
||||
participant1: null,
|
||||
participant2: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
it("names empty slots after the game that feeds them", () => {
|
||||
render(
|
||||
<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", () => {
|
||||
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
|
||||
const matches = [
|
||||
makeMatch("Quarterfinals", 1),
|
||||
makeMatch("Quarterfinals", 2),
|
||||
makeMatch("Quarterfinals", 3),
|
||||
makeMatch("Quarterfinals", 4),
|
||||
makeMatch("Semifinals", 1),
|
||||
makeMatch("Semifinals", 2),
|
||||
makeMatch("Finals", 1),
|
||||
];
|
||||
it("still shows TBD for a directly seeded slot", () => {
|
||||
render(
|
||||
<PlayoffBracket
|
||||
matches={emptyLlwsMatches()}
|
||||
rounds={LLWS_ROUNDS}
|
||||
bracketTemplateId="llws_20"
|
||||
/>
|
||||
);
|
||||
|
||||
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
|
||||
|
||||
// SF Match 1, slot p1 ← QF Match 1
|
||||
expect(map.get("Semifinals:1:p1")).toEqual({ round: "Quarterfinals", matchNumber: 1 });
|
||||
// SF Match 1, slot p2 ← QF Match 2
|
||||
expect(map.get("Semifinals:1:p2")).toEqual({ round: "Quarterfinals", matchNumber: 2 });
|
||||
// SF Match 2, slot p1 ← QF Match 3
|
||||
expect(map.get("Semifinals:2:p1")).toEqual({ round: "Quarterfinals", matchNumber: 3 });
|
||||
// SF Match 2, slot p2 ← QF Match 4
|
||||
expect(map.get("Semifinals:2:p2")).toEqual({ round: "Quarterfinals", matchNumber: 4 });
|
||||
// The opening round is seeded, not fed, so it has nothing to name.
|
||||
expect(screen.getAllByText("TBD").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("maps Finals slots to the correct SF matches", () => {
|
||||
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
|
||||
const matches = [
|
||||
makeMatch("Quarterfinals", 1),
|
||||
makeMatch("Quarterfinals", 2),
|
||||
makeMatch("Quarterfinals", 3),
|
||||
makeMatch("Quarterfinals", 4),
|
||||
makeMatch("Semifinals", 1),
|
||||
makeMatch("Semifinals", 2),
|
||||
makeMatch("Finals", 1),
|
||||
];
|
||||
it("gives every card the same height, including a lone final", () => {
|
||||
const { container } = render(
|
||||
<PlayoffBracket
|
||||
matches={emptyLlwsMatches()}
|
||||
rounds={LLWS_ROUNDS}
|
||||
bracketTemplateId="llws_20"
|
||||
/>
|
||||
);
|
||||
|
||||
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
|
||||
|
||||
expect(map.get("Finals:1:p1")).toEqual({ round: "Semifinals", matchNumber: 1 });
|
||||
expect(map.get("Finals:1:p2")).toEqual({ round: "Semifinals", matchNumber: 2 });
|
||||
});
|
||||
|
||||
it("does not add an entry when the source match does not exist in the previous round", () => {
|
||||
const rounds = ["Quarterfinals", "Finals"];
|
||||
const matches = [
|
||||
makeMatch("Quarterfinals", 1),
|
||||
makeMatch("Quarterfinals", 2),
|
||||
makeMatch("Finals", 1),
|
||||
];
|
||||
|
||||
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
|
||||
|
||||
expect(map.get("Finals:1:p1")).toEqual({ round: "Quarterfinals", matchNumber: 1 });
|
||||
expect(map.get("Finals:1:p2")).toEqual({ round: "Quarterfinals", matchNumber: 2 });
|
||||
expect(map.has("Finals:2:p1")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles a 16-team bracket correctly for Round of 16 → Quarterfinals", () => {
|
||||
const rounds = ["Round of 16", "Quarterfinals", "Semifinals", "Finals"];
|
||||
const matches = [
|
||||
...[1, 2, 3, 4, 5, 6, 7, 8].map((n) => makeMatch("Round of 16", n)),
|
||||
...[1, 2, 3, 4].map((n) => makeMatch("Quarterfinals", n)),
|
||||
...[1, 2].map((n) => makeMatch("Semifinals", n)),
|
||||
makeMatch("Finals", 1),
|
||||
];
|
||||
|
||||
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
|
||||
|
||||
expect(map.get("Quarterfinals:1:p1")).toEqual({ round: "Round of 16", matchNumber: 1 });
|
||||
expect(map.get("Quarterfinals:1:p2")).toEqual({ round: "Round of 16", matchNumber: 2 });
|
||||
expect(map.get("Quarterfinals:4:p1")).toEqual({ round: "Round of 16", matchNumber: 7 });
|
||||
expect(map.get("Quarterfinals:4:p2")).toEqual({ round: "Round of 16", matchNumber: 8 });
|
||||
const heights = new Set(
|
||||
[...container.querySelectorAll<HTMLElement>("[data-match-id]")].map(
|
||||
(el) => el.style.height
|
||||
)
|
||||
);
|
||||
// Previously a one-match column stretched its card to fill the bracket height.
|
||||
expect(heights.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
377
app/lib/__tests__/bracket-layout.test.ts
Normal file
377
app/lib/__tests__/bracket-layout.test.ts
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
/**
|
||||
* Bracket layout tests.
|
||||
*
|
||||
* The load-bearing assertions check the LLWS geometry against the official 2026 LLBWS
|
||||
* bracket, in the PDF's own game numbers. A bracket "lines up" when each card sits level
|
||||
* with the game that feeds it, so these tests assert column membership, top-to-bottom
|
||||
* order, and vertical alignment — not just that a layout was produced.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
LLWS_20,
|
||||
SIMPLE_16,
|
||||
NFL_14,
|
||||
getBracketTemplate,
|
||||
type BracketTemplate,
|
||||
type ConferenceGroup,
|
||||
} from "~/lib/bracket-templates";
|
||||
import {
|
||||
buildFeederMap,
|
||||
computeGroupLayout,
|
||||
describeSlotSource,
|
||||
matchKey,
|
||||
type SlotSource,
|
||||
} from "~/lib/bracket-layout";
|
||||
import { GAME_TO_MATCH, MATCH_TO_GAME } from "~/test/fixtures/llws-bracket";
|
||||
|
||||
interface TestMatch {
|
||||
round: string;
|
||||
matchNumber: number;
|
||||
}
|
||||
|
||||
/** Every match a template defines, as the renderer would receive them. */
|
||||
function allMatches(template: BracketTemplate): Map<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("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");
|
||||
});
|
||||
});
|
||||
351
app/lib/bracket-layout.ts
Normal file
351
app/lib/bracket-layout.ts
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
/**
|
||||
* Bracket geometry, derived from the real feeder graph.
|
||||
*
|
||||
* The renderer used to place cards by index within a round — match i at
|
||||
* `i * (height / roundSize)` — and drew connectors assuming matches 2k and 2k+1 feed
|
||||
* match k. That holds only when each round is an exact halving of the previous one.
|
||||
*
|
||||
* The LLWS winners bracket is not a halving: two of the four Opening Round games skip
|
||||
* Winners Round 2 entirely and go straight to the semifinals (see LLWS_ADVANCEMENT).
|
||||
* Under index math those games get pulled to the bottom of column one with nothing
|
||||
* above them in column two, and the connectors confidently join the wrong pairs.
|
||||
*
|
||||
* So lay out from the graph instead:
|
||||
* column = depth from the group's final, counted backwards
|
||||
* vertical order = the parent's slot order (participant1 above participant2)
|
||||
* connectors = actual feeder edges
|
||||
*
|
||||
* Counting columns back from the final is what makes a printed bracket line up: a team
|
||||
* entering late sits in the column where it actually plays, not the column its round
|
||||
* name suggests. For the LLWS International side this reproduces the official bracket
|
||||
* exactly, including putting the Australia/Mexico game alongside Winners Round 2.
|
||||
*
|
||||
* Pure — no React, no DB — so the geometry can be asserted against the printed bracket
|
||||
* in tests.
|
||||
*/
|
||||
|
||||
import {
|
||||
llwsSideAndLocal,
|
||||
type BracketTemplate,
|
||||
} from "~/lib/bracket-templates";
|
||||
import { resolveLLWSAdvancement } from "~/lib/llws-bracket";
|
||||
|
||||
// ── Feeder graph ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MatchRef {
|
||||
round: string;
|
||||
matchNumber: number;
|
||||
}
|
||||
|
||||
/** What fills one participant slot of a match. */
|
||||
export type SlotSource =
|
||||
| { kind: "match"; ref: MatchRef; result: "winner" | "loser" }
|
||||
| { kind: "seed" };
|
||||
|
||||
/** Keyed by `${round}#${matchNumber}`; the pair is [participant1, participant2]. */
|
||||
export type FeederMap = Map<string, [SlotSource, SlotSource]>;
|
||||
|
||||
const SEED: SlotSource = { kind: "seed" };
|
||||
|
||||
export function matchKey(round: string, matchNumber: number): string {
|
||||
return `${round}#${matchNumber}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invert a template's advancement rules into "what fills each slot".
|
||||
*
|
||||
* `llws_20` has an explicit, hand-verified routing table with deliberate cross-overs, so
|
||||
* it is inverted from that. Everything else follows the standard rule: slot p1 of match N
|
||||
* is the winner of match 2N-1 in the previous round, slot p2 the winner of match 2N.
|
||||
*/
|
||||
export function buildFeederMap(template: BracketTemplate | undefined): FeederMap {
|
||||
const feeders: FeederMap = new Map();
|
||||
if (!template) return feeders;
|
||||
|
||||
const slots = (key: string): [SlotSource, SlotSource] => {
|
||||
let pair = feeders.get(key);
|
||||
if (!pair) {
|
||||
pair = [SEED, SEED];
|
||||
feeders.set(key, pair);
|
||||
}
|
||||
return pair;
|
||||
};
|
||||
|
||||
// Seed every match in the template so unfed slots read as directly seeded.
|
||||
for (const round of template.rounds) {
|
||||
for (let n = 1; n <= round.matchCount; n++) slots(matchKey(round.name, n));
|
||||
}
|
||||
|
||||
if (template.id === "llws_20") {
|
||||
for (const round of template.rounds) {
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const { winner, loser } = resolveLLWSAdvancement(round.name, n);
|
||||
const ref: MatchRef = { round: round.name, matchNumber: n };
|
||||
for (const [destination, result] of [
|
||||
[winner, "winner"],
|
||||
[loser, "loser"],
|
||||
] as const) {
|
||||
if (!destination) continue;
|
||||
const pair = slots(matchKey(destination.round, destination.matchNumber));
|
||||
pair[destination.slot === "participant1Id" ? 0 : 1] = { kind: "match", ref, result };
|
||||
}
|
||||
}
|
||||
}
|
||||
return feeders;
|
||||
}
|
||||
|
||||
for (let ri = 1; ri < template.rounds.length; ri++) {
|
||||
const round = template.rounds[ri];
|
||||
const prev = template.rounds[ri - 1];
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const pair = slots(matchKey(round.name, n));
|
||||
for (const [slotIdx, source] of [
|
||||
[0, 2 * n - 1],
|
||||
[1, 2 * n],
|
||||
] as const) {
|
||||
if (source > prev.matchCount) continue;
|
||||
pair[slotIdx] = {
|
||||
kind: "match",
|
||||
ref: { round: prev.name, matchNumber: source },
|
||||
result: "winner",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return feeders;
|
||||
}
|
||||
|
||||
// ── Slot labels ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Round names as they read inside a card, where there is room for about twenty
|
||||
* characters. Anything not listed keeps its full name.
|
||||
*/
|
||||
const SHORT_ROUND_NAMES: Record<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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: one column per round, matches spread evenly, no edges.
|
||||
* Used when a group's shape can't be resolved into a single tree.
|
||||
*/
|
||||
function fallbackLayout<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 columns = visibleRounds.map((round) => {
|
||||
const matches = matchesByRound.get(round) ?? [];
|
||||
const span = leafCount / Math.max(matches.length, 1);
|
||||
return {
|
||||
label: round,
|
||||
matches: matches.map((match, i) => ({ match, center: (i + 0.5) * span })),
|
||||
};
|
||||
});
|
||||
return { columns, leafCount, edges: [] };
|
||||
}
|
||||
194
app/lib/llws-bracket.ts
Normal file
194
app/lib/llws-bracket.ts
Normal 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) };
|
||||
}
|
||||
|
|
@ -27,6 +27,13 @@ import {
|
|||
calculateAveragedPoints,
|
||||
type ScoringRules,
|
||||
} from "../scoring-rules";
|
||||
import {
|
||||
GAME_TO_MATCH,
|
||||
EXPECTED_SLOTS,
|
||||
gameNumberFor,
|
||||
required,
|
||||
destinationGame,
|
||||
} from "~/test/fixtures/llws-bracket";
|
||||
|
||||
// generateBracketFromTemplate's only DB touch for llws_20 is the bulk insert, so a
|
||||
// minimal stub is enough to capture the generated rows.
|
||||
|
|
@ -55,121 +62,6 @@ const DEFAULT_SCORING: ScoringRules = {
|
|||
pointsFor8th: 10,
|
||||
};
|
||||
|
||||
// ── PDF game number ↔ (round, match number) ──────────────────────────────────
|
||||
//
|
||||
// Transcribed directly from the 2026 LLBWS bracket. U.S. games take the low match
|
||||
// numbers in each round, International the high ones.
|
||||
const GAME_TO_MATCH: Record<number, { round: string; matchNumber: number }> = {
|
||||
// Opening Round — U.S. G2,4,6,8 (M1–4); Intl G1,3,5,7 (M5–8)
|
||||
2: { round: "Opening Round", matchNumber: 1 },
|
||||
4: { round: "Opening Round", matchNumber: 2 },
|
||||
6: { round: "Opening Round", matchNumber: 3 },
|
||||
8: { round: "Opening Round", matchNumber: 4 },
|
||||
1: { round: "Opening Round", matchNumber: 5 },
|
||||
3: { round: "Opening Round", matchNumber: 6 },
|
||||
5: { round: "Opening Round", matchNumber: 7 },
|
||||
7: { round: "Opening Round", matchNumber: 8 },
|
||||
// Winners Round 2 — U.S. G10,12; Intl G9,11
|
||||
10: { round: "Winners Round 2", matchNumber: 1 },
|
||||
12: { round: "Winners Round 2", matchNumber: 2 },
|
||||
9: { round: "Winners Round 2", matchNumber: 3 },
|
||||
11: { round: "Winners Round 2", matchNumber: 4 },
|
||||
// Elimination Round 1 — U.S. G14,16; Intl G13,15
|
||||
14: { round: "Elimination Round 1", matchNumber: 1 },
|
||||
16: { round: "Elimination Round 1", matchNumber: 2 },
|
||||
13: { round: "Elimination Round 1", matchNumber: 3 },
|
||||
15: { round: "Elimination Round 1", matchNumber: 4 },
|
||||
// Winners Semifinals — U.S. G17,19; Intl G18,20
|
||||
17: { round: "Winners Semifinals", matchNumber: 1 },
|
||||
19: { round: "Winners Semifinals", matchNumber: 2 },
|
||||
18: { round: "Winners Semifinals", matchNumber: 3 },
|
||||
20: { round: "Winners Semifinals", matchNumber: 4 },
|
||||
// Elimination Round 2 — U.S. G22,24; Intl G21,23
|
||||
22: { round: "Elimination Round 2", matchNumber: 1 },
|
||||
24: { round: "Elimination Round 2", matchNumber: 2 },
|
||||
21: { round: "Elimination Round 2", matchNumber: 3 },
|
||||
23: { round: "Elimination Round 2", matchNumber: 4 },
|
||||
// Elimination Round 3 — U.S. G26,28; Intl G25,27
|
||||
26: { round: "Elimination Round 3", matchNumber: 1 },
|
||||
28: { round: "Elimination Round 3", matchNumber: 2 },
|
||||
25: { round: "Elimination Round 3", matchNumber: 3 },
|
||||
27: { round: "Elimination Round 3", matchNumber: 4 },
|
||||
// Winners Final — U.S. G30; Intl G29
|
||||
30: { round: "Winners Final", matchNumber: 1 },
|
||||
29: { round: "Winners Final", matchNumber: 2 },
|
||||
// Elimination Round 4 — U.S. G32; Intl G31
|
||||
32: { round: "Elimination Round 4", matchNumber: 1 },
|
||||
31: { round: "Elimination Round 4", matchNumber: 2 },
|
||||
// Elimination Final — U.S. G34; Intl G33
|
||||
34: { round: "Elimination Final", matchNumber: 1 },
|
||||
33: { round: "Elimination Final", matchNumber: 2 },
|
||||
// Bracket Championship — U.S. G36; Intl G35
|
||||
36: { round: "Bracket Championship", matchNumber: 1 },
|
||||
35: { round: "Bracket Championship", matchNumber: 2 },
|
||||
// Finals
|
||||
37: { round: "Consolation Third Place", matchNumber: 1 },
|
||||
38: { round: "World Championship", matchNumber: 1 },
|
||||
};
|
||||
|
||||
const MATCH_TO_GAME = new Map<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("Template structure", () => {
|
||||
it("has correct identity and size", () => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ import {
|
|||
llwsSideAndLocal,
|
||||
STANDARD_BRACKET_SEEDING,
|
||||
} from "~/lib/bracket-templates";
|
||||
import {
|
||||
LLWS_LOSER_ADVANCES_ROUNDS,
|
||||
resolveLLWSAdvancement,
|
||||
type LLWSResolvedDestination,
|
||||
} from "~/lib/llws-bracket";
|
||||
|
||||
export type PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
|
||||
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
|
||||
|
|
@ -1563,190 +1568,10 @@ async function advanceNBAPlayInWinner(
|
|||
|
||||
// ── LLWS 20 (double elimination) ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Where one participant goes after an LLWS match: a round, a side-local match number,
|
||||
* and which slot to fill. `null` means eliminated (or, for winners, no further game).
|
||||
*/
|
||||
interface LLWSDestination {
|
||||
round: string;
|
||||
localMatch: number;
|
||||
slot: "participant1Id" | "participant2Id";
|
||||
}
|
||||
|
||||
/**
|
||||
* LLWS advancement map, in SIDE-LOCAL match numbers.
|
||||
*
|
||||
* Keyed by round, then by the local match number of the completed game. Each entry
|
||||
* says where the winner goes and where the loser goes (null = eliminated).
|
||||
*
|
||||
* Verified game-by-game against the official 2026 LLBWS bracket. Note the deliberate
|
||||
* cross-overs — the elimination bracket does NOT feed straight across:
|
||||
* Elim R1: L(Opening m2) v L(Opening m3) and L(Opening m1) v L(Opening m4)
|
||||
* Elim R3: L(Semi m1) v W(Elim R2 m2) and L(Semi m2) v W(Elim R2 m1)
|
||||
* Elim R4: W(Elim R3 m1) v W(Elim R3 m2)
|
||||
*
|
||||
* A loss in the winners bracket routes into the elimination bracket rather than
|
||||
* eliminating the team; a loss in the elimination bracket is final.
|
||||
*/
|
||||
const LLWS_ADVANCEMENT: Record<
|
||||
string,
|
||||
Record<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) };
|
||||
}
|
||||
// The routing table itself is pure and lives in lib/ so the renderer can import it
|
||||
// without pulling the database context into the browser bundle. Re-exported here so
|
||||
// existing server-side callers and tests keep their import path.
|
||||
export { LLWS_LOSER_ADVANCES_ROUNDS, resolveLLWSAdvancement, type LLWSResolvedDestination };
|
||||
|
||||
/**
|
||||
* Generate the 20-team LLWS double-elimination bracket (38 matches).
|
||||
|
|
|
|||
118
app/routes/__tests__/admin.sports-seasons.bracket.clear.test.ts
Normal file
118
app/routes/__tests__/admin.sports-seasons.bracket.clear.test.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* clear-bracket is the only path that can tear down a bracket, so the guard around it
|
||||
* matters: it discards recorded results and the placements derived from them.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
findPlayoffMatchesByEventId,
|
||||
deletePlayoffMatchesByEventId,
|
||||
} from "~/models/playoff-match";
|
||||
import { deleteParticipantResultsBySportsSeasonId } from "~/models/participant-result";
|
||||
import { recalculateAffectedLeagues } from "~/models/scoring-calculator";
|
||||
import { getScoringEventById } from "~/models/scoring-event";
|
||||
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
|
||||
|
||||
vi.mock("~/models/scoring-event", async (importOriginal) => ({
|
||||
...(await importOriginal<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 and the placements derived from them", 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");
|
||||
expect(deleteParticipantResultsBySportsSeasonId).toHaveBeenCalledWith("season-1");
|
||||
});
|
||||
|
||||
it("refuses to discard completed matches without confirmation", async () => {
|
||||
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
|
||||
match(true),
|
||||
match(false),
|
||||
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
|
||||
|
||||
const result = await run(clearRequest());
|
||||
|
||||
expect(result.error).toContain("1 completed match(es)");
|
||||
expect(deletePlayoffMatchesByEventId).not.toHaveBeenCalled();
|
||||
expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards completed matches once confirmed", async () => {
|
||||
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
|
||||
match(true),
|
||||
] as unknown as Awaited<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();
|
||||
});
|
||||
});
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
|
||||
import {
|
||||
findPlayoffMatchesByEventId,
|
||||
deletePlayoffMatchesByEventId,
|
||||
generateBracketFromTemplate,
|
||||
setMatchWinner,
|
||||
advanceWinnerTemplate,
|
||||
|
|
@ -288,6 +289,43 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
}
|
||||
|
||||
// The only way to repair a mis-seeded bracket: nothing else can rewrite a match's
|
||||
// participants. Clearing brings back the setup form, so the admin re-seeds from there.
|
||||
if (intent === "clear-bracket") {
|
||||
try {
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
if (!event) return { error: "Event not found" };
|
||||
|
||||
const existing = await findPlayoffMatchesByEventId(params.eventId);
|
||||
if (existing.length === 0) {
|
||||
return { error: "This event has no bracket to clear" };
|
||||
}
|
||||
// Clearing discards recorded results, so make the admin confirm once games have
|
||||
// actually been played.
|
||||
const completed = existing.filter((m) => m.isComplete).length;
|
||||
if (completed > 0 && formData.get("confirm") !== "true") {
|
||||
return {
|
||||
error: `This bracket has ${completed} completed match(es). Confirm to discard those results.`,
|
||||
};
|
||||
}
|
||||
|
||||
await deletePlayoffMatchesByEventId(params.eventId);
|
||||
// Placements were derived from the matches just deleted; leaving them behind would
|
||||
// keep stale points on the standings.
|
||||
await deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId);
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId, database(), { skipDiscord: true });
|
||||
|
||||
return {
|
||||
success: `Bracket cleared (${existing.length} match(es) removed). Set it up again below.`,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Error clearing bracket:", error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : "Failed to clear bracket",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "generate-bracket") {
|
||||
const templateId = formData.get("templateId");
|
||||
|
||||
|
|
|
|||
|
|
@ -613,6 +613,42 @@ export default function EventBracket({
|
|||
</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. This also
|
||||
clears the season's placements and the points derived from them.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form
|
||||
method="post"
|
||||
onSubmit={(e) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Delete all ${matches.length} match(es) in this bracket? Recorded results and placements will be lost.`
|
||||
)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="intent" value="clear-bracket" />
|
||||
<input type="hidden" name="confirm" value="true" />
|
||||
<Button type="submit" variant="destructive">
|
||||
Clear Bracket
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ====== SETUP PHASE ====== */}
|
||||
{showSetup && (
|
||||
<Card>
|
||||
|
|
|
|||
122
app/test/fixtures/llws-bracket.ts
vendored
Normal file
122
app/test/fixtures/llws-bracket.ts
vendored
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* The official 2026 LLBWS bracket (Williamsport, Aug 19–30), transcribed from the PDF.
|
||||
*
|
||||
* The printed bracket numbers its games 1–38. Both the routing tests and the layout
|
||||
* tests check themselves against these numbers, so the transcription lives here rather
|
||||
* than in either one.
|
||||
*/
|
||||
|
||||
// ── PDF game number ↔ (round, match number) ──────────────────────────────────
|
||||
//
|
||||
// Transcribed directly from the 2026 LLBWS bracket. U.S. games take the low match
|
||||
// numbers in each round, International the high ones.
|
||||
export const GAME_TO_MATCH: Record<number, { round: string; matchNumber: number }> = {
|
||||
// Opening Round — U.S. G2,4,6,8 (M1–4); Intl G1,3,5,7 (M5–8)
|
||||
2: { round: "Opening Round", matchNumber: 1 },
|
||||
4: { round: "Opening Round", matchNumber: 2 },
|
||||
6: { round: "Opening Round", matchNumber: 3 },
|
||||
8: { round: "Opening Round", matchNumber: 4 },
|
||||
1: { round: "Opening Round", matchNumber: 5 },
|
||||
3: { round: "Opening Round", matchNumber: 6 },
|
||||
5: { round: "Opening Round", matchNumber: 7 },
|
||||
7: { round: "Opening Round", matchNumber: 8 },
|
||||
// Winners Round 2 — U.S. G10,12; Intl G9,11
|
||||
10: { round: "Winners Round 2", matchNumber: 1 },
|
||||
12: { round: "Winners Round 2", matchNumber: 2 },
|
||||
9: { round: "Winners Round 2", matchNumber: 3 },
|
||||
11: { round: "Winners Round 2", matchNumber: 4 },
|
||||
// Elimination Round 1 — U.S. G14,16; Intl G13,15
|
||||
14: { round: "Elimination Round 1", matchNumber: 1 },
|
||||
16: { round: "Elimination Round 1", matchNumber: 2 },
|
||||
13: { round: "Elimination Round 1", matchNumber: 3 },
|
||||
15: { round: "Elimination Round 1", matchNumber: 4 },
|
||||
// Winners Semifinals — U.S. G17,19; Intl G18,20
|
||||
17: { round: "Winners Semifinals", matchNumber: 1 },
|
||||
19: { round: "Winners Semifinals", matchNumber: 2 },
|
||||
18: { round: "Winners Semifinals", matchNumber: 3 },
|
||||
20: { round: "Winners Semifinals", matchNumber: 4 },
|
||||
// Elimination Round 2 — U.S. G22,24; Intl G21,23
|
||||
22: { round: "Elimination Round 2", matchNumber: 1 },
|
||||
24: { round: "Elimination Round 2", matchNumber: 2 },
|
||||
21: { round: "Elimination Round 2", matchNumber: 3 },
|
||||
23: { round: "Elimination Round 2", matchNumber: 4 },
|
||||
// Elimination Round 3 — U.S. G26,28; Intl G25,27
|
||||
26: { round: "Elimination Round 3", matchNumber: 1 },
|
||||
28: { round: "Elimination Round 3", matchNumber: 2 },
|
||||
25: { round: "Elimination Round 3", matchNumber: 3 },
|
||||
27: { round: "Elimination Round 3", matchNumber: 4 },
|
||||
// Winners Final — U.S. G30; Intl G29
|
||||
30: { round: "Winners Final", matchNumber: 1 },
|
||||
29: { round: "Winners Final", matchNumber: 2 },
|
||||
// Elimination Round 4 — U.S. G32; Intl G31
|
||||
32: { round: "Elimination Round 4", matchNumber: 1 },
|
||||
31: { round: "Elimination Round 4", matchNumber: 2 },
|
||||
// Elimination Final — U.S. G34; Intl G33
|
||||
34: { round: "Elimination Final", matchNumber: 1 },
|
||||
33: { round: "Elimination Final", matchNumber: 2 },
|
||||
// Bracket Championship — U.S. G36; Intl G35
|
||||
36: { round: "Bracket Championship", matchNumber: 1 },
|
||||
35: { round: "Bracket Championship", matchNumber: 2 },
|
||||
// Finals
|
||||
37: { round: "Consolation Third Place", matchNumber: 1 },
|
||||
38: { round: "World Championship", matchNumber: 1 },
|
||||
};
|
||||
|
||||
export const MATCH_TO_GAME = new Map<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"],
|
||||
};
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
"app/models/**/*.ts",
|
||||
"app/services/**/*.ts",
|
||||
"app/lib/**/*.ts",
|
||||
"app/test/fixtures/**/*.ts",
|
||||
"app/types/**/*.ts",
|
||||
"vite.config.ts"
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue