import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types"; import { statsMap, type EspnStat } from "./espn"; import { logger } from "~/lib/logger"; // 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"; // ESPN IndyCar (IRL) standings endpoint const ESPN_INDYCAR_STANDINGS_URL = "https://site.api.espn.com/apis/v2/sports/racing/irl/standings"; // OC Blacktop IndyCar driver standings — updates within hours of a race finishing, // unlike ESPN's aggregate which can lag most of a day. Requires an API key. const OCBLACKTOP_INDYCAR_STANDINGS_URL = "https://api.ocblacktop.com/v1/indycar/standings/drivers"; interface JolpicaDriverStanding { position: string; points: string; wins: string; Driver: { driverId: string; givenName: string; familyName: string; }; } interface JolpicaStandingsResponse { MRData: { StandingsTable: { StandingsLists: Array<{ DriverStandings: JolpicaDriverStanding[]; }>; }; }; } export class F1StandingsAdapter implements StandingsSyncAdapter { async fetchStandings(): Promise { const standingsRes = await fetch(JOLPICA_DRIVER_STANDINGS_URL); 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); return { teamName: `${d.Driver.givenName} ${d.Driver.familyName}`, externalTeamId: d.Driver.driverId, leagueRank: position, wins, losses: 0, gamesPlayed: 0, winPct: 0, currentPoints: points, }; }); } } // ESPN racing standings shape (flat list, no conference/division nesting) interface EspnRacingAthlete { id: string; displayName: string; } interface EspnRacingEntry { athlete: EspnRacingAthlete; stats: EspnStat[]; } interface EspnRacingStandingsResponse { standings?: { entries?: EspnRacingEntry[]; }; // Some ESPN racing endpoints wrap entries under children[0].standings children?: Array<{ standings?: { entries?: EspnRacingEntry[] }; }>; } export class IndyCarStandingsAdapter implements StandingsSyncAdapter { async fetchStandings(): Promise { 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 = statsMap(entry.stats); const points = sm.get("championshipPts")?.value ?? 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, }; }); } } // OC Blacktop returns a flat array of driver standings. Fields are typed as // optional because this is untrusted external JSON — fetchFromOcBlacktop validates // them at runtime before use. interface OcBlacktopDriverStanding { id?: string; position?: number; points?: string; // e.g. "409.00" firstName?: string; lastName?: string; } /** * IndyCar standings from OC Blacktop, which posts updated championship points within * hours of a race — far fresher than ESPN's aggregate, which can stay stale most of a * day. Falls back to ESPN on any failure (missing key, network error, empty response) * so a flaky third party never breaks the sync. */ export class OcBlacktopIndyCarStandingsAdapter implements StandingsSyncAdapter { constructor( private readonly apiKey = process.env.OCBLACKTOP_API_KEY, private readonly fallback: StandingsSyncAdapter = new IndyCarStandingsAdapter() ) {} async fetchStandings(): Promise { try { return await this.fetchFromOcBlacktop(); } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.warn( `OC Blacktop IndyCar standings unavailable (${message}); falling back to ESPN.` ); return this.fallback.fetchStandings(); } } private async fetchFromOcBlacktop(): Promise { if (!this.apiKey) { throw new Error( "OCBLACKTOP_API_KEY is required to sync IndyCar standings from OC Blacktop." ); } const res = await fetch(OCBLACKTOP_INDYCAR_STANDINGS_URL, { headers: { "x-api-key": this.apiKey }, }); if (!res.ok) { throw new Error( `OC Blacktop IndyCar standings API returned ${res.status}: ${res.statusText}` ); } const drivers = (await res.json()) as OcBlacktopDriverStanding[]; if (!Array.isArray(drivers) || drivers.length === 0) { throw new Error( "OC Blacktop IndyCar standings API returned no entries — season may not have started or response shape changed" ); } return drivers.map((d): FetchedStandingsRecord => { // Validate the fields we depend on rather than coercing missing data into // plausible-looking values. A field going missing/renamed upstream means the // response shape drifted — throwing routes us to the ESPN fallback instead of // silently overwriting real standings with zeros. (A 0-point backmarker is // legitimate; an *unparseable* points value is not.) const points = Number.parseFloat(d.points ?? ""); const position = Number(d.position); const name = `${d.firstName ?? ""} ${d.lastName ?? ""}`.trim(); if (!d.id || !name || !Number.isFinite(position) || !Number.isFinite(points)) { throw new Error( `OC Blacktop IndyCar standings entry is missing expected fields (${JSON.stringify(d)}) — response shape may have changed` ); } return { teamName: name, externalTeamId: d.id, leagueRank: position, wins: 0, losses: 0, gamesPlayed: 0, winPct: 0, currentPoints: points, }; }); } }