diff --git a/app/models/participant-season-result.ts b/app/models/participant-season-result.ts index cc0e6fc..a662782 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 } from "drizzle-orm"; +import { eq, and, inArray, max } from "drizzle-orm"; export interface UpsertSeasonResultData { participantId: string; @@ -242,3 +242,21 @@ export async function hasParticipantSeasonResult( return !!result; } + +/** + * Returns the most recent updatedAt timestamp across all season results for a sports season. + * Used to display "Last synced" time for season_standings sports (F1, IndyCar). + */ +export async function getLastSeasonResultsSyncedAt( + sportsSeasonId: string, + providedDb?: ReturnType +): Promise { + const db = providedDb || database(); + + const result = await db + .select({ lastUpdated: max(schema.participantSeasonResults.updatedAt) }) + .from(schema.participantSeasonResults) + .where(eq(schema.participantSeasonResults.sportsSeasonId, sportsSeasonId)); + + return result[0]?.lastUpdated ?? null; +} diff --git a/app/routes/admin.sports-seasons.$id.tsx b/app/routes/admin.sports-seasons.$id.tsx index 43aab95..d27ad01 100644 --- a/app/routes/admin.sports-seasons.$id.tsx +++ b/app/routes/admin.sports-seasons.$id.tsx @@ -23,6 +23,7 @@ import { updateParticipant, } from "~/models/season-participant"; import { getLastSyncedAt, upsertRegularSeasonStandings } from "~/models/regular-season-standings"; +import { getLastSeasonResultsSyncedAt } 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"; @@ -138,7 +139,9 @@ export async function loader({ params }: Route.LoaderArgs) { const lastStandingsSyncedAt = sportsSeason.sport?.type === "team" ? await getLastSyncedAt(params.id) - : null; + : sportsSeason.sport?.type === "individual" + ? await getLastSeasonResultsSyncedAt(params.id) + : null; const pendingMappings = sportsSeason.sport?.type === "team" @@ -824,6 +827,82 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo )} + {sportsSeason.sport?.type === "individual" && ( + + +
+
+ Championship Standings + + Sync current standings from the official API, or edit manually. + {lastStandingsSyncedAt && ( + + Last synced: {new Date(lastStandingsSyncedAt).toLocaleString()}. + + )} + +
+
+ +
+ + +
+
+
+
+ {(actionData?.success && actionData.intent === "sync-standings") || actionData?.syncError ? ( + + {actionData?.success && actionData.intent === "sync-standings" && actionData.syncResult && ( +
+
+ Synced {actionData.syncResult.synced} driver{actionData.syncResult.synced !== 1 ? "s" : ""} successfully. +
+ {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?.syncError && ( +
+ {actionData.syncError} +
+ )} +
+ ) : null} +
+ )} + {sportsSeason.sport?.type === "team" && pendingMappings.length > 0 && ( diff --git a/app/services/standings-sync/__tests__/f1.test.ts b/app/services/standings-sync/__tests__/f1.test.ts new file mode 100644 index 0000000..988224f --- /dev/null +++ b/app/services/standings-sync/__tests__/f1.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { F1StandingsAdapter, IndyCarStandingsAdapter } from "../f1"; + +// Minimal Jolpica driver standings response +const SAMPLE_JOLPICA_STANDINGS = { + MRData: { + StandingsTable: { + StandingsLists: [ + { + DriverStandings: [ + { + position: "1", + points: "195", + wins: "5", + Driver: { driverId: "max_verstappen", givenName: "Max", familyName: "Verstappen" }, + }, + { + position: "2", + points: "152", + wins: "2", + Driver: { driverId: "norris", givenName: "Lando", familyName: "Norris" }, + }, + { + position: "3", + points: "133", + wins: "1", + Driver: { driverId: "leclerc", givenName: "Charles", familyName: "Leclerc" }, + }, + ], + }, + ], + }, + }, +}; + +// 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 = { + standings: { + entries: [ + { + athlete: { id: "3097", displayName: "Scott Dixon" }, + stats: [ + { name: "rank", value: 1 }, + { name: "points", value: 437 }, + { name: "wins", value: 3 }, + { name: "starts", value: 10 }, + ], + }, + { + athlete: { id: "3098", displayName: "Alex Palou" }, + stats: [ + { name: "rank", value: 2 }, + { name: "points", value: 398 }, + { name: "wins", value: 2 }, + { name: "starts", value: 10 }, + ], + }, + ], + }, +}; + +// IndyCar response nested under children (alternate ESPN shape) +const SAMPLE_ESPN_INDYCAR_NESTED = { + children: [ + { + standings: { + entries: SAMPLE_ESPN_INDYCAR.standings.entries, + }, + }, + ], +}; + +describe("F1StandingsAdapter", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + 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); + + const adapter = new F1StandingsAdapter(); + const records = await adapter.fetchStandings(); + + expect(records).toHaveLength(3); + expect(records[0].teamName).toBe("Max Verstappen"); + expect(records[1].teamName).toBe("Lando Norris"); + }); + + 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); + + const records = await new F1StandingsAdapter().fetchStandings(); + expect(records[0].externalTeamId).toBe("max_verstappen"); + expect(records[2].externalTeamId).toBe("leclerc"); + }); + + 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); + + const records = await new F1StandingsAdapter().fetchStandings(); + expect(records[0].currentPoints).toBe(195); + expect(records[1].currentPoints).toBe(152); + }); + + 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); + + 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); + + 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); + }); + + 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); + + 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); + + await expect(new F1StandingsAdapter().fetchStandings()).rejects.toThrow( + /no entries/i + ); + }); +}); + +describe("IndyCarStandingsAdapter", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + it("parses flat ESPN standings", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => SAMPLE_ESPN_INDYCAR, + } as Response); + + const records = await new IndyCarStandingsAdapter().fetchStandings(); + expect(records).toHaveLength(2); + expect(records[0].teamName).toBe("Scott Dixon"); + expect(records[1].teamName).toBe("Alex Palou"); + }); + + it("parses nested ESPN standings (children[0].standings)", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => SAMPLE_ESPN_INDYCAR_NESTED, + } as Response); + + const records = await new IndyCarStandingsAdapter().fetchStandings(); + expect(records).toHaveLength(2); + expect(records[0].teamName).toBe("Scott Dixon"); + }); + + it("sets externalTeamId to ESPN athlete id", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => SAMPLE_ESPN_INDYCAR, + } as Response); + + const records = await new IndyCarStandingsAdapter().fetchStandings(); + expect(records[0].externalTeamId).toBe("3097"); + }); + + it("maps championship points to currentPoints", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => SAMPLE_ESPN_INDYCAR, + } as Response); + + const records = await new IndyCarStandingsAdapter().fetchStandings(); + expect(records[0].currentPoints).toBe(437); + expect(records[1].currentPoints).toBe(398); + }); + + it("sets leagueRank from rank stat", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => SAMPLE_ESPN_INDYCAR, + } as Response); + + const records = await new IndyCarStandingsAdapter().fetchStandings(); + expect(records[0].leagueRank).toBe(1); + expect(records[1].leagueRank).toBe(2); + }); + + it("sets gamesPlayed from starts stat", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => SAMPLE_ESPN_INDYCAR, + } as Response); + + const records = await new IndyCarStandingsAdapter().fetchStandings(); + expect(records[0].gamesPlayed).toBe(10); + }); + + it("computes winPct as wins / starts", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => SAMPLE_ESPN_INDYCAR, + } as Response); + + const records = await new IndyCarStandingsAdapter().fetchStandings(); + expect(records[0].winPct).toBeCloseTo(3 / 10, 5); + }); + + it("throws when API returns HTTP error", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: "Not Found", + } as Response); + + await expect(new IndyCarStandingsAdapter().fetchStandings()).rejects.toThrow("404"); + }); + + it("throws when API returns no entries", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ standings: { entries: [] } }), + } as Response); + + await expect(new IndyCarStandingsAdapter().fetchStandings()).rejects.toThrow( + /no entries/i + ); + }); +}); diff --git a/app/services/standings-sync/f1.ts b/app/services/standings-sync/f1.ts index a06725f..305250c 100644 --- a/app/services/standings-sync/f1.ts +++ b/app/services/standings-sync/f1.ts @@ -1,31 +1,173 @@ import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types"; -/** - * F1 / IndyCar championship standings adapter. - * - * TODO: Implement using one of: - * - OpenF1 API: https://openf1.org/ (free, real-time, no key required) - * - Ergast API: https://ergast.com/mrd/ (free, historical, being deprecated) - * - Official F1 API: requires a key but is comprehensive - * - * NOTE: For season_standings sports, the sync result should upsert into - * participantSeasonResults (currentPoints + currentPosition) rather than - * regularSeasonStandings. The orchestrator in index.ts handles the routing. - */ +// 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 = + "https://site.api.espn.com/apis/v2/sports/racing/irl/standings"; + +interface JolpicaDriverStanding { + position: string; + points: string; + wins: string; + Driver: { + driverId: string; + givenName: string; + familyName: string; + }; +} + +interface JolpicaRace { + date: string; +} + +interface JolpicaScheduleResponse { + MRData: { + RaceTable: { + Races: JolpicaRace[]; + }; + }; +} + +interface JolpicaStandingsResponse { + MRData: { + StandingsTable: { + StandingsLists: Array<{ + DriverStandings: JolpicaDriverStanding[]; + }>; + }; + }; +} + +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 { - throw new Error( - "F1/IndyCar standings sync not yet implemented. " + - "Implement using OpenF1 API (https://openf1.org/) or Ergast API." - ); + const [standingsRes, completedRaces] = await Promise.all([ + fetch(JOLPICA_DRIVER_STANDINGS_URL), + countCompletedRaces(), + ]); + + if (!standingsRes.ok) { + throw new Error( + `F1 standings API returned ${standingsRes.status}: ${standingsRes.statusText}` + ); + } + + const json = (await standingsRes.json()) as JolpicaStandingsResponse; + const driverStandings = + json.MRData?.StandingsTable?.StandingsLists?.[0]?.DriverStandings ?? []; + + if (driverStandings.length === 0) { + throw new Error( + "F1 standings API returned no entries — season may not have started or response shape changed" + ); + } + + return driverStandings.map((d): FetchedStandingsRecord => { + 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}`, + externalTeamId: d.Driver.driverId, + leagueRank: position, + wins, + losses: 0, + gamesPlayed, + winPct, + currentPoints: points, + }; + }); } } +// ESPN racing standings shape (flat list, no conference/division nesting) +interface EspnRacingAthlete { + id: string; + displayName: string; +} + +interface EspnRacingStat { + name: string; + value?: number; + displayValue?: string; +} + +interface EspnRacingEntry { + athlete: EspnRacingAthlete; + stats: EspnRacingStat[]; +} + +interface EspnRacingStandingsResponse { + standings?: { + entries?: EspnRacingEntry[]; + }; + // Some ESPN racing endpoints wrap entries under children[0].standings + children?: Array<{ + standings?: { entries?: EspnRacingEntry[] }; + }>; +} + +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 { - throw new Error( - "IndyCar standings sync not yet implemented. " + - "Implement using the IndyCar official results API." - ); + const res = await fetch(ESPN_INDYCAR_STANDINGS_URL); + if (!res.ok) { + throw new Error( + `IndyCar standings API returned ${res.status}: ${res.statusText}` + ); + } + + const json = (await res.json()) as EspnRacingStandingsResponse; + + // ESPN racing standings can be flat or nested under children[0] + const entries: EspnRacingEntry[] = + json.standings?.entries ?? + json.children?.[0]?.standings?.entries ?? + []; + + if (entries.length === 0) { + throw new Error( + "IndyCar standings API returned no entries — season may not have started or response shape changed" + ); + } + + return entries.map((entry, idx): FetchedStandingsRecord => { + const sm = parseRacingStatsMap(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; + const rank = sm.get("rank")?.value ?? sm.get("position")?.value ?? idx + 1; + const winPct = starts > 0 ? wins / starts : 0; + + return { + teamName: entry.athlete.displayName, + externalTeamId: entry.athlete.id, + leagueRank: Math.round(rank), + wins, + losses: 0, + gamesPlayed: starts, + winPct, + currentPoints: points, + }; + }); } } diff --git a/app/services/standings-sync/index.ts b/app/services/standings-sync/index.ts index c94a8db..2e17589 100644 --- a/app/services/standings-sync/index.ts +++ b/app/services/standings-sync/index.ts @@ -4,6 +4,7 @@ import * as schema from "~/database/schema"; import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/season-participant"; import { upsertRegularSeasonStandings } from "~/models/regular-season-standings"; import { upsertPendingStandingsMappings } from "~/models/pending-standings-mappings"; +import { upsertSeasonResultsBulk } from "~/models/participant-season-result"; import { findMatchingTeamName } from "~/lib/normalize-team-name"; import { NhlStandingsAdapter } from "./nhl"; import { NbaStandingsAdapter } from "./nba"; @@ -12,13 +13,11 @@ import { MlbStandingsAdapter } from "./mlb"; import { WnbaStandingsAdapter } from "./wnba"; import { EplStandingsAdapter } from "./epl"; import { MlsStandingsAdapter } from "./mls"; +import { F1StandingsAdapter, IndyCarStandingsAdapter } from "./f1"; import type { StandingsSyncAdapter, SyncResult, UnmatchedTeam } from "./types"; -/** - * Map a simulator type to its standings sync adapter. - * Returns null for sports that don't use regularSeasonStandings - * (e.g. season_standings sports like F1 will use a different path when implemented). - */ +const SEASON_STANDINGS_SIMULATOR_TYPES = new Set(["f1_standings", "indycar_standings"]); + function getAdapter(simulatorType: string): StandingsSyncAdapter { switch (simulatorType) { case "nba_bracket": @@ -36,17 +35,13 @@ function getAdapter(simulatorType: string): StandingsSyncAdapter { case "wnba_bracket": return new WnbaStandingsAdapter(); case "f1_standings": - throw new Error( - "F1 standings sync is not yet implemented. Use the manual standings page." - ); + return new F1StandingsAdapter(); case "indycar_standings": - throw new Error( - "IndyCar standings sync is not yet implemented. Use the manual standings page." - ); + return new IndyCarStandingsAdapter(); default: throw new Error( `No standings sync adapter available for simulator type "${simulatorType}". ` + - "NBA, NHL, AFL, MLB, WNBA, EPL, and MLS are currently supported." + "NBA, NHL, AFL, MLB, WNBA, EPL, MLS, F1, and IndyCar are currently supported." ); } } @@ -55,7 +50,10 @@ function getAdapter(simulatorType: string): StandingsSyncAdapter { * Sync current standings from the appropriate external API for a sports season. * Matches API team names to participants by name and bulk-upserts the results. * - * Returns the count of synced teams and any API team names that couldn't be matched. + * For season_standings sports (F1, IndyCar), upserts into participantSeasonResults. + * For all other sports, upserts into regularSeasonStandings. + * + * Returns the count of synced entries and any API names that couldn't be matched. */ export async function syncStandings(sportsSeasonId: string): Promise { const db = database(); @@ -76,6 +74,8 @@ 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 adapter = getAdapter(simulatorType); const fetchedRecords = await adapter.fetchStandings(); @@ -83,25 +83,25 @@ export async function syncStandings(sportsSeasonId: string): Promise const participants = await findParticipantsBySportsSeasonId(sportsSeasonId); const participantNames = participants.map((p) => p.name); const participantByName = new Map(participants.map((p) => [p.name, p])); - // Build a map of externalId → participant for ID-first matching const participantByExternalId = new Map( participants.filter((p) => p.externalId).map((p) => [p.externalId ?? "", p]) ); - const toUpsert: Parameters[0] = []; - const unmatchedRecords: typeof fetchedRecords = []; + type RegularUpsertRow = Parameters[0][number]; + type SeasonResultRow = Parameters[0][number]; + + const toUpsertRegular: RegularUpsertRow[] = []; + const toUpsertSeason: SeasonResultRow[] = []; const matchedParticipantIds: string[] = []; + const unmatchedRecords: typeof fetchedRecords = []; for (const record of fetchedRecords) { - // Strategy A: try externalId-first (bypasses name matching entirely after first sync) let participant = participantByExternalId.get(record.externalTeamId) ?? null; if (!participant) { - // Fall back to name matching const matchedName = findMatchingTeamName(record.teamName, participantNames); if (matchedName) { participant = participantByName.get(matchedName) ?? null; - // Write-back: persist externalId so future syncs skip name matching for this team if (participant && !participant.externalId) { await updateParticipant(participant.id, { externalId: record.externalTeamId }); } @@ -114,39 +114,77 @@ export async function syncStandings(sportsSeasonId: string): Promise } matchedParticipantIds.push(participant.id); - toUpsert.push({ - participantId: participant.id, - sportsSeasonId, - wins: record.wins, - losses: record.losses, - otLosses: record.otLosses ?? null, - ties: record.ties ?? null, - tablePoints: record.tablePoints ?? null, - goalsFor: record.goalsFor ?? null, - goalsAgainst: record.goalsAgainst ?? null, - goalDifference: record.goalDifference ?? null, - winPct: record.winPct, - gamesPlayed: record.gamesPlayed, - gamesBack: record.gamesBack ?? null, - conference: record.conference ?? null, - division: record.division ?? null, - conferenceRank: record.conferenceRank ?? null, - divisionRank: record.divisionRank ?? null, - leagueRank: record.leagueRank, - streak: record.streak ?? null, - lastTen: record.lastTen ?? null, - homeRecord: record.homeRecord ?? null, - awayRecord: record.awayRecord ?? null, - externalTeamId: record.externalTeamId, - srs: record.srs ?? null, - syncedAt: new Date(), - }); + + if (isSeasonStandings) { + toUpsertSeason.push({ + participantId: participant.id, + sportsSeasonId, + currentPoints: record.currentPoints ?? 0, + currentPosition: record.leagueRank, + }); + } else { + toUpsertRegular.push({ + participantId: participant.id, + sportsSeasonId, + wins: record.wins, + losses: record.losses, + otLosses: record.otLosses ?? null, + ties: record.ties ?? null, + tablePoints: record.tablePoints ?? null, + goalsFor: record.goalsFor ?? null, + goalsAgainst: record.goalsAgainst ?? null, + goalDifference: record.goalDifference ?? null, + winPct: record.winPct, + gamesPlayed: record.gamesPlayed, + gamesBack: record.gamesBack ?? null, + conference: record.conference ?? null, + division: record.division ?? null, + conferenceRank: record.conferenceRank ?? null, + divisionRank: record.divisionRank ?? null, + leagueRank: record.leagueRank, + streak: record.streak ?? null, + lastTen: record.lastTen ?? null, + homeRecord: record.homeRecord ?? null, + awayRecord: record.awayRecord ?? null, + externalTeamId: record.externalTeamId, + srs: record.srs ?? null, + syncedAt: new Date(), + }); + } } - // Detect whether standings actually changed by comparing gamesPlayed + leagueRank - // against current DB values before upserting. let changed = false; - if (toUpsert.length > 0) { + + if (isSeasonStandings && toUpsertSeason.length > 0) { + const currentRows = await db + .select({ + participantId: schema.participantSeasonResults.participantId, + currentPoints: schema.participantSeasonResults.currentPoints, + currentPosition: schema.participantSeasonResults.currentPosition, + }) + .from(schema.participantSeasonResults) + .where(inArray(schema.participantSeasonResults.participantId, matchedParticipantIds)); + + const currentByParticipant = new Map(currentRows.map((r) => [r.participantId, r])); + + changed = toUpsertSeason.some((row) => { + const current = currentByParticipant.get(row.participantId); + if (!current) return true; + return ( + current.currentPosition !== row.currentPosition || + parseFloat(current.currentPoints ?? "0") !== (row.currentPoints ?? 0) + ); + }); + + await upsertSeasonResultsBulk(toUpsertSeason); + + if (changed) { + await db + .update(schema.sportsSeasons) + .set({ standingsLastChangedAt: new Date() }) + .where(eq(schema.sportsSeasons.id, sportsSeasonId)); + } + } else if (!isSeasonStandings && toUpsertRegular.length > 0) { const currentRows = await db .select({ participantId: schema.regularSeasonStandings.participantId, @@ -156,17 +194,15 @@ export async function syncStandings(sportsSeasonId: string): Promise .from(schema.regularSeasonStandings) .where(inArray(schema.regularSeasonStandings.participantId, matchedParticipantIds)); - const currentByParticipant = new Map( - currentRows.map((r) => [r.participantId, r]) - ); + const currentByParticipant = new Map(currentRows.map((r) => [r.participantId, r])); - changed = toUpsert.some((row) => { + changed = toUpsertRegular.some((row) => { const current = currentByParticipant.get(row.participantId); - if (!current) return true; // new row + if (!current) return true; return current.gamesPlayed !== row.gamesPlayed || current.leagueRank !== row.leagueRank; }); - await upsertRegularSeasonStandings(toUpsert); + await upsertRegularSeasonStandings(toUpsertRegular); if (changed) { await db @@ -176,7 +212,6 @@ export async function syncStandings(sportsSeasonId: string): Promise } } - // Persist unmatched records so admin can resolve them without losing data on page reload if (unmatchedRecords.length > 0) { await upsertPendingStandingsMappings(sportsSeasonId, unmatchedRecords); } @@ -186,5 +221,6 @@ export async function syncStandings(sportsSeasonId: string): Promise externalTeamId: r.externalTeamId, })); - return { synced: toUpsert.length, unmatched, changed }; + const synced = isSeasonStandings ? toUpsertSeason.length : toUpsertRegular.length; + return { synced, unmatched, changed }; } diff --git a/app/services/standings-sync/types.ts b/app/services/standings-sync/types.ts index 992b06d..c00c597 100644 --- a/app/services/standings-sync/types.ts +++ b/app/services/standings-sync/types.ts @@ -22,6 +22,7 @@ export interface FetchedStandingsRecord { homeRecord?: string; awayRecord?: string; srs?: number | null; // Net rating or SRS proxy (pointsFor - pointsAgainst per game) + currentPoints?: number; // championship points for season_standings sports (F1, IndyCar) } export interface StandingsSyncAdapter {