Add backfill for rounded tie splits and missing ledger rows

Two stored artifacts went stale when the tie-split math was unified.
team_standings totals hold pre-rounding values, and standings are only
rewritten when something re-triggers a recalculation, so already-finished
leagues would keep 217.50-style figures indefinitely. Separately,
qualifying_points and season_standings seasons finalized before this
change have no team_score_events rows, so their results stay missing from
Recent Scores.

The script re-ledgers every already-finalized one-shot sports season and
recalculates standings for every fantasy season. Both operations are pure
recomputes and ledger rows upsert on (team, season, scoring event), so it
is safe to re-run. Supports --dry, following backfill-qp-resplit.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
This commit is contained in:
Claude 2026-08-07 07:43:22 +00:00
parent 2d571d8010
commit 9024391d2f
No known key found for this signature in database

View file

@ -0,0 +1,130 @@
/**
* Backfill: recalculate standings after tie splits became whole points, and
* ledger the final placements that were never recorded.
*
* Two stored artifacts went stale when the tie-split math was unified:
*
* 1. team_standings.total_points / actual_points / projected_points hold
* pre-rounding values (a team carrying a golfer tied for 8th sits at
* 217.50 rather than 218). Standings are only rewritten when something
* re-triggers a recalculation, so leagues whose seasons already finished
* would keep the old figures indefinitely.
*
* 2. qualifying_points and season_standings sports were never written to
* team_score_events at all, so their results are missing from Recent
* Scores. recordFinalPlacementScoreEvents now writes them at
* finalization, but only for seasons finalized from here on.
*
* This re-runs both for every already-finalized sports season. Ledger rows are
* upserted on (team, season, scoring event), so re-running rewrites rather than
* duplicates. Standings recalculation is likewise a pure recompute from
* participant results.
*
* Safe to re-run. Validate on a DB snapshot first. Reads DATABASE_URL.
*
* npx tsx scripts/backfill-rounded-tie-splits.ts # apply
* npx tsx scripts/backfill-rounded-tie-splits.ts --dry # report only
*/
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { inArray } from "drizzle-orm";
import * as schema from "../database/schema.js";
import { DatabaseContext, database } from "../database/context.js";
import { recalculateStandings } from "../app/models/scoring-calculator.js";
import { recordFinalPlacementScoreEvents } from "../app/models/team-score-events.js";
const DRY = process.argv.includes("--dry");
const log = (...a: unknown[]) => console.log(...a);
/** Patterns that award all of their points at once, with no per-match deltas. */
type ScoringPattern = NonNullable<
(typeof schema.sportsSeasons.$inferSelect)["scoringPattern"]
>;
const ONE_SHOT_PATTERNS: ScoringPattern[] = ["qualifying_points", "season_standings"];
async function run() {
const db = database();
// Ledger pass: one-shot sports seasons that already have final placements.
const oneShotSeasons = await db.query.sportsSeasons.findMany({
where: inArray(schema.sportsSeasons.scoringPattern, ONE_SHOT_PATTERNS),
columns: { id: true, name: true, scoringPattern: true },
});
const finalized: typeof oneShotSeasons = [];
for (const ss of oneShotSeasons) {
const anyPlacement = await db.query.seasonParticipantResults.findFirst({
where: (r, { eq, and, gt }) =>
and(eq(r.sportsSeasonId, ss.id), gt(r.finalPosition, 0)),
columns: { id: true },
});
if (anyPlacement) finalized.push(ss);
}
log(
`One-shot sports seasons with final placements: ${finalized.length} of ${oneShotSeasons.length}`
);
let ledgered = 0;
let ledgerFailed = 0;
for (const ss of finalized) {
if (DRY) {
log(` (dry) ${ss.name} [${ss.scoringPattern}] — would write ledger rows`);
continue;
}
try {
await recordFinalPlacementScoreEvents({ sportsSeasonId: ss.id }, db);
ledgered += 1;
log(` ${ss.name} [${ss.scoringPattern}]: ledgered`);
} catch (e) {
ledgerFailed += 1;
log(` ! ${ss.name}: ${(e as Error).message}`);
}
}
// Standings pass: every fantasy season, so rounded awards land in stored
// totals. Cheap enough to run unconditionally and avoids trying to guess
// which seasons contain a tie.
const seasons = await db.query.seasons.findMany({ columns: { id: true, year: true } });
log(`\nFantasy seasons to recalculate: ${seasons.length}`);
let recalculated = 0;
let recalcFailed = 0;
for (const season of seasons) {
if (DRY) continue;
try {
await recalculateStandings(season.id, db);
recalculated += 1;
} catch (e) {
recalcFailed += 1;
log(` ! season ${season.id} (${season.year}): ${(e as Error).message}`);
}
}
log(
`\nDone${DRY ? " (dry run — no writes)" : ""}. ` +
`ledgered=${ledgered} (failed ${ledgerFailed}), ` +
`standings recalculated=${recalculated} (failed ${recalcFailed}).`
);
}
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);
});