2026-03-21 00:12:01 -07:00
|
|
|
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
|
2026-06-10 04:25:48 +00:00
|
|
|
import { statsMap, type EspnStat } from "./espn";
|
2026-07-06 21:20:47 -07:00
|
|
|
import { logger } from "~/lib/logger";
|
2026-06-10 04:25:48 +00:00
|
|
|
|
|
|
|
|
// 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";
|
|
|
|
|
|
2026-07-06 21:20:47 -07:00
|
|
|
// 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";
|
|
|
|
|
|
2026-06-10 04:25:48 +00:00
|
|
|
interface JolpicaDriverStanding {
|
|
|
|
|
position: string;
|
|
|
|
|
points: string;
|
|
|
|
|
wins: string;
|
|
|
|
|
Driver: {
|
|
|
|
|
driverId: string;
|
|
|
|
|
givenName: string;
|
|
|
|
|
familyName: string;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface JolpicaStandingsResponse {
|
|
|
|
|
MRData: {
|
|
|
|
|
StandingsTable: {
|
|
|
|
|
StandingsLists: Array<{
|
|
|
|
|
DriverStandings: JolpicaDriverStanding[];
|
|
|
|
|
}>;
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-03-21 00:12:01 -07:00
|
|
|
|
|
|
|
|
export class F1StandingsAdapter implements StandingsSyncAdapter {
|
|
|
|
|
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
|
2026-06-10 04:25:48 +00:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
});
|
2026-03-21 00:12:01 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 04:25:48 +00:00
|
|
|
// 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[] };
|
|
|
|
|
}>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-21 00:12:01 -07:00
|
|
|
export class IndyCarStandingsAdapter implements StandingsSyncAdapter {
|
|
|
|
|
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
|
2026-06-10 04:25:48 +00:00
|
|
|
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);
|
2026-06-10 09:17:23 -07:00
|
|
|
const points =
|
|
|
|
|
sm.get("championshipPts")?.value ??
|
|
|
|
|
sm.get("points")?.value ??
|
|
|
|
|
sm.get("pts")?.value ??
|
|
|
|
|
0;
|
2026-06-10 04:25:48 +00:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
});
|
2026-03-21 00:12:01 -07:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-06 21:20:47 -07:00
|
|
|
|
|
|
|
|
// 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<FetchedStandingsRecord[]> {
|
|
|
|
|
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<FetchedStandingsRecord[]> {
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|