Code — how this page is built
Under the hood
The actual source of the components on this page, read straight from the repo. Copy it, read it, or open it on GitHub.
F1LiveTimingview on GitHub ↗
Detects whether the latest OpenF1 session is live, then builds a timing board (position, gap, interval, tyre, last lap), merging short polled windows into a per-driver state and auto-refreshing every 5s when live.
'use client';
import * as React from 'react';
import {
fetchDrivers,
fetchLaps,
fetchLatestSession,
fetchNextSession,
fetchPositionsIntervals,
fetchRaceControl,
fetchRecentPits,
fetchSessionResult,
fetchStandings,
fetchStints,
fetchWeather,
type Driver,
type LapRow,
type RaceControlMsg,
type Session,
type StandingRow,
type Stint,
type Weather,
} from '@/app/f1/openf1';
const TYRE: Record<string, { c: string; t: string }> = {
SOFT: { c: '#da291c', t: 'S' },
MEDIUM: { c: '#ffd12e', t: 'M' },
HARD: { c: '#ebebeb', t: 'H' },
INTERMEDIATE: { c: '#43b02a', t: 'I' },
WET: { c: '#0067ad', t: 'W' },
};
const PURPLE = '#b13bff';
const GREEN = '#2ec27e';
const YELLOW = '#d9a400';
function fmtLap(s: number | null): string {
if (s == null) return '—';
const m = Math.floor(s / 60);
const sec = (s % 60).toFixed(3).padStart(6, '0');
return m > 0 ? `${m}:${sec}` : sec;
}
function fmtGap(g: string | number | null | undefined, isLeader: boolean): string {
if (isLeader) return 'Leader';
if (g == null || g === '') return '—';
if (typeof g === 'number') return `+${g.toFixed(3)}`;
return g;
}
function fmtCountdown(ms: number): string {
if (ms <= 0) return 'now';
const s = Math.floor(ms / 1000);
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m ${s % 60}s`;
}
function segColor(v: number | null): string {
switch (v) {
case 2051:
return PURPLE;
case 2049:
return GREEN;
case 2048:
case 2050:
return YELLOW;
case 2052:
return '#3b82f6';
default:
return 'rgba(255,255,255,0.12)';
}
}
function timeColor(t: number | null, personalBest: number | null, overallBest: number | null): string {
if (t == null) return 'inherit';
if (overallBest != null && t <= overallBest + 1e-6) return PURPLE;
if (personalBest != null && t <= personalBest + 1e-6) return GREEN;
return 'inherit';
}
function flagColor(flag: string | null, category: string | null): string {
const f = (flag ?? '').toUpperCase();
if (f.includes('RED')) return 'var(--accent-red)';
if (f.includes('YELLOW') || f.includes('DOUBLE YELLOW')) return YELLOW;
if (f.includes('GREEN') || f.includes('CLEAR')) return GREEN;
if (f.includes('BLUE')) return '#3b82f6';
if (category === 'SafetyCar') return YELLOW;
return 'rgba(255,255,255,0.45)';
}
interface DriverState {
lastLapNum: number;
lastLap: number | null;
bestLap: number | null;
lastSectors: (number | null)[];
bestSectors: (number | null)[];
segments: (number | null)[];
}
function blankState(): DriverState {
return { lastLapNum: -1, lastLap: null, bestLap: null, lastSectors: [null, null, null], bestSectors: [null, null, null], segments: [] };
}
interface Row {
num: number;
position: number;
driver?: Driver;
gap: string | number | null;
interval: string | number | null;
stint?: Stint;
inPit: boolean;
status?: string;
st: DriverState;
}
export function F1LiveTiming() {
const [session, setSession] = React.useState<Session | null>(null);
const [next, setNext] = React.useState<Session | null>(null);
const [live, setLive] = React.useState(false);
const [rows, setRows] = React.useState<Row[]>([]);
const [weather, setWeather] = React.useState<Weather | null>(null);
const [rc, setRc] = React.useState<RaceControlMsg[]>([]);
const [standings, setStandings] = React.useState<{ drivers: StandingRow[]; teams: StandingRow[] }>({ drivers: [], teams: [] });
const [status, setStatus] = React.useState<'loading' | 'ok' | 'error'>('loading');
const [updated, setUpdated] = React.useState(0);
const [, tick] = React.useReducer((n) => n + 1, 0);
const s = React.useRef({
drivers: new Map<number, Driver>(),
positions: new Map<number, number>(),
gaps: new Map<number, { gap: string | number | null; interval: string | number | null }>(),
stints: new Map<number, Stint>(),
pits: new Set<number>(),
statuses: new Map<number, string>(),
states: new Map<number, DriverState>(),
currentLap: 0,
bestLap: null as number | null,
bestSectors: [null, null, null] as (number | null)[],
});
const processLaps = React.useCallback((laps: LapRow[]) => {
const st = s.current;
for (const l of laps) {
if (l.lap_number > st.currentLap) st.currentLap = l.lap_number;
const ds = st.states.get(l.driver_number) ?? blankState();
if (l.lap_duration != null) {
// newest lap → last lap + sectors + segments
if (l.lap_number >= ds.lastLapNum) {
ds.lastLapNum = l.lap_number;
ds.lastLap = l.lap_duration;
ds.lastSectors = [l.duration_sector_1, l.duration_sector_2, l.duration_sector_3];
ds.segments = [...(l.segments_sector_1 ?? []), ...(l.segments_sector_2 ?? []), ...(l.segments_sector_3 ?? [])];
}
// personal bests
if (ds.bestLap == null || l.lap_duration < ds.bestLap) ds.bestLap = l.lap_duration;
if (st.bestLap == null || l.lap_duration < st.bestLap) st.bestLap = l.lap_duration;
}
const secs = [l.duration_sector_1, l.duration_sector_2, l.duration_sector_3];
secs.forEach((t, i) => {
if (t == null) return;
if (ds.bestSectors[i] == null || t < (ds.bestSectors[i] as number)) ds.bestSectors[i] = t;
if (st.bestSectors[i] == null || t < (st.bestSectors[i] as number)) st.bestSectors[i] = t;
});
st.states.set(l.driver_number, ds);
}
}, []);
const rebuild = React.useCallback(() => {
const st = s.current;
const out: Row[] = [];
for (const [num, pos] of st.positions) {
out.push({
num,
position: pos,
driver: st.drivers.get(num),
gap: st.gaps.get(num)?.gap ?? null,
interval: st.gaps.get(num)?.interval ?? null,
stint: st.stints.get(num),
inPit: st.pits.has(num),
status: st.statuses.get(num),
st: st.states.get(num) ?? blankState(),
});
}
out.sort((a, b) => a.position - b.position);
setRows(out);
}, []);
// initial load
React.useEffect(() => {
let alive = true;
(async () => {
const sess = await fetchLatestSession();
if (!alive) return;
if (!sess) return setStatus('error');
setSession(sess);
const now = Date.now();
const start = +new Date(sess.date_start);
const end = +new Date(sess.date_end);
const isLive = now >= start - 5 * 60000 && now <= end + 15 * 60000;
setLive(isLive);
// Batched to stay under OpenF1's rate limit (the getJson retry covers 429s).
const [drivers, stints, laps] = await Promise.all([
fetchDrivers(sess.session_key),
fetchStints(sess.session_key),
fetchLaps(sess.session_key),
]);
if (!alive) return;
s.current.drivers = drivers;
s.current.stints = stints;
processLaps(laps);
const [weath, rcMsgs, stand] = await Promise.all([
fetchWeather(sess.session_key),
fetchRaceControl(sess.session_key),
fetchStandings(),
]);
if (!alive) return;
if (isLive) {
const [pi, pits] = await Promise.all([
fetchPositionsIntervals(sess.session_key, new Date(now - 90_000).toISOString()),
fetchRecentPits(sess.session_key, new Date(now - 40_000).toISOString()),
]);
if (!alive) return;
s.current.positions = pi.positions;
s.current.gaps = pi.gaps;
s.current.pits = pits;
} else {
const result = await fetchSessionResult(sess.session_key);
if (!alive) return;
for (const r of result) {
s.current.positions.set(r.driver_number, r.position ?? 99);
s.current.gaps.set(r.driver_number, { gap: r.gap_to_leader, interval: null });
if (r.dnf) s.current.statuses.set(r.driver_number, 'DNF');
else if (r.dns) s.current.statuses.set(r.driver_number, 'DNS');
else if (r.dsq) s.current.statuses.set(r.driver_number, 'DSQ');
}
}
setWeather(weath);
setRc(rcMsgs);
setStandings(stand);
setNext(await fetchNextSession(sess.year, new Date(now).toISOString()));
setUpdated(Date.now());
setStatus('ok');
rebuild();
})();
return () => {
alive = false;
};
}, [processLaps, rebuild]);
// live polling — fast loop (order/gaps/laps/pits) every 6s; slow data
// (tyres/weather/race-control) every ~5th tick, to stay under OpenF1's rate limit.
React.useEffect(() => {
if (!live || !session) return;
let alive = true;
let n = 0;
const poll = async () => {
const now = Date.now();
const [pi, laps, pits] = await Promise.all([
fetchPositionsIntervals(session.session_key, new Date(now - 90_000).toISOString()),
fetchLaps(session.session_key, new Date(now - 150_000).toISOString()),
fetchRecentPits(session.session_key, new Date(now - 40_000).toISOString()),
]);
if (!alive) return;
pi.positions.forEach((v, k) => s.current.positions.set(k, v));
pi.gaps.forEach((v, k) => s.current.gaps.set(k, v));
s.current.pits = pits;
processLaps(laps);
if (n % 5 === 0) {
const [stints, weath, rcMsgs] = await Promise.all([
fetchStints(session.session_key),
fetchWeather(session.session_key),
fetchRaceControl(session.session_key),
]);
if (!alive) return;
if (stints.size) s.current.stints = stints;
if (weath) setWeather(weath);
if (rcMsgs.length) setRc(rcMsgs);
}
n += 1;
setUpdated(Date.now());
rebuild();
};
const id = window.setInterval(poll, 6000);
return () => {
alive = false;
window.clearInterval(id);
};
}, [live, session, processLaps, rebuild]);
React.useEffect(() => {
const id = window.setInterval(tick, 1000);
return () => window.clearInterval(id);
}, []);
if (status === 'loading') return <div className="font-mono text-sm opacity-50">reaching the timing tower…</div>;
if (status === 'error' || !session) return <div className="font-mono text-sm opacity-60">F1 timing unavailable — try again later</div>;
const ago = Math.max(0, Math.round((Date.now() - updated) / 1000));
const cur = s.current;
return (
<div className="flex w-full flex-col items-stretch gap-4" style={{ maxWidth: 'min(96vw, 1080px)' }}>
{/* header */}
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-2 rounded-md border border-foreground/15 bg-foreground/[0.04] px-4 py-3">
<div>
<div className="text-lg font-bold leading-tight">
{session.country_name} · {session.session_name}
</div>
<div className="font-mono text-[11px] uppercase tracking-[0.12em] opacity-55">
{session.circuit_short_name} · {session.year} · lap {cur.currentLap || '—'}
</div>
</div>
{weather ? (
<div className="flex gap-4 font-mono text-[11px] uppercase tracking-[0.08em] opacity-70">
<span>track {Math.round(weather.track_temperature)}°</span>
<span>air {Math.round(weather.air_temperature)}°</span>
<span>hum {Math.round(weather.humidity)}%</span>
<span>wind {weather.wind_speed.toFixed(1)}</span>
{weather.rainfall ? <span className="text-[#3b82f6]">rain</span> : null}
</div>
) : null}
{live ? (
<span className="inline-flex items-center gap-1.5 font-mono text-[11px] font-bold uppercase tracking-[0.14em] text-[var(--accent-red)]">
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--accent-red)]" /> live · {ago}s
</span>
) : (
<span className="font-mono text-[11px] uppercase tracking-[0.12em] opacity-50">
finished · {new Date(session.date_end).toLocaleDateString()}
</span>
)}
</div>
{/* board */}
<div className="overflow-x-auto rounded-md border border-foreground/15">
<table className="w-full min-w-[760px] border-collapse text-sm">
<thead>
<tr className="bg-foreground/[0.06] font-mono text-[10px] uppercase tracking-[0.08em] opacity-60">
<th className="py-1.5 pl-2 text-left font-normal">P</th>
<th className="py-1.5 text-left font-normal">Driver</th>
<th className="py-1.5 text-left font-normal">Tyre</th>
<th className="py-1.5 text-right font-normal">Int</th>
<th className="py-1.5 text-right font-normal">Leader</th>
<th className="py-1.5 text-right font-normal">Last</th>
<th className="py-1.5 text-right font-normal">Best</th>
<th className="py-1.5 px-3 text-left font-normal">Mini-sectors</th>
<th className="py-1.5 pr-2 text-right font-normal">S1 S2 S3</th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const tyre = r.stint?.compound ? TYRE[r.stint.compound] : undefined;
const age = r.stint ? Math.max(0, cur.currentLap - r.stint.lap_start + r.stint.tyre_age_at_start) : null;
return (
<tr key={r.num} className={`border-t border-foreground/10 ${r.inPit ? 'opacity-60' : ''}`}>
<td className="py-2 pl-2 font-mono opacity-60">{r.position}</td>
<td className="py-2">
<span className="inline-flex items-center gap-2">
<span className="inline-block h-4 w-1 rounded-sm" style={{ background: r.driver?.team_colour ? `#${r.driver.team_colour}` : '#888' }} />
<span className="font-semibold">{r.driver?.name_acronym ?? r.num}</span>
{r.inPit ? <span className="rounded-sm bg-[var(--accent-red)] px-1 text-[9px] font-bold text-white">PIT</span> : null}
</span>
</td>
<td className="py-2">
{tyre ? (
<span className="inline-flex items-center gap-1.5">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-bold" style={{ background: tyre.c, color: r.stint?.compound === 'HARD' ? '#14110b' : '#fff' }} title={r.stint?.compound}>
{tyre.t}
</span>
<span className="font-mono text-xs opacity-60">{age != null ? `${age}L` : ''}</span>
</span>
) : (
<span className="opacity-30">—</span>
)}
</td>
<td className="py-2 text-right font-mono tabular-nums opacity-80">{r.position === 1 || r.status ? '—' : fmtGap(r.interval, false)}</td>
<td className="py-2 text-right font-mono tabular-nums">
{r.status ? <span className="text-[var(--accent-red)]">{r.status}</span> : fmtGap(r.gap, r.position === 1)}
</td>
<td className="py-2 text-right font-mono tabular-nums" style={{ color: timeColor(r.st.lastLap, r.st.bestLap, cur.bestLap) }}>
{fmtLap(r.st.lastLap)}
</td>
<td className="py-2 text-right font-mono tabular-nums" style={{ color: r.st.bestLap != null && cur.bestLap != null && r.st.bestLap <= cur.bestLap + 1e-6 ? PURPLE : 'inherit' }}>
{fmtLap(r.st.bestLap)}
</td>
<td className="px-3 py-2">
<span className="flex h-3.5 items-end gap-px">
{r.st.segments.length
? r.st.segments.map((v, i) => <span key={i} className="h-3.5 w-[3px] rounded-[1px]" style={{ background: segColor(v) }} />)
: <span className="opacity-30">—</span>}
</span>
</td>
<td className="py-2 pr-2 text-right font-mono text-xs tabular-nums">
{[0, 1, 2].map((i) => (
<span key={i} className="ml-1.5" style={{ color: timeColor(r.st.lastSectors[i], r.st.bestSectors[i], cur.bestSectors[i]) }}>
{r.st.lastSectors[i] != null ? (r.st.lastSectors[i] as number).toFixed(3) : '—'}
</span>
))}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* race control + standings */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="rounded-md border border-foreground/15 p-3">
<div className="mb-2 font-mono text-[10px] uppercase tracking-[0.12em] opacity-55">Race control</div>
{rc.length ? (
<ul className="flex flex-col gap-1.5 font-mono text-[11px] leading-snug">
{rc.map((m, i) => (
<li key={i} className="flex gap-2">
<span className="shrink-0 opacity-45">{m.lap_number ? `L${m.lap_number}` : '·'}</span>
<span className="h-3 w-1 shrink-0 rounded-sm" style={{ background: flagColor(m.flag, m.category) }} />
<span className="opacity-80">{m.message}</span>
</li>
))}
</ul>
) : (
<div className="font-mono text-[11px] opacity-40">no messages</div>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<StandingsList title="Drivers" rows={standings.drivers} />
<StandingsList title="Teams" rows={standings.teams} />
</div>
</div>
{next ? (
<div className="text-center font-mono text-[11px] uppercase tracking-[0.12em] opacity-55">
next: {next.country_name} {next.session_name} · in {fmtCountdown(+new Date(next.date_start) - Date.now())}
</div>
) : null}
<div className="text-center font-mono text-[10px] uppercase tracking-[0.12em] opacity-35">
live timing via OpenF1 · standings via Jolpica{live ? ' · auto-refreshing' : ' · last completed session'}
</div>
</div>
);
}
function StandingsList({ title, rows }: { title: string; rows: StandingRow[] }) {
return (
<div className="rounded-md border border-foreground/15 p-3">
<div className="mb-2 font-mono text-[10px] uppercase tracking-[0.12em] opacity-55">{title}</div>
<ul className="flex flex-col gap-1 text-[12px]">
{rows.map((r) => (
<li key={r.pos} className="flex items-baseline gap-2">
<span className="w-4 shrink-0 font-mono opacity-45">{r.pos}</span>
<span className="truncate font-medium">{r.name}</span>
<span className="ml-auto shrink-0 font-mono font-bold">{r.points}</span>
</li>
))}
</ul>
</div>
);
}
openf1view on GitHub ↗
Typed OpenF1 fetchers + reducers: latest/next session, drivers, stints (tyres), and timing (position/intervals/laps) collapsed to the latest value per driver using the date filter.
/* OpenF1 (keyless, CORS-open) data layer for the live-timing board.
Time-series endpoints are reduced to the latest row per driver; live polling
fetches only a recent `since` window via the API's `date>=` filter so payloads
stay tiny. Championship standings come from the keyless Jolpica/Ergast API. */
const BASE = 'https://api.openf1.org/v1';
export interface Session {
session_key: number;
session_name: string;
session_type: string;
date_start: string;
date_end: string;
circuit_short_name: string;
country_name: string;
country_code: string;
year: number;
}
export interface Driver {
driver_number: number;
name_acronym: string;
full_name: string;
team_name: string;
team_colour: string;
}
export interface Stint {
compound: string;
lap_start: number;
lap_end: number;
tyre_age_at_start: number;
}
export interface LapRow {
driver_number: number;
lap_number: number;
lap_duration: number | null;
duration_sector_1: number | null;
duration_sector_2: number | null;
duration_sector_3: number | null;
segments_sector_1: (number | null)[] | null;
segments_sector_2: (number | null)[] | null;
segments_sector_3: (number | null)[] | null;
date_start: string | null;
is_pit_out_lap: boolean;
}
export interface Weather {
air_temperature: number;
track_temperature: number;
humidity: number;
wind_speed: number;
rainfall: number;
}
export interface RaceControlMsg {
date: string;
lap_number: number | null;
category: string | null;
flag: string | null;
message: string;
}
export interface ResultRow {
position: number | null;
driver_number: number;
number_of_laps: number | null;
gap_to_leader: string | number | null;
dnf: boolean;
dns: boolean;
dsq: boolean;
}
const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));
/** Fetch JSON; one retry on a 429 (OpenF1 rate-limit) or network error. */
async function getJson<T>(url: string): Promise<T | null> {
for (let attempt = 0; attempt < 2; attempt++) {
try {
const r = await fetch(url);
if (r.ok) return (await r.json()) as T;
if (r.status === 429 && attempt === 0) {
await sleep(700);
continue;
}
return null;
} catch {
if (attempt === 0) {
await sleep(500);
continue;
}
return null;
}
}
return null;
}
export async function fetchLatestSession(): Promise<Session | null> {
const d = await getJson<Session[]>(`${BASE}/sessions?session_key=latest`);
return d?.[0] ?? null;
}
export async function fetchNextSession(year: number, afterISO: string): Promise<Session | null> {
const d = await getJson<Session[]>(`${BASE}/sessions?year=${year}`);
if (!d) return null;
const after = +new Date(afterISO);
return (
d
.filter((s) => +new Date(s.date_start) > after)
.sort((a, b) => +new Date(a.date_start) - +new Date(b.date_start))[0] ?? null
);
}
export async function fetchDrivers(sk: number): Promise<Map<number, Driver>> {
const d = (await getJson<Driver[]>(`${BASE}/drivers?session_key=${sk}`)) ?? [];
return new Map(d.map((drv) => [drv.driver_number, drv]));
}
/** Current stint (compound + lap span) per driver. */
export async function fetchStints(sk: number): Promise<Map<number, Stint>> {
type S = Stint & { driver_number: number };
const d = (await getJson<S[]>(`${BASE}/stints?session_key=${sk}`)) ?? [];
const latest = new Map<number, S>();
for (const s of d) {
const cur = latest.get(s.driver_number);
if (!cur || s.lap_start > cur.lap_start) latest.set(s.driver_number, s);
}
const out = new Map<number, Stint>();
for (const [num, s] of latest)
out.set(num, { compound: s.compound, lap_start: s.lap_start, lap_end: s.lap_end, tyre_age_at_start: s.tyre_age_at_start });
return out;
}
/** Drivers currently in the pit lane (a pit entry in the last ~40s, live). */
export async function fetchRecentPits(sk: number, sinceISO: string): Promise<Set<number>> {
type Pit = { driver_number: number };
const d = (await getJson<Pit[]>(`${BASE}/pit?session_key=${sk}&date%3E=${encodeURIComponent(sinceISO)}`)) ?? [];
return new Set(d.map((p) => p.driver_number));
}
export async function fetchWeather(sk: number): Promise<Weather | null> {
const d = await getJson<Weather[]>(`${BASE}/weather?session_key=${sk}`);
return d?.length ? d[d.length - 1] : null;
}
export async function fetchRaceControl(sk: number): Promise<RaceControlMsg[]> {
const d = (await getJson<RaceControlMsg[]>(`${BASE}/race_control?session_key=${sk}`)) ?? [];
return d.slice(-8).reverse();
}
export async function fetchSessionResult(sk: number): Promise<ResultRow[]> {
return (await getJson<ResultRow[]>(`${BASE}/session_result?session_key=${sk}`)) ?? [];
}
interface PositionRow { driver_number: number; position: number; date: string }
interface IntervalRow { driver_number: number; gap_to_leader: string | number | null; interval: string | number | null; date: string }
/** Latest position + gap/interval per driver since a timestamp. */
export async function fetchPositionsIntervals(
sk: number,
sinceISO: string,
): Promise<{
positions: Map<number, number>;
gaps: Map<number, { gap: string | number | null; interval: string | number | null }>;
}> {
const enc = encodeURIComponent(sinceISO);
const [pos, ivl] = await Promise.all([
getJson<PositionRow[]>(`${BASE}/position?session_key=${sk}&date%3E=${enc}`),
getJson<IntervalRow[]>(`${BASE}/intervals?session_key=${sk}&date%3E=${enc}`),
]);
const positions = new Map<number, number>();
const posDate = new Map<number, number>();
for (const p of pos ?? []) {
const t = +new Date(p.date);
if (!posDate.has(p.driver_number) || t >= posDate.get(p.driver_number)!) {
posDate.set(p.driver_number, t);
positions.set(p.driver_number, p.position);
}
}
const gaps = new Map<number, { gap: string | number | null; interval: string | number | null }>();
const gapDate = new Map<number, number>();
for (const r of ivl ?? []) {
const t = +new Date(r.date);
if (!gapDate.has(r.driver_number) || t >= gapDate.get(r.driver_number)!) {
gapDate.set(r.driver_number, t);
gaps.set(r.driver_number, { gap: r.gap_to_leader, interval: r.interval });
}
}
return { positions, gaps };
}
/** Raw laps. With `sinceISO` → only recent laps (live); without → all (finished). */
export async function fetchLaps(sk: number, sinceISO?: string): Promise<LapRow[]> {
const q = sinceISO ? `&date_start%3E=${encodeURIComponent(sinceISO)}` : '';
return (await getJson<LapRow[]>(`${BASE}/laps?session_key=${sk}${q}`)) ?? [];
}
// ── Championship standings (Jolpica / Ergast) ──────────────────────────────
export interface StandingRow {
pos: string;
name: string;
detail: string;
points: string;
}
async function jolpica(path: string): Promise<unknown> {
try {
const r = await fetch(`https://api.jolpi.ca/ergast/f1/${path}`, { headers: { Accept: 'application/json' } });
return r.ok ? await r.json() : null;
} catch {
return null;
}
}
export async function fetchStandings(): Promise<{ drivers: StandingRow[]; teams: StandingRow[] }> {
const [dRaw, cRaw] = await Promise.all([
jolpica('current/driverStandings.json'),
jolpica('current/constructorStandings.json'),
]);
const dList =
(dRaw as { MRData?: { StandingsTable?: { StandingsLists?: { DriverStandings?: unknown[] }[] } } })?.MRData
?.StandingsTable?.StandingsLists?.[0]?.DriverStandings ?? [];
const cList =
(cRaw as { MRData?: { StandingsTable?: { StandingsLists?: { ConstructorStandings?: unknown[] }[] } } })?.MRData
?.StandingsTable?.StandingsLists?.[0]?.ConstructorStandings ?? [];
const drivers: StandingRow[] = (dList as Record<string, unknown>[]).slice(0, 10).map((s) => {
const d = s.Driver as { givenName: string; familyName: string };
const c = (s.Constructors as { name: string }[])?.slice(-1)[0];
return { pos: String(s.position), name: `${d.givenName} ${d.familyName}`, detail: c?.name ?? '', points: String(s.points) };
});
const teams: StandingRow[] = (cList as Record<string, unknown>[]).slice(0, 10).map((s) => {
const c = s.Constructor as { name: string };
return { pos: String(s.position), name: c.name, detail: '', points: String(s.points) };
});
return { drivers, teams };
}