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
|
|
|
/**
|
|
|
|
|
* 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>>
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
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
|
|
|
it("deletes the matches", async () => {
|
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
|
|
|
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");
|
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
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("leaves placements alone — they belong to the whole season, not this event", async () => {
|
|
|
|
|
// seasonParticipantResults is keyed by sports season, so deleting here would wipe
|
|
|
|
|
// every other event's placements with nothing to rebuild them. Reprocess Bracket is
|
|
|
|
|
// the tool that rebuilds them correctly.
|
|
|
|
|
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
|
|
|
|
|
match(true),
|
|
|
|
|
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
|
|
|
|
|
|
|
|
|
|
const result = await run(clearRequest("true"));
|
|
|
|
|
|
|
|
|
|
expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled();
|
|
|
|
|
expect(result.success).toContain("Reprocess Bracket");
|
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
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("refuses to discard completed matches without confirmation", async () => {
|
|
|
|
|
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
|
|
|
|
|
match(true),
|
|
|
|
|
match(false),
|
|
|
|
|
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
|
|
|
|
|
|
|
|
|
|
const result = await run(clearRequest());
|
|
|
|
|
|
|
|
|
|
expect(result.error).toContain("1 completed match(es)");
|
|
|
|
|
expect(deletePlayoffMatchesByEventId).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("discards completed matches once confirmed", async () => {
|
|
|
|
|
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
|
|
|
|
|
match(true),
|
|
|
|
|
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
|
|
|
|
|
|
|
|
|
|
const result = await run(clearRequest("true"));
|
|
|
|
|
|
|
|
|
|
expect(result.success).toBeDefined();
|
|
|
|
|
expect(deletePlayoffMatchesByEventId).toHaveBeenCalledWith("event-1");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects an event with no bracket rather than reporting a no-op success", async () => {
|
|
|
|
|
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue(
|
|
|
|
|
[] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const result = await run(clearRequest("true"));
|
|
|
|
|
|
|
|
|
|
expect(result.error).toContain("no bracket to clear");
|
|
|
|
|
expect(deletePlayoffMatchesByEventId).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
});
|