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 |
|
||||
| --- | --- |
|
||||
| `/` | **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 |
|
||||
| `/data-library` | Legacy alias for Admin / Data Health |
|
||||
| `/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`
|
||||
|
||||
## 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
|
||||
# Season: discover and store meeting metadata (2023+)
|
||||
@@ -134,6 +134,9 @@ go run ./cmd/main.go --ingest-session 9472
|
||||
# Preview without writing
|
||||
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)
|
||||
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
|
||||
|
||||
- `571edb9 Add visual regression coverage`
|
||||
- `e539abc Add command center screen`
|
||||
- `9532206 Polish live timing UI`
|
||||
- `a0f135a Update operator documentation`
|
||||
- `79b0b9f Rework command center for race weekends`
|
||||
- `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
|
||||
commit Phase 19 first, then Phase 20, or stage hunks carefully if separating the
|
||||
shared README/refactor README edits.
|
||||
Phase 19 and Phase 20 have been reviewed and committed. Start new work from a
|
||||
clean tree unless `git status --short` shows user changes made after this
|
||||
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 reported these Phase 19 changes:
|
||||
Claude implemented, and Codex reviewed/committed, a Race Hub redesign that turns
|
||||
`/race-hub` into a Weekend Workspace.
|
||||
|
||||
- New:
|
||||
- `frontend/src/components/OverviewView.tsx`
|
||||
@@ -97,7 +95,7 @@ Claude reported these Phase 19 changes:
|
||||
- root `README.md`
|
||||
- `documentations/refactor/README.md`
|
||||
|
||||
Claude reported these UX changes:
|
||||
UX changes:
|
||||
|
||||
- Race Hub is now a Weekend Workspace.
|
||||
- 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.
|
||||
- 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 run build` passed.
|
||||
- `npm run test:e2e` passed, 18 tests.
|
||||
- `npm run test:e2e:prod` passed, 6 tests.
|
||||
- `npm run test:visual` passed, 12 screenshots after regenerating race-hub baselines.
|
||||
- `npm run test:visual:prod` passed, 12 screenshots.
|
||||
- `npm --prefix frontend test -- --run`
|
||||
- `npm --prefix frontend run build`
|
||||
- `npm run test:e2e`
|
||||
- `npm run test:e2e:prod`
|
||||
- `npm run test:visual`
|
||||
- `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
|
||||
currently uncommitted and needs review.
|
||||
## Completed Phase 20: Paddock Briefing Ingestion CLI
|
||||
|
||||
Reported Phase 20 changes:
|
||||
A backend subagent implemented, and Codex reviewed/committed, Phase 20 after the
|
||||
RSS backend spike.
|
||||
|
||||
Phase 20 changes:
|
||||
|
||||
- Modified:
|
||||
- `cmd/main.go`
|
||||
- `README.md`
|
||||
- `documentations/refactor/README.md`
|
||||
- New:
|
||||
- `internal/news/refresh.go`
|
||||
- `internal/news/refresh_test.go`
|
||||
- `documentations/refactor/29-phase-20-paddock-briefing-ingestion.md`
|
||||
|
||||
Implemented behavior:
|
||||
Behavior:
|
||||
|
||||
- Adds `--ingest-news` as a CLI mode.
|
||||
- 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
|
||||
```
|
||||
|
||||
Phase 20 verification already run by Codex:
|
||||
Verification run by Codex before commit:
|
||||
|
||||
```bash
|
||||
go test ./cmd/... ./internal/news ./internal/store
|
||||
go test ./internal/web ./internal/query
|
||||
go test ./...
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Immediate Task
|
||||
|
||||
Start by reviewing and committing Phase 19. Then review and commit Phase 20.
|
||||
Do not start new implementation until both are accepted and committed.
|
||||
|
||||
1. Inspect working tree:
|
||||
Start with a quick sync:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git diff --stat
|
||||
git diff --name-only
|
||||
```
|
||||
|
||||
2. Review Claude’s Phase 19 work quickly but responsibly:
|
||||
|
||||
- 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"
|
||||
```
|
||||
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.
|
||||
|
||||
## 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
|
||||
`/` around race-weekend identity and splits ingestion/admin concerns into
|
||||
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):
|
||||
backend spike for publisher-owned RSS/Atom feeds, local SQLite caching, and a
|
||||
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
|
||||
|
||||
|
||||
@@ -1,37 +1,54 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { RACE_HUB_DATASETS } from '../lib/coverage'
|
||||
import type { DatasetInfo } from '../types'
|
||||
|
||||
interface Props {
|
||||
datasets: Record<string, DatasetInfo>
|
||||
}
|
||||
|
||||
const KNOWN_DATASETS: { key: string; label: string }[] = [
|
||||
{ key: 'meeting', label: 'Meeting' },
|
||||
{ key: 'session', label: 'Session' },
|
||||
{ key: 'drivers', label: 'Drivers' },
|
||||
{ key: 'results', label: 'Results' },
|
||||
{ key: 'starting_grid', label: 'Starting Grid' },
|
||||
]
|
||||
const DATASET_LABELS: Record<string, string> = {
|
||||
meeting: 'Meeting',
|
||||
session: 'Session',
|
||||
drivers: 'Drivers',
|
||||
results: 'Results',
|
||||
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) {
|
||||
const entries = KNOWN_DATASETS.map(({ key, label }) => ({
|
||||
const entries = RACE_HUB_DATASETS.map((key) => ({
|
||||
key,
|
||||
label,
|
||||
label: DATASET_LABELS[key] ?? key,
|
||||
info: datasets[key] as DatasetInfo | undefined,
|
||||
}))
|
||||
|
||||
const available = entries.filter((e) => e.info?.status === 'available').length
|
||||
const total = entries.length
|
||||
const missing = total - available
|
||||
|
||||
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">
|
||||
<span>
|
||||
{available}/{total} datasets available locally
|
||||
<span className="mono" style={{ color: 'var(--text-2)' }}>
|
||||
{available}/{total} datasets local
|
||||
</span>
|
||||
{available < total && (
|
||||
<span>
|
||||
Re-run <code>box-box --ingest-session <key></code> after backend
|
||||
support exists for missing datasets.
|
||||
{missing > 0 && (
|
||||
<span style={{ color: 'var(--text-3)' }}>
|
||||
{missing} dataset{missing === 1 ? '' : 's'} still missing —{' '}
|
||||
<Link to="/admin" className="rh-inline-link">
|
||||
manage ingestion
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
)}
|
||||
</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 =
|
||||
| 'results'
|
||||
| 'grid'
|
||||
| 'overview'
|
||||
| 'race_story'
|
||||
| 'strategy'
|
||||
| 'positions'
|
||||
| 'laps'
|
||||
| 'lap_data'
|
||||
| 'conditions'
|
||||
| 'race_control'
|
||||
| 'weather'
|
||||
| 'datasets'
|
||||
| 'data_status'
|
||||
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: 'results', label: 'Results' },
|
||||
{ id: 'grid', label: 'Grid' },
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'race_story', label: 'Race Story' },
|
||||
{ id: 'strategy', label: 'Strategy' },
|
||||
{ id: 'positions', label: 'Positions' },
|
||||
{ id: 'laps', label: 'Laps' },
|
||||
{ id: 'lap_data', label: 'Lap Data' },
|
||||
{ id: 'conditions', label: 'Conditions' },
|
||||
{ id: 'race_control', label: 'Race Control' },
|
||||
{ id: 'weather', label: 'Weather' },
|
||||
{ id: 'datasets', label: 'Datasets' },
|
||||
{ id: 'data_status', label: 'Data Status' },
|
||||
]
|
||||
|
||||
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 { useNavigate } from '@tanstack/react-router'
|
||||
import { fetchRaceHub } from '../api'
|
||||
import { LocalDataNavigator } from '../components/LocalDataNavigator'
|
||||
import { RaceHubHeader } from '../components/RaceHubHeader'
|
||||
import {
|
||||
fetchLocalMeetings,
|
||||
fetchRaceHub,
|
||||
fetchSeasons,
|
||||
fetchWeekend,
|
||||
} from '../api'
|
||||
import { DatasetStrip } from '../components/DatasetStrip'
|
||||
import { ClassificationTable } from '../components/ClassificationTable'
|
||||
import { StartingGridTable } from '../components/StartingGridTable'
|
||||
@@ -14,123 +17,339 @@ import { PositionEvolutionView } from '../components/PositionEvolutionView'
|
||||
import { LapsView } from '../components/LapsView'
|
||||
import { RaceControlView } from '../components/RaceControlView'
|
||||
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 {
|
||||
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) {
|
||||
const navigate = useNavigate()
|
||||
const [inputVal, setInputVal] = useState(sessionKey > 0 ? String(sessionKey) : '')
|
||||
const [activeTab, setActiveTab] = useState<Tab>('results')
|
||||
const [activeTab, setActiveTab] = useState<Tab>('overview')
|
||||
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(() => {
|
||||
setInputVal(sessionKey > 0 ? String(sessionKey) : '')
|
||||
}, [sessionKey])
|
||||
if (sessionKey !== 0) return
|
||||
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],
|
||||
queryFn: () => fetchRaceHub(sessionKey),
|
||||
enabled: sessionKey > 0,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
function handleLoad(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const key = parseInt(inputVal, 10)
|
||||
if (key > 0) {
|
||||
navigate({ to: '/race-hub', search: { session_key: key } })
|
||||
const meetingKey = raceHubQuery.data?.meeting?.meeting_key
|
||||
const weekendQuery = useQuery({
|
||||
queryKey: ['weekend', meetingKey],
|
||||
queryFn: () => fetchWeekend(meetingKey!),
|
||||
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 (
|
||||
<div className="page">
|
||||
{/* Session key input */}
|
||||
<form className="session-bar" onSubmit={handleLoad}>
|
||||
<label htmlFor="sk-input">Session Key</label>
|
||||
<input
|
||||
id="sk-input"
|
||||
type="number"
|
||||
placeholder="e.g. 9472"
|
||||
value={inputVal}
|
||||
onChange={(e) => setInputVal(e.target.value)}
|
||||
/>
|
||||
<button type="submit">Load</button>
|
||||
{sessionKey > 0 && (
|
||||
<span style={{ fontFamily: 'var(--f-mono)', fontSize: 10, color: 'var(--text-3)' }}>
|
||||
key {sessionKey}
|
||||
</span>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Local data browser when no session loaded */}
|
||||
{sessionKey === 0 && <LocalDataNavigator />}
|
||||
|
||||
{/* Loading */}
|
||||
{sessionKey > 0 && isLoading && (
|
||||
// ─── Loading / error for the requested session_key ───
|
||||
if (raceHubQuery.isLoading) {
|
||||
return (
|
||||
<div className="rh-page" style={accentStyle}>
|
||||
<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 */}
|
||||
{isError && (
|
||||
<div className="error-box">
|
||||
{error instanceof Error ? error.message : 'Failed to load race hub data'}
|
||||
{/* GP Identity band */}
|
||||
{data.meeting && (
|
||||
<section className="rh-identity" data-testid="rh-identity">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Data */}
|
||||
{data && (
|
||||
<>
|
||||
<RaceHubHeader
|
||||
meeting={data.meeting}
|
||||
session={data.session}
|
||||
source={data.source}
|
||||
/>
|
||||
<DatasetStrip datasets={data.datasets} />
|
||||
|
||||
<DatasetStrip datasets={data.datasets} />
|
||||
<TabBar active={activeTab} onChange={setActiveTab} />
|
||||
|
||||
<TabBar active={activeTab} onChange={setActiveTab} />
|
||||
{activeTab === 'overview' && <OverviewView data={data} />}
|
||||
|
||||
{activeTab === 'results' && (
|
||||
<div className="data-section">
|
||||
{activeTab === 'race_story' && (
|
||||
<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">
|
||||
<span className="sec-title">Final Classification</span>
|
||||
{data.results.length > 0 && (
|
||||
<span className="sec-meta">{data.results.length} drivers</span>
|
||||
<span className="sec-meta mono">{data.results.length} drivers</span>
|
||||
)}
|
||||
</div>
|
||||
<ClassificationTable results={data.results} grid={data.starting_grid} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'grid' && (
|
||||
<div className="data-section">
|
||||
{storyView === 'grid' && (
|
||||
<>
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Starting Grid</span>
|
||||
{data.starting_grid.length > 0 && (
|
||||
<span className="sec-meta">{data.starting_grid.length} positions</span>
|
||||
<span className="sec-meta mono">{data.starting_grid.length} positions</span>
|
||||
)}
|
||||
</div>
|
||||
<StartingGridTable grid={data.starting_grid} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'strategy' && (
|
||||
<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">
|
||||
{storyView === 'positions' && (
|
||||
<>
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Position Evolution</span>
|
||||
</div>
|
||||
@@ -141,54 +360,68 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
laps={data.laps}
|
||||
hasPositions={data.datasets['positions']?.status === 'available'}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'laps' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Laps</span>
|
||||
{data.laps.length > 0 && (
|
||||
<span className="sec-meta">{data.laps.length} samples</span>
|
||||
)}
|
||||
</div>
|
||||
<LapsView laps={data.laps} />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'strategy' && (
|
||||
<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 === 'race_control' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Race Control</span>
|
||||
{data.race_control.length > 0 && (
|
||||
<span className="sec-meta">{data.race_control.length} messages</span>
|
||||
)}
|
||||
</div>
|
||||
<RaceControlView messages={data.race_control} />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'lap_data' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Lap Data</span>
|
||||
{data.laps.length > 0 && (
|
||||
<span className="sec-meta mono">{data.laps.length} samples</span>
|
||||
)}
|
||||
</div>
|
||||
<LapsView laps={data.laps} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'weather' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Weather</span>
|
||||
{data.weather.length > 0 && (
|
||||
<span className="sec-meta">{data.weather.length} samples</span>
|
||||
)}
|
||||
</div>
|
||||
<WeatherView weather={data.weather} />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'conditions' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Conditions</span>
|
||||
{data.weather.length > 0 && (
|
||||
<span className="sec-meta mono">{data.weather.length} samples</span>
|
||||
)}
|
||||
</div>
|
||||
<WeatherView weather={data.weather} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'datasets' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Dataset Status</span>
|
||||
</div>
|
||||
<DatasetStatusView datasets={data.datasets} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
{activeTab === 'race_control' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Race Control</span>
|
||||
{data.race_control.length > 0 && (
|
||||
<span className="sec-meta mono">{data.race_control.length} messages</span>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -1602,6 +1602,620 @@ a { color: inherit; text-decoration: none; }
|
||||
|
||||
.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 */
|
||||
@media (max-width: 900px) {
|
||||
.cc-band-row {
|
||||
|
||||
@@ -1,55 +1,90 @@
|
||||
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 type { DatasetInfo } from '../types'
|
||||
|
||||
const allAvailable: Record<string, DatasetInfo> = {
|
||||
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 partial: Record<string, DatasetInfo> = {
|
||||
const coreOnly: Record<string, DatasetInfo> = {
|
||||
meeting: { status: 'available', source: 'local', count: 1 },
|
||||
session: { status: 'available', source: 'local', count: 1 },
|
||||
drivers: { status: 'missing', source: 'none' },
|
||||
results: { status: 'missing', source: 'none' },
|
||||
starting_grid: { status: 'missing', source: 'none' },
|
||||
drivers: { status: 'available', source: 'local', count: 20 },
|
||||
results: { status: 'available', source: 'local', count: 20 },
|
||||
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', () => {
|
||||
it('shows 5/5 when all available', () => {
|
||||
render(<DatasetStatusView datasets={allAvailable} />)
|
||||
expect(screen.getByText(/5\/5/)).toBeInTheDocument()
|
||||
it('shows 11/11 when all datasets are available', async () => {
|
||||
renderView(fullDatasets)
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/11\/11 datasets local/)).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows local badges for available datasets', () => {
|
||||
render(<DatasetStatusView datasets={allAvailable} />)
|
||||
const localBadges = screen.getAllByText('Local')
|
||||
expect(localBadges).toHaveLength(5)
|
||||
it('shows a Local badge for every available dataset', async () => {
|
||||
renderView(fullDatasets)
|
||||
await waitFor(() => expect(screen.getAllByText('Local')).toHaveLength(11))
|
||||
})
|
||||
|
||||
it('shows missing badges for missing datasets', () => {
|
||||
render(<DatasetStatusView datasets={partial} />)
|
||||
const missingBadges = screen.getAllByText('Missing')
|
||||
expect(missingBadges).toHaveLength(3)
|
||||
it('shows Missing badges for missing datasets', async () => {
|
||||
renderView(coreOnly)
|
||||
await waitFor(() => expect(screen.getAllByText('Missing')).toHaveLength(6))
|
||||
})
|
||||
|
||||
it('shows the ingest command when data is missing', () => {
|
||||
render(<DatasetStatusView datasets={partial} />)
|
||||
expect(screen.getByText(/ingest-session/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show ingest command when all available', () => {
|
||||
render(<DatasetStatusView datasets={allAvailable} />)
|
||||
it('links to admin when datasets are missing instead of inlining CLI commands', async () => {
|
||||
renderView(coreOnly)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('link', { name: /manage ingestion/i })).toHaveAttribute(
|
||||
'href',
|
||||
'/admin',
|
||||
)
|
||||
})
|
||||
expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows record counts', () => {
|
||||
render(<DatasetStatusView datasets={allAvailable} />)
|
||||
const twenties = screen.getAllByText('20')
|
||||
expect(twenties.length).toBeGreaterThan(0)
|
||||
it('does not surface ingest hints when fully covered', async () => {
|
||||
renderView(fullDatasets)
|
||||
await waitFor(() => expect(screen.getByText(/11\/11/)).toBeInTheDocument())
|
||||
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'
|
||||
|
||||
describe('TabBar', () => {
|
||||
it('renders all Race Hub tabs', () => {
|
||||
render(<TabBar active="results" onChange={() => {}} />)
|
||||
expect(screen.getByRole('tab', { name: 'Results' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Grid' })).toBeInTheDocument()
|
||||
it('renders all Race Hub workspace tabs', () => {
|
||||
render(<TabBar active="overview" onChange={() => {}} />)
|
||||
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Race Story' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Strategy' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Positions' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Laps' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Conditions' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Weather' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Datasets' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Data Status' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks the active tab with aria-selected', () => {
|
||||
render(<TabBar active="grid" onChange={() => {}} />)
|
||||
expect(screen.getByRole('tab', { name: 'Grid' })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByRole('tab', { name: 'Results' })).toHaveAttribute('aria-selected', 'false')
|
||||
render(<TabBar active="strategy" onChange={() => {}} />)
|
||||
expect(screen.getByRole('tab', { name: 'Strategy' })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute('aria-selected', 'false')
|
||||
})
|
||||
|
||||
it('applies active class only to the active tab', () => {
|
||||
render(<TabBar active="strategy" onChange={() => {}} />)
|
||||
const strategy = screen.getByRole('tab', { name: 'Strategy' })
|
||||
const results = screen.getByRole('tab', { name: 'Results' })
|
||||
expect(strategy.className).toContain('active')
|
||||
expect(results.className).not.toContain('active')
|
||||
render(<TabBar active="conditions" onChange={() => {}} />)
|
||||
const active = screen.getByRole('tab', { name: 'Conditions' })
|
||||
const inactive = screen.getByRole('tab', { name: 'Overview' })
|
||||
expect(active.className).toContain('active')
|
||||
expect(inactive.className).not.toContain('active')
|
||||
})
|
||||
|
||||
it('calls onChange with the correct tab id when clicked', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<TabBar active="results" onChange={onChange} />)
|
||||
render(<TabBar active="overview" onChange={onChange} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Race Control' }))
|
||||
expect(onChange).toHaveBeenCalledWith('race_control')
|
||||
})
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
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 }) => {
|
||||
await page.goto('/race-hub')
|
||||
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||
await page.getByRole('link', { name: 'Command' }).click()
|
||||
await expect(page).toHaveURL('/')
|
||||
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 page.getByTestId('cc-action-race-hub').click()
|
||||
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 }) => {
|
||||
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 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 }) => {
|
||||
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()
|
||||
})
|
||||
|
||||
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 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.locator('.drv-code', { hasText: 'VER' })).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -3,15 +3,40 @@ import { test, expect } from '@playwright/test'
|
||||
const FULL_SESSION = 9472
|
||||
const CORE_ONLY_SESSION = 9000
|
||||
|
||||
test.describe('Race Hub', () => {
|
||||
test('loads classification for a seeded session', async ({ page }) => {
|
||||
test.describe('Race Hub Weekend Workspace', () => {
|
||||
test('lands on the Overview tab with workspace identity', async ({ page }) => {
|
||||
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.locator('.drv-code', { hasText: 'VER' })).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 }) => {
|
||||
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||
await page.getByRole('tab', { name: 'Strategy' }).click()
|
||||
@@ -20,14 +45,6 @@ test.describe('Race Hub', () => {
|
||||
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 }) => {
|
||||
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
|
||||
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()
|
||||
})
|
||||
|
||||
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.getByRole('tab', { name: 'Race Story' }).click()
|
||||
await page.getByRole('tab', { name: 'Positions' }).click()
|
||||
|
||||
await expect(page.getByText('Lap-by-lap positions not available.')).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 expect(page.getByTestId('local-nav')).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()
|
||||
await expect(page).toHaveURL(/session_key=\d+/)
|
||||
await expect(page.getByTestId('race-hub')).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> {
|
||||
await page.goto(`/race-hub?session_key=${sessionKey}`)
|
||||
await expect(page.getByText('Final Classification')).toBeVisible()
|
||||
await expect(page.locator('.drv-code', { hasText: 'VER' })).toBeVisible()
|
||||
await expect(page.getByTestId('race-hub')).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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user