mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Complete React live and Race Hub views
This commit is contained in:
@@ -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> {
|
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
|
||||||
const res = await fetch(`/api/v1/race-hub?session_key=${sessionKey}`)
|
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()
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ function CliCommandLine({ cmd }: { cmd: string }) {
|
|||||||
|
|
||||||
export function ingestYearCommands(year: number): Command[] {
|
export function ingestYearCommands(year: number): Command[] {
|
||||||
return [
|
return [
|
||||||
{ comment: '# Ingest all meetings for a season', cmd: `box-box --ingest-year ${year}` },
|
{ comment: '# Discover season meetings and sessions', cmd: `box-box --ingest-year ${year}` },
|
||||||
{ comment: '# Preview without downloading', cmd: `box-box --ingest-year ${year} --dry-run` },
|
{ comment: '# Preview season discovery only', cmd: `box-box --ingest-year ${year} --dry-run` },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
111
frontend/src/components/LapsView.tsx
Normal file
111
frontend/src/components/LapsView.tsx
Normal 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 <key></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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ export function Nav() {
|
|||||||
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
||||||
Race Hub
|
Race Hub
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/live" activeProps={{ className: 'active' }}>
|
||||||
|
Live
|
||||||
|
</Link>
|
||||||
<Link to="/data-library" activeProps={{ className: 'active' }}>
|
<Link to="/data-library" activeProps={{ className: 'active' }}>
|
||||||
Data Library
|
Data Library
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
69
frontend/src/components/RaceControlView.tsx
Normal file
69
frontend/src/components/RaceControlView.tsx
Normal 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 <key></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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }[] = [
|
const TABS: { id: Tab; label: string }[] = [
|
||||||
{ id: 'results', label: 'Results' },
|
{ id: 'results', label: 'Results' },
|
||||||
{ id: 'grid', label: 'Grid' },
|
{ id: 'grid', label: 'Grid' },
|
||||||
{ id: 'strategy', label: 'Strategy' },
|
{ id: 'strategy', label: 'Strategy' },
|
||||||
{ id: 'positions', label: 'Positions' },
|
{ id: 'positions', label: 'Positions' },
|
||||||
|
{ id: 'laps', label: 'Laps' },
|
||||||
|
{ id: 'race_control', label: 'Race Control' },
|
||||||
|
{ id: 'weather', label: 'Weather' },
|
||||||
{ id: 'datasets', label: 'Datasets' },
|
{ id: 'datasets', label: 'Datasets' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
98
frontend/src/components/WeatherView.tsx
Normal file
98
frontend/src/components/WeatherView.tsx
Normal 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 <key></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>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
frontend/src/components/live/RaceControlFeed.tsx
Normal file
33
frontend/src/components/live/RaceControlFeed.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
frontend/src/components/live/SessionBanner.tsx
Normal file
37
frontend/src/components/live/SessionBanner.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
72
frontend/src/components/live/TimingTower.tsx
Normal file
72
frontend/src/components/live/TimingTower.tsx
Normal 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
135
frontend/src/lib/live.ts
Normal 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(':')
|
||||||
|
}
|
||||||
@@ -109,8 +109,8 @@ export function DataLibraryPage() {
|
|||||||
</div>
|
</div>
|
||||||
<CliCommands
|
<CliCommands
|
||||||
commands={[
|
commands={[
|
||||||
{ comment: '# Ingest a full season', cmd: 'box-box --ingest-year 2025' },
|
{ comment: '# Discover season meetings and sessions', cmd: 'box-box --ingest-year 2025' },
|
||||||
{ comment: '# Or a single session', cmd: 'box-box --ingest-session <session_key>' },
|
{ comment: '# Then ingest a full weekend or single session', cmd: 'box-box --ingest-meeting <meeting_key>' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,7 +206,7 @@ export function DataLibraryPage() {
|
|||||||
|
|
||||||
{!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && (
|
{!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && (
|
||||||
<div className="missing-notice">
|
<div className="missing-notice">
|
||||||
No meetings ingested for {selectedYear}. Run{' '}
|
No meetings discovered for {selectedYear}. Run{' '}
|
||||||
<code>box-box --ingest-year {selectedYear}</code>
|
<code>box-box --ingest-year {selectedYear}</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
106
frontend/src/pages/LiveTimingPage.tsx
Normal file
106
frontend/src/pages/LiveTimingPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ import { TabBar, type Tab } from '../components/TabBar'
|
|||||||
import { DatasetStatusView } from '../components/DatasetStatusView'
|
import { DatasetStatusView } from '../components/DatasetStatusView'
|
||||||
import { StrategyView } from '../components/StrategyView'
|
import { StrategyView } from '../components/StrategyView'
|
||||||
import { PositionEvolutionView } from '../components/PositionEvolutionView'
|
import { PositionEvolutionView } from '../components/PositionEvolutionView'
|
||||||
|
import { LapsView } from '../components/LapsView'
|
||||||
|
import { RaceControlView } from '../components/RaceControlView'
|
||||||
|
import { WeatherView } from '../components/WeatherView'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionKey: number
|
sessionKey: number
|
||||||
@@ -141,6 +144,42 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
</div>
|
</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' && (
|
{activeTab === 'datasets' && (
|
||||||
<div className="data-section">
|
<div className="data-section">
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createRootRoute, createRoute, createRouter, Outlet, redirect } from '@t
|
|||||||
import { Nav } from './components/Nav'
|
import { Nav } from './components/Nav'
|
||||||
import { RaceHubPage } from './pages/RaceHubPage'
|
import { RaceHubPage } from './pages/RaceHubPage'
|
||||||
import { DataLibraryPage } from './pages/DataLibraryPage'
|
import { DataLibraryPage } from './pages/DataLibraryPage'
|
||||||
|
import { LiveTimingPage } from './pages/LiveTimingPage'
|
||||||
|
|
||||||
type RaceHubSearch = {
|
type RaceHubSearch = {
|
||||||
session_key?: number
|
session_key?: number
|
||||||
@@ -43,7 +44,13 @@ export const dataLibraryRoute = createRoute({
|
|||||||
component: DataLibraryPage,
|
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 })
|
export const router = createRouter({ routeTree })
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,11 @@
|
|||||||
--green: #39c73a;
|
--green: #39c73a;
|
||||||
--yellow: #ffd600;
|
--yellow: #ffd600;
|
||||||
--purple: #c278ff;
|
--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-ui: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
--f-mono: 'JetBrains Mono', 'Cascadia Code', 'Consolas', monospace;
|
--f-mono: 'JetBrains Mono', 'Cascadia Code', 'Consolas', monospace;
|
||||||
@@ -465,6 +470,125 @@ a { color: inherit; text-decoration: none; }
|
|||||||
margin-bottom: var(--s5);
|
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 {
|
.missing-notice {
|
||||||
padding: var(--s4) var(--s5);
|
padding: var(--s4) var(--s5);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
|||||||
@@ -119,8 +119,8 @@ describe('DataLibraryPage', () => {
|
|||||||
expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025)
|
expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025)
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(await screen.findByText('Monaco')).toBeInTheDocument()
|
|
||||||
expect(await screen.findByTestId('meeting-detail')).toBeInTheDocument()
|
expect(await screen.findByTestId('meeting-detail')).toBeInTheDocument()
|
||||||
|
expect(screen.getAllByText('Monaco').length).toBeGreaterThan(0)
|
||||||
expect(screen.getByText('11/11')).toBeInTheDocument()
|
expect(screen.getByText('11/11')).toBeInTheDocument()
|
||||||
expect(screen.getByText('box-box --ingest-meeting 1229')).toBeInTheDocument()
|
expect(screen.getByText('box-box --ingest-meeting 1229')).toBeInTheDocument()
|
||||||
expect(screen.getByText('box-box --ingest-session 9472')).toBeInTheDocument()
|
expect(screen.getByText('box-box --ingest-session 9472')).toBeInTheDocument()
|
||||||
|
|||||||
52
frontend/src/test/LapsView.test.tsx
Normal file
52
frontend/src/test/LapsView.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
50
frontend/src/test/RaceControlView.test.tsx
Normal file
50
frontend/src/test/RaceControlView.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,12 +3,15 @@ import { render, screen, fireEvent } from '@testing-library/react'
|
|||||||
import { TabBar } from '../components/TabBar'
|
import { TabBar } from '../components/TabBar'
|
||||||
|
|
||||||
describe('TabBar', () => {
|
describe('TabBar', () => {
|
||||||
it('renders all 5 tabs', () => {
|
it('renders all Race Hub tabs', () => {
|
||||||
render(<TabBar active="results" onChange={() => {}} />)
|
render(<TabBar active="results" onChange={() => {}} />)
|
||||||
expect(screen.getByRole('tab', { name: 'Results' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Results' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Grid' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Grid' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Strategy' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Strategy' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Positions' })).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()
|
expect(screen.getByRole('tab', { name: 'Datasets' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -29,7 +32,7 @@ describe('TabBar', () => {
|
|||||||
it('calls onChange with the correct tab id when clicked', () => {
|
it('calls onChange with the correct tab id when clicked', () => {
|
||||||
const onChange = vi.fn()
|
const onChange = vi.fn()
|
||||||
render(<TabBar active="results" onChange={onChange} />)
|
render(<TabBar active="results" onChange={onChange} />)
|
||||||
fireEvent.click(screen.getByRole('tab', { name: 'Datasets' }))
|
fireEvent.click(screen.getByRole('tab', { name: 'Race Control' }))
|
||||||
expect(onChange).toHaveBeenCalledWith('datasets')
|
expect(onChange).toHaveBeenCalledWith('race_control')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
49
frontend/src/test/WeatherView.test.tsx
Normal file
49
frontend/src/test/WeatherView.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
127
frontend/src/test/live.test.ts
Normal file
127
frontend/src/test/live.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -169,3 +169,100 @@ export interface Weekend {
|
|||||||
sessions: WeekendSession[]
|
sessions: WeekendSession[]
|
||||||
default_session_key?: number
|
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[]>
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user