brackt/app/components/scoring/PlayoffBracket.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

628 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { Trophy, Star } from "lucide-react";
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
import { GradientIcon } from "~/components/ui/GradientIcon";
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";
interface Participant {
id: string;
name: string;
}
export interface Match {
id: string;
round: string;
matchNumber: number;
participant1Id: string | null;
participant2Id: string | null;
winnerId: string | null;
loserId: string | null;
isComplete: boolean;
participant1Score: string | null;
participant2Score: string | null;
isScoring?: boolean;
participant1?: Participant | null;
participant2?: Participant | null;
winner?: Participant | null;
loser?: Participant | null;
}
export interface TeamOwnership {
participantId: string;
teamName: string;
teamId: string;
ownerName?: string;
}
interface PlayoffBracketProps {
matches: Match[];
rounds: string[]; // Ordered list of round names (earliest first)
bracketTemplateId?: string | null;
preEliminatedParticipants?: { id: string; name: string }[];
participantPoints?: { participantId: string; points: number }[];
partialScoreParticipantIds?: string[];
teamOwnerships?: TeamOwnership[];
userParticipantIds?: string[];
showOwnership?: boolean;
title?: string;
description?: string;
/** "bracket" = full bracket + tables (default); "rankings" = final rankings table only */
mode?: "bracket" | "rankings";
}
/** Group matches by round, sorted by matchNumber, in one pass. */
export function groupMatchesByRound(matches: Match[]): Map<string, Match[]> {
const byRound = new Map<string, Match[]>();
for (const match of matches) {
if (!byRound.has(match.round)) byRound.set(match.round, []);
byRound.get(match.round)?.push(match);
}
for (const group of byRound.values()) {
group.sort((a, b) => a.matchNumber - b.matchNumber);
}
return byRound;
}
interface EliminatedEntry {
participant: Participant;
score: string | null;
rankLabel: string;
ownership: TeamOwnership | null;
}
/**
* For each complete match, determine which losers should be shown as eliminated
* in that round. In double-chance brackets (e.g. AFL), a participant who lost
* round X but then won a later round Y is NOT eliminated at round X — only their
* final loss counts.
*
* Returns a map of round name → array of eliminated participant IDs.
* Exported for unit testing.
*/
export function computeEliminatedByRound(
matches: Array<{
round: string;
isComplete: boolean;
winnerId: string | null;
loser?: { id: string; name: string } | null;
participant1Id?: string | null;
participant2Id?: string | null;
}>,
rounds: string[]
): Map<string, string[]> {
const roundIndex = new Map(rounds.map((r, i) => [r, i]));
// Track the latest round (by index) each participant has APPEARED IN.
// We track winnerId/loserId for completed matches AND participant1Id/participant2Id
// for ALL matches (even incomplete ones). This correctly handles:
// - AFL double-chance: a QF loser who appears in a later Semi-Finals match
// - NBA play-in: a 7v8 loser placed into a PIR2 slot (match not yet complete)
// If a participant appears in a later round slot, they are not yet eliminated.
const participantLatestRound = new Map<string, number>();
const updateLatest = (id: string, ri: number) => {
const prev = participantLatestRound.get(id) ?? -1;
if (ri > prev) participantLatestRound.set(id, ri);
};
for (const match of matches) {
const ri = roundIndex.get(match.round) ?? -1;
if (match.winnerId) updateLatest(match.winnerId, ri);
if (match.loser?.id) updateLatest(match.loser.id, ri);
if (match.participant1Id) updateLatest(match.participant1Id, ri);
if (match.participant2Id) updateLatest(match.participant2Id, ri);
}
const result = new Map<string, string[]>();
for (const match of matches) {
if (!match.isComplete || !match.loser) continue;
const lossRoundIndex = roundIndex.get(match.round) ?? -1;
const loserLatestAppear = participantLatestRound.get(match.loser.id) ?? -1;
// Double-chance survivor: participant appeared in a LATER round — not yet eliminated here.
if (loserLatestAppear > lossRoundIndex) continue;
if (!result.has(match.round)) result.set(match.round, []);
result.get(match.round)?.push(match.loser.id);
}
return result;
}
/** The score recorded for one participant in a match, or null if they didn't play in it. */
function participantScore(match: Match, participantId: string | null): string | null {
if (!participantId) return null;
if (participantId === match.participant1Id) return match.participant1Score;
if (participantId === match.participant2Id) return match.participant2Score;
return null;
}
/**
* A consolation final: a round contested by the losers of an earlier round, which
* splits the positions those losers would otherwise share. FIFA's "Third Place Game"
* (fed by the Semifinals) is the only one in the templates today.
*/
export interface ConsolationRound {
/** The consolation round itself, e.g. "Third Place Game". */
round: string;
/** The round whose losers contest it, e.g. "Semifinals". */
feederRound: string;
}
/**
* Find the template's consolation round, if it has one.
*
* A consolation round must be TERMINAL — its winner plays no further game, which is
* what lets its result split two exact positions. `loserFeedsInto` alone is not
* enough: a double-elimination bracket (llws_20) uses it on every winners-bracket
* round to route losers into the elimination bracket, and those targets are ordinary
* rounds that feed onward. Picking the first `loserFeedsInto` there would mistake
* "Elimination Round 1" for a third-place game and corrupt the final rankings.
*
* Exported for unit testing.
*/
export function findConsolationRound(
template: BracketTemplate | undefined
): ConsolationRound | undefined {
const isTerminal = (roundName: string) =>
template?.rounds.find((r) => r.name === roundName)?.feedsInto === null;
const feeder = template?.rounds.find(
(r) => r.loserFeedsInto && isTerminal(r.loserFeedsInto)
);
if (!feeder?.loserFeedsInto) return undefined;
return { round: feeder.loserFeedsInto, feederRound: feeder.name };
}
/**
* Round names whose losers are placed by some LATER round rather than finishing where
* they lost — i.e. double-elimination winners-bracket rounds, whose losers drop into
* the elimination bracket.
*
* The consolation feeder is deliberately excluded: its losers do finish at that tier
* (the consolation game splits their two positions), so it still consumes them.
*
* Exported for unit testing.
*/
export function roundsWithLosersPlacedLater(
template: BracketTemplate | undefined,
consolation: ConsolationRound | undefined
): Set<string> {
return new Set(
(template?.rounds ?? [])
.filter((r) => r.loserFeedsInto && r.name !== consolation?.feederRound)
.map((r) => r.name)
);
}
/**
* Build the ordered final-rankings list from completed matches.
*
* Ranks are derived by walking rounds latest-first: the final's loser is 2nd, the
* previous round's losers share the next tier, and so on — each round consuming as
* many positions as it has matches.
*
* A consolation round needs different handling, because its winner never loses a
* match and so the loser-driven walk above would leave them unranked and "in
* contention" forever. Its two places are exactly the top of the tier its feeder
* round's losers would otherwise share, so it is resolved *at the feeder round* —
* the winner takes that tier's first position and the loser the second — and the
* consolation round itself consumes no positions. Positions are derived rather than
* hardcoded, so a consolation round hanging off a different feeder still lands right.
*
* Exported for unit testing.
*/
export function computeRankedEntries(
matches: Match[],
rounds: string[],
matchesByRound: Map<string, Match[]>,
consolation: ConsolationRound | undefined,
ownershipMap: Map<string, TeamOwnership>,
/** See roundsWithLosersPlacedLater. Empty for single-elimination brackets. */
losersPlacedLater: Set<string> = new Set()
): EliminatedEntry[] {
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
// Only take the consolation path when both rounds actually have matches; otherwise
// fall through to the loser-driven walk so nothing is dropped.
const consolationActive =
consolation &&
rounds.includes(consolation.round) &&
rounds.includes(consolation.feederRound);
// Consolation matches we can place exactly. Anything else in that round (still in
// progress, or missing its hydrated winner/loser) deliberately stays eligible for
// the loser-driven walk rather than being silently dropped.
const consolationMatches =
consolationActive && consolation
? (matchesByRound.get(consolation.round) ?? []).filter(
(m): m is Match & { winner: Participant; loser: Participant } =>
m.isComplete && !!m.winner && !!m.loser
)
: [];
const exactlyPlacedMatchIds = new Set(consolationMatches.map((m) => m.id));
const entryFor = (
match: Match,
participant: Participant,
participantId: string | null
): Omit<EliminatedEntry, "rankLabel"> => ({
participant,
score: participantScore(match, participantId),
ownership: ownershipMap.get(participant.id) || null,
});
const losersByRound = new Map<string, Omit<EliminatedEntry, "rankLabel">[]>();
for (const match of matches) {
if (!match.isComplete || !match.loser) continue;
if (exactlyPlacedMatchIds.has(match.id)) continue;
if (!eliminatedByRound.get(match.round)?.includes(match.loser.id)) continue;
if (!losersByRound.has(match.round)) losersByRound.set(match.round, []);
losersByRound.get(match.round)?.push(entryFor(match, match.loser, match.loserId));
}
const rankedEntries: EliminatedEntry[] = [];
let nextRank = 2;
for (let ri = rounds.length - 1; ri >= 0; ri--) {
const roundName = rounds[ri];
// The consolation match splits the top of its feeder round's tier, so it is
// placed first and the round's remaining losers share what's left below it.
let tierRank = nextRank;
if (consolationActive && roundName === consolation?.feederRound) {
for (const match of consolationMatches) {
rankedEntries.push({
...entryFor(match, match.winner, match.winnerId),
rankLabel: `${tierRank}`,
});
rankedEntries.push({
...entryFor(match, match.loser, match.loserId),
rankLabel: `${tierRank + 1}`,
});
tierRank += 2;
}
}
for (const loser of losersByRound.get(roundName) ?? []) {
rankedEntries.push({ ...loser, rankLabel: `T${tierRank}` });
}
// The consolation round's places belong to its feeder round's tier, so it
// consumes none of its own.
if (consolationActive && roundName === consolation?.round) continue;
// A round normally consumes one position per match — its losers finish here,
// whether or not the games have been played yet (four semifinalists occupy 14
// regardless). But in a double-elimination bracket a winners-bracket loss places
// nobody: the loser drops into the elimination bracket and is ranked by whatever
// knocks them out later. Those rounds must consume nothing, or every position
// below inflates (a 20-team llws_20 bracket would end at "T23").
if (losersPlacedLater.has(roundName)) continue;
nextRank += matchesByRound.get(roundName)?.length ?? 0;
}
return rankedEntries;
}
/** Find the index of the first round that has scoring matches. */
function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number {
for (let i = 0; i < rounds.length; i++) {
if (matchesByRound.get(rounds[i])?.some((m) => m.isScoring)) return i;
}
return 0;
}
const RANKING_ROW_CLASSES: Record<number, string> = {
1: "bg-yellow-500/10",
2: "bg-white/[0.14]",
3: "bg-orange-600/[0.08]",
};
function rankingRowCls(currentRank: number | undefined): string {
const podium = currentRank !== undefined ? RANKING_ROW_CLASSES[currentRank] : undefined;
return `flex flex-col sm:flex-row sm:items-center gap-0 rounded-lg px-3 py-3 sm:px-5 sm:py-4 transition-colors ${podium ?? "bg-white/[0.04]"}`;
}
export function PlayoffBracket({
matches,
rounds,
bracketTemplateId = null,
preEliminatedParticipants = [],
participantPoints = [],
partialScoreParticipantIds: _partialScoreParticipantIds = [],
teamOwnerships = [],
userParticipantIds = [],
showOwnership = true,
title = "Playoff Bracket",
description,
mode = "bracket",
}: PlayoffBracketProps) {
const userParticipantSet = new Set(userParticipantIds);
const ownershipMap = new Map<string, TeamOwnership>();
teamOwnerships.forEach((o) => ownershipMap.set(o.participantId, o));
const pointsMap = new Map(participantPoints.map((p) => [p.participantId, p.points]));
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;
// Build elimination rankings
let bracketWinner: Participant | null = null;
const lastRound = rounds[rounds.length - 1];
const finalMatch = lastRound
? (matchesByRound.get(lastRound) || []).find((m) => m.matchNumber === 1)
: null;
if (finalMatch?.winner) bracketWinner = finalMatch.winner;
const allBracketParticipantIds = new Set<string>();
for (const match of matches) {
if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id);
if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id);
}
const rankedEntries = computeRankedEntries(
matches,
rounds,
matchesByRound,
consolation,
ownershipMap,
roundsWithLosersPlacedLater(template, consolation)
);
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
if (bracketWinner) rankedParticipantIds.add(bracketWinner.id);
const filteredPreEliminated = preEliminatedParticipants.filter(
(p) => !rankedParticipantIds.has(p.id)
);
const showRankings = rankedEntries.length > 0 || bracketWinner !== null || filteredPreEliminated.length > 0;
const participantMap = new Map<string, Participant>();
for (const match of matches) {
if (match.participant1) participantMap.set(match.participant1.id, match.participant1);
if (match.participant2) participantMap.set(match.participant2.id, match.participant2);
}
const activeParticipants = [...allBracketParticipantIds]
.filter((id) => !rankedParticipantIds.has(id))
.map((id) => participantMap.get(id))
.filter((p): p is Participant => p !== undefined)
// Owned-by-a-manager players first, then alphabetical by name.
.toSorted((a, b) => {
const aOwned = ownershipMap.has(a.id);
const bOwned = ownershipMap.has(b.id);
if (aOwned !== bOwned) return aOwned ? -1 : 1;
return a.name.localeCompare(b.name);
});
const isDraftedOrScoring = (participantId: string) =>
ownershipMap.has(participantId) || (pointsMap.get(participantId) ?? 0) > 0;
const winnerIsOwned = bracketWinner ? userParticipantSet.has(bracketWinner.id) : false;
const winnerOwnership = bracketWinner ? ownershipMap.get(bracketWinner.id) : undefined;
const winnerPts = bracketWinner ? pointsMap.get(bracketWinner.id) : undefined;
return (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-xl font-semibold">{title}</h2>
{description && (
<p className="text-sm text-muted-foreground mt-1">{description}</p>
)}
</div>
{matches.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm">No bracket matches available yet.</p>
<p className="text-xs mt-1">
Matches will appear here once the bracket is set up.
</p>
</div>
) : (
<div className="space-y-8">
{mode === "bracket" && (template?.phases ? (
<TabbedBracketLayout
rounds={rounds}
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet}
phases={template.phases}
scoringRoundIdx={scoringRoundIdx}
feeders={feeders}
template={template}
/>
) : template?.conferenceGroups ? (
<NbaBracketLayout
matches={matches}
rounds={rounds}
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet}
conferenceGroups={template.conferenceGroups}
scoringRoundIdx={scoringRoundIdx}
feeders={feeders}
template={template}
/>
) : (
<>
{/* ── Desktop ── */}
<div className="hidden md:block">
<BracketTreeView
rounds={rounds}
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet}
thirdPlaceRound={thirdPlaceRound}
feeders={feeders}
template={template}
/>
</div>
{/* ── Mobile ── */}
<div className="md:hidden">
<BracketTreePaginated
rounds={rounds}
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet}
firstScoringRoundIdx={scoringRoundIdx}
thirdPlaceRound={thirdPlaceRound}
feeders={feeders}
template={template}
/>
</div>
</>
))}
{/* ── In Contention ── */}
{mode === "bracket" && activeParticipants.length > 0 && (
<Card className="border-green-500/30">
<CardHeader>
<CardTitle className="text-green-600 dark:text-green-400 flex items-center gap-2">
<Star className="h-5 w-5" />
In Contention
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Participant</TableHead>
{showOwnership && (
<TableHead className="w-40 pl-6">Drafted By</TableHead>
)}
{pointsMap.size > 0 && (
<TableHead className="text-right w-16">Pts</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{activeParticipants.map((p) => {
const isOwned = userParticipantSet.has(p.id);
const ownership = showOwnership ? ownershipMap.get(p.id) || null : null;
const pts = pointsMap.get(p.id);
return (
<TableRow
key={p.id}
className={isOwned ? "bg-electric/5 border-l-2 border-l-electric" : ""}
>
<TableCell className={isOwned ? "text-electric font-medium" : ""}>
{p.name}
{isOwned && (
<Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />
)}
</TableCell>
{showOwnership && (
<TableCell className="pl-6">
{ownership ? (
<TeamOwnerBadge teamName={ownership.teamName} ownerName={ownership.ownerName} />
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
)}
{pointsMap.size > 0 && (
<TableCell className="text-right font-mono text-sm">
{pts !== undefined ? pts : ""}
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* ── Final Rankings ── */}
{mode === "rankings" && showRankings && (
<Card className="gap-2">
<CardHeader className="px-3 sm:px-6 pb-2">
<div className="flex items-center gap-2">
<GradientIcon icon={Trophy} className="h-5 w-5 shrink-0" />
<h2 className="text-xl font-bold leading-none tracking-tight">
{bracketWinner ? "Final Rankings" : "Eliminated Teams"}
</h2>
</div>
</CardHeader>
<CardContent className="px-3 sm:px-6">
<div className="space-y-3">
{bracketWinner && (
<RankingsRow
teamId={winnerOwnership?.teamId ?? bracketWinner.id}
teamName={winnerOwnership?.teamName ?? bracketWinner.name}
participantName={bracketWinner.name}
ownerName={winnerOwnership?.ownerName}
isOwned={winnerIsOwned}
rankLabel={1}
points={winnerPts}
showPoints={pointsMap.size > 0}
rowClass={rankingRowCls(1)}
/>
)}
{rankedEntries.filter((entry) => isDraftedOrScoring(entry.participant.id)).map((entry) => {
const isOwned = userParticipantSet.has(entry.participant.id);
const pts = pointsMap.get(entry.participant.id);
const numRank = parseInt(entry.rankLabel.replace("T", ""), 10);
return (
<RankingsRow
key={entry.participant.id}
teamId={entry.ownership?.teamId ?? entry.participant.id}
teamName={entry.ownership?.teamName ?? entry.participant.name}
participantName={entry.participant.name}
ownerName={entry.ownership?.ownerName}
isOwned={isOwned}
rankLabel={entry.rankLabel}
points={pts}
showPoints={pointsMap.size > 0}
rowClass={rankingRowCls(numRank)}
/>
);
})}
{filteredPreEliminated.filter((p) => isDraftedOrScoring(p.id)).map((p) => {
const isOwned = userParticipantSet.has(p.id);
const ownership = ownershipMap.get(p.id);
const pts = pointsMap.get(p.id);
return (
<RankingsRow
key={p.id}
teamId={ownership?.teamId ?? p.id}
teamName={ownership?.teamName ?? p.name}
participantName={p.name}
ownerName={ownership?.ownerName}
isOwned={isOwned}
points={pts}
showPoints={pointsMap.size > 0}
rowClass={rankingRowCls(undefined)}
/>
);
})}
</div>
</CardContent>
</Card>
)}
</div>
)}
</div>
);
}