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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017AUcHy9m7aF6axsY6Grpe4
136 lines
4.9 KiB
TypeScript
136 lines
4.9 KiB
TypeScript
/**
|
||
* 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<string, typeof events>();
|
||
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);
|
||
});
|