Fix NBA play-in elimination display and consolidate bracket reprocessing
- 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
64b59b4798
4 changed files with 88 additions and 86 deletions
|
|
@ -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[]>();
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue