Complete React live and Race Hub views

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

View File

@@ -48,8 +48,8 @@ function CliCommandLine({ cmd }: { cmd: string }) {
export function ingestYearCommands(year: number): Command[] {
return [
{ comment: '# Ingest all meetings for a season', cmd: `box-box --ingest-year ${year}` },
{ comment: '# Preview without downloading', cmd: `box-box --ingest-year ${year} --dry-run` },
{ comment: '# Discover season meetings and sessions', cmd: `box-box --ingest-year ${year}` },
{ comment: '# Preview season discovery only', cmd: `box-box --ingest-year ${year} --dry-run` },
]
}

View File

@@ -0,0 +1,111 @@
import type { Lap } from '../types'
import { formatLapTime } from '../utils'
interface Props {
laps: Lap[]
}
interface DriverLapSummary {
driver_number: number
total: number
best: Lap | null
lastLap: number
pitOuts: number
}
export function LapsView({ laps }: Props) {
if (laps.length === 0) {
return (
<div className="missing-notice">
Laps not ingested. Run <code>box-box --ingest-session &lt;key&gt;</code> to
load this dataset.
</div>
)
}
const byDriver = new Map<number, DriverLapSummary>()
for (const lap of laps) {
const summary =
byDriver.get(lap.driver_number) ??
{
driver_number: lap.driver_number,
total: 0,
best: null,
lastLap: 0,
pitOuts: 0,
}
summary.total += 1
summary.lastLap = Math.max(summary.lastLap, lap.lap_number)
if (lap.is_pit_out_lap) summary.pitOuts += 1
if (
lap.lap_duration != null &&
lap.lap_duration > 0 &&
(!summary.best ||
summary.best.lap_duration == null ||
lap.lap_duration < summary.best.lap_duration)
) {
summary.best = lap
}
byDriver.set(lap.driver_number, summary)
}
const rows = [...byDriver.values()].sort((a, b) => {
const aBest = a.best?.lap_duration ?? Number.POSITIVE_INFINITY
const bBest = b.best?.lap_duration ?? Number.POSITIVE_INFINITY
if (aBest !== bBest) return aBest - bBest
return a.driver_number - b.driver_number
})
const fastest = rows.find((row) => row.best?.lap_duration != null)?.best
return (
<div className="scroll-x" data-testid="laps-view">
<table className="data-table" style={{ minWidth: 460, maxWidth: 620 }}>
<thead>
<tr>
<th>Driver</th>
<th className="c">Best Lap</th>
<th className="r">Best Time</th>
<th className="r hide-mobile">Laps</th>
<th className="r hide-mobile">Pit Outs</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const isFastest =
fastest &&
row.best?.driver_number === fastest.driver_number &&
row.best?.lap_number === fastest.lap_number
return (
<tr key={row.driver_number}>
<td className="mono" style={{ fontWeight: 700 }}>
#{row.driver_number}
</td>
<td className="c mono">
{row.best ? (
<>
{row.best.lap_number}
{isFastest && (
<span style={{ color: 'var(--red)', marginLeft: 6 }}>FASTEST</span>
)}
</>
) : (
'—'
)}
</td>
<td className="r">{formatLapTime(row.best?.lap_duration)}</td>
<td className="r hide-mobile">{row.lastLap || row.total}</td>
<td className="r hide-mobile" style={{ color: 'var(--text-3)' }}>
{row.pitOuts || '—'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View File

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

View File

@@ -0,0 +1,69 @@
import type { RaceControlMessage } from '../types'
interface Props {
messages: RaceControlMessage[]
}
function formatEventTime(date: string): string {
if (!date) return '—'
const parsed = new Date(date)
if (Number.isNaN(parsed.getTime())) return date
return parsed.toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
function eventLabel(message: RaceControlMessage): string {
return message.flag || message.category || 'Message'
}
export function RaceControlView({ messages }: Props) {
if (messages.length === 0) {
return (
<div className="missing-notice">
Race control messages not ingested. Run{' '}
<code>box-box --ingest-session &lt;key&gt;</code> to load this dataset.
</div>
)
}
const rows = [...messages].sort((a, b) => a.date.localeCompare(b.date))
return (
<div className="scroll-x" data-testid="race-control-view">
<table className="data-table" style={{ minWidth: 620 }}>
<thead>
<tr>
<th>Time</th>
<th className="c">Lap</th>
<th>Event</th>
<th className="c hide-mobile">Driver</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{rows.map((message, index) => (
<tr key={`${message.date}-${index}`}>
<td className="mono" style={{ color: 'var(--text-3)' }}>
{formatEventTime(message.date)}
</td>
<td className="c mono">{message.lap_number ?? '—'}</td>
<td>
<span style={{ fontWeight: 700 }}>{eventLabel(message)}</span>
{message.scope && (
<span style={{ color: 'var(--text-3)', marginLeft: 6 }}>
{message.scope.toLowerCase()}
</span>
)}
</td>
<td className="c mono hide-mobile">{message.driver_number ?? '—'}</td>
<td style={{ whiteSpace: 'normal' }}>{message.message || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -1,10 +1,21 @@
export type Tab = 'results' | 'grid' | 'strategy' | 'positions' | 'datasets'
export type Tab =
| 'results'
| 'grid'
| 'strategy'
| 'positions'
| 'laps'
| 'race_control'
| 'weather'
| 'datasets'
const TABS: { id: Tab; label: string }[] = [
{ id: 'results', label: 'Results' },
{ id: 'grid', label: 'Grid' },
{ id: 'strategy', label: 'Strategy' },
{ id: 'positions', label: 'Positions' },
{ id: 'laps', label: 'Laps' },
{ id: 'race_control', label: 'Race Control' },
{ id: 'weather', label: 'Weather' },
{ id: 'datasets', label: 'Datasets' },
]

View File

@@ -0,0 +1,98 @@
import type { WeatherSample } from '../types'
interface Props {
weather: WeatherSample[]
}
function avg(values: number[]): number {
if (values.length === 0) return 0
return values.reduce((sum, val) => sum + val, 0) / values.length
}
function formatNumber(value: number, digits = 1): string {
return Number.isFinite(value) ? value.toFixed(digits) : '—'
}
function formatTime(date: string): string {
if (!date) return '—'
const parsed = new Date(date)
if (Number.isNaN(parsed.getTime())) return date
return parsed.toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
})
}
export function WeatherView({ weather }: Props) {
if (weather.length === 0) {
return (
<div className="missing-notice">
Weather samples not ingested. Run <code>box-box --ingest-session &lt;key&gt;</code>{' '}
to load this dataset.
</div>
)
}
const rows = [...weather].sort((a, b) => a.date.localeCompare(b.date))
const latest = rows[rows.length - 1]
const rainfallSamples = rows.filter((sample) => sample.rainfall > 0).length
return (
<div data-testid="weather-view">
<table className="data-table" style={{ maxWidth: 520, marginBottom: 'var(--s5)' }}>
<thead>
<tr>
<th>Summary</th>
<th className="r">Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Latest sample</td>
<td className="r">{formatTime(latest.date)}</td>
</tr>
<tr>
<td>Avg air / track</td>
<td className="r">
{formatNumber(avg(rows.map((sample) => sample.air_temperature)))}C /{' '}
{formatNumber(avg(rows.map((sample) => sample.track_temperature)))}C
</td>
</tr>
<tr>
<td>Rain samples</td>
<td className="r">{rainfallSamples}</td>
</tr>
</tbody>
</table>
<div className="scroll-x">
<table className="data-table" style={{ minWidth: 560 }}>
<thead>
<tr>
<th>Time</th>
<th className="r">Air</th>
<th className="r">Track</th>
<th className="r hide-mobile">Humidity</th>
<th className="r hide-mobile">Rain</th>
<th className="r hide-mobile">Wind</th>
</tr>
</thead>
<tbody>
{rows.slice(-12).map((sample) => (
<tr key={sample.date}>
<td className="mono" style={{ color: 'var(--text-3)' }}>
{formatTime(sample.date)}
</td>
<td className="r">{formatNumber(sample.air_temperature)}C</td>
<td className="r">{formatNumber(sample.track_temperature)}C</td>
<td className="r hide-mobile">{formatNumber(sample.humidity, 0)}%</td>
<td className="r hide-mobile">{formatNumber(sample.rainfall)}</td>
<td className="r hide-mobile">{formatNumber(sample.wind_speed)} m/s</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import type { LiveRCMessage } from '../../types'
import { latestRaceControl } from '../../lib/live'
interface Props {
messages: LiveRCMessage[]
}
export function RaceControlFeed({ messages }: Props) {
const latest = latestRaceControl(messages)
return (
<section className="live-rc">
<div className="sec-header">
<span className="sec-title">Race Control</span>
{messages.length > 0 && <span className="sec-meta">{messages.length} messages</span>}
</div>
{latest.length === 0 ? (
<div className="missing-notice">No race control messages in the current live snapshot.</div>
) : (
<div className="live-rc-list">
{latest.map((message, index) => (
<div className="live-rc-row" key={`${message.Time}-${message.Message}-${index}`}>
<span className="rc-time">{message.Time || '--:--'}</span>
{message.Flag && <span className="rc-flag">{message.Flag}</span>}
{message.Lap > 0 && <span className="rc-lap">L{message.Lap}</span>}
<span>{message.Message}</span>
</div>
))}
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,37 @@
import type { LiveStreamData } from '../../types'
import { extrapolateClock, trackStatusClass, trackStatusLabel } from '../../lib/live'
interface Props {
isLive: boolean
snapshot: LiveStreamData
connection: 'connected' | 'connecting' | 'disconnected' | 'error'
now: number
}
export function SessionBanner({ isLive, snapshot, connection, now }: Props) {
const session = snapshot.Session
const clock = extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now)
const status = snapshot.TrackStatus ? trackStatusLabel(snapshot.TrackStatus) : ''
return (
<section className="live-banner">
<div className="live-banner-main">
<span className={`live-conn live-conn-${connection}`}>{connection}</span>
<div>
<h1>{session?.MeetingName || 'Live Timing'}</h1>
<p>
{[session?.SessionName, session?.CircuitName].filter(Boolean).join(' · ') || 'F1 live feed'}
</p>
</div>
</div>
<div className="live-banner-meta">
<span className="mono">
Lap <strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
</span>
{status && <span className={`track-status ${trackStatusClass(snapshot.TrackStatus)}`}>{status}</span>}
{clock && <span className="mono">{clock}</span>}
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{isLive ? 'live' : 'stale'}</span>
</div>
</section>
)
}

View File

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