From b9a9eb4c4b0a7e8f68b370aad8c416c35008c21d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 18:19:52 +0000 Subject: [PATCH] Announce mirror knockouts from the admin bracket-scoring path too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live tennis-draw sync now fans eliminations out to mirror windows, but the admin bracket UI is a second live scoring path: when an admin enters match results for a shared qualifying major, mirror windows already receive "Qualifying Points Update" posts via the fan-out, yet the "Knocked Out" section was still dropped there. Thread the newly-decided losers (losers of matches reaching completion for the first time this action) through the admin route's fanOutMajorIfPrimary calls in the set-winner and set-round-winners intents, reusing the same per-window elimination translation. Re-scores, complete-round, reprocess, and finalize decide no new losers, so they pass nothing and never re-announce an exit — mirroring populateBracketFromDraw's first-completion rule. Extract the first-completion decision into a pure newlyDecidedLosers() helper shared by both intents and unit-tested directly. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WZGsG5R1q3kyKCaXuysiWJ --- ...min.sports-seasons.bracket.helpers.test.ts | 44 +++++++++++++++ ...ons.$id.events.$eventId.bracket.helpers.ts | 25 +++++++++ ...sons.$id.events.$eventId.bracket.server.ts | 54 +++++++++++++++---- 3 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 app/routes/__tests__/admin.sports-seasons.bracket.helpers.test.ts create mode 100644 app/routes/admin.sports-seasons.$id.events.$eventId.bracket.helpers.ts diff --git a/app/routes/__tests__/admin.sports-seasons.bracket.helpers.test.ts b/app/routes/__tests__/admin.sports-seasons.bracket.helpers.test.ts new file mode 100644 index 0000000..1230d64 --- /dev/null +++ b/app/routes/__tests__/admin.sports-seasons.bracket.helpers.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { newlyDecidedLosers } from "../admin.sports-seasons.$id.events.$eventId.bracket.helpers"; + +describe("newlyDecidedLosers", () => { + it("returns losers of matches that were not already complete", () => { + expect( + newlyDecidedLosers([ + { wasComplete: false, loserId: "sp-1" }, + { wasComplete: false, loserId: "sp-2" }, + ]) + ).toEqual(["sp-1", "sp-2"]); + }); + + it("drops re-scores of already-complete matches (no re-announce)", () => { + // sp-1's match just finished; sp-2's match was already complete before this + // action — only sp-1 is a NEW knockout. + expect( + newlyDecidedLosers([ + { wasComplete: false, loserId: "sp-1" }, + { wasComplete: true, loserId: "sp-2" }, + ]) + ).toEqual(["sp-1"]); + }); + + it("skips entries with an unknown loser", () => { + expect( + newlyDecidedLosers([ + { wasComplete: false, loserId: null }, + { wasComplete: false, loserId: "sp-3" }, + ]) + ).toEqual(["sp-3"]); + }); + + it("returns an empty array when nothing was newly decided", () => { + expect( + newlyDecidedLosers([ + { wasComplete: true, loserId: "sp-1" }, + { wasComplete: true, loserId: "sp-2" }, + ]) + ).toEqual([]); + expect(newlyDecidedLosers([])).toEqual([]); + }); +}); diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.helpers.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.helpers.ts new file mode 100644 index 0000000..bc38286 --- /dev/null +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.helpers.ts @@ -0,0 +1,25 @@ +/** + * Pure helpers for the bracket-scoring route action, extracted so the + * knockout-detection logic can be unit-tested without the full action harness. + */ + +/** + * From a set of matches being scored this action, return the season_participant + * ids knocked out for the FIRST time. A loser counts only when its match was NOT + * already complete — a re-score/correction of a finished match must not + * re-announce the exit (mirrors populateBracketFromDraw's first-completion rule in + * the live-sync path). Entries with an unknown loser (`null`) are skipped. + * + * The ids are season_participant ids of the primary window (playoff_matches are + * keyed by season_participant); the fan-out translates them to each mirror + * window's own season_participant id before announcing the "Knocked Out" section. + */ +export function newlyDecidedLosers( + entries: Array<{ wasComplete: boolean; loserId: string | null }> +): string[] { + return entries + .filter((e): e is { wasComplete: boolean; loserId: string } => + !e.wasComplete && e.loserId !== null + ) + .map((e) => e.loserId); +} 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 500858d..5c9f54e 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 @@ -68,6 +68,7 @@ import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.se import { fanOutMajorIfPrimary, syncMajorFromPrimaryEvent } from "~/services/sync-tournament-results"; import { syncTennisDraw, previewTennisDraw } from "~/services/match-sync"; import { articleTitleFromInput } from "~/services/match-sync/wikipedia-tennis"; +import { newlyDecidedLosers } from "./admin.sports-seasons.$id.events.$eventId.bracket.helpers"; export async function loader({ params }: Route.LoaderArgs) { const sportsSeason = await findSportsSeasonById(params.id); @@ -135,7 +136,15 @@ async function scoreQualifyingBracket( tournamentId: string | null; }, db: ReturnType, - recalcOptions?: Parameters[2] + recalcOptions?: Parameters[2], + /** + * Season_participant ids of players knocked out for the first time by this + * operation (losers of matches that just reached completion). Forwarded to the + * fan-out so every mirror window announces the "Knocked Out" section — a + * non-scoring-round exit earns no QP and is otherwise invisible to the mirror. + * Empty for re-scores/reprocesses, which decide no new losers. + */ + newlyEliminatedParticipantIds?: Set ): Promise { await processQualifyingBracketEvent(event.id, db); await recalculateAffectedLeagues( @@ -145,7 +154,10 @@ async function scoreQualifyingBracket( ); // If this is the shared major's primary window, propagate to siblings. // Mid-tournament (a single round): don't mark complete yet. - await fanOutMajorIfPrimary(event, { markComplete: false }); + await fanOutMajorIfPrimary(event, { + markComplete: false, + newlyEliminatedParticipantIds, + }); } /** @@ -398,6 +410,13 @@ export async function action({ request, params }: Route.ActionArgs) { return { error: "Could not determine loser" }; } + // A knockout is "newly decided" only when the match wasn't already complete + // (mirrors populateBracketFromDraw's first-completion rule) — a re-score/ + // correction of an already-finished match must not re-announce the exit. + const setWinnerNewlyEliminated = new Set( + newlyDecidedLosers([{ wasComplete: match.isComplete, loserId }]) + ); + // Set the winner await setMatchWinner(matchId, winnerId, loserId); @@ -424,11 +443,16 @@ export async function action({ request, params }: Route.ActionArgs) { if (event.isQualifyingEvent) { // Qualifying major (e.g. CS2): bracket results award QUALIFYING POINTS, not // fantasy points. matchIds scopes the Discord notification to just this match. - await scoreQualifyingBracket(event, db, { - eventId: event.id, - eventName: event.name ?? undefined, - matchIds: [matchId], - }); + await scoreQualifyingBracket( + event, + db, + { + eventId: event.id, + eventName: event.name ?? undefined, + matchIds: [matchId], + }, + setWinnerNewlyEliminated + ); } else { // Immediately score this match: loser gets their final placement, // winner gets provisional floor points (isPartialScore=true). @@ -500,6 +524,10 @@ export async function action({ request, params }: Route.ActionArgs) { let successCount = 0; const errors: string[] = []; const processedMatchIds: string[] = []; + // Per-match completion + loser, collected so newlyDecidedLosers() can pick out + // the batch's first-time knockouts to fan out to mirror windows (a non-scoring- + // round exit earns no QP and is otherwise invisible to the mirror). + const decidedEntries: Array<{ wasComplete: boolean; loserId: string | null }> = []; for (const { matchId, winnerId } of winnerAssignments) { try { @@ -568,6 +596,10 @@ export async function action({ request, params }: Route.ActionArgs) { successCount++; processedMatchIds.push(matchId); + // Only after the match fully succeeded: record its prior completion so + // newlyDecidedLosers() announces this loser only if it's a first-time exit + // (and never for a match whose write failed above). + decidedEntries.push({ wasComplete: match.isComplete, loserId }); } catch (error) { logger.error(`Error setting winner for match ${matchId}:`, error); errors.push( @@ -598,8 +630,12 @@ export async function action({ request, params }: Route.ActionArgs) { if (!event.isQualifyingEvent) { await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db); } else { - // Shared major primary window: propagate this round to siblings. - await fanOutMajorIfPrimary(event, { markComplete: false }); + // Shared major primary window: propagate this round to siblings, carrying + // the batch's newly-decided knockouts so mirrors announce them too. + await fanOutMajorIfPrimary(event, { + markComplete: false, + newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)), + }); } }