import { AppError } from "../security/errors"; import { isOpenF1Configured } from "./auth"; import { asRows, latestBy, num, openF1Get, rec, str } from "./client"; import { loadCircuitMap, projectCars, type CircuitMap } from "./circuit"; import { getF1News, type F1NewsItem } from "./news"; export type F1LiveDriver = { position: number; driverNumber: number; acronym: string; fullName: string; teamName: string; teamColor: string; headshotUrl: string | null; gap: string; interval: string; lastLap: string; bestLap: string; tyre: string; tyreAge: number | null; laps: number | null; }; export type F1LiveCar = { driverNumber: number; acronym: string; teamColor: string; x: number; y: number; }; export type F1LiveTelemetry = { driverNumber: number; acronym: string; speed: number | null; throttle: number | null; brake: number | null; rpm: number | null; gear: number | null; drs: boolean; }; export type F1LiveDashboard = { live: boolean; source: string; session: { sessionKey: number | null; meetingKey: number | null; meetingName: string | null; sessionName: string | null; sessionType: string | null; circuitName: string | null; countryName: string | null; countryCode: string | null; location: string | null; dateStart: string | null; dateEnd: string | null; year: number | null; currentLap: number | null; }; weather: { airTemperature: number | null; trackTemperature: number | null; humidity: number | null; windSpeed: number | null; windDirection: number | null; rainfall: boolean; } | null; timing: F1LiveDriver[]; map: { name: string | null; path: Array<{ x: number; y: number }>; corners: CircuitMap["corners"]; cars: F1LiveCar[]; } | null; telemetry: F1LiveTelemetry | null; raceControl: Array<{ date: string | null; category: string | null; flag: string | null; message: string; driverNumber: number | null; }>; schedule: Array<{ sessionName: string; dateStart: string | null; dateEnd: string | null; status: "live" | "done" | "upcoming"; }>; news: F1NewsItem[]; }; const TYRE: Record = { SOFT: "S", MEDIUM: "M", HARD: "H", INTERMEDIATE: "I", WET: "W", SUPERSOFT: "SS", ULTRASOFT: "US", }; let snapCache: { at: number; data: F1LiveDashboard } | null = null; let inflight: Promise | null = null; const OF1_MS = 8_000; function formatLap(v: unknown): string { const n = num(v); if (n == null || n <= 0) return "—"; const m = Math.floor(n / 60); const s = n - m * 60; return `${m}:${s.toFixed(3).padStart(6, "0")}`; } function formatGap(v: unknown, isLeader: boolean): string { if (isLeader) return "—"; if (typeof v === "string") { const t = v.trim(); if (!t) return "—"; if (/lap/i.test(t)) return t.replace(/^(\+)?/, "+"); const n = Number(t); if (Number.isFinite(n)) return n >= 0 ? `+${n.toFixed(3)}` : n.toFixed(3); return t; } const n = num(v); if (n == null) return "—"; return n >= 0 ? `+${n.toFixed(3)}` : n.toFixed(3); } function tyreLetter(compound: unknown): string { const c = str(compound)?.toUpperCase() || ""; return TYRE[c] || (c ? c.slice(0, 1) : "—"); } function isLiveWindow(start: string | null, end: string | null, now = Date.now()): boolean { if (!start) return false; const a = Date.parse(start); const b = end ? Date.parse(end) : a + 3 * 3600_000; if (!Number.isFinite(a)) return false; const pad = 20 * 60_000; return now >= a - pad && now <= (Number.isFinite(b) ? b + pad : a + 4 * 3600_000); } async function settled(p: Promise, fallback: T): Promise { try { return await p; } catch { return fallback; } } export async function getF1LiveDashboard(): Promise { if (!(await isOpenF1Configured())) { throw new AppError("NOT_CONFIGURED", "OpenF1 is niet geconfigureerd", 503); } const now = Date.now(); const ttl = snapCache?.data.live ? 2_000 : 10_000; if (snapCache && now - snapCache.at < ttl) return snapCache.data; if (snapCache && now - snapCache.at < 45_000) { if (!inflight) { inflight = buildDashboard().finally(() => { inflight = null; }); } return snapCache.data; } if (inflight) return inflight; inflight = buildDashboard().finally(() => { inflight = null; }); return inflight; } async function buildDashboard(): Promise { try { return await buildDashboardInner(); } catch (err) { if (snapCache) return snapCache.data; throw err; } } async function buildDashboardInner(): Promise { const sessions = asRows(await openF1Get("sessions", { session_key: "latest" }, OF1_MS)); const session = rec(sessions[0]); const sessionKey = num(session.session_key); const meetingKey = num(session.meeting_key); if (sessionKey == null) { throw new AppError("NOT_FOUND", "Geen OpenF1-sessie gevonden", 404); } const dateStart = str(session.date_start); const dateEnd = str(session.date_end); const live = isLiveWindow(dateStart, dateEnd); const locEnd = dateEnd ? Date.parse(dateEnd) : Date.now(); const locFrom = new Date((live ? Date.now() : locEnd) - (live ? 12_000 : 90_000)).toISOString(); const locTo = live ? undefined : new Date(locEnd + 2_000).toISOString(); const year = num(session.year) ?? new Date().getUTCFullYear(); const circuitKey = num(session.circuit_key); const [ driverRows, meetingRows, positionRows, intervalRows, lapRows, stintRows, weatherRows, raceControlRows, locationRows, scheduleRows, circuitPack, ] = await Promise.all([ settled(openF1Get("drivers", { session_key: sessionKey }, OF1_MS).then(asRows), []), settled(openF1Get("meetings", { meeting_key: meetingKey ?? "latest" }, OF1_MS).then(asRows), []), settled(openF1Get("position", { session_key: sessionKey }, OF1_MS).then(asRows), []), settled(openF1Get("intervals", { session_key: sessionKey }, OF1_MS).then(asRows), []), settled(openF1Get("laps", { session_key: sessionKey }, OF1_MS).then(asRows), []), settled(openF1Get("stints", { session_key: sessionKey }, OF1_MS).then(asRows), []), settled(openF1Get("weather", { session_key: sessionKey }, OF1_MS).then(asRows), []), settled(openF1Get("race_control", { session_key: sessionKey }, OF1_MS).then(asRows), []), settled( openF1Get( "location", { session_key: sessionKey, "date>=": locFrom, ...(locTo ? { "date<": locTo } : {}), }, OF1_MS ).then(asRows), [] ), meetingKey ? settled(openF1Get("sessions", { meeting_key: meetingKey }, OF1_MS).then(asRows), []) : Promise.resolve([]), circuitKey ? loadCircuitMap(circuitKey, year) : Promise.resolve(null), ]); const news = snapCache?.data.news ?? []; void getF1News(12) .then((items) => { if (snapCache) snapCache.data = { ...snapCache.data, news: items }; }) .catch(() => undefined); const meeting = rec(meetingRows[0]); const drivers = driverRows.map(rec); const lastPos = latestBy(positionRows.map(rec), (r) => num(r.driver_number) ?? 0, (r) => str(r.date) || ""); const lastInt = latestBy(intervalRows.map(rec), (r) => num(r.driver_number) ?? 0, (r) => str(r.date) || ""); const lastStint = latestBy(stintRows.map(rec), (r) => num(r.driver_number) ?? 0, (r) => String(num(r.stint_number) ?? 0)); const lastLoc = latestBy(locationRows.map(rec), (r) => num(r.driver_number) ?? 0, (r) => str(r.date) || ""); const lastWeather = weatherRows.length ? rec(weatherRows[weatherRows.length - 1]) : null; const lapsByDriver = new Map[]>(); for (const row of lapRows.map(rec)) { const dn = num(row.driver_number); if (dn == null) continue; const list = lapsByDriver.get(dn) || []; list.push(row); lapsByDriver.set(dn, list); } const posMap = new Map(lastPos.map((r) => [num(r.driver_number) ?? -1, r])); const intMap = new Map(lastInt.map((r) => [num(r.driver_number) ?? -1, r])); const stintMap = new Map(lastStint.map((r) => [num(r.driver_number) ?? -1, r])); const timing: F1LiveDriver[] = drivers .map((d) => { const n = num(d.driver_number) ?? 0; const pos = num(posMap.get(n)?.position) ?? 99; const laps = lapsByDriver.get(n) || []; const last = laps.length ? laps.reduce((a, b) => ((num(a.lap_number) ?? 0) >= (num(b.lap_number) ?? 0) ? a : b)) : null; const best = laps .map((l) => num(l.lap_duration)) .filter((v): v is number => v != null && v > 0) .sort((a, b) => a - b)[0]; const iv = intMap.get(n); const st = stintMap.get(n); const isLeader = pos === 1; return { position: pos, driverNumber: n, acronym: str(d.name_acronym) || `#${n}`, fullName: [str(d.first_name), str(d.last_name)].filter(Boolean).join(" ") || str(d.full_name) || "", teamName: str(d.team_name) || "", teamColor: str(d.team_colour) || "888888", headshotUrl: str(d.headshot_url), gap: formatGap(iv?.gap_to_leader, isLeader), interval: formatGap(iv?.interval, isLeader), lastLap: formatLap(last?.lap_duration), bestLap: formatLap(best), tyre: tyreLetter(st?.compound), tyreAge: num(st?.tyre_age_at_start) != null && last?.lap_number != null ? (num(st?.tyre_age_at_start) || 0) + Math.max(0, (num(last.lap_number) || 1) - (num(st?.lap_start) || 1)) : num(st?.tyre_age_at_start), laps: num(last?.lap_number), }; }) .sort((a, b) => a.position - b.position); const currentLap = timing.reduce((m, d) => Math.max(m, d.laps ?? 0), 0) || null; const leader = timing[0] || null; let telemetry: F1LiveTelemetry | null = null; if (leader) { const carRows = await settled( openF1Get("car_data", { session_key: sessionKey, driver_number: leader.driverNumber, "date>=": locFrom, ...(locTo ? { "date<": locTo } : {}), }).then(asRows), [] ); const lastCar = carRows.length ? rec(carRows[carRows.length - 1]) : null; if (lastCar) { const drs = num(lastCar.drs) ?? 0; telemetry = { driverNumber: leader.driverNumber, acronym: leader.acronym, speed: num(lastCar.speed), throttle: num(lastCar.throttle), brake: num(lastCar.brake), rpm: num(lastCar.rpm), gear: num(lastCar.n_gear), drs: drs >= 10, }; } } const locByNum = new Map(lastLoc.map((r) => [num(r.driver_number) ?? -1, r])); const carsRaw = timing .map((d) => { const loc = locByNum.get(d.driverNumber); const x = num(loc?.x); const y = num(loc?.y); if (x == null || y == null) return null; return { driverNumber: d.driverNumber, x, y }; }) .filter((c): c is { driverNumber: number; x: number; y: number } => c != null); const projected = circuitPack ? projectCars(carsRaw, circuitPack.raw) : []; const projMap = new Map(projected.map((c) => [c.driverNumber, c])); const cars: F1LiveCar[] = timing .map((d) => { const p = projMap.get(d.driverNumber); if (!p) return null; return { driverNumber: d.driverNumber, acronym: d.acronym, teamColor: d.teamColor, x: p.x, y: p.y }; }) .filter((c): c is F1LiveCar => c != null); const now = Date.now(); const schedule = scheduleRows .map(rec) .sort((a, b) => (str(a.date_start) || "").localeCompare(str(b.date_start) || "")) .map((s) => { const name = str(s.session_name) || str(s.session_type) || "Sessie"; const start = str(s.date_start); const end = str(s.date_end); const status: "live" | "done" | "upcoming" = isLiveWindow(start, end, now) ? "live" : start && Date.parse(start) > now ? "upcoming" : "done"; return { sessionName: name, dateStart: start, dateEnd: end, status }; }); const raceControl = raceControlRows .map(rec) .slice(-12) .reverse() .map((r) => ({ date: str(r.date), category: str(r.category), flag: str(r.flag), message: str(r.message) || "", driverNumber: num(r.driver_number), })) .filter((r) => r.message); const data: F1LiveDashboard = { live, source: "live", session: { sessionKey, meetingKey, meetingName: str(meeting.meeting_name) || str(meeting.meeting_official_name), sessionName: str(session.session_name), sessionType: str(session.session_type), circuitName: str(session.circuit_short_name) || str(meeting.circuit_short_name), countryName: str(session.country_name) || str(meeting.country_name), countryCode: str(session.country_code) || str(meeting.country_code), location: str(session.location) || str(meeting.location), dateStart, dateEnd, year, currentLap, }, weather: lastWeather ? { airTemperature: num(lastWeather.air_temperature), trackTemperature: num(lastWeather.track_temperature), humidity: num(lastWeather.humidity), windSpeed: num(lastWeather.wind_speed), windDirection: num(lastWeather.wind_direction), rainfall: (num(lastWeather.rainfall) ?? 0) > 0, } : null, timing, map: circuitPack ? { name: circuitPack.map.name, path: circuitPack.map.path, corners: circuitPack.map.corners, cars } : null, telemetry, raceControl, schedule, news, }; snapCache = { at: Date.now(), data }; return data; }