diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e0eb3fb..6a7d879 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { Meeting, RaceHub, Weekend } from './types' +import type { LiveStateResponse, Meeting, RaceHub, Weekend } from './types' export async function fetchRaceHub(sessionKey: number): Promise { const res = await fetch(`/api/v1/race-hub?session_key=${sessionKey}`) @@ -33,3 +33,11 @@ export async function fetchWeekend(meetingKey: number): Promise { } return res.json() } + +export async function fetchLiveState(): Promise { + const res = await fetch('/api/v1/live/state') + if (!res.ok) { + throw new Error(`API ${res.status}: ${res.statusText}`) + } + return res.json() +} diff --git a/frontend/src/components/CliCommands.tsx b/frontend/src/components/CliCommands.tsx index 65bb68f..f3fd3e3 100644 --- a/frontend/src/components/CliCommands.tsx +++ b/frontend/src/components/CliCommands.tsx @@ -48,8 +48,8 @@ function CliCommandLine({ cmd }: { cmd: string }) { export function ingestYearCommands(year: number): Command[] { return [ - { comment: '# Ingest all meetings for a season', cmd: `box-box --ingest-year ${year}` }, - { comment: '# Preview without downloading', cmd: `box-box --ingest-year ${year} --dry-run` }, + { comment: '# Discover season meetings and sessions', cmd: `box-box --ingest-year ${year}` }, + { comment: '# Preview season discovery only', cmd: `box-box --ingest-year ${year} --dry-run` }, ] } diff --git a/frontend/src/components/LapsView.tsx b/frontend/src/components/LapsView.tsx new file mode 100644 index 0000000..d8c9d60 --- /dev/null +++ b/frontend/src/components/LapsView.tsx @@ -0,0 +1,111 @@ +import type { Lap } from '../types' +import { formatLapTime } from '../utils' + +interface Props { + laps: Lap[] +} + +interface DriverLapSummary { + driver_number: number + total: number + best: Lap | null + lastLap: number + pitOuts: number +} + +export function LapsView({ laps }: Props) { + if (laps.length === 0) { + return ( +
+ Laps not ingested. Run box-box --ingest-session <key> to + load this dataset. +
+ ) + } + + const byDriver = new Map() + for (const lap of laps) { + const summary = + byDriver.get(lap.driver_number) ?? + { + driver_number: lap.driver_number, + total: 0, + best: null, + lastLap: 0, + pitOuts: 0, + } + + summary.total += 1 + summary.lastLap = Math.max(summary.lastLap, lap.lap_number) + if (lap.is_pit_out_lap) summary.pitOuts += 1 + if ( + lap.lap_duration != null && + lap.lap_duration > 0 && + (!summary.best || + summary.best.lap_duration == null || + lap.lap_duration < summary.best.lap_duration) + ) { + summary.best = lap + } + + byDriver.set(lap.driver_number, summary) + } + + const rows = [...byDriver.values()].sort((a, b) => { + const aBest = a.best?.lap_duration ?? Number.POSITIVE_INFINITY + const bBest = b.best?.lap_duration ?? Number.POSITIVE_INFINITY + if (aBest !== bBest) return aBest - bBest + return a.driver_number - b.driver_number + }) + + const fastest = rows.find((row) => row.best?.lap_duration != null)?.best + + return ( +
+ + + + + + + + + + + + {rows.map((row) => { + const isFastest = + fastest && + row.best?.driver_number === fastest.driver_number && + row.best?.lap_number === fastest.lap_number + + return ( + + + + + + + + ) + })} + +
DriverBest LapBest TimeLapsPit Outs
+ #{row.driver_number} + + {row.best ? ( + <> + {row.best.lap_number} + {isFastest && ( + FASTEST + )} + + ) : ( + '—' + )} + {formatLapTime(row.best?.lap_duration)}{row.lastLap || row.total} + {row.pitOuts || '—'} +
+
+ ) +} diff --git a/frontend/src/components/Nav.tsx b/frontend/src/components/Nav.tsx index 2d99437..c4ef195 100644 --- a/frontend/src/components/Nav.tsx +++ b/frontend/src/components/Nav.tsx @@ -10,6 +10,9 @@ export function Nav() { Race Hub + + Live + Data Library diff --git a/frontend/src/components/RaceControlView.tsx b/frontend/src/components/RaceControlView.tsx new file mode 100644 index 0000000..c894bec --- /dev/null +++ b/frontend/src/components/RaceControlView.tsx @@ -0,0 +1,69 @@ +import type { RaceControlMessage } from '../types' + +interface Props { + messages: RaceControlMessage[] +} + +function formatEventTime(date: string): string { + if (!date) return '—' + const parsed = new Date(date) + if (Number.isNaN(parsed.getTime())) return date + return parsed.toLocaleTimeString('en-GB', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) +} + +function eventLabel(message: RaceControlMessage): string { + return message.flag || message.category || 'Message' +} + +export function RaceControlView({ messages }: Props) { + if (messages.length === 0) { + return ( +
+ Race control messages not ingested. Run{' '} + box-box --ingest-session <key> to load this dataset. +
+ ) + } + + const rows = [...messages].sort((a, b) => a.date.localeCompare(b.date)) + + return ( +
+ + + + + + + + + + + + {rows.map((message, index) => ( + + + + + + + + ))} + +
TimeLapEventDriverMessage
+ {formatEventTime(message.date)} + {message.lap_number ?? '—'} + {eventLabel(message)} + {message.scope && ( + + {message.scope.toLowerCase()} + + )} + {message.driver_number ?? '—'}{message.message || '—'}
+
+ ) +} diff --git a/frontend/src/components/TabBar.tsx b/frontend/src/components/TabBar.tsx index 9ed10c5..f68ea8a 100644 --- a/frontend/src/components/TabBar.tsx +++ b/frontend/src/components/TabBar.tsx @@ -1,10 +1,21 @@ -export type Tab = 'results' | 'grid' | 'strategy' | 'positions' | 'datasets' +export type Tab = + | 'results' + | 'grid' + | 'strategy' + | 'positions' + | 'laps' + | 'race_control' + | 'weather' + | 'datasets' const TABS: { id: Tab; label: string }[] = [ { id: 'results', label: 'Results' }, { id: 'grid', label: 'Grid' }, { id: 'strategy', label: 'Strategy' }, { id: 'positions', label: 'Positions' }, + { id: 'laps', label: 'Laps' }, + { id: 'race_control', label: 'Race Control' }, + { id: 'weather', label: 'Weather' }, { id: 'datasets', label: 'Datasets' }, ] diff --git a/frontend/src/components/WeatherView.tsx b/frontend/src/components/WeatherView.tsx new file mode 100644 index 0000000..1814278 --- /dev/null +++ b/frontend/src/components/WeatherView.tsx @@ -0,0 +1,98 @@ +import type { WeatherSample } from '../types' + +interface Props { + weather: WeatherSample[] +} + +function avg(values: number[]): number { + if (values.length === 0) return 0 + return values.reduce((sum, val) => sum + val, 0) / values.length +} + +function formatNumber(value: number, digits = 1): string { + return Number.isFinite(value) ? value.toFixed(digits) : '—' +} + +function formatTime(date: string): string { + if (!date) return '—' + const parsed = new Date(date) + if (Number.isNaN(parsed.getTime())) return date + return parsed.toLocaleTimeString('en-GB', { + hour: '2-digit', + minute: '2-digit', + }) +} + +export function WeatherView({ weather }: Props) { + if (weather.length === 0) { + return ( +
+ Weather samples not ingested. Run box-box --ingest-session <key>{' '} + to load this dataset. +
+ ) + } + + const rows = [...weather].sort((a, b) => a.date.localeCompare(b.date)) + const latest = rows[rows.length - 1] + const rainfallSamples = rows.filter((sample) => sample.rainfall > 0).length + + return ( +
+ + + + + + + + + + + + + + + + + + + + + +
SummaryValue
Latest sample{formatTime(latest.date)}
Avg air / track + {formatNumber(avg(rows.map((sample) => sample.air_temperature)))}C /{' '} + {formatNumber(avg(rows.map((sample) => sample.track_temperature)))}C +
Rain samples{rainfallSamples}
+ +
+ + + + + + + + + + + + + {rows.slice(-12).map((sample) => ( + + + + + + + + + ))} + +
TimeAirTrackHumidityRainWind
+ {formatTime(sample.date)} + {formatNumber(sample.air_temperature)}C{formatNumber(sample.track_temperature)}C{formatNumber(sample.humidity, 0)}%{formatNumber(sample.rainfall)}{formatNumber(sample.wind_speed)} m/s
+
+
+ ) +} diff --git a/frontend/src/components/live/RaceControlFeed.tsx b/frontend/src/components/live/RaceControlFeed.tsx new file mode 100644 index 0000000..da270ed --- /dev/null +++ b/frontend/src/components/live/RaceControlFeed.tsx @@ -0,0 +1,33 @@ +import type { LiveRCMessage } from '../../types' +import { latestRaceControl } from '../../lib/live' + +interface Props { + messages: LiveRCMessage[] +} + +export function RaceControlFeed({ messages }: Props) { + const latest = latestRaceControl(messages) + + return ( +
+
+ Race Control + {messages.length > 0 && {messages.length} messages} +
+ {latest.length === 0 ? ( +
No race control messages in the current live snapshot.
+ ) : ( +
+ {latest.map((message, index) => ( +
+ {message.Time || '--:--'} + {message.Flag && {message.Flag}} + {message.Lap > 0 && L{message.Lap}} + {message.Message} +
+ ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/live/SessionBanner.tsx b/frontend/src/components/live/SessionBanner.tsx new file mode 100644 index 0000000..2f32078 --- /dev/null +++ b/frontend/src/components/live/SessionBanner.tsx @@ -0,0 +1,37 @@ +import type { LiveStreamData } from '../../types' +import { extrapolateClock, trackStatusClass, trackStatusLabel } from '../../lib/live' + +interface Props { + isLive: boolean + snapshot: LiveStreamData + connection: 'connected' | 'connecting' | 'disconnected' | 'error' + now: number +} + +export function SessionBanner({ isLive, snapshot, connection, now }: Props) { + const session = snapshot.Session + const clock = extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now) + const status = snapshot.TrackStatus ? trackStatusLabel(snapshot.TrackStatus) : '' + + return ( +
+
+ {connection} +
+

{session?.MeetingName || 'Live Timing'}

+

+ {[session?.SessionName, session?.CircuitName].filter(Boolean).join(' · ') || 'F1 live feed'} +

+
+
+
+ + Lap {snapshot.CurrentLap || '-'}/{snapshot.TotalLaps || '-'} + + {status && {status}} + {clock && {clock}} + {isLive ? 'live' : 'stale'} +
+
+ ) +} diff --git a/frontend/src/components/live/TimingTower.tsx b/frontend/src/components/live/TimingTower.tsx new file mode 100644 index 0000000..651e06f --- /dev/null +++ b/frontend/src/components/live/TimingTower.tsx @@ -0,0 +1,72 @@ +import { teamColor } from '../../utils' +import type { LiveStreamData } from '../../types' +import { driverCode, positionDelta, sortLiveTimingRows, tyreClass, tyreLabel } from '../../lib/live' + +interface Props { + snapshot: LiveStreamData +} + +export function TimingTower({ snapshot }: Props) { + const rows = sortLiveTimingRows(snapshot) + + if (rows.length === 0) { + return ( +
+ Live timing is connected, but no driver timing rows have arrived yet. +
+ ) + } + + return ( +
+ + + + + + + + + + + + + + {rows.map((row) => { + const driver = row.Driver + return ( + + + + + + + + + + ) + })} + +
PosΔDriverTyreLast LapGapBest
{row.Position}{positionDelta(driver)} +
+
+ {driverCode(row)} + {row.RacingNumber} + {driver.InPit && PIT} + {driver.Retired && OUT} +
+
+ {tyreLabel(row.Tyre)} + + {driver.LastLapTime || '-'} + {driver.GapToLeader || driver.Interval || '-'}{driver.BestLapTime || '-'}
+
+ ) +} diff --git a/frontend/src/lib/live.ts b/frontend/src/lib/live.ts new file mode 100644 index 0000000..c0e8f36 --- /dev/null +++ b/frontend/src/lib/live.ts @@ -0,0 +1,135 @@ +import type { + LiveDriverData, + LiveDriverInfo, + LiveRCMessage, + LiveStateResponse, + LiveStreamData, + LiveTyreData, +} from '../types' + +export interface LiveTimingRow { + RacingNumber: string + Position: number + Driver: LiveDriverData + Info?: LiveDriverInfo + Tyre?: LiveTyreData +} + +const TRACK_STATUS_LABELS: Record = { + '1': 'GREEN', + '2': 'YELLOW', + '4': 'SC', + '5': 'RED', + '6': 'VSC', +} + +export function parseLiveStateEvent(data: string): LiveStateResponse | null { + try { + const parsed = JSON.parse(data) as LiveStateResponse + return typeof parsed === 'object' && parsed !== null ? parsed : null + } catch { + return null + } +} + +export function sortLiveTimingRows(snapshot: LiveStreamData | null | undefined): LiveTimingRow[] { + if (!snapshot) return [] + + const rowsByNumber = new Map() + for (const [number, driver] of Object.entries(snapshot.Drivers ?? {})) { + rowsByNumber.set(number, { + RacingNumber: driver.RacingNumber || number, + Position: driver.Position || 0, + Driver: { ...driver, RacingNumber: driver.RacingNumber || number }, + Info: snapshot.DriverInfo?.[number], + Tyre: snapshot.Tyres?.[number], + }) + } + + for (const [number, info] of Object.entries(snapshot.DriverInfo ?? {})) { + if (!rowsByNumber.has(number)) { + rowsByNumber.set(number, { + RacingNumber: info.RacingNumber || number, + Position: 0, + Driver: { + RacingNumber: info.RacingNumber || number, + Position: 0, + } as LiveDriverData, + Info: info, + Tyre: snapshot.Tyres?.[number], + }) + } + } + + const rows = [...rowsByNumber.values()] + rows.sort((a, b) => { + if (a.Position > 0 && b.Position > 0) return a.Position - b.Position + if (a.Position > 0) return -1 + if (b.Position > 0) return 1 + + const aBest = a.Driver.BestLapTime || '' + const bBest = b.Driver.BestLapTime || '' + if (aBest && bBest) return aBest.localeCompare(bBest) + if (aBest) return -1 + if (bBest) return 1 + + return Number(a.RacingNumber) - Number(b.RacingNumber) + }) + + return rows.map((row, index) => ({ + ...row, + Position: row.Position || index + 1, + })) +} + +export function driverCode(row: LiveTimingRow): string { + return row.Info?.Tla || row.RacingNumber +} + +export function trackStatusLabel(status: string): string { + return TRACK_STATUS_LABELS[status] || status || 'UNKNOWN' +} + +export function trackStatusClass(status: string): string { + return `track-${trackStatusLabel(status).toLowerCase()}` +} + +export function positionDelta(driver: LiveDriverData): string { + if (!driver.PrevPosition || !driver.Position || driver.PrevPosition === driver.Position) return '' + return driver.PrevPosition > driver.Position ? '▲' : '▼' +} + +export function tyreLabel(tyre: LiveTyreData | undefined): string { + if (!tyre) return '?' + const compound = tyre.Compound?.charAt(0) || '?' + return `${compound} +${tyre.Age || 0}` +} + +export function tyreClass(tyre: LiveTyreData | undefined): string { + if (!tyre?.Compound) return 'tyre-unknown' + const compound = tyre.Compound.toLowerCase() + return `tyre-${compound === 'intermediate' ? 'inter' : compound}` +} + +export function latestRaceControl(messages: LiveRCMessage[], limit = 10): LiveRCMessage[] { + return [...(messages ?? [])].reverse().slice(0, limit) +} + +export function extrapolateClock(clock: string, refTime: string, extrapolating: boolean, now = Date.now()): string { + if (!clock || !extrapolating || !refTime) return clock || '' + + const parts = clock.split(':').map(Number) + if (parts.length !== 3 || parts.some((part) => !Number.isFinite(part))) return clock + + const refMs = new Date(refTime).getTime() + if (!Number.isFinite(refMs)) return clock + + const totalSeconds = parts[0] * 3600 + parts[1] * 60 + parts[2] + const elapsed = Math.max(0, (now - refMs) / 1000) + const remaining = Math.max(0, totalSeconds - elapsed) + const hours = Math.floor(remaining / 3600) + const minutes = Math.floor((remaining % 3600) / 60) + const seconds = Math.floor(remaining % 60) + + return [hours, minutes, seconds].map((part) => String(part).padStart(2, '0')).join(':') +} diff --git a/frontend/src/pages/DataLibraryPage.tsx b/frontend/src/pages/DataLibraryPage.tsx index b488cf4..f93861a 100644 --- a/frontend/src/pages/DataLibraryPage.tsx +++ b/frontend/src/pages/DataLibraryPage.tsx @@ -109,8 +109,8 @@ export function DataLibraryPage() { ' }, + { comment: '# Discover season meetings and sessions', cmd: 'box-box --ingest-year 2025' }, + { comment: '# Then ingest a full weekend or single session', cmd: 'box-box --ingest-meeting ' }, ]} /> @@ -206,7 +206,7 @@ export function DataLibraryPage() { {!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && (
- No meetings ingested for {selectedYear}. Run{' '} + No meetings discovered for {selectedYear}. Run{' '} box-box --ingest-year {selectedYear}
)} diff --git a/frontend/src/pages/LiveTimingPage.tsx b/frontend/src/pages/LiveTimingPage.tsx new file mode 100644 index 0000000..dfa475e --- /dev/null +++ b/frontend/src/pages/LiveTimingPage.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { fetchLiveState } from '../api' +import type { LiveStreamData } from '../types' +import { parseLiveStateEvent } from '../lib/live' +import { SessionBanner } from '../components/live/SessionBanner' +import { TimingTower } from '../components/live/TimingTower' +import { RaceControlFeed } from '../components/live/RaceControlFeed' + +type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error' + +export function LiveTimingPage() { + const [snapshot, setSnapshot] = useState(null) + const [isLive, setIsLive] = useState(false) + const [streamStatus, setStreamStatus] = useState('connecting') + const [now, setNow] = useState(Date.now()) + + const { data, isLoading, isError, error } = useQuery({ + queryKey: ['live-state'], + queryFn: fetchLiveState, + staleTime: 5_000, + }) + + useEffect(() => { + if (!data) return + setIsLive(data.is_live) + setSnapshot(data.data) + }, [data]) + + useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 1000) + return () => window.clearInterval(timer) + }, []) + + useEffect(() => { + if (!('EventSource' in window)) { + setStreamStatus('error') + return + } + + let cancelled = false + const events = new EventSource('/api/v1/live/stream') + setStreamStatus('connecting') + + events.onopen = () => { + if (!cancelled) setStreamStatus('connected') + } + + events.addEventListener('snapshot', (event) => { + const state = parseLiveStateEvent(event.data) + if (!state || cancelled) return + setIsLive(state.is_live) + setSnapshot(state.data) + setStreamStatus('connected') + }) + + events.addEventListener('heartbeat', () => { + if (!cancelled) setStreamStatus('connected') + }) + + events.onerror = () => { + if (!cancelled) setStreamStatus('disconnected') + } + + return () => { + cancelled = true + events.close() + } + }, []) + + return ( +
+ {isError && ( +
+ {error instanceof Error ? error.message : 'Failed to load live timing state'} +
+ )} + + {streamStatus === 'disconnected' && ( +
Live stream disconnected. Showing the last received snapshot.
+ )} + + {isLoading && !snapshot &&
connecting to live timing…
} + + {!isLoading && !snapshot && ( +
+
No live session active
+
Check back during an F1 race weekend.
+
+ )} + + {snapshot && ( + <> + +
+
+ Timing Tower +
+ +
+ + + )} +
+ ) +} diff --git a/frontend/src/pages/RaceHubPage.tsx b/frontend/src/pages/RaceHubPage.tsx index f16c7a1..434154e 100644 --- a/frontend/src/pages/RaceHubPage.tsx +++ b/frontend/src/pages/RaceHubPage.tsx @@ -11,6 +11,9 @@ import { TabBar, type Tab } from '../components/TabBar' import { DatasetStatusView } from '../components/DatasetStatusView' import { StrategyView } from '../components/StrategyView' import { PositionEvolutionView } from '../components/PositionEvolutionView' +import { LapsView } from '../components/LapsView' +import { RaceControlView } from '../components/RaceControlView' +import { WeatherView } from '../components/WeatherView' interface Props { sessionKey: number @@ -141,6 +144,42 @@ export function RaceHubPage({ sessionKey }: Props) { )} + {activeTab === 'laps' && ( +
+
+ Laps + {data.laps.length > 0 && ( + {data.laps.length} samples + )} +
+ +
+ )} + + {activeTab === 'race_control' && ( +
+
+ Race Control + {data.race_control.length > 0 && ( + {data.race_control.length} messages + )} +
+ +
+ )} + + {activeTab === 'weather' && ( +
+
+ Weather + {data.weather.length > 0 && ( + {data.weather.length} samples + )} +
+ +
+ )} + {activeTab === 'datasets' && (
diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 54ec510..8e0af01 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -2,6 +2,7 @@ import { createRootRoute, createRoute, createRouter, Outlet, redirect } from '@t import { Nav } from './components/Nav' import { RaceHubPage } from './pages/RaceHubPage' import { DataLibraryPage } from './pages/DataLibraryPage' +import { LiveTimingPage } from './pages/LiveTimingPage' type RaceHubSearch = { session_key?: number @@ -43,7 +44,13 @@ export const dataLibraryRoute = createRoute({ component: DataLibraryPage, }) -const routeTree = rootRoute.addChildren([indexRoute, raceHubRoute, dataLibraryRoute]) +export const liveTimingRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/live', + component: LiveTimingPage, +}) + +const routeTree = rootRoute.addChildren([indexRoute, raceHubRoute, dataLibraryRoute, liveTimingRoute]) export const router = createRouter({ routeTree }) diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 868a765..214b88f 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -15,6 +15,11 @@ --green: #39c73a; --yellow: #ffd600; --purple: #c278ff; + --tyre-soft: #ff3333; + --tyre-medium: #ffd600; + --tyre-hard: #d8d8d8; + --tyre-inter: #39b54a; + --tyre-wet: #0080ff; --f-ui: system-ui, -apple-system, 'Segoe UI', sans-serif; --f-mono: 'JetBrains Mono', 'Cascadia Code', 'Consolas', monospace; @@ -465,6 +470,125 @@ a { color: inherit; text-decoration: none; } margin-bottom: var(--s5); } +/* ── Live timing ── */ +.live-page { max-width: 1120px; } + +.live-banner { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--s5); + padding-bottom: var(--s4); + border-bottom: 1px solid var(--border); + margin-bottom: var(--s5); +} + +.live-banner-main { + display: flex; + align-items: center; + gap: var(--s4); + min-width: 0; +} + +.live-banner h1 { + font-size: 18px; + line-height: 1.2; +} + +.live-banner p { + color: var(--text-2); + font-size: 12px; +} + +.live-banner-meta { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--s3); + flex-wrap: wrap; +} + +.mono { font-family: var(--f-mono); } + +.live-conn, +.live-state, +.track-status, +.tyre-badge { + display: inline-flex; + align-items: center; + border-radius: 2px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.07em; + line-height: 1; + padding: 4px 7px; + text-transform: uppercase; + white-space: nowrap; +} + +.live-conn-connected, +.live-state-on, +.track-green { background: rgba(57,199,58,.12); color: var(--green); border: 1px solid rgba(57,199,58,.25); } +.live-conn-connecting { background: rgba(255,214,0,.12); color: var(--yellow); border: 1px solid rgba(255,214,0,.25); } +.live-conn-disconnected, +.live-conn-error, +.live-state { background: rgba(225,6,0,.10); color: #ff6b6b; border: 1px solid rgba(225,6,0,.24); } +.track-yellow, +.track-sc { background: rgba(255,214,0,.12); color: var(--yellow); border: 1px solid rgba(255,214,0,.25); } +.track-red { background: rgba(225,6,0,.12); color: #ff6b6b; border: 1px solid rgba(225,6,0,.25); } +.track-vsc { background: rgba(194,120,255,.12); color: var(--purple); border: 1px solid rgba(194,120,255,.25); } + +.live-tower { font-variant-numeric: tabular-nums; } +.live-tower .in-pit td { background: rgba(0, 80, 160, 0.14); } +.live-tower .pit-out td { background: rgba(57, 199, 58, 0.10); } +.live-tower .retired td { opacity: 0.62; } +.pos-delta { color: var(--text-3); font-family: var(--f-mono); } +.lap-pb { color: var(--green); } +.lap-ob { color: var(--purple); } +.badge-pit { background: rgba(0,128,255,.14); color: #66aaff; border: 1px solid rgba(0,128,255,.26); } +.badge-out { background: rgba(225,6,0,.12); color: #ff6b6b; border: 1px solid rgba(225,6,0,.24); } + +.tyre-soft { background: var(--tyre-soft); color: #fff; } +.tyre-medium { background: var(--tyre-medium); color: #111; } +.tyre-hard { background: var(--tyre-hard); color: #111; } +.tyre-inter { background: var(--tyre-inter); color: #fff; } +.tyre-wet { background: var(--tyre-wet); color: #fff; } +.tyre-unknown { background: var(--surface-2); color: var(--text-2); border: 1px solid var(--border-2); } + +.live-rc { margin-bottom: var(--s7); } + +.live-rc-list { + border-top: 1px solid var(--border); +} + +.live-rc-row { + display: flex; + align-items: baseline; + gap: var(--s3); + padding: var(--s3) 0; + border-bottom: 1px solid var(--border); + font-size: 12px; +} + +.rc-time, +.rc-lap { + color: var(--text-3); + flex-shrink: 0; + font-family: var(--f-mono); + font-size: 11px; +} + +.rc-flag { + flex-shrink: 0; + padding: 1px 5px; + background: var(--surface-2); + border: 1px solid var(--border-2); + border-radius: 2px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; +} + .missing-notice { padding: var(--s4) var(--s5); background: var(--surface); diff --git a/frontend/src/test/DataLibraryPage.test.tsx b/frontend/src/test/DataLibraryPage.test.tsx index 433fa33..95f943c 100644 --- a/frontend/src/test/DataLibraryPage.test.tsx +++ b/frontend/src/test/DataLibraryPage.test.tsx @@ -119,8 +119,8 @@ describe('DataLibraryPage', () => { expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025) }) - expect(await screen.findByText('Monaco')).toBeInTheDocument() expect(await screen.findByTestId('meeting-detail')).toBeInTheDocument() + expect(screen.getAllByText('Monaco').length).toBeGreaterThan(0) expect(screen.getByText('11/11')).toBeInTheDocument() expect(screen.getByText('box-box --ingest-meeting 1229')).toBeInTheDocument() expect(screen.getByText('box-box --ingest-session 9472')).toBeInTheDocument() diff --git a/frontend/src/test/LapsView.test.tsx b/frontend/src/test/LapsView.test.tsx new file mode 100644 index 0000000..ef4586e --- /dev/null +++ b/frontend/src/test/LapsView.test.tsx @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import { LapsView } from '../components/LapsView' +import type { Lap } from '../types' + +const laps: Lap[] = [ + { + session_key: 9472, + driver_number: 44, + meeting_key: 1229, + lap_number: 1, + date_start: '2025-05-25T13:04:00Z', + lap_duration: 75.2, + is_pit_out_lap: false, + }, + { + session_key: 9472, + driver_number: 1, + meeting_key: 1229, + lap_number: 1, + date_start: '2025-05-25T13:04:01Z', + lap_duration: 72.1, + is_pit_out_lap: false, + }, + { + session_key: 9472, + driver_number: 1, + meeting_key: 1229, + lap_number: 2, + date_start: '2025-05-25T13:05:14Z', + lap_duration: 73.5, + is_pit_out_lap: true, + }, +] + +describe('LapsView', () => { + it('renders compact best-lap rows by driver', () => { + render() + + expect(screen.getByTestId('laps-view')).toBeInTheDocument() + expect(screen.getByText('#1')).toBeInTheDocument() + expect(screen.getByText('#44')).toBeInTheDocument() + expect(screen.getByText('1:12.100')).toBeInTheDocument() + expect(screen.getByText('FASTEST')).toBeInTheDocument() + }) + + it('shows a missing-data state when no laps are present', () => { + render() + + expect(screen.getByText(/Laps not ingested/i)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/test/RaceControlView.test.tsx b/frontend/src/test/RaceControlView.test.tsx new file mode 100644 index 0000000..13e5164 --- /dev/null +++ b/frontend/src/test/RaceControlView.test.tsx @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import { RaceControlView } from '../components/RaceControlView' +import type { RaceControlMessage } from '../types' + +const messages: RaceControlMessage[] = [ + { + session_key: 9472, + meeting_key: 1229, + date: '2025-05-25T13:10:00Z', + category: 'Flag', + flag: 'YELLOW', + message: 'Yellow flag in sector 2', + scope: 'Sector', + driver_number: null, + lap_number: 6, + sector: 2, + qualifying_phase: null, + }, + { + session_key: 9472, + meeting_key: 1229, + date: '2025-05-25T13:12:00Z', + category: 'Other', + flag: '', + message: 'Car 44 noted for track limits', + scope: 'Driver', + driver_number: 44, + lap_number: 8, + sector: null, + qualifying_phase: null, + }, +] + +describe('RaceControlView', () => { + it('renders race-control messages from the payload array', () => { + render() + + expect(screen.getByTestId('race-control-view')).toBeInTheDocument() + expect(screen.getByText('YELLOW')).toBeInTheDocument() + expect(screen.getByText('Yellow flag in sector 2')).toBeInTheDocument() + expect(screen.getByText('Car 44 noted for track limits')).toBeInTheDocument() + }) + + it('shows a missing-data state when no messages are present', () => { + render() + + expect(screen.getByText(/Race control messages not ingested/i)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/test/TabBar.test.tsx b/frontend/src/test/TabBar.test.tsx index 25a5142..08a482b 100644 --- a/frontend/src/test/TabBar.test.tsx +++ b/frontend/src/test/TabBar.test.tsx @@ -3,12 +3,15 @@ import { render, screen, fireEvent } from '@testing-library/react' import { TabBar } from '../components/TabBar' describe('TabBar', () => { - it('renders all 5 tabs', () => { + it('renders all Race Hub tabs', () => { render( {}} />) expect(screen.getByRole('tab', { name: 'Results' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Grid' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Strategy' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Positions' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Laps' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Weather' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Datasets' })).toBeInTheDocument() }) @@ -29,7 +32,7 @@ describe('TabBar', () => { it('calls onChange with the correct tab id when clicked', () => { const onChange = vi.fn() render() - fireEvent.click(screen.getByRole('tab', { name: 'Datasets' })) - expect(onChange).toHaveBeenCalledWith('datasets') + fireEvent.click(screen.getByRole('tab', { name: 'Race Control' })) + expect(onChange).toHaveBeenCalledWith('race_control') }) }) diff --git a/frontend/src/test/WeatherView.test.tsx b/frontend/src/test/WeatherView.test.tsx new file mode 100644 index 0000000..87417df --- /dev/null +++ b/frontend/src/test/WeatherView.test.tsx @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import { WeatherView } from '../components/WeatherView' +import type { WeatherSample } from '../types' + +const weather: WeatherSample[] = [ + { + session_key: 9472, + meeting_key: 1229, + date: '2025-05-25T13:00:00Z', + air_temperature: 20, + track_temperature: 30, + humidity: 60, + pressure: 1010, + rainfall: 0, + wind_direction: 180, + wind_speed: 2, + }, + { + session_key: 9472, + meeting_key: 1229, + date: '2025-05-25T13:05:00Z', + air_temperature: 21, + track_temperature: 33, + humidity: 62, + pressure: 1011, + rainfall: 0.2, + wind_direction: 190, + wind_speed: 3, + }, +] + +describe('WeatherView', () => { + it('renders weather summary and recent samples', () => { + render() + + expect(screen.getByTestId('weather-view')).toBeInTheDocument() + expect(screen.getByText('Avg air / track')).toBeInTheDocument() + expect(screen.getByText('20.5C / 31.5C')).toBeInTheDocument() + expect(screen.getByText('Rain samples')).toBeInTheDocument() + expect(screen.getByText('0.2')).toBeInTheDocument() + }) + + it('shows a missing-data state when no weather samples are present', () => { + render() + + expect(screen.getByText(/Weather samples not ingested/i)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/test/live.test.ts b/frontend/src/test/live.test.ts new file mode 100644 index 0000000..3899b9f --- /dev/null +++ b/frontend/src/test/live.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest' +import { + extrapolateClock, + latestRaceControl, + parseLiveStateEvent, + sortLiveTimingRows, + trackStatusLabel, + tyreClass, + tyreLabel, +} from '../lib/live' +import type { LiveStreamData } from '../types' + +const snapshot: LiveStreamData = { + Drivers: { + '16': { + RacingNumber: '16', + Position: 1, + PrevPosition: 2, + GapToLeader: '', + Interval: '', + LastLapTime: '1:14.100', + LastLapPB: true, + LastLapOB: false, + BestLapTime: '1:13.900', + BestLapPB: false, + BestLapOB: false, + BestLapNum: 20, + InPit: false, + PitOut: false, + Retired: false, + KnockedOut: false, + Cutoff: false, + OnFlyingLap: false, + NumberOfLaps: 21, + SpeedTrap: '', + Sectors: [], + }, + '1': { + RacingNumber: '1', + Position: 2, + PrevPosition: 1, + GapToLeader: '+1.200', + Interval: '+1.200', + LastLapTime: '1:14.300', + LastLapPB: false, + LastLapOB: false, + BestLapTime: '1:13.800', + BestLapPB: false, + BestLapOB: true, + BestLapNum: 19, + InPit: false, + PitOut: false, + Retired: false, + KnockedOut: false, + Cutoff: false, + OnFlyingLap: false, + NumberOfLaps: 21, + SpeedTrap: '', + Sectors: [], + }, + }, + DriverInfo: { + '16': { + RacingNumber: '16', + BroadcastName: 'C LECLERC', + Tla: 'LEC', + TeamName: 'Ferrari', + TeamColour: 'e8002d', + FirstName: 'Charles', + LastName: 'Leclerc', + }, + '44': { + RacingNumber: '44', + BroadcastName: 'L HAMILTON', + Tla: 'HAM', + TeamName: 'Ferrari', + TeamColour: 'e8002d', + FirstName: 'Lewis', + LastName: 'Hamilton', + }, + }, + Tyres: { + '16': { Compound: 'MEDIUM', New: false, Age: 8 }, + }, + RCMessages: [ + { Time: '14:01', Category: 'Flag', Flag: 'GREEN', Message: 'GREEN LIGHT', Lap: 0 }, + { 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' }, + TrackStatus: '1', + CurrentLap: 21, + TotalLaps: 78, + Clock: '01:20:00', + ClockRefTime: '2026-05-25T12:00:00Z', + ClockExtrapolating: true, + Stints: {}, +} + +describe('live transforms', () => { + it('parses live EventSource snapshots without changing PascalCase data', () => { + const parsed = parseLiveStateEvent(JSON.stringify({ is_live: true, data: snapshot })) + expect(parsed?.is_live).toBe(true) + expect(parsed?.data?.Drivers['16'].RacingNumber).toBe('16') + }) + + it('sorts timing rows by live position and includes drivers with metadata only', () => { + const rows = sortLiveTimingRows(snapshot) + expect(rows.map((row) => row.RacingNumber)).toEqual(['16', '1', '44']) + expect(rows[2].Position).toBe(3) + }) + + it('formats tyre labels and classes', () => { + expect(tyreLabel({ Compound: 'MEDIUM', New: false, Age: 8 })).toBe('M +8') + expect(tyreClass({ Compound: 'INTERMEDIATE', New: true, Age: 1 })).toBe('tyre-inter') + expect(tyreLabel(undefined)).toBe('?') + }) + + it('maps track status and race control ordering', () => { + expect(trackStatusLabel('4')).toBe('SC') + expect(latestRaceControl(snapshot.RCMessages, 1)[0].Message).toBe('DRS ENABLED') + }) + + it('extrapolates the session clock from the reference time', () => { + expect(extrapolateClock('01:20:00', '2026-05-25T12:00:00Z', true, Date.parse('2026-05-25T12:00:30Z'))).toBe('01:19:30') + }) +}) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 8a1421d..575206b 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -169,3 +169,100 @@ export interface Weekend { sessions: WeekendSession[] default_session_key?: number } + +export interface LiveStateResponse { + is_live: boolean + data: LiveStreamData | null +} + +export interface LiveSectorData { + Value: string + PersonalFastest: boolean + OverallFastest: boolean +} + +export interface LiveDriverData { + RacingNumber: string + Position: number + PrevPosition: number + GapToLeader: string + Interval: string + LastLapTime: string + LastLapPB: boolean + LastLapOB: boolean + BestLapTime: string + BestLapPB: boolean + BestLapOB: boolean + BestLapNum: number + InPit: boolean + PitOut: boolean + Retired: boolean + KnockedOut: boolean + Cutoff: boolean + OnFlyingLap: boolean + NumberOfLaps: number + SpeedTrap: string + Sectors: LiveSectorData[] +} + +export interface LiveDriverInfo { + RacingNumber: string + BroadcastName: string + Tla: string + TeamName: string + TeamColour: string + FirstName: string + LastName: string +} + +export interface LiveTyreData { + Compound: string + New: boolean + Age: number +} + +export interface LiveRCMessage { + Time: string + Category: string + Flag: string + Message: string + Lap: number +} + +export interface LiveWeatherData { + AirTemp: number + TrackTemp: number + Humidity: number + WindSpeed: number + WindDir: number + Rainfall: boolean +} + +export interface LiveSessionMeta { + MeetingName: string + CircuitName: string + SessionType: string + SessionName: string +} + +export interface LiveStintData { + Compound: string + New: boolean + Laps: number +} + +export interface LiveStreamData { + Drivers: Record + DriverInfo: Record + Tyres: Record + RCMessages: LiveRCMessage[] + Weather: LiveWeatherData + Session: LiveSessionMeta + TrackStatus: string + CurrentLap: number + TotalLaps: number + Clock: string + ClockRefTime: string + ClockExtrapolating: boolean + Stints: Record +}