brackt/scripts/backfill-qp-resplit.ts
Claude 929e30c58c
Add --name/--sport filters to the QP backfill script
The reported bug was scoped to Wimbledon, and men's/women's majors share the
same tournament name ("Wimbledon") with gender only distinguished by the
sport, so a name-only filter can't isolate one gender. Add --sport (matched
against the sport's name, e.g. "Tennis - Men") alongside --name, and log the
sport name per tournament in --dry output so the target is easy to verify
before running for real.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017AUcHy9m7aF6axsY6Grpe4
2026-07-03 21:51:23 +00:00

170 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 916: (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, all tournaments
* npx tsx scripts/backfill-qp-resplit.ts --dry # report only
* npx tsx scripts/backfill-qp-resplit.ts --name Wimbledon # scope to matching event names
* npx tsx scripts/backfill-qp-resplit.ts --name Wimbledon --sport Men # + scope to a sport (e.g. gender)
* npx tsx scripts/backfill-qp-resplit.ts --name Wimbledon --sport Men --dry
*
* --name matches case-insensitively against each linked scoring event's name;
* matches ALL tournaments with a matching event (e.g. both years of "Wimbledon"),
* since the reported bug spans seasons. Tournament names don't encode gender (both
* men's and women's majors are just "Wimbledon") — use --sport to scope further,
* matched case-insensitively against the sport's name (e.g. "Tennis - Men").
* Both filters apply to a tournament if ANY of its linked events match.
*/
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");
function argValue(flag: string): string | null {
const idx = process.argv.indexOf(flag);
return idx !== -1 && process.argv[idx + 1] ? process.argv[idx + 1].toLowerCase() : null;
}
const NAME_FILTER = argValue("--name");
const SPORT_FILTER = argValue("--sport");
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);
}
if (NAME_FILTER) {
for (const [tournamentId, evs] of byTournament) {
const matches = evs.some((e) => e.name?.toLowerCase().includes(NAME_FILTER));
if (!matches) byTournament.delete(tournamentId);
}
log(`Filtering to tournaments matching --name "${NAME_FILTER}"`);
}
if (SPORT_FILTER) {
for (const [tournamentId, evs] of byTournament) {
const matches = evs.some((e) =>
e.sportsSeason?.sport?.name?.toLowerCase().includes(SPORT_FILTER)
);
if (!matches) byTournament.delete(tournamentId);
}
log(`Filtering to tournaments matching --sport "${SPORT_FILTER}"`);
}
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 sportName = evs[0]?.sportsSeason?.sport?.name ?? "unknown sport";
const name = `${evs[0]?.name ?? tournamentId} [${sportName}]`;
// 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);
});