From efec504c0804f37250ebb500e07d576d3d664e99 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 20:40:47 +0000 Subject: [PATCH 1/3] Fix qualifying points scoring, Discord rounding, and majors count Four related bugs in the Qualifying Points subsystem surfaced on Wimbledon: 1. Some leagues stored 2 QP instead of 1.5 for Round-of-16 losers. Sibling/ mirror windows scored via the placement-group path in processQualifyingEvent, which took the tie span from the players present on that window's roster (a draftable subset) instead of the full field. A window holding fewer than the 8 tied R16 losers split the 9-16 points too few ways and over-awarded. The tie span is now derived from the canonical tournament_results (the whole field) for tournament-linked events, falling back to the live count only for standalone events. Every league now scores identically. 2. Discord notifications rounded QP with Math.round, turning 1.5 into "2". Both the "Points Awarded" and "QP Standings" values now use a 2-decimal formatter mirroring the web UI's formatQP, so Discord and the site agree. 3. The Discord "QP Standings" block ranked players only among that event's scorers, showing two R16 losers as T1 instead of T9. Rank is now computed over the full season field and passed through to the notifier. 4. "N of M majors completed" reached "11 of 4": majorsCompleted was a stored counter incremented on every fan-out sync with a guard that misfired. It is now derived on read (getMajorsCompleted = count of completed qualifying events), which is self-correcting; the increment/decrement writes are removed along with the now-unused hasProcessedQualifyingPlacement helper. Adds scripts/backfill-qp-resplit.ts to silently re-score existing majors so already-corrupted seasons are corrected (2 -> 1.5), and threads a skipNotifications option through processQualifyingEvent / syncTournamentResults so the backfill does not re-ping every league. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017AUcHy9m7aF6axsY6Grpe4 --- .../__tests__/qualifying-points.test.ts | 47 ------ .../scoring-calculator-qualifying.test.ts | 92 +++++++++++- .../__tests__/scoring-event-delete.test.ts | 15 +- app/models/qualifying-points.ts | 15 -- app/models/scoring-calculator.ts | 51 ++++--- app/models/scoring-event.ts | 46 ++++-- ...sons.$id.events.$eventId.bracket.server.ts | 8 +- .../admin.sports-seasons.$id.events.server.ts | 9 +- ...d.sports-seasons.$sportsSeasonId.server.ts | 7 +- app/services/__tests__/discord.test.ts | 121 +++++++++++++--- .../__tests__/sync-tournament-results.test.ts | 8 +- app/services/discord.ts | 43 +++--- .../qualifying-points-discord.server.ts | 26 ++++ app/services/sync-tournament-results.ts | 21 ++- scripts/backfill-qp-resplit.ts | 136 ++++++++++++++++++ 15 files changed, 491 insertions(+), 154 deletions(-) create mode 100644 scripts/backfill-qp-resplit.ts diff --git a/app/models/__tests__/qualifying-points.test.ts b/app/models/__tests__/qualifying-points.test.ts index b74498a..dd262aa 100644 --- a/app/models/__tests__/qualifying-points.test.ts +++ b/app/models/__tests__/qualifying-points.test.ts @@ -3,7 +3,6 @@ import { calculateSplitQualifyingPoints, DEFAULT_QP_VALUES, diffChangedQualifyingPoints, - hasProcessedQualifyingPlacement, } from "../qualifying-points"; describe("diffChangedQualifyingPoints", () => { @@ -310,52 +309,6 @@ describe("Qualifying Points Configuration", () => { expect(totalQP2).toBe(20); // Should have 20 QP (1st place) }); - it("should not increment majorsCompleted when reprocessing", () => { - // First processing - let majorsCompleted = 0; - const wasAlreadyProcessed = false; - - if (!wasAlreadyProcessed) { - majorsCompleted += 1; - } - - expect(majorsCompleted).toBe(1); - - // Reprocessing (wasAlreadyProcessed = true) - const reprocessing = true; - - if (!reprocessing) { - majorsCompleted += 1; - } - - expect(majorsCompleted).toBe(1); // Should still be 1, not 2 - }); - }); - - describe("Processed event detection", () => { - it("treats a placed zero-QP result as already processed", () => { - expect( - hasProcessedQualifyingPlacement([ - { placement: 20, qualifyingPointsAwarded: "0.00" }, - ]) - ).toBe(true); - }); - - it("does not treat filler zero-QP rows without placements as processed", () => { - expect( - hasProcessedQualifyingPlacement([ - { placement: null, qualifyingPointsAwarded: "0" }, - ]) - ).toBe(false); - }); - - it("does not treat unprocessed placed rows as processed", () => { - expect( - hasProcessedQualifyingPlacement([ - { placement: 15, qualifyingPointsAwarded: null }, - ]) - ).toBe(false); - }); }); describe("Scoring Workflow", () => { diff --git a/app/models/__tests__/scoring-calculator-qualifying.test.ts b/app/models/__tests__/scoring-calculator-qualifying.test.ts index f2ef65f..2bd14cf 100644 --- a/app/models/__tests__/scoring-calculator-qualifying.test.ts +++ b/app/models/__tests__/scoring-calculator-qualifying.test.ts @@ -200,7 +200,14 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => { }); describe("processQualifyingEvent", () => { - function makeProcessQPMockDb(eventResults: Array>) { + function makeProcessQPMockDb( + eventResults: Array>, + options: { + tournamentId?: string | null; + canonicalPlacements?: number[]; + } = {} + ) { + const { tournamentId = null, canonicalPlacements = [] } = options; const setCalls: unknown[] = []; const updateChain = { set: (values: unknown) => { @@ -217,6 +224,7 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => { id: "event-1", sportsSeasonId: "sports-season-1", isQualifyingEvent: true, + tournamentId, sportsSeason: { majorsCompleted: 1 }, }), }, @@ -238,6 +246,13 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => { }), }, }, + // Canonical tournament_results lookup for the structural tie span: + // db.select({placement}).from(tournamentResults).where(...) → rows. + select: () => ({ + from: () => ({ + where: async () => canonicalPlacements.map((placement) => ({ placement })), + }), + }), update: () => updateChain, } as any; @@ -273,6 +288,81 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => { ); }); + it("splits a tied placement by the FULL canonical field, not the window's roster subset", async () => { + // Regression for the reported 2-vs-1.5 QP bug. Tennis Round-of-16 losers all + // land at placement 9 with a structural tie span of 8 (positions 9–16 → + // (2+2+2+2+1+1+1+1)/8 = 1.5). A sibling/mirror window only holds the drafted + // subset — here just 2 of the 8 tied players — but the split must still use 8, + // not the 2 present locally (which would wrongly give (2+2)/2 = 2.00). + const windowRows = [ + { + id: "result-1", + seasonParticipantId: "participant-1", + placement: 9, + qualifyingPointsAwarded: null, + scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" }, + }, + { + id: "result-2", + seasonParticipantId: "participant-2", + placement: 9, + qualifyingPointsAwarded: null, + scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" }, + }, + ]; + const { db, setCalls } = makeProcessQPMockDb(windowRows, { + tournamentId: "tournament-1", + canonicalPlacements: Array.from({ length: 8 }, () => 9), // full field: 8 at 9th + }); + + await processQualifyingEvent("event-1", db, { skipNotifications: true }); + + // Both present players earn the correct split of 1.50, not 2.00. + expect(setCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ qualifyingPointsAwarded: "1.50" }), + ]) + ); + expect(setCalls).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ qualifyingPointsAwarded: "2.00" }), + ]) + ); + }); + + it("falls back to the live roster count for standalone events with no tournament", async () => { + // No canonical field exists for a manual/standalone qualifying event, so the + // tie span is the players actually present: 2 players tied at 9th → + // (2+2)/2 = 2.00. This preserves existing behavior where there is no full field. + const windowRows = [ + { + id: "result-1", + seasonParticipantId: "participant-1", + placement: 9, + qualifyingPointsAwarded: null, + scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" }, + }, + { + id: "result-2", + seasonParticipantId: "participant-2", + placement: 9, + qualifyingPointsAwarded: null, + scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" }, + }, + ]; + const { db, setCalls } = makeProcessQPMockDb(windowRows, { + tournamentId: null, + }); + + await processQualifyingEvent("event-1", db, { skipNotifications: true }); + + expect(setCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ qualifyingPointsAwarded: "2.00" }), + ]) + ); + }); + it("does not increment majorsCompleted when reprocessing a placed zero-QP result", async () => { const { db, setCalls } = makeProcessQPMockDb([ { diff --git a/app/models/__tests__/scoring-event-delete.test.ts b/app/models/__tests__/scoring-event-delete.test.ts index 81e98f7..51804c1 100644 --- a/app/models/__tests__/scoring-event-delete.test.ts +++ b/app/models/__tests__/scoring-event-delete.test.ts @@ -15,7 +15,10 @@ vi.mock("../qualifying-points", async (importOriginal) => { import { deleteScoringEvent } from "../scoring-event"; describe("deleteScoringEvent", () => { - it("decrements majorsCompleted for a processed zero-QP qualifying event", async () => { + it("does not write majorsCompleted on delete (it is derived on read)", async () => { + // majorsCompleted is no longer a stored counter — it is computed via + // getMajorsCompleted from completed qualifying events. Deleting a qualifying + // event must therefore never issue a sportsSeasons.majorsCompleted update. const setCalls: unknown[] = []; const deleteChain = { where: vi.fn().mockResolvedValue(undefined) }; const updateChain = { @@ -43,12 +46,6 @@ describe("deleteScoringEvent", () => { }, ]), }, - sportsSeasons: { - findFirst: vi.fn().mockResolvedValue({ - id: "sports-season-1", - majorsCompleted: 1, - }), - }, }, transaction: vi.fn(async (callback) => callback(db)), delete: vi.fn(() => deleteChain), @@ -57,9 +54,9 @@ describe("deleteScoringEvent", () => { await deleteScoringEvent("event-1", db); - expect(setCalls).toEqual( + expect(setCalls).not.toEqual( expect.arrayContaining([ - expect.objectContaining({ majorsCompleted: 0 }), + expect.objectContaining({ majorsCompleted: expect.anything() }), ]) ); }); diff --git a/app/models/qualifying-points.ts b/app/models/qualifying-points.ts index 5812f33..722ffbd 100644 --- a/app/models/qualifying-points.ts +++ b/app/models/qualifying-points.ts @@ -75,21 +75,6 @@ export function calculateSplitQualifyingPoints( return roundQualifyingPoints(totalQP / tieCount); } -export function hasProcessedQualifyingPlacement( - results: Array<{ - placement?: number | null; - qualifyingPointsAwarded?: string | null; - }> -): boolean { - return results.some( - (result) => - result.placement !== null && - result.placement !== undefined && - result.qualifyingPointsAwarded !== null && - result.qualifyingPointsAwarded !== undefined - ); -} - /** * Initialize default qualifying point configuration for a sports season */ diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 190f1eb..59cd114 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -23,7 +23,6 @@ import { calculateSplitQualifyingPoints, diffChangedQualifyingPoints, getQPConfig, - hasProcessedQualifyingPlacement, recalculateParticipantQP, writeEventResultsQP, getQPStandings, @@ -854,7 +853,8 @@ export async function processQualifyingBracketEvent( */ export async function processQualifyingEvent( eventId: string, - providedDb?: ReturnType + providedDb?: ReturnType, + options: { skipNotifications?: boolean } = {} ): Promise { const db = providedDb || database(); @@ -877,9 +877,6 @@ export async function processQualifyingEvent( // Get all event results for this qualifying event const results = await getEventResults(eventId, db); - // Check if this was already processed (for majorsCompleted counter) - const wasAlreadyProcessed = hasProcessedQualifyingPlacement(results); - // Snapshot awarded QP before reprocessing so the Discord notification below can // announce only the participants whose QP actually changed. Without this, a // sibling window re-scored on every fan-out sync (syncTournamentResults) would @@ -914,6 +911,31 @@ export async function processQualifyingEvent( qpConfig.map((config) => [config.placement, parseFloat(config.points)]) ); + // Full-field tie span. The number of players tied at a placement is a property + // of the whole tournament field (canonical tournament_results), NOT of who + // happens to be on THIS window's roster. Sibling/mirror windows only hold the + // draftable subset of the field, so counting the placements present locally + // (group.length) under-counts a tied group and over-awards it: tennis R16 losers + // all sit at placement 9 with a structural span of 8 → (2+2+2+2+1+1+1+1)/8 = 1.5 + // QP; a window holding only 4 of them would wrongly split 4 ways → 2 QP. Deriving + // the span from canonical results keeps every window/league identical. Standalone + // events (no tournamentId) have no canonical field, so fall back to the live count. + let canonicalTieCountByPlacement: Map | null = null; + if (event.tournamentId) { + const canonicalResults = await db + .select({ placement: schema.tournamentResults.placement }) + .from(schema.tournamentResults) + .where(eq(schema.tournamentResults.tournamentId, event.tournamentId)); + canonicalTieCountByPlacement = new Map(); + for (const cr of canonicalResults) { + if (cr.placement === null) continue; + canonicalTieCountByPlacement.set( + cr.placement, + (canonicalTieCountByPlacement.get(cr.placement) ?? 0) + 1 + ); + } + } + // Group results by placement to handle ties const placementGroups = new Map(); for (const result of results) { @@ -926,7 +948,7 @@ export async function processQualifyingEvent( // Process each placement group and update event_results with QP awarded for (const [placement, group] of placementGroups) { - const tieCount = group.length; + const tieCount = canonicalTieCountByPlacement?.get(placement) ?? group.length; const qpPerParticipant = calculateSplitQualifyingPoints( placement, @@ -954,17 +976,10 @@ export async function processQualifyingEvent( await recalculateParticipantQP(participantId, event.sportsSeasonId, db); } - // Increment majorsCompleted counter (only if this is the first time processing) - if (!wasAlreadyProcessed) { - const sportsSeason = event.sportsSeason; - await db - .update(schema.sportsSeasons) - .set({ - majorsCompleted: (sportsSeason.majorsCompleted || 0) + 1, - updatedAt: new Date(), - }) - .where(eq(schema.sportsSeasons.id, event.sportsSeasonId)); - } + // NOTE: majorsCompleted is NOT a stored counter. It is derived on read via + // getMajorsCompleted() (count of completed qualifying events). Incrementing here + // per fan-out sync over-counted it past totalMajors ("11 of 4"), so the write was + // removed. See app/models/scoring-event.ts:getMajorsCompleted. logger.log( `[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants` @@ -981,7 +996,7 @@ export async function processQualifyingEvent( afterRows.map((r) => ({ id: r.seasonParticipantId, qp: r.qualifyingPointsAwarded })) ); - if (changedParticipantIds.size > 0) { + if (changedParticipantIds.size > 0 && !options.skipNotifications) { try { await notifyQualifyingPointsUpdate(event.sportsSeasonId, eventId, db, changedParticipantIds); } catch (error) { diff --git a/app/models/scoring-event.ts b/app/models/scoring-event.ts index fd6de2d..38dbcd9 100644 --- a/app/models/scoring-event.ts +++ b/app/models/scoring-event.ts @@ -3,7 +3,7 @@ import * as schema from "~/database/schema"; import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull, sql } from "drizzle-orm"; import type { BracketRegion } from "~/lib/bracket-templates"; import { recalculateAffectedLeagues } from "./scoring-calculator"; -import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points"; +import { recalculateParticipantQP } from "./qualifying-points"; import { findParticipantNamesByIds } from "./season-participant"; import { deleteTournament } from "./tournament"; import type { EventType } from "./scoring-event-types"; @@ -144,6 +144,33 @@ export async function getQualifyingEvents( }); } +/** + * Number of "majors completed" for a sports season, derived on read as the count of + * qualifying events that have been marked complete. Replaces the old stored + * sportsSeasons.majorsCompleted counter, which was incremented per fan-out sync and + * over-counted past totalMajors ("11 of 4"). Computing it makes the value + * self-correcting and immune to double-counting. + */ +export async function getMajorsCompleted( + sportsSeasonId: string, + providedDb?: ReturnType +): Promise { + const db = providedDb || database(); + + const rows = await db + .select({ count: sql`count(*)::int` }) + .from(schema.scoringEvents) + .where( + and( + eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), + eq(schema.scoringEvents.isQualifyingEvent, true), + eq(schema.scoringEvents.isComplete, true) + ) + ); + + return rows[0]?.count ?? 0; +} + /** * Update a scoring event */ @@ -246,13 +273,11 @@ export async function deleteScoringEvent( // For qualifying events: capture affected participant IDs before the cascade deletes // eventResults (which is how we know who had QP awarded from this event). let affectedParticipantIds: string[] = []; - let wasQPProcessed = false; if (event.isQualifyingEvent) { const results = await db.query.eventResults.findMany({ where: eq(schema.eventResults.scoringEventId, eventId), }); affectedParticipantIds = results.map((r) => r.seasonParticipantId); - wasQPProcessed = hasProcessedQualifyingPlacement(results); } await db.transaction(async (tx) => { @@ -275,18 +300,9 @@ export async function deleteScoringEvent( await recalculateParticipantQP(participantId, event.sportsSeasonId, db); } - // Decrement majorsCompleted if this event had already been processed - if (wasQPProcessed) { - const sportsSeason = await db.query.sportsSeasons.findFirst({ - where: eq(schema.sportsSeasons.id, event.sportsSeasonId), - }); - if (sportsSeason && (sportsSeason.majorsCompleted ?? 0) > 0) { - await db - .update(schema.sportsSeasons) - .set({ majorsCompleted: (sportsSeason.majorsCompleted ?? 1) - 1, updatedAt: new Date() }) - .where(eq(schema.sportsSeasons.id, event.sportsSeasonId)); - } - } + // majorsCompleted is derived on read (getMajorsCompleted), so deleting the event + // — which removes it from the completed-qualifying-event count — self-corrects the + // number. No stored counter to decrement. await recalculateAffectedLeagues(event.sportsSeasonId, db); } 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 cb7aebe..dba5c6a 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 @@ -882,9 +882,11 @@ export async function action({ request, params }: Route.ActionArgs) { // fantasy placements come from finalizeQualifyingPoints across all of them. if (event.isQualifyingEvent) { const db = database(); - // processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent), - // increments majorsCompleted, and recalcs participant QP totals. Season-wide - // fantasy finalization stays with finalizeQualifyingPoints across all majors. + // processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent) + // and recalcs participant QP totals. majorsCompleted is derived on read from + // completed qualifying events (see getMajorsCompleted) — marking this event + // complete below is what advances it. Season-wide fantasy finalization stays with + // finalizeQualifyingPoints across all majors. await processQualifyingEvent(params.eventId, db); await db .update(schema.scoringEvents) diff --git a/app/routes/admin.sports-seasons.$id.events.server.ts b/app/routes/admin.sports-seasons.$id.events.server.ts index 90b4968..3b7f7a8 100644 --- a/app/routes/admin.sports-seasons.$id.events.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.server.ts @@ -9,6 +9,7 @@ import { bulkCreateScoringEvents, ensurePrimaryEvent, countWindowsByTournament, + getMajorsCompleted, type CreateScoringEventData, } from "~/models/scoring-event"; import { isBracketMajor } from "~/lib/event-utils"; @@ -28,10 +29,14 @@ export async function loader({ params }: Route.LoaderArgs) { const events = await getScoringEventsForSportsSeason(params.id); - // For qualifying sports seasons, get QP standings with global ranks attached + // For qualifying sports seasons, get QP standings with global ranks attached. + // majorsCompleted is derived on read (count of completed qualifying events), not a + // stored counter — see getMajorsCompleted. let qpStandings = null; const scoringRules = null; + let majorsCompleted = 0; if (sportsSeason.scoringPattern === "qualifying_points") { + majorsCompleted = await getMajorsCompleted(params.id); const standings = await getQPStandings(params.id); let prevQP = -1; let prevRankStart = 1; @@ -88,7 +93,7 @@ export async function loader({ params }: Route.LoaderArgs) { }); return { - sportsSeason: sportsSeason as typeof sportsSeason & { + sportsSeason: { ...sportsSeason, majorsCompleted } as typeof sportsSeason & { sport: { id: string; name: string; type: string; slug: string }; }, events: eventsWithSharing, diff --git a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts index 4eeb5b8..2075586 100644 --- a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts +++ b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts @@ -14,6 +14,7 @@ import { getQPStandings } from "~/models/qualifying-points"; import { getUpcomingScoringEvents, getRecentCompletedEvents, + getMajorsCompleted, } from "~/models/scoring-event"; import { getRegularSeasonStandings } from "~/models/regular-season-standings"; import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value"; @@ -157,6 +158,7 @@ export async function loader(args: Route.LoaderArgs) { let seasonStandings: SeasonStanding[] = []; type QPStanding = Awaited>[number] & { globalRank: number }; let qpStandings: QPStanding[] = []; + let majorsCompleted = 0; // Group standings for group-stage events (e.g. FIFA World Cup) type RawGroupMatch = Awaited> extends Map> ? T : never; @@ -293,6 +295,9 @@ export async function loader(args: Route.LoaderArgs) { }, })); } else if (scoringPattern === "qualifying_points") { + // majorsCompleted is derived on read (count of completed qualifying events), not a + // stored counter — see getMajorsCompleted. + majorsCompleted = await getMajorsCompleted(sportsSeasonId); const standings = await getQPStandings(sportsSeasonId); // Compute global ranks with tie handling across the full field before filtering, // so the displayed rank numbers remain correct after undrafted/zero-QP rows are removed. @@ -341,7 +346,7 @@ export async function loader(args: Route.LoaderArgs) { return { league, season, - sportsSeason, + sportsSeason: { ...sportsSeason, majorsCompleted }, scoringPattern, playoffMatches, playoffRounds, diff --git a/app/services/__tests__/discord.test.ts b/app/services/__tests__/discord.test.ts index 72e2a50..60fb1c5 100644 --- a/app/services/__tests__/discord.test.ts +++ b/app/services/__tests__/discord.test.ts @@ -680,11 +680,35 @@ describe("sendQualifyingPointsUpdateNotification", () => { vi.stubGlobal("fetch", mockFetch(204)); }); - const BASE_ENTRIES = [ + // The caller now supplies each entry's rank in the FULL season field. This helper + // mirrors the production ranking (qualifying-points-discord.server.ts): sort by + // qpTotal desc, competition ranking with ties sharing the lower rank, so existing + // tests keep asserting ranks derived from qpTotal. + function withRanks(entries: T[]) { + const sorted = [...entries].sort((a, b) => b.qpTotal - a.qpTotal); + const rankByTotal = new Map(); + let prevTotal = Number.NaN; + let prevRank = 0; + sorted.forEach((e, i) => { + const rank = i > 0 && Math.abs(e.qpTotal - prevTotal) < 0.001 ? prevRank : i + 1; + rankByTotal.set(e.qpTotal, rank); + prevTotal = e.qpTotal; + prevRank = rank; + }); + const countByTotal = new Map(); + for (const e of entries) countByTotal.set(e.qpTotal, (countByTotal.get(e.qpTotal) ?? 0) + 1); + return entries.map((e) => ({ + ...e, + globalRank: rankByTotal.get(e.qpTotal) ?? 0, + globalRankTied: (countByTotal.get(e.qpTotal) ?? 0) > 1, + })); + } + + const BASE_ENTRIES = withRanks([ { participantName: "Novak Djokovic", qpEarned: 20, qpTotal: 45, ownerUsername: "alex" }, { participantName: "Carlos Alcaraz", qpEarned: 14, qpTotal: 34, ownerUsername: "chris" }, { participantName: "Rafael Nadal", qpEarned: 0, qpTotal: 20, ownerUsername: "alex" }, - ]; + ]); it("sends an embed with gold color and QP title", async () => { await sendQualifyingPointsUpdateNotification({ @@ -759,11 +783,11 @@ describe("sendQualifyingPointsUpdateNotification", () => { await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", - entries: [ + entries: withRanks([ { participantName: "Player A", qpEarned: 10, qpTotal: 25 }, { participantName: "Player B", qpEarned: 5, qpTotal: 25 }, { participantName: "Player C", qpEarned: 2, qpTotal: 10 }, - ], + ]), }); const desc = getDescription(); @@ -772,11 +796,74 @@ describe("sendQualifyingPointsUpdateNotification", () => { expect(desc).toContain("3\\. Player C"); }); + it("does not round fractional QP — a 1.5 QP award shows as 1.5, not 2", async () => { + // Regression for the reported bug: a tennis Round-of-16 loser earns 1.5 QP + // (positions 9–16 split) but Discord rounded it to 2 via Math.round. + await sendQualifyingPointsUpdateNotification({ + webhookUrl: WEBHOOK_URL, + seasonName: "Rumble League 2026", + sportName: "Tennis - Men", + eventName: "Wimbledon", + entries: [ + { + participantName: "Novak Djokovic", + qpEarned: 1.5, + qpTotal: 1.5, + globalRank: 9, + globalRankTied: true, + ownerUsername: "snarkymcgee", + }, + ], + }); + + const desc = getDescription(); + // Mirrors the web UI's formatQP: fractional QP renders with 2 decimals ("1.50"), + // never rounded to an integer. + expect(desc).toContain("+1.50 QP"); + expect(desc).not.toContain("+2 QP"); + expect(desc).toContain("1.50 QP"); + expect(desc).not.toContain("— 2 QP"); + }); + + it("ranks the standings by caller-provided full-field globalRank, not position among entries", async () => { + // Screenshot scenario: only two players scored this event, but they sit at T9 in + // the full season field (8 players ahead). They must show T9, not T1. + await sendQualifyingPointsUpdateNotification({ + webhookUrl: WEBHOOK_URL, + seasonName: "Rumble League 2026", + sportName: "Tennis - Men", + eventName: "Wimbledon", + entries: [ + { + participantName: "Novak Djokovic", + qpEarned: 1.5, + qpTotal: 1.5, + globalRank: 9, + globalRankTied: true, + ownerUsername: "snarkymcgee", + }, + { + participantName: "Jannik Sinner", + qpEarned: 1.5, + qpTotal: 1.5, + globalRank: 9, + globalRankTied: true, + ownerUsername: "tanay002", + }, + ], + }); + + const desc = getDescription(); + expect(desc).toContain("T9\\. Novak Djokovic (snarkymcgee) — 1.50 QP"); + expect(desc).toContain("T9\\. Jannik Sinner (tanay002) — 1.50 QP"); + expect(desc).not.toContain("T1\\."); + }); + it("uses Discord mention instead of username when ownerDiscordUserId provided", async () => { await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", - entries: [ + entries: withRanks([ { participantName: "Novak Djokovic", qpEarned: 20, @@ -784,7 +871,7 @@ describe("sendQualifyingPointsUpdateNotification", () => { ownerUsername: "alex", ownerDiscordUserId: "111222333", }, - ], + ]), }); const desc = getDescription(); @@ -796,10 +883,10 @@ describe("sendQualifyingPointsUpdateNotification", () => { await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", - entries: [ + entries: withRanks([ { participantName: "Player A", qpEarned: 20, qpTotal: 20, ownerDiscordUserId: "111" }, { participantName: "Player B", qpEarned: 0, qpTotal: 0, ownerDiscordUserId: "222" }, - ], + ]), }); const payload = getPayload(); @@ -864,9 +951,9 @@ describe("sendQualifyingPointsUpdateNotification", () => { await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", - entries: [ + entries: withRanks([ { participantName: "Player_One", qpEarned: 10, qpTotal: 10, ownerUsername: "user_name" }, - ], + ]), }); const desc = getDescription(); @@ -875,12 +962,14 @@ describe("sendQualifyingPointsUpdateNotification", () => { }); it("truncates description at 4096 characters", async () => { - const longEntries = Array.from({ length: 200 }, (_, i) => ({ - participantName: `Very Long Participant Name Number ${i}`, - qpEarned: i % 2 === 0 ? 5 : 0, - qpTotal: 200 - i, - ownerUsername: `owner_with_long_username_${i}`, - })); + const longEntries = withRanks( + Array.from({ length: 200 }, (_, i) => ({ + participantName: `Very Long Participant Name Number ${i}`, + qpEarned: i % 2 === 0 ? 5 : 0, + qpTotal: 200 - i, + ownerUsername: `owner_with_long_username_${i}`, + })) + ); await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, diff --git a/app/services/__tests__/sync-tournament-results.test.ts b/app/services/__tests__/sync-tournament-results.test.ts index c0a6cdc..8f40303 100644 --- a/app/services/__tests__/sync-tournament-results.test.ts +++ b/app/services/__tests__/sync-tournament-results.test.ts @@ -360,7 +360,7 @@ describe("syncTournamentResults", () => { expect(c?.qualifyingPointsAwarded).toBe("0"); expect(processQualifyingEvent).toHaveBeenCalledTimes(1); - expect(processQualifyingEvent).toHaveBeenCalledWith("ev-1", db); + expect(processQualifyingEvent).toHaveBeenCalledWith("ev-1", db, { skipNotifications: false }); }); // ------------------------------------------------------------------------- @@ -412,8 +412,8 @@ describe("syncTournamentResults", () => { expect(bRows.find((r) => r.seasonParticipantId === "sp-BZ")?.placement).toBe(3); expect(processQualifyingEvent).toHaveBeenCalledTimes(2); - expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db); - expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db); + expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db, { skipNotifications: false }); + expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db, { skipNotifications: false }); }); // ------------------------------------------------------------------------- @@ -723,7 +723,7 @@ describe("syncTournamentResults", () => { expect(report.windowsSynced).toBe(1); expect(processQualifyingEvent).toHaveBeenCalledTimes(1); - expect(processQualifyingEvent).toHaveBeenCalledWith("ev-SIBLING", db); + expect(processQualifyingEvent).toHaveBeenCalledWith("ev-SIBLING", db, { skipNotifications: false }); // No event_results written for the skipped primary window. expect( state.eventResults.some((r) => r.scoringEventId === "ev-PRIMARY") diff --git a/app/services/discord.ts b/app/services/discord.ts index 5ebfab5..501ef77 100644 --- a/app/services/discord.ts +++ b/app/services/discord.ts @@ -63,6 +63,16 @@ function escapeMarkdown(text: string): string { return text.replace(/[_*~`|\\]/g, "\\$&"); } +/** + * Format a QP value for display. QP is genuinely fractional (e.g. a tennis R16 loser + * earns 1.5 QP from the 9–16 split), so we must NOT round: show whole numbers plainly + * and fractional values to 2 decimals. Mirrors the web UI's formatQP + * (app/components/scoring/QualifyingPointsStandings.tsx) so Discord and the site agree. + */ +function formatQPValue(n: number): string { + return n % 1 === 0 ? n.toString() : n.toFixed(2); +} + export interface StandingEntry { teamId: string; teamName: string; @@ -251,6 +261,15 @@ export interface QPEventEntry { participantName: string; qpEarned: number; qpTotal: number; + /** + * The participant's rank in the FULL season QP standings (all participants), not + * their position among this event's scorers. Computed by the caller so the "QP + * Standings" block reflects the whole sport season — e.g. two R16 losers on 1.5 QP + * show as T9 (8 players ahead) rather than T1 among just the two of them. + */ + globalRank: number; + /** True when another participant in the full field shares this globalRank. */ + globalRankTied: boolean; ownerUsername?: string; ownerDiscordUserId?: string; } @@ -301,7 +320,7 @@ export async function sendQualifyingPointsUpdateNotification({ const label = managerLabel ? `${escapeMarkdown(e.participantName)} (${managerLabel})` : escapeMarkdown(e.participantName); - sections.push(`• **${label}** — +${Math.round(e.qpEarned)} QP`); + sections.push(`• **${label}** — +${formatQPValue(e.qpEarned)} QP`); } } @@ -338,33 +357,23 @@ export async function sendQualifyingPointsUpdateNotification({ } } - const sorted = [...entries].toSorted((a, b) => b.qpTotal - a.qpTotal); - // Compute ranks with tie detection - const ranks: number[] = []; - for (let i = 0; i < sorted.length; i++) { - if (i > 0 && sorted[i].qpTotal === sorted[i - 1].qpTotal) { - ranks.push(ranks[ranks.length - 1]); - } else { - ranks.push(i + 1); - } - } - const isTied = buildTiedRankChecker(ranks); + // Order by the participant's rank in the FULL season standings (globalRank), + // supplied by the caller, so the block reads as a season leaderboard slice. + const sorted = [...entries].toSorted((a, b) => a.globalRank - b.globalRank); // The standings block reflects QP earners; skip it entirely when this sync only // reported knockouts (no QP change) so we don't emit an empty header. if (sorted.length > 0) { sections.push("\n**QP Standings**"); - for (let i = 0; i < sorted.length; i++) { - const e = sorted[i]; - const r = ranks[i]; - const rankPrefix = isTied(r) ? `T${r}` : `${r}`; + for (const e of sorted) { + const rankPrefix = e.globalRankTied ? `T${e.globalRank}` : `${e.globalRank}`; const ownerLabel = e.ownerDiscordUserId ? `<@${e.ownerDiscordUserId}>` : e.ownerUsername ? escapeMarkdown(e.ownerUsername) : undefined; const managerLabel = ownerLabel ? ` (${ownerLabel})` : ""; - sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel} — ${Math.round(e.qpTotal)} QP`); + sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel} — ${formatQPValue(e.qpTotal)} QP`); } } diff --git a/app/services/qualifying-points-discord.server.ts b/app/services/qualifying-points-discord.server.ts index 5601b57..f85ee42 100644 --- a/app/services/qualifying-points-discord.server.ts +++ b/app/services/qualifying-points-discord.server.ts @@ -95,6 +95,29 @@ export async function notifyQualifyingPointsUpdate( qpTotals.map((t) => [t.participantId, parseFloat(t.totalQualifyingPoints)]) ); + // Rank every participant across the FULL season field (not just this event's + // scorers) so the notification's "QP Standings" block reads as a season-leaderboard + // slice and matches the website's globalRank. Standard competition ranking: ties + // share the lower rank (…, 9, 9, 11, …). Two R16 losers on 1.5 QP with 8 players + // ahead therefore render as T9, not T1 among just the two of them. + const rankedField = [...qpTotalById.entries()] + .map(([id, total]) => ({ id, total })) + .sort((a, b) => b.total - a.total); + const globalRankById = new Map(); + let prevTotal = Number.NaN; + let prevRank = 0; + rankedField.forEach((row, index) => { + const rank = + index > 0 && Math.abs(row.total - prevTotal) < 0.001 ? prevRank : index + 1; + globalRankById.set(row.id, rank); + prevTotal = row.total; + prevRank = rank; + }); + const countByRank = new Map(); + for (const rank of globalRankById.values()) { + countByRank.set(rank, (countByRank.get(rank) ?? 0) + 1); + } + // Batch-fetch all season + league metadata in one query const seasons = await db.query.seasons.findMany({ where: inArray(schema.seasons.id, seasonIds), @@ -158,10 +181,13 @@ export async function notifyQualifyingPointsUpdate( const entries: QPEventEntry[] = relevantParticipantIds.map((participantId) => { const ownerId = teamByParticipantId.get(participantId)?.ownerId ?? null; + const globalRank = globalRankById.get(participantId) ?? 0; return { participantName: participantNameById.get(participantId) ?? participantId, qpEarned: qpEarnedById.get(participantId) ?? 0, qpTotal: qpTotalById.get(participantId) ?? 0, + globalRank, + globalRankTied: (countByRank.get(globalRank) ?? 0) > 1, ownerUsername: ownerId ? (usernameByUserId.get(ownerId) ?? undefined) : undefined, ownerDiscordUserId: ownerId ? discordIdByUserId.get(ownerId) : undefined, }; diff --git a/app/services/sync-tournament-results.ts b/app/services/sync-tournament-results.ts index df86c6a..934a54e 100644 --- a/app/services/sync-tournament-results.ts +++ b/app/services/sync-tournament-results.ts @@ -35,6 +35,13 @@ export interface SyncOptions { * primary is already scored in place, so re-running it would be wasted work. */ skipEventId?: string; + /** + * Suppress ALL Discord notifications for this fan-out (both the per-window QP + * update from processQualifyingEvent and the league standings recalc). Used by + * one-off backfills that re-score historical events — the QP values change + * (e.g. a mis-split 2 → correct 1.5), which would otherwise re-ping every league. + */ + skipNotifications?: boolean; } /** @@ -57,7 +64,7 @@ export async function syncTournamentResults( options: SyncOptions = {} ): Promise { const db = database(); - const { markComplete = true, skipEventId } = options; + const { markComplete = true, skipEventId, skipNotifications = false } = options; const report: SyncReport = { tournamentId, @@ -176,7 +183,7 @@ export async function syncTournamentResults( } // 3d. Delegate to scoring engine inside the same transaction. - await processQualifyingEvent(ev.id, tx); + await processQualifyingEvent(ev.id, tx, { skipNotifications }); // 3e. Mark the window event complete (final-results sync only). This is // what makes "score once" actually complete every window — without it, @@ -213,8 +220,9 @@ export async function syncTournamentResults( eventName: w.eventName ?? undefined, // Mid-tournament fan-out (markComplete=false) updates standings silently; // the primary window already announced the round. Only the final sync - // (completion) announces to each window's leagues. - skipDiscord: !markComplete, + // (completion) announces to each window's leagues. A backfill + // (skipNotifications) is always silent. + skipDiscord: skipNotifications || !markComplete, }); } catch (e: unknown) { const msg = @@ -250,9 +258,9 @@ export async function syncTournamentResults( */ export async function syncMajorFromPrimaryEvent( primaryEventId: string, - options: { markComplete?: boolean } = {} + options: { markComplete?: boolean; skipNotifications?: boolean } = {} ): Promise { - const { markComplete = false } = options; + const { markComplete = false, skipNotifications = false } = options; const primaryEvent = await getScoringEventById(primaryEventId); if (!primaryEvent) { @@ -317,6 +325,7 @@ export async function syncMajorFromPrimaryEvent( return syncTournamentResults(tournamentId, { markComplete, skipEventId: primaryEventId, + skipNotifications, }); } diff --git a/scripts/backfill-qp-resplit.ts b/scripts/backfill-qp-resplit.ts new file mode 100644 index 0000000..8185f55 --- /dev/null +++ b/scripts/backfill-qp-resplit.ts @@ -0,0 +1,136 @@ +/** + * Backfill: re-score qualifying majors so mis-split QP is corrected. + * + * Sibling/mirror windows used to compute a placement's tie span from the players + * present on THAT window's roster (a subset of the field), so a tied group split + * fewer ways and over-awarded — tennis Round-of-16 losers landed at 2 QP instead of + * the correct 1.5 (positions 9–16: (2+2+2+2+1+1+1+1)/8). processQualifyingEvent now + * derives the tie span from the canonical full field, so re-running the fan-out + * rewrites the stored event_results QP, participant totals, and league standings. + * + * This is idempotent and SILENT: notifications are suppressed so re-scoring history + * (2 → 1.5 for many participants) does not re-ping every league's Discord. It does + * NOT re-link events or designate primaries — run backfill-major-linking.ts first if + * the data predates the shared-major model. majorsCompleted needs no fix (it is now + * derived on read from completed qualifying events). + * + * Safe to re-run. Validate on a DB snapshot first. Reads DATABASE_URL. + * + * npx tsx scripts/backfill-qp-resplit.ts # apply + * npx tsx scripts/backfill-qp-resplit.ts --dry # report only + */ + +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import { eq } from "drizzle-orm"; +import * as schema from "../database/schema.js"; +import { DatabaseContext, database } from "../database/context.js"; +import { isBracketMajor } from "../app/lib/event-utils.js"; +import { + syncTournamentResults, + syncMajorFromPrimaryEvent, +} from "../app/services/sync-tournament-results.js"; + +const DRY = process.argv.includes("--dry"); +const log = (...a: unknown[]) => console.log(...a); + +async function run() { + const db = database(); + + // Every tournament-linked qualifying event, grouped by canonical tournament. + const events = await db.query.scoringEvents.findMany({ + where: eq(schema.scoringEvents.isQualifyingEvent, true), + with: { sportsSeason: { with: { sport: true } } }, + }); + const byTournament = new Map(); + for (const ev of events) { + if (!ev.tournamentId) continue; // standalone/manual events: nothing to fan out + const arr = byTournament.get(ev.tournamentId) ?? []; + arr.push(ev); + byTournament.set(ev.tournamentId, arr); + } + + log(`Tournaments with linked qualifying events: ${byTournament.size}`); + let ok = 0; + let failed = 0; + + for (const [tournamentId, evs] of byTournament) { + const simulatorType = evs[0]?.sportsSeason?.sport?.simulatorType ?? null; + const bracketMajor = isBracketMajor(simulatorType); + const name = evs[0]?.name ?? tournamentId; + + // Only re-score fully-scored majors so we don't mark in-progress ones complete. + const anyComplete = evs.some((e) => e.isComplete); + const markComplete = evs.every((e) => e.isComplete); + + if (DRY) { + log( + ` (dry) ${name}: ${evs.length} window(s), bracket=${bracketMajor}, ` + + `complete=${markComplete} — would re-score silently` + ); + continue; + } + + try { + if (bracketMajor) { + // Re-derive canonical from the primary, then fan out to siblings (which now + // split ties by the full field). Primary is scored in place and skipped. + const primary = evs.find((e) => e.isPrimary); + if (!primary) { + log(` ! ${name}: no primary window — skipping (run backfill-major-linking.ts)`); + failed += 1; + continue; + } + const report = await syncMajorFromPrimaryEvent(primary.id, { + markComplete, + skipNotifications: true, + }); + ok += report.windowsSynced; + failed += report.windowsFailed; + log( + ` ${name}: re-scored via primary — ${report.windowsSynced} ok, ${report.windowsFailed} failed` + ); + } else { + // Golf/placement: canonical tournament_results already exist; just fan out. + if (!anyComplete) { + log(` ${name}: no completed window — skipping`); + continue; + } + const report = await syncTournamentResults(tournamentId, { + markComplete, + skipNotifications: true, + }); + ok += report.windowsSynced; + failed += report.windowsFailed; + log( + ` ${name}: re-scored — ${report.windowsSynced} ok, ${report.windowsFailed} failed` + ); + } + } catch (e) { + failed += 1; + log(` ! ${name}: ${(e as Error).message}`); + } + } + + log(`\nDone${DRY ? " (dry run — no writes)" : ""}. windows ok=${ok}, failed=${failed}.`); +} + +async function main() { + const dbUrl = process.env.DATABASE_URL; + if (!dbUrl) { + console.error("ERROR: DATABASE_URL is required"); + process.exit(1); + } + const client = postgres(dbUrl, { max: 1 }); + const db = drizzle(client, { schema }); + try { + await DatabaseContext.run(db, run); + } finally { + await client.end(); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); -- 2.45.3 From ad238e6bfb24e78e4832f031cd9a865299576890 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 21:17:18 +0000 Subject: [PATCH 2/3] Address code review: route cloned windows correctly, dedupe canonical query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the QP scoring fix, from self-review: - Cloned qualifying windows carry a bracketTemplateId (copied by cloneSportsSeason) but no playoff matches, so they hit the bracket branch, where processQualifyingBracketEvent derives zero states and writes no QP — the canonical-tie-count fix never ran for them. processQualifyingEvent now routes to the bracket writer only when the window actually has playoff matches; otherwise it falls through to the placement/canonical path like any no-template sibling. - The canonical tournament_results tie span was queried once per window inside the fan-out loop. syncTournamentResults already loads the canonical results, so it now builds the tie-count map once (buildTieCountByPlacement) and passes it to each processQualifyingEvent call; direct callers still query it lazily. - syncTournamentResults no longer re-stamps completedAt on windows that are already complete, so a backfill re-run doesn't churn timestamps. Adds regression tests: a cloned window (template id, no matches) scores R16 losers at 1.5 via the canonical path. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017AUcHy9m7aF6axsY6Grpe4 --- .../scoring-calculator-qualifying.test.ts | 76 +++++++++++++++++-- app/models/scoring-calculator.ts | 75 +++++++++++++----- .../__tests__/sync-tournament-results.test.ts | 30 +++++++- app/services/sync-tournament-results.ts | 15 +++- 4 files changed, 164 insertions(+), 32 deletions(-) diff --git a/app/models/__tests__/scoring-calculator-qualifying.test.ts b/app/models/__tests__/scoring-calculator-qualifying.test.ts index 2bd14cf..14c838f 100644 --- a/app/models/__tests__/scoring-calculator-qualifying.test.ts +++ b/app/models/__tests__/scoring-calculator-qualifying.test.ts @@ -205,9 +205,16 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => { options: { tournamentId?: string | null; canonicalPlacements?: number[]; + bracketTemplateId?: string | null; + playoffMatchIds?: string[]; } = {} ) { - const { tournamentId = null, canonicalPlacements = [] } = options; + const { + tournamentId = null, + canonicalPlacements = [], + bracketTemplateId = null, + playoffMatchIds = [], + } = options; const setCalls: unknown[] = []; const updateChain = { set: (values: unknown) => { @@ -225,6 +232,7 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => { sportsSeasonId: "sports-season-1", isQualifyingEvent: true, tournamentId, + bracketTemplateId, sportsSeason: { majorsCompleted: 1 }, }), }, @@ -246,13 +254,23 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => { }), }, }, - // Canonical tournament_results lookup for the structural tie span: - // db.select({placement}).from(tournamentResults).where(...) → rows. - select: () => ({ - from: () => ({ - where: async () => canonicalPlacements.map((placement) => ({ placement })), - }), - }), + // Two select shapes flow through the non-bracket path: + // • playoff-match existence check (has an `id` projection, uses .limit(1)) + // • canonical tournament_results tie span (has a `placement` projection). + // Discriminate by the projection keys and return a thenable that also + // exposes .limit so both call shapes resolve. + select: (fields: Record) => { + const isPlayoff = fields && "id" in fields; + const rows = isPlayoff + ? playoffMatchIds.map((id) => ({ id })) + : canonicalPlacements.map((placement) => ({ placement })); + return { + from: () => ({ + where: () => + Object.assign(Promise.resolve(rows), { limit: async () => rows }), + }), + }; + }, update: () => updateChain, } as any; @@ -330,6 +348,48 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => { ); }); + it("scores a cloned window (bracketTemplateId but no playoff matches) via the canonical path", async () => { + // cloneSportsSeason copies bracketTemplateId to league windows but not the + // playoff matches. Such a window must NOT take the bracket branch (which would + // derive zero states and write no QP) — it has to fall through to the + // placement/canonical path and still split R16 losers to 1.5. + const windowRows = [ + { + id: "result-1", + seasonParticipantId: "participant-1", + placement: 9, + qualifyingPointsAwarded: null, + scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" }, + }, + { + id: "result-2", + seasonParticipantId: "participant-2", + placement: 9, + qualifyingPointsAwarded: null, + scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" }, + }, + ]; + const { db, setCalls } = makeProcessQPMockDb(windowRows, { + tournamentId: "tournament-1", + bracketTemplateId: "tennis_128", // copied by clone… + playoffMatchIds: [], // …but no matches exist on this window + canonicalPlacements: Array.from({ length: 8 }, () => 9), + }); + + await processQualifyingEvent("event-1", db, { skipNotifications: true }); + + expect(setCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ qualifyingPointsAwarded: "1.50" }), + ]) + ); + expect(setCalls).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ qualifyingPointsAwarded: "2.00" }), + ]) + ); + }); + it("falls back to the live roster count for standalone events with no tournament", async () => { // No canonical field exists for a manual/standalone qualifying event, so the // tie span is the players actually present: 2 players tied at 9th → diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 59cd114..2d2a834 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -847,6 +847,23 @@ export async function processQualifyingBracketEvent( await writeEventResultsQP(eventId, event.sportsSeasonId, entries, db); } +/** + * Count how many results share each placement — the structural tie span used to + * split QP across a tied group. Callers pass the FULL canonical field + * (tournament_results) so the span reflects the whole tournament, not one window's + * roster subset. Null placements (filler / not-participating) are ignored. + */ +export function buildTieCountByPlacement( + results: Array<{ placement: number | null }> +): Map { + const map = new Map(); + for (const r of results) { + if (r.placement === null) continue; + map.set(r.placement, (map.get(r.placement) ?? 0) + 1); + } + return map; +} + /** * Process a qualifying event completion and update QP totals. * Ties in QP are handled by sharing placements (averaged points). @@ -854,7 +871,16 @@ export async function processQualifyingBracketEvent( export async function processQualifyingEvent( eventId: string, providedDb?: ReturnType, - options: { skipNotifications?: boolean } = {} + options: { + skipNotifications?: boolean; + /** + * Pre-computed full-field tie span (placement → count) from the canonical + * tournament_results. When the fan-out already loaded the canonical results it + * passes this in so we don't re-query per window. Omitted for direct callers, + * which fall back to querying it here. + */ + canonicalTieCountByPlacement?: Map; + } = {} ): Promise { const db = providedDb || database(); @@ -886,7 +912,23 @@ export async function processQualifyingEvent( qp: r.qualifyingPointsAwarded, })); + // Route to the bracket writer only when this window actually OWNS a bracket (has + // playoff matches). A window can carry a bracketTemplateId with no matches — e.g. a + // league window cloned from a bracket season copies the template id but not the + // matches (cloneSportsSeason) — and processQualifyingBracketEvent would derive zero + // states and write NO QP. Those windows must be scored via the placement/canonical + // path below, exactly like a no-template sibling. + let hasBracketMatches = false; if (event.bracketTemplateId) { + const existing = await db + .select({ id: schema.playoffMatches.id }) + .from(schema.playoffMatches) + .where(eq(schema.playoffMatches.scoringEventId, eventId)) + .limit(1); + hasBracketMatches = existing.length > 0; + } + + if (hasBracketMatches) { // Bracket-based qualifying event (e.g. CS2 Champions Stage): QP is owned by the // bracket/stage writers, which assign each placement its STRUCTURAL tie span. // Re-derive via processQualifyingBracketEvent and leave the Swiss-exit rows @@ -918,23 +960,20 @@ export async function processQualifyingEvent( // (group.length) under-counts a tied group and over-awards it: tennis R16 losers // all sit at placement 9 with a structural span of 8 → (2+2+2+2+1+1+1+1)/8 = 1.5 // QP; a window holding only 4 of them would wrongly split 4 ways → 2 QP. Deriving - // the span from canonical results keeps every window/league identical. Standalone - // events (no tournamentId) have no canonical field, so fall back to the live count. - let canonicalTieCountByPlacement: Map | null = null; - if (event.tournamentId) { - const canonicalResults = await db - .select({ placement: schema.tournamentResults.placement }) - .from(schema.tournamentResults) - .where(eq(schema.tournamentResults.tournamentId, event.tournamentId)); - canonicalTieCountByPlacement = new Map(); - for (const cr of canonicalResults) { - if (cr.placement === null) continue; - canonicalTieCountByPlacement.set( - cr.placement, - (canonicalTieCountByPlacement.get(cr.placement) ?? 0) + 1 - ); - } - } + // the span from canonical results keeps every window/league identical. The fan-out + // passes this map in (already loaded once per tournament); direct callers with a + // tournament link query it here. Standalone events (no tournamentId, no map) have + // no canonical field, so fall back to the live count. + const canonicalTieCountByPlacement: Map | null = + options.canonicalTieCountByPlacement ?? + (event.tournamentId + ? buildTieCountByPlacement( + await db + .select({ placement: schema.tournamentResults.placement }) + .from(schema.tournamentResults) + .where(eq(schema.tournamentResults.tournamentId, event.tournamentId)) + ) + : null); // Group results by placement to handle ties const placementGroups = new Map(); diff --git a/app/services/__tests__/sync-tournament-results.test.ts b/app/services/__tests__/sync-tournament-results.test.ts index 8f40303..024eea7 100644 --- a/app/services/__tests__/sync-tournament-results.test.ts +++ b/app/services/__tests__/sync-tournament-results.test.ts @@ -8,6 +8,16 @@ vi.mock("~/database/context", () => ({ vi.mock("~/models/scoring-calculator", () => ({ processQualifyingEvent: vi.fn(), recalculateAffectedLeagues: vi.fn(), + // Pure helper — keep the real implementation so the tie-count map handed to + // processQualifyingEvent reflects the mock canonical results. + buildTieCountByPlacement: (results: Array<{ placement: number | null }>) => { + const map = new Map(); + for (const r of results) { + if (r.placement === null) continue; + map.set(r.placement, (map.get(r.placement) ?? 0) + 1); + } + return map; + }, })); vi.mock("~/models/scoring-event", () => ({ @@ -360,7 +370,10 @@ describe("syncTournamentResults", () => { expect(c?.qualifyingPointsAwarded).toBe("0"); expect(processQualifyingEvent).toHaveBeenCalledTimes(1); - expect(processQualifyingEvent).toHaveBeenCalledWith("ev-1", db, { skipNotifications: false }); + expect(processQualifyingEvent).toHaveBeenCalledWith("ev-1", db, { + skipNotifications: false, + canonicalTieCountByPlacement: expect.any(Map), + }); }); // ------------------------------------------------------------------------- @@ -412,8 +425,14 @@ describe("syncTournamentResults", () => { expect(bRows.find((r) => r.seasonParticipantId === "sp-BZ")?.placement).toBe(3); expect(processQualifyingEvent).toHaveBeenCalledTimes(2); - expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db, { skipNotifications: false }); - expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db, { skipNotifications: false }); + expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db, { + skipNotifications: false, + canonicalTieCountByPlacement: expect.any(Map), + }); + expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db, { + skipNotifications: false, + canonicalTieCountByPlacement: expect.any(Map), + }); }); // ------------------------------------------------------------------------- @@ -723,7 +742,10 @@ describe("syncTournamentResults", () => { expect(report.windowsSynced).toBe(1); expect(processQualifyingEvent).toHaveBeenCalledTimes(1); - expect(processQualifyingEvent).toHaveBeenCalledWith("ev-SIBLING", db, { skipNotifications: false }); + expect(processQualifyingEvent).toHaveBeenCalledWith("ev-SIBLING", db, { + skipNotifications: false, + canonicalTieCountByPlacement: expect.any(Map), + }); // No event_results written for the skipped primary window. expect( state.eventResults.some((r) => r.scoringEventId === "ev-PRIMARY") diff --git a/app/services/sync-tournament-results.ts b/app/services/sync-tournament-results.ts index 934a54e..76d88a4 100644 --- a/app/services/sync-tournament-results.ts +++ b/app/services/sync-tournament-results.ts @@ -4,6 +4,7 @@ import * as schema from "~/database/schema"; import { processQualifyingEvent, recalculateAffectedLeagues, + buildTieCountByPlacement, } from "~/models/scoring-calculator"; import { completeScoringEvent, getScoringEventById } from "~/models/scoring-event"; import { getEventResults } from "~/models/event-result"; @@ -79,6 +80,11 @@ export async function syncTournamentResults( .from(schema.tournamentResults) .where(eq(schema.tournamentResults.tournamentId, tournamentId)); + // The full-field tie span (placement → count) is a property of the whole + // tournament, so compute it once here and hand it to every window's + // processQualifyingEvent instead of re-querying canonical results per window. + const canonicalTieCountByPlacement = buildTieCountByPlacement(canonicalResults); + // 2. Load every scoring_event that points at this tournament. const allLinkedEvents = await db .select() @@ -183,12 +189,17 @@ export async function syncTournamentResults( } // 3d. Delegate to scoring engine inside the same transaction. - await processQualifyingEvent(ev.id, tx, { skipNotifications }); + await processQualifyingEvent(ev.id, tx, { + skipNotifications, + canonicalTieCountByPlacement, + }); // 3e. Mark the window event complete (final-results sync only). This is // what makes "score once" actually complete every window — without it, // each sibling stayed "In Progress" and had to be completed by hand. - if (markComplete) { + // Skip windows already complete so a re-run (e.g. a backfill) doesn't + // needlessly re-stamp completedAt. + if (markComplete && !ev.isComplete) { await completeScoringEvent(ev.id, tx); } }); -- 2.45.3 From 83e41e9cf115b429db0f669b6411f67b34c0c032 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 21:33:25 +0000 Subject: [PATCH 3/3] Fix lint errors from oxlint: array-sort and function-scoping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use Array#toSorted() instead of Array#sort() in the QP global-rank builder (qualifying-points-discord.server.ts) and the test's withRanks helper. - Move the discord.test.ts withRanks helper to module scope — it captured nothing from the enclosing describe block, which oxlint's consistent-function-scoping rule flags as an error. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017AUcHy9m7aF6axsY6Grpe4 --- app/services/__tests__/discord.test.ts | 48 +++++++++---------- .../qualifying-points-discord.server.ts | 2 +- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/app/services/__tests__/discord.test.ts b/app/services/__tests__/discord.test.ts index 60fb1c5..242a114 100644 --- a/app/services/__tests__/discord.test.ts +++ b/app/services/__tests__/discord.test.ts @@ -675,35 +675,35 @@ describe("sendDraftOrderNotification", () => { }); }); +// The caller now supplies each entry's rank in the FULL season field. This helper +// mirrors the production ranking (qualifying-points-discord.server.ts): sort by +// qpTotal desc, competition ranking with ties sharing the lower rank, so existing +// tests keep asserting ranks derived from qpTotal. +function withRanks(entries: T[]) { + const sorted = [...entries].toSorted((a, b) => b.qpTotal - a.qpTotal); + const rankByTotal = new Map(); + let prevTotal = Number.NaN; + let prevRank = 0; + sorted.forEach((e, i) => { + const rank = i > 0 && Math.abs(e.qpTotal - prevTotal) < 0.001 ? prevRank : i + 1; + rankByTotal.set(e.qpTotal, rank); + prevTotal = e.qpTotal; + prevRank = rank; + }); + const countByTotal = new Map(); + for (const e of entries) countByTotal.set(e.qpTotal, (countByTotal.get(e.qpTotal) ?? 0) + 1); + return entries.map((e) => ({ + ...e, + globalRank: rankByTotal.get(e.qpTotal) ?? 0, + globalRankTied: (countByTotal.get(e.qpTotal) ?? 0) > 1, + })); +} + describe("sendQualifyingPointsUpdateNotification", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch(204)); }); - // The caller now supplies each entry's rank in the FULL season field. This helper - // mirrors the production ranking (qualifying-points-discord.server.ts): sort by - // qpTotal desc, competition ranking with ties sharing the lower rank, so existing - // tests keep asserting ranks derived from qpTotal. - function withRanks(entries: T[]) { - const sorted = [...entries].sort((a, b) => b.qpTotal - a.qpTotal); - const rankByTotal = new Map(); - let prevTotal = Number.NaN; - let prevRank = 0; - sorted.forEach((e, i) => { - const rank = i > 0 && Math.abs(e.qpTotal - prevTotal) < 0.001 ? prevRank : i + 1; - rankByTotal.set(e.qpTotal, rank); - prevTotal = e.qpTotal; - prevRank = rank; - }); - const countByTotal = new Map(); - for (const e of entries) countByTotal.set(e.qpTotal, (countByTotal.get(e.qpTotal) ?? 0) + 1); - return entries.map((e) => ({ - ...e, - globalRank: rankByTotal.get(e.qpTotal) ?? 0, - globalRankTied: (countByTotal.get(e.qpTotal) ?? 0) > 1, - })); - } - const BASE_ENTRIES = withRanks([ { participantName: "Novak Djokovic", qpEarned: 20, qpTotal: 45, ownerUsername: "alex" }, { participantName: "Carlos Alcaraz", qpEarned: 14, qpTotal: 34, ownerUsername: "chris" }, diff --git a/app/services/qualifying-points-discord.server.ts b/app/services/qualifying-points-discord.server.ts index f85ee42..5acb909 100644 --- a/app/services/qualifying-points-discord.server.ts +++ b/app/services/qualifying-points-discord.server.ts @@ -102,7 +102,7 @@ export async function notifyQualifyingPointsUpdate( // ahead therefore render as T9, not T1 among just the two of them. const rankedField = [...qpTotalById.entries()] .map(([id, total]) => ({ id, total })) - .sort((a, b) => b.total - a.total); + .toSorted((a, b) => b.total - a.total); const globalRankById = new Map(); let prevTotal = Number.NaN; let prevRank = 0; -- 2.45.3