From a054247a5555a3d6862a32240e27b380edbeca05 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:15:28 -0700 Subject: [PATCH] Fix standings snapshot upsert: atomic writes, correct date, and event-driven updates (#177) - Add unique index on (team_id, season_id, snapshot_date) in team_standings_snapshots so concurrent writes can't produce duplicate rows (migration 0051) - Rewrite createDailySnapshot to use INSERT ... ON CONFLICT DO UPDATE inside a transaction, replacing the SELECT-then-insert/skip pattern (N+1 queries, race-prone) - Remove early-exit guard in server/snapshots.ts background job so it always upserts rather than skipping seasons that already have a snapshot for today - Call createDailySnapshot in recalculateAffectedLeagues after recalculateStandings, so every bracket score update refreshes today's snapshot; wrapped in try/catch so a snapshot failure can't block Discord notifications - Also call createDailySnapshot from the admin rescore and finalize-bracket actions - Fix UTC date bug: replace toISOString().split('T')[0] with local date parts in both standings.ts and server/snapshots.ts (and the 7-day lookback query) - Convert all dynamic await import() calls in bracket.server.ts to static imports Co-authored-by: Claude Sonnet 4.6 --- app/models/scoring-calculator.ts | 7 +++ app/models/standings.ts | 44 +++++++++---------- ...sons.$id.events.$eventId.bracket.server.ts | 18 +++----- app/routes/admin.sports-seasons.$id.tsx | 2 + database/schema.ts | 6 ++- ...51_add_standings_snapshot_unique_index.sql | 2 + drizzle/meta/_journal.json | 7 +++ server/snapshots.ts | 18 ++------ 8 files changed, 53 insertions(+), 51 deletions(-) create mode 100644 drizzle/0051_add_standings_snapshot_unique_index.sql diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 7b38560..f8d43be 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -7,6 +7,7 @@ import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord"; import { BRACKET_TEMPLATES } from "~/lib/bracket-templates"; import { getUserDisplayName } from "~/models/user"; +import { createDailySnapshot } from "~/models/standings"; /** * Core scoring calculation engine @@ -1282,6 +1283,12 @@ export async function recalculateAffectedLeagues( await recalculateStandings(seasonId, db); + try { + await createDailySnapshot(seasonId, db); + } catch (err) { + console.error(`[ScoringCalculator] Snapshot failed for season ${seasonId}:`, err); + } + // Check if any team's score changed — if so, send Discord notification const afterStandings = await db.query.teamStandings.findMany({ where: eq(schema.teamStandings.seasonId, seasonId), diff --git a/app/models/standings.ts b/app/models/standings.ts index 9a051b2..dce08b4 100644 --- a/app/models/standings.ts +++ b/app/models/standings.ts @@ -304,7 +304,7 @@ export async function getSevenDayStandingsChange( const snapshots = await db.query.teamStandingsSnapshots.findMany({ where: and( eq(schema.teamStandingsSnapshots.seasonId, seasonId), - eq(schema.teamStandingsSnapshots.snapshotDate, sevenDaysAgo.toISOString().split("T")[0]) + eq(schema.teamStandingsSnapshots.snapshotDate, `${sevenDaysAgo.getFullYear()}-${String(sevenDaysAgo.getMonth() + 1).padStart(2, "0")}-${String(sevenDaysAgo.getDate()).padStart(2, "0")}`) ), }); @@ -334,27 +334,16 @@ export async function createDailySnapshot( ): Promise { const db = providedDb || database(); - const today = new Date().toISOString().split("T")[0]; + const now = new Date(); + const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; const standings = await db.query.teamStandings.findMany({ where: eq(schema.teamStandings.seasonId, seasonId), }); - for (const standing of standings) { - // Check if snapshot already exists for today - const existing = await db.query.teamStandingsSnapshots.findFirst({ - where: and( - eq(schema.teamStandingsSnapshots.teamId, standing.teamId), - eq(schema.teamStandingsSnapshots.seasonId, seasonId), - eq(schema.teamStandingsSnapshots.snapshotDate, today) - ), - }); - - if (!existing) { - await db.insert(schema.teamStandingsSnapshots).values({ - teamId: standing.teamId, - seasonId, - snapshotDate: today, + await db.transaction(async (tx) => { + for (const standing of standings) { + const snapshotData = { totalPoints: standing.totalPoints, rank: standing.currentRank, firstPlaceCount: standing.firstPlaceCount, @@ -366,15 +355,26 @@ export async function createDailySnapshot( seventhPlaceCount: standing.seventhPlaceCount, eighthPlaceCount: standing.eighthPlaceCount, participantsRemaining: standing.participantsRemaining, - // Phase 5.4: Copy projected points tracking actualPoints: standing.actualPoints, projectedPoints: standing.projectedPoints, participantsFinished: standing.participantsFinished, - }); - } - } + }; - console.log(`[Standings] Created daily snapshot for season ${seasonId}`); + await tx + .insert(schema.teamStandingsSnapshots) + .values({ teamId: standing.teamId, seasonId, snapshotDate: today, ...snapshotData }) + .onConflictDoUpdate({ + target: [ + schema.teamStandingsSnapshots.teamId, + schema.teamStandingsSnapshots.seasonId, + schema.teamStandingsSnapshots.snapshotDate, + ], + set: snapshotData, + }); + } + }); + + console.log(`[Standings] Upserted daily snapshot for season ${seasonId}`); } /** diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts index d4c3dfe..03ca412 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts @@ -25,9 +25,13 @@ import { processPlayoffEvent, processMatchResult, recalculateAffectedLeagues, + recalculateStandings, } from "~/models/scoring-calculator"; import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates"; +import { setParticipantResult, findParticipantResultsBySportsSeasonId } from "~/models/participant-result"; +import { findSeasonSportsBySportsSeasonId } from "~/models/season-sport"; +import { createDailySnapshot } from "~/models/standings"; import { createGroupsForEvent, addMembersToGroup, @@ -157,7 +161,6 @@ export async function action({ request, params }: Route.ActionArgs) { const participantsInBracket = new Set(participantIds); // Mark participants NOT in the bracket as eliminated - const { setParticipantResult } = await import("~/models/participant-result"); for (const participant of allParticipants) { if (!participantsInBracket.has(participant.id)) { await setParticipantResult( @@ -474,9 +477,6 @@ export async function action({ request, params }: Route.ActionArgs) { } // Validate round order: ensure previous rounds are complete - const { findParticipantResultsBySportsSeasonId } = await import( - "~/models/participant-result" - ); const existingResults = await findParticipantResultsBySportsSeasonId( params.id ); @@ -529,9 +529,6 @@ export async function action({ request, params }: Route.ActionArgs) { .where(eq(schema.scoringEvents.id, params.eventId)); // Get a fantasy season ID for scoring calculation - const { findSeasonSportsBySportsSeasonId } = await import( - "~/models/season-sport" - ); const seasonSports = await findSeasonSportsBySportsSeasonId(params.id); if (seasonSports.length === 0) { return { @@ -579,7 +576,6 @@ export async function action({ request, params }: Route.ActionArgs) { } // Mark participants NOT in the bracket as eliminated - const { setParticipantResult } = await import("~/models/participant-result"); let eliminatedCount = 0; for (const participant of allParticipants) { @@ -636,7 +632,6 @@ export async function action({ request, params }: Route.ActionArgs) { } // Get all participants in this sports season - const { findParticipantsBySportsSeasonId } = await import("~/models/participant"); const allParticipants = await findParticipantsBySportsSeasonId(params.id); // Get participants in matches @@ -660,7 +655,6 @@ export async function action({ request, params }: Route.ActionArgs) { } // Assign 0 points to participants not in the bracket (Q20) - const { setParticipantResult } = await import("~/models/participant-result"); for (const participant of allParticipants) { if (!participantsInMatches.has(participant.id)) { await setParticipantResult( @@ -678,12 +672,11 @@ export async function action({ request, params }: Route.ActionArgs) { .where(eq(schema.scoringEvents.id, params.eventId)); // Recalculate standings for all affected fantasy seasons - const { recalculateStandings } = await import("~/models/scoring-calculator"); - const { findSeasonSportsBySportsSeasonId } = await import("~/models/season-sport"); const seasonSports = await findSeasonSportsBySportsSeasonId(params.id); for (const seasonSport of seasonSports) { await recalculateStandings(seasonSport.seasonId, db); + await createDailySnapshot(seasonSport.seasonId, db); } return { @@ -832,7 +825,6 @@ export async function action({ request, params }: Route.ActionArgs) { // Mark eliminated group participants with finalPosition = 0 const eliminatedIds = await getEliminatedParticipantIds(params.eventId); - const { setParticipantResult } = await import("~/models/participant-result"); for (const participantId of eliminatedIds) { await setParticipantResult(participantId, event.sportsSeasonId, 0); } diff --git a/app/routes/admin.sports-seasons.$id.tsx b/app/routes/admin.sports-seasons.$id.tsx index b7838fd..5e04e11 100644 --- a/app/routes/admin.sports-seasons.$id.tsx +++ b/app/routes/admin.sports-seasons.$id.tsx @@ -6,6 +6,7 @@ import type { Route } from "./+types/admin.sports-seasons.$id"; import { findSportsSeasonById, updateSportsSeason, deleteSportsSeason } from "~/models/sports-season"; import { findParticipantsBySportsSeasonId } from "~/models/participant"; import { processSeasonStandings, recalculateStandings } from "~/models/scoring-calculator"; +import { createDailySnapshot } from "~/models/standings"; import { database } from "~/database/context"; import { participantEvSnapshots, seasonSports } from "~/database/schema"; import { eq, desc } from "drizzle-orm"; @@ -104,6 +105,7 @@ export async function action(args: Route.ActionArgs) { return { success: true, intent: "rescore", message: "No linked fantasy seasons found — nothing to rescore." }; } await Promise.all(links.map((link) => recalculateStandings(link.seasonId, db))); + await Promise.all(links.map((link) => createDailySnapshot(link.seasonId, db))); return { success: true, intent: "rescore", message: `Rescored ${links.length} linked season(s).` }; } catch (error) { console.error("Error rescoring:", error); diff --git a/database/schema.ts b/database/schema.ts index 59196d5..f208887 100644 --- a/database/schema.ts +++ b/database/schema.ts @@ -543,7 +543,11 @@ export const teamStandingsSnapshots = pgTable("team_standings_snapshots", { projectedPoints: decimal("projected_points", { precision: 10, scale: 2 }), // actualPoints + EVs of unfinished participantsFinished: integer("participants_finished"), // Count of finished participants createdAt: timestamp("created_at").defaultNow().notNull(), -}); +}, (t) => ({ + uniqueTeamSeasonDate: uniqueIndex("team_standings_snapshots_unique").on( + t.teamId, t.seasonId, t.snapshotDate + ), +})); // Expected value tracking (sports-season-specific) export const participantExpectedValues = pgTable("participant_expected_values", { diff --git a/drizzle/0051_add_standings_snapshot_unique_index.sql b/drizzle/0051_add_standings_snapshot_unique_index.sql new file mode 100644 index 0000000..1ea6b4f --- /dev/null +++ b/drizzle/0051_add_standings_snapshot_unique_index.sql @@ -0,0 +1,2 @@ +CREATE UNIQUE INDEX IF NOT EXISTS "team_standings_snapshots_unique" + ON "team_standings_snapshots" ("team_id", "season_id", "snapshot_date"); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 6213af1..638d944 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -358,6 +358,13 @@ "when": 1773821310661, "tag": "0050_chief_peter_quill", "breakpoints": true + }, + { + "idx": 51, + "version": "7", + "when": 1773900000000, + "tag": "0051_add_standings_snapshot_unique_index", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/snapshots.ts b/server/snapshots.ts index 3a8b6de..27cc647 100644 --- a/server/snapshots.ts +++ b/server/snapshots.ts @@ -56,7 +56,8 @@ export function stopSnapshotSystem(): void { * Only creates snapshots if they don't already exist for today */ async function createDailySnapshots(): Promise { - const today = new Date().toISOString().split("T")[0]; + const now = new Date(); + const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; console.log(`[Snapshots] Checking for snapshots to create (${today})`); @@ -77,21 +78,8 @@ async function createDailySnapshots(): Promise { for (const season of activeSeasons) { try { - // Check if we already have a snapshot for today for any team in this season - const existingSnapshot = await db.query.teamStandingsSnapshots.findFirst({ - where: eq(schema.teamStandingsSnapshots.seasonId, season.id), - orderBy: (snapshots, { desc }) => [desc(snapshots.snapshotDate)], - }); - - // If the most recent snapshot is from today, skip this season - if (existingSnapshot && existingSnapshot.snapshotDate === today) { - console.log(`[Snapshots] Snapshot already exists for season ${season.id} on ${today}`); - continue; - } - - // Create snapshot using the standings model function await createDailySnapshot(season.id, db); - console.log(`[Snapshots] ✅ Created snapshot for season ${season.id}`); + console.log(`[Snapshots] ✅ Upserted snapshot for season ${season.id}`); } catch (error) { console.error(`[Snapshots] Error creating snapshot for season ${season.id}:`, error); }