brackt/app/components/scoring/PlayoffBracket.tsx
Chris Parsons dbffddc9ff
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## Partial bracket scoring
- `processMatchResult`: new exported function that scores a single match
  immediately (loser → final placement, winner → provisional floor).
  Called from `set-winner` and `set-round-winners` so points are awarded
  as soon as a winner is set, before the full round is complete.
- `set-winner`: passes `eventName` to `processMatchResult`.
- `set-round-winners`: batches side effects — calls `recalculateAffectedLeagues`
  and `updateProbabilitiesAfterResult` once after the loop instead of per-match
  (`skipSideEffects: true` per match).

## Code review fixes
- **ROUND_CONFIG** (S1): extracted a `RoundScoringConfig` lookup table +
  `getRoundConfig()` helper, eliminating three parallel `if/else` chains in
  `processPlayoffEvent`, `processMatchResult`, and `getGuaranteedMinimumPosition`.
  AFL template overrides live in `TEMPLATE_ROUND_CONFIG` with an explanatory
  comment about why the `bracketTemplateId` guard is required.
- **skipSideEffects** (C2): new param on `processMatchResult`; bracket server
  uses it to batch standings/probability recalc in `set-round-winners`.
- **eventName** (W1): passed through `processMatchResult` → `recalculateAffectedLeagues`.
- **Round validation** (W2): `set-round-winners` now guards `match.round === round`
  before processing each assignment.
- **Comments** (C3/S3/W3): added notes on non-scoring loser assumption,
  `isScoring ?? true` default, and AFL Semi-Finals template requirement.

## PlayoffBracket eliminated-teams fix + tests
- Fixed `computeEliminatedByRound` to track participant *appearances* (not just
  wins), so AFL QF losers who advance to Semi-Finals via double-chance are
  correctly excluded from the QF eliminated list.
- Extracted the logic as an exported pure function for testability.
- Added 4 tests: standard elimination, AFL QF loser → SF win, AFL QF loser →
  SF loss, and normal advancement not protecting a later loser.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 10:50:30 -07:00

654 lines
28 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 { 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<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;
}>,
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 (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<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);
}
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;
}
export function PlayoffBracket({
matches,
rounds,
preEliminatedParticipants = [],
participantPoints = [],
partialScoreParticipantIds = [],
teamOwnerships = [],
userParticipantIds = [],
showOwnership = true,
title = "Playoff Bracket",
description,
}: 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]));
// 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<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
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 (no mutation)
// nextRank must start after the still-alive participants (not always 2)
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 totalEliminatedInBracket = [...losersByRound.values()].reduce(
(sum, losers) => sum + losers.length,
0
);
const stillAlive =
allBracketParticipantIds.size - totalEliminatedInBracket - (bracketWinner ? 1 : 0);
const rankedEntries: EliminatedEntry[] = [];
let nextRank = stillAlive + (bracketWinner ? 2 : 1);
for (let ri = rounds.length - 1; ri >= 0; ri--) {
const roundLosers = losersByRound.get(rounds[ri]) || [];
if (roundLosers.length === 0) continue;
const rankLabel = `T${nextRank}`;
for (const loser of roundLosers) {
rankedEntries.push({ ...loser, rankLabel });
}
nextRank += roundLosers.length;
}
// 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<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);
// 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 (
<div className="space-y-6">
<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">
{rounds.map((round) => {
const roundMatches = matchesByRound.get(round) || [];
if (roundMatches.length === 0) return null;
return (
<div key={round}>
<div className="flex items-center gap-2 mb-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{round}
</h3>
<div className="flex-1 border-t border-border" />
<span className="text-xs text-muted-foreground">
{roundMatches.length}{" "}
{roundMatches.length === 1 ? "match" : "matches"}
</span>
</div>
<div className="grid md:grid-cols-2 gap-2">
{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 (
<div
key={match.id}
className={`rounded-lg border bg-card px-3 py-2 ${matchHasOwned ? "border-electric/50" : "border-border"}`}
>
{/* Match label row */}
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-mono text-muted-foreground">
M{match.matchNumber}
</span>
{match.isComplete && (
<Badge variant="secondary" className="text-xs">
Done
</Badge>
)}
</div>
{/* Participants: left vs right (stacks on mobile) */}
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
{/* Participant 1 — left-aligned */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
{p1IsOwned && !p1IsWinner && (
<span className="inline-block w-1.5 h-1.5 rounded-full bg-electric shrink-0" />
)}
{p1IsWinner && (
<Trophy className="h-3.5 w-3.5 text-yellow-500 shrink-0" />
)}
<span
className={[
"text-sm font-medium truncate",
p1IsLoser ? "text-muted-foreground line-through" : "",
p1IsTbd ? "text-muted-foreground italic font-normal" : "",
p1IsWinner ? "font-semibold" : "",
p1IsOwned && !p1IsLoser ? "text-electric" : "",
]
.filter(Boolean)
.join(" ")}
>
{p1Name}
</span>
</div>
{!p1IsTbd && p1Ownership && (
<div className="mt-1">
<TeamOwnerBadge
teamName={p1Ownership.teamName}
ownerName={p1Ownership.ownerName}
/>
</div>
)}
</div>
{/* Center: scores + vs */}
<div className="shrink-0 flex sm:flex-col items-center gap-1 sm:gap-0.5 min-w-[2.5rem]">
{match.participant1Score ? (
<>
<span className="text-xs tabular-nums font-medium">
{parseFloat(match.participant1Score)}
</span>
<span className="text-[10px] text-muted-foreground/60 uppercase tracking-wider">
vs
</span>
<span className="text-xs tabular-nums font-medium">
{match.participant2Score
? parseFloat(match.participant2Score)
: "—"}
</span>
</>
) : (
<span className="text-xs text-muted-foreground">vs</span>
)}
</div>
{/* Participant 2 — right-aligned on sm+, left-aligned on mobile */}
<div className="flex-1 min-w-0 flex flex-col sm:items-end">
<div className="flex items-center gap-1 sm:justify-end">
<span
className={[
"text-sm font-medium truncate",
p2IsLoser ? "text-muted-foreground line-through" : "",
p2IsTbd ? "text-muted-foreground italic font-normal" : "",
p2IsWinner ? "font-semibold" : "",
p2IsOwned && !p2IsLoser ? "text-electric" : "",
]
.filter(Boolean)
.join(" ")}
>
{p2Name}
</span>
{p2IsWinner && (
<Trophy className="h-3.5 w-3.5 text-yellow-500 shrink-0" />
)}
{p2IsOwned && !p2IsWinner && (
<span className="inline-block w-1.5 h-1.5 rounded-full bg-electric shrink-0" />
)}
</div>
{!p2IsTbd && p2Ownership && (
<div className="mt-1">
<TeamOwnerBadge
teamName={p2Ownership.teamName}
ownerName={p2Ownership.ownerName}
/>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
})}
{/* In Contention */}
{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 / Eliminated Teams */}
{showRankings && (
<Card className="border-electric/30">
<CardHeader>
<CardTitle className="text-electric flex items-center gap-2">
<Trophy className="h-5 w-5" />
{bracketWinner ? "Final Rankings" : "Eliminated Teams"}
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">Rank</TableHead>
<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>
{bracketWinner && (
<TableRow className={winnerIsOwned ? "bg-electric/8 border-l-2 border-l-electric" : "bg-yellow-50/30 dark:bg-yellow-950/10"}>
<TableCell className="font-semibold text-yellow-600 dark:text-yellow-400">
<span className="flex items-center gap-1">
<Trophy className="h-3 w-3" />
#1
</span>
</TableCell>
<TableCell className="font-semibold">
{bracketWinner.name}
{winnerIsOwned && (
<Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />
)}
</TableCell>
{showOwnership && (
<TableCell className="pl-6">
{winnerOwnership ? (
<TeamOwnerBadge teamName={winnerOwnership.teamName} ownerName={winnerOwnership.ownerName} />
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
)}
{pointsMap.size > 0 && (
<TableCell className="text-right font-semibold text-electric">
{winnerPts ?? "—"}
</TableCell>
)}
</TableRow>
)}
{rankedEntries.map((entry, i) => {
const isOwned = userParticipantSet.has(entry.participant.id);
const pts = pointsMap.get(entry.participant.id);
return (
<TableRow
key={`${entry.participant.id}-${i}`}
className={isOwned ? "bg-electric/5 border-l-2 border-l-electric opacity-80" : "opacity-60"}
>
<TableCell className="font-mono text-xs text-muted-foreground">
{entry.rankLabel}
</TableCell>
<TableCell className={isOwned ? "text-electric font-medium" : "text-muted-foreground"}>
{entry.participant.name}
{isOwned && (
<Star className="inline ml-1.5 h-3 w-3 fill-current text-electric" />
)}
</TableCell>
{showOwnership && (
<TableCell className="pl-6">
{entry.ownership ? (
<TeamOwnerBadge
teamName={entry.ownership.teamName}
ownerName={entry.ownership.ownerName}
/>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
)}
{pointsMap.size > 0 && (
<TableCell className="text-right tabular-nums text-muted-foreground">
{pts ?? "—"}
</TableCell>
)}
</TableRow>
);
})}
{filteredPreEliminated.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 opacity-60" : "opacity-40"}
>
<TableCell className="font-mono text-xs text-muted-foreground"></TableCell>
<TableCell className={isOwned ? "text-electric font-medium" : "text-muted-foreground"}>
{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 tabular-nums text-muted-foreground">
{pts ?? "—"}
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
)}
</div>
);
}