mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Rework Race Hub as weekend workspace
This commit is contained in:
@@ -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 <key></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>
|
||||
|
||||
281
frontend/src/components/OverviewView.tsx
Normal file
281
frontend/src/components/OverviewView.tsx
Normal 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 : '') || '',
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
140
frontend/src/components/WeekendSwitcher.tsx
Normal file
140
frontend/src/components/WeekendSwitcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
260
frontend/src/test/RaceHubPage.test.tsx
Normal file
260
frontend/src/test/RaceHubPage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
Object.defineProperty(window, 'scrollTo', {
|
||||
value: () => {},
|
||||
writable: true,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user