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 (
{showText && (
{/* Manager avatar — always present for alignment; empty box when unowned */}
{ownership &&
ownership.teamName
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase() ?? "")
.join("")}
{name ?? feedLabel ?? "TBD"}
{/* Owner name below participant name */}
{showOwner && !isTbd && ownership && (
{ownership.ownerName ?? ownership.teamName}
)}
{/* Score */}
{showScore && formattedScore && (
{formattedScore}
)}
)}
);
}
interface BracketMatchSlotProps {
match: BracketMatch;
slotHeight: number;
ownershipMap: Map;
userParticipantIds: Set;
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 (
{/* Corona layer — left-2 matches card borderRadius to prevent gradient bleed at left corners */}
{/* Inner card, inset on right to expose corona glow strip */}
);
}
// ─── 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();
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 (
);
}
// ─── Tree columns (shared by full + paginated) ───────────────────────────────
export interface BracketGeometry {
layout: BracketLayout;
/** 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,
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;
userParticipantIds: Set;
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 (
{visible.map((column, vi) => {
const ci = firstColumn + vi;
const gutterEdges = layout.edges.filter((e) => e.fromColumn === ci);
return (
{/* Round column */}
{column.label}
{column.matches.map(({ match, center }) => (
))}
{/* Connector between this column and the next */}
{vi < visible.length - 1 && (
)}
);
})}
);
}
// ─── Full desktop tree ────────────────────────────────────────────────────────
interface BracketTreeViewProps {
rounds: string[];
matchesByRound: Map;
ownershipMap: Map;
userParticipantIds: Set;
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 (
);
}