Rework Race Hub as weekend workspace

This commit is contained in:
2026-05-25 12:35:08 -04:00
parent ee88a07aa1
commit 3bd169c55c
22 changed files with 2019 additions and 313 deletions

View File

@@ -110,16 +110,16 @@ BOXBOX_API_PORT=18080 npm run dev --prefix frontend
| Route | Purpose | | Route | Purpose |
| --- | --- | | --- | --- |
| `/` | **Command Center** — fan-facing race-weekend home with GP identity, live status, session schedule, and analysis links | | `/` | **Command Center** — fan-facing race-weekend home with GP identity, live status, session schedule, and analysis links |
| `/race-hub?session_key=<key>` | **Race Hub**classification, grid, strategy, positions, laps, race control, weather (from local DB) | | `/race-hub?session_key=<key>` | **Race Hub**weekend workspace with session rail and Overview / Race Story / Strategy / Lap Data / Conditions / Race Control / Data Status tabs. Bare `/race-hub` auto-resolves to the focus session. |
| `/admin` | **Admin / Data Health** — ingestion coverage, local data status, and suggested CLI commands | | `/admin` | **Admin / Data Health** — ingestion coverage, local data status, and suggested CLI commands |
| `/data-library` | Legacy alias for Admin / Data Health | | `/data-library` | Legacy alias for Admin / Data Health |
| `/live` | **Live Timing** — timing tower and race control via SSE when a session is live | | `/live` | **Live Timing** — timing tower and race control via SSE when a session is live |
Example after seeding: `http://localhost:5173/race-hub?session_key=9472` Example after seeding: `http://localhost:5173/race-hub?session_key=9472`
## Ingest historical data (OpenF1 → domain DB) ## Ingest historical data and briefing feeds
Ingestion is a **CLI mode** on the same binary. Only one of `--ingest-year`, `--ingest-meeting`, or `--ingest-session` may be set per run. Ingestion is a **CLI mode** on the same binary. Only one of `--ingest-year`, `--ingest-meeting`, `--ingest-session`, or `--ingest-news` may be set per run.
```bash ```bash
# Season: discover and store meeting metadata (2023+) # Season: discover and store meeting metadata (2023+)
@@ -134,6 +134,9 @@ go run ./cmd/main.go --ingest-session 9472
# Preview without writing # Preview without writing
go run ./cmd/main.go --dry-run --ingest-meeting 1229 go run ./cmd/main.go --dry-run --ingest-meeting 1229
# Refresh Paddock Briefing RSS/Atom feeds
go run ./cmd/main.go --ingest-news
# Custom DB path (default: ~/.local/share/box-box/boxbox.db) # Custom DB path (default: ~/.local/share/box-box/boxbox.db)
go run ./cmd/main.go --ingest-meeting 1229 --db /tmp/boxbox.db go run ./cmd/main.go --ingest-meeting 1229 --db /tmp/boxbox.db
``` ```

View 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)

View File

@@ -62,24 +62,22 @@ Known test note: do not run Playwright suites that share the same seeded SQLite
## Recent Commits ## Recent Commits
- `571edb9 Add visual regression coverage`
- `e539abc Add command center screen`
- `9532206 Polish live timing UI`
- `a0f135a Update operator documentation` - `a0f135a Update operator documentation`
- `79b0b9f Rework command center for race weekends` - `79b0b9f Rework command center for race weekends`
- `84a8827 Add paddock briefing RSS backend spike` - `84a8827 Add paddock briefing RSS backend spike`
- `ee88a07 Add paddock briefing feed ingestion`
- `Rework Race Hub as weekend workspace` (latest Phase 19 commit)
## Current Uncommitted Work ## Current State
Two phases are currently uncommitted and need review/integration. Review and Phase 19 and Phase 20 have been reviewed and committed. Start new work from a
commit Phase 19 first, then Phase 20, or stage hunks carefully if separating the clean tree unless `git status --short` shows user changes made after this
shared README/refactor README edits. handoff.
### Phase 19: Weekend Workspace / Race Hub Flow V1 ## Completed Phase 19: Weekend Workspace / Race Hub Flow V1
Claude has completed Phase 19. It is currently uncommitted and needs review. Claude implemented, and Codex reviewed/committed, a Race Hub redesign that turns
`/race-hub` into a Weekend Workspace.
Claude reported these Phase 19 changes:
- New: - New:
- `frontend/src/components/OverviewView.tsx` - `frontend/src/components/OverviewView.tsx`
@@ -97,7 +95,7 @@ Claude reported these Phase 19 changes:
- root `README.md` - root `README.md`
- `documentations/refactor/README.md` - `documentations/refactor/README.md`
Claude reported these UX changes: UX changes:
- Race Hub is now a Weekend Workspace. - Race Hub is now a Weekend Workspace.
- Compact GP identity band with country decal/accent strip. - Compact GP identity band with country decal/accent strip.
@@ -111,32 +109,34 @@ Claude reported these UX changes:
- `/race-hub?session_key=9472` still works and loads Bahrain GP 2024 seeded session. - `/race-hub?session_key=9472` still works and loads Bahrain GP 2024 seeded session.
- Bare `/race-hub` now resolves to a focus weekend/session via `pickFocusMeeting` and navigation replace. - Bare `/race-hub` now resolves to a focus weekend/session via `pickFocusMeeting` and navigation replace.
Claude reported these tests: Verification run by Codex before commit:
- `npm --prefix frontend test -- --run` passed, 98 tests. - `npm --prefix frontend test -- --run`
- `npm --prefix frontend run build` passed. - `npm --prefix frontend run build`
- `npm run test:e2e` passed, 18 tests. - `npm run test:e2e`
- `npm run test:e2e:prod` passed, 6 tests. - `npm run test:e2e:prod`
- `npm run test:visual` passed, 12 screenshots after regenerating race-hub baselines. - `npm run test:visual`
- `npm run test:visual:prod` passed, 12 screenshots. - `npm run test:visual:prod`
### Phase 20: Paddock Briefing Ingestion CLI Small review fix included: `frontend/src/test/setup.ts` stubs
`window.scrollTo` so TanStack Router scroll restoration does not spam jsdom test
stderr.
A backend subagent implemented Phase 20 after the RSS backend spike. It is also ## Completed Phase 20: Paddock Briefing Ingestion CLI
currently uncommitted and needs review.
Reported Phase 20 changes: A backend subagent implemented, and Codex reviewed/committed, Phase 20 after the
RSS backend spike.
Phase 20 changes:
- Modified: - Modified:
- `cmd/main.go` - `cmd/main.go`
- `README.md`
- `documentations/refactor/README.md`
- New: - New:
- `internal/news/refresh.go` - `internal/news/refresh.go`
- `internal/news/refresh_test.go` - `internal/news/refresh_test.go`
- `documentations/refactor/29-phase-20-paddock-briefing-ingestion.md` - `documentations/refactor/29-phase-20-paddock-briefing-ingestion.md`
Implemented behavior: Behavior:
- Adds `--ingest-news` as a CLI mode. - Adds `--ingest-news` as a CLI mode.
- Keeps it mutually exclusive with `--ingest-year`, `--ingest-meeting`, and - Keeps it mutually exclusive with `--ingest-year`, `--ingest-meeting`, and
@@ -159,81 +159,24 @@ go run ./cmd/main.go --dry-run --ingest-news
go run ./cmd/main.go --ingest-news --db /tmp/boxbox.db go run ./cmd/main.go --ingest-news --db /tmp/boxbox.db
``` ```
Phase 20 verification already run by Codex: Verification run by Codex before commit:
```bash ```bash
go test ./cmd/... ./internal/news ./internal/store go test ./cmd/... ./internal/news ./internal/store
go test ./internal/web ./internal/query
go test ./...
git diff --check git diff --check
``` ```
## Immediate Task ## Immediate Task
Start by reviewing and committing Phase 19. Then review and commit Phase 20. Start with a quick sync:
Do not start new implementation until both are accepted and committed.
1. Inspect working tree:
```bash ```bash
git status --short git status --short
git diff --stat git diff --stat
git diff --name-only
``` ```
2. Review Claudes Phase 19 work quickly but responsibly: Then continue with the next requested phase. The most natural next phase is
Phase 21: Paddock Briefing UI, unless the user wants to deepen Race Story first.
- Check `RaceHubPage.tsx`, `OverviewView.tsx`, `WeekendSwitcher.tsx`, `TabBar.tsx`, `DatasetStatusView.tsx`, `app.css`, route/test updates, docs.
- Make sure no admin/CLI guidance leaked back into Race Hub.
- Make sure `/race-hub?session_key=9472` compatibility is preserved.
- Make sure `/admin` remains the admin/data-health surface.
- Confirm visual tests and docs match the changed UX.
3. Run a focused verification pass. At minimum:
```bash
npm --prefix frontend test -- --run
npm --prefix frontend run build
npm run test:e2e
npm run test:visual
```
If time allows or if production behavior changed:
```bash
npm run test:e2e:prod
npm run test:visual:prod
```
4. Patch only small issues if found.
5. Stage only Phase 19 files.
6. Commit with a message like:
```bash
git commit -m "Rework Race Hub as weekend workspace"
```
Then review Phase 20:
1. Check `cmd/main.go`, `internal/news/refresh.go`,
`internal/news/refresh_test.go`,
`documentations/refactor/29-phase-20-paddock-briefing-ingestion.md`, and the
README/refactor README hunks.
2. Confirm the CLI mode does not interfere with OpenF1 ingestion modes or web/TUI
startup.
3. Confirm no live internet tests were added.
4. Re-run targeted backend tests if needed:
```bash
go test ./cmd/... ./internal/news ./internal/store
go test ./...
```
5. Stage Phase 20 files/hunks and commit with a message like:
```bash
git commit -m "Add paddock briefing feed ingestion"
```
## RSS / Paddock Briefing Context ## RSS / Paddock Briefing Context

View File

@@ -92,9 +92,15 @@ not implementation tickets yet.
- [25 Phase 18 Fan Command Center](25-phase-18-fan-command-center.md): reworks - [25 Phase 18 Fan Command Center](25-phase-18-fan-command-center.md): reworks
`/` around race-weekend identity and splits ingestion/admin concerns into `/` around race-weekend identity and splits ingestion/admin concerns into
the new `/admin` (Data Health) route. the new `/admin` (Data Health) route.
- [26 Phase 19 Weekend Workspace](26-phase-19-weekend-workspace.md): rebuilds
`/race-hub` as a session-card-rail workspace with Overview / Race Story /
Strategy / Lap Data / Conditions / Race Control / Data Status tabs,
an inline weekend switcher, and GP-accent identity treatment.
- [27 Phase 19B Paddock Briefing RSS](27-phase-19b-paddock-briefing-rss.md): - [27 Phase 19B Paddock Briefing RSS](27-phase-19b-paddock-briefing-rss.md):
backend spike for publisher-owned RSS/Atom feeds, local SQLite caching, and a backend spike for publisher-owned RSS/Atom feeds, local SQLite caching, and a
future fan-facing briefing API. future fan-facing briefing API.
- [29 Phase 20 Paddock Briefing Ingestion](29-phase-20-paddock-briefing-ingestion.md):
backend CLI slice for refreshing RSS/Atom feeds into the local news cache.
## External References ## External References

View File

@@ -1,37 +1,54 @@
import { Link } from '@tanstack/react-router'
import { RACE_HUB_DATASETS } from '../lib/coverage'
import type { DatasetInfo } from '../types' import type { DatasetInfo } from '../types'
interface Props { interface Props {
datasets: Record<string, DatasetInfo> datasets: Record<string, DatasetInfo>
} }
const KNOWN_DATASETS: { key: string; label: string }[] = [ const DATASET_LABELS: Record<string, string> = {
{ key: 'meeting', label: 'Meeting' }, meeting: 'Meeting',
{ key: 'session', label: 'Session' }, session: 'Session',
{ key: 'drivers', label: 'Drivers' }, drivers: 'Drivers',
{ key: 'results', label: 'Results' }, results: 'Results',
{ key: 'starting_grid', label: 'Starting Grid' }, starting_grid: 'Starting Grid',
] stints: 'Stints',
pit_stops: 'Pit Stops',
positions: 'Positions',
race_control: 'Race Control',
weather: 'Weather',
laps: 'Laps',
}
export function DatasetStatusView({ datasets }: Props) { export function DatasetStatusView({ datasets }: Props) {
const entries = KNOWN_DATASETS.map(({ key, label }) => ({ const entries = RACE_HUB_DATASETS.map((key) => ({
key, key,
label, label: DATASET_LABELS[key] ?? key,
info: datasets[key] as DatasetInfo | undefined, info: datasets[key] as DatasetInfo | undefined,
})) }))
const available = entries.filter((e) => e.info?.status === 'available').length const available = entries.filter((e) => e.info?.status === 'available').length
const total = entries.length const total = entries.length
const missing = total - available
return ( return (
<div> <div data-testid="rh-data-status">
<div className="rh-coverage-meter" aria-hidden="true">
<div
className="rh-coverage-fill"
style={{ width: `${(available / total) * 100}%` }}
/>
</div>
<div className="ds-legend"> <div className="ds-legend">
<span> <span className="mono" style={{ color: 'var(--text-2)' }}>
{available}/{total} datasets available locally {available}/{total} datasets local
</span> </span>
{available < total && ( {missing > 0 && (
<span> <span style={{ color: 'var(--text-3)' }}>
Re-run <code>box-box --ingest-session &lt;key&gt;</code> after backend {missing} dataset{missing === 1 ? '' : 's'} still missing {' '}
support exists for missing datasets. <Link to="/admin" className="rh-inline-link">
manage ingestion
</Link>
.
</span> </span>
)} )}
</div> </div>

View 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 : '') || '',
}
}

View File

@@ -1,22 +1,20 @@
export type Tab = export type Tab =
| 'results' | 'overview'
| 'grid' | 'race_story'
| 'strategy' | 'strategy'
| 'positions' | 'lap_data'
| 'laps' | 'conditions'
| 'race_control' | 'race_control'
| 'weather' | 'data_status'
| 'datasets'
const TABS: { id: Tab; label: string }[] = [ const TABS: { id: Tab; label: string }[] = [
{ id: 'results', label: 'Results' }, { id: 'overview', label: 'Overview' },
{ id: 'grid', label: 'Grid' }, { id: 'race_story', label: 'Race Story' },
{ id: 'strategy', label: 'Strategy' }, { id: 'strategy', label: 'Strategy' },
{ id: 'positions', label: 'Positions' }, { id: 'lap_data', label: 'Lap Data' },
{ id: 'laps', label: 'Laps' }, { id: 'conditions', label: 'Conditions' },
{ id: 'race_control', label: 'Race Control' }, { id: 'race_control', label: 'Race Control' },
{ id: 'weather', label: 'Weather' }, { id: 'data_status', label: 'Data Status' },
{ id: 'datasets', label: 'Datasets' },
] ]
interface Props { interface Props {

View 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>
)
}

View File

@@ -1,9 +1,12 @@
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { fetchRaceHub } from '../api' import {
import { LocalDataNavigator } from '../components/LocalDataNavigator' fetchLocalMeetings,
import { RaceHubHeader } from '../components/RaceHubHeader' fetchRaceHub,
fetchSeasons,
fetchWeekend,
} from '../api'
import { DatasetStrip } from '../components/DatasetStrip' import { DatasetStrip } from '../components/DatasetStrip'
import { ClassificationTable } from '../components/ClassificationTable' import { ClassificationTable } from '../components/ClassificationTable'
import { StartingGridTable } from '../components/StartingGridTable' import { StartingGridTable } from '../components/StartingGridTable'
@@ -14,123 +17,339 @@ import { PositionEvolutionView } from '../components/PositionEvolutionView'
import { LapsView } from '../components/LapsView' import { LapsView } from '../components/LapsView'
import { RaceControlView } from '../components/RaceControlView' import { RaceControlView } from '../components/RaceControlView'
import { WeatherView } from '../components/WeatherView' import { WeatherView } from '../components/WeatherView'
import { OverviewView } from '../components/OverviewView'
import { WeekendSwitcher } from '../components/WeekendSwitcher'
import { SourceBadge } from '../components/SourceBadge'
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
import {
formatSessionScheduleTime,
pickFocusMeeting,
sortSessionsByStart,
} from '../lib/schedule'
import type { Weekend, WeekendSession } from '../types'
interface Props { interface Props {
sessionKey: number sessionKey: number
} }
type RaceStorySubview = 'classification' | 'grid' | 'positions'
function pickAnalysisSession(weekend: Weekend | undefined): WeekendSession | undefined {
if (!weekend) return undefined
const local = weekend.sessions.filter((s) => s.source === 'local')
const partial = weekend.sessions.filter((s) => s.source === 'partial')
const pool = local.length > 0 ? local : partial.length > 0 ? partial : weekend.sessions
const race = pool.find((s) => s.session.session_type?.toLowerCase().includes('race'))
if (race) return race
const qual = pool.find((s) => s.session.session_type?.toLowerCase().includes('qualifying'))
if (qual) return qual
return pool[0]
}
export function RaceHubPage({ sessionKey }: Props) { export function RaceHubPage({ sessionKey }: Props) {
const navigate = useNavigate() const navigate = useNavigate()
const [inputVal, setInputVal] = useState(sessionKey > 0 ? String(sessionKey) : '') const [activeTab, setActiveTab] = useState<Tab>('overview')
const [activeTab, setActiveTab] = useState<Tab>('results') const [storyView, setStoryView] = useState<RaceStorySubview>('classification')
const [switcherOpen, setSwitcherOpen] = useState(false)
// ─── Auto-redirect when no session_key is supplied ───
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: fetchSeasons,
enabled: sessionKey === 0,
})
const latestSeason = seasonsQuery.data?.[0] ?? null
const meetingsQuery = useQuery({
queryKey: ['meetings', latestSeason],
queryFn: () => fetchLocalMeetings(latestSeason!),
enabled: sessionKey === 0 && latestSeason != null,
})
const focusMeeting = useMemo(() => {
if (sessionKey !== 0 || !meetingsQuery.data) return null
return pickFocusMeeting(meetingsQuery.data, new Date())
}, [sessionKey, meetingsQuery.data])
const fallbackWeekendQuery = useQuery({
queryKey: ['weekend', focusMeeting?.meeting_key],
queryFn: () => fetchWeekend(focusMeeting!.meeting_key),
enabled: sessionKey === 0 && focusMeeting != null,
})
useEffect(() => { useEffect(() => {
setInputVal(sessionKey > 0 ? String(sessionKey) : '') if (sessionKey !== 0) return
}, [sessionKey]) const weekend = fallbackWeekendQuery.data
if (!weekend) return
const target = pickAnalysisSession(weekend)?.session.session_key
?? weekend.default_session_key
?? weekend.sessions[0]?.session.session_key
if (target) {
navigate({ to: '/race-hub', search: { session_key: target }, replace: true })
}
}, [sessionKey, fallbackWeekendQuery.data, navigate])
const { data, isLoading, isError, error } = useQuery({ // ─── Active session payload ───
const raceHubQuery = useQuery({
queryKey: ['race-hub', sessionKey], queryKey: ['race-hub', sessionKey],
queryFn: () => fetchRaceHub(sessionKey), queryFn: () => fetchRaceHub(sessionKey),
enabled: sessionKey > 0, enabled: sessionKey > 0,
staleTime: 30_000, staleTime: 30_000,
}) })
function handleLoad(e: React.FormEvent) { const meetingKey = raceHubQuery.data?.meeting?.meeting_key
e.preventDefault() const weekendQuery = useQuery({
const key = parseInt(inputVal, 10) queryKey: ['weekend', meetingKey],
if (key > 0) { queryFn: () => fetchWeekend(meetingKey!),
navigate({ to: '/race-hub', search: { session_key: key } }) enabled: meetingKey != null && meetingKey > 0,
staleTime: 60_000,
})
const data = raceHubQuery.data
const weekend = weekendQuery.data
const accent = countryAccent(data?.meeting ?? null)
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
// ─── No session_key: show resolving state, fall back to switcher if no local data ───
if (sessionKey === 0) {
if (seasonsQuery.isLoading || meetingsQuery.isLoading || fallbackWeekendQuery.isLoading) {
return (
<div className="rh-page" style={accentStyle}>
<div className="loading-state">resolving latest local weekend</div>
</div>
)
} }
const seasons = seasonsQuery.data ?? []
if (seasons.length === 0) {
return (
<div className="rh-page rh-empty" data-testid="race-hub-empty" style={accentStyle}>
<div className="rh-empty-band">
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
<h1 className="rh-empty-title">No local sessions yet</h1>
<p className="rh-empty-sub">
The Race Hub reads from local ingest only. Once a weekend is ingested
it will open here automatically.
</p>
<div className="rh-empty-actions">
<a href="/admin" className="rh-empty-action">Open Admin · Data Health</a>
<a href="/" className="rh-empty-action">Back to Command Center</a>
</div>
</div>
</div>
)
}
return (
<div className="rh-page" style={accentStyle}>
<div className="loading-state">resolving latest local weekend</div>
</div>
)
} }
return ( // ─── Loading / error for the requested session_key ───
<div className="page"> if (raceHubQuery.isLoading) {
{/* Session key input */} return (
<form className="session-bar" onSubmit={handleLoad}> <div className="rh-page" style={accentStyle}>
<label htmlFor="sk-input">Session Key</label>
<input
id="sk-input"
type="number"
placeholder="e.g. 9472"
value={inputVal}
onChange={(e) => setInputVal(e.target.value)}
/>
<button type="submit">Load</button>
{sessionKey > 0 && (
<span style={{ fontFamily: 'var(--f-mono)', fontSize: 10, color: 'var(--text-3)' }}>
key {sessionKey}
</span>
)}
</form>
{/* Local data browser when no session loaded */}
{sessionKey === 0 && <LocalDataNavigator />}
{/* Loading */}
{sessionKey > 0 && isLoading && (
<div className="loading-state">loading session {sessionKey}</div> <div className="loading-state">loading session {sessionKey}</div>
</div>
)
}
if (raceHubQuery.isError || !data) {
return (
<div className="rh-page" style={accentStyle}>
<div className="error-box">
{raceHubQuery.error instanceof Error
? raceHubQuery.error.message
: `Failed to load session ${sessionKey}.`}
</div>
</div>
)
}
const decal = countryDecal(data.meeting ?? null)
const sessions = weekend ? sortSessionsByStart(weekend.sessions.map((w) => w.session)) : []
const sessionMeta = weekend
? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w]))
: {}
const activeSessionMeta = sessionMeta[sessionKey]
return (
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
{/* Topbar */}
<div className="rh-topbar">
<span className="rh-topbar-label mono">
box-box · race hub
{data.meeting?.year ? ` · ${data.meeting.year}` : ''}
</span>
<span className="rh-topbar-spacer" />
<SourceBadge source={data.source} />
<button
type="button"
className={`rh-switcher-toggle${switcherOpen ? ' active' : ''}`}
onClick={() => setSwitcherOpen((v) => !v)}
aria-expanded={switcherOpen}
data-testid="rh-switch-weekend"
>
{switcherOpen ? 'Close' : 'Switch Weekend'}
</button>
</div>
{switcherOpen && (
<WeekendSwitcher
currentMeetingKey={meetingKey}
currentSessionKey={sessionKey}
onClose={() => setSwitcherOpen(false)}
/>
)} )}
{/* Error */} {/* GP Identity band */}
{isError && ( {data.meeting && (
<div className="error-box"> <section className="rh-identity" data-testid="rh-identity">
{error instanceof Error ? error.message : 'Failed to load race hub data'} <div className="rh-identity-accent" aria-hidden="true" />
<div className="rh-identity-body">
<span className="rh-identity-decal mono">{decal}</span>
<div className="rh-identity-titles">
<h1 className="rh-identity-name">{data.meeting.meeting_name}</h1>
<div className="rh-identity-sub mono">
{[data.meeting.location, data.meeting.circuit_short_name]
.filter(Boolean)
.join(' · ')}
</div>
<div className="rh-identity-sub mono rh-identity-dates">
{formatGpDateRange(data.meeting)}
</div>
</div>
</div>
</section>
)}
{/* Session rail */}
{sessions.length > 0 && (
<nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail">
{sessions.map((session) => {
const meta = sessionMeta[session.session_key]
const active = session.session_key === sessionKey
return (
<button
key={session.session_key}
type="button"
className={`rh-session-chip${active ? ' active' : ''}`}
onClick={() =>
navigate({
to: '/race-hub',
search: { session_key: session.session_key },
})
}
aria-current={active ? 'page' : undefined}
data-testid={`rh-session-${session.session_key}`}
>
<span className="rh-session-abbrev mono">
{sessionTypeAbbrev(session.session_type, session.session_name)}
</span>
<span className="rh-session-name">{session.session_name}</span>
<span className="rh-session-time mono">
{formatSessionScheduleTime(session.date_start)}
</span>
{meta && (
<span className="rh-session-cov mono">
<span
className={`cc-cov-dot cc-cov-${meta.source}`}
aria-hidden="true"
/>
{formatCoverageHint(meta.datasets)}
</span>
)}
</button>
)
})}
</nav>
)}
{/* Active session sub-bar */}
{data.session && (
<div className="rh-active-bar" data-testid="rh-active-bar">
<span className="rh-active-name">{data.session.session_name}</span>
<span className="rh-active-meta mono">
{formatSessionScheduleTime(data.session.date_start)}
</span>
{activeSessionMeta && (
<span className="rh-active-cov mono">
<span
className={`cc-cov-dot cc-cov-${activeSessionMeta.source}`}
aria-hidden="true"
/>
{formatCoverageHint(activeSessionMeta.datasets)} datasets local
</span>
)}
<span className="rh-active-key mono">key {sessionKey}</span>
</div> </div>
)} )}
{/* Data */} <DatasetStrip datasets={data.datasets} />
{data && (
<>
<RaceHubHeader
meeting={data.meeting}
session={data.session}
source={data.source}
/>
<DatasetStrip datasets={data.datasets} /> <TabBar active={activeTab} onChange={setActiveTab} />
<TabBar active={activeTab} onChange={setActiveTab} /> {activeTab === 'overview' && <OverviewView data={data} />}
{activeTab === 'results' && ( {activeTab === 'race_story' && (
<div className="data-section"> <div className="data-section">
<div className="rh-story-controls" role="tablist" aria-label="Race story view">
<button
type="button"
role="tab"
aria-selected={storyView === 'classification'}
className={`rh-story-btn${storyView === 'classification' ? ' active' : ''}`}
onClick={() => setStoryView('classification')}
>
Classification
</button>
<button
type="button"
role="tab"
aria-selected={storyView === 'grid'}
className={`rh-story-btn${storyView === 'grid' ? ' active' : ''}`}
onClick={() => setStoryView('grid')}
>
Starting Grid
</button>
<button
type="button"
role="tab"
aria-selected={storyView === 'positions'}
className={`rh-story-btn${storyView === 'positions' ? ' active' : ''}`}
onClick={() => setStoryView('positions')}
>
Positions
</button>
</div>
{storyView === 'classification' && (
<>
<div className="sec-header"> <div className="sec-header">
<span className="sec-title">Final Classification</span> <span className="sec-title">Final Classification</span>
{data.results.length > 0 && ( {data.results.length > 0 && (
<span className="sec-meta">{data.results.length} drivers</span> <span className="sec-meta mono">{data.results.length} drivers</span>
)} )}
</div> </div>
<ClassificationTable results={data.results} grid={data.starting_grid} /> <ClassificationTable results={data.results} grid={data.starting_grid} />
</div> </>
)} )}
{activeTab === 'grid' && ( {storyView === 'grid' && (
<div className="data-section"> <>
<div className="sec-header"> <div className="sec-header">
<span className="sec-title">Starting Grid</span> <span className="sec-title">Starting Grid</span>
{data.starting_grid.length > 0 && ( {data.starting_grid.length > 0 && (
<span className="sec-meta">{data.starting_grid.length} positions</span> <span className="sec-meta mono">{data.starting_grid.length} positions</span>
)} )}
</div> </div>
<StartingGridTable grid={data.starting_grid} /> <StartingGridTable grid={data.starting_grid} />
</div> </>
)} )}
{activeTab === 'strategy' && ( {storyView === 'positions' && (
<div className="data-section"> <>
<div className="sec-header">
<span className="sec-title">Race Strategy</span>
</div>
<StrategyView
results={data.results}
stints={data.stints}
pit_stops={data.pit_stops}
hasStints={data.datasets['stints']?.status === 'available'}
/>
</div>
)}
{activeTab === 'positions' && (
<div className="data-section">
<div className="sec-header"> <div className="sec-header">
<span className="sec-title">Position Evolution</span> <span className="sec-title">Position Evolution</span>
</div> </div>
@@ -141,54 +360,68 @@ export function RaceHubPage({ sessionKey }: Props) {
laps={data.laps} laps={data.laps}
hasPositions={data.datasets['positions']?.status === 'available'} hasPositions={data.datasets['positions']?.status === 'available'}
/> />
</div> </>
)} )}
</div>
)}
{activeTab === 'laps' && ( {activeTab === 'strategy' && (
<div className="data-section"> <div className="data-section">
<div className="sec-header"> <div className="sec-header">
<span className="sec-title">Laps</span> <span className="sec-title">Race Strategy</span>
{data.laps.length > 0 && ( </div>
<span className="sec-meta">{data.laps.length} samples</span> <StrategyView
)} results={data.results}
</div> stints={data.stints}
<LapsView laps={data.laps} /> pit_stops={data.pit_stops}
</div> hasStints={data.datasets['stints']?.status === 'available'}
)} />
</div>
)}
{activeTab === 'race_control' && ( {activeTab === 'lap_data' && (
<div className="data-section"> <div className="data-section">
<div className="sec-header"> <div className="sec-header">
<span className="sec-title">Race Control</span> <span className="sec-title">Lap Data</span>
{data.race_control.length > 0 && ( {data.laps.length > 0 && (
<span className="sec-meta">{data.race_control.length} messages</span> <span className="sec-meta mono">{data.laps.length} samples</span>
)} )}
</div> </div>
<RaceControlView messages={data.race_control} /> <LapsView laps={data.laps} />
</div> </div>
)} )}
{activeTab === 'weather' && ( {activeTab === 'conditions' && (
<div className="data-section"> <div className="data-section">
<div className="sec-header"> <div className="sec-header">
<span className="sec-title">Weather</span> <span className="sec-title">Conditions</span>
{data.weather.length > 0 && ( {data.weather.length > 0 && (
<span className="sec-meta">{data.weather.length} samples</span> <span className="sec-meta mono">{data.weather.length} samples</span>
)} )}
</div> </div>
<WeatherView weather={data.weather} /> <WeatherView weather={data.weather} />
</div> </div>
)} )}
{activeTab === 'datasets' && ( {activeTab === 'race_control' && (
<div className="data-section"> <div className="data-section">
<div className="sec-header"> <div className="sec-header">
<span className="sec-title">Dataset Status</span> <span className="sec-title">Race Control</span>
</div> {data.race_control.length > 0 && (
<DatasetStatusView datasets={data.datasets} /> <span className="sec-meta mono">{data.race_control.length} messages</span>
</div> )}
)} </div>
</> <RaceControlView messages={data.race_control} />
</div>
)}
{activeTab === 'data_status' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Data Status</span>
</div>
<DatasetStatusView datasets={data.datasets} />
</div>
)} )}
</div> </div>
) )

View File

@@ -1602,6 +1602,620 @@ a { color: inherit; text-decoration: none; }
.mono { font-family: var(--f-mono); } .mono { font-family: var(--f-mono); }
/* ── Race Hub Weekend Workspace ── */
.rh-page {
max-width: 1160px;
margin: 0 auto;
padding: var(--s5) var(--s6);
display: flex;
flex-direction: column;
gap: var(--s5);
--gp-accent: var(--red);
}
.rh-topbar {
display: flex;
align-items: center;
gap: var(--s4);
padding-bottom: var(--s4);
border-bottom: 1px solid var(--border);
}
.rh-topbar-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-3);
}
.rh-topbar-spacer { flex: 1; }
.rh-switcher-toggle {
padding: 4px 10px;
font-size: 10px;
font-weight: 700;
font-family: var(--f-mono);
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-2);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 2px;
cursor: pointer;
transition: color 0.1s, border-color 0.1s, background 0.1s;
}
.rh-switcher-toggle:hover {
color: var(--text);
border-color: var(--border-2);
background: var(--surface-h);
}
.rh-switcher-toggle.active {
color: var(--text);
border-color: var(--gp-accent);
background: var(--surface-h);
}
/* GP identity band (compact) */
.rh-identity {
display: flex;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
min-height: 96px;
}
.rh-identity-accent {
width: 5px;
background: var(--gp-accent);
flex-shrink: 0;
}
.rh-identity-body {
flex: 1;
padding: var(--s4) var(--s5);
display: flex;
align-items: center;
gap: var(--s5);
min-width: 0;
}
.rh-identity-decal {
font-size: 40px;
font-weight: 800;
letter-spacing: -0.04em;
line-height: 0.9;
color: var(--text);
opacity: 0.9;
flex-shrink: 0;
}
.rh-identity-titles {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.rh-identity-name {
font-size: 20px;
font-weight: 700;
line-height: 1.15;
}
.rh-identity-sub {
font-size: 11px;
color: var(--text-3);
}
.rh-identity-dates { color: var(--text-2); }
/* Session rail */
.rh-session-rail {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
gap: var(--s3);
padding-bottom: var(--s2);
}
.rh-session-rail::-webkit-scrollbar { height: 4px; }
.rh-session-rail::-webkit-scrollbar-thumb { background: var(--border-2); }
.rh-session-chip {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto auto;
gap: 2px var(--s3);
flex: 0 0 auto;
min-width: 168px;
padding: var(--s3) var(--s4);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
text-align: left;
font: inherit;
color: inherit;
transition: background 0.1s, border-color 0.1s;
}
.rh-session-chip:hover {
background: var(--surface-h);
border-color: var(--border-2);
}
.rh-session-chip.active {
background: rgba(225, 6, 0, 0.05);
border-color: var(--gp-accent);
box-shadow: inset 3px 0 0 var(--gp-accent);
}
.rh-session-chip .rh-session-abbrev {
grid-row: 1 / span 3;
align-self: center;
font-size: 14px;
font-weight: 800;
letter-spacing: 0.04em;
color: var(--text);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 2px;
padding: 4px 7px;
min-width: 36px;
text-align: center;
}
.rh-session-chip.active .rh-session-abbrev {
color: var(--gp-accent);
border-color: var(--gp-accent);
}
.rh-session-chip .rh-session-name {
font-size: 12px;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rh-session-chip .rh-session-time {
font-size: 10px;
color: var(--text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rh-session-chip .rh-session-cov {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 10px;
color: var(--text-3);
}
/* Active session sub-bar */
.rh-active-bar {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--s4);
padding: var(--s3) 0;
border-bottom: 1px solid var(--border);
}
.rh-active-bar .rh-active-name {
font-size: 14px;
font-weight: 700;
color: var(--text);
}
.rh-active-bar .rh-active-meta,
.rh-active-bar .rh-active-cov {
font-size: 11px;
color: var(--text-3);
display: inline-flex;
align-items: center;
gap: 5px;
}
.rh-active-bar .rh-active-key {
margin-left: auto;
font-size: 10px;
color: var(--text-3);
letter-spacing: 0.05em;
}
/* Race Story sub-controls */
.rh-story-controls {
display: inline-flex;
border: 1px solid var(--border);
border-radius: 2px;
margin-bottom: var(--s4);
overflow: hidden;
}
.rh-story-btn {
padding: 5px 12px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
background: var(--surface);
border: none;
border-right: 1px solid var(--border);
cursor: pointer;
transition: color 0.1s, background 0.1s;
}
.rh-story-btn:last-child { border-right: none; }
.rh-story-btn:hover { color: var(--text-2); }
.rh-story-btn.active {
color: var(--text);
background: var(--surface-h);
box-shadow: inset 0 -2px 0 var(--gp-accent);
}
/* Overview tab */
.rh-overview { display: flex; flex-direction: column; gap: var(--s5); }
.rh-stat-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--s3);
}
.rh-stat-card {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--s4);
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--gp-accent);
border-radius: 4px;
min-width: 0;
}
.rh-stat-card.rh-stat-empty { border-left-color: var(--border-2); }
.rh-stat-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-3);
}
.rh-stat-primary {
font-size: 22px;
font-weight: 700;
font-family: var(--f-mono);
color: var(--text);
line-height: 1.1;
}
.rh-stat-secondary {
font-size: 12px;
color: var(--text-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rh-stat-tertiary {
font-size: 11px;
color: var(--text-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rh-stat-highlight {
margin-top: 2px;
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.rh-podium-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 3px;
margin-top: 4px;
}
.rh-podium-row {
display: grid;
grid-template-columns: 28px 1fr auto;
gap: var(--s3);
align-items: baseline;
font-size: 12px;
}
.rh-podium-pos { color: var(--text-3); }
.rh-podium-p1 .rh-podium-pos { color: #ffd700; font-weight: 700; }
.rh-podium-p2 .rh-podium-pos { color: #c0c0c0; font-weight: 700; }
.rh-podium-p3 .rh-podium-pos { color: #cd7f32; font-weight: 700; }
.rh-podium-driver { font-weight: 700; font-family: var(--f-mono); }
.rh-podium-gap { color: var(--text-3); font-size: 11px; }
.rh-overview-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--s4);
}
.rh-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
padding: var(--s4);
min-width: 0;
}
.rh-empty-line { font-size: 11px; color: var(--text-3); }
.rh-condition-strip {
display: flex;
flex-wrap: wrap;
gap: var(--s3);
}
.rh-condition-chip {
display: flex;
flex-direction: column;
gap: 1px;
padding: 4px 10px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 2px;
min-width: 64px;
}
.rh-condition-chip.rh-condition-wet { border-color: var(--tyre-wet); }
.rh-condition-label {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-3);
}
.rh-condition-value {
font-size: 13px;
font-weight: 600;
font-family: var(--f-mono);
color: var(--text);
}
.rh-rc-list { list-style: none; display: flex; flex-direction: column; gap: 4px; }
.rh-rc-row {
display: grid;
grid-template-columns: 50px 60px 1fr;
gap: var(--s3);
align-items: baseline;
font-size: 11px;
}
.rh-rc-time { color: var(--text-3); }
.rh-rc-flag {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-2);
}
.rh-rc-flag-yellow { color: var(--yellow); }
.rh-rc-flag-red { color: var(--red); }
.rh-rc-flag-green { color: var(--green); }
.rh-rc-flag-blue { color: #3a6cf5; }
.rh-rc-msg { color: var(--text-2); overflow: hidden; text-overflow: ellipsis; }
.rh-coverage-meter {
height: 6px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 2px;
overflow: hidden;
margin-bottom: var(--s3);
}
.rh-coverage-fill {
height: 100%;
background: var(--gp-accent);
transition: width 0.2s ease-out;
}
.rh-inline-link {
color: var(--text-2);
text-decoration: underline;
text-underline-offset: 2px;
}
.rh-inline-link:hover { color: var(--text); }
/* Weekend switcher overlay panel */
.rh-switcher {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
padding: var(--s4);
}
.rh-switcher-head {
display: flex;
align-items: center;
gap: var(--s4);
flex-wrap: wrap;
margin-bottom: var(--s4);
}
.rh-switcher-years {
display: flex;
gap: var(--s2);
margin-right: auto;
}
.rh-switcher-year {
padding: 2px 8px;
font-size: 11px;
font-family: var(--f-mono);
font-weight: 700;
color: var(--text-3);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 2px;
cursor: pointer;
}
.rh-switcher-year:hover { color: var(--text-2); }
.rh-switcher-year.active {
color: var(--text);
border-color: var(--gp-accent);
background: var(--surface-h);
}
.rh-switcher-close {
padding: 3px 10px;
font-size: 10px;
font-family: var(--f-mono);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-3);
background: transparent;
border: 1px solid var(--border);
border-radius: 2px;
cursor: pointer;
}
.rh-switcher-close:hover { color: var(--text); border-color: var(--border-2); }
.rh-switcher-empty {
font-size: 11px;
color: var(--text-3);
padding: var(--s3) 0;
}
.rh-switcher-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: var(--s3);
}
.rh-switcher-mtg {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
}
.rh-switcher-mtg.current { border-color: var(--gp-accent); }
.rh-switcher-mtg-head {
display: flex;
align-items: center;
gap: var(--s3);
padding: var(--s3) var(--s4);
width: 100%;
background: transparent;
border: none;
color: inherit;
text-align: left;
cursor: pointer;
}
.rh-switcher-mtg-head:hover { background: var(--surface-h); }
.rh-switcher-decal {
font-size: 12px;
font-weight: 800;
color: var(--text-2);
min-width: 28px;
}
.rh-switcher-mtg-name {
flex: 1;
font-size: 12px;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rh-switcher-mtg-meta {
font-size: 10px;
color: var(--text-3);
}
.rh-switcher-sessions {
display: flex;
flex-direction: column;
padding: 0 var(--s2) var(--s2);
}
.rh-switcher-session {
display: grid;
grid-template-columns: 36px 1fr auto;
gap: var(--s3);
align-items: center;
padding: 4px 8px;
background: transparent;
border: none;
border-radius: 2px;
cursor: pointer;
color: inherit;
text-align: left;
}
.rh-switcher-session:hover { background: var(--surface-h); }
.rh-switcher-session.active {
background: rgba(225, 6, 0, 0.05);
box-shadow: inset 3px 0 0 var(--gp-accent);
}
.rh-switcher-sess-abbrev {
font-size: 10px;
font-weight: 800;
text-align: center;
color: var(--text-2);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 2px;
padding: 1px 4px;
}
.rh-switcher-sess-name {
font-size: 12px;
font-weight: 500;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rh-switcher-sess-cov {
font-size: 10px;
color: var(--text-3);
display: inline-flex;
align-items: center;
gap: 4px;
}
/* Race Hub empty state */
.rh-empty { max-width: 720px; gap: var(--s5); }
.rh-empty-band {
padding: var(--s6) var(--s5);
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--gp-accent);
border-radius: 4px;
display: flex;
flex-direction: column;
gap: var(--s2);
}
.rh-empty-eyebrow {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-3);
}
.rh-empty-title { font-size: 22px; font-weight: 700; }
.rh-empty-sub { font-size: 13px; color: var(--text-2); max-width: 56ch; }
.rh-empty-actions { display: flex; flex-wrap: wrap; gap: var(--s3); }
.rh-empty-action {
padding: var(--s3) var(--s4);
font-size: 12px;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
}
.rh-empty-action:hover { background: var(--surface-h); border-color: var(--border-2); }
@media (max-width: 900px) {
.rh-overview-row { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
.rh-page { padding: var(--s4); gap: var(--s4); }
.rh-identity-body { padding: var(--s3) var(--s4); gap: var(--s3); }
.rh-identity-decal { font-size: 28px; }
.rh-identity-name { font-size: 17px; }
.rh-session-chip { min-width: 152px; }
.rh-stat-primary { font-size: 18px; }
.rh-active-bar .rh-active-key { margin-left: 0; }
.rh-switcher-grid { grid-template-columns: 1fr; }
}
/* Tablet */ /* Tablet */
@media (max-width: 900px) { @media (max-width: 900px) {
.cc-band-row { .cc-band-row {

View File

@@ -1,55 +1,90 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react' import { render, screen, waitFor } from '@testing-library/react'
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
import { DatasetStatusView } from '../components/DatasetStatusView' import { DatasetStatusView } from '../components/DatasetStatusView'
import type { DatasetInfo } from '../types' import type { DatasetInfo } from '../types'
const allAvailable: Record<string, DatasetInfo> = { const fullDatasets: Record<string, DatasetInfo> = {
meeting: { status: 'available', source: 'local', count: 1 }, meeting: { status: 'available', source: 'local', count: 1 },
session: { status: 'available', source: 'local', count: 1 }, session: { status: 'available', source: 'local', count: 1 },
drivers: { status: 'available', source: 'local', count: 20 }, drivers: { status: 'available', source: 'local', count: 20 },
results: { status: 'available', source: 'local', count: 20 }, results: { status: 'available', source: 'local', count: 20 },
starting_grid: { status: 'available', source: 'local', count: 20 }, starting_grid: { status: 'available', source: 'local', count: 20 },
stints: { status: 'available', source: 'local', count: 30 },
pit_stops: { status: 'available', source: 'local', count: 18 },
positions: { status: 'available', source: 'local', count: 120 },
race_control: { status: 'available', source: 'local', count: 5 },
weather: { status: 'available', source: 'local', count: 4 },
laps: { status: 'available', source: 'local', count: 200 },
} }
const partial: Record<string, DatasetInfo> = { const coreOnly: Record<string, DatasetInfo> = {
meeting: { status: 'available', source: 'local', count: 1 }, meeting: { status: 'available', source: 'local', count: 1 },
session: { status: 'available', source: 'local', count: 1 }, session: { status: 'available', source: 'local', count: 1 },
drivers: { status: 'missing', source: 'none' }, drivers: { status: 'available', source: 'local', count: 20 },
results: { status: 'missing', source: 'none' }, results: { status: 'available', source: 'local', count: 20 },
starting_grid: { status: 'missing', source: 'none' }, starting_grid: { status: 'available', source: 'local', count: 20 },
stints: { status: 'missing', source: 'none' },
pit_stops: { status: 'missing', source: 'none' },
positions: { status: 'missing', source: 'none' },
race_control: { status: 'missing', source: 'none' },
weather: { status: 'missing', source: 'none' },
laps: { status: 'missing', source: 'none' },
}
function renderView(datasets: Record<string, DatasetInfo>) {
const rootRoute = createRootRoute({
component: () => <DatasetStatusView datasets={datasets} />,
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => null,
})
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
return render(<RouterProvider router={router} />)
} }
describe('DatasetStatusView', () => { describe('DatasetStatusView', () => {
it('shows 5/5 when all available', () => { it('shows 11/11 when all datasets are available', async () => {
render(<DatasetStatusView datasets={allAvailable} />) renderView(fullDatasets)
expect(screen.getByText(/5\/5/)).toBeInTheDocument() await waitFor(() =>
expect(screen.getByText(/11\/11 datasets local/)).toBeInTheDocument(),
)
}) })
it('shows local badges for available datasets', () => { it('shows a Local badge for every available dataset', async () => {
render(<DatasetStatusView datasets={allAvailable} />) renderView(fullDatasets)
const localBadges = screen.getAllByText('Local') await waitFor(() => expect(screen.getAllByText('Local')).toHaveLength(11))
expect(localBadges).toHaveLength(5)
}) })
it('shows missing badges for missing datasets', () => { it('shows Missing badges for missing datasets', async () => {
render(<DatasetStatusView datasets={partial} />) renderView(coreOnly)
const missingBadges = screen.getAllByText('Missing') await waitFor(() => expect(screen.getAllByText('Missing')).toHaveLength(6))
expect(missingBadges).toHaveLength(3)
}) })
it('shows the ingest command when data is missing', () => { it('links to admin when datasets are missing instead of inlining CLI commands', async () => {
render(<DatasetStatusView datasets={partial} />) renderView(coreOnly)
expect(screen.getByText(/ingest-session/i)).toBeInTheDocument() await waitFor(() => {
}) expect(screen.getByRole('link', { name: /manage ingestion/i })).toHaveAttribute(
'href',
it('does not show ingest command when all available', () => { '/admin',
render(<DatasetStatusView datasets={allAvailable} />) )
})
expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument() expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument()
}) })
it('shows record counts', () => { it('does not surface ingest hints when fully covered', async () => {
render(<DatasetStatusView datasets={allAvailable} />) renderView(fullDatasets)
const twenties = screen.getAllByText('20') await waitFor(() => expect(screen.getByText(/11\/11/)).toBeInTheDocument())
expect(twenties.length).toBeGreaterThan(0) expect(screen.queryByText(/manage ingestion/i)).not.toBeInTheDocument()
})
it('renders record counts from the dataset payload', async () => {
renderView(fullDatasets)
await waitFor(() => {
const twenties = screen.getAllByText('20')
expect(twenties.length).toBeGreaterThan(0)
})
}) })
}) })

View 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()
})
})

View File

@@ -3,35 +3,34 @@ import { render, screen, fireEvent } from '@testing-library/react'
import { TabBar } from '../components/TabBar' import { TabBar } from '../components/TabBar'
describe('TabBar', () => { describe('TabBar', () => {
it('renders all Race Hub tabs', () => { it('renders all Race Hub workspace tabs', () => {
render(<TabBar active="results" onChange={() => {}} />) render(<TabBar active="overview" onChange={() => {}} />)
expect(screen.getByRole('tab', { name: 'Results' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Grid' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Race Story' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Strategy' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Strategy' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Positions' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Laps' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Conditions' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Weather' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Data Status' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Datasets' })).toBeInTheDocument()
}) })
it('marks the active tab with aria-selected', () => { it('marks the active tab with aria-selected', () => {
render(<TabBar active="grid" onChange={() => {}} />) render(<TabBar active="strategy" onChange={() => {}} />)
expect(screen.getByRole('tab', { name: 'Grid' })).toHaveAttribute('aria-selected', 'true') expect(screen.getByRole('tab', { name: 'Strategy' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('tab', { name: 'Results' })).toHaveAttribute('aria-selected', 'false') expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute('aria-selected', 'false')
}) })
it('applies active class only to the active tab', () => { it('applies active class only to the active tab', () => {
render(<TabBar active="strategy" onChange={() => {}} />) render(<TabBar active="conditions" onChange={() => {}} />)
const strategy = screen.getByRole('tab', { name: 'Strategy' }) const active = screen.getByRole('tab', { name: 'Conditions' })
const results = screen.getByRole('tab', { name: 'Results' }) const inactive = screen.getByRole('tab', { name: 'Overview' })
expect(strategy.className).toContain('active') expect(active.className).toContain('active')
expect(results.className).not.toContain('active') expect(inactive.className).not.toContain('active')
}) })
it('calls onChange with the correct tab id when clicked', () => { it('calls onChange with the correct tab id when clicked', () => {
const onChange = vi.fn() const onChange = vi.fn()
render(<TabBar active="results" onChange={onChange} />) render(<TabBar active="overview" onChange={onChange} />)
fireEvent.click(screen.getByRole('tab', { name: 'Race Control' })) fireEvent.click(screen.getByRole('tab', { name: 'Race Control' }))
expect(onChange).toHaveBeenCalledWith('race_control') expect(onChange).toHaveBeenCalledWith('race_control')
}) })

View File

@@ -1 +1,6 @@
import '@testing-library/jest-dom' import '@testing-library/jest-dom'
Object.defineProperty(window, 'scrollTo', {
value: () => {},
writable: true,
})

View File

@@ -13,7 +13,7 @@ test.describe('Command Center', () => {
}) })
test('nav link reaches command center from race hub', async ({ page }) => { test('nav link reaches command center from race hub', async ({ page }) => {
await page.goto('/race-hub') await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await page.getByRole('link', { name: 'Command' }).click() await page.getByRole('link', { name: 'Command' }).click()
await expect(page).toHaveURL('/') await expect(page).toHaveURL('/')
await expect(page.getByTestId('command-center')).toBeVisible() await expect(page.getByTestId('command-center')).toBeVisible()
@@ -24,12 +24,14 @@ test.describe('Command Center', () => {
await expect(page.getByTestId('cc-action-race-hub')).toContainText(String(FULL_SESSION)) await expect(page.getByTestId('cc-action-race-hub')).toContainText(String(FULL_SESSION))
await page.getByTestId('cc-action-race-hub').click() await page.getByTestId('cc-action-race-hub').click()
await expect(page).toHaveURL(new RegExp(`/race-hub\\?session_key=${FULL_SESSION}`)) await expect(page).toHaveURL(new RegExp(`/race-hub\\?session_key=${FULL_SESSION}`))
await expect(page.getByText('Final Classification')).toBeVisible() await expect(page.getByTestId('rh-identity')).toBeVisible()
await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible()
}) })
test('existing routes continue to work', async ({ page }) => { test('existing routes continue to work', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`) await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await expect(page.getByText('Final Classification')).toBeVisible() await expect(page.getByTestId('race-hub')).toBeVisible()
await expect(page.getByTestId('rh-overview')).toBeVisible()
await page.goto('/admin') await page.goto('/admin')
await expect(page.getByTestId('data-library')).toBeVisible() await expect(page.getByTestId('data-library')).toBeVisible()

View File

@@ -37,6 +37,7 @@ test.describe('Admin · Data Health', () => {
test('direct race hub session link still works', async ({ page }) => { test('direct race hub session link still works', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`) await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await expect(page.getByText('Final Classification')).toBeVisible() await expect(page.getByTestId('race-hub')).toBeVisible()
await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible()
}) })
}) })

View File

@@ -10,9 +10,14 @@ test.describe('Production serving (Go + built React)', () => {
await expect(page.getByTestId('cc-session-9472')).toBeVisible() await expect(page.getByTestId('cc-session-9472')).toBeVisible()
}) })
test('serves race hub with classification from built assets', async ({ page }) => { test('serves race hub workspace from built assets', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`) await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await expect(page.getByTestId('race-hub')).toBeVisible()
await expect(page.getByTestId('rh-identity')).toBeVisible()
await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible()
await page.getByRole('tab', { name: 'Race Story' }).click()
await expect(page.getByText('Final Classification')).toBeVisible() await expect(page.getByText('Final Classification')).toBeVisible()
await expect(page.locator('.drv-code', { hasText: 'VER' })).toBeVisible() await expect(page.locator('.drv-code', { hasText: 'VER' })).toBeVisible()
}) })

View File

@@ -3,15 +3,40 @@ import { test, expect } from '@playwright/test'
const FULL_SESSION = 9472 const FULL_SESSION = 9472
const CORE_ONLY_SESSION = 9000 const CORE_ONLY_SESSION = 9000
test.describe('Race Hub', () => { test.describe('Race Hub Weekend Workspace', () => {
test('loads classification for a seeded session', async ({ page }) => { test('lands on the Overview tab with workspace identity', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`) await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await expect(page.getByTestId('race-hub')).toBeVisible()
await expect(page.getByTestId('rh-identity')).toBeVisible()
await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible()
await expect(page.getByTestId('rh-overview')).toBeVisible()
await expect(page.getByRole('tab', { name: 'Overview' })).toHaveAttribute(
'aria-selected',
'true',
)
})
test('shows final classification when switching to Race Story', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await page.getByRole('tab', { name: 'Race Story' }).click()
await expect(page.getByText('Final Classification')).toBeVisible() await expect(page.getByText('Final Classification')).toBeVisible()
await expect(page.locator('.drv-code', { hasText: 'VER' })).toBeVisible() await expect(page.locator('.drv-code', { hasText: 'VER' })).toBeVisible()
await expect(page.locator('.drv-code', { hasText: 'HAM' })).toBeVisible() await expect(page.locator('.drv-code', { hasText: 'HAM' })).toBeVisible()
}) })
test('Race Story exposes classification, grid, and positions sub-views', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await page.getByRole('tab', { name: 'Race Story' }).click()
await page.getByRole('tab', { name: 'Starting Grid' }).click()
await expect(page.locator('.sec-title', { hasText: 'Starting Grid' })).toBeVisible()
await page.getByRole('tab', { name: 'Positions' }).click()
await expect(page.locator('[data-testid="position-chart"]')).toBeVisible()
})
test('strategy tab renders stint chart when stints are available', async ({ page }) => { test('strategy tab renders stint chart when stints are available', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`) await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await page.getByRole('tab', { name: 'Strategy' }).click() await page.getByRole('tab', { name: 'Strategy' }).click()
@@ -20,14 +45,6 @@ test.describe('Race Hub', () => {
await expect(page.getByText('Stints not available.')).not.toBeVisible() await expect(page.getByText('Stints not available.')).not.toBeVisible()
}) })
test('positions tab renders position chart when positions are available', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await page.getByRole('tab', { name: 'Positions' }).click()
await expect(page.locator('[data-testid="position-chart"]')).toBeVisible()
await expect(page.getByText('Lap-by-lap positions not available.')).not.toBeVisible()
})
test('strategy tab shows missing notice when stints are unavailable', async ({ page }) => { test('strategy tab shows missing notice when stints are unavailable', async ({ page }) => {
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`) await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
await page.getByRole('tab', { name: 'Strategy' }).click() await page.getByRole('tab', { name: 'Strategy' }).click()
@@ -36,25 +53,42 @@ test.describe('Race Hub', () => {
await expect(page.locator('[data-testid="strategy-chart"]')).not.toBeVisible() await expect(page.locator('[data-testid="strategy-chart"]')).not.toBeVisible()
}) })
test('positions tab shows missing notice when positions are unavailable', async ({ page }) => { test('positions sub-view shows missing notice when positions are unavailable', async ({
page,
}) => {
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`) await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
await page.getByRole('tab', { name: 'Race Story' }).click()
await page.getByRole('tab', { name: 'Positions' }).click() await page.getByRole('tab', { name: 'Positions' }).click()
await expect(page.getByText('Lap-by-lap positions not available.')).toBeVisible() await expect(page.getByText('Lap-by-lap positions not available.')).toBeVisible()
await expect(page.locator('[data-testid="position-chart"]')).not.toBeVisible() await expect(page.locator('[data-testid="position-chart"]')).not.toBeVisible()
}) })
test('navigates to race hub via local data browser', async ({ page }) => { test('switching weekend via the inline switcher navigates to selected session', async ({
page,
}) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await page.getByTestId('rh-switch-weekend').click()
await expect(page.getByTestId('rh-switcher')).toBeVisible()
// Active session is already loaded; just confirm a session button is reachable
await expect(page.getByTestId(`rh-switcher-session-${FULL_SESSION}`)).toBeVisible()
})
test('Data Status tab points at admin instead of inline CLI hints', async ({ page }) => {
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
await page.getByRole('tab', { name: 'Data Status' }).click()
await expect(page.getByTestId('rh-data-status')).toBeVisible()
await expect(page.getByRole('link', { name: /manage ingestion/i })).toHaveAttribute(
'href',
'/admin',
)
})
test('bare /race-hub redirects to the focus session', async ({ page }) => {
await page.goto('/race-hub') await page.goto('/race-hub')
await expect(page).toHaveURL(/session_key=\d+/)
await expect(page.getByTestId('local-nav')).toBeVisible() await expect(page.getByTestId('race-hub')).toBeVisible()
await expect(page.getByText('Monaco')).toBeVisible()
await page.getByRole('button', { name: 'Sessions' }).click()
await expect(page.getByTestId('weekend-sessions')).toBeVisible()
await page.getByTestId('open-session-9472').click()
await expect(page.getByText('Final Classification')).toBeVisible()
await expect(page.locator('.drv-code', { hasText: 'VER' })).toBeVisible()
}) })
}) })

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -24,8 +24,10 @@ export async function gotoCommandCenterReady(page: Page): Promise<void> {
export async function gotoRaceHubReady(page: Page, sessionKey = FULL_SESSION): Promise<void> { export async function gotoRaceHubReady(page: Page, sessionKey = FULL_SESSION): Promise<void> {
await page.goto(`/race-hub?session_key=${sessionKey}`) await page.goto(`/race-hub?session_key=${sessionKey}`)
await expect(page.getByText('Final Classification')).toBeVisible() await expect(page.getByTestId('race-hub')).toBeVisible()
await expect(page.locator('.drv-code', { hasText: 'VER' })).toBeVisible() await expect(page.getByTestId('rh-identity')).toBeVisible()
await expect(page.getByTestId(`rh-session-${sessionKey}`)).toBeVisible()
await expect(page.getByTestId('rh-overview')).toBeVisible()
await waitForScreenshotReady(page) await waitForScreenshotReady(page)
} }