Fix NBA play-in elimination display and consolidate bracket reprocessing (#299)

- Fix computeEliminatedByRound() to track participant1Id/participant2Id
  in addition to winnerId/loserId, so a 7v8 play-in loser placed in a
  PIR2 slot (incomplete match) is not shown as eliminated on the league page
- Replace separate Recalculate Floors and Reprocess Eliminations buttons
  with a single Reprocess Bracket action that replays all matches and
  re-marks non-bracket participants as eliminated
- Add NBA play-in test cases for the elimination computation logic
This commit is contained in:
Chris Parsons 2026-04-15 14:56:33 -07:00 committed by GitHub
parent 0eb486f8d3
commit 13bc1e3b5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 88 additions and 86 deletions

View file

@ -125,15 +125,19 @@ export function computeEliminatedByRound(
isComplete: boolean; isComplete: boolean;
winnerId: string | null; winnerId: string | null;
loser?: { id: string; name: string } | null; loser?: { id: string; name: string } | null;
participant1Id?: string | null;
participant2Id?: string | null;
}>, }>,
rounds: string[] rounds: string[]
): Map<string, string[]> { ): Map<string, string[]> {
const roundIndex = new Map(rounds.map((r, i) => [r, i])); 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). // Track the latest round (by index) each participant has APPEARED IN.
// Tracking appearances — not just wins — correctly handles AFL double-chance brackets, // We track winnerId/loserId for completed matches AND participant1Id/participant2Id
// where a Qualifying Finals loser advances to Semi-Finals automatically (no intermediate // for ALL matches (even incomplete ones). This correctly handles:
// win required). If a participant appears in a later round, they are not yet eliminated. // - 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 participantLatestRound = new Map<string, number>();
const updateLatest = (id: string, ri: number) => { const updateLatest = (id: string, ri: number) => {
const prev = participantLatestRound.get(id) ?? -1; const prev = participantLatestRound.get(id) ?? -1;
@ -143,6 +147,8 @@ export function computeEliminatedByRound(
const ri = roundIndex.get(match.round) ?? -1; const ri = roundIndex.get(match.round) ?? -1;
if (match.winnerId) updateLatest(match.winnerId, ri); if (match.winnerId) updateLatest(match.winnerId, ri);
if (match.loser?.id) updateLatest(match.loser.id, 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[]>(); const result = new Map<string, string[]>();

View file

@ -146,13 +146,16 @@ type TestMatch = Parameters<typeof computeEliminatedByRound>[0][number];
function makeCompleteMatch( function makeCompleteMatch(
round: string, round: string,
winnerId: string, winnerId: string,
loserId: string loserId: string,
overrides: Partial<{ participant1Id: string | null; participant2Id: string | null }> = {}
): TestMatch { ): TestMatch {
return { return {
round, round,
isComplete: true, isComplete: true,
winnerId, winnerId,
loser: { id: loserId, name: loserId }, loser: { id: loserId, name: loserId },
participant1Id: overrides.participant1Id ?? null,
participant2Id: overrides.participant2Id ?? null,
}; };
} }
@ -233,4 +236,58 @@ describe("computeEliminatedByRound", () => {
expect(result.get("Round of 16") ?? []).not.toContain("teamA"); expect(result.get("Round of 16") ?? []).not.toContain("teamA");
}); });
}); });
describe("NBA play-in bracket", () => {
const NBA_ROUNDS = [
"Play-In Round 1",
"Play-In Round 2",
"First Round",
"Conference Semifinals",
"Conference Finals",
"NBA Finals",
];
it("7v8 PIR1 loser placed in PIR2 is NOT shown as eliminated at PIR1", () => {
const matches: TestMatch[] = [
// PIR1 M1 (7v8): teamA wins, teamB loses but advances to PIR2
makeCompleteMatch("Play-In Round 1", "teamA", "teamB"),
// PIR1 M2 (9v10): teamC wins, teamD loses and is eliminated
makeCompleteMatch("Play-In Round 1", "teamC", "teamD"),
// PIR2 M1: teamB is placed as participant1 but match not yet played
{
round: "Play-In Round 2",
isComplete: false,
winnerId: null,
loser: null,
participant1Id: "teamB",
participant2Id: "teamC",
},
];
const result = computeEliminatedByRound(matches, NBA_ROUNDS);
// teamB (7v8 loser) should NOT be eliminated — they're in PIR2
expect(result.get("Play-In Round 1") ?? []).not.toContain("teamB");
// teamD (9v10 loser) IS eliminated — no future match
expect(result.get("Play-In Round 1")).toContain("teamD");
});
it("7v8 PIR1 loser who later loses PIR2 IS shown as eliminated at PIR2", () => {
const matches: TestMatch[] = [
makeCompleteMatch("Play-In Round 1", "teamA", "teamB"),
makeCompleteMatch("Play-In Round 1", "teamC", "teamD"),
// PIR2 now complete: teamB loses
makeCompleteMatch("Play-In Round 2", "teamC", "teamB"),
];
const result = computeEliminatedByRound(matches, NBA_ROUNDS);
// teamB not eliminated at PIR1 (appeared in PIR2)
expect(result.get("Play-In Round 1") ?? []).not.toContain("teamB");
// teamB IS eliminated at PIR2
expect(result.get("Play-In Round 2")).toContain("teamB");
// teamD still eliminated at PIR1
expect(result.get("Play-In Round 1")).toContain("teamD");
});
});
}); });

View file

@ -576,7 +576,7 @@ export async function action({ request, params }: Route.ActionArgs) {
} }
} }
if (intent === "recalculate-floors") { if (intent === "reprocess-bracket") {
try { try {
const event = await getScoringEventById(params.eventId); const event = await getScoringEventById(params.eventId);
if (!event) return { error: "Event not found" }; if (!event) return { error: "Event not found" };
@ -615,10 +615,6 @@ export async function action({ request, params }: Route.ActionArgs) {
}); });
for (const match of sortedMatches) { for (const match of sortedMatches) {
// Prefer the template-defined isScoring over the DB field: the DB column
// defaults to true, so legacy play-in rows that predate the column are
// incorrectly marked as scoring and would assign finalPosition=0 to losers
// who still have a second game (e.g. NBA 7v8 play-in loser → Round 2).
const isScoring = templateRoundIsScoring.get(match.round) ?? (match.isScoring ?? true); const isScoring = templateRoundIsScoring.get(match.round) ?? (match.isScoring ?? true);
await processMatchResult( await processMatchResult(
{ {
@ -634,66 +630,34 @@ export async function action({ request, params }: Route.ActionArgs) {
); );
} }
// skipDiscord: recalculate-floors is a data-correction tool, not a result announcement. // Mark participants NOT in any bracket match as eliminated (finalPosition = 0).
await recalculateAffectedLeagues(event.sportsSeasonId, undefined, { skipDiscord: true }); // This covers teams that didn't make the playoffs/play-in tournament.
return { success: `Recalculated floors from ${sortedMatches.length} completed match(es)` };
} catch (error) {
logger.error("Error recalculating floors:", error);
return {
error: error instanceof Error ? error.message : "Failed to recalculate floors",
};
}
}
if (intent === "reprocess-eliminations") {
try {
// Get the event
const event = await getScoringEventById(params.eventId);
if (!event) {
return { error: "Event not found" };
}
// Get all matches in the bracket
const matches = await findPlayoffMatchesByEventId(params.eventId);
if (matches.length === 0) {
return { error: "No bracket exists for this event" };
}
// Get all participants in the sports season
const allParticipants = await findParticipantsBySportsSeasonId(params.id); const allParticipants = await findParticipantsBySportsSeasonId(params.id);
const bracketParticipantIds = new Set<string>();
// Collect all participant IDs that appear in ANY match
const participantsInBracket = new Set<string>();
for (const match of matches) { for (const match of matches) {
if (match.participant1Id) participantsInBracket.add(match.participant1Id); if (match.participant1Id) bracketParticipantIds.add(match.participant1Id);
if (match.participant2Id) participantsInBracket.add(match.participant2Id); if (match.participant2Id) bracketParticipantIds.add(match.participant2Id);
} }
// Mark participants NOT in the bracket as eliminated
let eliminatedCount = 0; let eliminatedCount = 0;
for (const participant of allParticipants) { for (const participant of allParticipants) {
if (!participantsInBracket.has(participant.id)) { if (!bracketParticipantIds.has(participant.id)) {
await setParticipantResult( await setParticipantResult(
participant.id, participant.id,
event.sportsSeasonId, event.sportsSeasonId,
0 // 0 = didn't make playoffs, eliminated 0
); );
eliminatedCount++; eliminatedCount++;
} }
} }
logger.log(`[ReprocessEliminations] Marked ${eliminatedCount} participants as eliminated`); // skipDiscord: reprocess-bracket is a data-correction tool, not a result announcement.
await recalculateAffectedLeagues(event.sportsSeasonId, undefined, { skipDiscord: true });
return { return { success: `Reprocessed bracket: ${sortedMatches.length} match(es) replayed, ${eliminatedCount} non-bracket participant(s) eliminated` };
success: `Successfully marked ${eliminatedCount} participant(s) as eliminated`,
};
} catch (error) { } catch (error) {
logger.error("Error reprocessing eliminations:", error); logger.error("Error reprocessing bracket:", error);
return { return {
error: error: error instanceof Error ? error.message : "Failed to reprocess bracket",
error instanceof Error ? error.message : "Failed to reprocess eliminations",
}; };
} }
} }

View file

@ -323,55 +323,30 @@ export default function EventBracket({
</div> </div>
)} )}
{/* Recalculate Floors - Fix guaranteed minimum points for still-alive participants. {/* Reprocess Bracket - Full rebuild of participant results.
Hidden once every match is complete at that point all placements are final Hidden once every match is complete at that point all placements are final
and "finalize bracket" should be used instead. */} and "finalize bracket" should be used instead. */}
{matches.length > 0 && !matches.every((m) => m.isComplete) && ( {matches.length > 0 && !matches.every((m) => m.isComplete) && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Recalculate Floors</CardTitle> <CardTitle>Reprocess Bracket</CardTitle>
<CardDescription> <CardDescription>
Recompute guaranteed-minimum placements for participants still in the bracket. Replay all completed matches from scratch and re-mark non-bracket
Use this after scoring rule changes or when floor points look wrong. participants as eliminated. Use this to fix scoring data after rule
changes or when results look incorrect.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Form method="post"> <Form method="post">
<input type="hidden" name="intent" value="recalculate-floors" /> <input type="hidden" name="intent" value="reprocess-bracket" />
<Button type="submit" variant="outline"> <Button type="submit" variant="outline">
Recalculate Floors Reprocess Bracket
</Button> </Button>
</Form> </Form>
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Reprocess Eliminations - For existing brackets (non-group-stage) */}
{matches.length > 0 && !isGroupStageEvent && (
<Card>
<CardHeader>
<CardTitle>Reprocess Eliminations</CardTitle>
<CardDescription>
Mark participants not in this bracket as eliminated (for brackets created before automatic elimination tracking)
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post">
<input type="hidden" name="intent" value="reprocess-eliminations" />
<div className="space-y-4">
<div className="text-sm text-muted-foreground">
This will set finalPosition = 0 for all participants who are not in any playoff match,
allowing probability recalculation to set their odds to 0%.
</div>
<Button type="submit" variant="outline">
Reprocess Eliminations
</Button>
</div>
</Form>
</CardContent>
</Card>
)}
{/* ====== SETUP PHASE ====== */} {/* ====== SETUP PHASE ====== */}
{showSetup && ( {showSetup && (
<Card> <Card>