diff --git a/app/models/participant-season-result.ts b/app/models/participant-season-result.ts index a662782..67943c8 100644 --- a/app/models/participant-season-result.ts +++ b/app/models/participant-season-result.ts @@ -1,6 +1,6 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; -import { eq, and, inArray, max } from "drizzle-orm"; +import { eq, and, inArray, max, sql } from "drizzle-orm"; export interface UpsertSeasonResultData { participantId: string; @@ -15,55 +15,42 @@ export interface UpdateSeasonResultData { } /** - * Upsert a participant's season result (for F1, etc.) - * Creates if doesn't exist, updates if it does + * Upsert a single participant's season result atomically using ON CONFLICT DO UPDATE. */ export async function upsertParticipantSeasonResult( data: UpsertSeasonResultData, providedDb?: ReturnType ) { const db = providedDb || database(); + const now = new Date(); - // Check if result exists - const existing = await db.query.participantSeasonResults.findFirst({ - where: and( - eq(schema.participantSeasonResults.participantId, data.participantId), - eq(schema.participantSeasonResults.sportsSeasonId, data.sportsSeasonId) - ), - }); + const [row] = await db + .insert(schema.participantSeasonResults) + .values({ + participantId: data.participantId, + sportsSeasonId: data.sportsSeasonId, + currentPoints: (data.currentPoints ?? 0).toString(), + currentPosition: data.currentPosition ?? null, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [ + schema.participantSeasonResults.participantId, + schema.participantSeasonResults.sportsSeasonId, + ], + set: { + currentPoints: sql`excluded.current_points`, + currentPosition: sql`excluded.current_position`, + updatedAt: sql`excluded.updated_at`, + }, + }) + .returning(); - if (existing) { - // Update existing - const [updated] = await db - .update(schema.participantSeasonResults) - .set({ - currentPoints: data.currentPoints?.toString(), - currentPosition: data.currentPosition, - updatedAt: new Date(), - }) - .where(eq(schema.participantSeasonResults.id, existing.id)) - .returning(); - - return updated; - } else { - // Insert new - const [created] = await db - .insert(schema.participantSeasonResults) - .values({ - participantId: data.participantId, - sportsSeasonId: data.sportsSeasonId, - currentPoints: data.currentPoints?.toString() || "0", - currentPosition: data.currentPosition, - }) - .returning(); - - return created; - } + return row; } /** - * Bulk upsert season results for multiple participants - * Useful for entering standings for an entire F1 grid at once + * Bulk upsert season results for multiple participants in a single statement. */ export async function upsertSeasonResultsBulk( results: UpsertSeasonResultData[], @@ -71,13 +58,32 @@ export async function upsertSeasonResultsBulk( ) { const db = providedDb || database(); - const upserted = []; - for (const data of results) { - const result = await upsertParticipantSeasonResult(data, db); - upserted.push(result); - } + if (results.length === 0) return []; - return upserted; + const now = new Date(); + const values = results.map((data) => ({ + participantId: data.participantId, + sportsSeasonId: data.sportsSeasonId, + currentPoints: (data.currentPoints ?? 0).toString(), + currentPosition: data.currentPosition ?? null, + updatedAt: now, + })); + + return db + .insert(schema.participantSeasonResults) + .values(values) + .onConflictDoUpdate({ + target: [ + schema.participantSeasonResults.participantId, + schema.participantSeasonResults.sportsSeasonId, + ], + set: { + currentPoints: sql`excluded.current_points`, + currentPosition: sql`excluded.current_position`, + updatedAt: sql`excluded.updated_at`, + }, + }) + .returning(); } /** diff --git a/app/routes/admin.sports-seasons.$id.tsx b/app/routes/admin.sports-seasons.$id.tsx index d27ad01..0eedb49 100644 --- a/app/routes/admin.sports-seasons.$id.tsx +++ b/app/routes/admin.sports-seasons.$id.tsx @@ -23,7 +23,7 @@ import { updateParticipant, } from "~/models/season-participant"; import { getLastSyncedAt, upsertRegularSeasonStandings } from "~/models/regular-season-standings"; -import { getLastSeasonResultsSyncedAt } from "~/models/participant-season-result"; +import { getLastSeasonResultsSyncedAt, upsertParticipantSeasonResult } from "~/models/participant-season-result"; import { buildUnmatchedTeamResolutionView, type MatchConfidence } from "~/lib/unmatched-team-reconciliation"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; @@ -143,10 +143,7 @@ export async function loader({ params }: Route.LoaderArgs) { ? await getLastSeasonResultsSyncedAt(params.id) : null; - const pendingMappings = - sportsSeason.sport?.type === "team" - ? await getPendingStandingsMappings(params.id) - : []; + const pendingMappings = await getPendingStandingsMappings(params.id); return { sportsSeason, @@ -223,7 +220,10 @@ export async function action(args: Route.ActionArgs) { try { const standingData = JSON.parse(standingDataRaw) as Record; - const participant = await findParticipantById(participantId); + const [participant, sportsSeason] = await Promise.all([ + findParticipantById(participantId), + findSportsSeasonById(params.id), + ]); if (!participant || participant.sportsSeasonId !== params.id) { return { error: "Selected participant does not belong to this sports season." }; @@ -232,31 +232,40 @@ export async function action(args: Route.ActionArgs) { // Write externalId onto the participant for future ID-first matching await updateParticipant(participantId, { externalId: externalTeamId }); - // Upsert the standing record from the stored standingData - await upsertRegularSeasonStandings([ - { + // Route upsert to the correct table based on sport type + if (sportsSeason?.scoringPattern === "season_standings") { + await upsertParticipantSeasonResult({ participantId, sportsSeasonId: params.id, - wins: (standingData.wins as number) ?? 0, - losses: (standingData.losses as number) ?? 0, - otLosses: (standingData.otLosses as number | null) ?? null, - ties: (standingData.ties as number | null) ?? null, - winPct: (standingData.winPct as number) ?? 0, - gamesPlayed: (standingData.gamesPlayed as number) ?? 0, - gamesBack: (standingData.gamesBack as number | null) ?? null, - conference: (standingData.conference as string | null) ?? null, - division: (standingData.division as string | null) ?? null, - conferenceRank: (standingData.conferenceRank as number | null) ?? null, - divisionRank: (standingData.divisionRank as number | null) ?? null, - leagueRank: (standingData.leagueRank as number) ?? 0, - streak: (standingData.streak as string | null) ?? null, - lastTen: (standingData.lastTen as string | null) ?? null, - homeRecord: (standingData.homeRecord as string | null) ?? null, - awayRecord: (standingData.awayRecord as string | null) ?? null, - externalTeamId, - syncedAt: new Date(), - }, - ]); + currentPoints: (standingData.currentPoints as number) ?? 0, + currentPosition: (standingData.leagueRank as number) ?? null, + }); + } else { + await upsertRegularSeasonStandings([ + { + participantId, + sportsSeasonId: params.id, + wins: (standingData.wins as number) ?? 0, + losses: (standingData.losses as number) ?? 0, + otLosses: (standingData.otLosses as number | null) ?? null, + ties: (standingData.ties as number | null) ?? null, + winPct: (standingData.winPct as number) ?? 0, + gamesPlayed: (standingData.gamesPlayed as number) ?? 0, + gamesBack: (standingData.gamesBack as number | null) ?? null, + conference: (standingData.conference as string | null) ?? null, + division: (standingData.division as string | null) ?? null, + conferenceRank: (standingData.conferenceRank as number | null) ?? null, + divisionRank: (standingData.divisionRank as number | null) ?? null, + leagueRank: (standingData.leagueRank as number) ?? 0, + streak: (standingData.streak as string | null) ?? null, + lastTen: (standingData.lastTen as string | null) ?? null, + homeRecord: (standingData.homeRecord as string | null) ?? null, + awayRecord: (standingData.awayRecord as string | null) ?? null, + externalTeamId, + syncedAt: new Date(), + }, + ]); + } // Remove from pending queue await deletePendingStandingsMapping(params.id, externalTeamId); @@ -878,17 +887,7 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo {actionData.syncResult.unmatched.length > 0 && (
-

- {actionData.syncResult.unmatched.length} driver{actionData.syncResult.unmatched.length !== 1 ? "s" : ""} could not be matched to participants: -

-
    - {actionData.syncResult.unmatched.map((u: { teamName: string; externalTeamId: string }) => ( -
  • {u.teamName}
  • - ))} -
-

- Check that participant names match driver names from the data feed. You can edit names manually via the Participants section. -

+ {actionData.syncResult.unmatched.length} driver{actionData.syncResult.unmatched.length !== 1 ? "s" : ""} could not be matched — see the "Unmatched Drivers" card below to resolve.
)} @@ -903,16 +902,19 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo )} - {sportsSeason.sport?.type === "team" && pendingMappings.length > 0 && ( + {pendingMappings.length > 0 && ( - Unmatched Teams ({pendingMappings.length}) + {sportsSeason.sport?.type === "individual" ? "Unmatched Drivers" : "Unmatched Teams"} ({pendingMappings.length}) - These teams came back from the official standings feed but could not be tied to a - participant in this season. Use the standings details and suggested participant to + {sportsSeason.sport?.type === "individual" + ? "These drivers came back from the official standings feed but could not be tied to a participant in this season." + : "These teams came back from the official standings feed but could not be tied to a participant in this season." + }{" "} + Use the standings details and suggested participant to confirm the right match. Once resolved, future syncs will reuse the saved external ID. diff --git a/app/services/standings-sync/__tests__/f1.test.ts b/app/services/standings-sync/__tests__/f1.test.ts index 988224f..16687a4 100644 --- a/app/services/standings-sync/__tests__/f1.test.ts +++ b/app/services/standings-sync/__tests__/f1.test.ts @@ -33,24 +33,6 @@ const SAMPLE_JOLPICA_STANDINGS = { }, }; -// Minimal Jolpica schedule response — 8 races, 2 in the future (date > 2026-06-09) -const SAMPLE_JOLPICA_SCHEDULE = { - MRData: { - RaceTable: { - Races: [ - { date: "2026-03-16" }, - { date: "2026-03-23" }, - { date: "2026-04-06" }, - { date: "2026-04-20" }, - { date: "2026-05-04" }, - { date: "2026-05-25" }, - { date: "2026-06-08" }, - { date: "2026-07-06" }, // future - { date: "2026-07-27" }, // future - ], - }, - }, -}; // Minimal ESPN IndyCar standings response (flat, single standings list) const SAMPLE_ESPN_INDYCAR = { @@ -96,8 +78,7 @@ describe("F1StandingsAdapter", () => { it("returns one record per driver with correct names", async () => { vi.mocked(fetch) - .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response) - .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response); + .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response); const adapter = new F1StandingsAdapter(); const records = await adapter.fetchStandings(); @@ -109,8 +90,7 @@ describe("F1StandingsAdapter", () => { it("sets externalTeamId to driverId", async () => { vi.mocked(fetch) - .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response) - .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response); + .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response); const records = await new F1StandingsAdapter().fetchStandings(); expect(records[0].externalTeamId).toBe("max_verstappen"); @@ -119,8 +99,7 @@ describe("F1StandingsAdapter", () => { it("maps championship points to currentPoints", async () => { vi.mocked(fetch) - .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response) - .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response); + .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response); const records = await new F1StandingsAdapter().fetchStandings(); expect(records[0].currentPoints).toBe(195); @@ -129,61 +108,52 @@ describe("F1StandingsAdapter", () => { it("sets leagueRank to championship position", async () => { vi.mocked(fetch) - .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response) - .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response); + .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response); const records = await new F1StandingsAdapter().fetchStandings(); expect(records[0].leagueRank).toBe(1); expect(records[2].leagueRank).toBe(3); }); - it("counts only completed races for gamesPlayed", async () => { - vi.mocked(fetch) - .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response) - .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response); + it("sets gamesPlayed to 0 (not stored for season_standings sports)", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => SAMPLE_JOLPICA_STANDINGS, + } as Response); const records = await new F1StandingsAdapter().fetchStandings(); - // 7 races on/before 2026-06-09, 2 in the future - expect(records[0].gamesPlayed).toBe(7); - }); - - it("computes winPct as wins / gamesPlayed", async () => { - vi.mocked(fetch) - .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response) - .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response); - - const records = await new F1StandingsAdapter().fetchStandings(); - // Verstappen: 5 wins / 7 races - expect(records[0].winPct).toBeCloseTo(5 / 7, 5); - }); - - it("falls back gracefully when schedule fetch fails", async () => { - vi.mocked(fetch) - .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_STANDINGS } as Response) - .mockResolvedValueOnce({ ok: false, status: 503, statusText: "Service Unavailable" } as Response); - - const records = await new F1StandingsAdapter().fetchStandings(); - // gamesPlayed falls back to 0 from countCompletedRaces expect(records[0].gamesPlayed).toBe(0); + expect(records[1].gamesPlayed).toBe(0); + }); + + it("sets winPct to 0 (not stored for season_standings sports)", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => SAMPLE_JOLPICA_STANDINGS, + } as Response); + + const records = await new F1StandingsAdapter().fetchStandings(); + expect(records[0].winPct).toBe(0); }); it("throws when standings fetch fails", async () => { - vi.mocked(fetch) - .mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" } as Response) - .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response); + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + } as Response); await expect(new F1StandingsAdapter().fetchStandings()).rejects.toThrow("500"); }); it("throws when API returns empty standings", async () => { const empty = { MRData: { StandingsTable: { StandingsLists: [] } } }; - vi.mocked(fetch) - .mockResolvedValueOnce({ ok: true, json: async () => empty } as Response) - .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_JOLPICA_SCHEDULE } as Response); + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => empty, + } as Response); - await expect(new F1StandingsAdapter().fetchStandings()).rejects.toThrow( - /no entries/i - ); + await expect(new F1StandingsAdapter().fetchStandings()).rejects.toThrow(/no entries/i); }); }); diff --git a/app/services/standings-sync/f1.ts b/app/services/standings-sync/f1.ts index 305250c..342c451 100644 --- a/app/services/standings-sync/f1.ts +++ b/app/services/standings-sync/f1.ts @@ -1,8 +1,8 @@ import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types"; +import { statsMap, type EspnStat } from "./espn"; // Jolpica is the maintained successor to the deprecated Ergast API — same JSON shape, no key needed. const JOLPICA_DRIVER_STANDINGS_URL = "https://api.jolpi.ca/ergast/f1/current/driverStandings.json"; -const JOLPICA_SCHEDULE_URL = "https://api.jolpi.ca/ergast/f1/current.json"; // ESPN IndyCar (IRL) standings endpoint const ESPN_INDYCAR_STANDINGS_URL = @@ -19,18 +19,6 @@ interface JolpicaDriverStanding { }; } -interface JolpicaRace { - date: string; -} - -interface JolpicaScheduleResponse { - MRData: { - RaceTable: { - Races: JolpicaRace[]; - }; - }; -} - interface JolpicaStandingsResponse { MRData: { StandingsTable: { @@ -41,21 +29,9 @@ interface JolpicaStandingsResponse { }; } -async function countCompletedRaces(): Promise { - const res = await fetch(JOLPICA_SCHEDULE_URL); - if (!res.ok) return 0; - const json = (await res.json()) as JolpicaScheduleResponse; - const races = json.MRData?.RaceTable?.Races ?? []; - const today = new Date().toISOString().slice(0, 10); - return races.filter((r) => r.date <= today).length; -} - export class F1StandingsAdapter implements StandingsSyncAdapter { async fetchStandings(): Promise { - const [standingsRes, completedRaces] = await Promise.all([ - fetch(JOLPICA_DRIVER_STANDINGS_URL), - countCompletedRaces(), - ]); + const standingsRes = await fetch(JOLPICA_DRIVER_STANDINGS_URL); if (!standingsRes.ok) { throw new Error( @@ -77,8 +53,6 @@ export class F1StandingsAdapter implements StandingsSyncAdapter { const position = parseInt(d.position, 10); const points = parseFloat(d.points); const wins = parseInt(d.wins, 10); - const gamesPlayed = completedRaces; - const winPct = gamesPlayed > 0 ? wins / gamesPlayed : 0; return { teamName: `${d.Driver.givenName} ${d.Driver.familyName}`, @@ -86,8 +60,8 @@ export class F1StandingsAdapter implements StandingsSyncAdapter { leagueRank: position, wins, losses: 0, - gamesPlayed, - winPct, + gamesPlayed: 0, + winPct: 0, currentPoints: points, }; }); @@ -100,15 +74,9 @@ interface EspnRacingAthlete { displayName: string; } -interface EspnRacingStat { - name: string; - value?: number; - displayValue?: string; -} - interface EspnRacingEntry { athlete: EspnRacingAthlete; - stats: EspnRacingStat[]; + stats: EspnStat[]; } interface EspnRacingStandingsResponse { @@ -121,12 +89,6 @@ interface EspnRacingStandingsResponse { }>; } -function parseRacingStatsMap(stats: EspnRacingStat[]): Map { - const map = new Map(); - for (const s of stats) map.set(s.name, s); - return map; -} - export class IndyCarStandingsAdapter implements StandingsSyncAdapter { async fetchStandings(): Promise { const res = await fetch(ESPN_INDYCAR_STANDINGS_URL); @@ -151,7 +113,7 @@ export class IndyCarStandingsAdapter implements StandingsSyncAdapter { } return entries.map((entry, idx): FetchedStandingsRecord => { - const sm = parseRacingStatsMap(entry.stats); + const sm = statsMap(entry.stats); const points = sm.get("points")?.value ?? sm.get("pts")?.value ?? 0; const wins = sm.get("wins")?.value ?? 0; const starts = sm.get("starts")?.value ?? sm.get("racesStarted")?.value ?? 0; diff --git a/app/services/standings-sync/index.ts b/app/services/standings-sync/index.ts index 2e17589..b692b56 100644 --- a/app/services/standings-sync/index.ts +++ b/app/services/standings-sync/index.ts @@ -16,8 +16,6 @@ import { MlsStandingsAdapter } from "./mls"; import { F1StandingsAdapter, IndyCarStandingsAdapter } from "./f1"; import type { StandingsSyncAdapter, SyncResult, UnmatchedTeam } from "./types"; -const SEASON_STANDINGS_SIMULATOR_TYPES = new Set(["f1_standings", "indycar_standings"]); - function getAdapter(simulatorType: string): StandingsSyncAdapter { switch (simulatorType) { case "nba_bracket": @@ -74,7 +72,7 @@ export async function syncStandings(sportsSeasonId: string): Promise throw new Error(`Sport "${sportName}" has no simulator type configured`); } - const isSeasonStandings = SEASON_STANDINGS_SIMULATOR_TYPES.has(simulatorType); + const isSeasonStandings = sportsSeason.scoringPattern === "season_standings"; const adapter = getAdapter(simulatorType); const fetchedRecords = await adapter.fetchStandings(); diff --git a/database/schema.ts b/database/schema.ts index bcccf21..4998dec 100644 --- a/database/schema.ts +++ b/database/schema.ts @@ -927,7 +927,10 @@ export const participantSeasonResults = pgTable("participant_season_results", { currentPoints: decimal("current_points", { precision: 10, scale: 2 }).notNull().default("0"), // Current F1 championship points, etc. currentPosition: integer("current_position"), // Current standings position updatedAt: timestamp("updated_at").defaultNow().notNull(), -}); +}, (table) => ({ + uniqueParticipantSeason: uniqueIndex("psr_participant_season_idx") + .on(table.participantId, table.sportsSeasonId), +})); // EV snapshots — historical record of participant EV over time (one per participant per sport season per day) export const participantEvSnapshots = pgTable("participant_ev_snapshots", { diff --git a/drizzle/0119_eminent_siren.sql b/drizzle/0119_eminent_siren.sql new file mode 100644 index 0000000..3574964 --- /dev/null +++ b/drizzle/0119_eminent_siren.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX IF NOT EXISTS "psr_participant_season_idx" ON "participant_season_results" USING btree ("participant_id","sports_season_id"); \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index a73e0a0..c0c2bd9 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -834,6 +834,13 @@ "when": 1780890669286, "tag": "0118_chunky_vivisector", "breakpoints": true + }, + { + "idx": 119, + "version": "7", + "when": 1781031439603, + "tag": "0119_eminent_siren", + "breakpoints": true } ] } \ No newline at end of file