## What Tennis Grand Slam majors already store the full 128-player draw in `playoff_matches` (synced from Wikipedia), and the `PlayoffBracket` component + `tennis_128` template already exist — but users couldn't see any of it. The public league event page only rendered a bracket for `eventType === "playoff_game"`, while tennis majors are `major_tournament` events, so they fell through to the QP-only results table. ## Change - New `kind: "tennis"` branch in the event loader, gated on `isBracketMajor(simulatorType) && eventType === "major_tournament"`. Loads the bracket (primary-keyed for shared majors, via the existing read-only-sibling ownership remap) plus this window's local QP results. - The event page renders the full draw via the existing `PlayoffBracket`, followed by the QP results table. - Extracted the duplicated results-table markup into a shared `QpResultsTable` used by both the `tennis` and `results` branches. - "In Contention" table now sorts manager-drafted players first, then alphabetically by name. CS2 majors (handled earlier) and golf (`isBracketMajor` false) are unaffected. ## Verification - `npm run typecheck` — clean - `npm run test:run` — 2533 passed - `oxlint` — clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: #113
503 lines
20 KiB
TypeScript
503 lines
20 KiB
TypeScript
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.
|
||
.sort((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>
|
||
);
|
||
}
|
||
|