brackt/app/components/scoring/TabbedBracketLayout.tsx
Claude 89ceee432a
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

302 lines
11 KiB
TypeScript

import { cn } from "~/lib/utils";
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[]>;
ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>;
phases: BracketPhase[];
scoringRoundIdx: number;
feeders?: FeederMap;
template?: BracketTemplate;
}
function groupMatches(
matchesByRound: Map<string, BracketMatch[]>,
group: ConferenceGroup
): Map<string, BracketMatch[]> {
const out = new Map<string, BracketMatch[]>();
for (const [round, allowed] of Object.entries(group.roundMatchNumbers)) {
const filtered = (matchesByRound.get(round) ?? []).filter((m) =>
allowed.includes(m.matchNumber)
);
if (filtered.length > 0) out.set(round, filtered);
}
return out;
}
// ─── Play-In Layout ───────────────────────────────────────────────────────────
interface PlayInColumnProps {
label: string;
description: string;
match: BracketMatch | undefined;
ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>;
}
function PlayInColumn({
label,
description,
match,
ownershipMap,
userParticipantIds,
}: PlayInColumnProps) {
return (
<div>
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground text-center mb-2">
{label}
</p>
{match ? (
<BracketMatchSlot
match={match}
slotHeight={CARD_H}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
/>
) : (
<div style={{ height: CARD_H }} />
)}
<p className="text-[10px] text-muted-foreground text-center mt-2">{description}</p>
</div>
);
}
interface PlayInLayoutProps {
matchesByRound: Map<string, BracketMatch[]>;
ownershipMap: Map<string, BracketOwnership>;
userParticipantIds: Set<string>;
}
function PlayInLayout({ matchesByRound, ownershipMap, userParticipantIds }: PlayInLayoutProps) {
const pir1 = matchesByRound.get("Play-In Round 1") ?? [];
const pir2 = matchesByRound.get("Play-In Round 2") ?? [];
const conferences = [
{
name: "Eastern Conference",
match78: pir1.find((m) => m.matchNumber === 1),
match910: pir1.find((m) => m.matchNumber === 2),
matchFor8: pir2.find((m) => m.matchNumber === 1),
},
{
name: "Western Conference",
match78: pir1.find((m) => m.matchNumber === 3),
match910: pir1.find((m) => m.matchNumber === 4),
matchFor8: pir2.find((m) => m.matchNumber === 2),
},
];
return (
<div className="space-y-6">
{conferences.map((conf) => (
<div key={conf.name}>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">
{conf.name}
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<PlayInColumn
label="7 VS 8"
description="Winner → 7 seed · Loser plays again"
match={conf.match78}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
/>
<PlayInColumn
label="9 VS 10"
description="Winner plays again · Loser eliminated"
match={conf.match910}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
/>
<PlayInColumn
label="For 8 Seed"
description="Winner → 8 seed · Loser eliminated"
match={conf.matchFor8}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
/>
</div>
</div>
))}
</div>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
export function TabbedBracketLayout({
rounds,
matchesByRound,
ownershipMap,
userParticipantIds,
phases,
scoringRoundIdx,
feeders,
template,
}: TabbedBracketLayoutProps) {
const roundOrder = template?.rounds.map((r) => r.name) ?? rounds;
return (
<div className="space-y-10">
{phases.map((phase) => {
const groupRounds = phase.groups
? rounds.filter((r) => phase.groups?.some((g) => g.roundMatchNumbers[r] !== undefined))
: [];
const sharedRounds = (phase.sharedRounds ?? []).filter((r) => rounds.includes(r));
const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r));
const phaseRounds = phase.groups ? [...groupRounds, ...sharedRounds] : simpleRounds;
// Restrict each round to the match numbers this phase's groups actually claim.
// Rounds can be shared across phases (LLWS runs U.S. and International through
// the same rounds), so without this the mobile view would merge both sides into
// one column. No-op where a phase's groups already cover every match in the
// round (NCAA regions, NBA conferences) and for sharedRounds, which have no
// group filter.
const phaseMatchesByRound = new Map(
phaseRounds.map((r) => {
const all = matchesByRound.get(r) ?? [];
if (!phase.groups || sharedRounds.includes(r)) return [r, all] as const;
const allowed = new Set(
phase.groups.flatMap((g) => g.roundMatchNumbers[r] ?? [])
);
return [r, allowed.size > 0 ? all.filter((m) => allowed.has(m.matchNumber)) : all] as const;
})
);
const sharedMatchesByRound = new Map(sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
const phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx);
return (
<div key={phase.name}>
<p className={cn(
"text-sm font-semibold uppercase tracking-wider mb-4",
phases.length > 1 ? "text-muted-foreground" : "hidden"
)}>
{phase.name}
</p>
{/* Desktop */}
<div className="hidden md:block">
{phase.layout === "play-in" ? (
<PlayInLayout
matchesByRound={phaseMatchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
/>
) : phase.groups ? (
<div className="space-y-8">
{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
geometry={geometry}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
/>
</div>
</div>
</div>
);
})}
{sharedRounds.length > 0 && (
<TreeColumns
geometry={bracketGeometry(sharedRounds, sharedMatchesByRound, feeders, roundOrder)}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
/>
)}
</div>
) : (
<TreeColumns
geometry={bracketGeometry(phaseRounds, phaseMatchesByRound, feeders, roundOrder)}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
feeders={feeders}
template={template}
/>
)}
</div>
{/* 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}
matchesByRound={phaseMatchesByRound}
ownershipMap={ownershipMap}
userParticipantIds={userParticipantIds}
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
feeders={feeders}
template={template}
/>
)}
</div>
</div>
);
})}
</div>
);
}