import { Badge } from "~/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "~/components/ui/table"; import { Trophy, Star } from "lucide-react"; import { TeamOwnerBadge } from "~/components/ui/team-owner-badge"; interface Participant { id: string; name: string; } interface Match { 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; participant1?: Participant | null; participant2?: Participant | null; winner?: Participant | null; loser?: Participant | null; } interface TeamOwnership { participantId: string; teamName: string; teamId: string; ownerName?: string; } interface PlayoffBracketProps { matches: Match[]; rounds: string[]; // Ordered list of round names (earliest first) preEliminatedParticipants?: { id: string; name: string }[]; // Eliminated before bracket (e.g. group stage) participantPoints?: { participantId: string; points: number }[]; // Computed fantasy points per participant partialScoreParticipantIds?: string[]; // Still-competing participants with provisional floor scores teamOwnerships?: TeamOwnership[]; userParticipantIds?: string[]; showOwnership?: boolean; title?: string; description?: string; } /** Group matches by round, sorted by matchNumber, in one pass. */ export function groupMatchesByRound(matches: Match[]): Map { const byRound = new Map(); for (const match of matches) { if (!byRound.has(match.round)) byRound.set(match.round, []); byRound.get(match.round)!.push(match); } for (const group of byRound.values()) { group.sort((a, b) => a.matchNumber - b.matchNumber); } return byRound; } /** * For a standard single-elimination bracket, slot p1 of match N in round R * comes from match (2N-1) in the previous round, and slot p2 from match 2N. */ export function buildFeederMap( matchesByRound: Map, orderedRounds: string[] ): Map { const feederMap = new Map(); for (let ri = 1; ri < orderedRounds.length; ri++) { const currentRound = orderedRounds[ri]; const prevRound = orderedRounds[ri - 1]; const prevMatchNums = new Set( (matchesByRound.get(prevRound) || []).map((m) => m.matchNumber) ); for (const match of matchesByRound.get(currentRound) || []) { const p1Src = 2 * (match.matchNumber - 1) + 1; const p2Src = 2 * (match.matchNumber - 1) + 2; if (prevMatchNums.has(p1Src)) { feederMap.set(`${currentRound}:${match.matchNumber}:p1`, { round: prevRound, matchNumber: p1Src, }); } if (prevMatchNums.has(p2Src)) { feederMap.set(`${currentRound}:${match.matchNumber}:p2`, { round: prevRound, matchNumber: p2Src, }); } } } return feederMap; } interface EliminatedEntry { participant: Participant; score: string | null; rankLabel: string; ownership: TeamOwnership | null; } /** * For each complete match, determine which losers should be shown as eliminated * in that round. In double-chance brackets (e.g. AFL), a participant who lost * round X but then won a later round Y is NOT eliminated at round X — only their * final loss counts. * * Returns a map of round name → array of eliminated participant IDs. * Exported for unit testing. */ export function computeEliminatedByRound( matches: Array<{ round: string; isComplete: boolean; winnerId: string | null; loser?: { id: string; name: string } | null; }>, rounds: string[] ): Map { const roundIndex = new Map(rounds.map((r, i) => [r, i])); // Track the latest round (by index) each participant has APPEARED IN (as winner OR loser). // Tracking appearances — not just wins — correctly handles AFL double-chance brackets, // where a Qualifying Finals loser advances to Semi-Finals automatically (no intermediate // win required). If a participant appears in a later round, they are not yet eliminated. const participantLatestRound = new Map(); const updateLatest = (id: string, ri: number) => { const prev = participantLatestRound.get(id) ?? -1; if (ri > prev) participantLatestRound.set(id, ri); }; for (const match of matches) { const ri = roundIndex.get(match.round) ?? -1; if (match.winnerId) updateLatest(match.winnerId, ri); if (match.loser?.id) updateLatest(match.loser.id, ri); } const result = new Map(); for (const match of matches) { if (!match.isComplete || !match.loser) continue; const lossRoundIndex = roundIndex.get(match.round) ?? -1; const loserLatestAppear = participantLatestRound.get(match.loser.id) ?? -1; // Double-chance survivor: participant appeared in a LATER round — not yet eliminated here. if (loserLatestAppear > lossRoundIndex) continue; if (!result.has(match.round)) result.set(match.round, []); result.get(match.round)!.push(match.loser.id); } return result; } export function PlayoffBracket({ matches, rounds, preEliminatedParticipants = [], participantPoints = [], partialScoreParticipantIds: _partialScoreParticipantIds = [], teamOwnerships = [], userParticipantIds = [], showOwnership = true, title = "Playoff Bracket", description, }: PlayoffBracketProps) { const userParticipantSet = new Set(userParticipantIds); const ownershipMap = new Map(); teamOwnerships.forEach((o) => ownershipMap.set(o.participantId, o)); const pointsMap = new Map(participantPoints.map((p) => [p.participantId, p.points])); // Group matches once; reused for feeder map and rendering const matchesByRound = groupMatchesByRound(matches); const feederMap = buildFeederMap(matchesByRound, rounds); const getTbdLabel = (round: string, matchNumber: number, slot: "p1" | "p2") => { const feeder = feederMap.get(`${round}:${matchNumber}:${slot}`); if (!feeder) return "TBD"; return `Winner of ${feeder.round} M${feeder.matchNumber}`; }; // Build elimination rankings: collect losers per round, then assign rank labels. // In double-chance brackets (e.g. AFL), a participant may lose one round but // win a later one — only count them as eliminated at their FINAL losing match. const losersByRound = new Map>(); let _hasScore = false; let bracketWinner: Participant | null = null; const lastRound = rounds[rounds.length - 1]; const finalMatch = lastRound ? (matchesByRound.get(lastRound) || []).find((m) => m.matchNumber === 1) : null; if (finalMatch?.winner) bracketWinner = finalMatch.winner; // Compute which participants are eliminated in which round, handling double-chance. const eliminatedByRound = computeEliminatedByRound(matches, rounds); for (const match of matches) { if (!match.isComplete || !match.loser) continue; const eliminatedInRound = eliminatedByRound.get(match.round); if (!eliminatedInRound?.includes(match.loser.id)) continue; const loserScore = match.loserId === match.participant1Id ? match.participant1Score : match.participant2Score; if (loserScore) _hasScore = true; if (!losersByRound.has(match.round)) losersByRound.set(match.round, []); losersByRound.get(match.round)!.push({ participant: match.loser, score: loserScore, ownership: ownershipMap.get(match.loser.id) || null, }); } // Walk rounds latest→earliest to assign rank labels. // Use TOTAL match count per round (not eliminated-so-far) so that partial scoring // shows the correct eventual rank. E.g. R64 losers in NCAAM 68 always show T33 // even while other R64 games are still pending. const allBracketParticipantIds = new Set(); for (const match of matches) { if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id); if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id); } const rankedEntries: EliminatedEntry[] = []; let nextRank = 2; // rank 1 = champion (even if not yet decided) for (let ri = rounds.length - 1; ri >= 0; ri--) { const roundName = rounds[ri]; const roundLosers = losersByRound.get(roundName) || []; const totalMatchesInRound = matchesByRound.get(roundName)?.length ?? 0; if (roundLosers.length > 0) { const rankLabel = `T${nextRank}`; for (const loser of roundLosers) { rankedEntries.push({ ...loser, rankLabel }); } } nextRank += totalMatchesInRound; } // Exclude pre-eliminated participants already ranked via bracket match losers const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id)); if (bracketWinner) rankedParticipantIds.add(bracketWinner.id); const filteredPreEliminated = preEliminatedParticipants.filter( (p) => !rankedParticipantIds.has(p.id) ); const showRankings = rankedEntries.length > 0 || bracketWinner !== null || filteredPreEliminated.length > 0; // Build participant lookup from match data for the "In Contention" table const participantMap = new Map(); for (const match of matches) { if (match.participant1) participantMap.set(match.participant1.id, match.participant1); if (match.participant2) participantMap.set(match.participant2.id, match.participant2); } const activeParticipants = [...allBracketParticipantIds] .filter((id) => !rankedParticipantIds.has(id)) .map((id) => participantMap.get(id)) .filter((p): p is Participant => p !== undefined); // Hide participants with 0 points that nobody drafted — they add noise without value. const isDraftedOrScoring = (participantId: string) => ownershipMap.has(participantId) || (pointsMap.get(participantId) ?? 0) > 0; // Hoist winner row lookups so we don't need an IIFE in JSX const winnerIsOwned = bracketWinner ? userParticipantSet.has(bracketWinner.id) : false; const winnerOwnership = bracketWinner ? ownershipMap.get(bracketWinner.id) : undefined; const winnerPts = bracketWinner ? pointsMap.get(bracketWinner.id) : undefined; return (

{title}

{description && (

{description}

)}
{matches.length === 0 ? (

No bracket matches available yet.

Matches will appear here once the bracket is set up.

) : (
{rounds.map((round) => { const roundMatches = matchesByRound.get(round) || []; if (roundMatches.length === 0) return null; return (

{round}

{roundMatches.length}{" "} {roundMatches.length === 1 ? "match" : "matches"}
{roundMatches.map((match) => { const p1IsWinner = match.isComplete && match.winnerId === match.participant1Id; const p2IsWinner = match.isComplete && match.winnerId === match.participant2Id; const p1IsLoser = match.isComplete && match.loserId === match.participant1Id; const p2IsLoser = match.isComplete && match.loserId === match.participant2Id; const p1Name = match.participant1?.name || (match.participant1Id ? "TBD" : getTbdLabel(round, match.matchNumber, "p1")); const p2Name = match.participant2?.name || (match.participant2Id ? "TBD" : getTbdLabel(round, match.matchNumber, "p2")); const p1IsTbd = !match.participant1Id; const p2IsTbd = !match.participant2Id; const p1IsOwned = !p1IsTbd && userParticipantSet.has(match.participant1Id!); const p2IsOwned = !p2IsTbd && userParticipantSet.has(match.participant2Id!); const matchHasOwned = p1IsOwned || p2IsOwned; const p1Ownership = showOwnership && match.participant1Id ? ownershipMap.get(match.participant1Id) || null : null; const p2Ownership = showOwnership && match.participant2Id ? ownershipMap.get(match.participant2Id) || null : null; return (
{/* Match label row */}
M{match.matchNumber} {match.isComplete && ( Done )}
{/* Participants: left vs right (stacks on mobile) */}
{/* Participant 1 — left-aligned */}
{p1IsOwned && !p1IsWinner && ( )} {p1IsWinner && ( )} {p1Name}
{!p1IsTbd && p1Ownership && (
)}
{/* Center: scores + vs */}
{match.participant1Score ? ( <> {parseFloat(match.participant1Score)} vs {match.participant2Score ? parseFloat(match.participant2Score) : "—"} ) : ( vs )}
{/* Participant 2 — right-aligned on sm+, left-aligned on mobile */}
{p2Name} {p2IsWinner && ( )} {p2IsOwned && !p2IsWinner && ( )}
{!p2IsTbd && p2Ownership && (
)}
); })}
); })} {/* In Contention */} {activeParticipants.length > 0 && ( In Contention Participant {showOwnership && ( Drafted By )} {pointsMap.size > 0 && ( Pts )} {activeParticipants.map((p) => { const isOwned = userParticipantSet.has(p.id); const ownership = showOwnership ? ownershipMap.get(p.id) || null : null; const pts = pointsMap.get(p.id); return ( {p.name} {isOwned && ( )} {showOwnership && ( {ownership ? ( ) : ( - )} )} {pointsMap.size > 0 && ( {pts !== undefined ? pts : "–"} )} ); })}
)} {/* Final Rankings / Eliminated Teams */} {showRankings && ( {bracketWinner ? "Final Rankings" : "Eliminated Teams"} Rank Participant {showOwnership && ( Drafted By )} {pointsMap.size > 0 && ( Pts )} {bracketWinner && ( #1 {bracketWinner.name} {winnerIsOwned && ( )} {showOwnership && ( {winnerOwnership ? ( ) : ( - )} )} {pointsMap.size > 0 && ( {winnerPts ?? "—"} )} )} {rankedEntries.filter((entry) => isDraftedOrScoring(entry.participant.id)).map((entry) => { const isOwned = userParticipantSet.has(entry.participant.id); const pts = pointsMap.get(entry.participant.id); return ( {entry.rankLabel} {entry.participant.name} {isOwned && ( )} {showOwnership && ( {entry.ownership ? ( ) : ( - )} )} {pointsMap.size > 0 && ( {pts ?? "—"} )} ); })} {filteredPreEliminated.filter((p) => isDraftedOrScoring(p.id)).map((p) => { const isOwned = userParticipantSet.has(p.id); const ownership = showOwnership ? ownershipMap.get(p.id) || null : null; const pts = pointsMap.get(p.id); return ( {p.name} {isOwned && ( )} {showOwnership && ( {ownership ? ( ) : ( - )} )} {pointsMap.size > 0 && ( {pts ?? "—"} )} ); })}
)}
)}
); }