From 13bc1e3b5f52baba5698386ef743b958bad9c16e Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:56:33 -0700 Subject: [PATCH] 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 --- app/components/scoring/PlayoffBracket.tsx | 14 +++-- .../scoring/__tests__/PlayoffBracket.test.tsx | 59 +++++++++++++++++- ...sons.$id.events.$eventId.bracket.server.ts | 62 ++++--------------- ...ts-seasons.$id.events.$eventId.bracket.tsx | 39 +++--------- 4 files changed, 88 insertions(+), 86 deletions(-) diff --git a/app/components/scoring/PlayoffBracket.tsx b/app/components/scoring/PlayoffBracket.tsx index 0083759..cd5238e 100644 --- a/app/components/scoring/PlayoffBracket.tsx +++ b/app/components/scoring/PlayoffBracket.tsx @@ -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 { 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(); 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(); diff --git a/app/components/scoring/__tests__/PlayoffBracket.test.tsx b/app/components/scoring/__tests__/PlayoffBracket.test.tsx index acb1ca6..3a76506 100644 --- a/app/components/scoring/__tests__/PlayoffBracket.test.tsx +++ b/app/components/scoring/__tests__/PlayoffBracket.test.tsx @@ -146,13 +146,16 @@ type TestMatch = Parameters[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"); + }); + }); }); diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts index 72242e0..4cdde33 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts @@ -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(); + const bracketParticipantIds = new Set(); 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", }; } } diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx index 8008ea4..e652362 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx @@ -323,55 +323,30 @@ export default function EventBracket({ )} - {/* 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) && ( - Recalculate Floors + Reprocess Bracket - 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.
- +
)} - {/* Reprocess Eliminations - For existing brackets (non-group-stage) */} - {matches.length > 0 && !isGroupStageEvent && ( - - - Reprocess Eliminations - - Mark participants not in this bracket as eliminated (for brackets created before automatic elimination tracking) - - - -
- -
-
- This will set finalPosition = 0 for all participants who are not in any playoff match, - allowing probability recalculation to set their odds to 0%. -
- -
-
-
-
- )} - {/* ====== SETUP PHASE ====== */} {showSetup && (