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:
parent
75798d9711
commit
c48d54d873
1 changed files with 130 additions and 0 deletions
130
scripts/backfill-rounded-tie-splits.ts
Normal file
130
scripts/backfill-rounded-tie-splits.ts
Normal 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);
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue