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

7
.gitignore vendored
View File

@@ -16,3 +16,10 @@ build/
# Old file cache
.cache/
# Frontend dependencies/build output
frontend/node_modules/
frontend/dist/
# Local Claude workspace settings
.claude/

View File

@@ -179,3 +179,11 @@ After v1:
- Build full Command Center, Standings, Drivers, and Settings.
- Improve mobile/iPad live layouts.
- Add broader Playwright coverage and visual regression checks.
### Phase 6: React Race Hub Analytics
- Add Race Hub tabs or segmented views.
- Keep classification and grid intact.
- Add Dataset Status, Strategy, and Position Evolution views.
- Use real local-first data where available and honest missing states otherwise.
- Continue frontend work with Claude.

View File

@@ -0,0 +1,79 @@
# Phase 6 React Race Hub Analytics
## Purpose
Phase 6 expands the React Race Hub from a classification/grid slice into a more
useful race analysis surface. Phase 5 proved the React stack, API contract,
desktop layout, and phone table behavior. This phase should add the first
bespoke F1 analysis views without replacing the whole Web app.
This remains a frontend-led phase for Claude.
## Scope
Add Race Hub tabs or segmented views for:
- Overview / Classification
- Starting Grid
- Strategy
- Position Evolution
- Dataset Status
Strategy and position views should be built from local-first backend data only
when the backend exposes the needed datasets. If laps/stints/positions are not
yet available through `/api/v1/race-hub`, add clear missing states instead of
fake charts.
## Backend Contract
Current Race Hub API:
```text
GET /api/v1/race-hub?session_key=...
```
Current datasets:
- meeting
- session
- drivers
- results
- starting_grid
If analytics require laps, stints, pit stops, or position samples, keep backend
changes small and explicit. Do not reintroduce direct OpenF1 reads from the
React app.
## Design Direction
Improve the information hierarchy without drifting into generic dashboard UI:
- stronger timing-wall readability;
- compact controls;
- minimal panel framing;
- no decorative gradients or card sludge;
- team colors as data accents;
- mobile views that fit the active columns rather than relying on horizontal
scrolling.
## Non-Goals
Do not include these in Phase 6:
- live timing React rewrite;
- full season calendar rebuild;
- settings UI;
- ingest UI;
- static archive support;
- replacing the old Go-served Web UI entirely.
## Acceptance Criteria
Phase 6 is complete when:
- Race Hub has an ergonomic tab/segmented-view structure;
- classification and grid remain intact;
- analytics views show either real local data or honest missing states;
- desktop and phone layouts have been visually checked;
- frontend tests/build pass;
- Go build still passes.

View File

@@ -59,8 +59,10 @@ not implementation tickets yet.
coding slice for store-backed Race Hub read models and Web API metadata.
- [13 Phase 5 React Race Hub](13-phase-5-react-race-hub.md): first frontend
implementation slice for the production Web UI.
- [Claude Phase 5 Prompt](claude-phase-5-react-race-hub-prompt.md): current
handoff prompt for Claude to begin React/frontend work.
- [14 Phase 6 React Race Hub Analytics](14-phase-6-react-race-hub-analytics.md):
next frontend slice for strategy, position, and richer Race Hub views.
- [Claude Phase 6 Prompt](claude-phase-6-react-race-hub-analytics-prompt.md):
current handoff prompt for the next Claude frontend phase.
## External References

View File

@@ -1,103 +0,0 @@
# Claude Prompt: Phase 5 React Race Hub
You are the frontend/UI lead for `box-box`, an F1 local-first command center.
Backend Phases 1-4 are complete:
- `internal/live` owns shared official F1 SignalR live timing.
- `internal/store` owns the local SQLite domain DB.
- `internal/ingest` can ingest OpenF1 data into the local store.
- `internal/query` and Web API expose local-first Race Hub data at:
```text
GET /api/v1/race-hub?session_key=9472
```
Your task is Phase 5: begin the production React Web UI with a focused Race Hub
slice.
## Read First
Read these files before editing:
- `CLAUDE.md`
- `documentations/refactor/04-web-ui-product.md`
- `documentations/refactor/05-frontend-stack.md`
- `documentations/refactor/06-visual-design-direction.md`
- `documentations/refactor/12-phase-4-local-first-web-api.md`
- `documentations/refactor/13-phase-5-react-race-hub.md`
- `documentations/refactor/screens/index.html`
- `documentations/refactor/screens/race-hub.html`
- `documentations/refactor/screens/live-timing.html`
- `documentations/refactor/screens/mobile-live.html`
- `internal/web/racehub.go`
- `internal/query/racehub.go`
## Goal
Add the first production React frontend slice for Race Hub. Keep the existing
Go-served static Web UI working while the React route matures.
## Required Work
1. Add a Vite + React + TypeScript frontend foundation.
2. Use TanStack Query for API loading.
3. Use TanStack Router unless there is a clear reason to choose React Router.
4. Build a Race Hub screen backed by:
```text
/api/v1/race-hub?session_key=...
```
5. Show:
- meeting/session header;
- source/dataset status strip;
- classification table;
- starting grid table;
- driver/team identity treatment;
- missing/partial dataset states;
- desktop and phone-responsive layouts.
6. Keep old Web UI routes/assets intact.
7. Add at least basic tests or a smoke check.
8. Document how to run the React dev server and how it connects to the Go API.
## Design Guardrails
- Make it feel F1-native and operational, not like a generic SaaS dashboard.
- Avoid card sludge.
- Avoid decorative gradients, blobs, fake hero sections, and meaningless chrome.
- Prefer dense, scan-friendly timing-wall ergonomics.
- Use team colors as structured data accents.
- Do not put cards inside cards.
- Build mobile intentionally; do not just squeeze desktop.
- Use icons where appropriate, but do not overdecorate.
## Backend Guardrails
- Do not rewrite ingestion.
- Do not persist live SignalR data.
- Do not replace all Web endpoints.
- Make only small API tweaks if integration reveals a real contract problem.
- Preserve the existing static Web UI until the React slice is credible.
## Verification
Run the relevant frontend checks you add, plus:
```bash
go build -o /tmp/box-box ./cmd/main.go
```
If dependencies need to be installed, use the repo's package manager choice and
record the commands in your final response.
## Final Response
Report:
- frontend package/files added;
- dev command and URL;
- API endpoint used;
- tests/smoke checks run;
- screenshots or notes about desktop/mobile behavior if available;
- any backend contract issues discovered.

View File

@@ -0,0 +1,76 @@
# Claude Prompt: Phase 6 React Race Hub Analytics
You are continuing the React frontend work for `box-box`.
Phase 5 added the Vite + React + TypeScript app under `frontend/` and built the
first Race Hub route using:
```text
GET /api/v1/race-hub?session_key=...
```
Your task is Phase 6: expand Race Hub into a richer analysis surface while
keeping the design F1-native and operational.
## Read First
Read:
- `frontend/README.md`
- `frontend/src/pages/RaceHubPage.tsx`
- `frontend/src/styles/app.css`
- `frontend/src/types.ts`
- `documentations/refactor/06-visual-design-direction.md`
- `documentations/refactor/13-phase-5-react-race-hub.md`
- `documentations/refactor/14-phase-6-react-race-hub-analytics.md`
- `internal/query/racehub.go`
## Goal
Add a more useful Race Hub interaction model: tabs or segmented views for
classification, grid, strategy, position evolution, and dataset status.
## Required Work
1. Add a compact Race Hub tab/segmented control.
2. Preserve the existing classification and grid views.
3. Add a Dataset Status view that makes available/missing datasets very clear.
4. Add Strategy and Position Evolution views with honest missing states if the
backend does not yet expose enough data.
5. Improve the desktop information hierarchy so the timing table feels more
legible and intentional.
6. Preserve the mobile table fix: active phone columns must fit the viewport.
7. Add or update frontend tests.
## Guardrails
- Do not fake analytics data.
- Do not fetch OpenF1 directly from React.
- Do not start live timing React work.
- Avoid card sludge and generic SaaS dashboard patterns.
- Keep old Web UI behavior intact.
- Make backend changes only if they are tiny API contract fixes.
## Verification
Run:
```bash
cd frontend
npm test -- --run
npm run build
cd ..
go build -o /tmp/box-box ./cmd/main.go
```
Visually check desktop and phone widths.
## Final Response
Report:
- views/components added;
- API datasets used;
- tests/build results;
- desktop/mobile visual notes;
- any backend data needed for real strategy/position charts.

33
frontend/README.md Normal file
View File

@@ -0,0 +1,33 @@
# box-box Web Frontend
React Race Hub slice for the production Web UI.
## Commands
```bash
npm install
npm run dev
npm test -- --run
npm run build
```
The Vite dev server proxies `/api` requests to the Go server on
`http://localhost:8080`.
Run the backend separately:
```bash
go run cmd/main.go --web
```
Then open the React app, usually:
```text
http://localhost:5173/race-hub
```
Load an ingested session with:
```text
http://localhost:5173/race-hub?session_key=9472
```

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>box-box</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4448
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
frontend/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "box-box-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.81.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^25.0.1",
"typescript": "^5.7.2",
"vite": "^6.0.7",
"vitest": "^2.1.8"
}
}

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

21
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"types": ["vitest/globals"]
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

20
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: process.env.PORT ? parseInt(process.env.PORT) : 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
},
})