mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Add React race hub frontend
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -16,3 +16,10 @@ build/
|
||||
|
||||
# Old file cache
|
||||
.cache/
|
||||
|
||||
# Frontend dependencies/build output
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# Local Claude workspace settings
|
||||
.claude/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
33
frontend/README.md
Normal 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
12
frontend/index.html
Normal 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
4448
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/package.json
Normal file
30
frontend/package.json
Normal 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
9
frontend/src/api.ts
Normal 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()
|
||||
}
|
||||
98
frontend/src/components/ClassificationTable.tsx
Normal file
98
frontend/src/components/ClassificationTable.tsx
Normal 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 <key></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>
|
||||
)
|
||||
}
|
||||
35
frontend/src/components/DatasetStrip.tsx
Normal file
35
frontend/src/components/DatasetStrip.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
frontend/src/components/DriverCell.tsx
Normal file
17
frontend/src/components/DriverCell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
frontend/src/components/Nav.tsx
Normal file
16
frontend/src/components/Nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
frontend/src/components/RaceHubHeader.tsx
Normal file
38
frontend/src/components/RaceHubHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
frontend/src/components/StartingGridTable.tsx
Normal file
57
frontend/src/components/StartingGridTable.tsx
Normal 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 <key></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
23
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
111
frontend/src/pages/RaceHubPage.tsx
Normal file
111
frontend/src/pages/RaceHubPage.tsx
Normal 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 <key></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
47
frontend/src/router.tsx
Normal 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
380
frontend/src/styles/app.css
Normal 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; }
|
||||
}
|
||||
131
frontend/src/test/ClassificationTable.test.tsx
Normal file
131
frontend/src/test/ClassificationTable.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
1
frontend/src/test/setup.ts
Normal file
1
frontend/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
115
frontend/src/test/utils.test.ts
Normal file
115
frontend/src/test/utils.test.ts
Normal 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
84
frontend/src/types.ts
Normal 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
66
frontend/src/utils.ts
Normal 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
21
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
20
frontend/vite.config.ts
Normal 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'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user