Add React race hub frontend

This commit is contained in:
2026-05-25 01:10:44 -04:00
parent e08255db70
commit 32d500f5af
29 changed files with 5976 additions and 105 deletions

9
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { RaceHub } from './types'
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
const res = await fetch(`/api/v1/race-hub?session_key=${sessionKey}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}

View File

@@ -0,0 +1,98 @@
import type { EnrichedResult, EnrichedGrid } from '../types'
import { DriverCell } from './DriverCell'
import { formatDuration, formatGap, positionClass, gridDelta, gridDeltaClass } from '../utils'
interface Props {
results: EnrichedResult[]
grid: EnrichedGrid[]
}
export function ClassificationTable({ results, grid }: Props) {
if (results.length === 0) {
return (
<div className="missing-notice">
Results not ingested. Run{' '}
<code>box-box --ingest-session &lt;key&gt;</code> to load this dataset.
</div>
)
}
const gridByDriver = Object.fromEntries(grid.map((g) => [g.driver_number, g.position]))
const isRace = results.some((r) => r.points > 0 || r.number_of_laps > 0)
return (
<div className="scroll-x">
<table className="data-table" style={{ minWidth: 520 }}>
<thead>
<tr>
<th className="c" style={{ width: 28 }}>P</th>
<th>Driver</th>
<th className="hide-mobile">Team</th>
{isRace && <th className="c hide-mobile">Grid</th>}
{isRace && <th className="c hide-mobile">Δ</th>}
<th className="r">Time / Gap</th>
{isRace && <th className="r hide-mobile">Pts</th>}
</tr>
</thead>
<tbody>
{results.map((r) => {
const gridPos = gridByDriver[r.driver_number] ?? 0
const timeStr = r.dnf
? null
: r.dns
? null
: r.dsq
? null
: r.position === 1
? formatDuration(r.duration)
: formatGap(r.gap_to_leader)
return (
<tr key={r.driver_number}>
<td className="c">
<span className={positionClass(r.position)}>{r.position}</span>
</td>
<td>
<DriverCell
acronym={r.name_acronym || String(r.driver_number)}
number={r.driver_number}
colour={r.team_colour}
/>
</td>
<td className="hide-mobile" style={{ color: 'var(--text-2)', fontSize: 11 }}>
{r.team_name}
</td>
{isRace && (
<td className="c mono hide-mobile" style={{ color: 'var(--text-3)' }}>
{gridPos || '—'}
</td>
)}
{isRace && (
<td className="c hide-mobile">
<span className={gridDeltaClass(r.position, gridPos)}>
{gridDelta(r.position, gridPos)}
</span>
</td>
)}
<td className="r">
{r.dnf && <span className="status-dnf">DNF</span>}
{r.dns && <span className="status-dns">DNS</span>}
{r.dsq && <span className="status-dsq">DSQ</span>}
{!r.dnf && !r.dns && !r.dsq && (
<span style={{ fontFamily: 'var(--f-mono)' }}>{timeStr ?? '—'}</span>
)}
</td>
{isRace && (
<td className="r hide-mobile" style={{ fontWeight: r.points > 0 ? 700 : 400, color: r.points > 0 ? 'var(--text)' : 'var(--text-3)' }}>
{r.points > 0 ? r.points : '—'}
</td>
)}
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,35 @@
import type { DatasetInfo } from '../types'
const DATASET_LABELS: Record<string, string> = {
meeting: 'meeting',
session: 'session',
drivers: 'drivers',
results: 'results',
starting_grid: 'grid',
}
interface Props {
datasets: Record<string, DatasetInfo>
}
export function DatasetStrip({ datasets }: Props) {
const keys = Object.keys(DATASET_LABELS)
return (
<div className="dataset-strip">
{keys.map((key) => {
const info = datasets[key]
const available = info?.status === 'available'
return (
<div key={key} className="ds-item" title={info ? `${info.source} · ${info.count ?? 0} rows` : 'missing'}>
<div className={`ds-dot ${available ? 'ds-dot-local' : 'ds-dot-missing'}`} />
<span>{DATASET_LABELS[key]}</span>
{available && info.count != null && info.count > 0 && (
<span style={{ opacity: 0.5 }}>·{info.count}</span>
)}
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,17 @@
import { teamColor } from '../utils'
interface Props {
acronym: string
number: number
colour: string
}
export function DriverCell({ acronym, number, colour }: Props) {
return (
<div className="drv-cell">
<div className="drv-bar" style={{ background: teamColor(colour) }} />
<span className="drv-code">{acronym}</span>
<span className="drv-num">{number}</span>
</div>
)
}

View File

@@ -0,0 +1,16 @@
import { Link } from '@tanstack/react-router'
export function Nav() {
return (
<nav className="app-nav">
<Link to="/" className="nav-logo">
box<em>-</em>box
</Link>
<div className="nav-links">
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
Race Hub
</Link>
</div>
</nav>
)
}

View File

@@ -0,0 +1,38 @@
import type { Meeting, Session, RaceHub } from '../types'
import { formatDate } from '../utils'
interface Props {
meeting?: Meeting
session?: Session
source: RaceHub['source']
}
function SourceBadge({ source }: { source: RaceHub['source'] }) {
if (source === 'local') return <span className="badge badge-local">Local</span>
if (source === 'partial') return <span className="badge badge-partial">Partial</span>
return <span className="badge badge-none">No data</span>
}
export function RaceHubHeader({ meeting, session, source }: Props) {
const meetingName = meeting?.meeting_name ?? 'Unknown Meeting'
const sessionName = session?.session_name ?? 'Unknown Session'
const dateStr = formatDate(session?.date_start ?? meeting?.date_start)
const location = meeting ? `${meeting.location} · ${meeting.country_name}` : null
return (
<div className="rh-header">
<div className="rh-title-group">
<div className="rh-meeting">{meetingName}</div>
<div className="rh-session">{sessionName}</div>
<div className="rh-meta">
{dateStr && <span className="rh-meta-item">{dateStr}</span>}
{location && <span className="rh-meta-item" style={{ opacity: 0.6 }}>·</span>}
{location && <span className="rh-meta-item">{location}</span>}
</div>
</div>
<div style={{ flexShrink: 0, paddingTop: 2 }}>
<SourceBadge source={source} />
</div>
</div>
)
}

View File

@@ -0,0 +1,57 @@
import type { EnrichedGrid } from '../types'
import { DriverCell } from './DriverCell'
import { formatLapTime } from '../utils'
interface Props {
grid: EnrichedGrid[]
}
export function StartingGridTable({ grid }: Props) {
if (grid.length === 0) {
return (
<div className="missing-notice">
Starting grid not ingested. Run{' '}
<code>box-box --ingest-session &lt;key&gt;</code> to load this dataset.
</div>
)
}
return (
<div className="scroll-x">
<table className="data-table" style={{ minWidth: 380 }}>
<thead>
<tr>
<th className="c" style={{ width: 28 }}>P</th>
<th>Driver</th>
<th className="hide-mobile">Team</th>
<th className="r">Lap Time</th>
</tr>
</thead>
<tbody>
{grid.map((g) => (
<tr key={g.driver_number}>
<td className="c">
<span style={{ fontFamily: 'var(--f-mono)', color: 'var(--text-2)' }}>
{g.position}
</span>
</td>
<td>
<DriverCell
acronym={g.name_acronym || String(g.driver_number)}
number={g.driver_number}
colour={g.team_colour}
/>
</td>
<td className="hide-mobile" style={{ color: 'var(--text-2)', fontSize: 11 }}>
{g.team_name}
</td>
<td className="r" style={{ fontFamily: 'var(--f-mono)' }}>
{g.lap_duration != null ? formatLapTime(g.lap_duration) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

23
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,23 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider } from '@tanstack/react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { router } from './router'
import './styles/app.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</React.StrictMode>,
)

View File

@@ -0,0 +1,111 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { fetchRaceHub } from '../api'
import { RaceHubHeader } from '../components/RaceHubHeader'
import { DatasetStrip } from '../components/DatasetStrip'
import { ClassificationTable } from '../components/ClassificationTable'
import { StartingGridTable } from '../components/StartingGridTable'
interface Props {
sessionKey: number
}
export function RaceHubPage({ sessionKey }: Props) {
const navigate = useNavigate()
const [inputVal, setInputVal] = useState(sessionKey > 0 ? String(sessionKey) : '')
const { data, isLoading, isError, error } = 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 } })
}
}
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>
{/* Prompt when no key entered */}
{sessionKey === 0 && (
<div className="empty-state">
<div className="empty-state-title">Enter a session key to load Race Hub data</div>
<div className="empty-state-desc">
Example: <code>9472</code> (Monaco GP 2025 Race)<br />
Ingest data first with{' '}
<code>box-box --ingest-session &lt;key&gt;</code>
</div>
</div>
)}
{/* Loading */}
{sessionKey > 0 && isLoading && (
<div className="loading-state">loading session {sessionKey}</div>
)}
{/* Error */}
{isError && (
<div className="error-box">
{error instanceof Error ? error.message : 'Failed to load race hub data'}
</div>
)}
{/* Data */}
{data && (
<>
<RaceHubHeader
meeting={data.meeting}
session={data.session}
source={data.source}
/>
<DatasetStrip datasets={data.datasets} />
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Final Classification</span>
{data.results.length > 0 && (
<span className="sec-meta">{data.results.length} drivers</span>
)}
</div>
<ClassificationTable results={data.results} grid={data.starting_grid} />
</div>
<div className="data-section">
<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>
)}
</div>
<StartingGridTable grid={data.starting_grid} />
</div>
</>
)}
</div>
)
}

47
frontend/src/router.tsx Normal file
View File

@@ -0,0 +1,47 @@
import { createRootRoute, createRoute, createRouter, Outlet, redirect } from '@tanstack/react-router'
import { Nav } from './components/Nav'
import { RaceHubPage } from './pages/RaceHubPage'
type RaceHubSearch = {
session_key?: number
}
const rootRoute = createRootRoute({
component: () => (
<>
<Nav />
<Outlet />
</>
),
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
beforeLoad: () => {
throw redirect({ to: '/race-hub', search: {} })
},
})
export const raceHubRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/race-hub',
validateSearch: (search: Record<string, unknown>): RaceHubSearch => {
const sessionKey = Number(search.session_key)
return Number.isFinite(sessionKey) && sessionKey > 0 ? { session_key: sessionKey } : {}
},
component: function RaceHubRoute() {
const { session_key } = raceHubRoute.useSearch()
return <RaceHubPage sessionKey={session_key ?? 0} />
},
})
const routeTree = rootRoute.addChildren([indexRoute, raceHubRoute])
export const router = createRouter({ routeTree })
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}

380
frontend/src/styles/app.css Normal file
View File

@@ -0,0 +1,380 @@
/* ── Design tokens ── */
:root {
--bg: #0d0d0d;
--surface: #141414;
--surface-h: #1a1a1a;
--surface-2: #1e1e1e;
--border: #232323;
--border-2: #333;
--text: #e0e0e0;
--text-2: #909090;
--text-3: #484848;
--red: #e10600;
--green: #39c73a;
--yellow: #ffd600;
--purple: #c278ff;
--f-ui: system-ui, -apple-system, 'Segoe UI', sans-serif;
--f-mono: 'JetBrains Mono', 'Cascadia Code', 'Consolas', monospace;
--s1: 2px; --s2: 4px; --s3: 6px; --s4: 10px;
--s5: 16px; --s6: 24px; --s7: 40px;
--nav-h: 44px;
}
/* ── Reset ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #root { height: 100%; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--f-ui);
font-size: 13px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a { color: inherit; text-decoration: none; }
/* ── Nav ── */
.app-nav {
position: sticky;
top: 0;
z-index: 100;
height: var(--nav-h);
display: flex;
align-items: center;
gap: var(--s5);
padding: 0 var(--s6);
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.nav-logo {
font-family: var(--f-mono);
font-size: 15px;
font-weight: 700;
color: var(--text);
letter-spacing: -0.02em;
flex-shrink: 0;
}
.nav-logo em { color: var(--red); font-style: normal; }
.nav-links { display: flex; gap: 2px; }
.nav-links a {
padding: var(--s2) var(--s4);
font-size: 12px;
font-weight: 500;
color: var(--text-2);
border-radius: 2px;
transition: color 0.1s, background 0.1s;
}
.nav-links a:hover { color: var(--text); background: var(--surface-h); }
.nav-links a.active { color: var(--text); background: var(--surface-2); }
/* ── Page ── */
.page {
max-width: 1040px;
margin: 0 auto;
padding: var(--s5) var(--s6);
}
/* ── Session input bar ── */
.session-bar {
display: flex;
align-items: center;
gap: var(--s3);
padding-bottom: var(--s4);
border-bottom: 1px solid var(--border);
margin-bottom: var(--s5);
flex-wrap: wrap;
}
.session-bar label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-3);
}
.session-bar input {
width: 110px;
padding: 4px var(--s3);
background: var(--surface);
border: 1px solid var(--border-2);
color: var(--text);
font-family: var(--f-mono);
font-size: 13px;
border-radius: 2px;
outline: none;
}
.session-bar input:focus { border-color: var(--red); }
.session-bar button {
padding: 4px var(--s5);
background: var(--red);
color: #fff;
border: none;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
border-radius: 2px;
cursor: pointer;
}
.session-bar button:hover { background: #c50500; }
/* ── Race Hub header ── */
.rh-header {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: var(--s4);
padding-bottom: var(--s4);
border-bottom: 1px solid var(--border);
margin-bottom: var(--s4);
}
.rh-title-group { flex: 1; min-width: 180px; }
.rh-meeting { font-size: 20px; font-weight: 700; line-height: 1.2; }
.rh-session {
font-size: 13px;
color: var(--text-2);
margin-top: 3px;
}
.rh-meta {
display: flex;
align-items: center;
gap: var(--s3);
flex-wrap: wrap;
margin-top: var(--s3);
}
.rh-meta-item {
font-size: 11px;
font-family: var(--f-mono);
color: var(--text-3);
}
/* badges */
.badge {
display: inline-flex;
align-items: center;
padding: 1px 6px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
border-radius: 2px;
}
.badge-local { background: rgba(57,199,58,.12); color: var(--green); border: 1px solid rgba(57,199,58,.25); }
.badge-partial { background: rgba(255,214,0,.12); color: var(--yellow); border: 1px solid rgba(255,214,0,.25); }
.badge-none { background: rgba(80,80,80,.12); color: var(--text-3); border: 1px solid var(--border); }
/* ── Dataset strip ── */
.dataset-strip {
display: flex;
flex-wrap: wrap;
gap: var(--s4);
padding: var(--s3) 0;
border-bottom: 1px solid var(--border);
margin-bottom: var(--s6);
}
.ds-item {
display: flex;
align-items: center;
gap: 5px;
font-size: 10px;
font-family: var(--f-mono);
color: var(--text-3);
}
.ds-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.ds-dot-local { background: var(--green); }
.ds-dot-missing { background: var(--text-3); opacity: 0.5; }
/* ── Section header ── */
.sec-header {
display: flex;
align-items: baseline;
gap: var(--s4);
margin-bottom: var(--s3);
}
.sec-title {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-3);
}
.sec-meta {
font-size: 10px;
font-family: var(--f-mono);
color: var(--text-3);
}
/* ── Data sections ── */
.data-section { margin-bottom: var(--s7); }
.scroll-x { overflow-x: auto; -webkit-overflow-scrolling: touch; }
/* ── Tables ── */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.data-table th {
padding: var(--s2) var(--s3);
text-align: left;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-3);
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.data-table th.r { text-align: right; }
.data-table th.c { text-align: center; }
.data-table td {
padding: var(--s2) var(--s3);
border-bottom: 1px solid var(--border);
vertical-align: middle;
white-space: nowrap;
}
.data-table tbody tr:last-child td { border-bottom: none; }
.data-table tbody tr:hover { background: var(--surface-h); }
.data-table td.r { text-align: right; font-family: var(--f-mono); }
.data-table td.c { text-align: center; }
.data-table td.mono { font-family: var(--f-mono); }
/* ── Position badges ── */
.pos-p1 { color: #ffd700; font-weight: 700; font-family: var(--f-mono); }
.pos-p2 { color: #c0c0c0; font-weight: 700; font-family: var(--f-mono); }
.pos-p3 { color: #cd7f32; font-weight: 700; font-family: var(--f-mono); }
.pos-n { color: var(--text-2); font-family: var(--f-mono); }
.pos-gain { color: var(--green); font-size: 11px; }
.pos-loss { color: var(--red); font-size: 11px; }
.pos-same { color: var(--text-3); font-size: 11px; }
/* ── Driver cell ── */
.drv-cell {
display: flex;
align-items: center;
gap: var(--s2);
}
.drv-bar {
width: 3px;
height: 18px;
flex-shrink: 0;
border-radius: 1px;
}
.drv-code {
font-family: var(--f-mono);
font-size: 12px;
font-weight: 700;
color: var(--text);
min-width: 30px;
}
.drv-num {
font-family: var(--f-mono);
font-size: 10px;
color: var(--text-3);
min-width: 18px;
}
/* ── Status labels ── */
.status-dnf { color: var(--red); font-size: 10px; font-weight: 700; font-family: var(--f-mono); }
.status-dns { color: var(--text-3); font-size: 10px; font-weight: 700; font-family: var(--f-mono); }
.status-dsq { color: var(--yellow); font-size: 10px; font-weight: 700; font-family: var(--f-mono); }
/* ── Empty / loading states ── */
.empty-state {
padding: var(--s7) 0;
text-align: center;
color: var(--text-3);
}
.empty-state-title {
font-size: 14px;
font-weight: 600;
color: var(--text-2);
margin-bottom: var(--s3);
}
.empty-state-desc { font-size: 12px; line-height: 1.7; }
.empty-state-desc code {
font-family: var(--f-mono);
font-size: 11px;
color: var(--text-3);
}
.loading-state {
padding: var(--s7) 0;
text-align: center;
font-family: var(--f-mono);
font-size: 12px;
color: var(--text-3);
}
.error-box {
padding: var(--s4) var(--s5);
background: rgba(225,6,0,.07);
border: 1px solid rgba(225,6,0,.2);
border-radius: 2px;
color: #ff6b6b;
font-size: 12px;
margin-bottom: var(--s5);
}
.missing-notice {
padding: var(--s4) var(--s5);
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--text-3);
font-size: 12px;
color: var(--text-2);
margin-bottom: var(--s4);
}
.missing-notice code {
font-family: var(--f-mono);
font-size: 11px;
color: var(--text-3);
}
/* ── Mobile ── */
@media (max-width: 640px) {
.page { padding: var(--s3); }
.app-nav { padding: 0 var(--s4); gap: var(--s3); }
.nav-links a { padding: var(--s2) var(--s3); font-size: 11px; }
.rh-meeting { font-size: 17px; }
/* On narrow screens, hide lower-priority table columns */
.hide-mobile { display: none; }
.data-table { min-width: 100% !important; }
.data-table th,
.data-table td { padding: var(--s2); }
.drv-code { min-width: 24px; }
.drv-num { min-width: 14px; }
}

View File

@@ -0,0 +1,131 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ClassificationTable } from '../components/ClassificationTable'
import type { EnrichedResult, EnrichedGrid } from '../types'
const mockResults: EnrichedResult[] = [
{
driver_number: 16,
position: 1,
name_acronym: 'LEC',
full_name: 'Charles Leclerc',
team_name: 'Ferrari',
team_colour: 'e8002d',
dnf: false,
dns: false,
dsq: false,
duration: 5534.456,
gap_to_leader: null,
number_of_laps: 78,
points: 25,
session_key: 9472,
meeting_key: 1234,
},
{
driver_number: 1,
position: 2,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull Racing',
team_colour: '3671c6',
dnf: false,
dns: false,
dsq: false,
duration: null,
gap_to_leader: 3.456,
number_of_laps: 78,
points: 18,
session_key: 9472,
meeting_key: 1234,
},
{
driver_number: 44,
position: 5,
name_acronym: 'HAM',
full_name: 'Lewis Hamilton',
team_name: 'Ferrari',
team_colour: 'e8002d',
dnf: false,
dns: false,
dsq: false,
duration: null,
gap_to_leader: 21.234,
number_of_laps: 78,
points: 10,
session_key: 9472,
meeting_key: 1234,
},
]
const mockGrid: EnrichedGrid[] = [
{
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: 1234,
lap_duration: 74.892,
},
{
driver_number: 16,
position: 3,
name_acronym: 'LEC',
full_name: 'Charles Leclerc',
team_name: 'Ferrari',
team_colour: 'e8002d',
session_key: 9472,
meeting_key: 1234,
lap_duration: 75.123,
},
]
describe('ClassificationTable', () => {
it('renders driver acronyms', () => {
render(<ClassificationTable results={mockResults} grid={mockGrid} />)
expect(screen.getByText('LEC')).toBeInTheDocument()
expect(screen.getByText('VER')).toBeInTheDocument()
expect(screen.getByText('HAM')).toBeInTheDocument()
})
it('renders P1 badge for LEC', () => {
const { container } = render(<ClassificationTable results={mockResults} grid={mockGrid} />)
const p1 = container.querySelector('.pos-p1')
expect(p1).toBeInTheDocument()
expect(p1?.textContent).toBe('1')
})
it('shows grid gain arrow for LEC (started P3, finished P1)', () => {
render(<ClassificationTable results={mockResults} grid={mockGrid} />)
expect(screen.getByText('↑2')).toBeInTheDocument()
})
it('shows grid loss arrow for VER (started P1, finished P2)', () => {
render(<ClassificationTable results={mockResults} grid={mockGrid} />)
expect(screen.getByText('↓1')).toBeInTheDocument()
})
it('renders missing notice when results are empty', () => {
render(<ClassificationTable results={[]} grid={[]} />)
expect(screen.getByText(/not ingested/i)).toBeInTheDocument()
})
})
describe('ClassificationTable — DNF/DNS/DSQ', () => {
it('shows DNF label', () => {
const dnfResult: EnrichedResult = {
...mockResults[0],
driver_number: 23,
name_acronym: 'ALB',
position: 20,
dnf: true,
duration: null,
gap_to_leader: null,
points: 0,
}
render(<ClassificationTable results={[dnfResult]} grid={[]} />)
expect(screen.getByText('DNF')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom'

View File

@@ -0,0 +1,115 @@
import { describe, it, expect } from 'vitest'
import {
teamColor,
formatDuration,
formatGap,
formatLapTime,
gridDelta,
gridDeltaClass,
positionClass,
} from '../utils'
describe('teamColor', () => {
it('prepends # to bare hex', () => {
expect(teamColor('e8002d')).toBe('#e8002d')
})
it('passes through already-prefixed hex', () => {
expect(teamColor('#3671c6')).toBe('#3671c6')
})
it('returns fallback for empty string', () => {
expect(teamColor('')).toBe('#444444')
})
it('returns fallback for undefined', () => {
expect(teamColor(undefined)).toBe('#444444')
})
})
describe('formatDuration', () => {
it('formats a race duration with hours', () => {
expect(formatDuration(5534.456)).toBe('1:32:14.456')
})
it('formats a sub-hour duration', () => {
expect(formatLapTime(74.892)).toBe('1:14.892')
})
it('returns — for null', () => {
expect(formatDuration(null)).toBe('—')
})
it('returns — for undefined', () => {
expect(formatDuration(undefined)).toBe('—')
})
it('handles array (qualifying)', () => {
expect(formatDuration([74.892])).toBe('1:14.892')
})
it('returns — for zero', () => {
expect(formatDuration(0)).toBe('—')
})
})
describe('formatGap', () => {
it('formats a numeric gap', () => {
expect(formatGap(3.456)).toBe('+3.456')
})
it('passes through a string gap (lapped)', () => {
expect(formatGap('+1 LAP')).toBe('+1 LAP')
})
it('returns — for null', () => {
expect(formatGap(null)).toBe('—')
})
it('handles array gap', () => {
expect(formatGap([8.123])).toBe('+8.123')
})
})
describe('formatLapTime', () => {
it('formats qualifying lap time', () => {
expect(formatLapTime(74.892)).toBe('1:14.892')
})
it('returns — for null', () => {
expect(formatLapTime(null)).toBe('—')
})
it('pads seconds correctly', () => {
expect(formatLapTime(64.5)).toBe('1:04.500')
})
})
describe('gridDelta', () => {
it('shows gain when finish position improved', () => {
expect(gridDelta(1, 3)).toBe('↑2')
})
it('shows loss when finish position dropped', () => {
expect(gridDelta(5, 2)).toBe('↓3')
})
it('shows — for same position', () => {
expect(gridDelta(4, 4)).toBe('—')
})
it('shows — when grid position is 0', () => {
expect(gridDelta(1, 0)).toBe('—')
})
})
describe('gridDeltaClass', () => {
it('returns pos-gain for improvement', () => {
expect(gridDeltaClass(1, 5)).toBe('pos-gain')
})
it('returns pos-loss for drop', () => {
expect(gridDeltaClass(6, 2)).toBe('pos-loss')
})
it('returns pos-same for no change', () => {
expect(gridDeltaClass(3, 3)).toBe('pos-same')
})
})
describe('positionClass', () => {
it('returns pos-p1 for first', () => {
expect(positionClass(1)).toBe('pos-p1')
})
it('returns pos-p2 for second', () => {
expect(positionClass(2)).toBe('pos-p2')
})
it('returns pos-p3 for third', () => {
expect(positionClass(3)).toBe('pos-p3')
})
it('returns pos-n for other positions', () => {
expect(positionClass(10)).toBe('pos-n')
})
})

84
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,84 @@
export interface DatasetInfo {
status: 'available' | 'missing'
source: 'local' | 'openf1' | 'none'
count?: number
}
export interface Meeting {
meeting_key: number
meeting_name: string
meeting_official_name: string
location: string
country_name: string
country_code: string
country_flag: string
circuit_short_name: string
date_start: string
date_end: string
year: number
}
export interface Session {
session_key: number
session_name: string
session_type: string
meeting_key: number
date_start: string
date_end: string
gmt_offset: string
}
export interface Driver {
driver_number: number
name_acronym: string
full_name: string
first_name: string
last_name: string
team_name: string
team_colour: string
headshot_url: string
broadcast_name: string
session_key: number
meeting_key: number
}
export interface EnrichedResult {
driver_number: number
position: number
name_acronym: string
full_name: string
team_name: string
team_colour: string
dnf: boolean
dns: boolean
dsq: boolean
duration: number | number[] | null
gap_to_leader: number | string | number[] | null
number_of_laps: number
points: number
session_key: number
meeting_key: number
}
export interface EnrichedGrid {
driver_number: number
position: number
name_acronym: string
full_name: string
team_name: string
team_colour: string
session_key: number
meeting_key: number
lap_duration: number | null
}
export interface RaceHub {
source: 'local' | 'partial' | 'none'
session_key: number
datasets: Record<string, DatasetInfo>
meeting?: Meeting
session?: Session
drivers: Driver[]
results: EnrichedResult[]
starting_grid: EnrichedGrid[]
}

66
frontend/src/utils.ts Normal file
View File

@@ -0,0 +1,66 @@
export function teamColor(hex: string | undefined): string {
if (!hex) return '#444444'
return hex.startsWith('#') ? hex : `#${hex}`
}
export function formatDuration(val: number | number[] | null | undefined): string {
if (val == null) return '—'
const s = Array.isArray(val) ? val[0] : val
if (typeof s !== 'number' || isNaN(s) || s <= 0) return '—'
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const sec = (s % 60).toFixed(3)
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${sec.padStart(6, '0')}`
return `${m}:${sec.padStart(6, '0')}`
}
export function formatGap(val: number | string | number[] | null | undefined): string {
if (val == null) return '—'
if (typeof val === 'string') return val
const g = Array.isArray(val) ? val[0] : val
if (typeof g !== 'number' || isNaN(g)) return '—'
return `+${g.toFixed(3)}`
}
export function formatLapTime(seconds: number | null | undefined): string {
if (seconds == null || seconds <= 0) return '—'
const m = Math.floor(seconds / 60)
const s = (seconds % 60).toFixed(3)
return `${m}:${s.padStart(6, '0')}`
}
export function formatDate(dateStr: string | undefined): string {
if (!dateStr) return ''
try {
return new Date(dateStr).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
} catch {
return dateStr
}
}
export function gridDelta(finishPos: number, gridPos: number): string {
if (!gridPos || !finishPos) return '—'
const delta = gridPos - finishPos
if (delta > 0) return `${delta}`
if (delta < 0) return `${Math.abs(delta)}`
return '—'
}
export function gridDeltaClass(finishPos: number, gridPos: number): string {
if (!gridPos || !finishPos) return 'pos-same'
const delta = gridPos - finishPos
if (delta > 0) return 'pos-gain'
if (delta < 0) return 'pos-loss'
return 'pos-same'
}
export function positionClass(pos: number): string {
if (pos === 1) return 'pos-p1'
if (pos === 2) return 'pos-p2'
if (pos === 3) return 'pos-p3'
return 'pos-n'
}