import { avatarColor } from "~/lib/avatar-colors"; import { BRACKT_GRADIENT } from "~/lib/brand"; 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; 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, 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 ?? "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; } export function BracketMatchSlot({ match, slotHeight, ownershipMap, userParticipantIds, }: 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))); 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 */}
); } // ─── Per-pair connector column ──────────────────────────────────────────────── interface ConnectorColumnProps { currentMatches: BracketMatch[]; nextMatches: BracketMatch[]; bracketHeight: number; } function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: ConnectorColumnProps) { const mid = CONNECTOR_WIDTH / 2; const paths: string[] = []; const currentSlotH = bracketHeight / Math.max(currentMatches.length, 1); const nextSlotH = bracketHeight / Math.max(nextMatches.length, 1); // Use halving U-shapes only when prev > 1 (avoids false-positive 1→1 side branches like 3PG→Finals) if (nextMatches.length === Math.ceil(currentMatches.length / 2) && currentMatches.length > 1) { // Standard single-elimination halving: U-shape connectors for (let k = 0; k < nextMatches.length; k++) { const topY = (2 * k) * currentSlotH + currentSlotH / 2; const midY = k * nextSlotH + nextSlotH / 2; const botIdx = 2 * k + 1; if (botIdx < currentMatches.length) { const botY = botIdx * currentSlotH + currentSlotH / 2; paths.push(`M 0 ${topY} H ${mid} V ${botY} H 0`); paths.push(`M ${mid} ${midY} H ${CONNECTOR_WIDTH}`); } else { paths.push(`M 0 ${topY} H ${CONNECTOR_WIDTH}`); } } } else { // Non-standard (byes, play-ins, etc.): trace winners by participantId const winnerToIdx = new Map(); currentMatches.forEach((m, idx) => { if (m.winnerId) winnerToIdx.set(m.winnerId, idx); }); nextMatches.forEach((nextMatch, nextIdx) => { const destY = nextIdx * nextSlotH + nextSlotH / 2; for (const pId of [nextMatch.participant1Id, nextMatch.participant2Id]) { if (!pId) continue; const srcIdx = winnerToIdx.get(pId); if (srcIdx === undefined) continue; const srcY = srcIdx * currentSlotH + currentSlotH / 2; paths.push(`M 0 ${srcY} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`); } }); } return (
{paths.map((d) => ( ))}
); } // ─── Tree columns (shared by full + paginated) ─────────────────────────────── interface TreeColumnsProps { visibleRounds: string[]; matchesByRound: Map; ownershipMap: Map; userParticipantIds: Set; bracketHeight: number; transitionDuration?: number; } export function TreeColumns({ visibleRounds, matchesByRound, ownershipMap, userParticipantIds, bracketHeight, transitionDuration, }: TreeColumnsProps) { const tr = transitionDuration ? `${transitionDuration}ms ease` : undefined; return (
{visibleRounds.map((round, ri) => { const roundMatches = matchesByRound.get(round) ?? []; const slotHeight = bracketHeight / Math.max(roundMatches.length, 1); const cardHeight = Math.min(slotHeight - CARD_GAP, MAX_CARD_HEIGHT); const cardTop = (slotHeight - cardHeight) / 2; const nextRound = ri < visibleRounds.length - 1 ? visibleRounds[ri + 1] : null; const nextMatches = nextRound ? (matchesByRound.get(nextRound) ?? []) : []; return (
{/* Round column */}
{round}
{roundMatches.map((match, matchIdx) => (
))}
{/* Connector between this column and the next */} {nextRound && ( )}
); })}
); } // ─── Full desktop tree ──────────────────────────────────────────────────────── interface BracketTreeViewProps { rounds: string[]; matchesByRound: Map; ownershipMap: Map; userParticipantIds: Set; thirdPlaceRound?: string; } export function BracketTreeView({ rounds, matchesByRound, ownershipMap, userParticipantIds, thirdPlaceRound, }: BracketTreeViewProps) { const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds; const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined; const maxMatches = Math.max(...mainRounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1); const bracketHeight = maxMatches * (DESIRED_CARD_HEIGHT + CARD_GAP); const minWidth = mainRounds.length * COLUMN_WIDTH + Math.max(0, mainRounds.length - 1) * CONNECTOR_WIDTH; return (
{thirdPlaceMatch && (
3rd Place
)}
); }