Complete React live and Race Hub views

This commit is contained in:
2026-05-25 02:56:06 -04:00
parent 5eb0983ffb
commit e410a01db1
23 changed files with 1243 additions and 12 deletions

View File

@@ -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<RaceHub> {
const res = await fetch(`/api/v1/race-hub?session_key=${sessionKey}`)
@@ -33,3 +33,11 @@ export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
}
return res.json()
}
export async function fetchLiveState(): Promise<LiveStateResponse> {
const res = await fetch('/api/v1/live/state')
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}

View File

@@ -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` },
]
}

View File

@@ -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 (
<div className="missing-notice">
Laps not ingested. Run <code>box-box --ingest-session &lt;key&gt;</code> to
load this dataset.
</div>
)
}
const byDriver = new Map<number, DriverLapSummary>()
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 (
<div className="scroll-x" data-testid="laps-view">
<table className="data-table" style={{ minWidth: 460, maxWidth: 620 }}>
<thead>
<tr>
<th>Driver</th>
<th className="c">Best Lap</th>
<th className="r">Best Time</th>
<th className="r hide-mobile">Laps</th>
<th className="r hide-mobile">Pit Outs</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const isFastest =
fastest &&
row.best?.driver_number === fastest.driver_number &&
row.best?.lap_number === fastest.lap_number
return (
<tr key={row.driver_number}>
<td className="mono" style={{ fontWeight: 700 }}>
#{row.driver_number}
</td>
<td className="c mono">
{row.best ? (
<>
{row.best.lap_number}
{isFastest && (
<span style={{ color: 'var(--red)', marginLeft: 6 }}>FASTEST</span>
)}
</>
) : (
'—'
)}
</td>
<td className="r">{formatLapTime(row.best?.lap_duration)}</td>
<td className="r hide-mobile">{row.lastLap || row.total}</td>
<td className="r hide-mobile" style={{ color: 'var(--text-3)' }}>
{row.pitOuts || '—'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View File

@@ -10,6 +10,9 @@ export function Nav() {
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
Race Hub
</Link>
<Link to="/live" activeProps={{ className: 'active' }}>
Live
</Link>
<Link to="/data-library" activeProps={{ className: 'active' }}>
Data Library
</Link>

View File

@@ -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 (
<div className="missing-notice">
Race control messages not ingested. Run{' '}
<code>box-box --ingest-session &lt;key&gt;</code> to load this dataset.
</div>
)
}
const rows = [...messages].sort((a, b) => a.date.localeCompare(b.date))
return (
<div className="scroll-x" data-testid="race-control-view">
<table className="data-table" style={{ minWidth: 620 }}>
<thead>
<tr>
<th>Time</th>
<th className="c">Lap</th>
<th>Event</th>
<th className="c hide-mobile">Driver</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{rows.map((message, index) => (
<tr key={`${message.date}-${index}`}>
<td className="mono" style={{ color: 'var(--text-3)' }}>
{formatEventTime(message.date)}
</td>
<td className="c mono">{message.lap_number ?? '—'}</td>
<td>
<span style={{ fontWeight: 700 }}>{eventLabel(message)}</span>
{message.scope && (
<span style={{ color: 'var(--text-3)', marginLeft: 6 }}>
{message.scope.toLowerCase()}
</span>
)}
</td>
<td className="c mono hide-mobile">{message.driver_number ?? '—'}</td>
<td style={{ whiteSpace: 'normal' }}>{message.message || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -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' },
]

View File

@@ -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 (
<div className="missing-notice">
Weather samples not ingested. Run <code>box-box --ingest-session &lt;key&gt;</code>{' '}
to load this dataset.
</div>
)
}
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 (
<div data-testid="weather-view">
<table className="data-table" style={{ maxWidth: 520, marginBottom: 'var(--s5)' }}>
<thead>
<tr>
<th>Summary</th>
<th className="r">Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Latest sample</td>
<td className="r">{formatTime(latest.date)}</td>
</tr>
<tr>
<td>Avg air / track</td>
<td className="r">
{formatNumber(avg(rows.map((sample) => sample.air_temperature)))}C /{' '}
{formatNumber(avg(rows.map((sample) => sample.track_temperature)))}C
</td>
</tr>
<tr>
<td>Rain samples</td>
<td className="r">{rainfallSamples}</td>
</tr>
</tbody>
</table>
<div className="scroll-x">
<table className="data-table" style={{ minWidth: 560 }}>
<thead>
<tr>
<th>Time</th>
<th className="r">Air</th>
<th className="r">Track</th>
<th className="r hide-mobile">Humidity</th>
<th className="r hide-mobile">Rain</th>
<th className="r hide-mobile">Wind</th>
</tr>
</thead>
<tbody>
{rows.slice(-12).map((sample) => (
<tr key={sample.date}>
<td className="mono" style={{ color: 'var(--text-3)' }}>
{formatTime(sample.date)}
</td>
<td className="r">{formatNumber(sample.air_temperature)}C</td>
<td className="r">{formatNumber(sample.track_temperature)}C</td>
<td className="r hide-mobile">{formatNumber(sample.humidity, 0)}%</td>
<td className="r hide-mobile">{formatNumber(sample.rainfall)}</td>
<td className="r hide-mobile">{formatNumber(sample.wind_speed)} m/s</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -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 (
<section className="live-rc">
<div className="sec-header">
<span className="sec-title">Race Control</span>
{messages.length > 0 && <span className="sec-meta">{messages.length} messages</span>}
</div>
{latest.length === 0 ? (
<div className="missing-notice">No race control messages in the current live snapshot.</div>
) : (
<div className="live-rc-list">
{latest.map((message, index) => (
<div className="live-rc-row" key={`${message.Time}-${message.Message}-${index}`}>
<span className="rc-time">{message.Time || '--:--'}</span>
{message.Flag && <span className="rc-flag">{message.Flag}</span>}
{message.Lap > 0 && <span className="rc-lap">L{message.Lap}</span>}
<span>{message.Message}</span>
</div>
))}
</div>
)}
</section>
)
}

View File

@@ -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 (
<section className="live-banner">
<div className="live-banner-main">
<span className={`live-conn live-conn-${connection}`}>{connection}</span>
<div>
<h1>{session?.MeetingName || 'Live Timing'}</h1>
<p>
{[session?.SessionName, session?.CircuitName].filter(Boolean).join(' · ') || 'F1 live feed'}
</p>
</div>
</div>
<div className="live-banner-meta">
<span className="mono">
Lap <strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
</span>
{status && <span className={`track-status ${trackStatusClass(snapshot.TrackStatus)}`}>{status}</span>}
{clock && <span className="mono">{clock}</span>}
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{isLive ? 'live' : 'stale'}</span>
</div>
</section>
)
}

View File

@@ -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 (
<div className="missing-notice">
Live timing is connected, but no driver timing rows have arrived yet.
</div>
)
}
return (
<div className="scroll-x">
<table className="data-table live-tower">
<thead>
<tr>
<th>Pos</th>
<th>Δ</th>
<th>Driver</th>
<th>Tyre</th>
<th>Last Lap</th>
<th>Gap</th>
<th>Best</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const driver = row.Driver
return (
<tr
key={row.RacingNumber}
className={[
driver.InPit ? 'in-pit' : '',
driver.PitOut ? 'pit-out' : '',
driver.Retired ? 'retired' : '',
].filter(Boolean).join(' ')}
>
<td className="mono pos-n">{row.Position}</td>
<td className="pos-delta">{positionDelta(driver)}</td>
<td>
<div className="drv-cell">
<div className="drv-bar" style={{ background: teamColor(row.Info?.TeamColour) }} />
<span className="drv-code">{driverCode(row)}</span>
<span className="drv-num">{row.RacingNumber}</span>
{driver.InPit && <span className="badge badge-pit">PIT</span>}
{driver.Retired && <span className="badge badge-out">OUT</span>}
</div>
</td>
<td>
<span className={`tyre-badge ${tyreClass(row.Tyre)}`}>{tyreLabel(row.Tyre)}</span>
</td>
<td className={driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono'}>
{driver.LastLapTime || '-'}
</td>
<td className="mono">{driver.GapToLeader || driver.Interval || '-'}</td>
<td className={driver.BestLapOB ? 'mono lap-ob' : 'mono'}>{driver.BestLapTime || '-'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}

135
frontend/src/lib/live.ts Normal file
View File

@@ -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<string, string> = {
'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<string, LiveTimingRow>()
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(':')
}

View File

@@ -109,8 +109,8 @@ export function DataLibraryPage() {
</div>
<CliCommands
commands={[
{ comment: '# Ingest a full season', cmd: 'box-box --ingest-year 2025' },
{ comment: '# Or a single session', cmd: 'box-box --ingest-session <session_key>' },
{ 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 <meeting_key>' },
]}
/>
</div>
@@ -206,7 +206,7 @@ export function DataLibraryPage() {
{!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && (
<div className="missing-notice">
No meetings ingested for {selectedYear}. Run{' '}
No meetings discovered for {selectedYear}. Run{' '}
<code>box-box --ingest-year {selectedYear}</code>
</div>
)}

View File

@@ -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<LiveStreamData | null>(null)
const [isLive, setIsLive] = useState(false)
const [streamStatus, setStreamStatus] = useState<StreamStatus>('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 (
<div className="page live-page">
{isError && (
<div className="error-box">
{error instanceof Error ? error.message : 'Failed to load live timing state'}
</div>
)}
{streamStatus === 'disconnected' && (
<div className="missing-notice">Live stream disconnected. Showing the last received snapshot.</div>
)}
{isLoading && !snapshot && <div className="loading-state">connecting to live timing</div>}
{!isLoading && !snapshot && (
<div className="empty-state">
<div className="empty-state-title">No live session active</div>
<div className="empty-state-desc">Check back during an F1 race weekend.</div>
</div>
)}
{snapshot && (
<>
<SessionBanner isLive={isLive} snapshot={snapshot} connection={streamStatus} now={now} />
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Timing Tower</span>
</div>
<TimingTower snapshot={snapshot} />
</div>
<RaceControlFeed messages={snapshot.RCMessages ?? []} />
</>
)}
</div>
)
}

View File

@@ -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) {
</div>
)}
{activeTab === 'laps' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Laps</span>
{data.laps.length > 0 && (
<span className="sec-meta">{data.laps.length} samples</span>
)}
</div>
<LapsView laps={data.laps} />
</div>
)}
{activeTab === 'race_control' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Race Control</span>
{data.race_control.length > 0 && (
<span className="sec-meta">{data.race_control.length} messages</span>
)}
</div>
<RaceControlView messages={data.race_control} />
</div>
)}
{activeTab === 'weather' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Weather</span>
{data.weather.length > 0 && (
<span className="sec-meta">{data.weather.length} samples</span>
)}
</div>
<WeatherView weather={data.weather} />
</div>
)}
{activeTab === 'datasets' && (
<div className="data-section">
<div className="sec-header">

View File

@@ -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 })

View File

@@ -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);

View File

@@ -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()

View File

@@ -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(<LapsView laps={laps} />)
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(<LapsView laps={[]} />)
expect(screen.getByText(/Laps not ingested/i)).toBeInTheDocument()
})
})

View File

@@ -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(<RaceControlView messages={messages} />)
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(<RaceControlView messages={[]} />)
expect(screen.getByText(/Race control messages not ingested/i)).toBeInTheDocument()
})
})

View File

@@ -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(<TabBar active="results" onChange={() => {}} />)
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(<TabBar active="results" onChange={onChange} />)
fireEvent.click(screen.getByRole('tab', { name: 'Datasets' }))
expect(onChange).toHaveBeenCalledWith('datasets')
fireEvent.click(screen.getByRole('tab', { name: 'Race Control' }))
expect(onChange).toHaveBeenCalledWith('race_control')
})
})

View File

@@ -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(<WeatherView weather={weather} />)
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(<WeatherView weather={[]} />)
expect(screen.getByText(/Weather samples not ingested/i)).toBeInTheDocument()
})
})

View File

@@ -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')
})
})

View File

@@ -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<string, LiveDriverData>
DriverInfo: Record<string, LiveDriverInfo>
Tyres: Record<string, LiveTyreData>
RCMessages: LiveRCMessage[]
Weather: LiveWeatherData
Session: LiveSessionMeta
TrackStatus: string
CurrentLap: number
TotalLaps: number
Clock: string
ClockRefTime: string
ClockExtrapolating: boolean
Stints: Record<string, LiveStintData[]>
}