brackt/app/components/scoring/PlayoffBracket.tsx
Chris Parsons 698c6a6509
All checks were successful
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m25s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m8s
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Fix lint errors in PlayoffBracket and sync-tournament-results test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 21:02:06 -07:00

503 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 } 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<string, Match[]> {
const byRound = new Map<string, Match[]>();
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<string, Match[]>,
orderedRounds: string[]
): Map<string, { round: string; matchNumber: number }> {
const feederMap = new Map<string, { round: string; matchNumber: number }>();
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<string, string[]> {
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<string, number>();
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<string, string[]>();
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;
}
/** Find the index of the first round that has scoring matches. */
function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, 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<number, string> = {
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<string, TeamOwnership>();
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 thirdPlaceRound = template?.rounds
.find((r) => template.rounds.some((other) => other.loserFeedsInto === r.name))
?.name;
// Build elimination rankings
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
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 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 (!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,
});
}
const allBracketParticipantIds = new Set<string>();
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;
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;
}
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<string, Participant>();
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 (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-xl font-semibold">{title}</h2>
{description && (
<p className="text-sm text-muted-foreground mt-1">{description}</p>
)}
</div>
{matches.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm">No bracket matches available yet.</p>
<p className="text-xs mt-1">
Matches will appear here once the bracket is set up.
</p>
</div>
) : (
<div className="space-y-8">
{mode === "bracket" && (template?.phases ? (
<TabbedBracketLayout
rounds={rounds}
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet}
phases={template.phases}
scoringRoundIdx={scoringRoundIdx}
/>
) : template?.conferenceGroups ? (
<NbaBracketLayout
matches={matches}
rounds={rounds}
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet}
conferenceGroups={template.conferenceGroups}
scoringRoundIdx={scoringRoundIdx}
/>
) : (
<>
{/* ── Desktop ── */}
<div className="hidden md:block">
<BracketTreeView
rounds={rounds}
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet}
thirdPlaceRound={thirdPlaceRound}
/>
</div>
{/* ── Mobile ── */}
<div className="md:hidden">
<BracketTreePaginated
rounds={rounds}
matchesByRound={matchesByRound as Map<string, BracketMatch[]>}
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
userParticipantIds={userParticipantSet}
firstScoringRoundIdx={scoringRoundIdx}
thirdPlaceRound={thirdPlaceRound}
/>
</div>
</>
))}
{/* ── In Contention ── */}
{mode === "bracket" && activeParticipants.length > 0 && (
<Card className="border-green-500/30">
<CardHeader>
<CardTitle className="text-green-600 dark:text-green-400 flex items-center gap-2">
<Star className="h-5 w-5" />
In Contention
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Participant</TableHead>
{showOwnership && (
<TableHead className="w-40 pl-6">Drafted By</TableHead>
)}
{pointsMap.size > 0 && (
<TableHead className="text-right w-16">Pts</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{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 (
<TableRow
key={p.id}
className={isOwned ? "bg-electric/5 border-l-2 border-l-electric" : ""}
>
<TableCell className={isOwned ? "text-electric font-medium" : ""}>
{p.name}
{isOwned && (
<Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />
)}
</TableCell>
{showOwnership && (
<TableCell className="pl-6">
{ownership ? (
<TeamOwnerBadge teamName={ownership.teamName} ownerName={ownership.ownerName} />
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
)}
{pointsMap.size > 0 && (
<TableCell className="text-right font-mono text-sm">
{pts !== undefined ? pts : ""}
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* ── Final Rankings ── */}
{mode === "rankings" && showRankings && (
<Card className="gap-2">
<CardHeader className="px-3 sm:px-6 pb-2">
<div className="flex items-center gap-2">
<GradientIcon icon={Trophy} className="h-5 w-5 shrink-0" />
<h2 className="text-xl font-bold leading-none tracking-tight">
{bracketWinner ? "Final Rankings" : "Eliminated Teams"}
</h2>
</div>
</CardHeader>
<CardContent className="px-3 sm:px-6">
<div className="space-y-3">
{bracketWinner && (
<RankingsRow
teamId={winnerOwnership?.teamId ?? bracketWinner.id}
teamName={winnerOwnership?.teamName ?? bracketWinner.name}
participantName={bracketWinner.name}
ownerName={winnerOwnership?.ownerName}
isOwned={winnerIsOwned}
rankLabel={1}
points={winnerPts}
showPoints={pointsMap.size > 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 (
<RankingsRow
key={entry.participant.id}
teamId={entry.ownership?.teamId ?? entry.participant.id}
teamName={entry.ownership?.teamName ?? entry.participant.name}
participantName={entry.participant.name}
ownerName={entry.ownership?.ownerName}
isOwned={isOwned}
rankLabel={entry.rankLabel}
points={pts}
showPoints={pointsMap.size > 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 (
<RankingsRow
key={p.id}
teamId={ownership?.teamId ?? p.id}
teamName={ownership?.teamName ?? p.name}
participantName={p.name}
ownerName={ownership?.ownerName}
isOwned={isOwned}
points={pts}
showPoints={pointsMap.size > 0}
rowClass={rankingRowCls(undefined)}
/>
);
})}
</div>
</CardContent>
</Card>
)}
</div>
)}
</div>
);
}