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