mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Rework Race Hub as weekend workspace
This commit is contained in:
@@ -110,16 +110,16 @@ BOXBOX_API_PORT=18080 npm run dev --prefix frontend
|
|||||||
| Route | Purpose |
|
| Route | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `/` | **Command Center** — fan-facing race-weekend home with GP identity, live status, session schedule, and analysis links |
|
| `/` | **Command Center** — fan-facing race-weekend home with GP identity, live status, session schedule, and analysis links |
|
||||||
| `/race-hub?session_key=<key>` | **Race Hub** — classification, grid, strategy, positions, laps, race control, weather (from local DB) |
|
| `/race-hub?session_key=<key>` | **Race Hub** — weekend workspace with session rail and Overview / Race Story / Strategy / Lap Data / Conditions / Race Control / Data Status tabs. Bare `/race-hub` auto-resolves to the focus session. |
|
||||||
| `/admin` | **Admin / Data Health** — ingestion coverage, local data status, and suggested CLI commands |
|
| `/admin` | **Admin / Data Health** — ingestion coverage, local data status, and suggested CLI commands |
|
||||||
| `/data-library` | Legacy alias for Admin / Data Health |
|
| `/data-library` | Legacy alias for Admin / Data Health |
|
||||||
| `/live` | **Live Timing** — timing tower and race control via SSE when a session is live |
|
| `/live` | **Live Timing** — timing tower and race control via SSE when a session is live |
|
||||||
|
|
||||||
Example after seeding: `http://localhost:5173/race-hub?session_key=9472`
|
Example after seeding: `http://localhost:5173/race-hub?session_key=9472`
|
||||||
|
|
||||||
## Ingest historical data (OpenF1 → domain DB)
|
## Ingest historical data and briefing feeds
|
||||||
|
|
||||||
Ingestion is a **CLI mode** on the same binary. Only one of `--ingest-year`, `--ingest-meeting`, or `--ingest-session` may be set per run.
|
Ingestion is a **CLI mode** on the same binary. Only one of `--ingest-year`, `--ingest-meeting`, `--ingest-session`, or `--ingest-news` may be set per run.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Season: discover and store meeting metadata (2023+)
|
# Season: discover and store meeting metadata (2023+)
|
||||||
@@ -134,6 +134,9 @@ go run ./cmd/main.go --ingest-session 9472
|
|||||||
# Preview without writing
|
# Preview without writing
|
||||||
go run ./cmd/main.go --dry-run --ingest-meeting 1229
|
go run ./cmd/main.go --dry-run --ingest-meeting 1229
|
||||||
|
|
||||||
|
# Refresh Paddock Briefing RSS/Atom feeds
|
||||||
|
go run ./cmd/main.go --ingest-news
|
||||||
|
|
||||||
# Custom DB path (default: ~/.local/share/box-box/boxbox.db)
|
# Custom DB path (default: ~/.local/share/box-box/boxbox.db)
|
||||||
go run ./cmd/main.go --ingest-meeting 1229 --db /tmp/boxbox.db
|
go run ./cmd/main.go --ingest-meeting 1229 --db /tmp/boxbox.db
|
||||||
```
|
```
|
||||||
|
|||||||
128
documentations/refactor/26-phase-19-weekend-workspace.md
Normal file
128
documentations/refactor/26-phase-19-weekend-workspace.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# Phase 19: Weekend Workspace / Race Hub Flow V1
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Rework `/race-hub` from an "admin-style table on top, analysis below" page
|
||||||
|
into a Weekend Workspace that feels like a modern session companion: a
|
||||||
|
compact GP identity band, a horizontal session rail, an Overview snapshot,
|
||||||
|
and fan-oriented analysis tabs. Keep `/race-hub?session_key=…` working,
|
||||||
|
keep ingestion/admin concerns out of the fan surface, and make mobile/iPad
|
||||||
|
first-class.
|
||||||
|
|
||||||
|
## Completed Scope
|
||||||
|
|
||||||
|
- Replaced the legacy Race Hub layout with a Weekend Workspace:
|
||||||
|
- **Topbar**: `box-box · race hub · <year>` eyebrow, weekend source badge,
|
||||||
|
and a `Switch Weekend` toggle that opens an inline switcher panel.
|
||||||
|
- **GP identity band**: country decal, GP name, location · circuit, date
|
||||||
|
range, with a left-edge `--gp-accent` strip derived from
|
||||||
|
`frontend/src/lib/gpIdentity.ts`.
|
||||||
|
- **Session rail**: horizontal segmented strip of session cards (FP1,
|
||||||
|
FP2, FP3, Q, Sprint, R …) showing abbreviation, name, time, source dot,
|
||||||
|
and coverage hint. Switching is a single click; selected chip pulses
|
||||||
|
with the GP accent.
|
||||||
|
- **Active session sub-bar**: compact line with session name, scheduled
|
||||||
|
time, coverage label, and `key <session_key>` for power users.
|
||||||
|
- New tab grouping (`frontend/src/components/TabBar.tsx`):
|
||||||
|
Overview · Race Story · Strategy · Lap Data · Conditions · Race Control ·
|
||||||
|
Data Status. Race Story bundles classification, starting grid, and
|
||||||
|
position evolution behind a sub-segmented control so the operational
|
||||||
|
feel is preserved without exploding the top-level tab list.
|
||||||
|
- New **Overview** tab (`components/OverviewView.tsx`): operational stat
|
||||||
|
cards (Winner / Pole / Fastest Lap / Podium) plus compact panels for
|
||||||
|
Conditions, latest Race Control messages, and a Local Coverage meter
|
||||||
|
that links to the relevant Data Status tab.
|
||||||
|
- Inline **Weekend Switcher** (`components/WeekendSwitcher.tsx`): season
|
||||||
|
tabs, meeting cards with country decals, and expandable per-meeting
|
||||||
|
session lists that navigate via `useNavigate`. Replaces the old fullscreen
|
||||||
|
`LocalDataNavigator` table on the Race Hub surface.
|
||||||
|
- Auto-resolution when `/race-hub` is opened without `session_key`: the
|
||||||
|
page resolves the focus weekend via the same `pickFocusMeeting` helper
|
||||||
|
Command Center uses and `navigate(replace: true)` to the focus
|
||||||
|
session (race → qualifying → first local session).
|
||||||
|
- **Data Status** tab now points at `/admin` for missing datasets instead of
|
||||||
|
inlining CLI commands. Admin remains the home for ingestion guidance.
|
||||||
|
- GP accent is plumbed through CSS custom property `--gp-accent`, used by
|
||||||
|
session chips, story sub-control underline, overview stat cards, and the
|
||||||
|
topbar `Switch Weekend` border.
|
||||||
|
|
||||||
|
## Route Behavior
|
||||||
|
|
||||||
|
- `/race-hub?session_key=9472` — unchanged contract; loads the workspace
|
||||||
|
for that session and opens Overview by default.
|
||||||
|
- `/race-hub` (no key) — resolves locally via `fetchSeasons` →
|
||||||
|
`fetchLocalMeetings` → `pickFocusMeeting` → `fetchWeekend`, then
|
||||||
|
`navigate({ replace: true })` to the focus session's race/qualifying.
|
||||||
|
- `/data-library` and `/admin` remain untouched.
|
||||||
|
|
||||||
|
## What Did Not Change
|
||||||
|
|
||||||
|
- Backend APIs (`/api/v1/race-hub`, `/api/v1/seasons`, `/api/v1/meetings`,
|
||||||
|
`/api/v1/weekend`).
|
||||||
|
- Live SignalR bridge, `/live` page, TUI live mode.
|
||||||
|
- Command Center, Admin / Data Health flows.
|
||||||
|
- Existing chart and table components (`ClassificationTable`,
|
||||||
|
`StartingGridTable`, `StrategyView`, `PositionEvolutionView`, `LapsView`,
|
||||||
|
`RaceControlView`, `WeatherView`) are reused inside the new shell.
|
||||||
|
- The legacy `LocalDataNavigator` component is kept (still unit-tested) so
|
||||||
|
any future surfaces can reuse it, but it is no longer mounted on
|
||||||
|
`/race-hub`.
|
||||||
|
|
||||||
|
## Tests and Visual Coverage
|
||||||
|
|
||||||
|
Updated:
|
||||||
|
|
||||||
|
- `frontend/src/test/TabBar.test.tsx` — new tab list (Overview / Race Story
|
||||||
|
/ Strategy / Lap Data / Conditions / Race Control / Data Status).
|
||||||
|
- `frontend/src/test/DatasetStatusView.test.tsx` — rewritten against the
|
||||||
|
fan-facing dataset list (11/11), the new `Manage ingestion → /admin`
|
||||||
|
link, and the removal of inline CLI hints.
|
||||||
|
- `frontend/src/test/RaceHubPage.test.tsx` — new test file covering the
|
||||||
|
identity band, session rail, Race Story sub-controls, Data Status admin
|
||||||
|
link, and the inline weekend switcher.
|
||||||
|
- `tests/race-hub.spec.ts` — rewritten E2E spec covering Overview default,
|
||||||
|
Race Story sub-views, Strategy and Positions missing-data notices,
|
||||||
|
weekend switcher toggle, Data Status admin link, and the bare
|
||||||
|
`/race-hub` redirect.
|
||||||
|
- `tests/command-center.spec.ts`, `tests/data-library.spec.ts`,
|
||||||
|
`tests/production-smoke.spec.ts` — updated assertions to land on the
|
||||||
|
new workspace shell rather than the old "Final Classification" headline.
|
||||||
|
- `tests/visual/helpers.ts` — `gotoRaceHubReady` now waits for
|
||||||
|
`race-hub` + `rh-identity` + `rh-session-<key>` + `rh-overview`.
|
||||||
|
- `tests/visual/__snapshots__/{desktop,tablet,mobile}/race-hub.png` —
|
||||||
|
regenerated. Command Center, Admin, and Live snapshots untouched.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --prefix frontend test -- --run # 98 unit tests pass
|
||||||
|
npm --prefix frontend run build # tsc + vite, clean
|
||||||
|
npm run test:e2e # 18 E2E pass
|
||||||
|
npm run test:e2e:prod # 6 prod smoke pass
|
||||||
|
npm run test:visual # 12 baseline pass after regen
|
||||||
|
npm run test:visual:prod # 12 prod baseline pass after regen
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limitations and Follow-ups
|
||||||
|
|
||||||
|
- The Race Story tab keeps three legacy datasets behind a sub-segmented
|
||||||
|
control. A future pass could merge classification + position evolution
|
||||||
|
into a single scrollable "story" canvas.
|
||||||
|
- Overview's "Fastest Lap" picks the minimum non-pit-out `lap_duration` from
|
||||||
|
the ingested laps payload. Sessions that don't ingest laps show "No data
|
||||||
|
ingested" — accurate, but a future phase could fall back to OpenF1's
|
||||||
|
`fastest_lap` field if/when that lands locally.
|
||||||
|
- The inline weekend switcher fetches the active meeting's `/weekend`
|
||||||
|
payload only when expanded. Switching seasons or browsing many
|
||||||
|
meetings does not pre-warm sibling weekend queries; this is intentional
|
||||||
|
to avoid the N×weekend fan-out that Command Center already pays.
|
||||||
|
- Visual baselines are regenerated against the current seeded e2e DB. If
|
||||||
|
the seeded session list grows, the desktop snapshot will widen.
|
||||||
|
- Country accents in `gpIdentity.ts` remain a hand-tuned subset; unknown
|
||||||
|
codes fall back to a neutral gray (same behavior as Command Center).
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [21 MVP Completion Checklist](21-mvp-completion-checklist.md)
|
||||||
|
- [22 Phase 14 Visual Regression](22-phase-14-visual-regression.md)
|
||||||
|
- [25 Phase 18 Fan Command Center](25-phase-18-fan-command-center.md)
|
||||||
@@ -62,24 +62,22 @@ Known test note: do not run Playwright suites that share the same seeded SQLite
|
|||||||
|
|
||||||
## Recent Commits
|
## Recent Commits
|
||||||
|
|
||||||
- `571edb9 Add visual regression coverage`
|
|
||||||
- `e539abc Add command center screen`
|
|
||||||
- `9532206 Polish live timing UI`
|
|
||||||
- `a0f135a Update operator documentation`
|
- `a0f135a Update operator documentation`
|
||||||
- `79b0b9f Rework command center for race weekends`
|
- `79b0b9f Rework command center for race weekends`
|
||||||
- `84a8827 Add paddock briefing RSS backend spike`
|
- `84a8827 Add paddock briefing RSS backend spike`
|
||||||
|
- `ee88a07 Add paddock briefing feed ingestion`
|
||||||
|
- `Rework Race Hub as weekend workspace` (latest Phase 19 commit)
|
||||||
|
|
||||||
## Current Uncommitted Work
|
## Current State
|
||||||
|
|
||||||
Two phases are currently uncommitted and need review/integration. Review and
|
Phase 19 and Phase 20 have been reviewed and committed. Start new work from a
|
||||||
commit Phase 19 first, then Phase 20, or stage hunks carefully if separating the
|
clean tree unless `git status --short` shows user changes made after this
|
||||||
shared README/refactor README edits.
|
handoff.
|
||||||
|
|
||||||
### Phase 19: Weekend Workspace / Race Hub Flow V1
|
## Completed Phase 19: Weekend Workspace / Race Hub Flow V1
|
||||||
|
|
||||||
Claude has completed Phase 19. It is currently uncommitted and needs review.
|
Claude implemented, and Codex reviewed/committed, a Race Hub redesign that turns
|
||||||
|
`/race-hub` into a Weekend Workspace.
|
||||||
Claude reported these Phase 19 changes:
|
|
||||||
|
|
||||||
- New:
|
- New:
|
||||||
- `frontend/src/components/OverviewView.tsx`
|
- `frontend/src/components/OverviewView.tsx`
|
||||||
@@ -97,7 +95,7 @@ Claude reported these Phase 19 changes:
|
|||||||
- root `README.md`
|
- root `README.md`
|
||||||
- `documentations/refactor/README.md`
|
- `documentations/refactor/README.md`
|
||||||
|
|
||||||
Claude reported these UX changes:
|
UX changes:
|
||||||
|
|
||||||
- Race Hub is now a Weekend Workspace.
|
- Race Hub is now a Weekend Workspace.
|
||||||
- Compact GP identity band with country decal/accent strip.
|
- Compact GP identity band with country decal/accent strip.
|
||||||
@@ -111,32 +109,34 @@ Claude reported these UX changes:
|
|||||||
- `/race-hub?session_key=9472` still works and loads Bahrain GP 2024 seeded session.
|
- `/race-hub?session_key=9472` still works and loads Bahrain GP 2024 seeded session.
|
||||||
- Bare `/race-hub` now resolves to a focus weekend/session via `pickFocusMeeting` and navigation replace.
|
- Bare `/race-hub` now resolves to a focus weekend/session via `pickFocusMeeting` and navigation replace.
|
||||||
|
|
||||||
Claude reported these tests:
|
Verification run by Codex before commit:
|
||||||
|
|
||||||
- `npm --prefix frontend test -- --run` passed, 98 tests.
|
- `npm --prefix frontend test -- --run`
|
||||||
- `npm --prefix frontend run build` passed.
|
- `npm --prefix frontend run build`
|
||||||
- `npm run test:e2e` passed, 18 tests.
|
- `npm run test:e2e`
|
||||||
- `npm run test:e2e:prod` passed, 6 tests.
|
- `npm run test:e2e:prod`
|
||||||
- `npm run test:visual` passed, 12 screenshots after regenerating race-hub baselines.
|
- `npm run test:visual`
|
||||||
- `npm run test:visual:prod` passed, 12 screenshots.
|
- `npm run test:visual:prod`
|
||||||
|
|
||||||
### Phase 20: Paddock Briefing Ingestion CLI
|
Small review fix included: `frontend/src/test/setup.ts` stubs
|
||||||
|
`window.scrollTo` so TanStack Router scroll restoration does not spam jsdom test
|
||||||
|
stderr.
|
||||||
|
|
||||||
A backend subagent implemented Phase 20 after the RSS backend spike. It is also
|
## Completed Phase 20: Paddock Briefing Ingestion CLI
|
||||||
currently uncommitted and needs review.
|
|
||||||
|
|
||||||
Reported Phase 20 changes:
|
A backend subagent implemented, and Codex reviewed/committed, Phase 20 after the
|
||||||
|
RSS backend spike.
|
||||||
|
|
||||||
|
Phase 20 changes:
|
||||||
|
|
||||||
- Modified:
|
- Modified:
|
||||||
- `cmd/main.go`
|
- `cmd/main.go`
|
||||||
- `README.md`
|
|
||||||
- `documentations/refactor/README.md`
|
|
||||||
- New:
|
- New:
|
||||||
- `internal/news/refresh.go`
|
- `internal/news/refresh.go`
|
||||||
- `internal/news/refresh_test.go`
|
- `internal/news/refresh_test.go`
|
||||||
- `documentations/refactor/29-phase-20-paddock-briefing-ingestion.md`
|
- `documentations/refactor/29-phase-20-paddock-briefing-ingestion.md`
|
||||||
|
|
||||||
Implemented behavior:
|
Behavior:
|
||||||
|
|
||||||
- Adds `--ingest-news` as a CLI mode.
|
- Adds `--ingest-news` as a CLI mode.
|
||||||
- Keeps it mutually exclusive with `--ingest-year`, `--ingest-meeting`, and
|
- Keeps it mutually exclusive with `--ingest-year`, `--ingest-meeting`, and
|
||||||
@@ -159,81 +159,24 @@ go run ./cmd/main.go --dry-run --ingest-news
|
|||||||
go run ./cmd/main.go --ingest-news --db /tmp/boxbox.db
|
go run ./cmd/main.go --ingest-news --db /tmp/boxbox.db
|
||||||
```
|
```
|
||||||
|
|
||||||
Phase 20 verification already run by Codex:
|
Verification run by Codex before commit:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go test ./cmd/... ./internal/news ./internal/store
|
go test ./cmd/... ./internal/news ./internal/store
|
||||||
go test ./internal/web ./internal/query
|
|
||||||
go test ./...
|
|
||||||
git diff --check
|
git diff --check
|
||||||
```
|
```
|
||||||
|
|
||||||
## Immediate Task
|
## Immediate Task
|
||||||
|
|
||||||
Start by reviewing and committing Phase 19. Then review and commit Phase 20.
|
Start with a quick sync:
|
||||||
Do not start new implementation until both are accepted and committed.
|
|
||||||
|
|
||||||
1. Inspect working tree:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git status --short
|
git status --short
|
||||||
git diff --stat
|
git diff --stat
|
||||||
git diff --name-only
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Review Claude’s Phase 19 work quickly but responsibly:
|
Then continue with the next requested phase. The most natural next phase is
|
||||||
|
Phase 21: Paddock Briefing UI, unless the user wants to deepen Race Story first.
|
||||||
- Check `RaceHubPage.tsx`, `OverviewView.tsx`, `WeekendSwitcher.tsx`, `TabBar.tsx`, `DatasetStatusView.tsx`, `app.css`, route/test updates, docs.
|
|
||||||
- Make sure no admin/CLI guidance leaked back into Race Hub.
|
|
||||||
- Make sure `/race-hub?session_key=9472` compatibility is preserved.
|
|
||||||
- Make sure `/admin` remains the admin/data-health surface.
|
|
||||||
- Confirm visual tests and docs match the changed UX.
|
|
||||||
|
|
||||||
3. Run a focused verification pass. At minimum:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm --prefix frontend test -- --run
|
|
||||||
npm --prefix frontend run build
|
|
||||||
npm run test:e2e
|
|
||||||
npm run test:visual
|
|
||||||
```
|
|
||||||
|
|
||||||
If time allows or if production behavior changed:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run test:e2e:prod
|
|
||||||
npm run test:visual:prod
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Patch only small issues if found.
|
|
||||||
5. Stage only Phase 19 files.
|
|
||||||
6. Commit with a message like:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git commit -m "Rework Race Hub as weekend workspace"
|
|
||||||
```
|
|
||||||
|
|
||||||
Then review Phase 20:
|
|
||||||
|
|
||||||
1. Check `cmd/main.go`, `internal/news/refresh.go`,
|
|
||||||
`internal/news/refresh_test.go`,
|
|
||||||
`documentations/refactor/29-phase-20-paddock-briefing-ingestion.md`, and the
|
|
||||||
README/refactor README hunks.
|
|
||||||
2. Confirm the CLI mode does not interfere with OpenF1 ingestion modes or web/TUI
|
|
||||||
startup.
|
|
||||||
3. Confirm no live internet tests were added.
|
|
||||||
4. Re-run targeted backend tests if needed:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./cmd/... ./internal/news ./internal/store
|
|
||||||
go test ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Stage Phase 20 files/hunks and commit with a message like:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git commit -m "Add paddock briefing feed ingestion"
|
|
||||||
```
|
|
||||||
|
|
||||||
## RSS / Paddock Briefing Context
|
## RSS / Paddock Briefing Context
|
||||||
|
|
||||||
|
|||||||
@@ -92,9 +92,15 @@ not implementation tickets yet.
|
|||||||
- [25 Phase 18 Fan Command Center](25-phase-18-fan-command-center.md): reworks
|
- [25 Phase 18 Fan Command Center](25-phase-18-fan-command-center.md): reworks
|
||||||
`/` around race-weekend identity and splits ingestion/admin concerns into
|
`/` around race-weekend identity and splits ingestion/admin concerns into
|
||||||
the new `/admin` (Data Health) route.
|
the new `/admin` (Data Health) route.
|
||||||
|
- [26 Phase 19 Weekend Workspace](26-phase-19-weekend-workspace.md): rebuilds
|
||||||
|
`/race-hub` as a session-card-rail workspace with Overview / Race Story /
|
||||||
|
Strategy / Lap Data / Conditions / Race Control / Data Status tabs,
|
||||||
|
an inline weekend switcher, and GP-accent identity treatment.
|
||||||
- [27 Phase 19B Paddock Briefing RSS](27-phase-19b-paddock-briefing-rss.md):
|
- [27 Phase 19B Paddock Briefing RSS](27-phase-19b-paddock-briefing-rss.md):
|
||||||
backend spike for publisher-owned RSS/Atom feeds, local SQLite caching, and a
|
backend spike for publisher-owned RSS/Atom feeds, local SQLite caching, and a
|
||||||
future fan-facing briefing API.
|
future fan-facing briefing API.
|
||||||
|
- [29 Phase 20 Paddock Briefing Ingestion](29-phase-20-paddock-briefing-ingestion.md):
|
||||||
|
backend CLI slice for refreshing RSS/Atom feeds into the local news cache.
|
||||||
|
|
||||||
## External References
|
## External References
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,54 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { RACE_HUB_DATASETS } from '../lib/coverage'
|
||||||
import type { DatasetInfo } from '../types'
|
import type { DatasetInfo } from '../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
datasets: Record<string, DatasetInfo>
|
datasets: Record<string, DatasetInfo>
|
||||||
}
|
}
|
||||||
|
|
||||||
const KNOWN_DATASETS: { key: string; label: string }[] = [
|
const DATASET_LABELS: Record<string, string> = {
|
||||||
{ key: 'meeting', label: 'Meeting' },
|
meeting: 'Meeting',
|
||||||
{ key: 'session', label: 'Session' },
|
session: 'Session',
|
||||||
{ key: 'drivers', label: 'Drivers' },
|
drivers: 'Drivers',
|
||||||
{ key: 'results', label: 'Results' },
|
results: 'Results',
|
||||||
{ key: 'starting_grid', label: 'Starting Grid' },
|
starting_grid: 'Starting Grid',
|
||||||
]
|
stints: 'Stints',
|
||||||
|
pit_stops: 'Pit Stops',
|
||||||
|
positions: 'Positions',
|
||||||
|
race_control: 'Race Control',
|
||||||
|
weather: 'Weather',
|
||||||
|
laps: 'Laps',
|
||||||
|
}
|
||||||
|
|
||||||
export function DatasetStatusView({ datasets }: Props) {
|
export function DatasetStatusView({ datasets }: Props) {
|
||||||
const entries = KNOWN_DATASETS.map(({ key, label }) => ({
|
const entries = RACE_HUB_DATASETS.map((key) => ({
|
||||||
key,
|
key,
|
||||||
label,
|
label: DATASET_LABELS[key] ?? key,
|
||||||
info: datasets[key] as DatasetInfo | undefined,
|
info: datasets[key] as DatasetInfo | undefined,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const available = entries.filter((e) => e.info?.status === 'available').length
|
const available = entries.filter((e) => e.info?.status === 'available').length
|
||||||
const total = entries.length
|
const total = entries.length
|
||||||
|
const missing = total - available
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div data-testid="rh-data-status">
|
||||||
|
<div className="rh-coverage-meter" aria-hidden="true">
|
||||||
|
<div
|
||||||
|
className="rh-coverage-fill"
|
||||||
|
style={{ width: `${(available / total) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="ds-legend">
|
<div className="ds-legend">
|
||||||
<span>
|
<span className="mono" style={{ color: 'var(--text-2)' }}>
|
||||||
{available}/{total} datasets available locally
|
{available}/{total} datasets local
|
||||||
</span>
|
</span>
|
||||||
{available < total && (
|
{missing > 0 && (
|
||||||
<span>
|
<span style={{ color: 'var(--text-3)' }}>
|
||||||
Re-run <code>box-box --ingest-session <key></code> after backend
|
{missing} dataset{missing === 1 ? '' : 's'} still missing —{' '}
|
||||||
support exists for missing datasets.
|
<Link to="/admin" className="rh-inline-link">
|
||||||
|
manage ingestion
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
281
frontend/src/components/OverviewView.tsx
Normal file
281
frontend/src/components/OverviewView.tsx
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import type { RaceHub } from '../types'
|
||||||
|
import { compareFinishPosition, formatDuration, formatGap, formatLapTime } from '../utils'
|
||||||
|
import { countRaceHubDatasets } from '../lib/coverage'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: RaceHub
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverviewView({ data }: Props) {
|
||||||
|
const sortedResults = [...data.results].sort((a, b) =>
|
||||||
|
compareFinishPosition(a.position, b.position),
|
||||||
|
)
|
||||||
|
const winner = sortedResults[0]
|
||||||
|
const podium = sortedResults.filter((r) => r.position > 0).slice(0, 3)
|
||||||
|
const pole = data.starting_grid.find((g) => g.position === 1)
|
||||||
|
const fastest = pickFastestLap(data)
|
||||||
|
const latestWeather = data.weather.length > 0 ? data.weather[data.weather.length - 1] : null
|
||||||
|
const rcHighlights = data.race_control.slice(-3).reverse()
|
||||||
|
const coverage = countRaceHubDatasets(data.datasets)
|
||||||
|
|
||||||
|
const sessionType = (data.session?.session_type ?? '').toLowerCase()
|
||||||
|
const isRace = sessionType.includes('race')
|
||||||
|
const sessionLabel = isRace ? 'Race' : data.session?.session_type ?? 'Session'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rh-overview" data-testid="rh-overview">
|
||||||
|
<div className="rh-stat-grid">
|
||||||
|
{winner && winner.position > 0 ? (
|
||||||
|
<StatCard
|
||||||
|
label={isRace ? 'Winner' : `${sessionLabel} P1`}
|
||||||
|
primary={winner.name_acronym || `#${winner.driver_number}`}
|
||||||
|
primaryColor={winner.team_colour ? `#${winner.team_colour}` : undefined}
|
||||||
|
secondary={winner.full_name}
|
||||||
|
tertiary={winner.team_name}
|
||||||
|
highlight={
|
||||||
|
isRace
|
||||||
|
? formatDuration(winner.duration)
|
||||||
|
: winner.duration
|
||||||
|
? formatDuration(winner.duration)
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StatCard label={isRace ? 'Winner' : `${sessionLabel} P1`} placeholder />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PodiumCard podium={podium} />
|
||||||
|
|
||||||
|
{pole ? (
|
||||||
|
<StatCard
|
||||||
|
label={isRace ? 'Pole' : 'P1'}
|
||||||
|
primary={pole.name_acronym || `#${pole.driver_number}`}
|
||||||
|
primaryColor={pole.team_colour ? `#${pole.team_colour}` : undefined}
|
||||||
|
secondary={pole.full_name}
|
||||||
|
tertiary={pole.team_name}
|
||||||
|
highlight={pole.lap_duration ? formatLapTime(pole.lap_duration) : ''}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StatCard label={isRace ? 'Pole' : 'Grid'} placeholder />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fastest ? (
|
||||||
|
<StatCard
|
||||||
|
label="Fastest Lap"
|
||||||
|
primary={fastest.acronym}
|
||||||
|
primaryColor={fastest.colour ? `#${fastest.colour}` : undefined}
|
||||||
|
secondary={fastest.fullName}
|
||||||
|
tertiary={`Lap ${fastest.lap}`}
|
||||||
|
highlight={formatLapTime(fastest.time)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StatCard label="Fastest Lap" placeholder />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rh-overview-row">
|
||||||
|
<section className="rh-panel">
|
||||||
|
<div className="sec-header">
|
||||||
|
<span className="sec-title">Conditions</span>
|
||||||
|
{latestWeather && (
|
||||||
|
<span className="sec-meta mono">{shortTime(latestWeather.date)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{latestWeather ? (
|
||||||
|
<div className="rh-condition-strip" data-testid="rh-conditions">
|
||||||
|
<ConditionChip label="Air" value={`${latestWeather.air_temperature.toFixed(1)}°C`} />
|
||||||
|
<ConditionChip
|
||||||
|
label="Track"
|
||||||
|
value={`${latestWeather.track_temperature.toFixed(1)}°C`}
|
||||||
|
/>
|
||||||
|
<ConditionChip label="Humidity" value={`${latestWeather.humidity.toFixed(0)}%`} />
|
||||||
|
<ConditionChip
|
||||||
|
label="Wind"
|
||||||
|
value={`${latestWeather.wind_speed.toFixed(1)} m/s`}
|
||||||
|
/>
|
||||||
|
<ConditionChip
|
||||||
|
label="Rain"
|
||||||
|
value={latestWeather.rainfall > 0 ? 'Yes' : 'No'}
|
||||||
|
accent={latestWeather.rainfall > 0 ? 'wet' : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rh-empty-line">No weather samples ingested.</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rh-panel">
|
||||||
|
<div className="sec-header">
|
||||||
|
<span className="sec-title">Race Control · Latest</span>
|
||||||
|
<span className="sec-meta mono">{data.race_control.length}</span>
|
||||||
|
</div>
|
||||||
|
{rcHighlights.length === 0 ? (
|
||||||
|
<div className="rh-empty-line">No race-control messages.</div>
|
||||||
|
) : (
|
||||||
|
<ul className="rh-rc-list">
|
||||||
|
{rcHighlights.map((m, i) => (
|
||||||
|
<li key={i} className="rh-rc-row">
|
||||||
|
<span className="rh-rc-time mono">{shortTime(m.date)}</span>
|
||||||
|
<span className={`rh-rc-flag rh-rc-flag-${(m.flag || 'none').toLowerCase()}`}>
|
||||||
|
{m.flag || m.category || '—'}
|
||||||
|
</span>
|
||||||
|
<span className="rh-rc-msg">{m.message}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rh-panel">
|
||||||
|
<div className="sec-header">
|
||||||
|
<span className="sec-title">Local Coverage</span>
|
||||||
|
<span className="sec-meta mono">
|
||||||
|
{coverage.available}/{coverage.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="rh-coverage-meter" aria-hidden="true">
|
||||||
|
<div
|
||||||
|
className="rh-coverage-fill"
|
||||||
|
style={{ width: `${(coverage.available / coverage.total) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="rh-empty-line" style={{ marginTop: 'var(--s2)' }}>
|
||||||
|
{coverage.available === coverage.total
|
||||||
|
? 'Every Race Hub dataset is local for this session.'
|
||||||
|
: `${coverage.total - coverage.available} dataset${
|
||||||
|
coverage.total - coverage.available === 1 ? '' : 's'
|
||||||
|
} not ingested yet — see Data Status tab.`}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
label: string
|
||||||
|
primary?: string
|
||||||
|
primaryColor?: string
|
||||||
|
secondary?: string
|
||||||
|
tertiary?: string
|
||||||
|
highlight?: string
|
||||||
|
placeholder?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
primary,
|
||||||
|
primaryColor,
|
||||||
|
secondary,
|
||||||
|
tertiary,
|
||||||
|
highlight,
|
||||||
|
placeholder,
|
||||||
|
}: StatCardProps) {
|
||||||
|
if (placeholder) {
|
||||||
|
return (
|
||||||
|
<div className="rh-stat-card rh-stat-empty">
|
||||||
|
<div className="rh-stat-label mono">{label}</div>
|
||||||
|
<div className="rh-stat-primary">—</div>
|
||||||
|
<div className="rh-stat-secondary">No data ingested</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="rh-stat-card">
|
||||||
|
<div className="rh-stat-label mono">{label}</div>
|
||||||
|
<div className="rh-stat-primary" style={primaryColor ? { color: primaryColor } : undefined}>
|
||||||
|
{primary}
|
||||||
|
</div>
|
||||||
|
{secondary && <div className="rh-stat-secondary">{secondary}</div>}
|
||||||
|
{tertiary && <div className="rh-stat-tertiary">{tertiary}</div>}
|
||||||
|
{highlight && <div className="rh-stat-highlight mono">{highlight}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PodiumCard({ podium }: { podium: Array<{ name_acronym: string; team_colour: string; position: number; full_name: string; gap_to_leader: number | string | number[] | null; duration: number | number[] | null; driver_number: number }> }) {
|
||||||
|
if (podium.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rh-stat-card rh-stat-empty">
|
||||||
|
<div className="rh-stat-label mono">Podium</div>
|
||||||
|
<div className="rh-stat-primary">—</div>
|
||||||
|
<div className="rh-stat-secondary">No classified finishers</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="rh-stat-card">
|
||||||
|
<div className="rh-stat-label mono">Podium</div>
|
||||||
|
<ol className="rh-podium-list">
|
||||||
|
{podium.map((r) => (
|
||||||
|
<li key={r.driver_number} className={`rh-podium-row rh-podium-p${r.position}`}>
|
||||||
|
<span className="rh-podium-pos mono">P{r.position}</span>
|
||||||
|
<span
|
||||||
|
className="rh-podium-driver"
|
||||||
|
style={r.team_colour ? { color: `#${r.team_colour}` } : undefined}
|
||||||
|
>
|
||||||
|
{r.name_acronym || `#${r.driver_number}`}
|
||||||
|
</span>
|
||||||
|
<span className="rh-podium-gap mono">
|
||||||
|
{r.position === 1
|
||||||
|
? formatDuration(r.duration)
|
||||||
|
: formatGap(r.gap_to_leader)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConditionChip({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
accent?: 'wet'
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`rh-condition-chip${accent === 'wet' ? ' rh-condition-wet' : ''}`}>
|
||||||
|
<span className="rh-condition-label mono">{label}</span>
|
||||||
|
<span className="rh-condition-value">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortTime(iso: string): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (Number.isNaN(d.getTime())) return iso.slice(11, 16)
|
||||||
|
return d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFastestLap(
|
||||||
|
data: RaceHub,
|
||||||
|
): { lap: number; time: number; acronym: string; fullName: string; colour: string } | null {
|
||||||
|
const candidates = data.laps.filter(
|
||||||
|
(l) => l.lap_duration != null && l.lap_duration > 0 && !l.is_pit_out_lap,
|
||||||
|
)
|
||||||
|
if (candidates.length === 0) return null
|
||||||
|
let best = candidates[0]
|
||||||
|
for (const lap of candidates) {
|
||||||
|
if ((lap.lap_duration ?? 0) < (best.lap_duration ?? Infinity)) {
|
||||||
|
best = lap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const driverInfo = data.results.find((r) => r.driver_number === best.driver_number)
|
||||||
|
?? data.drivers.find((d) => d.driver_number === best.driver_number)
|
||||||
|
return {
|
||||||
|
lap: best.lap_number,
|
||||||
|
time: best.lap_duration ?? 0,
|
||||||
|
acronym:
|
||||||
|
('name_acronym' in (driverInfo ?? {}) ? (driverInfo as { name_acronym: string }).name_acronym : '')
|
||||||
|
|| `#${best.driver_number}`,
|
||||||
|
fullName:
|
||||||
|
('full_name' in (driverInfo ?? {}) ? (driverInfo as { full_name: string }).full_name : '') || '',
|
||||||
|
colour:
|
||||||
|
('team_colour' in (driverInfo ?? {}) ? (driverInfo as { team_colour: string }).team_colour : '') || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,20 @@
|
|||||||
export type Tab =
|
export type Tab =
|
||||||
| 'results'
|
| 'overview'
|
||||||
| 'grid'
|
| 'race_story'
|
||||||
| 'strategy'
|
| 'strategy'
|
||||||
| 'positions'
|
| 'lap_data'
|
||||||
| 'laps'
|
| 'conditions'
|
||||||
| 'race_control'
|
| 'race_control'
|
||||||
| 'weather'
|
| 'data_status'
|
||||||
| 'datasets'
|
|
||||||
|
|
||||||
const TABS: { id: Tab; label: string }[] = [
|
const TABS: { id: Tab; label: string }[] = [
|
||||||
{ id: 'results', label: 'Results' },
|
{ id: 'overview', label: 'Overview' },
|
||||||
{ id: 'grid', label: 'Grid' },
|
{ id: 'race_story', label: 'Race Story' },
|
||||||
{ id: 'strategy', label: 'Strategy' },
|
{ id: 'strategy', label: 'Strategy' },
|
||||||
{ id: 'positions', label: 'Positions' },
|
{ id: 'lap_data', label: 'Lap Data' },
|
||||||
{ id: 'laps', label: 'Laps' },
|
{ id: 'conditions', label: 'Conditions' },
|
||||||
{ id: 'race_control', label: 'Race Control' },
|
{ id: 'race_control', label: 'Race Control' },
|
||||||
{ id: 'weather', label: 'Weather' },
|
{ id: 'data_status', label: 'Data Status' },
|
||||||
{ id: 'datasets', label: 'Datasets' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
140
frontend/src/components/WeekendSwitcher.tsx
Normal file
140
frontend/src/components/WeekendSwitcher.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
|
||||||
|
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
|
||||||
|
import { countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentMeetingKey?: number
|
||||||
|
currentSessionKey?: number
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose }: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons })
|
||||||
|
const [year, setYear] = useState<number | null>(null)
|
||||||
|
const [openMeetingKey, setOpenMeetingKey] = useState<number | null>(
|
||||||
|
currentMeetingKey ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (year == null && seasonsQuery.data?.length) {
|
||||||
|
setYear(seasonsQuery.data[0])
|
||||||
|
}
|
||||||
|
}, [seasonsQuery.data, year])
|
||||||
|
|
||||||
|
const meetingsQuery = useQuery({
|
||||||
|
queryKey: ['meetings', year],
|
||||||
|
queryFn: () => fetchLocalMeetings(year!),
|
||||||
|
enabled: year != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const weekendQuery = useQuery({
|
||||||
|
queryKey: ['weekend', openMeetingKey],
|
||||||
|
queryFn: () => fetchWeekend(openMeetingKey!),
|
||||||
|
enabled: openMeetingKey != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const seasons = seasonsQuery.data ?? []
|
||||||
|
const meetings = meetingsQuery.data ?? []
|
||||||
|
const weekend = weekendQuery.data
|
||||||
|
|
||||||
|
function openSession(sessionKey: number) {
|
||||||
|
navigate({ to: '/race-hub', search: { session_key: sessionKey } })
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rh-switcher" data-testid="rh-switcher">
|
||||||
|
<div className="rh-switcher-head">
|
||||||
|
<span className="sec-title">Switch Weekend</span>
|
||||||
|
<div className="rh-switcher-years">
|
||||||
|
{seasons.map((y) => (
|
||||||
|
<button
|
||||||
|
key={y}
|
||||||
|
type="button"
|
||||||
|
className={`rh-switcher-year${y === year ? ' active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setYear(y)
|
||||||
|
setOpenMeetingKey(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{y}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="rh-switcher-close" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{meetingsQuery.isLoading && (
|
||||||
|
<div className="rh-switcher-empty">loading meetings…</div>
|
||||||
|
)}
|
||||||
|
{!meetingsQuery.isLoading && meetings.length === 0 && (
|
||||||
|
<div className="rh-switcher-empty">No meetings ingested for {year}.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{meetings.length > 0 && (
|
||||||
|
<div className="rh-switcher-grid">
|
||||||
|
{meetings.map((m) => {
|
||||||
|
const expanded = m.meeting_key === openMeetingKey
|
||||||
|
const isCurrent = m.meeting_key === currentMeetingKey
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={m.meeting_key}
|
||||||
|
className={`rh-switcher-mtg${expanded ? ' expanded' : ''}${isCurrent ? ' current' : ''}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rh-switcher-mtg-head"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={() =>
|
||||||
|
setOpenMeetingKey((prev) => (prev === m.meeting_key ? null : m.meeting_key))
|
||||||
|
}
|
||||||
|
data-testid={`rh-switcher-meeting-${m.meeting_key}`}
|
||||||
|
>
|
||||||
|
<span className="rh-switcher-decal mono">{countryDecal(m)}</span>
|
||||||
|
<span className="rh-switcher-mtg-name">{m.meeting_name}</span>
|
||||||
|
<span className="rh-switcher-mtg-meta mono">{formatGpDateRange(m)}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="rh-switcher-sessions">
|
||||||
|
{weekendQuery.isLoading && (
|
||||||
|
<div className="rh-switcher-empty">loading sessions…</div>
|
||||||
|
)}
|
||||||
|
{weekend && weekend.meeting_key === m.meeting_key &&
|
||||||
|
weekend.sessions.map(({ session, source, datasets }) => {
|
||||||
|
const active = session.session_key === currentSessionKey
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={session.session_key}
|
||||||
|
type="button"
|
||||||
|
className={`rh-switcher-session${active ? ' active' : ''}`}
|
||||||
|
onClick={() => openSession(session.session_key)}
|
||||||
|
data-testid={`rh-switcher-session-${session.session_key}`}
|
||||||
|
>
|
||||||
|
<span className="rh-switcher-sess-abbrev mono">
|
||||||
|
{sessionTypeAbbrev(session.session_type, session.session_name)}
|
||||||
|
</span>
|
||||||
|
<span className="rh-switcher-sess-name">{session.session_name}</span>
|
||||||
|
<span className="rh-switcher-sess-cov mono">
|
||||||
|
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
|
||||||
|
{formatCoverageHint(datasets)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { fetchRaceHub } from '../api'
|
import {
|
||||||
import { LocalDataNavigator } from '../components/LocalDataNavigator'
|
fetchLocalMeetings,
|
||||||
import { RaceHubHeader } from '../components/RaceHubHeader'
|
fetchRaceHub,
|
||||||
|
fetchSeasons,
|
||||||
|
fetchWeekend,
|
||||||
|
} from '../api'
|
||||||
import { DatasetStrip } from '../components/DatasetStrip'
|
import { DatasetStrip } from '../components/DatasetStrip'
|
||||||
import { ClassificationTable } from '../components/ClassificationTable'
|
import { ClassificationTable } from '../components/ClassificationTable'
|
||||||
import { StartingGridTable } from '../components/StartingGridTable'
|
import { StartingGridTable } from '../components/StartingGridTable'
|
||||||
@@ -14,123 +17,339 @@ import { PositionEvolutionView } from '../components/PositionEvolutionView'
|
|||||||
import { LapsView } from '../components/LapsView'
|
import { LapsView } from '../components/LapsView'
|
||||||
import { RaceControlView } from '../components/RaceControlView'
|
import { RaceControlView } from '../components/RaceControlView'
|
||||||
import { WeatherView } from '../components/WeatherView'
|
import { WeatherView } from '../components/WeatherView'
|
||||||
|
import { OverviewView } from '../components/OverviewView'
|
||||||
|
import { WeekendSwitcher } from '../components/WeekendSwitcher'
|
||||||
|
import { SourceBadge } from '../components/SourceBadge'
|
||||||
|
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
||||||
|
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
|
||||||
|
import {
|
||||||
|
formatSessionScheduleTime,
|
||||||
|
pickFocusMeeting,
|
||||||
|
sortSessionsByStart,
|
||||||
|
} from '../lib/schedule'
|
||||||
|
import type { Weekend, WeekendSession } from '../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionKey: number
|
sessionKey: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RaceStorySubview = 'classification' | 'grid' | 'positions'
|
||||||
|
|
||||||
|
function pickAnalysisSession(weekend: Weekend | undefined): WeekendSession | undefined {
|
||||||
|
if (!weekend) return undefined
|
||||||
|
const local = weekend.sessions.filter((s) => s.source === 'local')
|
||||||
|
const partial = weekend.sessions.filter((s) => s.source === 'partial')
|
||||||
|
const pool = local.length > 0 ? local : partial.length > 0 ? partial : weekend.sessions
|
||||||
|
const race = pool.find((s) => s.session.session_type?.toLowerCase().includes('race'))
|
||||||
|
if (race) return race
|
||||||
|
const qual = pool.find((s) => s.session.session_type?.toLowerCase().includes('qualifying'))
|
||||||
|
if (qual) return qual
|
||||||
|
return pool[0]
|
||||||
|
}
|
||||||
|
|
||||||
export function RaceHubPage({ sessionKey }: Props) {
|
export function RaceHubPage({ sessionKey }: Props) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [inputVal, setInputVal] = useState(sessionKey > 0 ? String(sessionKey) : '')
|
const [activeTab, setActiveTab] = useState<Tab>('overview')
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('results')
|
const [storyView, setStoryView] = useState<RaceStorySubview>('classification')
|
||||||
|
const [switcherOpen, setSwitcherOpen] = useState(false)
|
||||||
|
|
||||||
|
// ─── Auto-redirect when no session_key is supplied ───
|
||||||
|
const seasonsQuery = useQuery({
|
||||||
|
queryKey: ['seasons'],
|
||||||
|
queryFn: fetchSeasons,
|
||||||
|
enabled: sessionKey === 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||||
|
|
||||||
|
const meetingsQuery = useQuery({
|
||||||
|
queryKey: ['meetings', latestSeason],
|
||||||
|
queryFn: () => fetchLocalMeetings(latestSeason!),
|
||||||
|
enabled: sessionKey === 0 && latestSeason != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const focusMeeting = useMemo(() => {
|
||||||
|
if (sessionKey !== 0 || !meetingsQuery.data) return null
|
||||||
|
return pickFocusMeeting(meetingsQuery.data, new Date())
|
||||||
|
}, [sessionKey, meetingsQuery.data])
|
||||||
|
|
||||||
|
const fallbackWeekendQuery = useQuery({
|
||||||
|
queryKey: ['weekend', focusMeeting?.meeting_key],
|
||||||
|
queryFn: () => fetchWeekend(focusMeeting!.meeting_key),
|
||||||
|
enabled: sessionKey === 0 && focusMeeting != null,
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setInputVal(sessionKey > 0 ? String(sessionKey) : '')
|
if (sessionKey !== 0) return
|
||||||
}, [sessionKey])
|
const weekend = fallbackWeekendQuery.data
|
||||||
|
if (!weekend) return
|
||||||
|
const target = pickAnalysisSession(weekend)?.session.session_key
|
||||||
|
?? weekend.default_session_key
|
||||||
|
?? weekend.sessions[0]?.session.session_key
|
||||||
|
if (target) {
|
||||||
|
navigate({ to: '/race-hub', search: { session_key: target }, replace: true })
|
||||||
|
}
|
||||||
|
}, [sessionKey, fallbackWeekendQuery.data, navigate])
|
||||||
|
|
||||||
const { data, isLoading, isError, error } = useQuery({
|
// ─── Active session payload ───
|
||||||
|
const raceHubQuery = useQuery({
|
||||||
queryKey: ['race-hub', sessionKey],
|
queryKey: ['race-hub', sessionKey],
|
||||||
queryFn: () => fetchRaceHub(sessionKey),
|
queryFn: () => fetchRaceHub(sessionKey),
|
||||||
enabled: sessionKey > 0,
|
enabled: sessionKey > 0,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleLoad(e: React.FormEvent) {
|
const meetingKey = raceHubQuery.data?.meeting?.meeting_key
|
||||||
e.preventDefault()
|
const weekendQuery = useQuery({
|
||||||
const key = parseInt(inputVal, 10)
|
queryKey: ['weekend', meetingKey],
|
||||||
if (key > 0) {
|
queryFn: () => fetchWeekend(meetingKey!),
|
||||||
navigate({ to: '/race-hub', search: { session_key: key } })
|
enabled: meetingKey != null && meetingKey > 0,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = raceHubQuery.data
|
||||||
|
const weekend = weekendQuery.data
|
||||||
|
const accent = countryAccent(data?.meeting ?? null)
|
||||||
|
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
|
||||||
|
|
||||||
|
// ─── No session_key: show resolving state, fall back to switcher if no local data ───
|
||||||
|
if (sessionKey === 0) {
|
||||||
|
if (seasonsQuery.isLoading || meetingsQuery.isLoading || fallbackWeekendQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="rh-page" style={accentStyle}>
|
||||||
|
<div className="loading-state">resolving latest local weekend…</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
const seasons = seasonsQuery.data ?? []
|
||||||
|
if (seasons.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rh-page rh-empty" data-testid="race-hub-empty" style={accentStyle}>
|
||||||
|
<div className="rh-empty-band">
|
||||||
|
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
|
||||||
|
<h1 className="rh-empty-title">No local sessions yet</h1>
|
||||||
|
<p className="rh-empty-sub">
|
||||||
|
The Race Hub reads from local ingest only. Once a weekend is ingested
|
||||||
|
it will open here automatically.
|
||||||
|
</p>
|
||||||
|
<div className="rh-empty-actions">
|
||||||
|
<a href="/admin" className="rh-empty-action">Open Admin · Data Health</a>
|
||||||
|
<a href="/" className="rh-empty-action">Back to Command Center</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="rh-page" style={accentStyle}>
|
||||||
|
<div className="loading-state">resolving latest local weekend…</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// ─── Loading / error for the requested session_key ───
|
||||||
<div className="page">
|
if (raceHubQuery.isLoading) {
|
||||||
{/* Session key input */}
|
return (
|
||||||
<form className="session-bar" onSubmit={handleLoad}>
|
<div className="rh-page" style={accentStyle}>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Local data browser when no session loaded */}
|
|
||||||
{sessionKey === 0 && <LocalDataNavigator />}
|
|
||||||
|
|
||||||
{/* Loading */}
|
|
||||||
{sessionKey > 0 && isLoading && (
|
|
||||||
<div className="loading-state">loading session {sessionKey}…</div>
|
<div className="loading-state">loading session {sessionKey}…</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (raceHubQuery.isError || !data) {
|
||||||
|
return (
|
||||||
|
<div className="rh-page" style={accentStyle}>
|
||||||
|
<div className="error-box">
|
||||||
|
{raceHubQuery.error instanceof Error
|
||||||
|
? raceHubQuery.error.message
|
||||||
|
: `Failed to load session ${sessionKey}.`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const decal = countryDecal(data.meeting ?? null)
|
||||||
|
const sessions = weekend ? sortSessionsByStart(weekend.sessions.map((w) => w.session)) : []
|
||||||
|
const sessionMeta = weekend
|
||||||
|
? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w]))
|
||||||
|
: {}
|
||||||
|
const activeSessionMeta = sessionMeta[sessionKey]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
|
||||||
|
{/* Topbar */}
|
||||||
|
<div className="rh-topbar">
|
||||||
|
<span className="rh-topbar-label mono">
|
||||||
|
box-box · race hub
|
||||||
|
{data.meeting?.year ? ` · ${data.meeting.year}` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="rh-topbar-spacer" />
|
||||||
|
<SourceBadge source={data.source} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rh-switcher-toggle${switcherOpen ? ' active' : ''}`}
|
||||||
|
onClick={() => setSwitcherOpen((v) => !v)}
|
||||||
|
aria-expanded={switcherOpen}
|
||||||
|
data-testid="rh-switch-weekend"
|
||||||
|
>
|
||||||
|
{switcherOpen ? 'Close' : 'Switch Weekend'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{switcherOpen && (
|
||||||
|
<WeekendSwitcher
|
||||||
|
currentMeetingKey={meetingKey}
|
||||||
|
currentSessionKey={sessionKey}
|
||||||
|
onClose={() => setSwitcherOpen(false)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error */}
|
{/* GP Identity band */}
|
||||||
{isError && (
|
{data.meeting && (
|
||||||
<div className="error-box">
|
<section className="rh-identity" data-testid="rh-identity">
|
||||||
{error instanceof Error ? error.message : 'Failed to load race hub data'}
|
<div className="rh-identity-accent" aria-hidden="true" />
|
||||||
|
<div className="rh-identity-body">
|
||||||
|
<span className="rh-identity-decal mono">{decal}</span>
|
||||||
|
<div className="rh-identity-titles">
|
||||||
|
<h1 className="rh-identity-name">{data.meeting.meeting_name}</h1>
|
||||||
|
<div className="rh-identity-sub mono">
|
||||||
|
{[data.meeting.location, data.meeting.circuit_short_name]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
</div>
|
||||||
|
<div className="rh-identity-sub mono rh-identity-dates">
|
||||||
|
{formatGpDateRange(data.meeting)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Session rail */}
|
||||||
|
{sessions.length > 0 && (
|
||||||
|
<nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail">
|
||||||
|
{sessions.map((session) => {
|
||||||
|
const meta = sessionMeta[session.session_key]
|
||||||
|
const active = session.session_key === sessionKey
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={session.session_key}
|
||||||
|
type="button"
|
||||||
|
className={`rh-session-chip${active ? ' active' : ''}`}
|
||||||
|
onClick={() =>
|
||||||
|
navigate({
|
||||||
|
to: '/race-hub',
|
||||||
|
search: { session_key: session.session_key },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
data-testid={`rh-session-${session.session_key}`}
|
||||||
|
>
|
||||||
|
<span className="rh-session-abbrev mono">
|
||||||
|
{sessionTypeAbbrev(session.session_type, session.session_name)}
|
||||||
|
</span>
|
||||||
|
<span className="rh-session-name">{session.session_name}</span>
|
||||||
|
<span className="rh-session-time mono">
|
||||||
|
{formatSessionScheduleTime(session.date_start)}
|
||||||
|
</span>
|
||||||
|
{meta && (
|
||||||
|
<span className="rh-session-cov mono">
|
||||||
|
<span
|
||||||
|
className={`cc-cov-dot cc-cov-${meta.source}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{formatCoverageHint(meta.datasets)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active session sub-bar */}
|
||||||
|
{data.session && (
|
||||||
|
<div className="rh-active-bar" data-testid="rh-active-bar">
|
||||||
|
<span className="rh-active-name">{data.session.session_name}</span>
|
||||||
|
<span className="rh-active-meta mono">
|
||||||
|
{formatSessionScheduleTime(data.session.date_start)}
|
||||||
|
</span>
|
||||||
|
{activeSessionMeta && (
|
||||||
|
<span className="rh-active-cov mono">
|
||||||
|
<span
|
||||||
|
className={`cc-cov-dot cc-cov-${activeSessionMeta.source}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{formatCoverageHint(activeSessionMeta.datasets)} datasets local
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="rh-active-key mono">key {sessionKey}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Data */}
|
<DatasetStrip datasets={data.datasets} />
|
||||||
{data && (
|
|
||||||
<>
|
|
||||||
<RaceHubHeader
|
|
||||||
meeting={data.meeting}
|
|
||||||
session={data.session}
|
|
||||||
source={data.source}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DatasetStrip datasets={data.datasets} />
|
<TabBar active={activeTab} onChange={setActiveTab} />
|
||||||
|
|
||||||
<TabBar active={activeTab} onChange={setActiveTab} />
|
{activeTab === 'overview' && <OverviewView data={data} />}
|
||||||
|
|
||||||
{activeTab === 'results' && (
|
{activeTab === 'race_story' && (
|
||||||
<div className="data-section">
|
<div className="data-section">
|
||||||
|
<div className="rh-story-controls" role="tablist" aria-label="Race story view">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={storyView === 'classification'}
|
||||||
|
className={`rh-story-btn${storyView === 'classification' ? ' active' : ''}`}
|
||||||
|
onClick={() => setStoryView('classification')}
|
||||||
|
>
|
||||||
|
Classification
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={storyView === 'grid'}
|
||||||
|
className={`rh-story-btn${storyView === 'grid' ? ' active' : ''}`}
|
||||||
|
onClick={() => setStoryView('grid')}
|
||||||
|
>
|
||||||
|
Starting Grid
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={storyView === 'positions'}
|
||||||
|
className={`rh-story-btn${storyView === 'positions' ? ' active' : ''}`}
|
||||||
|
onClick={() => setStoryView('positions')}
|
||||||
|
>
|
||||||
|
Positions
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{storyView === 'classification' && (
|
||||||
|
<>
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
<span className="sec-title">Final Classification</span>
|
<span className="sec-title">Final Classification</span>
|
||||||
{data.results.length > 0 && (
|
{data.results.length > 0 && (
|
||||||
<span className="sec-meta">{data.results.length} drivers</span>
|
<span className="sec-meta mono">{data.results.length} drivers</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ClassificationTable results={data.results} grid={data.starting_grid} />
|
<ClassificationTable results={data.results} grid={data.starting_grid} />
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'grid' && (
|
{storyView === 'grid' && (
|
||||||
<div className="data-section">
|
<>
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
<span className="sec-title">Starting Grid</span>
|
<span className="sec-title">Starting Grid</span>
|
||||||
{data.starting_grid.length > 0 && (
|
{data.starting_grid.length > 0 && (
|
||||||
<span className="sec-meta">{data.starting_grid.length} positions</span>
|
<span className="sec-meta mono">{data.starting_grid.length} positions</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<StartingGridTable grid={data.starting_grid} />
|
<StartingGridTable grid={data.starting_grid} />
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'strategy' && (
|
{storyView === 'positions' && (
|
||||||
<div className="data-section">
|
<>
|
||||||
<div className="sec-header">
|
|
||||||
<span className="sec-title">Race Strategy</span>
|
|
||||||
</div>
|
|
||||||
<StrategyView
|
|
||||||
results={data.results}
|
|
||||||
stints={data.stints}
|
|
||||||
pit_stops={data.pit_stops}
|
|
||||||
hasStints={data.datasets['stints']?.status === 'available'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'positions' && (
|
|
||||||
<div className="data-section">
|
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
<span className="sec-title">Position Evolution</span>
|
<span className="sec-title">Position Evolution</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,54 +360,68 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
laps={data.laps}
|
laps={data.laps}
|
||||||
hasPositions={data.datasets['positions']?.status === 'available'}
|
hasPositions={data.datasets['positions']?.status === 'available'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'laps' && (
|
{activeTab === 'strategy' && (
|
||||||
<div className="data-section">
|
<div className="data-section">
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
<span className="sec-title">Laps</span>
|
<span className="sec-title">Race Strategy</span>
|
||||||
{data.laps.length > 0 && (
|
</div>
|
||||||
<span className="sec-meta">{data.laps.length} samples</span>
|
<StrategyView
|
||||||
)}
|
results={data.results}
|
||||||
</div>
|
stints={data.stints}
|
||||||
<LapsView laps={data.laps} />
|
pit_stops={data.pit_stops}
|
||||||
</div>
|
hasStints={data.datasets['stints']?.status === 'available'}
|
||||||
)}
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'race_control' && (
|
{activeTab === 'lap_data' && (
|
||||||
<div className="data-section">
|
<div className="data-section">
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
<span className="sec-title">Race Control</span>
|
<span className="sec-title">Lap Data</span>
|
||||||
{data.race_control.length > 0 && (
|
{data.laps.length > 0 && (
|
||||||
<span className="sec-meta">{data.race_control.length} messages</span>
|
<span className="sec-meta mono">{data.laps.length} samples</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<RaceControlView messages={data.race_control} />
|
<LapsView laps={data.laps} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'weather' && (
|
{activeTab === 'conditions' && (
|
||||||
<div className="data-section">
|
<div className="data-section">
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
<span className="sec-title">Weather</span>
|
<span className="sec-title">Conditions</span>
|
||||||
{data.weather.length > 0 && (
|
{data.weather.length > 0 && (
|
||||||
<span className="sec-meta">{data.weather.length} samples</span>
|
<span className="sec-meta mono">{data.weather.length} samples</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<WeatherView weather={data.weather} />
|
<WeatherView weather={data.weather} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'datasets' && (
|
{activeTab === 'race_control' && (
|
||||||
<div className="data-section">
|
<div className="data-section">
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
<span className="sec-title">Dataset Status</span>
|
<span className="sec-title">Race Control</span>
|
||||||
</div>
|
{data.race_control.length > 0 && (
|
||||||
<DatasetStatusView datasets={data.datasets} />
|
<span className="sec-meta mono">{data.race_control.length} messages</span>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</>
|
<RaceControlView messages={data.race_control} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'data_status' && (
|
||||||
|
<div className="data-section">
|
||||||
|
<div className="sec-header">
|
||||||
|
<span className="sec-title">Data Status</span>
|
||||||
|
</div>
|
||||||
|
<DatasetStatusView datasets={data.datasets} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1602,6 +1602,620 @@ a { color: inherit; text-decoration: none; }
|
|||||||
|
|
||||||
.mono { font-family: var(--f-mono); }
|
.mono { font-family: var(--f-mono); }
|
||||||
|
|
||||||
|
/* ── Race Hub Weekend Workspace ── */
|
||||||
|
.rh-page {
|
||||||
|
max-width: 1160px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--s5) var(--s6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s5);
|
||||||
|
--gp-accent: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s4);
|
||||||
|
padding-bottom: var(--s4);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh-topbar-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh-topbar-spacer { flex: 1; }
|
||||||
|
|
||||||
|
.rh-switcher-toggle {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-2);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.1s, border-color 0.1s, background 0.1s;
|
||||||
|
}
|
||||||
|
.rh-switcher-toggle:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--border-2);
|
||||||
|
background: var(--surface-h);
|
||||||
|
}
|
||||||
|
.rh-switcher-toggle.active {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--gp-accent);
|
||||||
|
background: var(--surface-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GP identity band (compact) */
|
||||||
|
.rh-identity {
|
||||||
|
display: flex;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh-identity-accent {
|
||||||
|
width: 5px;
|
||||||
|
background: var(--gp-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh-identity-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--s4) var(--s5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s5);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh-identity-decal {
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
line-height: 0.9;
|
||||||
|
color: var(--text);
|
||||||
|
opacity: 0.9;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh-identity-titles {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh-identity-name {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh-identity-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh-identity-dates { color: var(--text-2); }
|
||||||
|
|
||||||
|
/* Session rail */
|
||||||
|
.rh-session-rail {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
gap: var(--s3);
|
||||||
|
padding-bottom: var(--s2);
|
||||||
|
}
|
||||||
|
.rh-session-rail::-webkit-scrollbar { height: 4px; }
|
||||||
|
.rh-session-rail::-webkit-scrollbar-thumb { background: var(--border-2); }
|
||||||
|
|
||||||
|
.rh-session-chip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
grid-template-rows: auto auto auto;
|
||||||
|
gap: 2px var(--s3);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 168px;
|
||||||
|
padding: var(--s3) var(--s4);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
transition: background 0.1s, border-color 0.1s;
|
||||||
|
}
|
||||||
|
.rh-session-chip:hover {
|
||||||
|
background: var(--surface-h);
|
||||||
|
border-color: var(--border-2);
|
||||||
|
}
|
||||||
|
.rh-session-chip.active {
|
||||||
|
background: rgba(225, 6, 0, 0.05);
|
||||||
|
border-color: var(--gp-accent);
|
||||||
|
box-shadow: inset 3px 0 0 var(--gp-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh-session-chip .rh-session-abbrev {
|
||||||
|
grid-row: 1 / span 3;
|
||||||
|
align-self: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 4px 7px;
|
||||||
|
min-width: 36px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.rh-session-chip.active .rh-session-abbrev {
|
||||||
|
color: var(--gp-accent);
|
||||||
|
border-color: var(--gp-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh-session-chip .rh-session-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.rh-session-chip .rh-session-time {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.rh-session-chip .rh-session-cov {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active session sub-bar */
|
||||||
|
.rh-active-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s4);
|
||||||
|
padding: var(--s3) 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.rh-active-bar .rh-active-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.rh-active-bar .rh-active-meta,
|
||||||
|
.rh-active-bar .rh-active-cov {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.rh-active-bar .rh-active-key {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Race Story sub-controls */
|
||||||
|
.rh-story-controls {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-bottom: var(--s4);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.rh-story-btn {
|
||||||
|
padding: 5px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
background: var(--surface);
|
||||||
|
border: none;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.1s, background 0.1s;
|
||||||
|
}
|
||||||
|
.rh-story-btn:last-child { border-right: none; }
|
||||||
|
.rh-story-btn:hover { color: var(--text-2); }
|
||||||
|
.rh-story-btn.active {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-h);
|
||||||
|
box-shadow: inset 0 -2px 0 var(--gp-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overview tab */
|
||||||
|
.rh-overview { display: flex; flex-direction: column; gap: var(--s5); }
|
||||||
|
|
||||||
|
.rh-stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh-stat-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--s4);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--gp-accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.rh-stat-card.rh-stat-empty { border-left-color: var(--border-2); }
|
||||||
|
|
||||||
|
.rh-stat-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh-stat-primary {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh-stat-secondary {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh-stat-tertiary {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh-stat-highlight {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh-podium-list {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.rh-podium-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px 1fr auto;
|
||||||
|
gap: var(--s3);
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.rh-podium-pos { color: var(--text-3); }
|
||||||
|
.rh-podium-p1 .rh-podium-pos { color: #ffd700; font-weight: 700; }
|
||||||
|
.rh-podium-p2 .rh-podium-pos { color: #c0c0c0; font-weight: 700; }
|
||||||
|
.rh-podium-p3 .rh-podium-pos { color: #cd7f32; font-weight: 700; }
|
||||||
|
.rh-podium-driver { font-weight: 700; font-family: var(--f-mono); }
|
||||||
|
.rh-podium-gap { color: var(--text-3); font-size: 11px; }
|
||||||
|
|
||||||
|
.rh-overview-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: var(--s4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh-panel {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: var(--s4);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh-empty-line { font-size: 11px; color: var(--text-3); }
|
||||||
|
|
||||||
|
.rh-condition-strip {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
.rh-condition-chip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
min-width: 64px;
|
||||||
|
}
|
||||||
|
.rh-condition-chip.rh-condition-wet { border-color: var(--tyre-wet); }
|
||||||
|
.rh-condition-label {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
.rh-condition-value {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh-rc-list { list-style: none; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.rh-rc-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 50px 60px 1fr;
|
||||||
|
gap: var(--s3);
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.rh-rc-time { color: var(--text-3); }
|
||||||
|
.rh-rc-flag {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.rh-rc-flag-yellow { color: var(--yellow); }
|
||||||
|
.rh-rc-flag-red { color: var(--red); }
|
||||||
|
.rh-rc-flag-green { color: var(--green); }
|
||||||
|
.rh-rc-flag-blue { color: #3a6cf5; }
|
||||||
|
.rh-rc-msg { color: var(--text-2); overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
.rh-coverage-meter {
|
||||||
|
height: 6px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: var(--s3);
|
||||||
|
}
|
||||||
|
.rh-coverage-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--gp-accent);
|
||||||
|
transition: width 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rh-inline-link {
|
||||||
|
color: var(--text-2);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
.rh-inline-link:hover { color: var(--text); }
|
||||||
|
|
||||||
|
/* Weekend switcher overlay panel */
|
||||||
|
.rh-switcher {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: var(--s4);
|
||||||
|
}
|
||||||
|
.rh-switcher-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s4);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: var(--s4);
|
||||||
|
}
|
||||||
|
.rh-switcher-years {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s2);
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
.rh-switcher-year {
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-3);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.rh-switcher-year:hover { color: var(--text-2); }
|
||||||
|
.rh-switcher-year.active {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--gp-accent);
|
||||||
|
background: var(--surface-h);
|
||||||
|
}
|
||||||
|
.rh-switcher-close {
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.rh-switcher-close:hover { color: var(--text); border-color: var(--border-2); }
|
||||||
|
.rh-switcher-empty {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
padding: var(--s3) 0;
|
||||||
|
}
|
||||||
|
.rh-switcher-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
.rh-switcher-mtg {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.rh-switcher-mtg.current { border-color: var(--gp-accent); }
|
||||||
|
.rh-switcher-mtg-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
padding: var(--s3) var(--s4);
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.rh-switcher-mtg-head:hover { background: var(--surface-h); }
|
||||||
|
.rh-switcher-decal {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-2);
|
||||||
|
min-width: 28px;
|
||||||
|
}
|
||||||
|
.rh-switcher-mtg-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.rh-switcher-mtg-meta {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
.rh-switcher-sessions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0 var(--s2) var(--s2);
|
||||||
|
}
|
||||||
|
.rh-switcher-session {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 36px 1fr auto;
|
||||||
|
gap: var(--s3);
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.rh-switcher-session:hover { background: var(--surface-h); }
|
||||||
|
.rh-switcher-session.active {
|
||||||
|
background: rgba(225, 6, 0, 0.05);
|
||||||
|
box-shadow: inset 3px 0 0 var(--gp-accent);
|
||||||
|
}
|
||||||
|
.rh-switcher-sess-abbrev {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-2);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
}
|
||||||
|
.rh-switcher-sess-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.rh-switcher-sess-cov {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Race Hub empty state */
|
||||||
|
.rh-empty { max-width: 720px; gap: var(--s5); }
|
||||||
|
.rh-empty-band {
|
||||||
|
padding: var(--s6) var(--s5);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--gp-accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s2);
|
||||||
|
}
|
||||||
|
.rh-empty-eyebrow {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
.rh-empty-title { font-size: 22px; font-weight: 700; }
|
||||||
|
.rh-empty-sub { font-size: 13px; color: var(--text-2); max-width: 56ch; }
|
||||||
|
.rh-empty-actions { display: flex; flex-wrap: wrap; gap: var(--s3); }
|
||||||
|
.rh-empty-action {
|
||||||
|
padding: var(--s3) var(--s4);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.rh-empty-action:hover { background: var(--surface-h); border-color: var(--border-2); }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.rh-overview-row { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.rh-page { padding: var(--s4); gap: var(--s4); }
|
||||||
|
.rh-identity-body { padding: var(--s3) var(--s4); gap: var(--s3); }
|
||||||
|
.rh-identity-decal { font-size: 28px; }
|
||||||
|
.rh-identity-name { font-size: 17px; }
|
||||||
|
.rh-session-chip { min-width: 152px; }
|
||||||
|
.rh-stat-primary { font-size: 18px; }
|
||||||
|
.rh-active-bar .rh-active-key { margin-left: 0; }
|
||||||
|
.rh-switcher-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Tablet */
|
/* Tablet */
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.cc-band-row {
|
.cc-band-row {
|
||||||
|
|||||||
@@ -1,55 +1,90 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
|
||||||
import { DatasetStatusView } from '../components/DatasetStatusView'
|
import { DatasetStatusView } from '../components/DatasetStatusView'
|
||||||
import type { DatasetInfo } from '../types'
|
import type { DatasetInfo } from '../types'
|
||||||
|
|
||||||
const allAvailable: Record<string, DatasetInfo> = {
|
const fullDatasets: Record<string, DatasetInfo> = {
|
||||||
meeting: { status: 'available', source: 'local', count: 1 },
|
meeting: { status: 'available', source: 'local', count: 1 },
|
||||||
session: { status: 'available', source: 'local', count: 1 },
|
session: { status: 'available', source: 'local', count: 1 },
|
||||||
drivers: { status: 'available', source: 'local', count: 20 },
|
drivers: { status: 'available', source: 'local', count: 20 },
|
||||||
results: { status: 'available', source: 'local', count: 20 },
|
results: { status: 'available', source: 'local', count: 20 },
|
||||||
starting_grid: { status: 'available', source: 'local', count: 20 },
|
starting_grid: { status: 'available', source: 'local', count: 20 },
|
||||||
|
stints: { status: 'available', source: 'local', count: 30 },
|
||||||
|
pit_stops: { status: 'available', source: 'local', count: 18 },
|
||||||
|
positions: { status: 'available', source: 'local', count: 120 },
|
||||||
|
race_control: { status: 'available', source: 'local', count: 5 },
|
||||||
|
weather: { status: 'available', source: 'local', count: 4 },
|
||||||
|
laps: { status: 'available', source: 'local', count: 200 },
|
||||||
}
|
}
|
||||||
|
|
||||||
const partial: Record<string, DatasetInfo> = {
|
const coreOnly: Record<string, DatasetInfo> = {
|
||||||
meeting: { status: 'available', source: 'local', count: 1 },
|
meeting: { status: 'available', source: 'local', count: 1 },
|
||||||
session: { status: 'available', source: 'local', count: 1 },
|
session: { status: 'available', source: 'local', count: 1 },
|
||||||
drivers: { status: 'missing', source: 'none' },
|
drivers: { status: 'available', source: 'local', count: 20 },
|
||||||
results: { status: 'missing', source: 'none' },
|
results: { status: 'available', source: 'local', count: 20 },
|
||||||
starting_grid: { status: 'missing', source: 'none' },
|
starting_grid: { status: 'available', source: 'local', count: 20 },
|
||||||
|
stints: { status: 'missing', source: 'none' },
|
||||||
|
pit_stops: { status: 'missing', source: 'none' },
|
||||||
|
positions: { status: 'missing', source: 'none' },
|
||||||
|
race_control: { status: 'missing', source: 'none' },
|
||||||
|
weather: { status: 'missing', source: 'none' },
|
||||||
|
laps: { status: 'missing', source: 'none' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderView(datasets: Record<string, DatasetInfo>) {
|
||||||
|
const rootRoute = createRootRoute({
|
||||||
|
component: () => <DatasetStatusView datasets={datasets} />,
|
||||||
|
})
|
||||||
|
const indexRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/',
|
||||||
|
component: () => null,
|
||||||
|
})
|
||||||
|
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
|
||||||
|
return render(<RouterProvider router={router} />)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('DatasetStatusView', () => {
|
describe('DatasetStatusView', () => {
|
||||||
it('shows 5/5 when all available', () => {
|
it('shows 11/11 when all datasets are available', async () => {
|
||||||
render(<DatasetStatusView datasets={allAvailable} />)
|
renderView(fullDatasets)
|
||||||
expect(screen.getByText(/5\/5/)).toBeInTheDocument()
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/11\/11 datasets local/)).toBeInTheDocument(),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows local badges for available datasets', () => {
|
it('shows a Local badge for every available dataset', async () => {
|
||||||
render(<DatasetStatusView datasets={allAvailable} />)
|
renderView(fullDatasets)
|
||||||
const localBadges = screen.getAllByText('Local')
|
await waitFor(() => expect(screen.getAllByText('Local')).toHaveLength(11))
|
||||||
expect(localBadges).toHaveLength(5)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows missing badges for missing datasets', () => {
|
it('shows Missing badges for missing datasets', async () => {
|
||||||
render(<DatasetStatusView datasets={partial} />)
|
renderView(coreOnly)
|
||||||
const missingBadges = screen.getAllByText('Missing')
|
await waitFor(() => expect(screen.getAllByText('Missing')).toHaveLength(6))
|
||||||
expect(missingBadges).toHaveLength(3)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows the ingest command when data is missing', () => {
|
it('links to admin when datasets are missing instead of inlining CLI commands', async () => {
|
||||||
render(<DatasetStatusView datasets={partial} />)
|
renderView(coreOnly)
|
||||||
expect(screen.getByText(/ingest-session/i)).toBeInTheDocument()
|
await waitFor(() => {
|
||||||
})
|
expect(screen.getByRole('link', { name: /manage ingestion/i })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
it('does not show ingest command when all available', () => {
|
'/admin',
|
||||||
render(<DatasetStatusView datasets={allAvailable} />)
|
)
|
||||||
|
})
|
||||||
expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument()
|
expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows record counts', () => {
|
it('does not surface ingest hints when fully covered', async () => {
|
||||||
render(<DatasetStatusView datasets={allAvailable} />)
|
renderView(fullDatasets)
|
||||||
const twenties = screen.getAllByText('20')
|
await waitFor(() => expect(screen.getByText(/11\/11/)).toBeInTheDocument())
|
||||||
expect(twenties.length).toBeGreaterThan(0)
|
expect(screen.queryByText(/manage ingestion/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders record counts from the dataset payload', async () => {
|
||||||
|
renderView(fullDatasets)
|
||||||
|
await waitFor(() => {
|
||||||
|
const twenties = screen.getAllByText('20')
|
||||||
|
expect(twenties.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
260
frontend/src/test/RaceHubPage.test.tsx
Normal file
260
frontend/src/test/RaceHubPage.test.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Outlet,
|
||||||
|
RouterProvider,
|
||||||
|
createRouter,
|
||||||
|
createRootRoute,
|
||||||
|
createRoute,
|
||||||
|
} from '@tanstack/react-router'
|
||||||
|
import { RaceHubPage } from '../pages/RaceHubPage'
|
||||||
|
import type { DatasetInfo, Meeting, RaceHub, Session, Weekend } from '../types'
|
||||||
|
|
||||||
|
vi.mock('../api', () => ({
|
||||||
|
fetchRaceHub: vi.fn(),
|
||||||
|
fetchSeasons: vi.fn(),
|
||||||
|
fetchLocalMeetings: vi.fn(),
|
||||||
|
fetchWeekend: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { fetchRaceHub, fetchSeasons, fetchLocalMeetings, fetchWeekend } from '../api'
|
||||||
|
|
||||||
|
const mockFetchRaceHub = vi.mocked(fetchRaceHub)
|
||||||
|
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
||||||
|
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
|
||||||
|
const mockFetchWeekend = vi.mocked(fetchWeekend)
|
||||||
|
|
||||||
|
const meeting: Meeting = {
|
||||||
|
meeting_key: 1229,
|
||||||
|
meeting_name: 'Monaco Grand Prix',
|
||||||
|
meeting_official_name: 'FORMULA 1 GRAND PRIX DE MONACO 2025',
|
||||||
|
location: 'Monaco',
|
||||||
|
country_name: 'Monaco',
|
||||||
|
country_code: 'MON',
|
||||||
|
country_flag: '',
|
||||||
|
circuit_short_name: 'Monaco',
|
||||||
|
date_start: '2025-05-23T00:00:00+00:00',
|
||||||
|
date_end: '2025-05-25T00:00:00+00:00',
|
||||||
|
year: 2025,
|
||||||
|
}
|
||||||
|
|
||||||
|
const raceSession: Session = {
|
||||||
|
session_key: 9472,
|
||||||
|
session_name: 'Race',
|
||||||
|
session_type: 'Race',
|
||||||
|
meeting_key: 1229,
|
||||||
|
date_start: '2025-05-25T13:00:00+00:00',
|
||||||
|
date_end: '2025-05-25T15:00:00+00:00',
|
||||||
|
gmt_offset: '02:00:00',
|
||||||
|
}
|
||||||
|
|
||||||
|
const qualSession: Session = {
|
||||||
|
session_key: 9471,
|
||||||
|
session_name: 'Qualifying',
|
||||||
|
session_type: 'Qualifying',
|
||||||
|
meeting_key: 1229,
|
||||||
|
date_start: '2025-05-24T14:00:00+00:00',
|
||||||
|
date_end: '2025-05-24T15:00:00+00:00',
|
||||||
|
gmt_offset: '02:00:00',
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullDatasets: Record<string, DatasetInfo> = {
|
||||||
|
meeting: { status: 'available', source: 'local', count: 1 },
|
||||||
|
session: { status: 'available', source: 'local', count: 1 },
|
||||||
|
drivers: { status: 'available', source: 'local', count: 20 },
|
||||||
|
results: { status: 'available', source: 'local', count: 20 },
|
||||||
|
starting_grid: { status: 'available', source: 'local', count: 20 },
|
||||||
|
stints: { status: 'available', source: 'local', count: 30 },
|
||||||
|
pit_stops: { status: 'available', source: 'local', count: 18 },
|
||||||
|
positions: { status: 'available', source: 'local', count: 120 },
|
||||||
|
race_control: { status: 'available', source: 'local', count: 5 },
|
||||||
|
weather: { status: 'available', source: 'local', count: 4 },
|
||||||
|
laps: { status: 'available', source: 'local', count: 200 },
|
||||||
|
}
|
||||||
|
|
||||||
|
const raceHub: RaceHub = {
|
||||||
|
source: 'local',
|
||||||
|
session_key: 9472,
|
||||||
|
datasets: fullDatasets,
|
||||||
|
meeting,
|
||||||
|
session: raceSession,
|
||||||
|
drivers: [
|
||||||
|
{
|
||||||
|
driver_number: 1,
|
||||||
|
name_acronym: 'VER',
|
||||||
|
full_name: 'Max Verstappen',
|
||||||
|
first_name: 'Max',
|
||||||
|
last_name: 'Verstappen',
|
||||||
|
team_name: 'Red Bull Racing',
|
||||||
|
team_colour: '3671C6',
|
||||||
|
headshot_url: '',
|
||||||
|
broadcast_name: 'M VERSTAPPEN',
|
||||||
|
session_key: 9472,
|
||||||
|
meeting_key: 1229,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
driver_number: 1,
|
||||||
|
position: 1,
|
||||||
|
name_acronym: 'VER',
|
||||||
|
full_name: 'Max Verstappen',
|
||||||
|
team_name: 'Red Bull Racing',
|
||||||
|
team_colour: '3671C6',
|
||||||
|
dnf: false,
|
||||||
|
dns: false,
|
||||||
|
dsq: false,
|
||||||
|
duration: 5500,
|
||||||
|
gap_to_leader: null,
|
||||||
|
number_of_laps: 78,
|
||||||
|
points: 25,
|
||||||
|
session_key: 9472,
|
||||||
|
meeting_key: 1229,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
starting_grid: [
|
||||||
|
{
|
||||||
|
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: 1229,
|
||||||
|
lap_duration: 70.5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stints: [],
|
||||||
|
pit_stops: [],
|
||||||
|
positions: [],
|
||||||
|
race_control: [],
|
||||||
|
weather: [
|
||||||
|
{
|
||||||
|
session_key: 9472,
|
||||||
|
meeting_key: 1229,
|
||||||
|
date: '2025-05-25T13:30:00+00:00',
|
||||||
|
air_temperature: 22,
|
||||||
|
track_temperature: 40,
|
||||||
|
humidity: 50,
|
||||||
|
pressure: 1010,
|
||||||
|
rainfall: 0,
|
||||||
|
wind_direction: 180,
|
||||||
|
wind_speed: 1.2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
laps: [
|
||||||
|
{
|
||||||
|
session_key: 9472,
|
||||||
|
driver_number: 1,
|
||||||
|
meeting_key: 1229,
|
||||||
|
lap_number: 42,
|
||||||
|
date_start: '2025-05-25T14:00:00+00:00',
|
||||||
|
lap_duration: 71.5,
|
||||||
|
is_pit_out_lap: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekend: Weekend = {
|
||||||
|
source: 'local',
|
||||||
|
meeting_key: 1229,
|
||||||
|
meeting,
|
||||||
|
default_session_key: 9472,
|
||||||
|
sessions: [
|
||||||
|
{ session: qualSession, source: 'local', datasets: fullDatasets },
|
||||||
|
{ session: raceSession, source: 'local', datasets: fullDatasets },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRaceHub(sessionKey: number) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const rootRoute = createRootRoute({
|
||||||
|
component: () => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Outlet />
|
||||||
|
</QueryClientProvider>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
const raceHubRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/race-hub',
|
||||||
|
validateSearch: (search: Record<string, unknown>) => {
|
||||||
|
const sk = Number(search.session_key)
|
||||||
|
return Number.isFinite(sk) && sk > 0 ? { session_key: sk } : {}
|
||||||
|
},
|
||||||
|
component: function RaceHubRouteComponent() {
|
||||||
|
const { session_key } = raceHubRoute.useSearch()
|
||||||
|
return <RaceHubPage sessionKey={session_key ?? 0} />
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree: rootRoute.addChildren([raceHubRoute]),
|
||||||
|
history: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Navigate to the URL before mounting
|
||||||
|
router.navigate({ to: '/race-hub', search: sessionKey ? { session_key: sessionKey } : {} })
|
||||||
|
return render(<RouterProvider router={router} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RaceHubPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockFetchSeasons.mockResolvedValue([2025])
|
||||||
|
mockFetchLocalMeetings.mockResolvedValue([meeting])
|
||||||
|
mockFetchWeekend.mockResolvedValue(weekend)
|
||||||
|
mockFetchRaceHub.mockResolvedValue(raceHub)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the workspace identity band, session rail, and overview for a known session', async () => {
|
||||||
|
renderRaceHub(9472)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
||||||
|
expect(screen.getByTestId('rh-identity')).toHaveTextContent('Monaco Grand Prix')
|
||||||
|
expect(screen.getByTestId('rh-identity')).toHaveTextContent('MON')
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId('rh-session-9472')).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
expect(screen.getByTestId('rh-session-9471')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Overview is default
|
||||||
|
expect(screen.getByTestId('rh-overview')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Winner')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exposes Race Story sub-controls for classification, grid, and positions', async () => {
|
||||||
|
renderRaceHub(9472)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: 'Race Story' }))
|
||||||
|
|
||||||
|
expect(screen.getByRole('tab', { name: 'Classification' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('tab', { name: 'Starting Grid' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('tab', { name: 'Positions' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Final Classification')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps Data Status accessible and free of inline CLI guidance', async () => {
|
||||||
|
renderRaceHub(9472)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: 'Data Status' }))
|
||||||
|
|
||||||
|
expect(screen.getByTestId('rh-data-status')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggles the inline weekend switcher', async () => {
|
||||||
|
renderRaceHub(9472)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('rh-switch-weekend'))
|
||||||
|
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,35 +3,34 @@ import { render, screen, fireEvent } from '@testing-library/react'
|
|||||||
import { TabBar } from '../components/TabBar'
|
import { TabBar } from '../components/TabBar'
|
||||||
|
|
||||||
describe('TabBar', () => {
|
describe('TabBar', () => {
|
||||||
it('renders all Race Hub tabs', () => {
|
it('renders all Race Hub workspace tabs', () => {
|
||||||
render(<TabBar active="results" onChange={() => {}} />)
|
render(<TabBar active="overview" onChange={() => {}} />)
|
||||||
expect(screen.getByRole('tab', { name: 'Results' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Grid' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Race Story' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Strategy' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Strategy' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Positions' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Laps' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Conditions' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Weather' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Data Status' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Datasets' })).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('marks the active tab with aria-selected', () => {
|
it('marks the active tab with aria-selected', () => {
|
||||||
render(<TabBar active="grid" onChange={() => {}} />)
|
render(<TabBar active="strategy" onChange={() => {}} />)
|
||||||
expect(screen.getByRole('tab', { name: 'Grid' })).toHaveAttribute('aria-selected', 'true')
|
expect(screen.getByRole('tab', { name: 'Strategy' })).toHaveAttribute('aria-selected', 'true')
|
||||||
expect(screen.getByRole('tab', { name: 'Results' })).toHaveAttribute('aria-selected', 'false')
|
expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute('aria-selected', 'false')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('applies active class only to the active tab', () => {
|
it('applies active class only to the active tab', () => {
|
||||||
render(<TabBar active="strategy" onChange={() => {}} />)
|
render(<TabBar active="conditions" onChange={() => {}} />)
|
||||||
const strategy = screen.getByRole('tab', { name: 'Strategy' })
|
const active = screen.getByRole('tab', { name: 'Conditions' })
|
||||||
const results = screen.getByRole('tab', { name: 'Results' })
|
const inactive = screen.getByRole('tab', { name: 'Overview' })
|
||||||
expect(strategy.className).toContain('active')
|
expect(active.className).toContain('active')
|
||||||
expect(results.className).not.toContain('active')
|
expect(inactive.className).not.toContain('active')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls onChange with the correct tab id when clicked', () => {
|
it('calls onChange with the correct tab id when clicked', () => {
|
||||||
const onChange = vi.fn()
|
const onChange = vi.fn()
|
||||||
render(<TabBar active="results" onChange={onChange} />)
|
render(<TabBar active="overview" onChange={onChange} />)
|
||||||
fireEvent.click(screen.getByRole('tab', { name: 'Race Control' }))
|
fireEvent.click(screen.getByRole('tab', { name: 'Race Control' }))
|
||||||
expect(onChange).toHaveBeenCalledWith('race_control')
|
expect(onChange).toHaveBeenCalledWith('race_control')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1 +1,6 @@
|
|||||||
import '@testing-library/jest-dom'
|
import '@testing-library/jest-dom'
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'scrollTo', {
|
||||||
|
value: () => {},
|
||||||
|
writable: true,
|
||||||
|
})
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ test.describe('Command Center', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('nav link reaches command center from race hub', async ({ page }) => {
|
test('nav link reaches command center from race hub', async ({ page }) => {
|
||||||
await page.goto('/race-hub')
|
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||||
await page.getByRole('link', { name: 'Command' }).click()
|
await page.getByRole('link', { name: 'Command' }).click()
|
||||||
await expect(page).toHaveURL('/')
|
await expect(page).toHaveURL('/')
|
||||||
await expect(page.getByTestId('command-center')).toBeVisible()
|
await expect(page.getByTestId('command-center')).toBeVisible()
|
||||||
@@ -24,12 +24,14 @@ test.describe('Command Center', () => {
|
|||||||
await expect(page.getByTestId('cc-action-race-hub')).toContainText(String(FULL_SESSION))
|
await expect(page.getByTestId('cc-action-race-hub')).toContainText(String(FULL_SESSION))
|
||||||
await page.getByTestId('cc-action-race-hub').click()
|
await page.getByTestId('cc-action-race-hub').click()
|
||||||
await expect(page).toHaveURL(new RegExp(`/race-hub\\?session_key=${FULL_SESSION}`))
|
await expect(page).toHaveURL(new RegExp(`/race-hub\\?session_key=${FULL_SESSION}`))
|
||||||
await expect(page.getByText('Final Classification')).toBeVisible()
|
await expect(page.getByTestId('rh-identity')).toBeVisible()
|
||||||
|
await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('existing routes continue to work', async ({ page }) => {
|
test('existing routes continue to work', async ({ page }) => {
|
||||||
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||||
await expect(page.getByText('Final Classification')).toBeVisible()
|
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('rh-overview')).toBeVisible()
|
||||||
|
|
||||||
await page.goto('/admin')
|
await page.goto('/admin')
|
||||||
await expect(page.getByTestId('data-library')).toBeVisible()
|
await expect(page.getByTestId('data-library')).toBeVisible()
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ test.describe('Admin · Data Health', () => {
|
|||||||
|
|
||||||
test('direct race hub session link still works', async ({ page }) => {
|
test('direct race hub session link still works', async ({ page }) => {
|
||||||
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||||
await expect(page.getByText('Final Classification')).toBeVisible()
|
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||||
|
await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,9 +10,14 @@ test.describe('Production serving (Go + built React)', () => {
|
|||||||
await expect(page.getByTestId('cc-session-9472')).toBeVisible()
|
await expect(page.getByTestId('cc-session-9472')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('serves race hub with classification from built assets', async ({ page }) => {
|
test('serves race hub workspace from built assets', async ({ page }) => {
|
||||||
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||||
|
|
||||||
|
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('rh-identity')).toBeVisible()
|
||||||
|
await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Race Story' }).click()
|
||||||
await expect(page.getByText('Final Classification')).toBeVisible()
|
await expect(page.getByText('Final Classification')).toBeVisible()
|
||||||
await expect(page.locator('.drv-code', { hasText: 'VER' })).toBeVisible()
|
await expect(page.locator('.drv-code', { hasText: 'VER' })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,15 +3,40 @@ import { test, expect } from '@playwright/test'
|
|||||||
const FULL_SESSION = 9472
|
const FULL_SESSION = 9472
|
||||||
const CORE_ONLY_SESSION = 9000
|
const CORE_ONLY_SESSION = 9000
|
||||||
|
|
||||||
test.describe('Race Hub', () => {
|
test.describe('Race Hub Weekend Workspace', () => {
|
||||||
test('loads classification for a seeded session', async ({ page }) => {
|
test('lands on the Overview tab with workspace identity', async ({ page }) => {
|
||||||
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||||
|
|
||||||
|
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('rh-identity')).toBeVisible()
|
||||||
|
await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible()
|
||||||
|
await expect(page.getByTestId('rh-overview')).toBeVisible()
|
||||||
|
await expect(page.getByRole('tab', { name: 'Overview' })).toHaveAttribute(
|
||||||
|
'aria-selected',
|
||||||
|
'true',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows final classification when switching to Race Story', async ({ page }) => {
|
||||||
|
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||||
|
await page.getByRole('tab', { name: 'Race Story' }).click()
|
||||||
|
|
||||||
await expect(page.getByText('Final Classification')).toBeVisible()
|
await expect(page.getByText('Final Classification')).toBeVisible()
|
||||||
await expect(page.locator('.drv-code', { hasText: 'VER' })).toBeVisible()
|
await expect(page.locator('.drv-code', { hasText: 'VER' })).toBeVisible()
|
||||||
await expect(page.locator('.drv-code', { hasText: 'HAM' })).toBeVisible()
|
await expect(page.locator('.drv-code', { hasText: 'HAM' })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Race Story exposes classification, grid, and positions sub-views', async ({ page }) => {
|
||||||
|
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||||
|
await page.getByRole('tab', { name: 'Race Story' }).click()
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Starting Grid' }).click()
|
||||||
|
await expect(page.locator('.sec-title', { hasText: 'Starting Grid' })).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Positions' }).click()
|
||||||
|
await expect(page.locator('[data-testid="position-chart"]')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
test('strategy tab renders stint chart when stints are available', async ({ page }) => {
|
test('strategy tab renders stint chart when stints are available', async ({ page }) => {
|
||||||
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||||
await page.getByRole('tab', { name: 'Strategy' }).click()
|
await page.getByRole('tab', { name: 'Strategy' }).click()
|
||||||
@@ -20,14 +45,6 @@ test.describe('Race Hub', () => {
|
|||||||
await expect(page.getByText('Stints not available.')).not.toBeVisible()
|
await expect(page.getByText('Stints not available.')).not.toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('positions tab renders position chart when positions are available', async ({ page }) => {
|
|
||||||
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
|
||||||
await page.getByRole('tab', { name: 'Positions' }).click()
|
|
||||||
|
|
||||||
await expect(page.locator('[data-testid="position-chart"]')).toBeVisible()
|
|
||||||
await expect(page.getByText('Lap-by-lap positions not available.')).not.toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('strategy tab shows missing notice when stints are unavailable', async ({ page }) => {
|
test('strategy tab shows missing notice when stints are unavailable', async ({ page }) => {
|
||||||
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
|
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
|
||||||
await page.getByRole('tab', { name: 'Strategy' }).click()
|
await page.getByRole('tab', { name: 'Strategy' }).click()
|
||||||
@@ -36,25 +53,42 @@ test.describe('Race Hub', () => {
|
|||||||
await expect(page.locator('[data-testid="strategy-chart"]')).not.toBeVisible()
|
await expect(page.locator('[data-testid="strategy-chart"]')).not.toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('positions tab shows missing notice when positions are unavailable', async ({ page }) => {
|
test('positions sub-view shows missing notice when positions are unavailable', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
|
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
|
||||||
|
await page.getByRole('tab', { name: 'Race Story' }).click()
|
||||||
await page.getByRole('tab', { name: 'Positions' }).click()
|
await page.getByRole('tab', { name: 'Positions' }).click()
|
||||||
|
|
||||||
await expect(page.getByText('Lap-by-lap positions not available.')).toBeVisible()
|
await expect(page.getByText('Lap-by-lap positions not available.')).toBeVisible()
|
||||||
await expect(page.locator('[data-testid="position-chart"]')).not.toBeVisible()
|
await expect(page.locator('[data-testid="position-chart"]')).not.toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('navigates to race hub via local data browser', async ({ page }) => {
|
test('switching weekend via the inline switcher navigates to selected session', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||||
|
await page.getByTestId('rh-switch-weekend').click()
|
||||||
|
|
||||||
|
await expect(page.getByTestId('rh-switcher')).toBeVisible()
|
||||||
|
// Active session is already loaded; just confirm a session button is reachable
|
||||||
|
await expect(page.getByTestId(`rh-switcher-session-${FULL_SESSION}`)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Data Status tab points at admin instead of inline CLI hints', async ({ page }) => {
|
||||||
|
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
|
||||||
|
await page.getByRole('tab', { name: 'Data Status' }).click()
|
||||||
|
|
||||||
|
await expect(page.getByTestId('rh-data-status')).toBeVisible()
|
||||||
|
await expect(page.getByRole('link', { name: /manage ingestion/i })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/admin',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bare /race-hub redirects to the focus session', async ({ page }) => {
|
||||||
await page.goto('/race-hub')
|
await page.goto('/race-hub')
|
||||||
|
await expect(page).toHaveURL(/session_key=\d+/)
|
||||||
await expect(page.getByTestId('local-nav')).toBeVisible()
|
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||||
await expect(page.getByText('Monaco')).toBeVisible()
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Sessions' }).click()
|
|
||||||
await expect(page.getByTestId('weekend-sessions')).toBeVisible()
|
|
||||||
await page.getByTestId('open-session-9472').click()
|
|
||||||
|
|
||||||
await expect(page.getByText('Final Classification')).toBeVisible()
|
|
||||||
await expect(page.locator('.drv-code', { hasText: 'VER' })).toBeVisible()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 56 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 56 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 56 KiB |
@@ -24,8 +24,10 @@ export async function gotoCommandCenterReady(page: Page): Promise<void> {
|
|||||||
|
|
||||||
export async function gotoRaceHubReady(page: Page, sessionKey = FULL_SESSION): Promise<void> {
|
export async function gotoRaceHubReady(page: Page, sessionKey = FULL_SESSION): Promise<void> {
|
||||||
await page.goto(`/race-hub?session_key=${sessionKey}`)
|
await page.goto(`/race-hub?session_key=${sessionKey}`)
|
||||||
await expect(page.getByText('Final Classification')).toBeVisible()
|
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||||
await expect(page.locator('.drv-code', { hasText: 'VER' })).toBeVisible()
|
await expect(page.getByTestId('rh-identity')).toBeVisible()
|
||||||
|
await expect(page.getByTestId(`rh-session-${sessionKey}`)).toBeVisible()
|
||||||
|
await expect(page.getByTestId('rh-overview')).toBeVisible()
|
||||||
await waitForScreenshotReady(page)
|
await waitForScreenshotReady(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user