From c48d54d873a5bdcb4e1b5d8f2e7958ec194732fc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 07:43:22 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz --- scripts/backfill-rounded-tie-splits.ts | 130 +++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 scripts/backfill-rounded-tie-splits.ts diff --git a/scripts/backfill-rounded-tie-splits.ts b/scripts/backfill-rounded-tie-splits.ts new file mode 100644 index 0000000..618676a --- /dev/null +++ b/scripts/backfill-rounded-tie-splits.ts @@ -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); +});