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:
parent
0eb486f8d3
commit
13bc1e3b5f
4 changed files with 88 additions and 86 deletions
|
|
@ -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[]>();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue