import { useState } from "react"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { Button } from "~/components/ui/button"; 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; const LABEL_HEIGHT = 32; const CARD_GAP = 14; // vertical gap between cards (split top/bottom) const DESIRED_CARD_HEIGHT = 112; // target card height — tall enough to show owner info const MAX_CARD_HEIGHT = 140; // cap for sparse later rounds // Avatar color palette (matches TeamOwnerBadge) const AVATAR_COLORS = ["#adf661", "#2ce1c1", "#8b5cf6", "#f59e0b", "#ef4444", "#3b82f6"]; function hashName(name: string): number { let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) & 0xffff; return h; } function avatarColor(name: string) { return AVATAR_COLORS[hashName(name) % AVATAR_COLORS.length]; } 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 && (
{/* Electric dot for user's pick (when not winner) */} {isOwned && !isWinner && showText && ( )}
{name ?? "TBD"} {/* Owner info row */} {showOwner && !isTbd && ownership && (
{ownership.teamName .split(/\s+/) .slice(0, 2) .map((w) => w[0]?.toUpperCase() ?? "") .join("")}
{ownership.ownerName ?? ownership.teamName}
)}
{/* Score */} {showScore && formattedScore && ( {formattedScore} )}
)}
); } interface BracketMatchSlotProps { match: BracketMatch; slotHeight: number; ownershipMap: Map; userParticipantIds: Set; } 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: "linear-gradient(to bottom, #adf661, #2ce1c1)" } : 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[] = []; if (nextMatches.length === Math.ceil(currentMatches.length / 2)) { const currentSlotH = bracketHeight / Math.max(currentMatches.length, 1); const nextSlotH = bracketHeight / Math.max(nextMatches.length, 1); 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; // U-shape from two source midpoints; horizontal out to next column paths.push(`M 0 ${topY} H ${mid} V ${botY} H 0`); paths.push(`M ${mid} ${midY} H ${CONNECTOR_WIDTH}`); } else { // Odd last match: straight line through paths.push(`M 0 ${topY} 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; } function TreeColumns({ visibleRounds, matchesByRound, ownershipMap, userParticipantIds, bracketHeight, }: TreeColumnsProps) { 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; } export function BracketTreeView({ rounds, matchesByRound, ownershipMap, userParticipantIds, }: BracketTreeViewProps) { const maxMatches = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1); const bracketHeight = maxMatches * (DESIRED_CARD_HEIGHT + CARD_GAP); const minWidth = rounds.length * COLUMN_WIDTH + Math.max(0, rounds.length - 1) * CONNECTOR_WIDTH; return (
); } // ─── Paginated mobile tree ──────────────────────────────────────────────────── interface BracketTreePaginatedProps { rounds: string[]; matchesByRound: Map; ownershipMap: Map; userParticipantIds: Set; /** Index of the first scoring round — default page starts here */ firstScoringRoundIdx?: number; } export function BracketTreePaginated({ rounds, matchesByRound, ownershipMap, userParticipantIds, firstScoringRoundIdx, }: BracketTreePaginatedProps) { // Default to showing around the first scoring round const defaultPage = Math.max( 0, Math.min( firstScoringRoundIdx !== undefined ? Math.max(0, firstScoringRoundIdx - 1) : rounds.length - 2, rounds.length - 2, ), ); const [pageStart, setPageStart] = useState(defaultPage); const visibleRounds = rounds.slice(pageStart, pageStart + 2); const canGoLeft = pageStart > 0; const canGoRight = pageStart + 2 < rounds.length; const maxMatchesInView = Math.max(...visibleRounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1); const bracketHeight = maxMatchesInView * (DESIRED_CARD_HEIGHT + CARD_GAP); return (
{/* Round tab navigation */}
{rounds.map((round, ri) => { const isActive = ri >= pageStart && ri < pageStart + 2; return ( ); })}
{/* Bracket columns */}
{/* Prev / Next navigation */}
{visibleRounds[0]} {visibleRounds[1] ? ` → ${visibleRounds[1]}` : ""}
); }