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
|
# Old file cache
|
||||||
.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.
|
- Build full Command Center, Standings, Drivers, and Settings.
|
||||||
- Improve mobile/iPad live layouts.
|
- Improve mobile/iPad live layouts.
|
||||||
- Add broader Playwright coverage and visual regression checks.
|
- 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.
|
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
|
- [13 Phase 5 React Race Hub](13-phase-5-react-race-hub.md): first frontend
|
||||||
implementation slice for the production Web UI.
|
implementation slice for the production Web UI.
|
||||||
- [Claude Phase 5 Prompt](claude-phase-5-react-race-hub-prompt.md): current
|
- [14 Phase 6 React Race Hub Analytics](14-phase-6-react-race-hub-analytics.md):
|
||||||
handoff prompt for Claude to begin React/frontend work.
|
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
|
## 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