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"; import { GradientIcon } from "~/components/ui/GradientIcon"; import { RankingsRow } from "./RankingsRow"; import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView"; import { BracketTreePaginated } from "./BracketTreePaginated"; import { getBracketTemplate, type BracketTemplate } from "~/lib/bracket-templates"; import { NbaBracketLayout } from "./NbaBracketLayout"; import { TabbedBracketLayout } from "./TabbedBracketLayout"; interface Participant { id: string; name: string; } export 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; isScoring?: boolean; participant1?: Participant | null; participant2?: Participant | null; winner?: Participant | null; loser?: Participant | null; } export interface TeamOwnership { participantId: string; teamName: string; teamId: string; ownerName?: string; } interface PlayoffBracketProps { matches: Match[]; rounds: string[]; // Ordered list of round names (earliest first) bracketTemplateId?: string | null; preEliminatedParticipants?: { id: string; name: string }[]; participantPoints?: { participantId: string; points: number }[]; partialScoreParticipantIds?: string[]; teamOwnerships?: TeamOwnership[]; userParticipantIds?: string[]; showOwnership?: boolean; title?: string; description?: string; /** "bracket" = full bracket + tables (default); "rankings" = final rankings table only */ mode?: "bracket" | "rankings"; } /** 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; participant1Id?: string | null; participant2Id?: 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. // We track winnerId/loserId for completed matches AND participant1Id/participant2Id // for ALL matches (even incomplete ones). This correctly handles: // - AFL double-chance: a QF loser who appears in a later Semi-Finals match // - NBA play-in: a 7v8 loser placed into a PIR2 slot (match not yet complete) // If a participant appears in a later round slot, 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); if (match.participant1Id) updateLatest(match.participant1Id, ri); if (match.participant2Id) updateLatest(match.participant2Id, 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; } /** The score recorded for one participant in a match, or null if they didn't play in it. */ function participantScore(match: Match, participantId: string | null): string | null { if (!participantId) return null; if (participantId === match.participant1Id) return match.participant1Score; if (participantId === match.participant2Id) return match.participant2Score; return null; } /** * A consolation final: a round contested by the losers of an earlier round, which * splits the positions those losers would otherwise share. FIFA's "Third Place Game" * (fed by the Semifinals) is the only one in the templates today. */ export interface ConsolationRound { /** The consolation round itself, e.g. "Third Place Game". */ round: string; /** The round whose losers contest it, e.g. "Semifinals". */ feederRound: string; } /** * Find the template's consolation round, if it has one. * Exported for unit testing. */ export function findConsolationRound( template: BracketTemplate | undefined ): ConsolationRound | undefined { const feeder = template?.rounds.find((r) => r.loserFeedsInto); if (!feeder?.loserFeedsInto) return undefined; return { round: feeder.loserFeedsInto, feederRound: feeder.name }; } /** * Build the ordered final-rankings list from completed matches. * * Ranks are derived by walking rounds latest-first: the final's loser is 2nd, the * previous round's losers share the next tier, and so on — each round consuming as * many positions as it has matches. * * A consolation round needs different handling, because its winner never loses a * match and so the loser-driven walk above would leave them unranked and "in * contention" forever. Its two places are exactly the top of the tier its feeder * round's losers would otherwise share, so it is resolved *at the feeder round* — * the winner takes that tier's first position and the loser the second — and the * consolation round itself consumes no positions. Positions are derived rather than * hardcoded, so a consolation round hanging off a different feeder still lands right. * * Exported for unit testing. */ export function computeRankedEntries( matches: Match[], rounds: string[], matchesByRound: Map, consolation: ConsolationRound | undefined, ownershipMap: Map ): EliminatedEntry[] { const eliminatedByRound = computeEliminatedByRound(matches, rounds); // Only take the consolation path when both rounds actually have matches; otherwise // fall through to the loser-driven walk so nothing is dropped. const consolationActive = consolation && rounds.includes(consolation.round) && rounds.includes(consolation.feederRound); // Consolation matches we can place exactly. Anything else in that round (still in // progress, or missing its hydrated winner/loser) deliberately stays eligible for // the loser-driven walk rather than being silently dropped. const consolationMatches = consolationActive && consolation ? (matchesByRound.get(consolation.round) ?? []).filter( (m): m is Match & { winner: Participant; loser: Participant } => m.isComplete && !!m.winner && !!m.loser ) : []; const exactlyPlacedMatchIds = new Set(consolationMatches.map((m) => m.id)); const entryFor = ( match: Match, participant: Participant, participantId: string | null ): Omit => ({ participant, score: participantScore(match, participantId), ownership: ownershipMap.get(participant.id) || null, }); const losersByRound = new Map[]>(); for (const match of matches) { if (!match.isComplete || !match.loser) continue; if (exactlyPlacedMatchIds.has(match.id)) continue; if (!eliminatedByRound.get(match.round)?.includes(match.loser.id)) continue; if (!losersByRound.has(match.round)) losersByRound.set(match.round, []); losersByRound.get(match.round)?.push(entryFor(match, match.loser, match.loserId)); } const rankedEntries: EliminatedEntry[] = []; let nextRank = 2; for (let ri = rounds.length - 1; ri >= 0; ri--) { const roundName = rounds[ri]; // The consolation match splits the top of its feeder round's tier, so it is // placed first and the round's remaining losers share what's left below it. let tierRank = nextRank; if (consolationActive && roundName === consolation?.feederRound) { for (const match of consolationMatches) { rankedEntries.push({ ...entryFor(match, match.winner, match.winnerId), rankLabel: `${tierRank}`, }); rankedEntries.push({ ...entryFor(match, match.loser, match.loserId), rankLabel: `${tierRank + 1}`, }); tierRank += 2; } } for (const loser of losersByRound.get(roundName) ?? []) { rankedEntries.push({ ...loser, rankLabel: `T${tierRank}` }); } // The consolation round's places belong to its feeder round's tier, so it // consumes none of its own. if (consolationActive && roundName === consolation?.round) continue; nextRank += matchesByRound.get(roundName)?.length ?? 0; } return rankedEntries; } /** Find the index of the first round that has scoring matches. */ function firstScoringRoundIdx(matchesByRound: Map, rounds: string[]): number { for (let i = 0; i < rounds.length; i++) { if (matchesByRound.get(rounds[i])?.some((m) => m.isScoring)) return i; } return 0; } const RANKING_ROW_CLASSES: Record = { 1: "bg-yellow-500/10", 2: "bg-white/[0.14]", 3: "bg-orange-600/[0.08]", }; function rankingRowCls(currentRank: number | undefined): string { const podium = currentRank !== undefined ? RANKING_ROW_CLASSES[currentRank] : undefined; return `flex flex-col sm:flex-row sm:items-center gap-0 rounded-lg px-3 py-3 sm:px-5 sm:py-4 transition-colors ${podium ?? "bg-white/[0.04]"}`; } export function PlayoffBracket({ matches, rounds, bracketTemplateId = null, preEliminatedParticipants = [], participantPoints = [], partialScoreParticipantIds: _partialScoreParticipantIds = [], teamOwnerships = [], userParticipantIds = [], showOwnership = true, title = "Playoff Bracket", description, mode = "bracket", }: 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])); const matchesByRound = groupMatchesByRound(matches); const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds); const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined; const consolation = findConsolationRound(template); const thirdPlaceRound = consolation?.round; // Build elimination rankings 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; 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 = computeRankedEntries( matches, rounds, matchesByRound, consolation, ownershipMap ); 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; 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) // Owned-by-a-manager players first, then alphabetical by name. .toSorted((a, b) => { const aOwned = ownershipMap.has(a.id); const bOwned = ownershipMap.has(b.id); if (aOwned !== bOwned) return aOwned ? -1 : 1; return a.name.localeCompare(b.name); }); const isDraftedOrScoring = (participantId: string) => ownershipMap.has(participantId) || (pointsMap.get(participantId) ?? 0) > 0; 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 (
{/* Header */}

{title}

{description && (

{description}

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

No bracket matches available yet.

Matches will appear here once the bracket is set up.

) : (
{mode === "bracket" && (template?.phases ? ( } ownershipMap={ownershipMap as Map} userParticipantIds={userParticipantSet} phases={template.phases} scoringRoundIdx={scoringRoundIdx} /> ) : template?.conferenceGroups ? ( } ownershipMap={ownershipMap as Map} userParticipantIds={userParticipantSet} conferenceGroups={template.conferenceGroups} scoringRoundIdx={scoringRoundIdx} /> ) : ( <> {/* ── Desktop ── */}
} ownershipMap={ownershipMap as Map} userParticipantIds={userParticipantSet} thirdPlaceRound={thirdPlaceRound} />
{/* ── Mobile ── */}
} ownershipMap={ownershipMap as Map} userParticipantIds={userParticipantSet} firstScoringRoundIdx={scoringRoundIdx} thirdPlaceRound={thirdPlaceRound} />
))} {/* ── In Contention ── */} {mode === "bracket" && 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 ── */} {mode === "rankings" && showRankings && (

{bracketWinner ? "Final Rankings" : "Eliminated Teams"}

{bracketWinner && ( 0} rowClass={rankingRowCls(1)} /> )} {rankedEntries.filter((entry) => isDraftedOrScoring(entry.participant.id)).map((entry) => { const isOwned = userParticipantSet.has(entry.participant.id); const pts = pointsMap.get(entry.participant.id); const numRank = parseInt(entry.rankLabel.replace("T", ""), 10); return ( 0} rowClass={rankingRowCls(numRank)} /> ); })} {filteredPreEliminated.filter((p) => isDraftedOrScoring(p.id)).map((p) => { const isOwned = userParticipantSet.has(p.id); const ownership = ownershipMap.get(p.id); const pts = pointsMap.get(p.id); return ( 0} rowClass={rankingRowCls(undefined)} /> ); })}
)}
)}
); }