/** * 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); });