import { useEffect, useRef, 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 SLOT_WIDTH = 2 * COLUMN_WIDTH + CONNECTOR_WIDTH; // viewport width for 2 columns 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 && (
{/* 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: "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[] = []; 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
)}
); } // ─── 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; thirdPlaceRound?: string; } interface AnimState { fromPage: number; toPage: number; dir: "left" | "right"; phase: "sliding" | "settling"; } export function BracketTreePaginated({ rounds, matchesByRound, ownershipMap, userParticipantIds, firstScoringRoundIdx, thirdPlaceRound, }: BracketTreePaginatedProps) { const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds; const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined; const defaultPage = Math.max( 0, Math.min( firstScoringRoundIdx !== undefined ? Math.max(0, firstScoringRoundIdx - 1) : mainRounds.length - 2, mainRounds.length - 2, ), ); const [page, setPage] = useState(defaultPage); const [anim, setAnim] = useState(null); const stripRef = useRef(null); // Sliding phase: after React paints the strip at its initial offset, trigger the CSS transition useEffect(() => { if (!anim || anim.phase !== "sliding" || !stripRef.current) return; const el = stripRef.current; const finalX = anim.dir === "right" ? -SLOT_WIDTH : 0; const raf = requestAnimationFrame(() => { el.style.transition = "transform 200ms ease"; el.style.transform = `translateX(${finalX}px)`; }); return () => cancelAnimationFrame(raf); }, [anim]); // Settling phase: slide done, cards now CSS-transition to toPage layout; clear after transitions useEffect(() => { if (!anim || anim.phase !== "settling") return; const timer = setTimeout(() => setAnim(null), 520); return () => clearTimeout(timer); }, [anim]); const navigate = (newPage: number) => { if (anim || newPage < 0 || newPage > mainRounds.length - 2) return; setAnim({ fromPage: page, toPage: newPage, dir: newPage > page ? "right" : "left", phase: "sliding" }); }; const handleTransitionEnd = () => { if (!anim || anim.phase !== "sliding") return; if (stripRef.current) { stripRef.current.style.transition = "none"; stripRef.current.style.transform = "translateX(0)"; } setPage(anim.toPage); setAnim(prev => prev ? { ...prev, phase: "settling" } : null); }; const targetPage = anim ? anim.toPage : page; const labelRounds = mainRounds.slice(targetPage, targetPage + 2); const label = labelRounds[1] ? `${labelRounds[0]} → ${labelRounds[1]}` : labelRounds[0]; const calcHeight = (p: number) => { const rs = mainRounds.slice(p, p + 2); const max = Math.max(...rs.map((r) => matchesByRound.get(r)?.length ?? 0), 1); return max * (DESIRED_CARD_HEIGHT + CARD_GAP); }; const pageHeight = calcHeight(page); const animFromHeight = anim ? calcHeight(anim.fromPage) : pageHeight; const animToHeight = anim ? calcHeight(anim.toPage) : pageHeight; const visibleRounds = mainRounds.slice(page, page + 2); const fromRounds = anim ? mainRounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds; const toRounds = anim ? mainRounds.slice(anim.toPage, anim.toPage + 2) : visibleRounds; // Sliding: show from/to slots side-by-side at their respective heights, no transitions // Settling: left slot shows toRounds at animToHeight with CSS transitions so cards animate smoothly // Null: show current page let leftRounds: string[]; let rightRounds: string[] = []; let leftHeight: number; let rightHeight = 0; let settlingTransition = false; if (anim?.phase === "sliding") { leftRounds = anim.dir === "right" ? fromRounds : toRounds; rightRounds = anim.dir === "right" ? toRounds : fromRounds; leftHeight = anim.dir === "right" ? animFromHeight : animToHeight; rightHeight = anim.dir === "right" ? animToHeight : animFromHeight; } else if (anim?.phase === "settling") { leftRounds = toRounds; leftHeight = animToHeight; settlingTransition = true; } else { leftRounds = visibleRounds; leftHeight = pageHeight; } // During settling, minHeight transitions from animFromHeight to animToHeight in sync with cards const containerMinHeight = anim?.phase === "settling" ? animToHeight : animFromHeight; const initialX = anim?.phase === "sliding" && anim.dir === "left" ? -SLOT_WIDTH : 0; return (
{label}
{anim?.phase === "sliding" && (
)}
{thirdPlaceMatch && (
3rd Place
)}
); }