brackt/app/services/standings-sync/f1.ts
Claude 9541318adf
Some checks failed
🚀 Deploy / 🧪 Test (pull_request) Failing after 43s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m22s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Add F1 and IndyCar standings auto-sync
Implements F1StandingsAdapter (Jolpica/Ergast API) and IndyCarStandingsAdapter
(ESPN IRL API), wires them into the syncStandings orchestrator with a
season_standings branch that upserts into participantSeasonResults instead of
regularSeasonStandings, and adds a "Championship Standings" card with a Sync
Standings button to the admin sports season page for individual sports.

https://claude.ai/code/session_012ACmUs2vAwEF9jZu7Guos2
2026-06-09 18:33:10 +00:00

173 lines
4.9 KiB
TypeScript

import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
// 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<number> {
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<FetchedStandingsRecord[]> {
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<string, EspnRacingStat> {
const map = new Map<string, EspnRacingStat>();
for (const s of stats) map.set(s.name, s);
return map;
}
export class IndyCarStandingsAdapter implements StandingsSyncAdapter {
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
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,
};
});
}
}