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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user