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;
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 (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.
// 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;
@ -143,6 +147,8 @@ export function computeEliminatedByRound(
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[]>();

View file

@ -146,13 +146,16 @@ type TestMatch = Parameters<typeof computeEliminatedByRound>[0][number];
function makeCompleteMatch(
round: string,
winnerId: string,
loserId: string
loserId: string,
overrides: Partial<{ participant1Id: string | null; participant2Id: string | null }> = {}
): TestMatch {
return {
round,
isComplete: true,
winnerId,
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");
});
});
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 {
const event = await getScoringEventById(params.eventId);
if (!event) return { error: "Event not found" };
@ -615,10 +615,6 @@ export async function action({ request, params }: Route.ActionArgs) {
});
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);
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.
await recalculateAffectedLeagues(event.sportsSeasonId, undefined, { skipDiscord: true });
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
// Mark participants NOT in any bracket match as eliminated (finalPosition = 0).
// This covers teams that didn't make the playoffs/play-in tournament.
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
// Collect all participant IDs that appear in ANY match
const participantsInBracket = new Set<string>();
const bracketParticipantIds = new Set<string>();
for (const match of matches) {
if (match.participant1Id) participantsInBracket.add(match.participant1Id);
if (match.participant2Id) participantsInBracket.add(match.participant2Id);
if (match.participant1Id) bracketParticipantIds.add(match.participant1Id);
if (match.participant2Id) bracketParticipantIds.add(match.participant2Id);
}
// Mark participants NOT in the bracket as eliminated
let eliminatedCount = 0;
for (const participant of allParticipants) {
if (!participantsInBracket.has(participant.id)) {
if (!bracketParticipantIds.has(participant.id)) {
await setParticipantResult(
participant.id,
event.sportsSeasonId,
0 // 0 = didn't make playoffs, eliminated
0
);
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 {
success: `Successfully marked ${eliminatedCount} participant(s) as eliminated`,
};
return { success: `Reprocessed bracket: ${sortedMatches.length} match(es) replayed, ${eliminatedCount} non-bracket participant(s) eliminated` };
} catch (error) {
logger.error("Error reprocessing eliminations:", error);
logger.error("Error reprocessing bracket:", error);
return {
error:
error instanceof Error ? error.message : "Failed to reprocess eliminations",
error: error instanceof Error ? error.message : "Failed to reprocess bracket",
};
}
}

View file

@ -323,55 +323,30 @@ export default function EventBracket({
</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
and "finalize bracket" should be used instead. */}
{matches.length > 0 && !matches.every((m) => m.isComplete) && (
<Card>
<CardHeader>
<CardTitle>Recalculate Floors</CardTitle>
<CardTitle>Reprocess Bracket</CardTitle>
<CardDescription>
Recompute guaranteed-minimum placements for participants still in the bracket.
Use this after scoring rule changes or when floor points look wrong.
Replay all completed matches from scratch and re-mark non-bracket
participants as eliminated. Use this to fix scoring data after rule
changes or when results look incorrect.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post">
<input type="hidden" name="intent" value="recalculate-floors" />
<input type="hidden" name="intent" value="reprocess-bracket" />
<Button type="submit" variant="outline">
Recalculate Floors
Reprocess Bracket
</Button>
</Form>
</CardContent>
</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 ====== */}
{showSetup && (
<Card>