Announce mirror knockouts from the admin bracket-scoring path too
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m1s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m17s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WZGsG5R1q3kyKCaXuysiWJ
This commit is contained in:
Claude 2026-07-04 18:19:52 +00:00
parent 7ca89aafc4
commit b9a9eb4c4b
No known key found for this signature in database
3 changed files with 114 additions and 9 deletions

View file

@ -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([]);
});
});

View file

@ -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);
}

View file

@ -68,6 +68,7 @@ import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.se
import { fanOutMajorIfPrimary, syncMajorFromPrimaryEvent } from "~/services/sync-tournament-results"; import { fanOutMajorIfPrimary, syncMajorFromPrimaryEvent } from "~/services/sync-tournament-results";
import { syncTennisDraw, previewTennisDraw } from "~/services/match-sync"; import { syncTennisDraw, previewTennisDraw } from "~/services/match-sync";
import { articleTitleFromInput } from "~/services/match-sync/wikipedia-tennis"; 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) { export async function loader({ params }: Route.LoaderArgs) {
const sportsSeason = await findSportsSeasonById(params.id); const sportsSeason = await findSportsSeasonById(params.id);
@ -135,7 +136,15 @@ async function scoreQualifyingBracket(
tournamentId: string | null; tournamentId: string | null;
}, },
db: ReturnType<typeof database>, db: ReturnType<typeof database>,
recalcOptions?: Parameters<typeof recalculateAffectedLeagues>[2] recalcOptions?: Parameters<typeof recalculateAffectedLeagues>[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<string>
): Promise<void> { ): Promise<void> {
await processQualifyingBracketEvent(event.id, db); await processQualifyingBracketEvent(event.id, db);
await recalculateAffectedLeagues( await recalculateAffectedLeagues(
@ -145,7 +154,10 @@ async function scoreQualifyingBracket(
); );
// If this is the shared major's primary window, propagate to siblings. // If this is the shared major's primary window, propagate to siblings.
// Mid-tournament (a single round): don't mark complete yet. // 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" }; 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 // Set the winner
await setMatchWinner(matchId, winnerId, loserId); await setMatchWinner(matchId, winnerId, loserId);
@ -424,11 +443,16 @@ export async function action({ request, params }: Route.ActionArgs) {
if (event.isQualifyingEvent) { if (event.isQualifyingEvent) {
// Qualifying major (e.g. CS2): bracket results award QUALIFYING POINTS, not // Qualifying major (e.g. CS2): bracket results award QUALIFYING POINTS, not
// fantasy points. matchIds scopes the Discord notification to just this match. // fantasy points. matchIds scopes the Discord notification to just this match.
await scoreQualifyingBracket(event, db, { await scoreQualifyingBracket(
eventId: event.id, event,
eventName: event.name ?? undefined, db,
matchIds: [matchId], {
}); eventId: event.id,
eventName: event.name ?? undefined,
matchIds: [matchId],
},
setWinnerNewlyEliminated
);
} else { } else {
// Immediately score this match: loser gets their final placement, // Immediately score this match: loser gets their final placement,
// winner gets provisional floor points (isPartialScore=true). // winner gets provisional floor points (isPartialScore=true).
@ -500,6 +524,10 @@ export async function action({ request, params }: Route.ActionArgs) {
let successCount = 0; let successCount = 0;
const errors: string[] = []; const errors: string[] = [];
const processedMatchIds: 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) { for (const { matchId, winnerId } of winnerAssignments) {
try { try {
@ -568,6 +596,10 @@ export async function action({ request, params }: Route.ActionArgs) {
successCount++; successCount++;
processedMatchIds.push(matchId); 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) { } catch (error) {
logger.error(`Error setting winner for match ${matchId}:`, error); logger.error(`Error setting winner for match ${matchId}:`, error);
errors.push( errors.push(
@ -598,8 +630,12 @@ export async function action({ request, params }: Route.ActionArgs) {
if (!event.isQualifyingEvent) { if (!event.isQualifyingEvent) {
await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db); await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db);
} else { } else {
// Shared major primary window: propagate this round to siblings. // Shared major primary window: propagate this round to siblings, carrying
await fanOutMajorIfPrimary(event, { markComplete: false }); // the batch's newly-decided knockouts so mirrors announce them too.
await fanOutMajorIfPrimary(event, {
markComplete: false,
newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)),
});
} }
} }