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
170 lines
6.7 KiB
TypeScript
170 lines
6.7 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, 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);
|
||
});
|