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>
)
}

View File

@@ -1,9 +1,12 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { fetchRaceHub } from '../api'
import { LocalDataNavigator } from '../components/LocalDataNavigator'
import { RaceHubHeader } from '../components/RaceHubHeader'
import {
fetchLocalMeetings,
fetchRaceHub,
fetchSeasons,
fetchWeekend,
} from '../api'
import { DatasetStrip } from '../components/DatasetStrip'
import { ClassificationTable } from '../components/ClassificationTable'
import { StartingGridTable } from '../components/StartingGridTable'
@@ -14,123 +17,339 @@ import { PositionEvolutionView } from '../components/PositionEvolutionView'
import { LapsView } from '../components/LapsView'
import { RaceControlView } from '../components/RaceControlView'
import { WeatherView } from '../components/WeatherView'
import { OverviewView } from '../components/OverviewView'
import { WeekendSwitcher } from '../components/WeekendSwitcher'
import { SourceBadge } from '../components/SourceBadge'
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
import {
formatSessionScheduleTime,
pickFocusMeeting,
sortSessionsByStart,
} from '../lib/schedule'
import type { Weekend, WeekendSession } from '../types'
interface Props {
sessionKey: number
}
type RaceStorySubview = 'classification' | 'grid' | 'positions'
function pickAnalysisSession(weekend: Weekend | undefined): WeekendSession | undefined {
if (!weekend) return undefined
const local = weekend.sessions.filter((s) => s.source === 'local')
const partial = weekend.sessions.filter((s) => s.source === 'partial')
const pool = local.length > 0 ? local : partial.length > 0 ? partial : weekend.sessions
const race = pool.find((s) => s.session.session_type?.toLowerCase().includes('race'))
if (race) return race
const qual = pool.find((s) => s.session.session_type?.toLowerCase().includes('qualifying'))
if (qual) return qual
return pool[0]
}
export function RaceHubPage({ sessionKey }: Props) {
const navigate = useNavigate()
const [inputVal, setInputVal] = useState(sessionKey > 0 ? String(sessionKey) : '')
const [activeTab, setActiveTab] = useState<Tab>('results')
const [activeTab, setActiveTab] = useState<Tab>('overview')
const [storyView, setStoryView] = useState<RaceStorySubview>('classification')
const [switcherOpen, setSwitcherOpen] = useState(false)
// ─── Auto-redirect when no session_key is supplied ───
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: fetchSeasons,
enabled: sessionKey === 0,
})
const latestSeason = seasonsQuery.data?.[0] ?? null
const meetingsQuery = useQuery({
queryKey: ['meetings', latestSeason],
queryFn: () => fetchLocalMeetings(latestSeason!),
enabled: sessionKey === 0 && latestSeason != null,
})
const focusMeeting = useMemo(() => {
if (sessionKey !== 0 || !meetingsQuery.data) return null
return pickFocusMeeting(meetingsQuery.data, new Date())
}, [sessionKey, meetingsQuery.data])
const fallbackWeekendQuery = useQuery({
queryKey: ['weekend', focusMeeting?.meeting_key],
queryFn: () => fetchWeekend(focusMeeting!.meeting_key),
enabled: sessionKey === 0 && focusMeeting != null,
})
useEffect(() => {
setInputVal(sessionKey > 0 ? String(sessionKey) : '')
}, [sessionKey])
if (sessionKey !== 0) return
const weekend = fallbackWeekendQuery.data
if (!weekend) return
const target = pickAnalysisSession(weekend)?.session.session_key
?? weekend.default_session_key
?? weekend.sessions[0]?.session.session_key
if (target) {
navigate({ to: '/race-hub', search: { session_key: target }, replace: true })
}
}, [sessionKey, fallbackWeekendQuery.data, navigate])
const { data, isLoading, isError, error } = useQuery({
// ─── Active session payload ───
const raceHubQuery = useQuery({
queryKey: ['race-hub', sessionKey],
queryFn: () => fetchRaceHub(sessionKey),
enabled: sessionKey > 0,
staleTime: 30_000,
})
function handleLoad(e: React.FormEvent) {
e.preventDefault()
const key = parseInt(inputVal, 10)
if (key > 0) {
navigate({ to: '/race-hub', search: { session_key: key } })
const meetingKey = raceHubQuery.data?.meeting?.meeting_key
const weekendQuery = useQuery({
queryKey: ['weekend', meetingKey],
queryFn: () => fetchWeekend(meetingKey!),
enabled: meetingKey != null && meetingKey > 0,
staleTime: 60_000,
})
const data = raceHubQuery.data
const weekend = weekendQuery.data
const accent = countryAccent(data?.meeting ?? null)
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
// ─── No session_key: show resolving state, fall back to switcher if no local data ───
if (sessionKey === 0) {
if (seasonsQuery.isLoading || meetingsQuery.isLoading || fallbackWeekendQuery.isLoading) {
return (
<div className="rh-page" style={accentStyle}>
<div className="loading-state">resolving latest local weekend</div>
</div>
)
}
const seasons = seasonsQuery.data ?? []
if (seasons.length === 0) {
return (
<div className="rh-page rh-empty" data-testid="race-hub-empty" style={accentStyle}>
<div className="rh-empty-band">
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
<h1 className="rh-empty-title">No local sessions yet</h1>
<p className="rh-empty-sub">
The Race Hub reads from local ingest only. Once a weekend is ingested
it will open here automatically.
</p>
<div className="rh-empty-actions">
<a href="/admin" className="rh-empty-action">Open Admin · Data Health</a>
<a href="/" className="rh-empty-action">Back to Command Center</a>
</div>
</div>
</div>
)
}
return (
<div className="rh-page" style={accentStyle}>
<div className="loading-state">resolving latest local weekend</div>
</div>
)
}
return (
<div className="page">
{/* Session key input */}
<form className="session-bar" onSubmit={handleLoad}>
<label htmlFor="sk-input">Session Key</label>
<input
id="sk-input"
type="number"
placeholder="e.g. 9472"
value={inputVal}
onChange={(e) => setInputVal(e.target.value)}
/>
<button type="submit">Load</button>
{sessionKey > 0 && (
<span style={{ fontFamily: 'var(--f-mono)', fontSize: 10, color: 'var(--text-3)' }}>
key {sessionKey}
</span>
)}
</form>
{/* Local data browser when no session loaded */}
{sessionKey === 0 && <LocalDataNavigator />}
{/* Loading */}
{sessionKey > 0 && isLoading && (
// ─── Loading / error for the requested session_key ───
if (raceHubQuery.isLoading) {
return (
<div className="rh-page" style={accentStyle}>
<div className="loading-state">loading session {sessionKey}</div>
</div>
)
}
if (raceHubQuery.isError || !data) {
return (
<div className="rh-page" style={accentStyle}>
<div className="error-box">
{raceHubQuery.error instanceof Error
? raceHubQuery.error.message
: `Failed to load session ${sessionKey}.`}
</div>
</div>
)
}
const decal = countryDecal(data.meeting ?? null)
const sessions = weekend ? sortSessionsByStart(weekend.sessions.map((w) => w.session)) : []
const sessionMeta = weekend
? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w]))
: {}
const activeSessionMeta = sessionMeta[sessionKey]
return (
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
{/* Topbar */}
<div className="rh-topbar">
<span className="rh-topbar-label mono">
box-box · race hub
{data.meeting?.year ? ` · ${data.meeting.year}` : ''}
</span>
<span className="rh-topbar-spacer" />
<SourceBadge source={data.source} />
<button
type="button"
className={`rh-switcher-toggle${switcherOpen ? ' active' : ''}`}
onClick={() => setSwitcherOpen((v) => !v)}
aria-expanded={switcherOpen}
data-testid="rh-switch-weekend"
>
{switcherOpen ? 'Close' : 'Switch Weekend'}
</button>
</div>
{switcherOpen && (
<WeekendSwitcher
currentMeetingKey={meetingKey}
currentSessionKey={sessionKey}
onClose={() => setSwitcherOpen(false)}
/>
)}
{/* Error */}
{isError && (
<div className="error-box">
{error instanceof Error ? error.message : 'Failed to load race hub data'}
{/* GP Identity band */}
{data.meeting && (
<section className="rh-identity" data-testid="rh-identity">
<div className="rh-identity-accent" aria-hidden="true" />
<div className="rh-identity-body">
<span className="rh-identity-decal mono">{decal}</span>
<div className="rh-identity-titles">
<h1 className="rh-identity-name">{data.meeting.meeting_name}</h1>
<div className="rh-identity-sub mono">
{[data.meeting.location, data.meeting.circuit_short_name]
.filter(Boolean)
.join(' · ')}
</div>
<div className="rh-identity-sub mono rh-identity-dates">
{formatGpDateRange(data.meeting)}
</div>
</div>
</div>
</section>
)}
{/* Session rail */}
{sessions.length > 0 && (
<nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail">
{sessions.map((session) => {
const meta = sessionMeta[session.session_key]
const active = session.session_key === sessionKey
return (
<button
key={session.session_key}
type="button"
className={`rh-session-chip${active ? ' active' : ''}`}
onClick={() =>
navigate({
to: '/race-hub',
search: { session_key: session.session_key },
})
}
aria-current={active ? 'page' : undefined}
data-testid={`rh-session-${session.session_key}`}
>
<span className="rh-session-abbrev mono">
{sessionTypeAbbrev(session.session_type, session.session_name)}
</span>
<span className="rh-session-name">{session.session_name}</span>
<span className="rh-session-time mono">
{formatSessionScheduleTime(session.date_start)}
</span>
{meta && (
<span className="rh-session-cov mono">
<span
className={`cc-cov-dot cc-cov-${meta.source}`}
aria-hidden="true"
/>
{formatCoverageHint(meta.datasets)}
</span>
)}
</button>
)
})}
</nav>
)}
{/* Active session sub-bar */}
{data.session && (
<div className="rh-active-bar" data-testid="rh-active-bar">
<span className="rh-active-name">{data.session.session_name}</span>
<span className="rh-active-meta mono">
{formatSessionScheduleTime(data.session.date_start)}
</span>
{activeSessionMeta && (
<span className="rh-active-cov mono">
<span
className={`cc-cov-dot cc-cov-${activeSessionMeta.source}`}
aria-hidden="true"
/>
{formatCoverageHint(activeSessionMeta.datasets)} datasets local
</span>
)}
<span className="rh-active-key mono">key {sessionKey}</span>
</div>
)}
{/* Data */}
{data && (
<>
<RaceHubHeader
meeting={data.meeting}
session={data.session}
source={data.source}
/>
<DatasetStrip datasets={data.datasets} />
<DatasetStrip datasets={data.datasets} />
<TabBar active={activeTab} onChange={setActiveTab} />
<TabBar active={activeTab} onChange={setActiveTab} />
{activeTab === 'overview' && <OverviewView data={data} />}
{activeTab === 'results' && (
<div className="data-section">
{activeTab === 'race_story' && (
<div className="data-section">
<div className="rh-story-controls" role="tablist" aria-label="Race story view">
<button
type="button"
role="tab"
aria-selected={storyView === 'classification'}
className={`rh-story-btn${storyView === 'classification' ? ' active' : ''}`}
onClick={() => setStoryView('classification')}
>
Classification
</button>
<button
type="button"
role="tab"
aria-selected={storyView === 'grid'}
className={`rh-story-btn${storyView === 'grid' ? ' active' : ''}`}
onClick={() => setStoryView('grid')}
>
Starting Grid
</button>
<button
type="button"
role="tab"
aria-selected={storyView === 'positions'}
className={`rh-story-btn${storyView === 'positions' ? ' active' : ''}`}
onClick={() => setStoryView('positions')}
>
Positions
</button>
</div>
{storyView === 'classification' && (
<>
<div className="sec-header">
<span className="sec-title">Final Classification</span>
{data.results.length > 0 && (
<span className="sec-meta">{data.results.length} drivers</span>
<span className="sec-meta mono">{data.results.length} drivers</span>
)}
</div>
<ClassificationTable results={data.results} grid={data.starting_grid} />
</div>
</>
)}
{activeTab === 'grid' && (
<div className="data-section">
{storyView === 'grid' && (
<>
<div className="sec-header">
<span className="sec-title">Starting Grid</span>
{data.starting_grid.length > 0 && (
<span className="sec-meta">{data.starting_grid.length} positions</span>
<span className="sec-meta mono">{data.starting_grid.length} positions</span>
)}
</div>
<StartingGridTable grid={data.starting_grid} />
</div>
</>
)}
{activeTab === 'strategy' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Race Strategy</span>
</div>
<StrategyView
results={data.results}
stints={data.stints}
pit_stops={data.pit_stops}
hasStints={data.datasets['stints']?.status === 'available'}
/>
</div>
)}
{activeTab === 'positions' && (
<div className="data-section">
{storyView === 'positions' && (
<>
<div className="sec-header">
<span className="sec-title">Position Evolution</span>
</div>
@@ -141,54 +360,68 @@ export function RaceHubPage({ sessionKey }: Props) {
laps={data.laps}
hasPositions={data.datasets['positions']?.status === 'available'}
/>
</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 === 'strategy' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Race Strategy</span>
</div>
<StrategyView
results={data.results}
stints={data.stints}
pit_stops={data.pit_stops}
hasStints={data.datasets['stints']?.status === 'available'}
/>
</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 === 'lap_data' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Lap Data</span>
{data.laps.length > 0 && (
<span className="sec-meta mono">{data.laps.length} samples</span>
)}
</div>
<LapsView laps={data.laps} />
</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 === 'conditions' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Conditions</span>
{data.weather.length > 0 && (
<span className="sec-meta mono">{data.weather.length} samples</span>
)}
</div>
<WeatherView weather={data.weather} />
</div>
)}
{activeTab === 'datasets' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Dataset Status</span>
</div>
<DatasetStatusView datasets={data.datasets} />
</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 mono">{data.race_control.length} messages</span>
)}
</div>
<RaceControlView messages={data.race_control} />
</div>
)}
{activeTab === 'data_status' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Data Status</span>
</div>
<DatasetStatusView datasets={data.datasets} />
</div>
)}
</div>
)

View File

@@ -1602,6 +1602,620 @@ a { color: inherit; text-decoration: none; }
.mono { font-family: var(--f-mono); }
/* ── Race Hub Weekend Workspace ── */
.rh-page {
max-width: 1160px;
margin: 0 auto;
padding: var(--s5) var(--s6);
display: flex;
flex-direction: column;
gap: var(--s5);
--gp-accent: var(--red);
}
.rh-topbar {
display: flex;
align-items: center;
gap: var(--s4);
padding-bottom: var(--s4);
border-bottom: 1px solid var(--border);
}
.rh-topbar-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-3);
}
.rh-topbar-spacer { flex: 1; }
.rh-switcher-toggle {
padding: 4px 10px;
font-size: 10px;
font-weight: 700;
font-family: var(--f-mono);
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-2);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 2px;
cursor: pointer;
transition: color 0.1s, border-color 0.1s, background 0.1s;
}
.rh-switcher-toggle:hover {
color: var(--text);
border-color: var(--border-2);
background: var(--surface-h);
}
.rh-switcher-toggle.active {
color: var(--text);
border-color: var(--gp-accent);
background: var(--surface-h);
}
/* GP identity band (compact) */
.rh-identity {
display: flex;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
min-height: 96px;
}
.rh-identity-accent {
width: 5px;
background: var(--gp-accent);
flex-shrink: 0;
}
.rh-identity-body {
flex: 1;
padding: var(--s4) var(--s5);
display: flex;
align-items: center;
gap: var(--s5);
min-width: 0;
}
.rh-identity-decal {
font-size: 40px;
font-weight: 800;
letter-spacing: -0.04em;
line-height: 0.9;
color: var(--text);
opacity: 0.9;
flex-shrink: 0;
}
.rh-identity-titles {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.rh-identity-name {
font-size: 20px;
font-weight: 700;
line-height: 1.15;
}
.rh-identity-sub {
font-size: 11px;
color: var(--text-3);
}
.rh-identity-dates { color: var(--text-2); }
/* Session rail */
.rh-session-rail {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
gap: var(--s3);
padding-bottom: var(--s2);
}
.rh-session-rail::-webkit-scrollbar { height: 4px; }
.rh-session-rail::-webkit-scrollbar-thumb { background: var(--border-2); }
.rh-session-chip {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto auto;
gap: 2px var(--s3);
flex: 0 0 auto;
min-width: 168px;
padding: var(--s3) var(--s4);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
text-align: left;
font: inherit;
color: inherit;
transition: background 0.1s, border-color 0.1s;
}
.rh-session-chip:hover {
background: var(--surface-h);
border-color: var(--border-2);
}
.rh-session-chip.active {
background: rgba(225, 6, 0, 0.05);
border-color: var(--gp-accent);
box-shadow: inset 3px 0 0 var(--gp-accent);
}
.rh-session-chip .rh-session-abbrev {
grid-row: 1 / span 3;
align-self: center;
font-size: 14px;
font-weight: 800;
letter-spacing: 0.04em;
color: var(--text);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 2px;
padding: 4px 7px;
min-width: 36px;
text-align: center;
}
.rh-session-chip.active .rh-session-abbrev {
color: var(--gp-accent);
border-color: var(--gp-accent);
}
.rh-session-chip .rh-session-name {
font-size: 12px;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rh-session-chip .rh-session-time {
font-size: 10px;
color: var(--text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rh-session-chip .rh-session-cov {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 10px;
color: var(--text-3);
}
/* Active session sub-bar */
.rh-active-bar {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--s4);
padding: var(--s3) 0;
border-bottom: 1px solid var(--border);
}
.rh-active-bar .rh-active-name {
font-size: 14px;
font-weight: 700;
color: var(--text);
}
.rh-active-bar .rh-active-meta,
.rh-active-bar .rh-active-cov {
font-size: 11px;
color: var(--text-3);
display: inline-flex;
align-items: center;
gap: 5px;
}
.rh-active-bar .rh-active-key {
margin-left: auto;
font-size: 10px;
color: var(--text-3);
letter-spacing: 0.05em;
}
/* Race Story sub-controls */
.rh-story-controls {
display: inline-flex;
border: 1px solid var(--border);
border-radius: 2px;
margin-bottom: var(--s4);
overflow: hidden;
}
.rh-story-btn {
padding: 5px 12px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
background: var(--surface);
border: none;
border-right: 1px solid var(--border);
cursor: pointer;
transition: color 0.1s, background 0.1s;
}
.rh-story-btn:last-child { border-right: none; }
.rh-story-btn:hover { color: var(--text-2); }
.rh-story-btn.active {
color: var(--text);
background: var(--surface-h);
box-shadow: inset 0 -2px 0 var(--gp-accent);
}
/* Overview tab */
.rh-overview { display: flex; flex-direction: column; gap: var(--s5); }
.rh-stat-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--s3);
}
.rh-stat-card {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--s4);
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--gp-accent);
border-radius: 4px;
min-width: 0;
}
.rh-stat-card.rh-stat-empty { border-left-color: var(--border-2); }
.rh-stat-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-3);
}
.rh-stat-primary {
font-size: 22px;
font-weight: 700;
font-family: var(--f-mono);
color: var(--text);
line-height: 1.1;
}
.rh-stat-secondary {
font-size: 12px;
color: var(--text-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rh-stat-tertiary {
font-size: 11px;
color: var(--text-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rh-stat-highlight {
margin-top: 2px;
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.rh-podium-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 3px;
margin-top: 4px;
}
.rh-podium-row {
display: grid;
grid-template-columns: 28px 1fr auto;
gap: var(--s3);
align-items: baseline;
font-size: 12px;
}
.rh-podium-pos { color: var(--text-3); }
.rh-podium-p1 .rh-podium-pos { color: #ffd700; font-weight: 700; }
.rh-podium-p2 .rh-podium-pos { color: #c0c0c0; font-weight: 700; }
.rh-podium-p3 .rh-podium-pos { color: #cd7f32; font-weight: 700; }
.rh-podium-driver { font-weight: 700; font-family: var(--f-mono); }
.rh-podium-gap { color: var(--text-3); font-size: 11px; }
.rh-overview-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--s4);
}
.rh-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
padding: var(--s4);
min-width: 0;
}
.rh-empty-line { font-size: 11px; color: var(--text-3); }
.rh-condition-strip {
display: flex;
flex-wrap: wrap;
gap: var(--s3);
}
.rh-condition-chip {
display: flex;
flex-direction: column;
gap: 1px;
padding: 4px 10px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 2px;
min-width: 64px;
}
.rh-condition-chip.rh-condition-wet { border-color: var(--tyre-wet); }
.rh-condition-label {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-3);
}
.rh-condition-value {
font-size: 13px;
font-weight: 600;
font-family: var(--f-mono);
color: var(--text);
}
.rh-rc-list { list-style: none; display: flex; flex-direction: column; gap: 4px; }
.rh-rc-row {
display: grid;
grid-template-columns: 50px 60px 1fr;
gap: var(--s3);
align-items: baseline;
font-size: 11px;
}
.rh-rc-time { color: var(--text-3); }
.rh-rc-flag {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-2);
}
.rh-rc-flag-yellow { color: var(--yellow); }
.rh-rc-flag-red { color: var(--red); }
.rh-rc-flag-green { color: var(--green); }
.rh-rc-flag-blue { color: #3a6cf5; }
.rh-rc-msg { color: var(--text-2); overflow: hidden; text-overflow: ellipsis; }
.rh-coverage-meter {
height: 6px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 2px;
overflow: hidden;
margin-bottom: var(--s3);
}
.rh-coverage-fill {
height: 100%;
background: var(--gp-accent);
transition: width 0.2s ease-out;
}
.rh-inline-link {
color: var(--text-2);
text-decoration: underline;
text-underline-offset: 2px;
}
.rh-inline-link:hover { color: var(--text); }
/* Weekend switcher overlay panel */
.rh-switcher {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
padding: var(--s4);
}
.rh-switcher-head {
display: flex;
align-items: center;
gap: var(--s4);
flex-wrap: wrap;
margin-bottom: var(--s4);
}
.rh-switcher-years {
display: flex;
gap: var(--s2);
margin-right: auto;
}
.rh-switcher-year {
padding: 2px 8px;
font-size: 11px;
font-family: var(--f-mono);
font-weight: 700;
color: var(--text-3);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 2px;
cursor: pointer;
}
.rh-switcher-year:hover { color: var(--text-2); }
.rh-switcher-year.active {
color: var(--text);
border-color: var(--gp-accent);
background: var(--surface-h);
}
.rh-switcher-close {
padding: 3px 10px;
font-size: 10px;
font-family: var(--f-mono);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-3);
background: transparent;
border: 1px solid var(--border);
border-radius: 2px;
cursor: pointer;
}
.rh-switcher-close:hover { color: var(--text); border-color: var(--border-2); }
.rh-switcher-empty {
font-size: 11px;
color: var(--text-3);
padding: var(--s3) 0;
}
.rh-switcher-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: var(--s3);
}
.rh-switcher-mtg {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
}
.rh-switcher-mtg.current { border-color: var(--gp-accent); }
.rh-switcher-mtg-head {
display: flex;
align-items: center;
gap: var(--s3);
padding: var(--s3) var(--s4);
width: 100%;
background: transparent;
border: none;
color: inherit;
text-align: left;
cursor: pointer;
}
.rh-switcher-mtg-head:hover { background: var(--surface-h); }
.rh-switcher-decal {
font-size: 12px;
font-weight: 800;
color: var(--text-2);
min-width: 28px;
}
.rh-switcher-mtg-name {
flex: 1;
font-size: 12px;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rh-switcher-mtg-meta {
font-size: 10px;
color: var(--text-3);
}
.rh-switcher-sessions {
display: flex;
flex-direction: column;
padding: 0 var(--s2) var(--s2);
}
.rh-switcher-session {
display: grid;
grid-template-columns: 36px 1fr auto;
gap: var(--s3);
align-items: center;
padding: 4px 8px;
background: transparent;
border: none;
border-radius: 2px;
cursor: pointer;
color: inherit;
text-align: left;
}
.rh-switcher-session:hover { background: var(--surface-h); }
.rh-switcher-session.active {
background: rgba(225, 6, 0, 0.05);
box-shadow: inset 3px 0 0 var(--gp-accent);
}
.rh-switcher-sess-abbrev {
font-size: 10px;
font-weight: 800;
text-align: center;
color: var(--text-2);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 2px;
padding: 1px 4px;
}
.rh-switcher-sess-name {
font-size: 12px;
font-weight: 500;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rh-switcher-sess-cov {
font-size: 10px;
color: var(--text-3);
display: inline-flex;
align-items: center;
gap: 4px;
}
/* Race Hub empty state */
.rh-empty { max-width: 720px; gap: var(--s5); }
.rh-empty-band {
padding: var(--s6) var(--s5);
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--gp-accent);
border-radius: 4px;
display: flex;
flex-direction: column;
gap: var(--s2);
}
.rh-empty-eyebrow {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-3);
}
.rh-empty-title { font-size: 22px; font-weight: 700; }
.rh-empty-sub { font-size: 13px; color: var(--text-2); max-width: 56ch; }
.rh-empty-actions { display: flex; flex-wrap: wrap; gap: var(--s3); }
.rh-empty-action {
padding: var(--s3) var(--s4);
font-size: 12px;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
}
.rh-empty-action:hover { background: var(--surface-h); border-color: var(--border-2); }
@media (max-width: 900px) {
.rh-overview-row { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
.rh-page { padding: var(--s4); gap: var(--s4); }
.rh-identity-body { padding: var(--s3) var(--s4); gap: var(--s3); }
.rh-identity-decal { font-size: 28px; }
.rh-identity-name { font-size: 17px; }
.rh-session-chip { min-width: 152px; }
.rh-stat-primary { font-size: 18px; }
.rh-active-bar .rh-active-key { margin-left: 0; }
.rh-switcher-grid { grid-template-columns: 1fr; }
}
/* Tablet */
@media (max-width: 900px) {
.cc-band-row {

View File

@@ -1,55 +1,90 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
import { DatasetStatusView } from '../components/DatasetStatusView'
import type { DatasetInfo } from '../types'
const allAvailable: Record<string, DatasetInfo> = {
const fullDatasets: Record<string, DatasetInfo> = {
meeting: { status: 'available', source: 'local', count: 1 },
session: { status: 'available', source: 'local', count: 1 },
drivers: { status: 'available', source: 'local', count: 20 },
results: { status: 'available', source: 'local', count: 20 },
starting_grid: { status: 'available', source: 'local', count: 20 },
stints: { status: 'available', source: 'local', count: 30 },
pit_stops: { status: 'available', source: 'local', count: 18 },
positions: { status: 'available', source: 'local', count: 120 },
race_control: { status: 'available', source: 'local', count: 5 },
weather: { status: 'available', source: 'local', count: 4 },
laps: { status: 'available', source: 'local', count: 200 },
}
const partial: Record<string, DatasetInfo> = {
const coreOnly: Record<string, DatasetInfo> = {
meeting: { status: 'available', source: 'local', count: 1 },
session: { status: 'available', source: 'local', count: 1 },
drivers: { status: 'missing', source: 'none' },
results: { status: 'missing', source: 'none' },
starting_grid: { status: 'missing', source: 'none' },
drivers: { status: 'available', source: 'local', count: 20 },
results: { status: 'available', source: 'local', count: 20 },
starting_grid: { status: 'available', source: 'local', count: 20 },
stints: { status: 'missing', source: 'none' },
pit_stops: { status: 'missing', source: 'none' },
positions: { status: 'missing', source: 'none' },
race_control: { status: 'missing', source: 'none' },
weather: { status: 'missing', source: 'none' },
laps: { status: 'missing', source: 'none' },
}
function renderView(datasets: Record<string, DatasetInfo>) {
const rootRoute = createRootRoute({
component: () => <DatasetStatusView datasets={datasets} />,
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => null,
})
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
return render(<RouterProvider router={router} />)
}
describe('DatasetStatusView', () => {
it('shows 5/5 when all available', () => {
render(<DatasetStatusView datasets={allAvailable} />)
expect(screen.getByText(/5\/5/)).toBeInTheDocument()
it('shows 11/11 when all datasets are available', async () => {
renderView(fullDatasets)
await waitFor(() =>
expect(screen.getByText(/11\/11 datasets local/)).toBeInTheDocument(),
)
})
it('shows local badges for available datasets', () => {
render(<DatasetStatusView datasets={allAvailable} />)
const localBadges = screen.getAllByText('Local')
expect(localBadges).toHaveLength(5)
it('shows a Local badge for every available dataset', async () => {
renderView(fullDatasets)
await waitFor(() => expect(screen.getAllByText('Local')).toHaveLength(11))
})
it('shows missing badges for missing datasets', () => {
render(<DatasetStatusView datasets={partial} />)
const missingBadges = screen.getAllByText('Missing')
expect(missingBadges).toHaveLength(3)
it('shows Missing badges for missing datasets', async () => {
renderView(coreOnly)
await waitFor(() => expect(screen.getAllByText('Missing')).toHaveLength(6))
})
it('shows the ingest command when data is missing', () => {
render(<DatasetStatusView datasets={partial} />)
expect(screen.getByText(/ingest-session/i)).toBeInTheDocument()
})
it('does not show ingest command when all available', () => {
render(<DatasetStatusView datasets={allAvailable} />)
it('links to admin when datasets are missing instead of inlining CLI commands', async () => {
renderView(coreOnly)
await waitFor(() => {
expect(screen.getByRole('link', { name: /manage ingestion/i })).toHaveAttribute(
'href',
'/admin',
)
})
expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument()
})
it('shows record counts', () => {
render(<DatasetStatusView datasets={allAvailable} />)
const twenties = screen.getAllByText('20')
expect(twenties.length).toBeGreaterThan(0)
it('does not surface ingest hints when fully covered', async () => {
renderView(fullDatasets)
await waitFor(() => expect(screen.getByText(/11\/11/)).toBeInTheDocument())
expect(screen.queryByText(/manage ingestion/i)).not.toBeInTheDocument()
})
it('renders record counts from the dataset payload', async () => {
renderView(fullDatasets)
await waitFor(() => {
const twenties = screen.getAllByText('20')
expect(twenties.length).toBeGreaterThan(0)
})
})
})

View File

@@ -0,0 +1,260 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
Outlet,
RouterProvider,
createRouter,
createRootRoute,
createRoute,
} from '@tanstack/react-router'
import { RaceHubPage } from '../pages/RaceHubPage'
import type { DatasetInfo, Meeting, RaceHub, Session, Weekend } from '../types'
vi.mock('../api', () => ({
fetchRaceHub: vi.fn(),
fetchSeasons: vi.fn(),
fetchLocalMeetings: vi.fn(),
fetchWeekend: vi.fn(),
}))
import { fetchRaceHub, fetchSeasons, fetchLocalMeetings, fetchWeekend } from '../api'
const mockFetchRaceHub = vi.mocked(fetchRaceHub)
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
const mockFetchWeekend = vi.mocked(fetchWeekend)
const meeting: Meeting = {
meeting_key: 1229,
meeting_name: 'Monaco Grand Prix',
meeting_official_name: 'FORMULA 1 GRAND PRIX DE MONACO 2025',
location: 'Monaco',
country_name: 'Monaco',
country_code: 'MON',
country_flag: '',
circuit_short_name: 'Monaco',
date_start: '2025-05-23T00:00:00+00:00',
date_end: '2025-05-25T00:00:00+00:00',
year: 2025,
}
const raceSession: Session = {
session_key: 9472,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1229,
date_start: '2025-05-25T13:00:00+00:00',
date_end: '2025-05-25T15:00:00+00:00',
gmt_offset: '02:00:00',
}
const qualSession: Session = {
session_key: 9471,
session_name: 'Qualifying',
session_type: 'Qualifying',
meeting_key: 1229,
date_start: '2025-05-24T14:00:00+00:00',
date_end: '2025-05-24T15:00:00+00:00',
gmt_offset: '02:00:00',
}
const fullDatasets: Record<string, DatasetInfo> = {
meeting: { status: 'available', source: 'local', count: 1 },
session: { status: 'available', source: 'local', count: 1 },
drivers: { status: 'available', source: 'local', count: 20 },
results: { status: 'available', source: 'local', count: 20 },
starting_grid: { status: 'available', source: 'local', count: 20 },
stints: { status: 'available', source: 'local', count: 30 },
pit_stops: { status: 'available', source: 'local', count: 18 },
positions: { status: 'available', source: 'local', count: 120 },
race_control: { status: 'available', source: 'local', count: 5 },
weather: { status: 'available', source: 'local', count: 4 },
laps: { status: 'available', source: 'local', count: 200 },
}
const raceHub: RaceHub = {
source: 'local',
session_key: 9472,
datasets: fullDatasets,
meeting,
session: raceSession,
drivers: [
{
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
first_name: 'Max',
last_name: 'Verstappen',
team_name: 'Red Bull Racing',
team_colour: '3671C6',
headshot_url: '',
broadcast_name: 'M VERSTAPPEN',
session_key: 9472,
meeting_key: 1229,
},
],
results: [
{
driver_number: 1,
position: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull Racing',
team_colour: '3671C6',
dnf: false,
dns: false,
dsq: false,
duration: 5500,
gap_to_leader: null,
number_of_laps: 78,
points: 25,
session_key: 9472,
meeting_key: 1229,
},
],
starting_grid: [
{
driver_number: 1,
position: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull Racing',
team_colour: '3671C6',
session_key: 9472,
meeting_key: 1229,
lap_duration: 70.5,
},
],
stints: [],
pit_stops: [],
positions: [],
race_control: [],
weather: [
{
session_key: 9472,
meeting_key: 1229,
date: '2025-05-25T13:30:00+00:00',
air_temperature: 22,
track_temperature: 40,
humidity: 50,
pressure: 1010,
rainfall: 0,
wind_direction: 180,
wind_speed: 1.2,
},
],
laps: [
{
session_key: 9472,
driver_number: 1,
meeting_key: 1229,
lap_number: 42,
date_start: '2025-05-25T14:00:00+00:00',
lap_duration: 71.5,
is_pit_out_lap: false,
},
],
}
const weekend: Weekend = {
source: 'local',
meeting_key: 1229,
meeting,
default_session_key: 9472,
sessions: [
{ session: qualSession, source: 'local', datasets: fullDatasets },
{ session: raceSession, source: 'local', datasets: fullDatasets },
],
}
function renderRaceHub(sessionKey: number) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
const rootRoute = createRootRoute({
component: () => (
<QueryClientProvider client={queryClient}>
<Outlet />
</QueryClientProvider>
),
})
const raceHubRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/race-hub',
validateSearch: (search: Record<string, unknown>) => {
const sk = Number(search.session_key)
return Number.isFinite(sk) && sk > 0 ? { session_key: sk } : {}
},
component: function RaceHubRouteComponent() {
const { session_key } = raceHubRoute.useSearch()
return <RaceHubPage sessionKey={session_key ?? 0} />
},
})
const router = createRouter({
routeTree: rootRoute.addChildren([raceHubRoute]),
history: undefined,
})
// Navigate to the URL before mounting
router.navigate({ to: '/race-hub', search: sessionKey ? { session_key: sessionKey } : {} })
return render(<RouterProvider router={router} />)
}
describe('RaceHubPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchSeasons.mockResolvedValue([2025])
mockFetchLocalMeetings.mockResolvedValue([meeting])
mockFetchWeekend.mockResolvedValue(weekend)
mockFetchRaceHub.mockResolvedValue(raceHub)
})
it('renders the workspace identity band, session rail, and overview for a known session', async () => {
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
expect(screen.getByTestId('rh-identity')).toHaveTextContent('Monaco Grand Prix')
expect(screen.getByTestId('rh-identity')).toHaveTextContent('MON')
await waitFor(() =>
expect(screen.getByTestId('rh-session-9472')).toBeInTheDocument(),
)
expect(screen.getByTestId('rh-session-9471')).toBeInTheDocument()
// Overview is default
expect(screen.getByTestId('rh-overview')).toBeInTheDocument()
expect(screen.getByText('Winner')).toBeInTheDocument()
})
it('exposes Race Story sub-controls for classification, grid, and positions', async () => {
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
fireEvent.click(screen.getByRole('tab', { name: 'Race Story' }))
expect(screen.getByRole('tab', { name: 'Classification' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Starting Grid' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Positions' })).toBeInTheDocument()
expect(screen.getByText('Final Classification')).toBeInTheDocument()
})
it('keeps Data Status accessible and free of inline CLI guidance', async () => {
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
fireEvent.click(screen.getByRole('tab', { name: 'Data Status' }))
expect(screen.getByTestId('rh-data-status')).toBeInTheDocument()
expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument()
})
it('toggles the inline weekend switcher', async () => {
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('rh-switch-weekend'))
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
})
})

View File

@@ -3,35 +3,34 @@ import { render, screen, fireEvent } from '@testing-library/react'
import { TabBar } from '../components/TabBar'
describe('TabBar', () => {
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()
it('renders all Race Hub workspace tabs', () => {
render(<TabBar active="overview" onChange={() => {}} />)
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Race Story' })).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: 'Lap Data' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Conditions' })).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: 'Data Status' })).toBeInTheDocument()
})
it('marks the active tab with aria-selected', () => {
render(<TabBar active="grid" onChange={() => {}} />)
expect(screen.getByRole('tab', { name: 'Grid' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('tab', { name: 'Results' })).toHaveAttribute('aria-selected', 'false')
render(<TabBar active="strategy" onChange={() => {}} />)
expect(screen.getByRole('tab', { name: 'Strategy' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute('aria-selected', 'false')
})
it('applies active class only to the active tab', () => {
render(<TabBar active="strategy" onChange={() => {}} />)
const strategy = screen.getByRole('tab', { name: 'Strategy' })
const results = screen.getByRole('tab', { name: 'Results' })
expect(strategy.className).toContain('active')
expect(results.className).not.toContain('active')
render(<TabBar active="conditions" onChange={() => {}} />)
const active = screen.getByRole('tab', { name: 'Conditions' })
const inactive = screen.getByRole('tab', { name: 'Overview' })
expect(active.className).toContain('active')
expect(inactive.className).not.toContain('active')
})
it('calls onChange with the correct tab id when clicked', () => {
const onChange = vi.fn()
render(<TabBar active="results" onChange={onChange} />)
render(<TabBar active="overview" onChange={onChange} />)
fireEvent.click(screen.getByRole('tab', { name: 'Race Control' }))
expect(onChange).toHaveBeenCalledWith('race_control')
})

View File

@@ -1 +1,6 @@
import '@testing-library/jest-dom'
Object.defineProperty(window, 'scrollTo', {
value: () => {},
writable: true,
})