claude/points-split-rounding-inconsistency-7yn7xp #141
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