/** * 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. * * The ledger pass covers every already-finalized one-shot sports season and is * safe to repeat: recordFinalPlacementScoreEvents clears that season's existing * event-level rows before writing. * * The standings pass is deliberately narrow. recalculateStandings rewrites * previousRank, so any season it touches loses its rank-movement arrows until the * next scoring event — it is NOT a pure recompute. Only leagues drafting from a * sports season that actually contains a tied placement are recalculated; every * other league is left alone. * * 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, eq } 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"; import { countSharedPlacements } from "../app/models/participant-result.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, restricted to leagues that can actually change. // // recalculateStandings is NOT a pure recompute: it sets previousRank = // currentRank, so every season it touches loses its rank-movement arrows until // the next real scoring event. Sweeping all seasons would spend that cost on // leagues holding no tied placement at all, so scope it to sports seasons that // genuinely have a tie, then to the fantasy seasons drafting from them. const allResults = await db.query.seasonParticipantResults.findMany({ columns: { sportsSeasonId: true, finalPosition: true }, }); const tiedSportsSeasonIds = new Set(); for (const [sportsSeasonId, byPosition] of countSharedPlacements(allResults)) { for (const count of byPosition.values()) { if (count > 1) { tiedSportsSeasonIds.add(sportsSeasonId); break; } } } const affectedSeasonIds = new Set(); if (tiedSportsSeasonIds.size > 0) { const picks = await db .select({ seasonId: schema.draftPicks.seasonId, sportsSeasonId: schema.seasonParticipants.sportsSeasonId, }) .from(schema.draftPicks) .innerJoin( schema.seasonParticipants, eq(schema.draftPicks.participantId, schema.seasonParticipants.id) ) .where( inArray(schema.seasonParticipants.sportsSeasonId, [...tiedSportsSeasonIds]) ); for (const pick of picks) affectedSeasonIds.add(pick.seasonId); } log( `\nSports seasons with a tied placement: ${tiedSportsSeasonIds.size}` + `\nFantasy seasons to recalculate: ${affectedSeasonIds.size}` ); let recalculated = 0; let recalcFailed = 0; for (const seasonId of affectedSeasonIds) { if (DRY) { log(` (dry) season ${seasonId} — would recalculate standings`); continue; } try { await recalculateStandings(seasonId, db); recalculated += 1; } catch (e) { recalcFailed += 1; log(` ! season ${seasonId}: ${(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); });