Rework Race Hub as weekend workspace

This commit is contained in:
2026-05-25 12:35:08 -04:00
parent ee88a07aa1
commit 3bd169c55c
22 changed files with 2019 additions and 313 deletions

View File

@@ -1,37 +1,54 @@
import { Link } from '@tanstack/react-router'
import { RACE_HUB_DATASETS } from '../lib/coverage'
import type { DatasetInfo } from '../types'
interface Props {
datasets: Record<string, DatasetInfo>
}
const KNOWN_DATASETS: { key: string; label: string }[] = [
{ key: 'meeting', label: 'Meeting' },
{ key: 'session', label: 'Session' },
{ key: 'drivers', label: 'Drivers' },
{ key: 'results', label: 'Results' },
{ key: 'starting_grid', label: 'Starting Grid' },
]
const DATASET_LABELS: Record<string, string> = {
meeting: 'Meeting',
session: 'Session',
drivers: 'Drivers',
results: 'Results',
starting_grid: 'Starting Grid',
stints: 'Stints',
pit_stops: 'Pit Stops',
positions: 'Positions',
race_control: 'Race Control',
weather: 'Weather',
laps: 'Laps',
}
export function DatasetStatusView({ datasets }: Props) {
const entries = KNOWN_DATASETS.map(({ key, label }) => ({
const entries = RACE_HUB_DATASETS.map((key) => ({
key,
label,
label: DATASET_LABELS[key] ?? key,
info: datasets[key] as DatasetInfo | undefined,
}))
const available = entries.filter((e) => e.info?.status === 'available').length
const total = entries.length
const missing = total - available
return (
<div>
<div data-testid="rh-data-status">
<div className="rh-coverage-meter" aria-hidden="true">
<div
className="rh-coverage-fill"
style={{ width: `${(available / total) * 100}%` }}
/>
</div>
<div className="ds-legend">
<span>
{available}/{total} datasets available locally
<span className="mono" style={{ color: 'var(--text-2)' }}>
{available}/{total} datasets local
</span>
{available < total && (
<span>
Re-run <code>box-box --ingest-session &lt;key&gt;</code> after backend
support exists for missing datasets.
{missing > 0 && (
<span style={{ color: 'var(--text-3)' }}>
{missing} dataset{missing === 1 ? '' : 's'} still missing {' '}
<Link to="/admin" className="rh-inline-link">
manage ingestion
</Link>
.
</span>
)}
</div>

View File

@@ -0,0 +1,281 @@
import type { RaceHub } from '../types'
import { compareFinishPosition, formatDuration, formatGap, formatLapTime } from '../utils'
import { countRaceHubDatasets } from '../lib/coverage'
interface Props {
data: RaceHub
}
export function OverviewView({ data }: Props) {
const sortedResults = [...data.results].sort((a, b) =>
compareFinishPosition(a.position, b.position),
)
const winner = sortedResults[0]
const podium = sortedResults.filter((r) => r.position > 0).slice(0, 3)
const pole = data.starting_grid.find((g) => g.position === 1)
const fastest = pickFastestLap(data)
const latestWeather = data.weather.length > 0 ? data.weather[data.weather.length - 1] : null
const rcHighlights = data.race_control.slice(-3).reverse()
const coverage = countRaceHubDatasets(data.datasets)
const sessionType = (data.session?.session_type ?? '').toLowerCase()
const isRace = sessionType.includes('race')
const sessionLabel = isRace ? 'Race' : data.session?.session_type ?? 'Session'
return (
<div className="rh-overview" data-testid="rh-overview">
<div className="rh-stat-grid">
{winner && winner.position > 0 ? (
<StatCard
label={isRace ? 'Winner' : `${sessionLabel} P1`}
primary={winner.name_acronym || `#${winner.driver_number}`}
primaryColor={winner.team_colour ? `#${winner.team_colour}` : undefined}
secondary={winner.full_name}
tertiary={winner.team_name}
highlight={
isRace
? formatDuration(winner.duration)
: winner.duration
? formatDuration(winner.duration)
: ''
}
/>
) : (
<StatCard label={isRace ? 'Winner' : `${sessionLabel} P1`} placeholder />
)}
<PodiumCard podium={podium} />
{pole ? (
<StatCard
label={isRace ? 'Pole' : 'P1'}
primary={pole.name_acronym || `#${pole.driver_number}`}
primaryColor={pole.team_colour ? `#${pole.team_colour}` : undefined}
secondary={pole.full_name}
tertiary={pole.team_name}
highlight={pole.lap_duration ? formatLapTime(pole.lap_duration) : ''}
/>
) : (
<StatCard label={isRace ? 'Pole' : 'Grid'} placeholder />
)}
{fastest ? (
<StatCard
label="Fastest Lap"
primary={fastest.acronym}
primaryColor={fastest.colour ? `#${fastest.colour}` : undefined}
secondary={fastest.fullName}
tertiary={`Lap ${fastest.lap}`}
highlight={formatLapTime(fastest.time)}
/>
) : (
<StatCard label="Fastest Lap" placeholder />
)}
</div>
<div className="rh-overview-row">
<section className="rh-panel">
<div className="sec-header">
<span className="sec-title">Conditions</span>
{latestWeather && (
<span className="sec-meta mono">{shortTime(latestWeather.date)}</span>
)}
</div>
{latestWeather ? (
<div className="rh-condition-strip" data-testid="rh-conditions">
<ConditionChip label="Air" value={`${latestWeather.air_temperature.toFixed(1)}°C`} />
<ConditionChip
label="Track"
value={`${latestWeather.track_temperature.toFixed(1)}°C`}
/>
<ConditionChip label="Humidity" value={`${latestWeather.humidity.toFixed(0)}%`} />
<ConditionChip
label="Wind"
value={`${latestWeather.wind_speed.toFixed(1)} m/s`}
/>
<ConditionChip
label="Rain"
value={latestWeather.rainfall > 0 ? 'Yes' : 'No'}
accent={latestWeather.rainfall > 0 ? 'wet' : undefined}
/>
</div>
) : (
<div className="rh-empty-line">No weather samples ingested.</div>
)}
</section>
<section className="rh-panel">
<div className="sec-header">
<span className="sec-title">Race Control · Latest</span>
<span className="sec-meta mono">{data.race_control.length}</span>
</div>
{rcHighlights.length === 0 ? (
<div className="rh-empty-line">No race-control messages.</div>
) : (
<ul className="rh-rc-list">
{rcHighlights.map((m, i) => (
<li key={i} className="rh-rc-row">
<span className="rh-rc-time mono">{shortTime(m.date)}</span>
<span className={`rh-rc-flag rh-rc-flag-${(m.flag || 'none').toLowerCase()}`}>
{m.flag || m.category || '—'}
</span>
<span className="rh-rc-msg">{m.message}</span>
</li>
))}
</ul>
)}
</section>
<section className="rh-panel">
<div className="sec-header">
<span className="sec-title">Local Coverage</span>
<span className="sec-meta mono">
{coverage.available}/{coverage.total}
</span>
</div>
<div className="rh-coverage-meter" aria-hidden="true">
<div
className="rh-coverage-fill"
style={{ width: `${(coverage.available / coverage.total) * 100}%` }}
/>
</div>
<div className="rh-empty-line" style={{ marginTop: 'var(--s2)' }}>
{coverage.available === coverage.total
? 'Every Race Hub dataset is local for this session.'
: `${coverage.total - coverage.available} dataset${
coverage.total - coverage.available === 1 ? '' : 's'
} not ingested yet — see Data Status tab.`}
</div>
</section>
</div>
</div>
)
}
interface StatCardProps {
label: string
primary?: string
primaryColor?: string
secondary?: string
tertiary?: string
highlight?: string
placeholder?: boolean
}
function StatCard({
label,
primary,
primaryColor,
secondary,
tertiary,
highlight,
placeholder,
}: StatCardProps) {
if (placeholder) {
return (
<div className="rh-stat-card rh-stat-empty">
<div className="rh-stat-label mono">{label}</div>
<div className="rh-stat-primary"></div>
<div className="rh-stat-secondary">No data ingested</div>
</div>
)
}
return (
<div className="rh-stat-card">
<div className="rh-stat-label mono">{label}</div>
<div className="rh-stat-primary" style={primaryColor ? { color: primaryColor } : undefined}>
{primary}
</div>
{secondary && <div className="rh-stat-secondary">{secondary}</div>}
{tertiary && <div className="rh-stat-tertiary">{tertiary}</div>}
{highlight && <div className="rh-stat-highlight mono">{highlight}</div>}
</div>
)
}
function PodiumCard({ podium }: { podium: Array<{ name_acronym: string; team_colour: string; position: number; full_name: string; gap_to_leader: number | string | number[] | null; duration: number | number[] | null; driver_number: number }> }) {
if (podium.length === 0) {
return (
<div className="rh-stat-card rh-stat-empty">
<div className="rh-stat-label mono">Podium</div>
<div className="rh-stat-primary"></div>
<div className="rh-stat-secondary">No classified finishers</div>
</div>
)
}
return (
<div className="rh-stat-card">
<div className="rh-stat-label mono">Podium</div>
<ol className="rh-podium-list">
{podium.map((r) => (
<li key={r.driver_number} className={`rh-podium-row rh-podium-p${r.position}`}>
<span className="rh-podium-pos mono">P{r.position}</span>
<span
className="rh-podium-driver"
style={r.team_colour ? { color: `#${r.team_colour}` } : undefined}
>
{r.name_acronym || `#${r.driver_number}`}
</span>
<span className="rh-podium-gap mono">
{r.position === 1
? formatDuration(r.duration)
: formatGap(r.gap_to_leader)}
</span>
</li>
))}
</ol>
</div>
)
}
function ConditionChip({
label,
value,
accent,
}: {
label: string
value: string
accent?: 'wet'
}) {
return (
<div className={`rh-condition-chip${accent === 'wet' ? ' rh-condition-wet' : ''}`}>
<span className="rh-condition-label mono">{label}</span>
<span className="rh-condition-value">{value}</span>
</div>
)
}
function shortTime(iso: string): string {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso.slice(11, 16)
return d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', hour12: false })
}
function pickFastestLap(
data: RaceHub,
): { lap: number; time: number; acronym: string; fullName: string; colour: string } | null {
const candidates = data.laps.filter(
(l) => l.lap_duration != null && l.lap_duration > 0 && !l.is_pit_out_lap,
)
if (candidates.length === 0) return null
let best = candidates[0]
for (const lap of candidates) {
if ((lap.lap_duration ?? 0) < (best.lap_duration ?? Infinity)) {
best = lap
}
}
const driverInfo = data.results.find((r) => r.driver_number === best.driver_number)
?? data.drivers.find((d) => d.driver_number === best.driver_number)
return {
lap: best.lap_number,
time: best.lap_duration ?? 0,
acronym:
('name_acronym' in (driverInfo ?? {}) ? (driverInfo as { name_acronym: string }).name_acronym : '')
|| `#${best.driver_number}`,
fullName:
('full_name' in (driverInfo ?? {}) ? (driverInfo as { full_name: string }).full_name : '') || '',
colour:
('team_colour' in (driverInfo ?? {}) ? (driverInfo as { team_colour: string }).team_colour : '') || '',
}
}

View File

@@ -1,22 +1,20 @@
export type Tab =
| 'results'
| 'grid'
| 'overview'
| 'race_story'
| 'strategy'
| 'positions'
| 'laps'
| 'lap_data'
| 'conditions'
| 'race_control'
| 'weather'
| 'datasets'
| 'data_status'
const TABS: { id: Tab; label: string }[] = [
{ id: 'results', label: 'Results' },
{ id: 'grid', label: 'Grid' },
{ id: 'overview', label: 'Overview' },
{ id: 'race_story', label: 'Race Story' },
{ id: 'strategy', label: 'Strategy' },
{ id: 'positions', label: 'Positions' },
{ id: 'laps', label: 'Laps' },
{ id: 'lap_data', label: 'Lap Data' },
{ id: 'conditions', label: 'Conditions' },
{ id: 'race_control', label: 'Race Control' },
{ id: 'weather', label: 'Weather' },
{ id: 'datasets', label: 'Datasets' },
{ id: 'data_status', label: 'Data Status' },
]
interface Props {

View File

@@ -0,0 +1,140 @@
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
import { countryDecal, formatGpDateRange } from '../lib/gpIdentity'
interface Props {
currentMeetingKey?: number
currentSessionKey?: number
onClose: () => void
}
export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose }: Props) {
const navigate = useNavigate()
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons })
const [year, setYear] = useState<number | null>(null)
const [openMeetingKey, setOpenMeetingKey] = useState<number | null>(
currentMeetingKey ?? null,
)
useEffect(() => {
if (year == null && seasonsQuery.data?.length) {
setYear(seasonsQuery.data[0])
}
}, [seasonsQuery.data, year])
const meetingsQuery = useQuery({
queryKey: ['meetings', year],
queryFn: () => fetchLocalMeetings(year!),
enabled: year != null,
})
const weekendQuery = useQuery({
queryKey: ['weekend', openMeetingKey],
queryFn: () => fetchWeekend(openMeetingKey!),
enabled: openMeetingKey != null,
})
const seasons = seasonsQuery.data ?? []
const meetings = meetingsQuery.data ?? []
const weekend = weekendQuery.data
function openSession(sessionKey: number) {
navigate({ to: '/race-hub', search: { session_key: sessionKey } })
onClose()
}
return (
<div className="rh-switcher" data-testid="rh-switcher">
<div className="rh-switcher-head">
<span className="sec-title">Switch Weekend</span>
<div className="rh-switcher-years">
{seasons.map((y) => (
<button
key={y}
type="button"
className={`rh-switcher-year${y === year ? ' active' : ''}`}
onClick={() => {
setYear(y)
setOpenMeetingKey(null)
}}
>
{y}
</button>
))}
</div>
<button type="button" className="rh-switcher-close" onClick={onClose}>
Close
</button>
</div>
{meetingsQuery.isLoading && (
<div className="rh-switcher-empty">loading meetings</div>
)}
{!meetingsQuery.isLoading && meetings.length === 0 && (
<div className="rh-switcher-empty">No meetings ingested for {year}.</div>
)}
{meetings.length > 0 && (
<div className="rh-switcher-grid">
{meetings.map((m) => {
const expanded = m.meeting_key === openMeetingKey
const isCurrent = m.meeting_key === currentMeetingKey
return (
<div
key={m.meeting_key}
className={`rh-switcher-mtg${expanded ? ' expanded' : ''}${isCurrent ? ' current' : ''}`}
>
<button
type="button"
className="rh-switcher-mtg-head"
aria-expanded={expanded}
onClick={() =>
setOpenMeetingKey((prev) => (prev === m.meeting_key ? null : m.meeting_key))
}
data-testid={`rh-switcher-meeting-${m.meeting_key}`}
>
<span className="rh-switcher-decal mono">{countryDecal(m)}</span>
<span className="rh-switcher-mtg-name">{m.meeting_name}</span>
<span className="rh-switcher-mtg-meta mono">{formatGpDateRange(m)}</span>
</button>
{expanded && (
<div className="rh-switcher-sessions">
{weekendQuery.isLoading && (
<div className="rh-switcher-empty">loading sessions</div>
)}
{weekend && weekend.meeting_key === m.meeting_key &&
weekend.sessions.map(({ session, source, datasets }) => {
const active = session.session_key === currentSessionKey
return (
<button
key={session.session_key}
type="button"
className={`rh-switcher-session${active ? ' active' : ''}`}
onClick={() => openSession(session.session_key)}
data-testid={`rh-switcher-session-${session.session_key}`}
>
<span className="rh-switcher-sess-abbrev mono">
{sessionTypeAbbrev(session.session_type, session.session_name)}
</span>
<span className="rh-switcher-sess-name">{session.session_name}</span>
<span className="rh-switcher-sess-cov mono">
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
{formatCoverageHint(datasets)}
</span>
</button>
)
})}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}