brackt/app/lib/bracket-layout.ts

421 lines
16 KiB
TypeScript
Raw Normal View History

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
2026-08-21 17:50:59 +00:00
/**
* 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" };
Only derive feeders where the halving rule actually holds Review caught that the generic feeder rule was being applied to templates that route by their own logic. It was harmless as dead code; driving the renderer with it made several brackets worse than before. The rule pairs rounds by array order and assumes match n is fed by 2n-1 and 2n. That describes advanceWinnerTemplate, not every bracket: - afl_10's Wildcard Round feeds the Elimination Finals, skipping the round listed next to it, so array order fabricated the entire chain and drew ten wrong connectors contradicting advanceAFLWinner. - fifa_48's Third Place Game sits between the Semifinals and the Finals, so the Finals came out fed by the third place game. Once BracketTreeView filtered the consolation round out, the group had three roots and the whole World Cup bracket rendered with no connectors at all. - ncaa_68 labelled Round of 64 #1/#2 with First Four feeds that advanceFirstFourWinner doesn't use. - nba_20's play-in halves in size but pairs the 7v8 loser with the 9v10 winner. Follow each round's declared feedsInto, and derive edges only where the round halves exactly — the condition under which the generic ceil(n/2) mapping is true. Bespoke transitions that happen to halve are named explicitly. Slots left without a feeder read TBD, which is honest. Dropping those edges sends the group to the fallback, so the fallback now has to keep drawing what those brackets already drew: halving U-shapes by round size, and winner tracing through irregular shapes. Previously it drew nothing, which also silently removed every connector from brackets with no bracketTemplateId. Also from review: - clear-bracket deleted seasonParticipantResults for the entire sports season with no rebuild. That table is keyed by season, not event, so it wiped placements for every other event in the season — permanently zeroing standings on a finalized qualifying season. Delete only the matches and point the admin at Reprocess Bracket, which rebuilds placements correctly. - The clear-bracket form sent confirm=true from a hidden field, making the server's completed-match guard unreachable. It's a checkbox now, so the guard is real, including without JS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnzbrCHoM8ESbtbDamaqFb
2026-08-21 18:18:05 +00:00
/**
* `template.id:roundName` for transitions routed by a dedicated advancement function
* rather than advanceWinnerTemplate's ceil(n/2) rule, and whose round sizes happen to
* halve so the check in buildFeederMap can't rule them out on shape alone.
*
* The NBA play-in is the case: Play-In Round 2 pairs the 7v8 *loser* with the 9v10
* winner (advanceNBAPlayInWinner), which no winners-only halving describes.
*/
const BESPOKE_TRANSITIONS = new Set(["nba_20:Play-In Round 1"]);
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
2026-08-21 17:50:59 +00:00
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;
}
Only derive feeders where the halving rule actually holds Review caught that the generic feeder rule was being applied to templates that route by their own logic. It was harmless as dead code; driving the renderer with it made several brackets worse than before. The rule pairs rounds by array order and assumes match n is fed by 2n-1 and 2n. That describes advanceWinnerTemplate, not every bracket: - afl_10's Wildcard Round feeds the Elimination Finals, skipping the round listed next to it, so array order fabricated the entire chain and drew ten wrong connectors contradicting advanceAFLWinner. - fifa_48's Third Place Game sits between the Semifinals and the Finals, so the Finals came out fed by the third place game. Once BracketTreeView filtered the consolation round out, the group had three roots and the whole World Cup bracket rendered with no connectors at all. - ncaa_68 labelled Round of 64 #1/#2 with First Four feeds that advanceFirstFourWinner doesn't use. - nba_20's play-in halves in size but pairs the 7v8 loser with the 9v10 winner. Follow each round's declared feedsInto, and derive edges only where the round halves exactly — the condition under which the generic ceil(n/2) mapping is true. Bespoke transitions that happen to halve are named explicitly. Slots left without a feeder read TBD, which is honest. Dropping those edges sends the group to the fallback, so the fallback now has to keep drawing what those brackets already drew: halving U-shapes by round size, and winner tracing through irregular shapes. Previously it drew nothing, which also silently removed every connector from brackets with no bracketTemplateId. Also from review: - clear-bracket deleted seasonParticipantResults for the entire sports season with no rebuild. That table is keyed by season, not event, so it wiped placements for every other event in the season — permanently zeroing standings on a finalized qualifying season. Delete only the matches and point the admin at Reprocess Bracket, which rebuilds placements correctly. - The clear-bracket form sent confirm=true from a hidden field, making the server's completed-match guard unreachable. It's a checkbox now, so the guard is real, including without JS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnzbrCHoM8ESbtbDamaqFb
2026-08-21 18:18:05 +00:00
// Follow each round's declared `feedsInto` rather than array order — AFL's Wildcard
// Round feeds the Elimination Finals, skipping the round printed next to it.
for (const prev of template.rounds) {
if (!prev.feedsInto) continue;
const round = template.rounds.find((r) => r.name === prev.feedsInto);
if (!round) continue;
// advanceWinnerTemplate sends match n to ceil(n/2) in the next round, slot by
// parity. That describes the bracket only where the round halves exactly; a
// play-in, a bye round, or a First Four routes by rules of its own, and inventing
// a halving there would draw connectors and slot labels that are simply wrong.
// Leaving those edges out drops the group to computeGroupLayout's fallback, which
// is the geometry these brackets already had.
if (prev.matchCount !== round.matchCount * 2) continue;
if (BESPOKE_TRANSITIONS.has(`${template.id}:${prev.name}`)) continue;
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
2026-08-21 17:50:59 +00:00
for (let n = 1; n <= round.matchCount; n++) {
const pair = slots(matchKey(round.name, n));
Only derive feeders where the halving rule actually holds Review caught that the generic feeder rule was being applied to templates that route by their own logic. It was harmless as dead code; driving the renderer with it made several brackets worse than before. The rule pairs rounds by array order and assumes match n is fed by 2n-1 and 2n. That describes advanceWinnerTemplate, not every bracket: - afl_10's Wildcard Round feeds the Elimination Finals, skipping the round listed next to it, so array order fabricated the entire chain and drew ten wrong connectors contradicting advanceAFLWinner. - fifa_48's Third Place Game sits between the Semifinals and the Finals, so the Finals came out fed by the third place game. Once BracketTreeView filtered the consolation round out, the group had three roots and the whole World Cup bracket rendered with no connectors at all. - ncaa_68 labelled Round of 64 #1/#2 with First Four feeds that advanceFirstFourWinner doesn't use. - nba_20's play-in halves in size but pairs the 7v8 loser with the 9v10 winner. Follow each round's declared feedsInto, and derive edges only where the round halves exactly — the condition under which the generic ceil(n/2) mapping is true. Bespoke transitions that happen to halve are named explicitly. Slots left without a feeder read TBD, which is honest. Dropping those edges sends the group to the fallback, so the fallback now has to keep drawing what those brackets already drew: halving U-shapes by round size, and winner tracing through irregular shapes. Previously it drew nothing, which also silently removed every connector from brackets with no bracketTemplateId. Also from review: - clear-bracket deleted seasonParticipantResults for the entire sports season with no rebuild. That table is keyed by season, not event, so it wiped placements for every other event in the season — permanently zeroing standings on a finalized qualifying season. Delete only the matches and point the admin at Reprocess Bracket, which rebuilds placements correctly. - The clear-bracket form sent confirm=true from a hidden field, making the server's completed-match guard unreachable. It's a checkbox now, so the guard is real, including without JS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnzbrCHoM8ESbtbDamaqFb
2026-08-21 18:18:05 +00:00
pair[0] = {
kind: "match",
ref: { round: prev.name, matchNumber: 2 * n - 1 },
result: "winner",
};
pair[1] = {
kind: "match",
ref: { round: prev.name, matchNumber: 2 * n },
result: "winner",
};
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
2026-08-21 17:50:59 +00:00
}
}
return feeders;
}
// ── Slot labels ───────────────────────────────────────────────────────────────
/**
* Round names as they read inside a card, where there is room for about twenty
* characters. Anything not listed keeps its full name.
*/
const SHORT_ROUND_NAMES: Record<string, string> = {
"Opening Round": "Opening",
"Winners Round 2": "Winners R2",
"Winners Semifinals": "Winners SF",
"Winners Final": "Winners Final",
"Elimination Round 1": "Elim R1",
"Elimination Round 2": "Elim R2",
"Elimination Round 3": "Elim R3",
"Elimination Round 4": "Elim R4",
"Elimination Final": "Elim Final",
"Bracket Championship": "Bracket Final",
};
/**
* How an empty slot should read: "Winner of Winners SF 2" rather than "TBD".
*
* Returns null for a directly seeded slot, which the caller renders as "TBD".
*
* The cross-bracket feeds matter most here a winners-bracket loser dropping into the
* elimination bracket is a real edge that no line can show, because the two sides render
* as separate trees.
*/
export function describeSlotSource(
source: SlotSource | undefined,
template: BracketTemplate | undefined
): string | null {
if (!source || source.kind !== "match") return null;
const { round, matchNumber } = source.ref;
const name = SHORT_ROUND_NAMES[round] ?? round;
const verb = source.result === "winner" ? "Winner" : "Loser";
const roundMatchCount = template?.rounds.find((r) => r.name === round)?.matchCount ?? 0;
if (template?.id !== "llws_20") {
return `${verb} of ${name}${roundMatchCount <= 1 ? "" : ` ${matchNumber}`}`;
}
// LLWS numbers matches globally across both sides, so semifinal 4 is International
// semifinal 2. Name it the way the printed bracket does — by side-local number, or by
// side where each side plays only one such game and the number would say nothing.
const { side, localMatch } = llwsSideAndLocal(round, matchNumber);
const isShared = round === "Consolation Third Place" || round === "World Championship";
const perSideCount = isShared ? roundMatchCount : roundMatchCount / 2;
if (perSideCount > 1) return `${verb} of ${name} ${localMatch}`;
if (isShared) return `${verb} of ${name}`;
return `${verb} of ${side === 0 ? "U.S." : "Intl"} ${name}`;
}
// ── Layout ────────────────────────────────────────────────────────────────────
export interface LaidOutMatch<M> {
match: M;
/** Centre of the card, in slot units (1 unit = one leaf row). */
center: number;
}
export interface LayoutColumn<M> {
label: string;
matches: LaidOutMatch<M>[];
}
export interface BracketLayout<M> {
columns: LayoutColumn<M>[];
/** Number of leaf rows; multiply by row height for the pixel height of the bracket. */
leafCount: number;
/** Edges to draw, as (column index of the source, source centre, target centre). */
edges: { fromColumn: number; fromCenter: number; toCenter: number }[];
}
interface PositionedMatch {
round: string;
matchNumber: number;
Only derive feeders where the halving rule actually holds Review caught that the generic feeder rule was being applied to templates that route by their own logic. It was harmless as dead code; driving the renderer with it made several brackets worse than before. The rule pairs rounds by array order and assumes match n is fed by 2n-1 and 2n. That describes advanceWinnerTemplate, not every bracket: - afl_10's Wildcard Round feeds the Elimination Finals, skipping the round listed next to it, so array order fabricated the entire chain and drew ten wrong connectors contradicting advanceAFLWinner. - fifa_48's Third Place Game sits between the Semifinals and the Finals, so the Finals came out fed by the third place game. Once BracketTreeView filtered the consolation round out, the group had three roots and the whole World Cup bracket rendered with no connectors at all. - ncaa_68 labelled Round of 64 #1/#2 with First Four feeds that advanceFirstFourWinner doesn't use. - nba_20's play-in halves in size but pairs the 7v8 loser with the 9v10 winner. Follow each round's declared feedsInto, and derive edges only where the round halves exactly — the condition under which the generic ceil(n/2) mapping is true. Bespoke transitions that happen to halve are named explicitly. Slots left without a feeder read TBD, which is honest. Dropping those edges sends the group to the fallback, so the fallback now has to keep drawing what those brackets already drew: halving U-shapes by round size, and winner tracing through irregular shapes. Previously it drew nothing, which also silently removed every connector from brackets with no bracketTemplateId. Also from review: - clear-bracket deleted seasonParticipantResults for the entire sports season with no rebuild. That table is keyed by season, not event, so it wiped placements for every other event in the season — permanently zeroing standings on a finalized qualifying season. Delete only the matches and point the admin at Reprocess Bracket, which rebuilds placements correctly. - The clear-bracket form sent confirm=true from a hidden field, making the server's completed-match guard unreachable. It's a checkbox now, so the guard is real, including without JS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnzbrCHoM8ESbtbDamaqFb
2026-08-21 18:18:05 +00:00
/** Only read by the fallback, to trace edges through an unrecognised shape. */
winnerId?: string | null;
participant1Id?: string | null;
participant2Id?: string | null;
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
2026-08-21 17:50:59 +00:00
}
/**
* 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 };
}
/**
Only derive feeders where the halving rule actually holds Review caught that the generic feeder rule was being applied to templates that route by their own logic. It was harmless as dead code; driving the renderer with it made several brackets worse than before. The rule pairs rounds by array order and assumes match n is fed by 2n-1 and 2n. That describes advanceWinnerTemplate, not every bracket: - afl_10's Wildcard Round feeds the Elimination Finals, skipping the round listed next to it, so array order fabricated the entire chain and drew ten wrong connectors contradicting advanceAFLWinner. - fifa_48's Third Place Game sits between the Semifinals and the Finals, so the Finals came out fed by the third place game. Once BracketTreeView filtered the consolation round out, the group had three roots and the whole World Cup bracket rendered with no connectors at all. - ncaa_68 labelled Round of 64 #1/#2 with First Four feeds that advanceFirstFourWinner doesn't use. - nba_20's play-in halves in size but pairs the 7v8 loser with the 9v10 winner. Follow each round's declared feedsInto, and derive edges only where the round halves exactly — the condition under which the generic ceil(n/2) mapping is true. Bespoke transitions that happen to halve are named explicitly. Slots left without a feeder read TBD, which is honest. Dropping those edges sends the group to the fallback, so the fallback now has to keep drawing what those brackets already drew: halving U-shapes by round size, and winner tracing through irregular shapes. Previously it drew nothing, which also silently removed every connector from brackets with no bracketTemplateId. Also from review: - clear-bracket deleted seasonParticipantResults for the entire sports season with no rebuild. That table is keyed by season, not event, so it wiped placements for every other event in the season — permanently zeroing standings on a finalized qualifying season. Delete only the matches and point the admin at Reprocess Bracket, which rebuilds placements correctly. - The clear-bracket form sent confirm=true from a hidden field, making the server's completed-match guard unreachable. It's a checkbox now, so the guard is real, including without JS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnzbrCHoM8ESbtbDamaqFb
2026-08-21 18:18:05 +00:00
* The previous behaviour, kept for groups whose shape can't be resolved into a single
* tree: one column per round, matches spread evenly over it, and edges inferred from the
* round sizes. Brackets with bespoke routing (AFL, CFP byes, a bracket with no template)
* land here, so it has to keep drawing what they drew before rather than nothing.
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
2026-08-21 17:50:59 +00:00
*/
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
);
Only derive feeders where the halving rule actually holds Review caught that the generic feeder rule was being applied to templates that route by their own logic. It was harmless as dead code; driving the renderer with it made several brackets worse than before. The rule pairs rounds by array order and assumes match n is fed by 2n-1 and 2n. That describes advanceWinnerTemplate, not every bracket: - afl_10's Wildcard Round feeds the Elimination Finals, skipping the round listed next to it, so array order fabricated the entire chain and drew ten wrong connectors contradicting advanceAFLWinner. - fifa_48's Third Place Game sits between the Semifinals and the Finals, so the Finals came out fed by the third place game. Once BracketTreeView filtered the consolation round out, the group had three roots and the whole World Cup bracket rendered with no connectors at all. - ncaa_68 labelled Round of 64 #1/#2 with First Four feeds that advanceFirstFourWinner doesn't use. - nba_20's play-in halves in size but pairs the 7v8 loser with the 9v10 winner. Follow each round's declared feedsInto, and derive edges only where the round halves exactly — the condition under which the generic ceil(n/2) mapping is true. Bespoke transitions that happen to halve are named explicitly. Slots left without a feeder read TBD, which is honest. Dropping those edges sends the group to the fallback, so the fallback now has to keep drawing what those brackets already drew: halving U-shapes by round size, and winner tracing through irregular shapes. Previously it drew nothing, which also silently removed every connector from brackets with no bracketTemplateId. Also from review: - clear-bracket deleted seasonParticipantResults for the entire sports season with no rebuild. That table is keyed by season, not event, so it wiped placements for every other event in the season — permanently zeroing standings on a finalized qualifying season. Delete only the matches and point the admin at Reprocess Bracket, which rebuilds placements correctly. - The clear-bracket form sent confirm=true from a hidden field, making the server's completed-match guard unreachable. It's a checkbox now, so the guard is real, including without JS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnzbrCHoM8ESbtbDamaqFb
2026-08-21 18:18:05 +00:00
const centersFor = (matches: M[]) => {
const span = leafCount / Math.max(matches.length, 1);
return matches.map((_, i) => (i + 0.5) * span);
};
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
2026-08-21 17:50:59 +00:00
const columns = visibleRounds.map((round) => {
const matches = matchesByRound.get(round) ?? [];
Only derive feeders where the halving rule actually holds Review caught that the generic feeder rule was being applied to templates that route by their own logic. It was harmless as dead code; driving the renderer with it made several brackets worse than before. The rule pairs rounds by array order and assumes match n is fed by 2n-1 and 2n. That describes advanceWinnerTemplate, not every bracket: - afl_10's Wildcard Round feeds the Elimination Finals, skipping the round listed next to it, so array order fabricated the entire chain and drew ten wrong connectors contradicting advanceAFLWinner. - fifa_48's Third Place Game sits between the Semifinals and the Finals, so the Finals came out fed by the third place game. Once BracketTreeView filtered the consolation round out, the group had three roots and the whole World Cup bracket rendered with no connectors at all. - ncaa_68 labelled Round of 64 #1/#2 with First Four feeds that advanceFirstFourWinner doesn't use. - nba_20's play-in halves in size but pairs the 7v8 loser with the 9v10 winner. Follow each round's declared feedsInto, and derive edges only where the round halves exactly — the condition under which the generic ceil(n/2) mapping is true. Bespoke transitions that happen to halve are named explicitly. Slots left without a feeder read TBD, which is honest. Dropping those edges sends the group to the fallback, so the fallback now has to keep drawing what those brackets already drew: halving U-shapes by round size, and winner tracing through irregular shapes. Previously it drew nothing, which also silently removed every connector from brackets with no bracketTemplateId. Also from review: - clear-bracket deleted seasonParticipantResults for the entire sports season with no rebuild. That table is keyed by season, not event, so it wiped placements for every other event in the season — permanently zeroing standings on a finalized qualifying season. Delete only the matches and point the admin at Reprocess Bracket, which rebuilds placements correctly. - The clear-bracket form sent confirm=true from a hidden field, making the server's completed-match guard unreachable. It's a checkbox now, so the guard is real, including without JS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnzbrCHoM8ESbtbDamaqFb
2026-08-21 18:18:05 +00:00
const centers = centersFor(matches);
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
2026-08-21 17:50:59 +00:00
return {
label: round,
Only derive feeders where the halving rule actually holds Review caught that the generic feeder rule was being applied to templates that route by their own logic. It was harmless as dead code; driving the renderer with it made several brackets worse than before. The rule pairs rounds by array order and assumes match n is fed by 2n-1 and 2n. That describes advanceWinnerTemplate, not every bracket: - afl_10's Wildcard Round feeds the Elimination Finals, skipping the round listed next to it, so array order fabricated the entire chain and drew ten wrong connectors contradicting advanceAFLWinner. - fifa_48's Third Place Game sits between the Semifinals and the Finals, so the Finals came out fed by the third place game. Once BracketTreeView filtered the consolation round out, the group had three roots and the whole World Cup bracket rendered with no connectors at all. - ncaa_68 labelled Round of 64 #1/#2 with First Four feeds that advanceFirstFourWinner doesn't use. - nba_20's play-in halves in size but pairs the 7v8 loser with the 9v10 winner. Follow each round's declared feedsInto, and derive edges only where the round halves exactly — the condition under which the generic ceil(n/2) mapping is true. Bespoke transitions that happen to halve are named explicitly. Slots left without a feeder read TBD, which is honest. Dropping those edges sends the group to the fallback, so the fallback now has to keep drawing what those brackets already drew: halving U-shapes by round size, and winner tracing through irregular shapes. Previously it drew nothing, which also silently removed every connector from brackets with no bracketTemplateId. Also from review: - clear-bracket deleted seasonParticipantResults for the entire sports season with no rebuild. That table is keyed by season, not event, so it wiped placements for every other event in the season — permanently zeroing standings on a finalized qualifying season. Delete only the matches and point the admin at Reprocess Bracket, which rebuilds placements correctly. - The clear-bracket form sent confirm=true from a hidden field, making the server's completed-match guard unreachable. It's a checkbox now, so the guard is real, including without JS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnzbrCHoM8ESbtbDamaqFb
2026-08-21 18:18:05 +00:00
matches: matches.map((match, i) => ({ match, center: centers[i] })),
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
2026-08-21 17:50:59 +00:00
};
});
Only derive feeders where the halving rule actually holds Review caught that the generic feeder rule was being applied to templates that route by their own logic. It was harmless as dead code; driving the renderer with it made several brackets worse than before. The rule pairs rounds by array order and assumes match n is fed by 2n-1 and 2n. That describes advanceWinnerTemplate, not every bracket: - afl_10's Wildcard Round feeds the Elimination Finals, skipping the round listed next to it, so array order fabricated the entire chain and drew ten wrong connectors contradicting advanceAFLWinner. - fifa_48's Third Place Game sits between the Semifinals and the Finals, so the Finals came out fed by the third place game. Once BracketTreeView filtered the consolation round out, the group had three roots and the whole World Cup bracket rendered with no connectors at all. - ncaa_68 labelled Round of 64 #1/#2 with First Four feeds that advanceFirstFourWinner doesn't use. - nba_20's play-in halves in size but pairs the 7v8 loser with the 9v10 winner. Follow each round's declared feedsInto, and derive edges only where the round halves exactly — the condition under which the generic ceil(n/2) mapping is true. Bespoke transitions that happen to halve are named explicitly. Slots left without a feeder read TBD, which is honest. Dropping those edges sends the group to the fallback, so the fallback now has to keep drawing what those brackets already drew: halving U-shapes by round size, and winner tracing through irregular shapes. Previously it drew nothing, which also silently removed every connector from brackets with no bracketTemplateId. Also from review: - clear-bracket deleted seasonParticipantResults for the entire sports season with no rebuild. That table is keyed by season, not event, so it wiped placements for every other event in the season — permanently zeroing standings on a finalized qualifying season. Delete only the matches and point the admin at Reprocess Bracket, which rebuilds placements correctly. - The clear-bracket form sent confirm=true from a hidden field, making the server's completed-match guard unreachable. It's a checkbox now, so the guard is real, including without JS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnzbrCHoM8ESbtbDamaqFb
2026-08-21 18:18:05 +00:00
const edges: BracketLayout<M>["edges"] = [];
for (let ci = 0; ci < columns.length - 1; ci++) {
const from = columns[ci].matches;
const to = columns[ci + 1].matches;
if (to.length === Math.ceil(from.length / 2) && from.length > 1) {
// A halving: matches 2k and 2k+1 feed match k.
for (let k = 0; k < to.length; k++) {
for (const idx of [2 * k, 2 * k + 1]) {
if (idx >= from.length) continue;
edges.push({
fromColumn: ci,
fromCenter: from[idx].center,
toCenter: to[k].center,
});
}
}
continue;
}
// Otherwise the only thing that can be known is where a winner actually went, so
// nothing is drawn until the games are played.
const winnerToCenter = new Map<string, number>();
for (const { match, center } of from) {
if (match.winnerId) winnerToCenter.set(match.winnerId, center);
}
for (const { match, center } of to) {
for (const id of [match.participant1Id, match.participant2Id]) {
const fromCenter = id ? winnerToCenter.get(id) : undefined;
if (fromCenter === undefined) continue;
edges.push({ fromColumn: ci, fromCenter, toCenter: center });
}
}
}
return { columns, leafCount, edges };
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
2026-08-21 17:50:59 +00:00
}