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
560 lines
18 KiB
TypeScript
560 lines
18 KiB
TypeScript
import { avatarColor } from "~/lib/avatar-colors";
|
|
import { BRACKT_GRADIENT } from "~/lib/brand";
|
|
import {
|
|
computeGroupLayout,
|
|
describeSlotSource,
|
|
matchKey,
|
|
type BracketLayout,
|
|
type FeederMap,
|
|
} from "~/lib/bracket-layout";
|
|
import type { BracketTemplate } from "~/lib/bracket-templates";
|
|
|
|
export interface BracketMatch {
|
|
id: string;
|
|
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?: { id: string; name: string } | null;
|
|
participant2?: { id: string; name: string } | null;
|
|
winner?: { id: string; name: string } | null;
|
|
loser?: { id: string; name: string } | null;
|
|
}
|
|
|
|
export interface BracketOwnership {
|
|
participantId: string;
|
|
teamName: string;
|
|
teamId: string;
|
|
ownerName?: string;
|
|
}
|
|
|
|
const COLUMN_WIDTH = 152;
|
|
const CONNECTOR_WIDTH = 24;
|
|
export const SLOT_WIDTH = 2 * COLUMN_WIDTH + CONNECTOR_WIDTH;
|
|
export const LABEL_HEIGHT = 32;
|
|
export const CARD_GAP = 14;
|
|
export const DESIRED_CARD_HEIGHT = 112;
|
|
export const MAX_CARD_HEIGHT = 140;
|
|
|
|
|
|
function formatScore(score: string | null): string | null {
|
|
if (!score) return null;
|
|
const n = parseFloat(score);
|
|
if (isNaN(n)) return null;
|
|
return Number.isInteger(n) ? String(n) : n.toFixed(1);
|
|
}
|
|
|
|
// ─── Match Slot ──────────────────────────────────────────────────────────────
|
|
|
|
interface ParticipantRowProps {
|
|
name: string | null;
|
|
/** What fills this slot when it's still empty, e.g. "Winner of Winners SF 2". */
|
|
feedLabel?: string | null;
|
|
isTbd: boolean;
|
|
isWinner: boolean;
|
|
isLoser: boolean;
|
|
isOwned: boolean;
|
|
ownership: BracketOwnership | null;
|
|
score: string | null;
|
|
rowHeight: number;
|
|
showScore: boolean;
|
|
showOwner: boolean;
|
|
showText: boolean;
|
|
}
|
|
|
|
function ParticipantRow({
|
|
name,
|
|
feedLabel,
|
|
isTbd,
|
|
isWinner,
|
|
isLoser,
|
|
isOwned,
|
|
ownership,
|
|
score,
|
|
rowHeight,
|
|
showScore,
|
|
showOwner,
|
|
showText,
|
|
}: ParticipantRowProps) {
|
|
const formattedScore = formatScore(score);
|
|
|
|
return (
|
|
<div
|
|
className="relative flex items-center overflow-hidden"
|
|
style={{ height: rowHeight }}
|
|
>
|
|
{showText && (
|
|
<div className={[
|
|
"flex items-center flex-1 min-w-0 gap-1.5 px-2 pl-[7px] ml-2",
|
|
isWinner && isOwned ? "border border-electric/50 bg-electric/8 rounded-md mr-2 my-0.5 self-stretch py-1" :
|
|
isWinner ? "border border-white/15 bg-white/5 rounded-md mr-2 my-0.5 self-stretch py-1" : "",
|
|
].filter(Boolean).join(" ")}>
|
|
{/* Manager avatar — always present for alignment; empty box when unowned */}
|
|
<div
|
|
className="shrink-0 rounded-[3px] flex items-center justify-center text-[8px] font-bold"
|
|
style={{
|
|
width: 18,
|
|
height: 18,
|
|
background: ownership ? "#000" : "transparent",
|
|
color: ownership ? avatarColor(ownership.teamName) : undefined,
|
|
}}
|
|
>
|
|
{ownership &&
|
|
ownership.teamName
|
|
.split(/\s+/)
|
|
.slice(0, 2)
|
|
.map((w) => w[0]?.toUpperCase() ?? "")
|
|
.join("")}
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<span
|
|
className={[
|
|
"text-[13px] leading-tight block truncate",
|
|
isTbd ? "text-muted-foreground/50 italic" : "",
|
|
isLoser && isOwned ? "text-electric/50 line-through" :
|
|
isLoser ? "text-muted-foreground line-through" : "",
|
|
isWinner ? "font-semibold" : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")}
|
|
>
|
|
{name ?? feedLabel ?? "TBD"}
|
|
</span>
|
|
|
|
{/* Owner name below participant name */}
|
|
{showOwner && !isTbd && ownership && (
|
|
<span className="text-[10px] text-muted-foreground truncate block leading-none">
|
|
{ownership.ownerName ?? ownership.teamName}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Score */}
|
|
{showScore && formattedScore && (
|
|
<span
|
|
className={[
|
|
"shrink-0 text-xs tabular-nums font-medium ml-auto pl-1",
|
|
isLoser ? "text-muted-foreground/60" : "",
|
|
isWinner ? "text-yellow-400" : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")}
|
|
>
|
|
{formattedScore}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface BracketMatchSlotProps {
|
|
match: BracketMatch;
|
|
slotHeight: number;
|
|
ownershipMap: Map<string, BracketOwnership>;
|
|
userParticipantIds: Set<string>;
|
|
feeders?: FeederMap;
|
|
template?: BracketTemplate;
|
|
}
|
|
|
|
export function BracketMatchSlot({
|
|
match,
|
|
slotHeight,
|
|
ownershipMap,
|
|
userParticipantIds,
|
|
feeders,
|
|
template,
|
|
}: BracketMatchSlotProps) {
|
|
const rowHeight = slotHeight / 2;
|
|
const showText = rowHeight >= 10;
|
|
const showScore = rowHeight >= 20 && (!!match.participant1Score || match.isComplete);
|
|
const showOwner = rowHeight >= 36;
|
|
|
|
const p1Id = match.participant1Id;
|
|
const p2Id = match.participant2Id;
|
|
const p1IsWinner = match.isComplete && match.winnerId === p1Id;
|
|
const p2IsWinner = match.isComplete && match.winnerId === p2Id;
|
|
const p1IsLoser = match.isComplete && match.loserId === p1Id;
|
|
const p2IsLoser = match.isComplete && match.loserId === p2Id;
|
|
const p1IsOwned = !!(p1Id && userParticipantIds.has(p1Id));
|
|
const p2IsOwned = !!(p2Id && userParticipantIds.has(p2Id));
|
|
const matchHasOwned = p1IsOwned || p2IsOwned;
|
|
const isTbd1 = !p1Id;
|
|
const isTbd2 = !p2Id;
|
|
|
|
const p1Ownership = p1Id ? ownershipMap.get(p1Id) ?? null : null;
|
|
const p2Ownership = p2Id ? ownershipMap.get(p2Id) ?? null : null;
|
|
|
|
// Corona glow: gradient for complete matches, electric for user's picks, subtle white for pending
|
|
const coronaStyle: React.CSSProperties = match.isComplete
|
|
? { background: BRACKT_GRADIENT }
|
|
: matchHasOwned
|
|
? { background: "rgba(44, 225, 193, 0.4)" }
|
|
: { background: "rgba(255, 255, 255, 0.07)" };
|
|
|
|
const INSET = Math.max(1, Math.min(2, Math.floor(slotHeight / 20)));
|
|
|
|
// An empty slot reads better as "Loser of Winners SF 2" than "TBD" — especially for
|
|
// the feeds that cross between the winners and elimination brackets, which render as
|
|
// separate trees and so can never be joined by a line.
|
|
const slotSources = feeders?.get(matchKey(match.round, match.matchNumber));
|
|
const feed1 = describeSlotSource(slotSources?.[0], template);
|
|
const feed2 = describeSlotSource(slotSources?.[1], template);
|
|
|
|
return (
|
|
<div className="relative overflow-hidden" style={{ height: slotHeight }}>
|
|
{/* Corona layer — left-2 matches card borderRadius to prevent gradient bleed at left corners */}
|
|
<div className="absolute left-2 right-0 rounded-lg" style={{ ...coronaStyle, top: INSET - 2, bottom: INSET - 2 }} />
|
|
|
|
{/* Inner card, inset on right to expose corona glow strip */}
|
|
<div
|
|
className="absolute flex flex-col overflow-hidden"
|
|
style={{
|
|
top: 0,
|
|
right: INSET + 2,
|
|
bottom: 0,
|
|
left: 0,
|
|
borderRadius: 8,
|
|
background: "var(--color-muted)",
|
|
paddingTop: INSET,
|
|
paddingBottom: INSET,
|
|
}}
|
|
>
|
|
<ParticipantRow
|
|
name={match.participant1?.name ?? null}
|
|
feedLabel={feed1}
|
|
isTbd={isTbd1}
|
|
isWinner={p1IsWinner}
|
|
isLoser={p1IsLoser}
|
|
isOwned={p1IsOwned}
|
|
ownership={p1Ownership}
|
|
score={match.participant1Score}
|
|
rowHeight={rowHeight - INSET}
|
|
showScore={showScore}
|
|
showOwner={showOwner}
|
|
showText={showText}
|
|
/>
|
|
<ParticipantRow
|
|
name={match.participant2?.name ?? null}
|
|
feedLabel={feed2}
|
|
isTbd={isTbd2}
|
|
isWinner={p2IsWinner}
|
|
isLoser={p2IsLoser}
|
|
isOwned={p2IsOwned}
|
|
ownership={p2Ownership}
|
|
score={match.participant2Score}
|
|
rowHeight={rowHeight - INSET}
|
|
showScore={showScore}
|
|
showOwner={showOwner}
|
|
showText={showText}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Connector column ─────────────────────────────────────────────────────────
|
|
|
|
interface ConnectorColumnProps {
|
|
/** Edges crossing this gutter, in slot units. */
|
|
edges: { fromCenter: number; toCenter: number }[];
|
|
rowHeight: number;
|
|
offset: number;
|
|
bracketHeight: number;
|
|
}
|
|
|
|
/**
|
|
* Draws the feeder edges crossing one gutter. Because the layout assigns columns by
|
|
* depth from the final, every edge spans exactly one gutter — so a card that enters the
|
|
* bracket late is drawn in the column where it actually plays, and there is never an
|
|
* edge to route across a skipped column.
|
|
*/
|
|
function ConnectorColumn({ edges, rowHeight, offset, bracketHeight }: ConnectorColumnProps) {
|
|
const mid = CONNECTOR_WIDTH / 2;
|
|
|
|
// Merge the two edges feeding one card into a single elbow, so a pair reads as one
|
|
// bracket join rather than two overlapping lines.
|
|
const byTarget = new Map<number, number[]>();
|
|
for (const { fromCenter, toCenter } of edges) {
|
|
const sources = byTarget.get(toCenter) ?? [];
|
|
sources.push(fromCenter);
|
|
byTarget.set(toCenter, sources);
|
|
}
|
|
|
|
const paths: string[] = [];
|
|
for (const [toCenter, sources] of byTarget) {
|
|
const destY = toCenter * rowHeight - offset;
|
|
const ys = sources.map((c) => c * rowHeight - offset).toSorted((a, b) => a - b);
|
|
if (ys.length === 1) {
|
|
paths.push(`M 0 ${ys[0]} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`);
|
|
continue;
|
|
}
|
|
paths.push(`M 0 ${ys[0]} H ${mid} V ${ys[ys.length - 1]} H 0`);
|
|
for (const y of ys.slice(1, -1)) paths.push(`M 0 ${y} H ${mid}`);
|
|
paths.push(`M ${mid} ${destY} H ${CONNECTOR_WIDTH}`);
|
|
}
|
|
|
|
return (
|
|
<div style={{ flex: `0 0 ${CONNECTOR_WIDTH}px`, position: "relative" }}>
|
|
<svg
|
|
style={{
|
|
position: "absolute",
|
|
top: LABEL_HEIGHT,
|
|
left: 0,
|
|
width: CONNECTOR_WIDTH,
|
|
height: bracketHeight,
|
|
overflow: "visible",
|
|
pointerEvents: "none",
|
|
}}
|
|
>
|
|
{paths.map((d) => (
|
|
<path key={d} d={d} fill="none" stroke="rgb(255 255 255 / 22%)" strokeWidth={1.5} />
|
|
))}
|
|
</svg>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Tree columns (shared by full + paginated) ───────────────────────────────
|
|
|
|
export interface BracketGeometry {
|
|
layout: BracketLayout<BracketMatch>;
|
|
/** Height of one leaf row. */
|
|
rowHeight: number;
|
|
/** Height of the card area, excluding the round labels. */
|
|
bracketHeight: number;
|
|
/** Narrowest the columns and gutters can be drawn without overlapping. */
|
|
minWidth: number;
|
|
/** Pixels trimmed off the top, non-zero only for a cropped column window. */
|
|
offset: number;
|
|
}
|
|
|
|
/**
|
|
* Lay out a group's matches from the feeder graph and derive its pixel geometry.
|
|
*
|
|
* Height comes from the number of leaf rows rather than the largest round, so a bracket
|
|
* whose widest column isn't its first still gets the room it needs.
|
|
*/
|
|
export function bracketGeometry(
|
|
visibleRounds: string[],
|
|
matchesByRound: Map<string, BracketMatch[]>,
|
|
feeders: FeederMap | undefined,
|
|
templateRoundOrder: string[]
|
|
): BracketGeometry {
|
|
const layout = computeGroupLayout(
|
|
visibleRounds,
|
|
matchesByRound,
|
|
feeders ?? new Map(),
|
|
templateRoundOrder
|
|
);
|
|
const rowHeight = DESIRED_CARD_HEIGHT + CARD_GAP;
|
|
const columnCount = Math.max(layout.columns.length, 1);
|
|
return {
|
|
layout,
|
|
rowHeight,
|
|
bracketHeight: Math.max(layout.leafCount, 1) * rowHeight,
|
|
minWidth: columnCount * COLUMN_WIDTH + (columnCount - 1) * CONNECTOR_WIDTH,
|
|
offset: 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Crop a layout to a window of columns, as the mobile pager does.
|
|
*
|
|
* Card positions are absolute within the whole bracket, so showing a slice of columns
|
|
* means trimming the empty space above them rather than re-flowing — otherwise a later
|
|
* page would render its two columns stranded at the bottom of a full-height bracket.
|
|
*/
|
|
export function windowGeometry(
|
|
geometry: BracketGeometry,
|
|
firstColumn: number,
|
|
lastColumn: number
|
|
): BracketGeometry {
|
|
const centers = geometry.layout.columns
|
|
.slice(firstColumn, lastColumn + 1)
|
|
.flatMap((c) => c.matches.map((m) => m.center));
|
|
if (centers.length === 0) return geometry;
|
|
|
|
const min = Math.min(...centers);
|
|
const max = Math.max(...centers);
|
|
return {
|
|
...geometry,
|
|
bracketHeight: (max - min + 1) * geometry.rowHeight,
|
|
offset: (min - 0.5) * geometry.rowHeight,
|
|
};
|
|
}
|
|
|
|
interface TreeColumnsProps {
|
|
geometry: BracketGeometry;
|
|
ownershipMap: Map<string, BracketOwnership>;
|
|
userParticipantIds: Set<string>;
|
|
transitionDuration?: number;
|
|
feeders?: FeederMap;
|
|
template?: BracketTemplate;
|
|
/** Restrict rendering to a window of columns (used by the mobile pager). */
|
|
columnRange?: [number, number];
|
|
}
|
|
|
|
export function TreeColumns({
|
|
geometry,
|
|
ownershipMap,
|
|
userParticipantIds,
|
|
transitionDuration,
|
|
feeders,
|
|
template,
|
|
columnRange,
|
|
}: TreeColumnsProps) {
|
|
const tr = transitionDuration ? `${transitionDuration}ms ease` : undefined;
|
|
const { layout, rowHeight, bracketHeight, offset } = geometry;
|
|
|
|
const [firstColumn, lastColumn] = columnRange ?? [0, layout.columns.length - 1];
|
|
const visible = layout.columns.slice(firstColumn, lastColumn + 1);
|
|
|
|
// Cards keep a fixed height regardless of how many share a column — stretching a
|
|
// lone final to fill its column is what made it tower over the rest of the bracket.
|
|
const cardHeight = Math.min(
|
|
Math.max(rowHeight - CARD_GAP, 1),
|
|
MAX_CARD_HEIGHT
|
|
);
|
|
|
|
return (
|
|
<div style={{ display: "flex", width: "100%", height: bracketHeight + LABEL_HEIGHT, transition: tr ? `height ${tr}` : undefined }}>
|
|
{visible.map((column, vi) => {
|
|
const ci = firstColumn + vi;
|
|
const gutterEdges = layout.edges.filter((e) => e.fromColumn === ci);
|
|
|
|
return (
|
|
<div key={column.label + ci} style={{ display: "contents" }}>
|
|
{/* Round column */}
|
|
<div style={{ flex: "1 1 0", minWidth: COLUMN_WIDTH, position: "relative" }}>
|
|
<div
|
|
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground truncate text-center"
|
|
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
|
|
>
|
|
{column.label}
|
|
</div>
|
|
|
|
<div style={{ position: "relative", height: bracketHeight, transition: tr ? `height ${tr}` : undefined }}>
|
|
{column.matches.map(({ match, center }) => (
|
|
<div
|
|
key={match.id}
|
|
data-match-id={match.id}
|
|
style={{
|
|
position: "absolute",
|
|
top: center * rowHeight - offset - cardHeight / 2,
|
|
left: 0,
|
|
right: 0,
|
|
height: cardHeight,
|
|
transition: tr ? `top ${tr}, height ${tr}` : undefined,
|
|
}}
|
|
>
|
|
<BracketMatchSlot
|
|
match={match}
|
|
slotHeight={cardHeight}
|
|
ownershipMap={ownershipMap}
|
|
userParticipantIds={userParticipantIds}
|
|
feeders={feeders}
|
|
template={template}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Connector between this column and the next */}
|
|
{vi < visible.length - 1 && (
|
|
<ConnectorColumn
|
|
edges={gutterEdges}
|
|
rowHeight={rowHeight}
|
|
offset={offset}
|
|
bracketHeight={bracketHeight}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Full desktop tree ────────────────────────────────────────────────────────
|
|
|
|
interface BracketTreeViewProps {
|
|
rounds: string[];
|
|
matchesByRound: Map<string, BracketMatch[]>;
|
|
ownershipMap: Map<string, BracketOwnership>;
|
|
userParticipantIds: Set<string>;
|
|
thirdPlaceRound?: string;
|
|
feeders?: FeederMap;
|
|
template?: BracketTemplate;
|
|
}
|
|
|
|
export function BracketTreeView({
|
|
rounds,
|
|
matchesByRound,
|
|
ownershipMap,
|
|
userParticipantIds,
|
|
thirdPlaceRound,
|
|
feeders,
|
|
template,
|
|
}: BracketTreeViewProps) {
|
|
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
|
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
|
|
|
|
const geometry = bracketGeometry(
|
|
mainRounds,
|
|
matchesByRound,
|
|
feeders,
|
|
template?.rounds.map((r) => r.name) ?? mainRounds
|
|
);
|
|
const { bracketHeight, minWidth } = geometry;
|
|
|
|
return (
|
|
<div
|
|
className="w-full overflow-x-auto"
|
|
style={{ minHeight: bracketHeight + LABEL_HEIGHT + 2 }}
|
|
>
|
|
<div style={{ minWidth }}>
|
|
<TreeColumns
|
|
geometry={geometry}
|
|
ownershipMap={ownershipMap}
|
|
userParticipantIds={userParticipantIds}
|
|
feeders={feeders}
|
|
template={template}
|
|
/>
|
|
{thirdPlaceMatch && (
|
|
<div style={{ display: "flex", paddingTop: 20 }}>
|
|
<div style={{ flex: 1 }} />
|
|
<div style={{ minWidth: COLUMN_WIDTH }}>
|
|
<div
|
|
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground text-center"
|
|
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
|
|
>
|
|
3rd Place
|
|
</div>
|
|
<div style={{ height: Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT) }}>
|
|
<BracketMatchSlot
|
|
match={thirdPlaceMatch}
|
|
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
|
ownershipMap={ownershipMap}
|
|
userParticipantIds={userParticipantIds}
|
|
feeders={feeders}
|
|
template={template}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|