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> {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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` },
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
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' }}>
|
||||
Race Hub
|
||||
</Link>
|
||||
<Link to="/live" activeProps={{ className: 'active' }}>
|
||||
Live
|
||||
</Link>
|
||||
<Link to="/data-library" activeProps={{ className: 'active' }}>
|
||||
Data Library
|
||||
</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 }[] = [
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
|
||||
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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
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 { 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">
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
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'
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
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[]
|
||||
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