diff --git a/.agents/harnesses.sh b/.agents/harnesses.sh
index cef91c5..0142a2a 100644
--- a/.agents/harnesses.sh
+++ b/.agents/harnesses.sh
@@ -31,11 +31,13 @@ harness_cursor() { # Cursor CLI agent — composer-2.5, hea
( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --model composer-2.5 --force --trust )
}
-harness_agy() { # Antigravity CLI — Gemini 3.1 Pro, headless full-auto
- # --new-project is required for --model to take effect (otherwise agy resumes the
- # previous conversation and silently keeps its old model).
+harness_agy() { # Antigravity CLI — UNRELIABLE headless (2026-07): with
+ # --new-project it ignores the prompt and asks to scaffold a project; without it, it
+ # resumes the previous conversation (silently keeping its old model — --model only
+ # applies to new conversations) and can hang past the print timeout. Do not trust for
+ # dispatch until fixed upstream; verify with a trivial prompt first.
local dir="$1" prompt="$2"
- ( cd "$dir" && agy --print --new-project --print-timeout 60m \
+ ( cd "$dir" && agy --print --print-timeout 60m \
--model="Gemini 3.1 Pro (High)" --dangerously-skip-permissions "$(cat "$prompt")" )
}
diff --git a/frontend/src/components/CommandCenterHero.tsx b/frontend/src/components/CommandCenterHero.tsx
new file mode 100644
index 0000000..a67391f
--- /dev/null
+++ b/frontend/src/components/CommandCenterHero.tsx
@@ -0,0 +1,415 @@
+import { Link } from '@tanstack/react-router'
+import { Activity, ArrowRight, Play } from 'lucide-react'
+import { sessionTypeAbbrev } from '../lib/coverage'
+import { countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
+import { classifySessionStatus, type HeroStateKind } from '../lib/hero'
+import { sortLiveTimingRows, trackStatusInfo } from '../lib/live'
+import {
+ formatCountdown,
+ formatSessionScheduleTime,
+ meetingStartTime,
+ sessionStartTime,
+ type FocusMeetingKind,
+} from '../lib/schedule'
+import type { EnrichedResult, LiveStreamData, Meeting, Session, WeekendSession } from '../types'
+import '../styles/hero.css'
+
+export interface CommandCenterHeroProps {
+ state: HeroStateKind
+ now: Date
+ accent: string
+ liveActive: boolean
+ liveData: LiveStreamData | null | undefined
+ focusMeeting: Meeting
+ focusKind: FocusMeetingKind
+ sessions: WeekendSession[]
+ currentSession: Session | null
+ nextSession: Session | null
+ analysisSessionKey?: number
+ analysisSessionName?: string
+ lastRaceName: string
+ lastRacePodium: EnrichedResult[]
+ lastRaceSessionKey?: number
+ nextMeeting: Meeting | null
+}
+
+export function CommandCenterHero({
+ state,
+ now,
+ accent,
+ liveActive,
+ liveData,
+ focusMeeting,
+ focusKind,
+ sessions,
+ currentSession,
+ nextSession,
+ analysisSessionKey,
+ analysisSessionName,
+ lastRaceName,
+ lastRacePodium,
+ lastRaceSessionKey,
+ nextMeeting,
+}: CommandCenterHeroProps) {
+ const accentStyle = {
+ '--gp-accent': accent,
+ '--hero-accent': accent,
+ } as React.CSSProperties
+
+ return (
+
+
+
+
+
+ {state === 'live' && (
+
+ )}
+ {state === 'upcoming' && (
+
+ )}
+ {state === 'between' && (
+
+ )}
+
+
+
+
+ )
+}
+
+function LiveHero({
+ liveActive,
+ liveData,
+ currentSession,
+ focusMeeting,
+}: {
+ liveActive: boolean
+ liveData: LiveStreamData | null | undefined
+ currentSession: Session | null
+ focusMeeting: Meeting
+}) {
+ const sessionName =
+ liveData?.Session?.SessionName ?? currentSession?.session_name ?? 'Live session'
+ const trackStatus = trackStatusInfo(liveData?.TrackStatus)
+ const topThree = sortLiveTimingRows(liveData).filter((r) => r.Position > 0).slice(0, 3)
+ const decal = countryDecal(focusMeeting)
+
+ return (
+ <>
+
+
{decal}
+
+
● Live now
+
{sessionName}
+
+ {[focusMeeting.meeting_name, focusMeeting.circuit_short_name].filter(Boolean).join(' · ')}
+
+
+ {trackStatus.label}
+
+
+ {topThree.length > 0 && (
+
+ {topThree.map((row) => (
+
+
+ P{row.Position}
+
+
+
+ {row.Info?.Tla ?? row.RacingNumber}
+
+
+ {row.Position === 1 ? 'LEAD' : row.Driver.Interval || row.Driver.GapToLeader || '—'}
+
+
+ ))}
+
+ )}
+
+
{liveActive ? 'SignalR' : 'On track'}
+
LIVE
+
{sessionName}
+
+
+
+ >
+ )
+}
+
+function UpcomingHero({
+ now,
+ focusMeeting,
+ focusKind,
+ sessions,
+ currentSession,
+ nextSession,
+ liveActive,
+ analysisSessionKey,
+ analysisSessionName,
+}: {
+ now: Date
+ focusMeeting: Meeting
+ focusKind: FocusMeetingKind
+ sessions: WeekendSession[]
+ currentSession: Session | null
+ nextSession: Session | null
+ liveActive: boolean
+ analysisSessionKey?: number
+ analysisSessionName?: string
+}) {
+ const decal = countryDecal(focusMeeting)
+ const countdownTarget =
+ nextSession && sessionStartTime(nextSession)
+ ? sessionStartTime(nextSession)!
+ : meetingStartTime(focusMeeting)
+
+ const kindLabel =
+ focusKind === 'current'
+ ? 'Current weekend'
+ : focusKind === 'next'
+ ? 'Next weekend'
+ : 'Weekend'
+
+ const sortedSessions = [...sessions].sort((a, b) => {
+ const left = sessionStartTime(a.session)?.getTime() ?? 0
+ const right = sessionStartTime(b.session)?.getTime() ?? 0
+ return left - right
+ })
+
+ return (
+ <>
+
+
{decal}
+
+
{kindLabel}
+
{focusMeeting.meeting_name}
+
+ {[focusMeeting.location, focusMeeting.circuit_short_name].filter(Boolean).join(' · ')}
+
+
{formatGpDateRange(focusMeeting)}
+
+
+ {nextSession ? (
+ <>
+
Next · {nextSession.session_name}
+ {countdownTarget && (
+
{formatCountdown(countdownTarget, now)}
+ )}
+
{formatSessionScheduleTime(nextSession.date_start)}
+ >
+ ) : (
+ <>
+
Status
+
Complete
+
Weekend finished
+ >
+ )}
+
+
+
+ {sortedSessions.length > 0 && (
+
+ {sortedSessions.map(({ session }) => {
+ const status = classifySessionStatus(session, now)
+ const isNext = nextSession?.session_key === session.session_key
+ const isCurrent = currentSession?.session_key === session.session_key
+ const isLive = isCurrent && liveActive
+ return (
+
+
+ {sessionTypeAbbrev(session.session_type, session.session_name)}
+
+
{session.session_name}
+
+ {formatSessionScheduleTime(session.date_start)}
+
+
+ {isCurrent ? 'On track' : status === 'done' ? 'Done' : isNext ? 'Next' : 'Upcoming'}
+
+
+ )
+ })}
+
+ )}
+
+
+
+
Watch Live
+
+ {analysisSessionKey != null && (
+
+
Open Analysis
+ {analysisSessionName ? ` · ${analysisSessionName}` : ''}
+
+ )}
+
+ >
+ )
+}
+
+function BetweenHero({
+ now,
+ lastRaceName,
+ lastRacePodium,
+ lastRaceSessionKey,
+ nextMeeting,
+ analysisSessionKey,
+ analysisSessionName,
+}: {
+ now: Date
+ lastRaceName: string
+ lastRacePodium: EnrichedResult[]
+ lastRaceSessionKey?: number
+ nextMeeting: Meeting | null
+ analysisSessionKey?: number
+ analysisSessionName?: string
+}) {
+ const nextStart = nextMeeting ? meetingStartTime(nextMeeting) : null
+ const podium = lastRacePodium
+ .filter((r) => r.position >= 1 && r.position <= 3)
+ .sort((a, b) => a.position - b.position)
+
+ return (
+ <>
+
+
+
Between race weekends
+ {lastRaceName && (
+
+ After {lastRaceName}
+
+ )}
+ {!lastRaceName &&
Season pause
}
+
+
+ {podium.length > 0 && (
+
+ {podium.map((r) => (
+
+
+ P{r.position}
+
+
+
+ {r.name_acronym}
+
+
+ ))}
+
+ )}
+
+ {nextMeeting && (
+
+
+ {countryFlag(nextMeeting) && (
+
+ {countryFlag(nextMeeting)}
+
+ )}
+
+
Next GP
+
+ {nextMeeting.meeting_name}
+
+
+
+ {nextStart && (
+ <>
+
+ {formatCountdown(nextStart, now)}
+
+
{formatGpDateRange(nextMeeting)}
+ >
+ )}
+
+ )}
+
+
+
+ {lastRaceSessionKey != null && (
+
+ View {lastRaceName || 'last race'} results →
+
+ )}
+ {analysisSessionKey != null && analysisSessionKey !== lastRaceSessionKey && (
+
+ Open Analysis{analysisSessionName ? ` · ${analysisSessionName}` : ''}
+
+ )}
+
+ Live Timing →
+
+
+ >
+ )
+}
diff --git a/frontend/src/components/StrategyView.tsx b/frontend/src/components/StrategyView.tsx
index 3dee6a4..4904fd8 100644
--- a/frontend/src/components/StrategyView.tsx
+++ b/frontend/src/components/StrategyView.tsx
@@ -1,28 +1,9 @@
import type { EnrichedResult, Stint, PitStop } from '../types'
import { compareFinishPosition } from '../utils'
-
-const COMPOUND_COLORS: Record = {
- SOFT: '#e8002d',
- MEDIUM: '#ffd600',
- HARD: '#e8e8e4',
- INTERMEDIATE: '#39b54a',
- WET: '#0067ff',
-}
-
-function compoundColor(c: string): string {
- return COMPOUND_COLORS[c.toUpperCase()] ?? '#666'
-}
-
-function compoundInitial(c: string): string {
- const abbr: Record = {
- SOFT: 'S',
- MEDIUM: 'M',
- HARD: 'H',
- INTERMEDIATE: 'I',
- WET: 'W',
- }
- return abbr[c.toUpperCase()] ?? c[0] ?? '?'
-}
+import {
+ TyreStintTimeline,
+ type StintTimelineRow,
+} from './charts/TyreStintTimeline'
interface Props {
results: EnrichedResult[]
@@ -31,7 +12,7 @@ interface Props {
hasStints: boolean
}
-export function StrategyView({ results, stints, pit_stops, hasStints }: Props) {
+export function StrategyView({ results, stints, pit_stops: _pitStops, hasStints }: Props) {
if (!hasStints) {
return (
@@ -89,147 +70,29 @@ export function StrategyView({ results, stints, pit_stops, hasStints }: Props) {
const cmp = compareFinishPosition(a.position, b.position)
return cmp !== 0 ? cmp : a.driver_number - b.driver_number
})
+
const totalLaps = Math.max(
...stints.map((s) => s.lap_end),
...results.map((r) => r.number_of_laps),
- 1
+ 1,
)
- const SVG_W = 640
- const LEFT = 48
- const RIGHT = 12
- const ROW_H = 28
- const BAR_H = 14
- const BAR_Y = 7
- const BAR_W = SVG_W - LEFT - RIGHT
- const SVG_H = sortedDrivers.length * ROW_H + 8
-
- const lapX = (lap: number) => LEFT + ((lap - 1) / totalLaps) * BAR_W
- const stintW = (s: Stint) =>
- Math.max(2, ((s.lap_end - s.lap_start + 1) / totalLaps) * BAR_W)
-
- const usedCompounds = [...new Set(stints.map((s) => s.compound.toUpperCase()))].filter(
- (c) => c in COMPOUND_COLORS
- )
+ const timelineRows: StintTimelineRow[] = sortedDrivers.map((driver) => ({
+ label: driver.name_acronym || String(driver.driver_number),
+ color: driver.team_colour ? `#${driver.team_colour}` : '#888',
+ stints: stints
+ .filter((s) => s.driver_number === driver.driver_number)
+ .map((s) => ({
+ compound: s.compound,
+ lapStart: s.lap_start,
+ lapEnd: s.lap_end,
+ isNew: s.tyre_age_at_start === 0,
+ })),
+ }))
return (
-
-
-
-
-
- {usedCompounds.map((c) => (
-
-
- {c.charAt(0) + c.slice(1).toLowerCase()}
-
- ))}
- {pit_stops.length > 0 && (
-
-
- Pit stop
-
- )}
-
- {totalLaps} laps
-
-
+
)
}
diff --git a/frontend/src/components/charts/DeltaTimeGraph.tsx b/frontend/src/components/charts/DeltaTimeGraph.tsx
new file mode 100644
index 0000000..c4e840f
--- /dev/null
+++ b/frontend/src/components/charts/DeltaTimeGraph.tsx
@@ -0,0 +1,282 @@
+import { useCallback, useMemo, useRef, useState } from 'react'
+import {
+ computeCumulativeDeltas,
+ deltaPolylineSegments,
+ formatDeltaSeconds,
+ type DeltaSeries,
+ type DriverDeltaResult,
+} from '../../lib/delta'
+import '../../styles/delta-graph.css'
+
+export type { DeltaSeries }
+
+export interface DeltaTimeGraphProps {
+ series: DeltaSeries[]
+ referenceLabel?: string
+}
+
+const W = 640
+const H = 220
+const PL = 44
+const PR = 16
+const PT = 12
+const PB = 28
+
+function lapCount(series: ReadonlyArray
): number {
+ return series.reduce((max, s) => Math.max(max, s.lapTimes.length), 0)
+}
+
+function yExtent(drivers: ReadonlyArray): { min: number; max: number } {
+ let min = 0
+ let max = 0
+ for (const driver of drivers) {
+ for (const delta of driver.deltas) {
+ if (delta === null) continue
+ min = Math.min(min, delta)
+ max = Math.max(max, delta)
+ }
+ }
+ if (min === max) {
+ const pad = 1
+ return { min: min - pad, max: max + pad }
+ }
+ const span = max - min
+ const pad = span * 0.08
+ return { min: min - pad, max: max + pad }
+}
+
+function niceYTicks(min: number, max: number): number[] {
+ const span = max - min
+ if (span <= 0) return [0]
+ const rough = span / 4
+ const magnitude = Math.pow(10, Math.floor(Math.log10(rough)))
+ const step = Math.ceil(rough / magnitude) * magnitude
+ const ticks: number[] = []
+ const start = Math.ceil(min / step) * step
+ for (let v = start; v <= max + step * 0.01; v += step) {
+ ticks.push(Number(v.toFixed(6)))
+ }
+ if (!ticks.some((t) => Math.abs(t) < step * 0.01)) {
+ ticks.push(0)
+ ticks.sort((a, b) => a - b)
+ }
+ return ticks
+}
+
+export function DeltaTimeGraph({ series, referenceLabel }: DeltaTimeGraphProps) {
+ const svgRef = useRef(null)
+ const [hoverLap, setHoverLap] = useState(null)
+
+ const drivers = useMemo(
+ () => computeCumulativeDeltas(series, referenceLabel),
+ [series, referenceLabel],
+ )
+
+ const laps = useMemo(() => lapCount(series), [series])
+ const plotW = W - PL - PR
+ const plotH = H - PT - PB
+ const { min: yMin, max: yMax } = useMemo(() => yExtent(drivers), [drivers])
+ const yTicks = useMemo(() => niceYTicks(yMin, yMax), [yMin, yMax])
+
+ const toX = useCallback(
+ (lapIndex: number) => {
+ if (laps <= 1) return PL + plotW / 2
+ return PL + (lapIndex / (laps - 1)) * plotW
+ },
+ [laps, plotW],
+ )
+
+ const toY = useCallback(
+ (delta: number) => {
+ const span = yMax - yMin || 1
+ return PT + ((delta - yMin) / span) * plotH
+ },
+ [yMin, yMax, plotH],
+ )
+
+ const lapTickNumbers = useMemo(() => {
+ const ticks: number[] = []
+ for (let lap = 5; lap <= laps; lap += 5) {
+ ticks.push(lap)
+ }
+ return ticks
+ }, [laps])
+
+ const handlePointerMove = useCallback(
+ (e: React.PointerEvent | React.MouseEvent) => {
+ if (!svgRef.current || laps === 0) return
+ const rect = svgRef.current.getBoundingClientRect()
+ if (rect.width <= 0) return
+ const x = ((e.clientX - rect.left) / rect.width) * W
+ const frac = Math.max(0, Math.min(1, (x - PL) / plotW))
+ const lapIndex = laps <= 1 ? 0 : Math.round(frac * (laps - 1))
+ if (!Number.isFinite(lapIndex)) return
+ setHoverLap(Math.max(0, Math.min(laps - 1, lapIndex)))
+ },
+ [laps, plotW],
+ )
+
+ const handlePointerLeave = useCallback(() => setHoverLap(null), [])
+
+ if (series.length === 0 || laps === 0) {
+ return (
+
+
No lap data to compare.
+
+ )
+ }
+
+ if (drivers.length === 0) {
+ return (
+
+
Select at least two drivers to compare.
+
+ )
+ }
+
+ const hoverX = hoverLap !== null ? toX(hoverLap) : null
+ const tooltipRows = hoverLap !== null
+ ? drivers
+ .map((d) => {
+ const delta = d.deltas[hoverLap]
+ if (delta == null) return null
+ return { label: d.label, color: d.color, delta }
+ })
+ .filter((row): row is { label: string; color: string; delta: number } => row !== null)
+ : []
+
+ const tooltipH = 18 + tooltipRows.length * 14
+ const tooltipW = 120
+ const tooltipX = hoverX !== null ? Math.min(Math.max(hoverX + 8, PL), W - PR - tooltipW) : 0
+ const tooltipY = PT
+
+ return (
+
+
+
+ )
+}
diff --git a/frontend/src/components/charts/TelemetryTraceChart.tsx b/frontend/src/components/charts/TelemetryTraceChart.tsx
new file mode 100644
index 0000000..90d446f
--- /dev/null
+++ b/frontend/src/components/charts/TelemetryTraceChart.tsx
@@ -0,0 +1,194 @@
+import { useMemo, useRef, useState } from 'react'
+import '../../styles/telemetry-trace.css'
+
+export interface TelemetryTraceSample {
+ speed: number
+ throttle: number
+ brake: number
+}
+
+export interface TelemetryTraceSeries {
+ label: string
+ color: string
+ samples: TelemetryTraceSample[]
+}
+
+export type TelemetryTraceChannel = 'speed' | 'throttle' | 'brake'
+
+export interface TelemetryTraceChartProps {
+ series: TelemetryTraceSeries[]
+ channels?: TelemetryTraceChannel[]
+ /** Height of each channel panel in SVG units (viewBox space). */
+ height?: number
+}
+
+const VIEW_WIDTH = 800
+const PANEL_GAP = 18
+const PAD_TOP = 6
+const PAD_BOTTOM = 6
+
+const CHANNEL_LABELS: Record = {
+ speed: 'Speed',
+ throttle: 'Throttle',
+ brake: 'Brake',
+}
+
+const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v))
+
+function channelValue(sample: TelemetryTraceSample, channel: TelemetryTraceChannel): number {
+ const raw = sample[channel]
+ // Throttle/brake are percentages; clamp so out-of-range API values can't
+ // draw outside the panel. Speed is clamped to >= 0.
+ if (channel === 'speed') return Math.max(0, raw)
+ return clamp(raw, 0, 100)
+}
+
+function formatValue(value: number, channel: TelemetryTraceChannel): string {
+ if (channel === 'speed') return `${Math.round(value)} km/h`
+ return `${Math.round(value)}%`
+}
+
+export function TelemetryTraceChart({
+ series,
+ channels = ['speed', 'throttle', 'brake'],
+ height = 110,
+}: TelemetryTraceChartProps) {
+ const svgRef = useRef(null)
+ const [hoverIndex, setHoverIndex] = useState(null)
+
+ // Series are index-aligned; mismatched lengths are clamped to the shortest
+ // series so every drawn x has a value for every driver.
+ const sampleCount = useMemo(
+ () => (series.length === 0 ? 0 : Math.min(...series.map((s) => s.samples.length))),
+ [series],
+ )
+
+ const speedMax = useMemo(() => {
+ let max = 0
+ for (const s of series) {
+ for (let i = 0; i < sampleCount; i++) {
+ max = Math.max(max, channelValue(s.samples[i], 'speed'))
+ }
+ }
+ return max > 0 ? max : 1
+ }, [series, sampleCount])
+
+ if (series.length === 0 || sampleCount === 0 || channels.length === 0) {
+ return No telemetry data
+ }
+
+ // Clamp a stale hover index in case the series prop shrank between renders.
+ const hover = hoverIndex === null ? null : Math.min(hoverIndex, sampleCount - 1)
+
+ const panelHeight = height
+ const totalHeight = channels.length * panelHeight + (channels.length - 1) * PANEL_GAP
+ const xAt = (i: number) => (i / Math.max(1, sampleCount - 1)) * VIEW_WIDTH
+
+ const channelMax = (channel: TelemetryTraceChannel) => (channel === 'speed' ? speedMax : 100)
+
+ const yAt = (value: number, channel: TelemetryTraceChannel, panelTop: number) => {
+ const usable = panelHeight - PAD_TOP - PAD_BOTTOM
+ const frac = channelValue({ speed: value, throttle: value, brake: value }, channel) / channelMax(channel)
+ return panelTop + PAD_TOP + (1 - frac) * usable
+ }
+
+ const handleMouseMove = (e: React.MouseEvent) => {
+ const rect = svgRef.current?.getBoundingClientRect()
+ if (!rect || rect.width === 0) {
+ setHoverIndex(0)
+ return
+ }
+ const frac = clamp((e.clientX - rect.left) / rect.width, 0, 1)
+ setHoverIndex(clamp(Math.round(frac * (sampleCount - 1)), 0, sampleCount - 1))
+ }
+
+ return (
+
+
+ {series.map((s) => (
+
+
+ {s.label}
+
+ ))}
+
+
+ {hover !== null && (
+
+ Sample {hover}
+ {series.map((s) => (
+
+
+ {s.label}
+ {channels.map((channel) => (
+
+ {formatValue(channelValue(s.samples[hover], channel), channel)}
+
+ ))}
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/charts/TyreStintTimeline.tsx b/frontend/src/components/charts/TyreStintTimeline.tsx
new file mode 100644
index 0000000..731574c
--- /dev/null
+++ b/frontend/src/components/charts/TyreStintTimeline.tsx
@@ -0,0 +1,182 @@
+import { compoundClass } from '../../lib/live'
+import '../../styles/stint-timeline.css'
+
+export interface StintTimelineStint {
+ compound: string
+ lapStart: number
+ lapEnd: number
+ isNew?: boolean
+}
+
+export interface StintTimelineRow {
+ label: string
+ color: string
+ stints: StintTimelineStint[]
+}
+
+interface TyreStintTimelineProps {
+ rows: StintTimelineRow[]
+ totalLaps: number
+}
+
+const SVG_W = 640
+const LEFT = 48
+const RIGHT = 12
+const ROW_H = 28
+const BAR_H = 14
+const BAR_Y = 7
+const AXIS_H = 20
+const BAR_W = SVG_W - LEFT - RIGHT
+
+const COMPOUND_ORDER = ['SOFT', 'MEDIUM', 'HARD', 'INTERMEDIATE', 'WET'] as const
+
+function compoundLabel(compound: string): string {
+ const upper = compound.toUpperCase()
+ if (upper === 'INTERMEDIATE') return 'Intermediate'
+ return upper.charAt(0) + upper.slice(1).toLowerCase()
+}
+
+function stintLength(stint: StintTimelineStint): number {
+ return stint.lapEnd - stint.lapStart + 1
+}
+
+function stintTitle(stint: StintTimelineStint): string {
+ const length = stintLength(stint)
+ return `${compoundLabel(stint.compound)} · L${stint.lapStart}–${stint.lapEnd} · ${length} lap${length === 1 ? '' : 's'}`
+}
+
+function lapX(lap: number, totalLaps: number): number {
+ return LEFT + (lap / totalLaps) * BAR_W
+}
+
+function stintBarX(stint: StintTimelineStint, totalLaps: number): number {
+ return LEFT + ((stint.lapStart - 1) / totalLaps) * BAR_W
+}
+
+function stintBarW(stint: StintTimelineStint, totalLaps: number): number {
+ return Math.max(2, (stintLength(stint) / totalLaps) * BAR_W)
+}
+
+function axisTicks(totalLaps: number): number[] {
+ const ticks: number[] = []
+ for (let lap = 0; lap <= totalLaps; lap += 10) {
+ ticks.push(lap)
+ }
+ return ticks
+}
+
+function collectUsedCompounds(rows: StintTimelineRow[]): string[] {
+ const seen = new Set()
+ for (const row of rows) {
+ for (const stint of row.stints) {
+ seen.add(stint.compound.toUpperCase())
+ }
+ }
+ const ordered = COMPOUND_ORDER.filter((c) => seen.has(c))
+ const extras = [...seen]
+ .filter((c) => !COMPOUND_ORDER.includes(c as (typeof COMPOUND_ORDER)[number]))
+ .sort()
+ return [...ordered, ...extras]
+}
+
+export function TyreStintTimeline({ rows, totalLaps }: TyreStintTimelineProps) {
+ const safeTotal = Math.max(totalLaps, 1)
+ const usedCompounds = collectUsedCompounds(rows)
+ const ticks = axisTicks(safeTotal)
+ const chartH = rows.length * ROW_H
+ const svgH = chartH + AXIS_H + 4
+
+ if (rows.length === 0) {
+ return (
+
+
No stint data to display.
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+ {usedCompounds.map((compound) => (
+
+
+ {compoundLabel(compound)}
+
+ ))}
+ {safeTotal} laps
+
+
+ )
+}
diff --git a/frontend/src/components/live/TeamRadioTicker.tsx b/frontend/src/components/live/TeamRadioTicker.tsx
new file mode 100644
index 0000000..459e299
--- /dev/null
+++ b/frontend/src/components/live/TeamRadioTicker.tsx
@@ -0,0 +1,108 @@
+import { useMemo, useRef, useState } from 'react'
+import { Pause, Play } from 'lucide-react'
+import type { LiveDriverInfo, LiveRadioCapture, LiveSessionMeta } from '../../types'
+import { radioCaptureKey, radioClipUrl } from '../../lib/radio'
+import { teamColor } from '../../utils'
+import '../../styles/team-radio.css'
+
+interface Props {
+ captures?: LiveRadioCapture[]
+ driverInfo?: Record
+ session?: LiveSessionMeta
+}
+
+function relativeTime(utc: string): string {
+ const timestamp = new Date(utc).getTime()
+ if (!utc || Number.isNaN(timestamp)) return '--'
+
+ const diffSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000))
+ if (diffSeconds < 60) return `${diffSeconds}s ago`
+ const minutes = Math.floor(diffSeconds / 60)
+ if (minutes < 60) return `${minutes}m ago`
+ const hours = Math.floor(minutes / 60)
+ if (hours < 24) return `${hours}h ago`
+ return new Date(utc).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+}
+
+function driverLabel(info: LiveDriverInfo | undefined, racingNumber: string): string {
+ return info?.Tla || racingNumber
+}
+
+export function TeamRadioTicker({ captures = [], driverInfo = {}, session }: Props) {
+ const audioRef = useRef(null)
+ const [playingKey, setPlayingKey] = useState(null)
+
+ const newestFirst = useMemo(() => [...captures].reverse(), [captures])
+
+ const toggleCapture = (capture: LiveRadioCapture) => {
+ const key = radioCaptureKey(capture)
+ const audio = audioRef.current
+ const url = radioClipUrl(session, capture)
+ if (!audio || !url) return
+
+ if (playingKey === key) {
+ audio.pause()
+ setPlayingKey(null)
+ return
+ }
+
+ audio.pause()
+ audio.src = url
+ setPlayingKey(key)
+ const play = audio.play()
+ if (play) {
+ play.catch(() => setPlayingKey(null))
+ }
+ }
+
+ return (
+
+
+ Team Radio
+ {captures.length > 0 && {captures.length} clips}
+
+
+
+ )
+}
diff --git a/frontend/src/lib/delta.ts b/frontend/src/lib/delta.ts
new file mode 100644
index 0000000..eb8fc64
--- /dev/null
+++ b/frontend/src/lib/delta.ts
@@ -0,0 +1,112 @@
+// Cumulative lap-time delta math for driver comparison charts.
+// Pure functions only — no React — so everything is unit-testable.
+
+export interface DeltaSeries {
+ label: string
+ color: string
+ /** Lap duration in seconds; null = no time (pit/out lap). */
+ lapTimes: (number | null)[]
+}
+
+export interface DriverDeltaResult {
+ label: string
+ color: string
+ /** Cumulative delta vs reference (seconds); null when that lap has no time. */
+ deltas: (number | null)[]
+}
+
+/** Format a delta value for axis labels and tooltips (e.g. "+2.5s", "-1.2s"). */
+export function formatDeltaSeconds(delta: number): string {
+ const sign = delta >= 0 ? '+' : ''
+ return `${sign}${delta.toFixed(1)}s`
+}
+
+function buildCumulative(lapTimes: ReadonlyArray): number[] {
+ const cumulative: number[] = []
+ let running = 0
+ for (const lap of lapTimes) {
+ if (lap !== null) {
+ running += lap
+ }
+ cumulative.push(running)
+ }
+ return cumulative
+}
+
+function resolveReference(
+ series: ReadonlyArray,
+ referenceLabel?: string,
+): DeltaSeries | null {
+ if (series.length === 0) return null
+ if (referenceLabel) {
+ return series.find((s) => s.label === referenceLabel) ?? series[0]
+ }
+ return series[0]
+}
+
+/**
+ * Compute per-lap cumulative time delta for each non-reference driver.
+ * Positive = behind reference; negative = ahead.
+ * Null laps carry cumulative forward but emit null in deltas (skip when plotting).
+ */
+export function computeCumulativeDeltas(
+ series: ReadonlyArray,
+ referenceLabel?: string,
+): DriverDeltaResult[] {
+ const reference = resolveReference(series, referenceLabel)
+ if (!reference) return []
+
+ const refCumulative = buildCumulative(reference.lapTimes)
+
+ return series
+ .filter((s) => s.label !== reference.label)
+ .map((driver) => {
+ const driverCumulative = buildCumulative(driver.lapTimes)
+ const lapCount = Math.max(driver.lapTimes.length, refCumulative.length)
+ const deltas: (number | null)[] = []
+
+ for (let i = 0; i < lapCount; i++) {
+ if (driver.lapTimes[i] === null) {
+ deltas.push(null)
+ continue
+ }
+ const refValue = refCumulative[i] ?? refCumulative[refCumulative.length - 1] ?? 0
+ const driverValue =
+ driverCumulative[i] ?? driverCumulative[driverCumulative.length - 1] ?? 0
+ deltas.push(driverValue - refValue)
+ }
+
+ return {
+ label: driver.label,
+ color: driver.color,
+ deltas,
+ }
+ })
+}
+
+/** Split delta samples into contiguous SVG polyline point strings (gaps at null laps). */
+export function deltaPolylineSegments(
+ deltas: ReadonlyArray,
+ toPoint: (lapIndex: number, delta: number) => string,
+): string[] {
+ const segments: string[] = []
+ let current: string[] = []
+
+ for (let i = 0; i < deltas.length; i++) {
+ const value = deltas[i]
+ if (value === null) {
+ if (current.length > 0) {
+ segments.push(current.join(' '))
+ current = []
+ }
+ continue
+ }
+ current.push(toPoint(i, value))
+ }
+
+ if (current.length > 0) {
+ segments.push(current.join(' '))
+ }
+
+ return segments
+}
diff --git a/frontend/src/lib/hero.ts b/frontend/src/lib/hero.ts
new file mode 100644
index 0000000..3c12636
--- /dev/null
+++ b/frontend/src/lib/hero.ts
@@ -0,0 +1,27 @@
+import type { Session } from '../types'
+import { sessionEndTime, sessionStartTime, type FocusMeetingKind } from './schedule'
+
+export type HeroStateKind = 'live' | 'upcoming' | 'between'
+
+export interface HeroStateInputs {
+ now: Date
+ liveActive: boolean
+ currentSession: Session | null
+ focusKind: FocusMeetingKind | null
+}
+
+export function classifySessionStatus(session: Session, now: Date): 'live' | 'done' | 'upcoming' {
+ const start = sessionStartTime(session)
+ const end = sessionEndTime(session)
+ if (start && end && now >= start && now < end) return 'live'
+ if (start && now >= start) return 'done'
+ return 'upcoming'
+}
+
+export function heroState(inputs: HeroStateInputs): HeroStateKind {
+ const { liveActive, currentSession, focusKind } = inputs
+
+ if (liveActive || currentSession) return 'live'
+ if (focusKind === 'current') return 'upcoming'
+ return 'between'
+}
diff --git a/frontend/src/lib/radio.ts b/frontend/src/lib/radio.ts
new file mode 100644
index 0000000..6ff21d9
--- /dev/null
+++ b/frontend/src/lib/radio.ts
@@ -0,0 +1,22 @@
+import type { LiveRadioCapture, LiveSessionMeta } from '../types'
+
+export const LIVE_TIMING_STATIC_BASE = 'https://livetiming.formula1.com/static/'
+
+export function radioClipUrl(
+ session: Pick | null | undefined,
+ capture: Pick | null | undefined,
+): string {
+ const sessionPath = session?.Path?.trim()
+ const capturePath = capture?.Path?.trim()
+ if (!sessionPath || !capturePath) return ''
+ if (/^https?:\/\//i.test(capturePath)) return capturePath
+
+ const base = LIVE_TIMING_STATIC_BASE.replace(/\/+$/, '')
+ const normalizedSession = sessionPath.replace(/^\/+|\/+$/g, '')
+ const normalizedCapture = capturePath.replace(/^\/+/g, '')
+ return `${base}/${normalizedSession}/${normalizedCapture}`
+}
+
+export function radioCaptureKey(capture: LiveRadioCapture): string {
+ return `${capture.Utc}-${capture.RacingNumber}-${capture.Path}`
+}
diff --git a/frontend/src/pages/CommandCenterPage.tsx b/frontend/src/pages/CommandCenterPage.tsx
index 7f54907..29c7493 100644
--- a/frontend/src/pages/CommandCenterPage.tsx
+++ b/frontend/src/pages/CommandCenterPage.tsx
@@ -1,38 +1,38 @@
import { useEffect, useMemo, useState } from 'react'
import { useQueries, useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
-import { fetchLiveState, fetchLocalMeetings, fetchSeasonMeetings, fetchSeasons, fetchSessions, fetchWeekend, fetchChampionshipHub } from '../api'
+import {
+ fetchChampionshipHub,
+ fetchLiveState,
+ fetchLocalMeetings,
+ fetchRaceHub,
+ fetchSeasonMeetings,
+ fetchSeasons,
+ fetchSessions,
+ fetchWeekend,
+} from '../api'
+import { CommandCenterHero } from '../components/CommandCenterHero'
+import { PaddockBriefing } from '../components/PaddockBriefing'
import { RACE_HUB_DATASETS, countWeekendStats, formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
+import { countryAccent, countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
+import { classifySessionStatus, heroState } from '../lib/hero'
import {
currentAndNextSession,
focusMeetingKind,
- formatCountdown,
formatSessionScheduleTime,
meetingHasStarted,
+ mostRecentPastMeeting,
+ nextUpcomingMeeting,
pickFocusMeeting,
- sessionEndTime,
- sessionStartTime,
sortSessionsByStart,
} from '../lib/schedule'
-import { countryAccent, countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
import type { Meeting, Session, Weekend, WeekendSession } from '../types'
-import { PaddockBriefing } from '../components/PaddockBriefing'
-import { Play, Activity, Calendar, Trophy } from 'lucide-react'
-
-type WeekendStatusKind = 'live' | 'current' | 'next' | 'recent' | 'fallback'
+import { Trophy } from 'lucide-react'
const missingDatasets = Object.fromEntries(
RACE_HUB_DATASETS.map((dataset) => [dataset, { status: 'missing', source: 'none', count: 0 }]),
) as WeekendSession['datasets']
-function classifySessionStatus(session: Session, now: Date): 'live' | 'done' | 'upcoming' {
- const start = sessionStartTime(session)
- const end = sessionEndTime(session)
- if (start && end && now >= start && now < end) return 'live'
- if (start && now >= start) return 'done'
- return 'upcoming'
-}
-
function meetingStatus(meeting: Meeting, focusKey: number | undefined, now: Date) {
if (meeting.meeting_key === focusKey) return 'focus'
if (meetingHasStarted(meeting, now)) return 'past'
@@ -148,7 +148,36 @@ export function CommandCenterPage() {
focusWeekendSessions[0]?.session
const analysisSessionKey = actionSession?.session_key
- const weekendsLoading = weekendQueries.some((q) => q.isLoading)
+ const liveActive = liveQuery.data?.is_live === true
+ const heroStateKind = heroState({
+ now: nowDate,
+ liveActive,
+ currentSession,
+ focusKind,
+ })
+
+ const lastPastMeeting = useMemo(
+ () => mostRecentPastMeeting(focusMeetings, nowDate),
+ [focusMeetings, nowDate],
+ )
+ const lastPastWeekend = lastPastMeeting ? weekendsByKey.get(lastPastMeeting.meeting_key) : undefined
+ const lastRaceAnalysis = pickAnalysisSession(lastPastWeekend)
+ const lastRaceSessionKey = lastRaceAnalysis?.session.session_key
+
+ const lastRaceHubQuery = useQuery({
+ queryKey: ['race-hub', lastRaceSessionKey, 'hero-podium'],
+ queryFn: () => fetchRaceHub(lastRaceSessionKey!),
+ enabled: lastRaceSessionKey != null && heroStateKind === 'between',
+ staleTime: 60_000,
+ })
+
+ const nextMeetingForHero = useMemo(() => {
+ if (heroStateKind !== 'between') return null
+ return nextUpcomingMeeting(focusMeetings, nowDate)
+ }, [heroStateKind, focusMeetings, nowDate])
+
+ const lastRacePodium = lastRaceHubQuery.data?.results ?? []
+ const lastRaceName = champHub?.last_race ?? lastPastMeeting?.meeting_name ?? ''
if (seasonsQuery.isLoading) {
return loading command center…
@@ -189,19 +218,7 @@ export function CommandCenterPage() {
)
}
- const liveActive = liveQuery.data?.is_live === true
- const statusKind: WeekendStatusKind = liveActive
- ? 'live'
- : focusKind === 'current'
- ? 'current'
- : focusKind === 'next'
- ? 'next'
- : focusKind === 'recent'
- ? 'recent'
- : 'fallback'
-
const accent = countryAccent(focusMeeting ?? null)
- const decal = countryDecal(focusMeeting ?? null)
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
return (
@@ -224,76 +241,25 @@ export function CommandCenterPage() {
)}
- {focusMeeting && (
-
-
-
-
-
-
{decal}
-
-
-
-
-
{focusMeeting.meeting_name}
-
- {[focusMeeting.location, focusMeeting.circuit_short_name]
- .filter(Boolean)
- .join(' · ')}
-
-
{formatGpDateRange(focusMeeting)}
-
-
-
-
-
-
-
-
+ {focusMeeting && focusKind && (
+
)}
@@ -483,65 +449,3 @@ function FormSparkline({ form, color }: { form: number[], color: string }) {
)
}
-function WeekendKindLabel({ kind }: { kind: WeekendStatusKind }) {
- switch (kind) {
- case 'live':
- return
● Live now
- case 'current':
- return
Current weekend
- case 'next':
- return
Next weekend
- case 'recent':
- return
Recent weekend
- default:
- return
Weekend
- }
-}
-
-interface CountdownBlockProps {
- liveActive: boolean
- currentSession: Session | null
- nextSession: Session | null
- meeting: Meeting
- now: Date
-}
-
-function CountdownBlock({ liveActive, currentSession, nextSession, meeting, now }: CountdownBlockProps) {
- if (liveActive) {
- return (
-
-
SignalR
-
LIVE
-
{currentSession?.session_name ?? 'Feed connected'}
-
- )
- }
- if (currentSession) {
- return (
-
-
On Track
-
{currentSession.session_name}
-
In session
-
- )
- }
- if (nextSession && sessionStartTime(nextSession)) {
- return (
-
-
Next · {nextSession.session_name}
-
{formatCountdown(sessionStartTime(nextSession)!, now)}
-
{formatSessionScheduleTime(nextSession.date_start)}
-
- )
- }
- if (meetingHasStarted(meeting, now)) {
- return (
-
-
Status
-
Complete
-
Weekend finished
-
- )
- }
- return null
-}
diff --git a/frontend/src/pages/LiveTimingPage.tsx b/frontend/src/pages/LiveTimingPage.tsx
index 89b0a7c..1321bba 100644
--- a/frontend/src/pages/LiveTimingPage.tsx
+++ b/frontend/src/pages/LiveTimingPage.tsx
@@ -21,6 +21,7 @@ import { TimingTower } from '../components/live/TimingTower'
import { BattleChips } from '../components/live/BattleChips'
import { PinnedDrivers } from '../components/live/PinnedDrivers'
import { RaceControlFeed } from '../components/live/RaceControlFeed'
+import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
import { TrackMap } from '../components/live/TrackMap'
import { TyreDegPanel } from '../components/live/TyreDegPanel'
import { Radio } from 'lucide-react'
@@ -213,6 +214,11 @@ export function LiveTimingPage() {
/>
+
diff --git a/frontend/src/styles/delta-graph.css b/frontend/src/styles/delta-graph.css
new file mode 100644
index 0000000..e3fafd5
--- /dev/null
+++ b/frontend/src/styles/delta-graph.css
@@ -0,0 +1,76 @@
+.delta-graph {
+ width: 100%;
+ min-width: 280px;
+ max-width: 640px;
+}
+
+.delta-graph-svg {
+ display: block;
+ width: 100%;
+}
+
+.delta-graph-empty {
+ padding: 1.5rem 1rem;
+ text-align: center;
+ color: var(--text-3);
+ font-size: 0.875rem;
+}
+
+.delta-graph-zero-line {
+ stroke: var(--text-3);
+ stroke-width: 1;
+ stroke-dasharray: 4 3;
+}
+
+.delta-graph-grid-line {
+ stroke: var(--border);
+ stroke-width: 0.5;
+}
+
+.delta-graph-axis-label {
+ fill: var(--text-3);
+ font-family: var(--f-mono);
+ font-size: 9px;
+}
+
+.delta-graph-driver-line {
+ fill: none;
+ stroke-width: 2;
+ stroke-linejoin: round;
+ stroke-linecap: round;
+}
+
+.delta-graph-crosshair {
+ stroke: var(--text-2);
+ stroke-width: 1;
+ stroke-dasharray: 3 3;
+ pointer-events: none;
+}
+
+.delta-graph-hover-layer {
+ cursor: crosshair;
+ touch-action: none;
+}
+
+.delta-graph-tooltip {
+ pointer-events: none;
+}
+
+.delta-graph-tooltip-bg {
+ fill: var(--bg-elevated, var(--bg));
+ stroke: var(--border);
+ stroke-width: 1;
+}
+
+.delta-graph-tooltip-title {
+ fill: var(--text-1);
+ font-family: var(--f-mono);
+ font-size: 10px;
+ font-weight: 600;
+}
+
+.delta-graph-tooltip-row {
+ fill: var(--text-2);
+ font-family: var(--f-mono);
+ font-size: 9px;
+}
diff --git a/frontend/src/styles/hero.css b/frontend/src/styles/hero.css
new file mode 100644
index 0000000..7f1a8c1
--- /dev/null
+++ b/frontend/src/styles/hero.css
@@ -0,0 +1,373 @@
+.hero-panel {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s4);
+}
+
+.hero-card {
+ display: flex;
+ background: var(--surface);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 8px;
+ overflow: hidden;
+ min-height: 124px;
+}
+
+.hero-accent {
+ width: 4px;
+ flex-shrink: 0;
+ box-shadow: 0 0 12px var(--hero-accent, var(--gp-accent));
+ background: var(--hero-accent, var(--gp-accent));
+}
+
+.hero-accent--live {
+ animation: hero-pulse 1.4s ease-in-out infinite;
+}
+
+@keyframes hero-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.55; }
+}
+
+.hero-body {
+ flex: 1;
+ padding: var(--s5);
+ min-width: 0;
+ position: relative;
+ overflow: hidden;
+ background:
+ radial-gradient(ellipse at top right, color-mix(in srgb, var(--gp-accent) 15%, transparent), transparent 60%),
+ linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
+ linear-gradient(to bottom, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
+ var(--surface);
+ background-size: 100% 100%, 20px 20px, 20px 20px, 100% 100%;
+}
+
+.hero-body::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(90deg, transparent 0%, rgba(0, 0, 0, 0.3) 100%);
+ pointer-events: none;
+}
+
+.hero-inner {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ gap: var(--s5);
+}
+
+.hero-row {
+ display: flex;
+ align-items: stretch;
+ gap: var(--s6);
+ min-width: 0;
+ flex-wrap: wrap;
+}
+
+.hero-identity {
+ flex: 1;
+ min-width: 0;
+}
+
+.hero-decal {
+ font-size: 56px;
+ font-weight: 800;
+ line-height: 0.85;
+ letter-spacing: -0.04em;
+ color: color-mix(in srgb, var(--gp-accent) 35%, var(--text-3));
+ opacity: 0.9;
+ flex-shrink: 0;
+}
+
+.hero-eyebrow {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--text-3);
+ margin-bottom: var(--s2);
+}
+
+.hero-eyebrow--live {
+ color: var(--red);
+}
+
+.hero-title {
+ font-size: 28px;
+ font-weight: 800;
+ letter-spacing: -0.02em;
+ line-height: 1.1;
+ margin: 0;
+}
+
+.hero-sub {
+ font-size: 12px;
+ color: var(--text-2);
+ margin-top: var(--s2);
+}
+
+.hero-countdown {
+ text-align: right;
+ flex-shrink: 0;
+}
+
+.hero-cd-label {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--text-3);
+ margin-bottom: var(--s2);
+}
+
+.hero-cd-value {
+ font-size: 22px;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+ color: var(--text);
+}
+
+.hero-cd-value--live {
+ color: var(--red);
+ font-size: 28px;
+ letter-spacing: 0.08em;
+}
+
+.hero-cd-sub {
+ font-size: 11px;
+ color: var(--text-3);
+ margin-top: var(--s2);
+}
+
+.hero-track-status {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--s3);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ padding: var(--s2) var(--s3);
+ border-radius: 4px;
+ margin-top: var(--s3);
+}
+
+.hero-track-status--green { color: #3dd68c; background: rgba(61, 214, 140, 0.1); }
+.hero-track-status--yellow { color: #f5c842; background: rgba(245, 200, 66, 0.1); }
+.hero-track-status--sc,
+.hero-track-status--vsc { color: #f5a623; background: rgba(245, 166, 35, 0.12); }
+.hero-track-status--red { color: var(--red); background: rgba(225, 6, 0, 0.12); }
+.hero-track-status--unknown { color: var(--text-3); background: rgba(255, 255, 255, 0.05); }
+
+.hero-timing {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s2);
+ min-width: 200px;
+}
+
+.hero-timing-row {
+ display: grid;
+ grid-template-columns: 28px 1fr auto;
+ align-items: center;
+ gap: var(--s3);
+ padding: var(--s2) var(--s3);
+ background: rgba(255, 255, 255, 0.03);
+ border-radius: 6px;
+ font-size: 13px;
+}
+
+.hero-timing-pos {
+ font-weight: 700;
+ color: var(--text-3);
+ font-variant-numeric: tabular-nums;
+}
+
+.hero-timing-pos--p1 { color: #ffd700; }
+.hero-timing-pos--p2 { color: #c0c0c0; }
+.hero-timing-pos--p3 { color: #cd7f32; }
+
+.hero-timing-bar {
+ width: 3px;
+ height: 14px;
+ border-radius: 2px;
+ flex-shrink: 0;
+}
+
+.hero-timing-driver {
+ display: flex;
+ align-items: center;
+ gap: var(--s3);
+ font-weight: 600;
+ font-family: var(--f-mono);
+}
+
+.hero-timing-gap {
+ font-size: 11px;
+ color: var(--text-3);
+ font-family: var(--f-mono);
+ font-variant-numeric: tabular-nums;
+}
+
+.hero-cta {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--s3);
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--red);
+ text-decoration: none;
+ transition: opacity 0.1s;
+}
+
+.hero-cta:hover {
+ opacity: 0.85;
+}
+
+.hero-cta--primary {
+ padding: var(--s3) var(--s5);
+ background: rgba(225, 6, 0, 0.12);
+ border: 1px solid rgba(225, 6, 0, 0.35);
+ border-radius: 6px;
+}
+
+.hero-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--s3);
+ align-items: center;
+}
+
+.hero-secondary-link {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-2);
+ text-decoration: none;
+}
+
+.hero-secondary-link:hover {
+ color: var(--text);
+}
+
+.hero-schedule-strip {
+ display: flex;
+ gap: var(--s3);
+ overflow-x: auto;
+ padding-bottom: var(--s2);
+}
+
+.hero-schedule-card {
+ flex: 0 0 auto;
+ min-width: 120px;
+ padding: var(--s3) var(--s4);
+ border-radius: 6px;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ background: rgba(255, 255, 255, 0.02);
+ text-decoration: none;
+ color: inherit;
+ transition: background 0.1s, border-color 0.1s;
+}
+
+.hero-schedule-card:hover {
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.hero-schedule-card.is-next {
+ border-color: color-mix(in srgb, var(--gp-accent) 50%, transparent);
+ background: color-mix(in srgb, var(--gp-accent) 8%, transparent);
+}
+
+.hero-schedule-card.is-done {
+ opacity: 0.55;
+}
+
+.hero-schedule-card.is-live {
+ border-color: rgba(225, 6, 0, 0.4);
+ background: rgba(225, 6, 0, 0.08);
+}
+
+.hero-schedule-abbrev {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ color: var(--text-3);
+}
+
+.hero-schedule-name {
+ font-size: 12px;
+ font-weight: 600;
+ margin: var(--s2) 0;
+}
+
+.hero-schedule-time {
+ font-size: 10px;
+ color: var(--text-3);
+}
+
+.hero-schedule-marker {
+ font-size: 9px;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ margin-top: var(--s2);
+ color: var(--text-3);
+}
+
+.hero-schedule-marker--next { color: var(--gp-accent); }
+.hero-schedule-marker--live { color: var(--red); }
+
+.hero-podium {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s2);
+ min-width: 200px;
+}
+
+.hero-podium-row {
+ display: grid;
+ grid-template-columns: 32px 1fr;
+ align-items: center;
+ gap: var(--s3);
+ padding: var(--s2) var(--s3);
+ background: rgba(255, 255, 255, 0.03);
+ border-radius: 6px;
+ font-size: 13px;
+}
+
+.hero-podium-pos {
+ font-weight: 700;
+ font-family: var(--f-mono);
+ color: var(--text-3);
+}
+
+.hero-podium-pos--p1 { color: #ffd700; }
+.hero-podium-pos--p2 { color: #c0c0c0; }
+.hero-podium-pos--p3 { color: #cd7f32; }
+
+.hero-podium-driver {
+ display: flex;
+ align-items: center;
+ gap: var(--s3);
+ font-weight: 600;
+ font-family: var(--f-mono);
+}
+
+.hero-between-next {
+ display: flex;
+ align-items: center;
+ gap: var(--s4);
+}
+
+.hero-flag {
+ font-size: 32px;
+ line-height: 1;
+}
+
+@media (min-width: 700px) {
+ .hero-row {
+ flex-wrap: nowrap;
+ }
+}
diff --git a/frontend/src/styles/stint-timeline.css b/frontend/src/styles/stint-timeline.css
new file mode 100644
index 0000000..38e9219
--- /dev/null
+++ b/frontend/src/styles/stint-timeline.css
@@ -0,0 +1,94 @@
+.stint-timeline {
+ --stint-left: 48px;
+ --stint-right: 12px;
+ --stint-row-h: 28px;
+ --stint-bar-h: 14px;
+ --stint-axis-h: 20px;
+}
+
+.stint-timeline__scroll {
+ overflow-x: auto;
+}
+
+.stint-timeline__svg {
+ width: 100%;
+ min-width: 280px;
+ max-width: 640px;
+ display: block;
+}
+
+.stint-timeline__label {
+ font-family: var(--f-mono);
+ font-weight: 700;
+ font-size: 10px;
+}
+
+.stint-timeline__bar {
+ stroke: none;
+}
+
+.stint-timeline__bar.tyre-soft { fill: var(--tyre-soft); }
+.stint-timeline__bar.tyre-medium { fill: var(--tyre-medium); }
+.stint-timeline__bar.tyre-hard { fill: var(--tyre-hard); stroke: #555; stroke-width: 0.5; }
+.stint-timeline__bar.tyre-inter { fill: var(--tyre-inter); }
+.stint-timeline__bar.tyre-wet { fill: var(--tyre-wet); }
+.stint-timeline__bar.tyre-unknown { fill: var(--surface-2); stroke: var(--border-2); stroke-width: 0.5; }
+
+.stint-timeline__bar--new {
+ stroke: var(--text);
+ stroke-width: 1;
+ stroke-dasharray: 2 1;
+}
+
+.stint-timeline__axis-tick {
+ font-family: var(--f-mono);
+ font-size: 9px;
+ fill: var(--text-3);
+}
+
+.stint-timeline__axis-line {
+ stroke: var(--border-2);
+ stroke-width: 1;
+}
+
+.stint-timeline__legend {
+ display: flex;
+ gap: var(--s4);
+ flex-wrap: wrap;
+ margin-top: var(--s4);
+ font-size: 11px;
+ color: var(--text-3);
+ align-items: center;
+}
+
+.stint-timeline__legend-swatch {
+ width: 10px;
+ height: 10px;
+ border-radius: 2px;
+ display: inline-block;
+}
+
+.stint-timeline__legend-swatch.tyre-hard {
+ border: 1px solid #555;
+}
+
+.stint-timeline__legend-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.stint-timeline__meta {
+ margin-left: auto;
+ font-family: var(--f-mono);
+ font-size: 10px;
+}
+
+.stint-timeline__empty {
+ padding: var(--s5);
+ color: var(--text-3);
+ font-size: 13px;
+ text-align: center;
+ border: 1px dashed var(--border-2);
+ border-radius: var(--r2);
+}
diff --git a/frontend/src/styles/team-radio.css b/frontend/src/styles/team-radio.css
new file mode 100644
index 0000000..152a405
--- /dev/null
+++ b/frontend/src/styles/team-radio.css
@@ -0,0 +1,82 @@
+.team-radio {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.team-radio-audio {
+ display: none;
+}
+
+.team-radio-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ max-height: 260px;
+ overflow-y: auto;
+ padding-right: 2px;
+}
+
+.team-radio-row {
+ display: grid;
+ grid-template-columns: 28px minmax(44px, 56px) minmax(68px, 1fr);
+ align-items: center;
+ gap: 8px;
+ min-height: 34px;
+ padding: 6px 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.team-radio-row:last-child {
+ border-bottom: 0;
+}
+
+.team-radio-play {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: 50%;
+ color: var(--text-1);
+ background: rgba(255, 255, 255, 0.06);
+ cursor: pointer;
+}
+
+.team-radio-play:hover:not(:disabled),
+.team-radio-play:focus-visible:not(:disabled) {
+ border-color: rgba(255, 255, 255, 0.36);
+ background: rgba(255, 255, 255, 0.12);
+}
+
+.team-radio-play:disabled {
+ color: var(--text-3);
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+.team-radio-driver {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 44px;
+ height: 24px;
+ border-left: 3px solid var(--text-3);
+ color: var(--text-1);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0;
+}
+
+.team-radio-time {
+ min-width: 0;
+ color: var(--text-2);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ text-align: right;
+ white-space: nowrap;
+}
diff --git a/frontend/src/styles/telemetry-trace.css b/frontend/src/styles/telemetry-trace.css
new file mode 100644
index 0000000..87b316f
--- /dev/null
+++ b/frontend/src/styles/telemetry-trace.css
@@ -0,0 +1,101 @@
+.telemetry-trace {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.telemetry-trace-empty {
+ padding: 24px;
+ text-align: center;
+ color: var(--text-3);
+ font-size: 13px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+}
+
+.telemetry-trace-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 14px;
+ font-size: 12px;
+ color: var(--text-2);
+}
+
+.telemetry-trace-legend-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.telemetry-trace-swatch {
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ border-radius: 2px;
+ flex: none;
+}
+
+.telemetry-trace-svg {
+ display: block;
+ width: 100%;
+ height: auto;
+}
+
+.telemetry-trace-panel-bg {
+ fill: var(--surface);
+ stroke: var(--border);
+ stroke-width: 1;
+ rx: 4;
+}
+
+.telemetry-trace-panel-title {
+ fill: var(--text-2);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.telemetry-trace-axis-max {
+ fill: var(--text-3);
+ font-size: 10px;
+}
+
+.telemetry-trace-crosshair {
+ stroke: var(--text-2);
+ stroke-width: 1;
+ stroke-dasharray: 3 3;
+ pointer-events: none;
+}
+
+.telemetry-trace-readout {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 12px;
+ padding: 6px 10px;
+ font-size: 12px;
+ color: var(--text-2);
+ background: var(--surface-h);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+}
+
+.telemetry-trace-readout-index {
+ color: var(--text-3);
+}
+
+.telemetry-trace-readout-driver {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.telemetry-trace-readout-label {
+ color: var(--text);
+ font-weight: 600;
+}
+
+.telemetry-trace-readout-value {
+ font-variant-numeric: tabular-nums;
+}
diff --git a/frontend/src/test/CommandCenterPage.test.tsx b/frontend/src/test/CommandCenterPage.test.tsx
index 4ec99f8..1519c59 100644
--- a/frontend/src/test/CommandCenterPage.test.tsx
+++ b/frontend/src/test/CommandCenterPage.test.tsx
@@ -12,9 +12,20 @@ vi.mock('../api', () => ({
fetchSessions: vi.fn(),
fetchWeekend: vi.fn(),
fetchLiveState: vi.fn(),
+ fetchChampionshipHub: vi.fn(),
+ fetchRaceHub: vi.fn(),
}))
-import { fetchSeasons, fetchLocalMeetings, fetchSeasonMeetings, fetchSessions, fetchWeekend, fetchLiveState } from '../api'
+import {
+ fetchSeasons,
+ fetchLocalMeetings,
+ fetchSeasonMeetings,
+ fetchSessions,
+ fetchWeekend,
+ fetchLiveState,
+ fetchChampionshipHub,
+ fetchRaceHub,
+} from '../api'
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
@@ -22,6 +33,8 @@ const mockFetchSeasonMeetings = vi.mocked(fetchSeasonMeetings)
const mockFetchSessions = vi.mocked(fetchSessions)
const mockFetchWeekend = vi.mocked(fetchWeekend)
const mockFetchLiveState = vi.mocked(fetchLiveState)
+const mockFetchChampionshipHub = vi.mocked(fetchChampionshipHub)
+const mockFetchRaceHub = vi.mocked(fetchRaceHub)
const meeting: Meeting = {
meeting_key: 1229,
@@ -102,6 +115,48 @@ describe('CommandCenterPage', () => {
vi.clearAllMocks()
mockFetchLiveState.mockResolvedValue({ is_live: false, data: null })
mockFetchSessions.mockResolvedValue([])
+ mockFetchChampionshipHub.mockResolvedValue({
+ season: 2025,
+ round: 1,
+ total_rounds: 1,
+ rounds_left: 0,
+ last_race: 'Monaco',
+ round_labels: ['R1'],
+ drivers: [],
+ teams: [],
+ })
+ mockFetchRaceHub.mockResolvedValue({
+ source: 'local',
+ session_key: 9472,
+ datasets: fullDatasets,
+ results: [
+ {
+ driver_number: 1,
+ position: 1,
+ name_acronym: 'VER',
+ full_name: 'Max Verstappen',
+ team_name: 'Red Bull Racing',
+ team_colour: '3671c6',
+ dnf: false,
+ dns: false,
+ dsq: false,
+ duration: 7200,
+ gap_to_leader: null,
+ number_of_laps: 78,
+ points: 25,
+ session_key: 9472,
+ meeting_key: 1229,
+ },
+ ],
+ starting_grid: [],
+ drivers: [],
+ stints: [],
+ pit_stops: [],
+ positions: [],
+ race_control: [],
+ weather: [],
+ laps: [],
+ })
})
it('shows empty state when no seasons are ingested', async () => {
@@ -131,11 +186,10 @@ describe('CommandCenterPage', () => {
expect(screen.getByTestId('cc-session-9472')).toBeInTheDocument()
})
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
- expect(screen.getByTestId('cc-focus')).toHaveTextContent('MON')
expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar')
expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01')
expect(screen.getByText('No live session')).toBeInTheDocument()
- expect(screen.getByTestId('cc-action-race-hub')).toHaveTextContent('Race')
+ expect(screen.getByTestId('hero-last-race-link')).toHaveTextContent('Monaco')
})
it('uses OpenF1 calendar metadata to focus the current weekend when local ingest is behind', async () => {
@@ -181,9 +235,9 @@ describe('CommandCenterPage', () => {
await waitFor(() => {
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
})
- expect(screen.getByTestId('cc-focus')).toHaveTextContent('Current weekend')
+ expect(screen.getByTestId('cc-focus')).toHaveTextContent('Live now')
expect(screen.getByTestId('cc-session-9602')).toHaveTextContent('On track')
- expect(screen.getByTestId('cc-action-race-hub')).toHaveTextContent('Qualifying')
+ expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live')
vi.useRealTimers()
})
diff --git a/frontend/src/test/LiveComponents.test.tsx b/frontend/src/test/LiveComponents.test.tsx
index c3df461..7af18bc 100644
--- a/frontend/src/test/LiveComponents.test.tsx
+++ b/frontend/src/test/LiveComponents.test.tsx
@@ -201,6 +201,7 @@ describe('TimingTower', () => {
CircuitName: 'Silverstone',
SessionType: 'Sprint Qualifying',
SessionName: 'Sprint Qualifying',
+ Path: '',
}}
/>,
)
diff --git a/frontend/src/test/TeamRadioTicker.test.tsx b/frontend/src/test/TeamRadioTicker.test.tsx
new file mode 100644
index 0000000..02569a1
--- /dev/null
+++ b/frontend/src/test/TeamRadioTicker.test.tsx
@@ -0,0 +1,87 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
+import { radioClipUrl } from '../lib/radio'
+import type { LiveDriverInfo, LiveRadioCapture, LiveSessionMeta } from '../types'
+
+const session: LiveSessionMeta = {
+ MeetingName: 'British Grand Prix',
+ CircuitName: 'Silverstone',
+ SessionType: 'Race',
+ SessionName: 'Race',
+ Path: '/2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/',
+}
+
+const driverInfo: Record = {
+ '4': {
+ RacingNumber: '4',
+ BroadcastName: 'L NORRIS',
+ Tla: 'NOR',
+ TeamName: 'McLaren',
+ TeamColour: 'ff8000',
+ FirstName: 'Lando',
+ LastName: 'Norris',
+ },
+ '16': {
+ RacingNumber: '16',
+ BroadcastName: 'C LECLERC',
+ Tla: 'LEC',
+ TeamName: 'Ferrari',
+ TeamColour: 'e8002d',
+ FirstName: 'Charles',
+ LastName: 'Leclerc',
+ },
+}
+
+const captures: LiveRadioCapture[] = [
+ { Utc: '2026-07-05T14:05:00Z', RacingNumber: '16', Path: '/TeamRadio/LEC-1.mp3' },
+ { Utc: '2026-07-05T14:07:00Z', RacingNumber: '4', Path: 'TeamRadio/NOR-1.mp3' },
+]
+
+afterEach(() => {
+ vi.restoreAllMocks()
+})
+
+describe('radioClipUrl', () => {
+ it('builds static CDN URLs from the session path and capture path', () => {
+ expect(radioClipUrl(session, captures[0])).toBe(
+ 'https://livetiming.formula1.com/static/2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/TeamRadio/LEC-1.mp3',
+ )
+ })
+
+ it('returns an empty URL without path data', () => {
+ expect(radioClipUrl({ ...session, Path: '' }, captures[0])).toBe('')
+ expect(radioClipUrl(session, { ...captures[0], Path: '' })).toBe('')
+ })
+})
+
+describe('TeamRadioTicker', () => {
+ it('renders captures newest first with driver labels', () => {
+ vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-05T14:10:00Z'))
+
+ render()
+
+ const rows = screen.getAllByRole('button')
+ expect(rows).toHaveLength(2)
+ expect(screen.getByText('NOR')).toBeInTheDocument()
+ expect(screen.getByText('LEC')).toBeInTheDocument()
+ expect(screen.getAllByText(/m ago/).map((node) => node.textContent)).toEqual(['3m ago', '5m ago'])
+ })
+
+ it('uses one audio element and toggles play/pause per clip', () => {
+ const play = vi.spyOn(window.HTMLMediaElement.prototype, 'play').mockResolvedValue()
+ const pause = vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => {})
+
+ render()
+
+ fireEvent.click(screen.getByLabelText('Play NOR radio'))
+ expect(play).toHaveBeenCalledTimes(1)
+ expect(document.querySelectorAll('audio')).toHaveLength(1)
+ expect(document.querySelector('audio')?.getAttribute('src')).toBe(
+ 'https://livetiming.formula1.com/static/2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/TeamRadio/NOR-1.mp3',
+ )
+
+ fireEvent.click(screen.getByLabelText('Pause NOR radio'))
+ expect(pause).toHaveBeenCalled()
+ })
+})
diff --git a/frontend/src/test/delta-time-graph.test.tsx b/frontend/src/test/delta-time-graph.test.tsx
new file mode 100644
index 0000000..291d136
--- /dev/null
+++ b/frontend/src/test/delta-time-graph.test.tsx
@@ -0,0 +1,121 @@
+import { describe, expect, it, vi } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { DeltaTimeGraph } from '../components/charts/DeltaTimeGraph'
+import {
+ computeCumulativeDeltas,
+ deltaPolylineSegments,
+ formatDeltaSeconds,
+ type DeltaSeries,
+} from '../lib/delta'
+
+const reference: DeltaSeries = {
+ label: 'VER',
+ color: '#3671C6',
+ lapTimes: [90, 91, 92],
+}
+
+const challenger: DeltaSeries = {
+ label: 'HAM',
+ color: '#E8002D',
+ lapTimes: [89, 92, 90],
+}
+
+describe('formatDeltaSeconds', () => {
+ it('formats signed deltas with one decimal and s suffix', () => {
+ expect(formatDeltaSeconds(2.5)).toBe('+2.5s')
+ expect(formatDeltaSeconds(-1.2)).toBe('-1.2s')
+ expect(formatDeltaSeconds(0)).toBe('+0.0s')
+ })
+})
+
+describe('computeCumulativeDeltas', () => {
+ it('computes cumulative delta vs the first series by default', () => {
+ const result = computeCumulativeDeltas([reference, challenger])
+ expect(result).toHaveLength(1)
+ expect(result[0].label).toBe('HAM')
+ // Lap 1: 89-90=-1, Lap 2: (89+92)-(90+91)=0, Lap 3: (89+92+90)-(90+91+92)=-2
+ expect(result[0].deltas[0]).toBeCloseTo(-1)
+ expect(result[0].deltas[1]).toBeCloseTo(0)
+ expect(result[0].deltas[2]).toBeCloseTo(-2)
+ })
+
+ it('respects an explicit reference label', () => {
+ const result = computeCumulativeDeltas([reference, challenger], 'HAM')
+ expect(result).toHaveLength(1)
+ expect(result[0].label).toBe('VER')
+ expect(result[0].deltas[0]).toBeCloseTo(1)
+ expect(result[0].deltas[2]).toBeCloseTo(2)
+ })
+
+ it('emits null for missing lap times while carrying cumulative forward', () => {
+ const withNull: DeltaSeries = {
+ label: 'NOR',
+ color: '#FF8000',
+ lapTimes: [88, null, 93],
+ }
+ const result = computeCumulativeDeltas([reference, withNull])
+ expect(result[0].deltas[0]).toBeCloseTo(-2)
+ expect(result[0].deltas[1]).toBeNull()
+ // After null: NOR cum=181, VER cum=273 → delta -92
+ expect(result[0].deltas[2]).toBeCloseTo(-92)
+ })
+
+ it('returns an empty array when only one series is provided', () => {
+ expect(computeCumulativeDeltas([reference])).toEqual([])
+ })
+})
+
+describe('deltaPolylineSegments', () => {
+ it('splits polylines at null laps', () => {
+ const segments = deltaPolylineSegments(
+ [1, null, 2],
+ (lap, delta) => `${lap},${delta}`,
+ )
+ expect(segments).toEqual(['0,1', '2,2'])
+ })
+})
+
+describe('DeltaTimeGraph', () => {
+ it('renders an empty state with no series', () => {
+ render()
+ expect(screen.getByTestId('delta-time-graph-empty')).toBeInTheDocument()
+ expect(screen.getByText(/No lap data/i)).toBeInTheDocument()
+ })
+
+ it('renders an empty state with only the reference driver', () => {
+ render()
+ expect(screen.getByTestId('delta-time-graph-empty')).toBeInTheDocument()
+ expect(screen.getByText(/at least two drivers/i)).toBeInTheDocument()
+ })
+
+ it('renders a zero line and one polyline per non-reference driver', () => {
+ const { container } = render()
+ expect(screen.getByTestId('delta-time-graph')).toBeInTheDocument()
+ expect(container.querySelector('[data-testid="delta-zero-line"]')).toBeInTheDocument()
+ expect(screen.getByTestId('delta-line-HAM')).toBeInTheDocument()
+ expect(container.querySelectorAll('.delta-graph-driver-line')).toHaveLength(1)
+ expect(screen.queryByTestId('delta-line-VER')).not.toBeInTheDocument()
+ })
+
+ it('shows a crosshair tooltip on hover', () => {
+ vi.spyOn(SVGSVGElement.prototype, 'getBoundingClientRect').mockReturnValue({
+ x: 0,
+ y: 0,
+ left: 0,
+ top: 0,
+ width: 640,
+ height: 220,
+ right: 640,
+ bottom: 220,
+ toJSON: () => ({}),
+ })
+ const { container } = render()
+ const hoverLayer = container.querySelector('.delta-graph-hover-layer')
+ expect(hoverLayer).toBeTruthy()
+ fireEvent.mouseMove(hoverLayer!, { clientX: 44, clientY: 100 })
+ expect(screen.getByTestId('delta-crosshair')).toBeInTheDocument()
+ expect(screen.getByTestId('delta-tooltip')).toBeInTheDocument()
+ expect(screen.getByText(/Lap 1/)).toBeInTheDocument()
+ vi.restoreAllMocks()
+ })
+})
diff --git a/frontend/src/test/hero.test.ts b/frontend/src/test/hero.test.ts
new file mode 100644
index 0000000..21af353
--- /dev/null
+++ b/frontend/src/test/hero.test.ts
@@ -0,0 +1,123 @@
+import { describe, it, expect } from 'vitest'
+import { heroState, classifySessionStatus } from '../lib/hero'
+import { currentAndNextSession } from '../lib/schedule'
+import type { Session } from '../types'
+
+const session = (overrides: Partial = {}): Session => ({
+ session_key: 9472,
+ session_name: 'Race',
+ session_type: 'Race',
+ meeting_key: 1,
+ date_start: '2025-05-25T13:00:00+00:00',
+ date_end: '2025-05-25T15:00:00+00:00',
+ gmt_offset: '02:00:00',
+ ...overrides,
+})
+
+describe('heroState', () => {
+ it('returns live when the live snapshot is active', () => {
+ const now = new Date('2025-05-24T12:00:00Z')
+ expect(
+ heroState({
+ now,
+ liveActive: true,
+ currentSession: null,
+ focusKind: 'current',
+ }),
+ ).toBe('live')
+ })
+
+ it('returns live when a session is in progress', () => {
+ const now = new Date('2025-05-25T14:00:00Z')
+ const sessions = [session()]
+ const { current } = currentAndNextSession(sessions, now)
+ expect(
+ heroState({
+ now,
+ liveActive: false,
+ currentSession: current,
+ focusKind: 'current',
+ }),
+ ).toBe('live')
+ })
+
+ it('returns upcoming during a focus weekend gap before the next session', () => {
+ const now = new Date('2025-05-24T12:00:00Z')
+ const sessions = [
+ session({
+ session_key: 1,
+ session_name: 'FP1',
+ date_start: '2025-05-23T10:00:00+00:00',
+ date_end: '2025-05-23T11:00:00+00:00',
+ }),
+ session({
+ session_key: 2,
+ session_name: 'Race',
+ date_start: '2025-05-25T13:00:00+00:00',
+ date_end: '2025-05-25T15:00:00+00:00',
+ }),
+ ]
+ const { current, next } = currentAndNextSession(sessions, now)
+ expect(current).toBeNull()
+ expect(next?.session_key).toBe(2)
+ expect(
+ heroState({
+ now,
+ liveActive: false,
+ currentSession: current,
+ focusKind: 'current',
+ }),
+ ).toBe('upcoming')
+ })
+
+ it('returns upcoming when the weekend just ended but is still the focus weekend', () => {
+ const now = new Date('2025-05-25T16:00:00Z')
+ const sessions = [session()]
+ const { current } = currentAndNextSession(sessions, now)
+ expect(current).toBeNull()
+ expect(
+ heroState({
+ now,
+ liveActive: false,
+ currentSession: current,
+ focusKind: 'current',
+ }),
+ ).toBe('upcoming')
+ })
+
+ it('returns between before the next grand prix weekend', () => {
+ const now = new Date('2025-06-01T12:00:00Z')
+ expect(
+ heroState({
+ now,
+ liveActive: false,
+ currentSession: null,
+ focusKind: 'next',
+ }),
+ ).toBe('between')
+ })
+
+ it('returns between after the season when only a recent meeting remains', () => {
+ const now = new Date('2026-01-01T00:00:00Z')
+ expect(
+ heroState({
+ now,
+ liveActive: false,
+ currentSession: null,
+ focusKind: 'recent',
+ }),
+ ).toBe('between')
+ })
+})
+
+describe('classifySessionStatus', () => {
+ it('marks a session as done immediately after it ends', () => {
+ const now = new Date('2025-05-25T15:00:00Z')
+ expect(classifySessionStatus(session(), now)).toBe('done')
+ })
+
+ it('marks a session as live during its window', () => {
+ const now = new Date('2025-05-25T14:00:00Z')
+ expect(classifySessionStatus(session(), now)).toBe('live')
+ })
+})
diff --git a/frontend/src/test/hero.test.tsx b/frontend/src/test/hero.test.tsx
new file mode 100644
index 0000000..3db06d1
--- /dev/null
+++ b/frontend/src/test/hero.test.tsx
@@ -0,0 +1,257 @@
+import { describe, it, expect, vi } from 'vitest'
+import type { ComponentProps, ReactNode } from 'react'
+import { render, screen } from '@testing-library/react'
+import type { EnrichedResult, LiveStreamData, Meeting, WeekendSession } from '../types'
+
+vi.mock('@tanstack/react-router', () => ({
+ Link: ({
+ to,
+ children,
+ ...rest
+ }: {
+ to: string
+ children: ReactNode
+ className?: string
+ 'data-testid'?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+import { CommandCenterHero } from '../components/CommandCenterHero'
+
+const focusMeeting: Meeting = {
+ meeting_key: 1229,
+ meeting_name: 'Monaco',
+ meeting_official_name: 'FORMULA 1 GRAND PRIX DE MONACO 2025',
+ location: 'Monaco',
+ country_name: 'Monaco',
+ country_code: 'MON',
+ country_flag: '',
+ circuit_short_name: 'Monaco',
+ date_start: '2025-05-23T00:00:00+00:00',
+ date_end: '2025-05-25T00:00:00+00:00',
+ year: 2025,
+}
+
+const weekendSessions: WeekendSession[] = [
+ {
+ session: {
+ session_key: 9472,
+ session_name: 'Race',
+ session_type: 'Race',
+ meeting_key: 1229,
+ date_start: '2025-05-25T13:00:00+00:00',
+ date_end: '2025-05-25T15:00:00+00:00',
+ gmt_offset: '02:00:00',
+ },
+ source: 'local',
+ datasets: {} as WeekendSession['datasets'],
+ },
+]
+
+const liveData: LiveStreamData = {
+ Drivers: {
+ '1': {
+ RacingNumber: '1',
+ Position: 1,
+ PrevPosition: 1,
+ GapToLeader: '',
+ Interval: '',
+ LastLapTime: '1:14.000',
+ LastLapPB: false,
+ LastLapOB: false,
+ BestLapTime: '1:13.500',
+ BestLapPB: false,
+ BestLapOB: false,
+ BestLapNum: 10,
+ InPit: false,
+ PitOut: false,
+ Retired: false,
+ KnockedOut: false,
+ Cutoff: false,
+ OnFlyingLap: false,
+ NumberOfLaps: 10,
+ SpeedTrap: '',
+ Sectors: [],
+ },
+ '44': {
+ RacingNumber: '44',
+ Position: 2,
+ PrevPosition: 2,
+ GapToLeader: '+1.2',
+ Interval: '+1.2',
+ LastLapTime: '1:14.200',
+ LastLapPB: false,
+ LastLapOB: false,
+ BestLapTime: '1:13.700',
+ BestLapPB: false,
+ BestLapOB: false,
+ BestLapNum: 10,
+ InPit: false,
+ PitOut: false,
+ Retired: false,
+ KnockedOut: false,
+ Cutoff: false,
+ OnFlyingLap: false,
+ NumberOfLaps: 10,
+ SpeedTrap: '',
+ Sectors: [],
+ },
+ },
+ DriverInfo: {
+ '1': {
+ RacingNumber: '1',
+ BroadcastName: 'M VERSTAPPEN',
+ Tla: 'VER',
+ TeamName: 'Red Bull Racing',
+ TeamColour: '3671c6',
+ FirstName: 'Max',
+ LastName: 'Verstappen',
+ },
+ '44': {
+ RacingNumber: '44',
+ BroadcastName: 'L HAMILTON',
+ Tla: 'HAM',
+ TeamName: 'Ferrari',
+ TeamColour: 'e8002d',
+ FirstName: 'Lewis',
+ LastName: 'Hamilton',
+ },
+ },
+ Tyres: {},
+ RCMessages: [],
+ Weather: {
+ AirTemp: 20,
+ TrackTemp: 30,
+ Humidity: 50,
+ WindSpeed: 1,
+ WindDir: 0,
+ Rainfall: false,
+ },
+ Session: {
+ MeetingName: 'Monaco',
+ CircuitName: 'Monaco',
+ SessionType: 'Race',
+ SessionName: 'Race',
+ },
+ TrackStatus: '1',
+ CurrentLap: 10,
+ TotalLaps: 78,
+ Clock: '',
+ ClockRefTime: '',
+ ClockExtrapolating: false,
+ Stints: {},
+}
+
+const podium: EnrichedResult[] = [
+ {
+ driver_number: 1,
+ position: 1,
+ name_acronym: 'VER',
+ full_name: 'Max Verstappen',
+ team_name: 'Red Bull Racing',
+ team_colour: '3671c6',
+ dnf: false,
+ dns: false,
+ dsq: false,
+ duration: 7200,
+ gap_to_leader: null,
+ number_of_laps: 78,
+ points: 25,
+ session_key: 9472,
+ meeting_key: 1229,
+ },
+ {
+ driver_number: 44,
+ position: 2,
+ name_acronym: 'HAM',
+ full_name: 'Lewis Hamilton',
+ team_name: 'Ferrari',
+ team_colour: 'e8002d',
+ dnf: false,
+ dns: false,
+ dsq: false,
+ duration: 7205,
+ gap_to_leader: 3.2,
+ number_of_laps: 78,
+ points: 18,
+ session_key: 9472,
+ meeting_key: 1229,
+ },
+]
+
+function renderHero(overrides: Partial> = {}) {
+ const props: ComponentProps = {
+ state: 'upcoming',
+ now: new Date('2025-05-24T12:00:00Z'),
+ accent: '#d61a3e',
+ liveActive: false,
+ liveData: null,
+ focusMeeting,
+ focusKind: 'current',
+ sessions: weekendSessions,
+ currentSession: null,
+ nextSession: weekendSessions[0].session,
+ lastRaceName: '',
+ lastRacePodium: [],
+ nextMeeting: null,
+ ...overrides,
+ }
+
+ return render()
+}
+
+describe('CommandCenterHero', () => {
+ it('renders live timing link and top three in live state', () => {
+ renderHero({
+ state: 'live',
+ liveActive: true,
+ liveData,
+ currentSession: weekendSessions[0].session,
+ })
+
+ expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live')
+ expect(screen.getByTestId('hero-live-timing')).toHaveTextContent('VER')
+ expect(screen.getByTestId('hero-live-timing')).toHaveTextContent('HAM')
+ expect(screen.getByText('TRACK CLEAR')).toBeInTheDocument()
+ })
+
+ it('renders countdown and schedule strip in upcoming state', () => {
+ renderHero({
+ state: 'upcoming',
+ nextSession: weekendSessions[0].session,
+ })
+
+ expect(screen.getByTestId('hero-countdown')).toHaveTextContent('1d 01h 00m 00s')
+ expect(screen.getByTestId('hero-schedule-strip')).toHaveTextContent('Race')
+ expect(screen.getByTestId('hero-schedule-strip')).toHaveTextContent('Next')
+ })
+
+ it('renders last race podium and next GP countdown in between state', () => {
+ const nextMeeting: Meeting = {
+ ...focusMeeting,
+ meeting_key: 1301,
+ meeting_name: 'Canada',
+ country_code: 'CAN',
+ date_start: '2025-06-06T00:00:00+00:00',
+ date_end: '2025-06-08T00:00:00+00:00',
+ }
+
+ renderHero({
+ state: 'between',
+ lastRaceName: 'Monaco',
+ lastRacePodium: podium,
+ nextMeeting,
+ now: new Date('2025-06-01T12:00:00Z'),
+ })
+
+ expect(screen.getByTestId('hero-last-race')).toHaveTextContent('After Monaco')
+ expect(screen.getByTestId('hero-podium')).toHaveTextContent('VER')
+ expect(screen.getByTestId('hero-podium')).toHaveTextContent('HAM')
+ expect(screen.getByTestId('hero-next-gp-countdown')).toHaveTextContent('Canada')
+ expect(screen.getByTestId('hero-next-gp-countdown')).toHaveTextContent('4d')
+ })
+})
diff --git a/frontend/src/test/live.test.ts b/frontend/src/test/live.test.ts
index 28b90ef..68b331b 100644
--- a/frontend/src/test/live.test.ts
+++ b/frontend/src/test/live.test.ts
@@ -100,7 +100,8 @@ const snapshot: LiveStreamData = {
{ Time: '14:08', Category: 'Drs', Flag: '', Message: 'DRS ENABLED', Lap: 3 },
],
Weather: { AirTemp: 20, TrackTemp: 31, Humidity: 55, WindSpeed: 2, WindDir: 180, Rainfall: false },
- Session: { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race' },
+ Session: { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race', Path: '2026/Monaco/Race' },
+ TeamRadio: [],
TrackStatus: '1',
CurrentLap: 21,
TotalLaps: 78,
@@ -220,7 +221,7 @@ describe('track status mapping', () => {
describe('live qualifying display', () => {
it('puts the SQ1 cutoff after P17 for a 22-car sprint qualifying session', () => {
const display = liveSessionDisplay(
- { MeetingName: 'British Grand Prix', CircuitName: 'Silverstone', SessionType: 'Sprint Qualifying', SessionName: 'Sprint Qualifying' },
+ { MeetingName: 'British Grand Prix', CircuitName: 'Silverstone', SessionType: 'Sprint Qualifying', SessionName: 'Sprint Qualifying', Path: '' },
rows(22),
)
expect(display.phaseLabel).toBe('SQ1')
@@ -232,7 +233,7 @@ describe('live qualifying display', () => {
it('keeps the normal Q1 cutoff after P15 for a 20-car qualifying session', () => {
const display = liveSessionDisplay(
- { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying' },
+ { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying', Path: '' },
rows(20),
)
expect(display.phaseLabel).toBe('Q1')
@@ -241,7 +242,7 @@ describe('live qualifying display', () => {
it('moves phase 2 cutoff after P10 once five cars are knocked out', () => {
const display = liveSessionDisplay(
- { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying' },
+ { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying', Path: '' },
rows(20, 5),
)
expect(display.phaseLabel).toBe('Q2')
@@ -251,13 +252,13 @@ describe('live qualifying display', () => {
it('shows no cutoff for race sessions or Q3', () => {
expect(
liveSessionDisplay(
- { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race' },
+ { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race', Path: '' },
rows(20),
).cutoffPosition,
).toBeNull()
expect(
liveSessionDisplay(
- { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Q3' },
+ { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Q3', Path: '' },
rows(10),
).cutoffPosition,
).toBeNull()
diff --git a/frontend/src/test/telemetry-trace-chart.test.tsx b/frontend/src/test/telemetry-trace-chart.test.tsx
new file mode 100644
index 0000000..1281cb7
--- /dev/null
+++ b/frontend/src/test/telemetry-trace-chart.test.tsx
@@ -0,0 +1,118 @@
+import { describe, expect, it } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import {
+ TelemetryTraceChart,
+ type TelemetryTraceSeries,
+} from '../components/charts/TelemetryTraceChart'
+
+function makeSeries(label: string, color: string, speeds: number[]): TelemetryTraceSeries {
+ return {
+ label,
+ color,
+ samples: speeds.map((speed, i) => ({
+ speed,
+ throttle: (i * 25) % 101,
+ brake: i % 2 === 0 ? 100 : 0,
+ })),
+ }
+}
+
+const VER = makeSeries('VER', '#2563eb', [280, 310, 190, 90, 240])
+const LEC = makeSeries('LEC', '#dc2626', [275, 305, 200, 95, 235])
+
+describe('TelemetryTraceChart', () => {
+ it('renders one polyline per series per channel', () => {
+ const { container } = render()
+ const lines = container.querySelectorAll('polyline.telemetry-trace-line')
+ expect(lines).toHaveLength(2 * 3)
+ for (const channel of ['speed', 'throttle', 'brake']) {
+ expect(
+ container.querySelectorAll(`polyline.telemetry-trace-line[data-channel="${channel}"]`),
+ ).toHaveLength(2)
+ }
+ expect(
+ container.querySelectorAll('polyline.telemetry-trace-line[data-series="VER"]'),
+ ).toHaveLength(3)
+ })
+
+ it('respects the channels prop', () => {
+ const { container } = render()
+ expect(container.querySelectorAll('polyline.telemetry-trace-line')).toHaveLength(1)
+ expect(container.querySelectorAll('.telemetry-trace-panel')).toHaveLength(1)
+ })
+
+ it('renders an empty state for no series', () => {
+ render()
+ expect(screen.getByText('No telemetry data')).toBeInTheDocument()
+ })
+
+ it('renders an empty state when a series has no samples', () => {
+ render()
+ expect(screen.getByText('No telemetry data')).toBeInTheDocument()
+ })
+
+ it('survives a single sample and mismatched series lengths', () => {
+ const short = makeSeries('VER', '#2563eb', [300])
+ const long = makeSeries('LEC', '#dc2626', [280, 290, 300])
+ const { container } = render()
+ // Clamped to the shortest series: every polyline has exactly one point.
+ const lines = container.querySelectorAll('polyline.telemetry-trace-line')
+ expect(lines).toHaveLength(6)
+ for (const line of lines) {
+ expect(line.getAttribute('points')!.trim().split(' ')).toHaveLength(1)
+ }
+ })
+
+ it('clamps throttle values to 0-100 within the panel', () => {
+ const wild: TelemetryTraceSeries = {
+ label: 'VER',
+ color: '#2563eb',
+ samples: [
+ { speed: 100, throttle: 150, brake: 0 },
+ { speed: 100, throttle: -20, brake: 0 },
+ { speed: 100, throttle: 50, brake: 0 },
+ ],
+ }
+ const { container } = render(
+ ,
+ )
+ const line = container.querySelector('polyline.telemetry-trace-line[data-channel="throttle"]')!
+ const ys = line
+ .getAttribute('points')!
+ .split(' ')
+ .map((p) => Number(p.split(',')[1]))
+ // Panel occupies y 0..110 with 6px padding; clamped values pin to the edges.
+ expect(ys[0]).toBeCloseTo(6, 1) // 150% -> 100% -> panel top
+ expect(ys[1]).toBeCloseTo(104, 1) // -20% -> 0% -> panel bottom
+ expect(ys[2]).toBeCloseTo(55, 1) // 50% -> middle
+ })
+
+ it('shows the speed axis max in km/h', () => {
+ render()
+ expect(screen.getByText('310 km/h')).toBeInTheDocument()
+ })
+
+ it('lists every series in the legend with a color swatch', () => {
+ const { container } = render()
+ const legend = container.querySelector('.telemetry-trace-legend')!
+ expect(legend.textContent).toContain('VER')
+ expect(legend.textContent).toContain('LEC')
+ expect(legend.querySelectorAll('.telemetry-trace-swatch')).toHaveLength(2)
+ })
+
+ it('shows a crosshair and per-driver readout on hover, hides on leave', () => {
+ const { container } = render()
+ const svg = container.querySelector('svg.telemetry-trace-svg')!
+ expect(screen.queryByTestId('telemetry-trace-crosshair')).not.toBeInTheDocument()
+
+ fireEvent.mouseMove(svg, { clientX: 0, clientY: 10 })
+ expect(screen.getByTestId('telemetry-trace-crosshair')).toBeInTheDocument()
+ const readout = screen.getByTestId('telemetry-trace-readout')
+ expect(readout.textContent).toContain('VER')
+ expect(readout.textContent).toContain('LEC')
+ expect(readout.textContent).toContain('km/h')
+
+ fireEvent.mouseLeave(svg)
+ expect(screen.queryByTestId('telemetry-trace-crosshair')).not.toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/test/tyre-stint-timeline.test.tsx b/frontend/src/test/tyre-stint-timeline.test.tsx
new file mode 100644
index 0000000..b090c2e
--- /dev/null
+++ b/frontend/src/test/tyre-stint-timeline.test.tsx
@@ -0,0 +1,149 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { TyreStintTimeline, type StintTimelineRow } from '../components/charts/TyreStintTimeline'
+import { StrategyView } from '../components/StrategyView'
+import type { EnrichedResult, Stint, PitStop } from '../types'
+
+const sampleRows: StintTimelineRow[] = [
+ {
+ label: 'VER',
+ color: '#3671C6',
+ stints: [
+ { compound: 'MEDIUM', lapStart: 1, lapEnd: 30 },
+ { compound: 'SOFT', lapStart: 31, lapEnd: 78 },
+ ],
+ },
+ {
+ label: 'HAM',
+ color: '#E8002D',
+ stints: [{ compound: 'SOFT', lapStart: 1, lapEnd: 18 }],
+ },
+]
+
+describe('TyreStintTimeline', () => {
+ it('renders one rect per stint', () => {
+ const { container } = render(
+ ,
+ )
+ expect(container.querySelectorAll('.stint-timeline__bar')).toHaveLength(3)
+ })
+
+ it('maps compounds to color classes', () => {
+ const { container } = render(
+ ,
+ )
+ const bars = container.querySelectorAll('.stint-timeline__bar')
+ expect(bars[0]).toHaveClass('tyre-medium')
+ expect(bars[1]).toHaveClass('tyre-soft')
+ expect(bars[2]).toHaveClass('tyre-soft')
+ })
+
+ it('shows native title with compound, lap range, and stint length', () => {
+ const { container } = render(
+ ,
+ )
+ const titles = [...container.querySelectorAll('title')].map((t) => t.textContent)
+ expect(titles).toContain('Medium · L1–30 · 30 laps')
+ expect(titles).toContain('Soft · L31–78 · 48 laps')
+ expect(titles).toContain('Soft · L1–18 · 18 laps')
+ })
+
+ it('renders lap-axis ticks every 10 laps', () => {
+ render()
+ expect(screen.getByText('0')).toBeInTheDocument()
+ expect(screen.getByText('10')).toBeInTheDocument()
+ expect(screen.getByText('20')).toBeInTheDocument()
+ expect(screen.getByText('70')).toBeInTheDocument()
+ })
+
+ it('shows legend only for used compounds', () => {
+ render()
+ expect(screen.getByTestId('legend-soft')).toBeInTheDocument()
+ expect(screen.getByTestId('legend-medium')).toBeInTheDocument()
+ expect(screen.queryByTestId('legend-hard')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('legend-wet')).not.toBeInTheDocument()
+ expect(screen.getByText('Soft')).toBeInTheDocument()
+ expect(screen.getByText('Medium')).toBeInTheDocument()
+ })
+
+ it('renders empty state when rows are empty', () => {
+ render()
+ expect(screen.getByTestId('stint-timeline-empty')).toBeInTheDocument()
+ expect(screen.getByText(/No stint data/i)).toBeInTheDocument()
+ })
+})
+
+const results: EnrichedResult[] = [
+ {
+ driver_number: 1,
+ position: 1,
+ name_acronym: 'VER',
+ full_name: 'Max Verstappen',
+ team_name: 'Red Bull Racing',
+ team_colour: '3671C6',
+ dnf: false,
+ dns: false,
+ dsq: false,
+ duration: null,
+ gap_to_leader: null,
+ number_of_laps: 78,
+ points: 25,
+ session_key: 9472,
+ meeting_key: 1229,
+ },
+ {
+ driver_number: 44,
+ position: 2,
+ name_acronym: 'HAM',
+ full_name: 'Lewis Hamilton',
+ team_name: 'Ferrari',
+ team_colour: 'E8002D',
+ dnf: false,
+ dns: false,
+ dsq: false,
+ duration: null,
+ gap_to_leader: 5.1,
+ number_of_laps: 78,
+ points: 18,
+ session_key: 9472,
+ meeting_key: 1229,
+ },
+]
+
+const stints: Stint[] = [
+ {
+ session_key: 9472,
+ driver_number: 1,
+ meeting_key: 1229,
+ stint_number: 1,
+ compound: 'MEDIUM',
+ lap_start: 1,
+ lap_end: 30,
+ tyre_age_at_start: 0,
+ },
+ {
+ session_key: 9472,
+ driver_number: 44,
+ meeting_key: 1229,
+ stint_number: 1,
+ compound: 'SOFT',
+ lap_start: 1,
+ lap_end: 18,
+ tyre_age_at_start: 0,
+ },
+]
+
+const pitStops: PitStop[] = []
+
+describe('StrategyView integration', () => {
+ it('renders timeline when stints exist', () => {
+ const { container } = render(
+ ,
+ )
+ expect(container.querySelector('[data-testid="strategy-chart"]')).toBeInTheDocument()
+ expect(screen.getByTestId('stint-timeline')).toBeInTheDocument()
+ expect(screen.getByText('VER')).toBeInTheDocument()
+ expect(screen.getByText('HAM')).toBeInTheDocument()
+ expect(container.querySelectorAll('.stint-timeline__bar')).toHaveLength(2)
+ })
+})
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 68e4929..790c7e8 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -261,6 +261,7 @@ export interface LiveSessionMeta {
CircuitName: string
SessionType: string
SessionName: string
+ Path: string
}
export interface LiveStintData {
@@ -269,6 +270,12 @@ export interface LiveStintData {
Laps: number
}
+export interface LiveRadioCapture {
+ Utc: string
+ RacingNumber: string
+ Path: string
+}
+
export interface LiveStreamData {
Drivers: Record
DriverInfo: Record
@@ -277,6 +284,7 @@ export interface LiveStreamData {
RCMessages: LiveRCMessage[]
Weather: LiveWeatherData
Session: LiveSessionMeta
+ TeamRadio: LiveRadioCapture[]
TrackStatus: string
CurrentLap: number
TotalLaps: number
diff --git a/internal/live/parser_test.go b/internal/live/parser_test.go
index 5667a45..4f60166 100644
--- a/internal/live/parser_test.go
+++ b/internal/live/parser_test.go
@@ -5,6 +5,7 @@ import (
"compress/flate"
"encoding/base64"
"encoding/json"
+ "fmt"
"testing"
"time"
@@ -211,12 +212,16 @@ func TestProcessTopicSessionInfoCircuitName(t *testing.T) {
state.ProcessTopic("SessionInfo", json.RawMessage(`{
"Meeting": {"Name": "British Grand Prix", "Circuit": {"ShortName": "Silverstone"}},
"Name": "Race",
- "Type": "Race"
+ "Type": "Race",
+ "Path": "2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/"
}`))
s := state.Snapshot().Session
if s.MeetingName != "British Grand Prix" || s.CircuitName != "Silverstone" {
t.Fatalf("session = %+v", s)
}
+ if s.Path != "2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/" {
+ t.Fatalf("session path = %q", s.Path)
+ }
}
func TestProcessTopicDriverList(t *testing.T) {
@@ -285,6 +290,68 @@ func TestProcessTopicRaceControlMessages(t *testing.T) {
}
}
+func TestProcessTopicTeamRadioSnapshotAndPatch(t *testing.T) {
+ state := live.NewState()
+ snapshot := json.RawMessage(`{
+ "Captures": [
+ {"Utc": "2026-07-05T14:05:30Z", "RacingNumber": "4", "Path": "TeamRadio/NOR-1.mp3"},
+ {"Utc": "2026-07-05T14:04:10Z", "RacingNumber": "16", "Path": "TeamRadio/LEC-1.mp3"}
+ ]
+ }`)
+
+ if !state.ProcessTopic("TeamRadio", snapshot) {
+ t.Fatal("TeamRadio snapshot should update state")
+ }
+
+ patch := json.RawMessage(`{
+ "Captures": {
+ "2": {"Utc": "2026-07-05T14:06:00Z", "RacingNumber": "44", "Path": "TeamRadio/HAM-1.mp3"}
+ }
+ }`)
+ if !state.ProcessTopic("TeamRadio", patch) {
+ t.Fatal("TeamRadio keyed patch should update state")
+ }
+
+ radio := state.Snapshot().TeamRadio
+ if len(radio) != 3 {
+ t.Fatalf("radio captures = %d, want 3", len(radio))
+ }
+ if radio[0].RacingNumber != "16" || radio[1].RacingNumber != "4" || radio[2].RacingNumber != "44" {
+ t.Fatalf("radio order = %+v", radio)
+ }
+}
+
+func TestProcessTopicTeamRadioMalformed(t *testing.T) {
+ state := live.NewState()
+ if state.ProcessTopic("TeamRadio", json.RawMessage(`{"Captures": {"1": {"Utc": "2026-07-05T14:06:00Z", "Path": "missing-driver.mp3"}}}`)) {
+ t.Fatal("incomplete TeamRadio capture should not update state")
+ }
+ if state.ProcessTopic("TeamRadio", json.RawMessage(`{"Captures": "not-a-list"}`)) {
+ t.Fatal("unexpected TeamRadio captures shape should not update state")
+ }
+ if len(state.Snapshot().TeamRadio) != 0 {
+ t.Fatalf("malformed captures mutated state: %+v", state.Snapshot().TeamRadio)
+ }
+}
+
+func TestProcessTopicTeamRadioCapsAtTwenty(t *testing.T) {
+ state := live.NewState()
+ for i := 0; i < 25; i++ {
+ payload := json.RawMessage([]byte(fmt.Sprintf(`{
+ "Captures": [{"Utc": "2026-07-05T14:%02d:00Z", "RacingNumber": "%d", "Path": "TeamRadio/%02d.mp3"}]
+ }`, i, i, i)))
+ state.ProcessTopic("TeamRadio", payload)
+ }
+
+ radio := state.Snapshot().TeamRadio
+ if len(radio) != 20 {
+ t.Fatalf("radio captures = %d, want 20", len(radio))
+ }
+ if radio[0].Utc != "2026-07-05T14:05:00Z" || radio[19].Utc != "2026-07-05T14:24:00Z" {
+ t.Fatalf("radio cap kept wrong captures: first=%+v last=%+v", radio[0], radio[19])
+ }
+}
+
func TestProcessTopicWeatherData(t *testing.T) {
state := live.NewState()
state.ProcessTopic("WeatherData", json.RawMessage(`{
diff --git a/internal/live/signalr.go b/internal/live/signalr.go
index e9b8342..9265d70 100644
--- a/internal/live/signalr.go
+++ b/internal/live/signalr.go
@@ -105,6 +105,7 @@ func connectToF1SignalRCore(dataChan chan LiveStreamData) error {
"RaceControlMessages",
"WeatherData",
"SessionInfo",
+ "TeamRadio",
"CurrentTyres",
"TimingAppData",
"TimingStats",
@@ -196,7 +197,7 @@ func connectToF1LegacySignalR(dataChan chan LiveStreamData) error {
return err
}
- subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","Position.z","CarData.z","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","CurrentTyres","TimingAppData","TimingStats"]],"I":1}`)
+ subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","Position.z","CarData.z","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","TeamRadio","CurrentTyres","TimingAppData","TimingStats"]],"I":1}`)
err = c.WriteMessage(websocket.TextMessage, subscribeMsg)
if err != nil {
return err
diff --git a/internal/live/state.go b/internal/live/state.go
index bd7d8d4..08d1488 100644
--- a/internal/live/state.go
+++ b/internal/live/state.go
@@ -8,6 +8,8 @@ import (
"encoding/json"
"fmt"
"io"
+ "log"
+ "sort"
"strings"
"time"
)
@@ -23,6 +25,7 @@ type State struct {
RCMessages []LiveRCMessage
Weather LiveWeatherData
Session LiveSessionMeta
+ TeamRadio []LiveRadioCapture
TrackStatus string
CurrentLap int
TotalLaps int
@@ -34,6 +37,7 @@ type State struct {
}
const signalRRecordSeparator = byte(0x1e)
+const maxTeamRadioCaptures = 20
// NewState returns an empty live timing accumulator.
func NewState() *State {
@@ -71,6 +75,8 @@ func (s *State) Snapshot() LiveStreamData {
}
cpyRC := make([]LiveRCMessage, len(s.RCMessages))
copy(cpyRC, s.RCMessages)
+ cpyRadio := make([]LiveRadioCapture, len(s.TeamRadio))
+ copy(cpyRadio, s.TeamRadio)
cpyStints := make(map[string][]LiveStintData, len(s.Stints))
for k, v := range s.Stints {
st := make([]LiveStintData, len(v))
@@ -86,6 +92,7 @@ func (s *State) Snapshot() LiveStreamData {
RCMessages: cpyRC,
Weather: s.Weather,
Session: s.Session,
+ TeamRadio: cpyRadio,
TrackStatus: s.TrackStatus,
CurrentLap: s.CurrentLap,
TotalLaps: s.TotalLaps,
@@ -335,6 +342,7 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
} `json:"Meeting"`
Name string `json:"Name"`
Type string `json:"Type"`
+ Path string `json:"Path"`
}
if json.Unmarshal(data, &si) == nil {
if si.Meeting.Name != "" {
@@ -349,8 +357,13 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
if si.Type != "" {
s.Session.SessionType = si.Type
}
+ if si.Path != "" {
+ s.Session.Path = si.Path
+ }
updated = true
}
+ case "TeamRadio":
+ updated = s.updateTeamRadio(data)
case "CurrentTyres":
var ct map[string]json.RawMessage
if json.Unmarshal(data, &ct) == nil {
@@ -483,6 +496,62 @@ func readCompressed(r io.ReadCloser, err error) ([]byte, bool) {
return out, err == nil
}
+func (s *State) updateTeamRadio(data json.RawMessage) bool {
+ var payload struct {
+ Captures json.RawMessage `json:"Captures"`
+ }
+ if err := json.Unmarshal(data, &payload); err != nil {
+ log.Printf("live: skipping malformed TeamRadio payload: %v", err)
+ return false
+ }
+ if len(payload.Captures) == 0 || string(payload.Captures) == "null" {
+ log.Printf("live: skipping TeamRadio payload without Captures")
+ return false
+ }
+
+ captures := indexedRawValues(payload.Captures)
+ if len(captures) == 0 {
+ log.Printf("live: skipping TeamRadio payload with unexpected Captures shape")
+ return false
+ }
+
+ updated := false
+ for _, captureRaw := range captures {
+ var capture LiveRadioCapture
+ if err := json.Unmarshal(captureRaw.Raw, &capture); err != nil {
+ log.Printf("live: skipping malformed TeamRadio capture: %v", err)
+ continue
+ }
+ if capture.Utc == "" || capture.RacingNumber == "" || capture.Path == "" {
+ log.Printf("live: skipping incomplete TeamRadio capture: utc=%q racing_number=%q path=%q", capture.Utc, capture.RacingNumber, capture.Path)
+ continue
+ }
+ if s.hasTeamRadioCapture(capture) {
+ continue
+ }
+ s.TeamRadio = append(s.TeamRadio, capture)
+ updated = true
+ }
+ if updated {
+ sort.SliceStable(s.TeamRadio, func(i, j int) bool {
+ return s.TeamRadio[i].Utc < s.TeamRadio[j].Utc
+ })
+ if len(s.TeamRadio) > maxTeamRadioCaptures {
+ s.TeamRadio = append([]LiveRadioCapture(nil), s.TeamRadio[len(s.TeamRadio)-maxTeamRadioCaptures:]...)
+ }
+ }
+ return updated
+}
+
+func (s *State) hasTeamRadioCapture(capture LiveRadioCapture) bool {
+ for _, existing := range s.TeamRadio {
+ if existing.Utc == capture.Utc && existing.RacingNumber == capture.RacingNumber && existing.Path == capture.Path {
+ return true
+ }
+ }
+ return false
+}
+
func (s *State) updatePositions(data json.RawMessage) bool {
var payload struct {
Position json.RawMessage `json:"Position"`
@@ -799,6 +868,9 @@ func indexedRawValues(raw json.RawMessage) []indexedRaw {
fmt.Sscanf(k, "%d", &i)
values = append(values, indexedRaw{Index: i, Raw: v})
}
+ sort.Slice(values, func(i, j int) bool {
+ return values[i].Index < values[j].Index
+ })
return values
}
diff --git a/internal/live/types.go b/internal/live/types.go
index 72e047c..4d62f6d 100644
--- a/internal/live/types.go
+++ b/internal/live/types.go
@@ -85,6 +85,7 @@ type LiveSessionMeta struct {
CircuitName string
SessionType string
SessionName string
+ Path string
}
// LivePositionData is the latest raw F1 GPS position for one driver.
@@ -144,6 +145,13 @@ type LiveStintData struct {
Laps int
}
+// LiveRadioCapture is one team radio audio clip from the live timing feed.
+type LiveRadioCapture struct {
+ Utc string
+ RacingNumber string
+ Path string
+}
+
// LiveStreamData is an immutable snapshot of all live timing state.
type LiveStreamData struct {
Drivers map[string]LiveDriverData
@@ -153,6 +161,7 @@ type LiveStreamData struct {
RCMessages []LiveRCMessage
Weather LiveWeatherData
Session LiveSessionMeta
+ TeamRadio []LiveRadioCapture
TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC
CurrentLap int
TotalLaps int
diff --git a/tests/command-center.spec.ts b/tests/command-center.spec.ts
index a744e23..a63e327 100644
--- a/tests/command-center.spec.ts
+++ b/tests/command-center.spec.ts
@@ -9,7 +9,7 @@ test.describe('Command Center', () => {
await expect(page.getByTestId('command-center')).toBeVisible()
await expect(page.getByTestId('cc-focus')).toBeVisible()
await expect(page.getByTestId('cc-session-9472')).toBeVisible()
- await expect(page.getByTestId('cc-actions')).toBeVisible()
+ await expect(page.getByTestId('hero-last-race-link')).toBeVisible()
})
test('nav link reaches command center from race hub', async ({ page }) => {
@@ -21,8 +21,8 @@ test.describe('Command Center', () => {
test('open analysis action opens race hub for the focus session', async ({ page }) => {
await page.goto('/')
- await expect(page.getByTestId('cc-action-race-hub')).toContainText(String(FULL_SESSION))
- await page.getByTestId('cc-action-race-hub').click()
+ await expect(page.getByTestId('hero-last-race-link')).toBeVisible()
+ await page.getByTestId('hero-last-race-link').click()
await expect(page).toHaveURL(new RegExp(`/race-hub\\?session_key=${FULL_SESSION}`))
await expect(page.getByTestId('rh-identity')).toBeVisible()
await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible()