Compare commits

..

44 Commits

Author SHA1 Message Date
AmanTahiliani
469028b07a fix(#75): preserve Race Hub meeting/session on Weekend return
Add an explicit Back to Weekend link that deep-links to /?meeting_key&session_key,
restore that focus on the adaptive Weekend home with a Continue analysis CTA, and
regenerate Race Hub visual baselines against the integrated #80 nav.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 19:40:04 -04:00
AmanTahiliani
5d5987cc7e fix(race-hub): consume canonical weekend context for #75 review
Address PR #82 review blockers: drop the competing /weekend
default_analysis_session resolver, land bare /race-hub via
/api/v1/weekend-context, derive Live/preparing/partial/unavailable from
authoritative context with a moving clock, hide Local Coverage behind
Diagnostics, isolate the future-session fixture from the shared seed,
and strengthen return-to-Weekend context coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 18:51:34 -04:00
AmanTahiliani
7c98489b91 feat(race-hub): trustworthy defaults and fan-facing analysis hierarchy (#75)
Make bare /race-hub resolve to a completed session and never open empty
post-session analysis for a future race.

- Backend: add `default_analysis_session` to the Weekend context. It never
  resolves to a future session (picks the richest completed session, ties
  toward the later one; 0 when everything is upcoming). Existing
  `default_session_key` and deep links are unchanged.
- Frontend default resolution prefers the most recently completed weekend
  (`pickAnalysisFocusMeeting`) and consumes `default_analysis_session`, falling
  back to the switcher when only upcoming sessions exist.
- New `sessionState` lib maps timing + coverage to user language
  (upcoming/live/preparing/partial/ready/cancelled); the session rail, active
  sub-bar, and WeekendSwitcher now label states instead of raw x/11 counts.
- Future sessions render a purpose-built PreSessionView (expected availability +
  countdown) instead of empty Winner/Podium/Pole/Strategy/Compare cards.
- Analysis navigation regrouped into Story / Analysis / Data & Context; every
  existing tab is preserved. Diagnostics (renamed from Data Status) is now a
  secondary action and the raw dataset strip is hidden behind an explicit
  toggle, so operational coverage no longer precedes fan content.
- Loading/error states offer Retry and a path back to Weekend.

Tests: Go query tests for future-exclusion; Vitest for default selection,
future pre-session, partial state, error/retry, grouped nav, and sessionState;
hermetic Playwright for bare/completed/future/return-to-Weekend; new
race-hub-future visual snapshots. Seed adds a far-future session inside the
Monaco meeting (kept in-meeting so Command Center focus is unaffected).

Note: `default_analysis_session` is an additive field on the existing
`/weekend` contract (no new endpoint), per the spec's "context contract" scope.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 18:51:13 -04:00
Aman Tahiliani
84a9a3579f Merge pull request #80 from AmanTahiliani/feat/issue-73-adaptive-weekend-shell-and-responsive-na
Adaptive Weekend shell and responsive navigation (#73)
2026-07-12 18:44:51 -04:00
AmanTahiliani
07e5857760 fix(#73): consume canonical Weekend Context contract and repair navigation
Address the independent review blockers on PR #80 after rebasing onto the
authoritative #72 Weekend Context API.

- Replace the invented frontend WeekendContext with the exact backend contract
  (temporal_state, previous/focus/next meetings, previous_completed/active/next/
  default_analysis sessions with availability). Every valid canonical payload now
  maps to a designed state via a total resolveViewState; a well-formed response
  can never fall through to the limited-data placeholder.
- Make the canonical read the single source of truth: useWeekendContext no longer
  fans out to season/meetings/per-weekend/OpenF1/live queries. Only supplementary
  championship + news reads run, and only once the canonical context resolves.
- Fix the Prepare/analysis flow: /preview is a stable alias that renders the
  preparation surface (PreSessionView) instead of redirecting back to the same
  between-races screen.
- One primary navigation system per breakpoint: the mobile top-bar links are
  hidden so the bottom bar is the sole primary nav, and Admin is moved out of
  every Primary landmark into an operator-utilities toolbar.
- Add Vitest coverage for the contract mapping, every temporal state, loading/
  error/limited surfaces, the no-fanout guarantee, the /preview CTA, and the nav
  hierarchy; add hermetic Playwright journeys (seeded + injected canonical
  payloads), 390/768/1440 overflow checks, and Weekend visual snapshots. Retire
  the stale Command Center specs/snapshots.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 18:38:08 -04:00
AmanTahiliani
06223d992f feat(#73): Adaptive Weekend shell and responsive navigation
Implemented by claude via .agents/dev dispatch.
2026-07-12 18:22:04 -04:00
Aman Tahiliani
ba55945b6f Merge pull request #79 from AmanTahiliani/feat/issue-72-canonical-weekend-context-api-and-sessio
Canonical Weekend Context API and session truth (#72)
2026-07-12 18:19:46 -04:00
AmanTahiliani
643af2355a fix: make weekend context transitions truthful 2026-07-12 18:15:14 -04:00
AmanTahiliani
45084558cd feat: add canonical weekend context API 2026-07-12 18:05:13 -04:00
AmanTahiliani
90c24ad6aa chore: make sprint harness models explicit 2026-07-12 17:25:19 -04:00
AmanTahiliani
e028e3e3fe docs: add v0.4.0 product direction and mockups 2026-07-12 17:24:42 -04:00
AmanTahiliani
0a42c05487 fix: align race story cursor to chart 2026-07-12 16:11:26 -04:00
AmanTahiliani
08ff75b469 fix: align race story chart to lap timing 2026-07-12 16:05:04 -04:00
Aman Tahiliani
16c035b037 Merge pull request #69 from AmanTahiliani/feat/issue-68-polish-the-race-story-section-chapter-st
Polish the Race Story section (chapter strip, playback controls, map empty-state, graph) (#68)
2026-07-12 13:44:00 -04:00
AmanTahiliani
1dac73cdc5 fix(race-story): sync chapter highlight and empty-state E2E (#68)
Clamp active-chapter detection to the same scrub bounds as chapter clicks,
keep an explicit selection on click for out-of-window timestamps, point E2E
at the empty-state card, and move Race Story empty-state styles out of app.css.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 13:40:21 -04:00
AmanTahiliani
02a84f67b4 feat(race-story): polish chapter strip, controls, graph, and map UX (#68)
Consolidate the Race Story section into a cohesive timeline surface: custom
chapter-strip scrolling with active-card sync, unified segmented playback
controls, chapter bands on the position graph with decimated axes and
de-collided labels, map toggle gated by an on-mount replay/outline probe,
and shared empty-state cards. Adds vitest coverage and visual snapshots.

Note: replay/outline queries now probe on mount (not only when Map opens) so
the toggle can be hidden before users hit a dead panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 13:23:07 -04:00
Aman Tahiliani
f0a8e4e631 Merge pull request #67 from AmanTahiliani/phase-2
Phase 2 delivery — Replay & Storytelling, Driver & Rivalry Hub, Between-Races
2026-07-12 13:05:53 -04:00
AmanTahiliani
519053d340 docs(phase-2): delivery screenshots for the phase-2 PR
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:51:11 -04:00
AmanTahiliani
e0d7b9981e fix(test): add required round_positions to hub driver fixtures from pre-#24 branches
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:39:45 -04:00
Aman Tahiliani
79acf0579a Merge pull request #66 from AmanTahiliani/feat/issue-24-rivalry-compare-view
Rivalry compare view (#24)
2026-07-12 12:38:00 -04:00
AmanTahiliani
82abbd6b35 feat(#24): Rivalry compare view
Implemented by claude via .agents/dev dispatch.
2026-07-11 18:29:52 -04:00
Aman Tahiliani
faa173578c Merge pull request #65 from AmanTahiliani/feat/issue-23-teammate-h2h-surfacing
Teammate H2H surfacing (#23)
2026-07-11 18:25:51 -04:00
AmanTahiliani
39f1560282 merge phase-2 — keep both DriversView props (drivers for H2H, season for profile links) 2026-07-11 18:25:47 -04:00
Aman Tahiliani
d3f7bab6dc Merge pull request #64 from AmanTahiliani/feat/issue-21-narrative-chapter-headlines
Narrative chapter headlines (#21)
2026-07-11 18:23:54 -04:00
AmanTahiliani
5b112fb012 feat: add narrative chapter headlines and race-hub chapter strip (#21)
Generate deterministic template headlines server-side for each replay chapter
kind, expose Chapter.headline in the race-hub payload, and render a horizontal
chapter strip on the story view with active-chapter highlighting, click-to-jump,
and a 90-second tour mode built on the existing scrubber playback.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 18:23:11 -04:00
Aman Tahiliani
96d741424c Merge pull request #63 from AmanTahiliani/feat/issue-20-combined-position-graph-track-map-scrubb
Combined position-graph + track-map scrubber (#20)
2026-07-11 18:20:04 -04:00
AmanTahiliani
c2617d39bb feat: add replay track map scrubber
Spike: replay location fan-out is bounded with a semaphore at 4 concurrent GetLocation calls, covered by TestAssembleReplayFramesBoundsLocationFanOut.

Adds /api/v1/replay/frames with 5s interval floor, 3000-frame cap, nearest-sample snapping, and empty-driver omission. Wires RaceStoryCanvas to lazy-load replay frames plus cached track outlines when the map panel opens, sharing the existing scrubber/playback state across chart and map.

Tests: HOME=/private/tmp/box-box-home GOCACHE=/private/tmp/box-box-go-cache GOMODCACHE=/Users/aman/go/pkg/mod go test ./internal/web; go build ./... (same env, passed with read-only module stat-cache warning); npm run test; npx tsc --noEmit. Full go test ./... is blocked in this sandbox by existing network/listener-dependent internal/api and internal/news tests.
2026-07-11 18:17:15 -04:00
Aman Tahiliani
beacda278d Merge pull request #62 from AmanTahiliani/feat/issue-26-calendar-aware-briefing-digest
Calendar-aware briefing digest (#26)
2026-07-11 18:15:39 -04:00
AmanTahiliani
f24ec4be94 feat(frontend): calendar-aware paddock briefing digest (#26)
Reframe Briefing into GP-window sections with driver/team tag chips, a sticky
"since last race" header, and client-side digest helpers (gpWindows, groupByWindow,
tagItems). Overlapping inter-race windows resolve to the latest GP; undated items
land in a Recent bucket. Vitest covers digest lib + page render/filter behaviour.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 18:14:55 -04:00
Aman Tahiliani
09104451d0 Merge pull request #61 from AmanTahiliani/feat/issue-22-driver-profile-page
Driver profile page (#22)
2026-07-11 18:13:51 -04:00
AmanTahiliani
f8bd183c26 merge phase-2 (preview page, chapters) — resolve additive import conflicts 2026-07-11 18:13:41 -04:00
AmanTahiliani
01ec1f24e7 feat(#22): Driver profile page
Implemented by claude via .agents/dev dispatch.
2026-07-11 18:12:00 -04:00
AmanTahiliani
3f022a0cf9 fix(agents): quote skill YAML descriptions — strict parsers (cursor CLI) fail on embedded quotes/colons
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 18:10:34 -04:00
Aman Tahiliani
5970649a9c Merge pull request #60 from AmanTahiliani/feat/issue-19-auto-chapter-detection-rc-position-swing
Auto-chapter detection (RC + position swings) (#19)
2026-07-11 18:06:33 -04:00
AmanTahiliani
7212eb9b44 feat: detect race replay chapters
Add deterministic server-side chapter detection for starts, flag periods, pit phases, decisive top-five swings, and finishes, then expose chapters on the race-hub payload.

Spike result: race-hub already loads race control, positions, and laps in one read model. The requested Detect signature does not include pit stops, so pit phases use IsPitOutLap clusters as the local deterministic pit-stop proxy.
2026-07-11 18:04:44 -04:00
Aman Tahiliani
785dccdca2 Merge pull request #59 from AmanTahiliani/feat/issue-25-next-race-preview-page
Next-race preview page (#25)
2026-07-11 17:59:51 -04:00
AmanTahiliani
e8de2068c1 feat(frontend): add next-race preview page (#25)
Assemble /preview from existing schedule, track-outline, results, and
championship hub endpoints with client-side preview.ts helpers, section
loading/empty states, and Vitest coverage for lib + page render paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 17:59:00 -04:00
AmanTahiliani
3cc6157337 feat(championship): surface teammate H2H bars on drivers view (#23)
Add reusable TeammateH2H component and teammatePairs helper that ranks
intra-team battles by closeness, using existing hub teammate_wins data.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 17:58:56 -04:00
Aman Tahiliani
1782fd313c Merge pull request #55 from AmanTahiliani/codex/hide-live-track-map
Temporarily hide Live track map while GPS feed is unavailable
2026-07-11 17:47:03 -04:00
Aman Tahiliani
be7eb3c034 Merge pull request #58 from AmanTahiliani/fix/championship-sprint-points-57
fix(championship): include Sprint points (ANT 158->179) - Fixes #57
2026-07-10 23:57:12 -04:00
Aman Tahiliani
71523641ad chore: clean up verification artifacts for repo hygiene
Removes temporary verification screenshots, docker files and verify.html
introduced for PR #58 validation.

- Before/after images and real-app screenshots remain accessible in history
  at e51cabb for PR review but are not needed in main
- Keeps only the actual fix: internal/query/championship.go + test
- Maintains docs/ structure without binary clutter (PRODUCT_ROADMAP, assets, etc.)

Fixes #57 remains: Sprint points now included (ANT 158->179)
2026-07-10 23:53:11 -04:00
Aman Tahiliani
e51cabba62 docs(verify): add real app screenshots with sprint fix - ANT 179, RUS 154, HAM 147
- Running /tmp/boxbox-server --db /tmp/boxbox-verify.db (localChampionshipHub)
- /verify.html shows championship hub table with fixed points
- /api/v1/championship/hub?year=2026&source=local returns 179/154/147
- Captured via google-chrome --headless --screenshot
- Add Dockerfile + docker-compose.yml for future prod-parity verification
2026-07-10 23:50:05 -04:00
Aman Tahiliani
fc6fc36d8f docs: add verification screenshots for sprint fix #57
- before 158 vs after 179
- code fix snippet
- go test passing 25+8=33
- verification output showing 179,154,147 totals
2026-07-10 23:33:57 -04:00
Aman Tahiliani
16d82afad5 fix(championship): include Sprint points in standings
- GetChampionshipInputs now sums Race + Sprint sessions per meeting
- Bahrain 1282 / Saudi 1283 cancelled (Force Majeure) - intentional Round 9/24
- 2026 sprints up to British GP: 11240 Chinese, 11275 Miami, 11286 Canadian, 11321 British
  Totals ANT +21, RUS +26, HAM +18 etc.
- Adds regression test TestGetChampionshipInputsIncludesSprintPoints (25+8=33)
- Fixes #57
2026-07-10 23:32:12 -04:00
142 changed files with 15014 additions and 1349 deletions

View File

@@ -25,7 +25,8 @@ EOF
harness_claude() { # Claude Code — print mode, auto-accept edits harness_claude() { # Claude Code — print mode, auto-accept edits
local dir="$1" prompt="$2" local dir="$1" prompt="$2"
( cd "$dir" && claude -p "$(cat "$prompt")" --permission-mode acceptEdits ) local model="${BOXBOX_CLAUDE_MODEL:-opus}"
( cd "$dir" && claude -p "$(cat "$prompt")" --model "$model" --effort high --permission-mode acceptEdits )
} }
harness_codex() { # OpenAI Codex CLI — non-interactive exec, full auto harness_codex() { # OpenAI Codex CLI — non-interactive exec, full auto
@@ -40,7 +41,8 @@ harness_opencode() { # opencode — non-interactive run
harness_cursor() { # Cursor CLI agent — composer-2.5, headless full-auto harness_cursor() { # Cursor CLI agent — composer-2.5, headless full-auto
local dir="$1" prompt="$2" local dir="$1" prompt="$2"
( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --model composer-2.5 --force --trust ) local model="${BOXBOX_CURSOR_MODEL:-composer-2.5}"
( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --model "$model" --force --trust )
} }
harness_agy() { # Antigravity CLI — disabled until headless is verified harness_agy() { # Antigravity CLI — disabled until headless is verified

View File

@@ -1,6 +1,6 @@
--- ---
name: groom name: groom
description: Groom a box-box GitHub issue into a Ready spec via a seeded grill-me interrogation. Use when the user asks to groom an issue, for example "/groom <issue-number>" or "$groom <issue-number>" with optional "--lens architect". Single-issue path: asks targeted questions, writes a structured spec into the issue body, sets Effort/Priority, and leaves Stage at Research for approval. description: 'Groom a box-box GitHub issue into a Ready spec via a seeded grill-me interrogation. Use when the user asks to groom an issue, for example "/groom <issue-number>" or "$groom <issue-number>" with optional "--lens architect". Single-issue path: asks targeted questions, writes a structured spec into the issue body, sets Effort/Priority, and leaves Stage at Research for approval.'
argument-hint: <issue-number> [--lens architect] argument-hint: <issue-number> [--lens architect]
--- ---

View File

@@ -1,6 +1,6 @@
--- ---
name: implement name: implement
description: Dispatch a Ready box-box issue to a coding harness (claude/codex/opencode/pi/cursor) in an isolated git worktree, run the build gate, and open a PR. Use when supervising implementation via "/implement <issue-number> --harness <name> [--dry-run]" or from any harness terminal with .agents/bin/dev. description: 'Dispatch a Ready box-box issue to a coding harness (claude/codex/opencode/pi/cursor) in an isolated git worktree, run the build gate, and open a PR. Use when supervising implementation via "/implement <issue-number> --harness <name> [--dry-run]" or from any harness terminal with .agents/bin/dev.'
argument-hint: <issue-number> --harness <claude|codex|opencode|pi|cursor> [--dry-run] argument-hint: <issue-number> --harness <claude|codex|opencode|pi|cursor> [--dry-run]
--- ---

View File

@@ -1,6 +1,6 @@
--- ---
name: lens-architect name: lens-architect
description: Grill/analyze a box-box issue or epic from a software-architecture perspective and post the findings as a comment. Use standalone as "/lens-architect <issue-number>" for an on-the-fly architecture review, or let /groom compose it via "--lens architect". Reads the base grill + architect persona and focuses on reuse, data flow, seams, testability, and risk. description: 'Grill/analyze a box-box issue or epic from a software-architecture perspective and post the findings as a comment. Use standalone as "/lens-architect <issue-number>" for an on-the-fly architecture review, or let /groom compose it via "--lens architect". Reads the base grill + architect persona and focuses on reuse, data flow, seams, testability, and risk.'
argument-hint: <issue-number> argument-hint: <issue-number>
--- ---

View File

@@ -1,6 +1,6 @@
--- ---
name: review name: review
description: Locally review a box-box PR against its linked GitHub issue spec, run tests, capture visual screenshots when applicable, create a .review packet, and post a GitHub PR comment. Use when a ticket implementation is ready for independent local review before the human merge gate. description: 'Locally review a box-box PR against its linked GitHub issue spec, run tests, capture visual screenshots when applicable, create a .review packet, and post a GitHub PR comment. Use when a ticket implementation is ready for independent local review before the human merge gate.'
argument-hint: <pr-number> [--harness <name>] [--publish-screenshots] argument-hint: <pr-number> [--harness <name>] [--publish-screenshots]
--- ---

View File

@@ -1,6 +1,6 @@
--- ---
name: write-spec name: write-spec
description: Render a groomed design into the box-box Ready-spec template and write it into a GitHub issue body, then set Effort and Priority. Called by /groom after a grill session, or run standalone as "/write-spec <issue-number>" to (re)write an issue's spec from agreed decisions. Does not change Stage. description: 'Render a groomed design into the box-box Ready-spec template and write it into a GitHub issue body, then set Effort and Priority. Called by /groom after a grill session, or run standalone as "/write-spec <issue-number>" to (re)write an issue''s spec from agreed decisions. Does not change Stage.'
argument-hint: <issue-number> argument-hint: <issue-number>
--- ---

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 683 KiB

View File

@@ -0,0 +1,32 @@
# v0.4.0 — Current Weekend & Product Truth
Product research references for the v0.4.0 feature sprint.
## Direction
The sprint replaces route-first navigation with a state-aware Weekend experience:
- Weekend is the adaptive home for what happened, what is live, and what is next.
- Preview content folds into Weekend before a session.
- Live remains a stable deep link and becomes Weekend's active-session state.
- Race Hub remains explicit completed-session analysis rather than a primary landing destination.
- Championship and Briefing remain dedicated destinations.
- Explore owns secondary discovery; Admin moves to operator utility.
## Mockups
- `mockups/weekend-between-races.png` — desktop between-races/post-weekend state.
- `mockups/weekend-live.png` — desktop active-session state; the circuit is static sector context, not live GPS.
- `mockups/weekend-between-sessions-mobile.png` — 390×844 between-session state.
These are directional references, not pixel-perfect specifications. Implementations must preserve the established box-box visual language, accessibility, data constraints, and responsive behavior while satisfying their issue acceptance criteria.
## Constraints
- No OpenF1 REST dependency during active sessions.
- Public live GPS is not assumed to be available.
- Championship round numbers exclude tests and cancelled meetings.
- Connection health, live-session state, archive availability, and local-analysis readiness are separate concepts.
- Future sessions must not render empty post-session analysis.
The authoritative product decisions and research packet are recorded in GitHub issue #71 under epic #70.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -2,15 +2,20 @@ import type {
ArticleContent, ArticleContent,
CarDataSample, CarDataSample,
ChampionshipHub, ChampionshipHub,
DriverSummary,
EnrichedGrid,
EnrichedResult,
LapsComparisonResponse, LapsComparisonResponse,
LiveStateResponse, LiveStateResponse,
LiveSessionMeta, LiveSessionMeta,
Meeting, Meeting,
NewsItem, NewsItem,
RaceHub, RaceHub,
ReplayFramesResponse,
Session, Session,
TrackOutline, TrackOutline,
Weekend, Weekend,
WeekendContext,
} from './types' } from './types'
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> { export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
@@ -40,7 +45,11 @@ export async function fetchLocalMeetings(year: number): Promise<Meeting[]> {
} }
export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> { export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
const res = await fetch(`/api/v1/meetings?year=${year}&source=openf1`) return fetchMeetings(year, 'openf1')
}
export async function fetchMeetings(year: number, source = 'auto'): Promise<Meeting[]> {
const res = await fetch(`/api/v1/meetings?year=${year}&source=${source}`)
if (!res.ok) { if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`) throw new Error(`API ${res.status}: ${res.statusText}`)
} }
@@ -48,6 +57,47 @@ export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
return Array.isArray(meetings) ? meetings : [] return Array.isArray(meetings) ? meetings : []
} }
export async function fetchResults(sessionKey: number, source = 'auto'): Promise<EnrichedResult[]> {
const res = await fetch(`/api/v1/results?session_key=${sessionKey}&source=${source}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
const results = await res.json()
return Array.isArray(results) ? results : []
}
export async function fetchStartingGrid(sessionKey: number, source = 'auto'): Promise<EnrichedGrid[]> {
const res = await fetch(`/api/v1/grid?session_key=${sessionKey}&source=${source}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
const grid = await res.json()
return Array.isArray(grid) ? grid : []
}
export async function fetchTrackOutline(circuitKey: number, year: number): Promise<TrackOutline | null> {
const res = await fetch(`/api/v1/track-outline?circuit_key=${circuitKey}&year=${year}`)
if (!res.ok) return null
const data = await res.json()
if (data?.error || !Array.isArray(data?.points) || data.points.length < 2) return null
return data as TrackOutline
}
export async function fetchReplayFrames(
sessionKey: number,
intervalMs = 5000,
): Promise<ReplayFramesResponse> {
const params = new URLSearchParams({
session_key: String(sessionKey),
interval_ms: String(intervalMs),
})
const res = await fetch(`/api/v1/replay/frames?${params}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}
export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise<Session[]> { export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise<Session[]> {
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`) const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
if (!res.ok) { if (!res.ok) {
@@ -65,6 +115,20 @@ export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
return res.json() return res.json()
} }
// fetchWeekendContext consumes the canonical /api/v1/weekend-context endpoint
// (backend story #72). The response is the authoritative WeekendContext shape and
// is used verbatim as the Weekend home's source of truth. Any HTTP error throws
// so the hook can surface an explicit error state; there is no client-side
// re-derivation of the contract. Race Hub bare-default landing also reads this
// for `default_analysis_session` (#75).
export async function fetchWeekendContext(): Promise<WeekendContext> {
const res = await fetch('/api/v1/weekend-context')
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}
export async function fetchChampionshipHub(year?: number): Promise<ChampionshipHub> { export async function fetchChampionshipHub(year?: number): Promise<ChampionshipHub> {
const params = new URLSearchParams({ source: 'auto' }) const params = new URLSearchParams({ source: 'auto' })
if (year) params.set('year', year.toString()) if (year) params.set('year', year.toString())
@@ -76,6 +140,19 @@ export async function fetchChampionshipHub(year?: number): Promise<ChampionshipH
return res.json() return res.json()
} }
export async function fetchDriverSummary(
driverNumber: number,
year?: number,
): Promise<DriverSummary> {
const params = new URLSearchParams({ driver_number: String(driverNumber) })
if (year) params.set('year', String(year))
const res = await fetch(`/api/v1/driver/summary?${params.toString()}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}
export async function fetchLiveState(): Promise<LiveStateResponse> { export async function fetchLiveState(): Promise<LiveStateResponse> {
const res = await fetch('/api/v1/live/state') const res = await fetch('/api/v1/live/state')
if (!res.ok) { if (!res.ok) {

View File

@@ -0,0 +1,116 @@
import { useEffect, useRef } from 'react'
import { BookOpen } from 'lucide-react'
import type { Chapter } from '../types'
import {
activeChapterIndex,
chapterKindLabel,
chapterLapRange,
chapterStartScrub,
} from '../lib/chapters'
import { EmptyStateCard } from './EmptyStateCard'
import '../styles/chapters.css'
interface Props {
chapters: Chapter[]
scrubTime: number | null
tMin: number
tRange: number
tourActive: boolean
tourChapterIndex: number | null
/** Explicit selection from a chapter click; wins over scrub-derived active. */
selectedChapterIndex?: number | null
onChapterClick: (index: number, scrub: number) => void
onTourToggle: () => void
}
export function ChapterStrip({
chapters,
scrubTime,
tMin,
tRange,
tourActive,
tourChapterIndex,
selectedChapterIndex = null,
onChapterClick,
onTourToggle,
}: Props) {
const scrollRef = useRef<HTMLDivElement>(null)
const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange)
const highlightedIndex = tourActive
? tourChapterIndex
: (selectedChapterIndex ?? activeIndex)
useEffect(() => {
if (highlightedIndex === null || !scrollRef.current) return
const card = scrollRef.current.querySelector<HTMLElement>(
`[data-testid="chapter-card-${highlightedIndex}"]`,
)
card?.scrollIntoView?.({ behavior: 'smooth', inline: 'center', block: 'nearest' })
}, [highlightedIndex])
if (chapters.length === 0) {
return (
<div className="chapter-strip" data-testid="chapter-strip">
<EmptyStateCard
icon={BookOpen}
title="No story chapters"
hint="This session does not have enough race-control or position data to build narrative chapters."
testId="chapter-strip-empty"
className="chapter-strip-empty-card"
/>
</div>
)
}
return (
<div className="chapter-strip" data-testid="chapter-strip">
<div className="chapter-strip-header">
<span className="chapter-strip-title">Race chapters</span>
<div className="chapter-strip-actions">
<button
type="button"
className={`chapter-tour-btn ${tourActive ? 'active' : ''}`}
onClick={onTourToggle}
aria-pressed={tourActive}
>
{tourActive ? 'Exit 90s' : '90s tour'}
</button>
</div>
</div>
<div className="chapter-strip-scroll-wrap">
<div
ref={scrollRef}
className="chapter-strip-scroll"
role="list"
aria-label="Race story chapters"
>
{chapters.map((chapter, index) => {
const scrub = chapterStartScrub(chapter, tMin, tRange) ?? index / Math.max(chapters.length - 1, 1)
const isActive = highlightedIndex === index
const headline = chapter.headline || chapter.title
return (
<button
key={`${chapter.kind}-${chapter.start_lap}-${index}`}
type="button"
role="listitem"
className={`chapter-card ${isActive ? (tourActive ? 'tour-active' : 'active') : ''}`}
onClick={() => onChapterClick(index, scrub)}
aria-current={isActive ? 'true' : undefined}
data-testid={`chapter-card-${index}`}
>
<div className="chapter-card-top">
<span className={`chapter-kind chapter-kind--${chapter.kind}`}>
{chapterKindLabel(chapter.kind)}
</span>
<span className="chapter-lap-range">{chapterLapRange(chapter)}</span>
</div>
<span className="chapter-headline">{headline}</span>
</button>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,20 @@
import type { LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react'
interface Props {
icon: LucideIcon
title: string
hint: ReactNode
testId?: string
className?: string
}
export function EmptyStateCard({ icon: Icon, title, hint, testId, className = '' }: Props) {
return (
<div className={`empty-state ui-card ${className}`.trim()} data-testid={testId}>
<Icon size={32} className="empty-state-icon" aria-hidden />
<div className="empty-state-title">{title}</div>
<div className="empty-state-desc">{hint}</div>
</div>
)
}

View File

@@ -1,33 +1,66 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { Compass, FileText, Home, Trophy } from 'lucide-react'
const PRIMARY = [
{ to: '/', label: 'Weekend', icon: Home, exact: true },
{ to: '/championship', label: 'Championship', icon: Trophy, exact: false },
{ to: '/briefing', label: 'Briefing', icon: FileText, exact: false },
{ to: '/explore', label: 'Explore', icon: Compass, exact: false },
] as const
/**
* Nav renders one primary navigation system per breakpoint:
* - Desktop/tablet: the top bar's `aria-label="Primary"` links.
* - Mobile (≤640px): the bottom `aria-label="Primary"` bar; the top bar's links
* are hidden via CSS so the two are never both active at once.
*
* Admin is an operator utility, deliberately outside every Primary landmark — it
* lives in a plain toolbar slot and never appears in the mobile bottom nav.
*/
export function Nav() { export function Nav() {
return ( return (
<nav className="app-nav"> <>
<Link to="/" className="nav-logo"> <header className="app-nav">
box<em>-</em>box <Link to="/" className="nav-logo">
</Link> box<em>-</em>box
<div className="nav-links">
<Link to="/" activeProps={{ className: 'active' }} activeOptions={{ exact: true }}>
Command
</Link> </Link>
<Link to="/live" activeProps={{ className: 'active' }}> <nav className="nav-links" aria-label="Primary">
Live {PRIMARY.map(({ to, label, exact }) => (
</Link> <Link
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}> key={to}
Race Hub to={to}
</Link> activeProps={{ className: 'active' }}
<Link to="/championship" activeProps={{ className: 'active' }}> activeOptions={exact ? { exact: true } : undefined}
Championship >
</Link> {label}
<Link to="/briefing" activeProps={{ className: 'active' }}> </Link>
Briefing ))}
</Link> </nav>
</div> <div className="nav-utility" role="toolbar" aria-label="Operator utilities">
<div className="nav-utility"> <Link
<Link to="/admin" className="nav-utility-link" activeProps={{ className: 'nav-utility-link active' }}> to="/admin"
Admin className="nav-utility-link"
</Link> activeProps={{ className: 'nav-utility-link active' }}
</div> >
</nav> Admin
</Link>
</div>
</header>
<nav className="app-bottom-nav" aria-label="Primary">
{PRIMARY.map(({ to, label, icon: Icon, exact }) => (
<Link
key={to}
to={to}
className="bottom-nav-link"
activeProps={{ className: 'bottom-nav-link active' }}
activeOptions={exact ? { exact: true } : undefined}
>
<Icon size={18} aria-hidden="true" />
<span>{label}</span>
</Link>
))}
</nav>
</>
) )
} }

View File

@@ -1,6 +1,5 @@
import type { RaceHub } from '../types' import type { RaceHub } from '../types'
import { compareFinishPosition, formatDuration, formatGap, formatLapTime } from '../utils' import { compareFinishPosition, formatDuration, formatGap, formatLapTime } from '../utils'
import { countRaceHubDatasets } from '../lib/coverage'
import { Thermometer, Map, Droplets, Wind, CloudRain } from 'lucide-react' import { Thermometer, Map, Droplets, Wind, CloudRain } from 'lucide-react'
interface Props { interface Props {
@@ -17,7 +16,6 @@ export function OverviewView({ data }: Props) {
const fastest = pickFastestLap(data) const fastest = pickFastestLap(data)
const latestWeather = data.weather.length > 0 ? data.weather[data.weather.length - 1] : null const latestWeather = data.weather.length > 0 ? data.weather[data.weather.length - 1] : null
const rcHighlights = data.race_control.slice(-3).reverse() const rcHighlights = data.race_control.slice(-3).reverse()
const coverage = countRaceHubDatasets(data.datasets)
const sessionType = (data.session?.session_type ?? '').toLowerCase() const sessionType = (data.session?.session_type ?? '').toLowerCase()
const isRace = sessionType.includes('race') const isRace = sessionType.includes('race')
@@ -129,28 +127,6 @@ export function OverviewView({ data }: Props) {
</ul> </ul>
)} )}
</section> </section>
<section className="rh-panel ui-card">
<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>
</div> </div>
) )

View File

@@ -1,7 +1,23 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { fetchNews } from '../api' import {
fetchChampionshipHub,
fetchNews,
fetchSeasonMeetings,
fetchSeasons,
} from '../api'
import {
activeDigestWindow,
gpWindows,
itemsForWindow,
sinceLastLabel,
tagColour,
tagItems,
topTags,
} from '../lib/digest'
import { timeAgo } from '../utils' import { timeAgo } from '../utils'
import '../styles/digest.css'
const SOURCE_DISPLAY: Record<string, string> = { const SOURCE_DISPLAY: Record<string, string> = {
'fia': 'FIA', 'fia': 'FIA',
@@ -14,14 +30,53 @@ const SOURCE_DISPLAY: Record<string, string> = {
} }
export function PaddockBriefing() { export function PaddockBriefing() {
const now = useMemo(() => new Date(), [])
const { data: news, isLoading, isError } = useQuery({ const { data: news, isLoading, isError } = useQuery({
queryKey: ['news'], queryKey: ['news'],
queryFn: () => fetchNews(100), queryFn: () => fetchNews(100),
staleTime: 60_000, staleTime: 60_000,
}) })
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: fetchSeasons,
})
const latestSeason = seasonsQuery.data?.[0] ?? null
const meetingsQuery = useQuery({
queryKey: ['season-meetings', latestSeason],
queryFn: () => fetchSeasonMeetings(latestSeason!),
enabled: latestSeason != null,
})
const hubQuery = useQuery({
queryKey: ['championship-hub', latestSeason],
queryFn: () => fetchChampionshipHub(latestSeason!),
enabled: latestSeason != null,
})
const meetings = meetingsQuery.data ?? []
const hub = hubQuery.data
const tagged = useMemo(
() => tagItems(news ?? [], hub?.drivers ?? [], hub?.teams ?? []),
[news, hub],
)
const windows = useMemo(() => gpWindows(meetings, now), [meetings, now])
const activeWindow = useMemo(
() => activeDigestWindow(windows, meetings, now),
[windows, meetings, now],
)
const sinceItems = useMemo(
() => itemsForWindow(tagged, activeWindow),
[tagged, activeWindow],
)
const sinceTags = useMemo(() => topTags(sinceItems, 4), [sinceItems])
const sinceLabel = sinceLastLabel(meetings, now)
const unreadCount = news?.filter((i) => !i.read_at).length ?? 0 const unreadCount = news?.filter((i) => !i.read_at).length ?? 0
const preview = news?.slice(0, 5) ?? [] const preview = sinceItems.length > 0 ? sinceItems.slice(0, 5) : (tagged.slice(0, 5))
return ( return (
<section className="cc-briefing" data-testid="paddock-briefing"> <section className="cc-briefing" data-testid="paddock-briefing">
@@ -37,6 +92,27 @@ export function PaddockBriefing() {
</Link> </Link>
</div> </div>
{meetings.length > 0 && (
<div className="cc-brief-digest-meta" data-testid="cc-brief-digest-meta">
<span>Since {sinceLabel}</span>
<span>·</span>
<span>{sinceItems.length} items</span>
{sinceTags.length > 0 && (
<div className="cc-brief-digest-tags">
{sinceTags.map((tag) => (
<span
key={tag.key}
className="cc-brief-tag"
style={{ borderColor: tagColour(tag.colour) }}
>
{tag.label}
</span>
))}
</div>
)}
</div>
)}
{isLoading && <div className="briefing-state loading-state">loading</div>} {isLoading && <div className="briefing-state loading-state">loading</div>}
{isError && <div className="briefing-state error-box">Failed to load briefing</div>} {isError && <div className="briefing-state error-box">Failed to load briefing</div>}

View File

@@ -0,0 +1,141 @@
import type { Session } from '../types'
import { RACE_HUB_DATASETS } from '../lib/coverage'
import { formatCountdown, formatSessionScheduleTime, sessionStartTime } from '../lib/schedule'
import {
sessionStateDescription,
type SessionState,
} from '../lib/sessionState'
const EXPECTED_LABELS: Record<string, string> = {
results: 'Final results',
starting_grid: 'Starting grid',
stints: 'Tyre strategy',
pit_stops: 'Pit stops',
positions: 'Position changes',
laps: 'Lap times',
race_control: 'Race control',
weather: 'Track conditions',
}
interface PreSessionProps {
session: Session
sessionName: string
now: Date
}
/**
* Purpose-built view for a session that has not run yet. Instead of rendering
* empty Winner / Podium / Pole / Strategy / Compare cards, it explains that the
* session is upcoming and previews the analysis that will appear once the data
* is ingested.
*/
export function PreSessionView({ session, sessionName, now }: PreSessionProps) {
const start = sessionStartTime(session)
const expected = RACE_HUB_DATASETS.filter((key) => EXPECTED_LABELS[key])
return (
<div className="rh-presession" data-testid="rh-presession">
<section className="rh-presession-band">
<span className="rh-presession-eyebrow mono">Upcoming session</span>
<h2 className="rh-presession-title">{sessionName}</h2>
<p className="rh-presession-sub">
This session hasnt run yet, so theres no result to analyse. Winner,
podium, pole, strategy and comparison views will appear here once the
session completes and its data is ingested.
</p>
<div className="rh-presession-countdown mono" data-testid="rh-presession-countdown">
{start
? `Starts ${formatSessionScheduleTime(session.date_start)} · in ${formatCountdown(start, now)}`
: 'Start time to be confirmed.'}
</div>
</section>
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Expected once complete</span>
</div>
<div className="rh-expected-grid">
{expected.map((key) => (
<div key={key} className="rh-expected-card">
<span className="rh-expected-dot" aria-hidden="true" />
<span>{EXPECTED_LABELS[key]}</span>
</div>
))}
</div>
</div>
</div>
)
}
interface PhaseProps {
state: Extract<SessionState, 'preparing' | 'unavailable' | 'cancelled'>
sessionName: string
onOpenDiagnostics?: () => void
}
/**
* Distinct fan-facing surfaces for settling/preparing and unavailable sessions.
* Genuine request failures stay on the page-level error recovery path.
*/
export function SessionPhaseView({ state, sessionName, onOpenDiagnostics }: PhaseProps) {
const title =
state === 'preparing'
? 'Analysis preparing'
: state === 'cancelled'
? 'Session cancelled'
: 'Analysis unavailable'
const testId =
state === 'preparing'
? 'rh-preparing'
: state === 'cancelled'
? 'rh-cancelled'
: 'rh-unavailable'
return (
<div className="rh-presession" data-testid={testId}>
<section className="rh-presession-band">
<span className="rh-presession-eyebrow mono">{sessionStateLabelEyebrow(state)}</span>
<h2 className="rh-presession-title">{title}</h2>
<p className="rh-presession-sub">
{sessionName}: {sessionStateDescription(state)}
</p>
{state === 'preparing' && (
<p className="rh-presession-sub">
Check back shortly, or open Diagnostics if you need raw dataset coverage.
</p>
)}
{onOpenDiagnostics && (state === 'preparing' || state === 'unavailable') && (
<div className="rh-empty-actions" style={{ marginTop: 'var(--s4)' }}>
<button type="button" className="rh-empty-action" onClick={onOpenDiagnostics}>
Open Diagnostics
</button>
</div>
)}
</section>
</div>
)
}
function sessionStateLabelEyebrow(state: PhaseProps['state']): string {
if (state === 'preparing') return 'Settling'
if (state === 'cancelled') return 'Cancelled'
return 'Unavailable'
}
interface PartialBannerProps {
onOpenDiagnostics?: () => void
}
export function PartialAnalysisBanner({ onOpenDiagnostics }: PartialBannerProps) {
return (
<div className="rh-partial-banner" data-testid="rh-partial-banner" role="status">
<span>Partial analysis some datasets are still missing.</span>
{onOpenDiagnostics && (
<button type="button" className="rh-partial-banner-link" onClick={onOpenDiagnostics}>
Diagnostics
</button>
)}
</div>
)
}

View File

@@ -1,6 +1,36 @@
import { useState, useMemo, useRef, useCallback } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import type { EnrichedResult, EnrichedGrid, PositionSample, Lap, Session } from '../types' import { useQuery } from '@tanstack/react-query'
import { LineChart } from 'lucide-react'
import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session, Chapter } from '../types'
import { fetchReplayFrames, fetchTrackOutline } from '../api'
import { ReplayTrackMap } from './ReplayTrackMap'
import { ChapterStrip } from './ChapterStrip'
import { EmptyStateCard } from './EmptyStateCard'
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils' import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
import {
chapterBandFill,
chapterEndScrub,
chapterStartScrub,
chapterTourDurations,
deCollideYPositions,
decimatedPositionLabels,
} from '../lib/chapters'
import { isReplayMapAvailable } from '../lib/replayMap'
import '../styles/race-story.css'
const CHAPTER_TOUR_MS = 90_000
const CHART_W = 640
const CHART_H = 180
const CHART_PL = 40
const CHART_PR = 48
const CHART_PT = 8
const CHART_PB = 20
interface ChartTiming {
tMin: number
tMax: number
tRange: number
}
interface Props { interface Props {
data: { data: {
@@ -12,38 +42,256 @@ interface Props {
race_control?: any[] race_control?: any[]
pit_stops?: any[] pit_stops?: any[]
session?: Session session?: Session
meeting?: Meeting
drivers?: Driver[]
chapters?: Chapter[]
} }
} }
/**
* Position samples are recorded for the entire session, including the pre-race
* grid period. Use the winning driver's laps to define the race window so
* that the x-axis starts at lights-out rather than at the earliest sample.
*/
function raceChartTiming(
positions: PositionSample[],
laps: Lap[],
results: EnrichedResult[],
): ChartTiming | null {
const positionTimes = positions
.map((position) => new Date(position.date).getTime())
.filter(Number.isFinite)
if (positionTimes.length === 0) return null
const fallbackMin = Math.min(...positionTimes)
const fallbackMax = Math.max(...positionTimes)
const fallback = {
tMin: fallbackMin,
tMax: fallbackMax,
tRange: Math.max(fallbackMax - fallbackMin, 1),
}
const winner = results.find((result) => result.position === 1)
if (!winner) return fallback
const winnerLaps = laps
.filter((lap) => lap.driver_number === winner.driver_number && lap.lap_number > 0)
.map((lap) => ({ ...lap, start: new Date(lap.date_start).getTime() }))
.filter((lap) => Number.isFinite(lap.start))
.sort((a, b) => a.lap_number - b.lap_number)
if (winnerLaps.length === 0) return fallback
const firstLap = winnerLaps[0]
const lastLap = winnerLaps[winnerLaps.length - 1]
const finalLapDuration = lastLap.lap_duration ?? 0
const tMax = lastLap.start + (finalLapDuration > 0 ? finalLapDuration * 1000 : 0)
if (tMax <= firstLap.start) return fallback
return { tMin: firstLap.start, tMax, tRange: tMax - firstLap.start }
}
export function RaceStoryCanvas({ data }: Props) { export function RaceStoryCanvas({ data }: Props) {
const { results, starting_grid: grid, positions, datasets, race_control = [], pit_stops = [], laps = [] } = data const {
results,
starting_grid: grid,
positions,
datasets,
race_control = [],
pit_stops = [],
laps = [],
session,
meeting,
drivers = [],
chapters = [],
} = data
const hasPositions = datasets['positions']?.status === 'available' const hasPositions = datasets['positions']?.status === 'available'
const [scrubTime, setScrubTime] = useState<number | null>(null) const [scrubTime, setScrubTime] = useState<number | null>(null)
const [hoverDriver, setHoverDriver] = useState<number | null>(null) const [hoverDriver, setHoverDriver] = useState<number | null>(null)
const [mapOpen, setMapOpen] = useState(false)
const [isPlaying, setIsPlaying] = useState(false)
const [playbackSpeed, setPlaybackSpeed] = useState(10)
const [chapterTourActive, setChapterTourActive] = useState(false)
const [tourChapterIndex, setTourChapterIndex] = useState<number | null>(null)
const [selectedChapterIndex, setSelectedChapterIndex] = useState<number | null>(null)
const svgRef = useRef<SVGSVGElement>(null) const svgRef = useRef<SVGSVGElement>(null)
const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 })
// Position Evolution Chart Logic const chartTiming = useMemo(() => raceChartTiming(positions, laps, results), [laps, positions, results])
const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions]) const hasChartData = hasPositions && chartTiming !== null
const hasChartData = hasPositions && allTimes.length > 0 const circuitKey = session?.circuit_key ?? meeting?.circuit_key ?? 0
const outlineYear = meeting?.year ?? (session?.date_start ? new Date(session.date_start).getFullYear() : 0)
const canProbeMap = Boolean(session?.session_key) && circuitKey > 0 && outlineYear > 0
const replayQuery = useQuery({
queryKey: ['replay-frames', session?.session_key, 5000],
queryFn: () => fetchReplayFrames(session!.session_key, 5000),
enabled: canProbeMap,
})
const outlineQuery = useQuery({
queryKey: ['track-outline', circuitKey, outlineYear],
queryFn: () => fetchTrackOutline(circuitKey, outlineYear),
enabled: canProbeMap,
})
const mapProbeSettled = !canProbeMap || (!replayQuery.isLoading && !outlineQuery.isLoading)
const mapAvailable = useMemo(
() =>
canProbeMap &&
isReplayMapAvailable(
replayQuery.data,
outlineQuery.data,
replayQuery.isError || outlineQuery.isError,
),
[canProbeMap, outlineQuery.data, outlineQuery.isError, replayQuery.data, replayQuery.isError],
)
const showMapPanel = mapOpen && mapAvailable
useEffect(() => {
if (!mapAvailable && mapOpen) {
setMapOpen(false)
}
}, [mapAvailable, mapOpen])
useEffect(() => {
if (!isPlaying || !chartTiming) return
let frame = 0
let last = performance.now()
const tick = (now: number) => {
const delta = now - last
last = now
setScrubTime((current) => {
const next = Math.min(1, (current ?? 0) + (delta * playbackSpeed) / chartTiming.tRange)
if (next >= 1) {
setIsPlaying(false)
}
return next
})
frame = requestAnimationFrame(tick)
}
frame = requestAnimationFrame(tick)
return () => cancelAnimationFrame(frame)
}, [chartTiming, isPlaying, playbackSpeed])
const stopChapterTour = () => {
setChapterTourActive(false)
setTourChapterIndex(null)
}
const jumpToChapter = (index: number, scrub: number) => {
setIsPlaying(false)
stopChapterTour()
setSelectedChapterIndex(index)
setScrubTime(scrub)
}
const clearChapterSelection = () => {
setSelectedChapterIndex(null)
}
const toggleChapterTour = () => {
if (chapterTourActive) {
stopChapterTour()
return
}
if (!chartTiming || chapters.length === 0) return
setIsPlaying(false)
setSelectedChapterIndex(null)
setChapterTourActive(true)
setTourChapterIndex(0)
const startScrub = chapterStartScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? 0
setScrubTime(startScrub)
const durations = chapterTourDurations(chapters, CHAPTER_TOUR_MS)
tourRef.current = {
chapterIndex: 0,
startedAt: performance.now(),
durationMs: durations[0] ?? CHAPTER_TOUR_MS / chapters.length,
startScrub,
endScrub: chapterEndScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? startScrub,
}
}
useEffect(() => {
if (!chapterTourActive || !chartTiming || chapters.length === 0) return
let frame = 0
const tick = (now: number) => {
const state = tourRef.current
const elapsed = now - state.startedAt
const progress = Math.min(1, elapsed / Math.max(state.durationMs, 1))
const scrub = state.startScrub + (state.endScrub - state.startScrub) * progress
setScrubTime(scrub)
setTourChapterIndex(state.chapterIndex)
if (progress >= 1) {
const nextIndex = state.chapterIndex + 1
if (nextIndex >= chapters.length) {
stopChapterTour()
return
}
const durations = chapterTourDurations(chapters, CHAPTER_TOUR_MS)
const startScrub = chapterStartScrub(chapters[nextIndex], chartTiming.tMin, chartTiming.tRange) ?? 0
const endScrub = chapterEndScrub(chapters[nextIndex], chartTiming.tMin, chartTiming.tRange) ?? startScrub
tourRef.current = {
chapterIndex: nextIndex,
startedAt: now,
durationMs: durations[nextIndex] ?? CHAPTER_TOUR_MS / chapters.length,
startScrub,
endScrub,
}
}
frame = requestAnimationFrame(tick)
}
frame = requestAnimationFrame(tick)
return () => cancelAnimationFrame(frame)
}, [chapterTourActive, chartTiming, chapters])
useEffect(() => {
if (!chapterTourActive) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
stopChapterTour()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [chapterTourActive])
const replayTMs = useMemo(() => {
const replay = replayQuery.data
const frames = replay?.frames ?? []
if (frames.length === 0) return 0
const lastFrameT = frames[frames.length - 1].t
const progress = scrubTime ?? 0
if (!chartTiming || !replay?.start_time) {
return Math.max(0, Math.min(lastFrameT, Math.round(progress * lastFrameT)))
}
const replayStart = new Date(replay.start_time).getTime()
const chartTime = chartTiming.tMin + progress * chartTiming.tRange
return Math.max(0, Math.min(lastFrameT, Math.round(chartTime - replayStart)))
}, [chartTiming, replayQuery.data, scrubTime])
let chartContent = null let chartContent = null
let displayResults = results let displayResults = results
if (hasChartData) { if (hasChartData && chartTiming) {
const tMin = new Date(allTimes[0]).getTime() const { tMin, tRange } = chartTiming
const tMax = new Date(allTimes[allTimes.length - 1]).getTime() const normaliseTime = (time: number) => Math.max(0, Math.min(1, (time - tMin) / tRange))
const tRange = Math.max(tMax - tMin, 1)
const byDriver = new Map<number, Array<{ t: number; pos: number }>>() const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
for (const p of positions) { for (const p of positions) {
const time = new Date(p.date).getTime()
if (!Number.isFinite(time)) continue
if (!byDriver.has(p.driver_number)) byDriver.set(p.driver_number, []) if (!byDriver.has(p.driver_number)) byDriver.set(p.driver_number, [])
byDriver.get(p.driver_number)!.push({ byDriver.get(p.driver_number)!.push({
t: (new Date(p.date).getTime() - tMin) / tRange, t: normaliseTime(time),
pos: p.position, pos: p.position,
}) })
} }
const dnfSet = new Set(results.filter(r => r.dnf || r.dns || r.dsq).map(r => r.driver_number)) const dnfSet = new Set(results.filter((r) => r.dnf || r.dns || r.dsq).map((r) => r.driver_number))
for (const [dNum, samples] of byDriver.entries()) { for (const [dNum, samples] of byDriver.entries()) {
samples.sort((a, b) => a.t - b.t) samples.sort((a, b) => a.t - b.t)
if (samples.length > 0 && !dnfSet.has(dNum)) { if (samples.length > 0 && !dnfSet.has(dNum)) {
@@ -51,16 +299,16 @@ export function RaceStoryCanvas({ data }: Props) {
} }
} }
const getInterpPos = (samples: {t: number, pos: number}[], t: number) => { const getInterpPos = (samples: { t: number; pos: number }[], t: number) => {
if (!samples || samples.length === 0) return null if (!samples || samples.length === 0) return null
if (t <= samples[0].t) return samples[0].pos if (t <= samples[0].t) return samples[0].pos
if (t >= samples[samples.length - 1].t) return samples[samples.length - 1].pos if (t >= samples[samples.length - 1].t) return samples[samples.length - 1].pos
for (let i = 0; i < samples.length - 1; i++) { for (let i = 0; i < samples.length - 1; i++) {
if (samples[i].t <= t && samples[i+1].t >= t) { if (samples[i].t <= t && samples[i + 1].t >= t) {
const dt = samples[i+1].t - samples[i].t const dt = samples[i + 1].t - samples[i].t
if (dt === 0) return samples[i].pos if (dt === 0) return samples[i].pos
const frac = (t - samples[i].t) / dt const frac = (t - samples[i].t) / dt
return samples[i].pos + (samples[i+1].pos - samples[i].pos) * frac return samples[i].pos + (samples[i + 1].pos - samples[i].pos) * frac
} }
} }
return null return null
@@ -84,43 +332,46 @@ export function RaceStoryCanvas({ data }: Props) {
const maxPos = Math.max(...positions.map((p) => p.position), results.length, 2) const maxPos = Math.max(...positions.map((p) => p.position), results.length, 2)
const colorByDriver = new Map(results.map((r) => [r.driver_number, r.team_colour])) const colorByDriver = new Map(results.map((r) => [r.driver_number, r.team_colour]))
const acronymByDriver = new Map(results.map((r) => [r.driver_number, r.name_acronym])) const acronymByDriver = new Map(results.map((r) => [r.driver_number, r.name_acronym]))
const positionLabels = decimatedPositionLabels(maxPos)
const W = 640 const W = CHART_W
const H = 180 const H = CHART_H
const PL = 40 const PL = CHART_PL
const PR = 48 const PR = CHART_PR
const PT = 8 const PT = CHART_PT
const PB = 20 const PB = CHART_PB
const plotW = W - PL - PR const plotW = W - PL - PR
const plotH = H - PT - PB const plotH = H - PT - PB
const toX = (t: number) => PL + t * plotW const toX = (t: number) => PL + t * plotW
const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH
const winner = results.find(r => r.position === 1) const winner = results.find((r) => r.position === 1)
const winnerLaps = winner ? laps.filter(l => l.driver_number === winner.driver_number) : [] const winnerLaps = winner ? laps.filter((l) => l.driver_number === winner.driver_number) : []
const lapTicks: { lap: number, t: number }[] = [] const lapTicks: { lap: number; t: number }[] = []
const lapInterval = winnerLaps.length < 30 ? 5 : 10 const lapInterval = winnerLaps.length < 30 ? 5 : 10
for (const lap of winnerLaps) { for (let i = 0; i < winnerLaps.length; i++) {
const lap = winnerLaps[i]
if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) { if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) {
const t = (new Date(lap.date_start).getTime() - tMin) / tRange const lapStart = new Date(lap.date_start).getTime()
if (t >= 0 && t <= 1) { const nextLapStart = winnerLaps[i + 1] ? new Date(winnerLaps[i + 1].date_start).getTime() : NaN
lapTicks.push({ lap: lap.lap_number, t }) const lapEnd = lap.lap_duration && lap.lap_duration > 0
} ? lapStart + lap.lap_duration * 1000
: nextLapStart
if (Number.isFinite(lapEnd)) lapTicks.push({ lap: lap.lap_number, t: normaliseTime(lapEnd) })
} }
} }
// Safety Car / VSC periods
const scPeriods: { start: number; end: number | null; type: 'SC' | 'VSC' }[] = [] const scPeriods: { start: number; end: number | null; type: 'SC' | 'VSC' }[] = []
let activeSC: { start: number; type: 'SC' | 'VSC' } | null = null let activeSC: { start: number; type: 'SC' | 'VSC' } | null = null
const rc = [...race_control].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) const rc = [...race_control].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
for (const msg of rc) { for (const msg of rc) {
const t = new Date(msg.date).getTime() const t = new Date(msg.date).getTime()
const m = msg.message?.toUpperCase() || '' const m = msg.message?.toUpperCase() || ''
const cat = msg.category?.toUpperCase() || '' const cat = msg.category?.toUpperCase() || ''
if (m.includes('VIRTUAL SAFETY CAR DEPLOYED') || cat === 'VIRTUALSAFETYCAR') { if (m.includes('VIRTUAL SAFETY CAR DEPLOYED') || cat === 'VIRTUALSAFETYCAR') {
if (!activeSC) activeSC = { start: t, type: 'VSC' } if (!activeSC) activeSC = { start: t, type: 'VSC' }
} else if (m.includes('SAFETY CAR DEPLOYED') || cat === 'SAFETYCAR') { } else if (m.includes('SAFETY CAR DEPLOYED') || cat === 'SAFETYCAR') {
@@ -136,23 +387,73 @@ export function RaceStoryCanvas({ data }: Props) {
scPeriods.push({ start: activeSC.start, end: null, type: activeSC.type }) scPeriods.push({ start: activeSC.start, end: null, type: activeSC.type })
} }
const chapterBands = chapters
.map((chapter, index) => {
const startT = chapterStartScrub(chapter, tMin, tRange)
const endT = chapterEndScrub(chapter, tMin, tRange)
if (startT === null) return null
const end = endT ?? startT
return {
key: `${chapter.kind}-${index}`,
start: Math.min(startT, end),
end: Math.max(startT, end),
fill: chapterBandFill(chapter.kind),
}
})
.filter((band): band is NonNullable<typeof band> => band !== null)
const labelCandidates = Array.from(byDriver.entries())
.map(([dNum, samples]) => {
const last = samples[samples.length - 1]
if (!last) return null
return { key: dNum, y: toY(last.pos) }
})
.filter((item): item is { key: number; y: number } => item !== null)
const labelYByDriver = deCollideYPositions(labelCandidates, 12)
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => { const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
setIsPlaying(false)
stopChapterTour()
clearChapterSelection()
if (!svgRef.current) return if (!svgRef.current) return
const rect = svgRef.current.getBoundingClientRect() const rect = svgRef.current.getBoundingClientRect()
const x = e.clientX - rect.left if (rect.width <= 0) return
// Pointer coordinates are CSS pixels; convert them to the SVG viewBox
// before comparing with the fixed chart margins and plot width.
const x = ((e.clientX - rect.left) / rect.width) * W
const t = Math.max(0, Math.min(1, (x - PL) / plotW)) const t = Math.max(0, Math.min(1, (x - PL) / plotW))
setScrubTime(t) setScrubTime(t)
} }
chartContent = ( chartContent = (
<div className="rs-chart-container scroll-x" data-testid="position-chart"> <div
className={`rs-chart-container scroll-x${showMapPanel ? '' : ' rs-chart-container--full'}`}
data-testid="position-chart"
>
<svg <svg
ref={svgRef} ref={svgRef}
className="rs-position-chart-svg"
viewBox={`0 0 ${W} ${H}`} viewBox={`0 0 ${W} ${H}`}
style={{ width: '100%', minWidth: 280, maxWidth: W, display: 'block' }}
role="img" role="img"
aria-label="Position evolution chart" aria-label="Position evolution chart"
> >
{chapterBands.map((band) => {
const x1 = toX(Math.max(0, band.start))
const x2 = toX(Math.min(1, band.end))
if (x2 <= PL || x1 >= W - PR) return null
return (
<rect
key={band.key}
x={x1}
y={PT}
width={Math.max(0, x2 - x1)}
height={plotH}
fill={band.fill}
data-testid="chapter-band"
/>
)
})}
{scPeriods.map((sc, i) => { {scPeriods.map((sc, i) => {
const startT = (sc.start - tMin) / tRange const startT = (sc.start - tMin) / tRange
const endT = sc.end ? (sc.end - tMin) / tRange : 1 const endT = sc.end ? (sc.end - tMin) / tRange : 1
@@ -171,33 +472,41 @@ export function RaceStoryCanvas({ data }: Props) {
) )
})} })}
{Array.from({ length: maxPos }, (_, i) => i + 1).map((pos) => ( {positionLabels.map((pos) => (
<g key={pos}> <g key={pos}>
<line <line
x1={PL} x1={PL}
x2={W - PR} x2={W - PR}
y1={toY(pos)} y1={toY(pos)}
y2={toY(pos)} y2={toY(pos)}
stroke="var(--border)" className="rs-chart-grid-line"
strokeWidth={0.5}
/> />
<text <text
x={PL - 4} x={PL - 4}
y={toY(pos) + 4} y={toY(pos) + 4}
textAnchor="end" textAnchor="end"
fill="var(--text-3)" className="rs-chart-axis-label"
fontSize={8}
fontFamily="var(--f-mono)"
> >
P{pos} P{pos}
</text> </text>
</g> </g>
))} ))}
{lapTicks.map(tick => ( {lapTicks.map((tick) => (
<g key={`lap-${tick.lap}`}> <g key={`lap-${tick.lap}`}>
<line x1={toX(tick.t)} x2={toX(tick.t)} y1={H - PB} y2={H - PB + 4} stroke="var(--border)" strokeWidth={1} /> <line
<text x={toX(tick.t)} y={H - PB + 14} textAnchor="middle" fill="var(--text-3)" fontSize={9} fontFamily="var(--f-mono)"> x1={toX(tick.t)}
x2={toX(tick.t)}
y1={H - PB}
y2={H - PB + 4}
className="rs-chart-lap-tick"
/>
<text
x={toX(tick.t)}
y={H - PB + 14}
textAnchor="middle"
className="rs-chart-axis-label"
>
L{tick.lap} L{tick.lap}
</text> </text>
</g> </g>
@@ -210,11 +519,12 @@ export function RaceStoryCanvas({ data }: Props) {
const last = samples[samples.length - 1] const last = samples[samples.length - 1]
const isHovered = hoverDriver === dNum const isHovered = hoverDriver === dNum
const isFaded = hoverDriver !== null && !isHovered const isFaded = hoverDriver !== null && !isHovered
const labelY = last ? (labelYByDriver.get(dNum) ?? toY(last.pos)) : 0
const driverPits = pit_stops.filter(p => p.driver_number === dNum) const driverPits = pit_stops.filter((p) => p.driver_number === dNum)
return ( return (
<g <g
key={dNum} key={dNum}
style={{ opacity: isFaded ? 0.2 : 1, transition: 'opacity 0.2s' }} style={{ opacity: isFaded ? 0.2 : 1, transition: 'opacity 0.2s' }}
onMouseEnter={() => setHoverDriver(dNum)} onMouseEnter={() => setHoverDriver(dNum)}
@@ -230,30 +540,31 @@ export function RaceStoryCanvas({ data }: Props) {
className="rs-driver-line" className="rs-driver-line"
pathLength={1} pathLength={1}
/> />
{driverPits.map((p, i) => { {driverPits.map((p, i) => {
const t = (new Date(p.date).getTime() - tMin) / tRange const time = new Date(p.date).getTime()
if (t < 0 || t > 1) return null if (!Number.isFinite(time) || time < tMin || time > tMin + tRange) return null
const t = normaliseTime(time)
const pos = getInterpPos(samples, t) const pos = getInterpPos(samples, t)
if (pos === null) return null if (pos === null) return null
return ( return (
<circle <circle
key={`pit-${i}`} key={`pit-${i}`}
cx={toX(t)} cx={toX(t)}
cy={toY(pos)} cy={toY(pos)}
r={3} r={3}
fill="var(--bg)" fill="var(--bg)"
stroke={color} stroke={color}
strokeWidth={2} strokeWidth={2}
className="rs-pit-dot" className="rs-pit-dot"
/> />
) )
})} })}
{last && ( {last && (
<text <text
x={toX(last.t) + 6} x={toX(last.t) + 6}
y={toY(last.pos) + 4} y={labelY + 4}
fill={color} fill={color}
fontSize={isHovered ? 11 : 9} fontSize={isHovered ? 11 : 9}
fontFamily="var(--f-mono)" fontFamily="var(--f-mono)"
@@ -287,25 +598,110 @@ export function RaceStoryCanvas({ data }: Props) {
width={plotW} width={plotW}
height={plotH} height={plotH}
fill="transparent" fill="transparent"
data-testid="position-chart-interaction"
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerLeave={() => setScrubTime(null)} onPointerLeave={() => {
if (!isPlaying) {
clearChapterSelection()
setScrubTime(null)
}
}}
style={{ cursor: 'crosshair', touchAction: 'none' }} style={{ cursor: 'crosshair', touchAction: 'none' }}
/> />
</svg> </svg>
<div className="rs-segmented-control" aria-label="Race replay controls" data-testid="replay-controls">
<button
type="button"
className={`rs-segment ${isPlaying ? 'active' : ''}`}
onClick={() => {
setScrubTime((current) => current ?? 0)
stopChapterTour()
clearChapterSelection()
setIsPlaying((current) => !current)
}}
aria-pressed={isPlaying}
>
{isPlaying ? 'Pause' : 'Play'}
</button>
<span className="rs-segment-divider" aria-hidden />
{[1, 10, 30].map((speed) => (
<button
key={speed}
type="button"
className={`rs-segment ${playbackSpeed === speed ? 'active' : ''}`}
onClick={() => setPlaybackSpeed(speed)}
aria-pressed={playbackSpeed === speed}
>
{speed}x
</button>
))}
{mapProbeSettled && mapAvailable && (
<>
<span className="rs-segment-divider" aria-hidden />
<button
type="button"
className={`rs-segment ${mapOpen ? 'active' : ''}`}
onClick={() => setMapOpen((current) => !current)}
aria-pressed={mapOpen}
data-testid="replay-map-toggle"
>
Map
</button>
</>
)}
</div>
</div> </div>
) )
} }
return ( return (
<div className="race-story-canvas"> <div className="race-story-canvas">
{hasChartData ? ( {hasChartData && chartTiming && chapters.length > 0 && (
chartContent <ChapterStrip
) : ( chapters={chapters}
<div className="analysis-notice"> scrubTime={scrubTime}
<strong>Lap-by-lap positions not available.</strong> This session does not tMin={chartTiming.tMin}
have ingested position samples in <code>/api/v1/race-hub</code>. tRange={chartTiming.tRange}
</div> tourActive={chapterTourActive}
tourChapterIndex={tourChapterIndex}
selectedChapterIndex={selectedChapterIndex}
onChapterClick={jumpToChapter}
onTourToggle={toggleChapterTour}
/>
)} )}
<div className={`rs-replay-shell${showMapPanel ? ' rs-replay-shell--split' : ''}`}>
<div className="rs-replay-main">
{hasChartData ? (
chartContent
) : (
<EmptyStateCard
icon={LineChart}
title="Lap-by-lap positions not available"
hint={
<>
This session does not have ingested position samples in{' '}
<code>/api/v1/race-hub</code>.
</>
}
testId="race-story-no-positions"
className="race-story-empty-card"
/>
)}
</div>
{showMapPanel && (
<div className="rs-replay-map-slot" data-testid="replay-map-slot">
<ReplayTrackMap
outline={outlineQuery.data}
replay={replayQuery.data}
tMs={replayTMs}
drivers={drivers}
results={results}
loading={outlineQuery.isLoading || replayQuery.isLoading}
error={outlineQuery.isError || replayQuery.isError}
/>
</div>
)}
</div>
{displayResults.length > 0 && ( {displayResults.length > 0 && (
<div className="rs-field-list"> <div className="rs-field-list">
@@ -313,30 +709,29 @@ export function RaceStoryCanvas({ data }: Props) {
const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0 const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
const currentPos = scrubTime !== null ? i + 1 : r.position const currentPos = scrubTime !== null ? i + 1 : r.position
const isWinner = i === 0 && r.position === 1 const isWinner = i === 0 && r.position === 1
const pClass = currentPos === 1 ? 'rs-pos-p1' : currentPos === 2 ? 'rs-pos-p2' : currentPos === 3 ? 'rs-pos-p3' : '' const pClass =
currentPos === 1 ? 'rs-pos-p1' : currentPos === 2 ? 'rs-pos-p2' : currentPos === 3 ? 'rs-pos-p3' : ''
let currentPoints: number | string = r.points let currentPoints: number | string = r.points
if (scrubTime !== null) { if (scrubTime !== null) {
const isSprint = data.session?.session_type?.toLowerCase().includes('sprint') const isSprint = data.session?.session_type?.toLowerCase().includes('sprint')
const ptsArray = isSprint ? [8, 7, 6, 5, 4, 3, 2, 1] : [25, 18, 15, 12, 10, 8, 6, 4, 2, 1] const ptsArray = isSprint ? [8, 7, 6, 5, 4, 3, 2, 1] : [25, 18, 15, 12, 10, 8, 6, 4, 2, 1]
currentPoints = currentPos <= ptsArray.length ? ptsArray[currentPos - 1] : 0 currentPoints = currentPos <= ptsArray.length ? ptsArray[currentPos - 1] : 0
} }
return ( return (
<div <div
key={r.driver_number} key={r.driver_number}
className={`rs-driver-row ${hoverDriver === r.driver_number ? 'rs-driver-row-hover' : ''}`} className={`rs-driver-row ${hoverDriver === r.driver_number ? 'rs-driver-row-hover' : ''}`}
onMouseEnter={() => setHoverDriver(r.driver_number)} onMouseEnter={() => setHoverDriver(r.driver_number)}
onMouseLeave={() => setHoverDriver(null)} onMouseLeave={() => setHoverDriver(null)}
> >
<div className="rs-driver-left"> <div className="rs-driver-left">
<div className={`rs-pos-col ${pClass}`}> <div className={`rs-pos-col ${pClass}`}>{currentPos}</div>
{currentPos}
</div>
<div className="rs-driver-cell"> <div className="rs-driver-cell">
<div <div
className="rs-driver-color" className="rs-driver-color"
style={{ background: r.team_colour ? `#${r.team_colour}` : 'var(--border)' }} style={{ background: r.team_colour ? `#${r.team_colour}` : 'var(--border)' }}
/> />
<div className="rs-driver-identity"> <div className="rs-driver-identity">
<span className="rs-driver-name">{r.name_acronym || r.driver_number}</span> <span className="rs-driver-name">{r.name_acronym || r.driver_number}</span>
@@ -354,12 +749,12 @@ export function RaceStoryCanvas({ data }: Props) {
</span> </span>
<span className="rs-metric-label">Grid</span> <span className="rs-metric-label">Grid</span>
</div> </div>
<div className="rs-metric" style={{ width: '80px', opacity: scrubTime !== null ? 0.3 : 1 }}> <div className="rs-metric" style={{ width: '80px', opacity: scrubTime !== null ? 0.3 : 1 }}>
<span>{isWinner ? formatDuration(r.duration) : formatGap(r.gap_to_leader)}</span> <span>{isWinner ? formatDuration(r.duration) : formatGap(r.gap_to_leader)}</span>
<span className="rs-metric-label">{isWinner ? 'Time' : 'Gap'}</span> <span className="rs-metric-label">{isWinner ? 'Time' : 'Gap'}</span>
</div> </div>
<div className="rs-metric" style={{ width: '40px' }}> <div className="rs-metric" style={{ width: '40px' }}>
<span style={{ color: Number(currentPoints) > 0 ? 'var(--text)' : 'var(--text-3)' }}> <span style={{ color: Number(currentPoints) > 0 ? 'var(--text)' : 'var(--text-3)' }}>
{currentPoints} {currentPoints}

View File

@@ -0,0 +1,159 @@
import { useMemo, useState } from 'react'
import { Loader2, MapPin, Satellite } from 'lucide-react'
import type { Driver, EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types'
import { buildOutlinePath } from '../lib/trackmap'
import { interpolateReplayCars, replayCarToSvg } from '../lib/replay'
import { EmptyStateCard } from './EmptyStateCard'
import '../styles/replay-map.css'
interface Props {
outline?: TrackOutline | null
replay?: ReplayFramesResponse | null
tMs: number
drivers: Driver[]
results: EnrichedResult[]
loading?: boolean
error?: boolean
}
export function ReplayTrackMap({
outline,
replay,
tMs,
drivers,
results,
loading = false,
error = false,
}: Props) {
const [pinned, setPinned] = useState<string | null>(null)
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
const driverInfo = useMemo(() => {
const info = new Map<string, { label: string; color: string }>()
for (const driver of drivers) {
info.set(String(driver.driver_number), {
label: driver.name_acronym || String(driver.driver_number),
color: normalizeColor(driver.team_colour),
})
}
for (const result of results) {
const key = String(result.driver_number)
if (!info.has(key)) {
info.set(key, {
label: result.name_acronym || key,
color: normalizeColor(result.team_colour),
})
}
}
return info
}, [drivers, results])
const cars = useMemo(() => {
if (!outline?.bounds || !replay?.frames?.length) return []
const positions = interpolateReplayCars(replay.frames, tMs)
return Object.entries(positions)
.map(([number, car]) => ({
number,
svg: replayCarToSvg(car, outline.bounds),
info: driverInfo.get(number),
}))
.sort((a, b) => Number(a.number) - Number(b.number))
}, [driverInfo, outline?.bounds, replay?.frames, tMs])
if (loading) {
return (
<section className="replay-map-panel" data-testid="replay-track-map">
<EmptyStateCard
icon={Loader2}
title="Loading replay map"
hint="Fetching track outline and historical GPS frames."
testId="replay-map-loading"
className="replay-map-empty-card"
/>
</section>
)
}
if (error) {
return (
<section className="replay-map-panel" data-testid="replay-track-map">
<EmptyStateCard
icon={Satellite}
title="Replay GPS unavailable"
hint="This session does not have ingested location samples for the replay map."
testId="replay-map-error"
className="replay-map-empty-card"
/>
</section>
)
}
if (!outline || !outlinePath) {
return (
<section className="replay-map-panel" data-testid="replay-track-map">
<EmptyStateCard
icon={MapPin}
title="Track outline unavailable"
hint="Circuit GPS outline data is missing for this session."
testId="replay-map-no-outline"
className="replay-map-empty-card"
/>
</section>
)
}
if (!replay?.frames?.length || cars.length === 0) {
return (
<section className="replay-map-panel" data-testid="replay-track-map">
<EmptyStateCard
icon={Satellite}
title="Historical GPS unavailable"
hint="Fewer than two replay frames were returned for this session."
testId="replay-map-no-frames"
className="replay-map-empty-card"
/>
</section>
)
}
return (
<section className="replay-map-panel" data-testid="replay-track-map">
<div className="replay-map-stage">
<svg className="replay-map-svg" viewBox="0 0 100 100" role="img" aria-label="Replay track map">
<path className="replay-map-outline-shadow" d={outlinePath} />
<path className="replay-map-outline" d={outlinePath} />
{cars.map((car) => {
const label = car.info?.label ?? car.number
const selected = pinned === car.number
return (
<g
key={car.number}
role="button"
tabIndex={0}
aria-label={`${label} replay position`}
className={`replay-car ${selected ? 'replay-car-pinned' : ''}`}
transform={`translate(${car.svg.x.toFixed(2)} ${car.svg.y.toFixed(2)})`}
onClick={() => setPinned(selected ? null : car.number)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setPinned(selected ? null : car.number)
}
}}
>
<title>{label}</title>
<circle r="2.7" fill={car.info?.color ?? '#777777'} />
<text y="0.85">{label}</text>
</g>
)
})}
</svg>
</div>
</section>
)
}
function normalizeColor(color: string | undefined): string {
const raw = color?.trim()
if (!raw) return '#777777'
return raw.startsWith('#') ? raw : `#${raw}`
}

View File

@@ -0,0 +1,271 @@
import { useMemo, useState } from 'react'
import type { ChampHubDriver, ChampionshipHub } from '../types'
import { teamColor } from '../utils'
import { gapSeries, h2hTally, lastRounds } from '../lib/rivalry'
import '../styles/rivalry.css'
const PAD_L = 48
const PAD_T = 16
const PLOT_W = 882
const PLOT_H = 316
const GAP_PLOT_H = 200
function fmtPts(n: number): string {
return Number.isInteger(n) ? String(n) : n.toFixed(1)
}
/** Up to ~7 evenly spaced round labels, always including the last round. */
function xTicks(n: number, labels: string[], x: (i: number) => number): { x: number; label: string }[] {
const step = Math.max(1, Math.ceil(n / 7))
const ticks: { x: number; label: string }[] = []
for (let i = 0; i < n; i += step) {
ticks.push({ x: x(i), label: labels[i] ?? `R${i + 1}` })
}
const last = labels[n - 1] ?? `R${n}`
if (ticks[ticks.length - 1]?.label !== last) {
ticks.push({ x: x(n - 1), label: last })
}
return ticks
}
function polyline(values: number[], x: (i: number) => number, y: (v: number) => number): string {
return values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ')
}
interface PickerProps {
id: 'a' | 'b'
label: string
drivers: ChampHubDriver[]
value: number
color: string
onChange: (driverNumber: number) => void
}
function DriverPicker({ id, label, drivers, value, color, onChange }: PickerProps) {
return (
<label className="rivalry-picker">
<span className="rivalry-picker-label">{label}</span>
<select
className="rivalry-picker-select"
style={{ borderLeft: `3px solid ${color}` }}
value={value}
data-testid={`rivalry-pick-${id}`}
onChange={(e) => onChange(Number(e.target.value))}
>
{drivers.map((d) => (
<option key={d.driver_number} value={d.driver_number}>
{d.name_acronym} · {d.full_name}
</option>
))}
</select>
</label>
)
}
export function RivalryCompare({ hub }: { hub: ChampionshipHub }) {
const { drivers } = hub
const [pickA, setPickA] = useState<number | null>(null)
const [pickB, setPickB] = useState<number | null>(null)
// Default to the top two in the standings; fall back there if a picked
// driver disappears (e.g. season switch re-fetches the hub).
const a = drivers.find((d) => d.driver_number === pickA) ?? drivers[0]
const b = drivers.find((d) => d.driver_number === pickB) ?? drivers[1]
const tally = useMemo(
() => (a && b ? h2hTally(a.round_positions ?? [], b.round_positions ?? [], hub.round_labels) : null),
[a, b, hub.round_labels],
)
if (drivers.length < 2) {
return (
<div className="champ-chart-empty" data-testid="champ-view-rivalry">
Need at least two drivers in the standings to compare a rivalry.
</div>
)
}
const colorA = teamColor(a.team_colour)
const colorB = teamColor(b.team_colour)
const sameTeam = a.team_name === b.team_name
const cumA = a.cumulative ?? []
const cumB = b.cumulative ?? []
const rounds = Math.min(cumA.length, cumB.length)
const gaps = gapSeries(cumA, cumB)
const lastGap = gaps[gaps.length - 1] ?? 0
const strip = tally ? lastRounds(tally, 5) : []
const pickers = (
<div className="rivalry-pickers">
<DriverPicker id="a" label="Driver A" drivers={drivers} value={a.driver_number} color={colorA} onChange={setPickA} />
<span className="rivalry-vs mono">vs</span>
<DriverPicker id="b" label="Driver B" drivers={drivers} value={b.driver_number} color={colorB} onChange={setPickB} />
</div>
)
if (rounds === 0) {
return (
<div className="rivalry" data-testid="champ-view-rivalry">
{pickers}
<div className="champ-chart-empty">
No completed rounds yet the rivalry will appear after the first race.
</div>
</div>
)
}
// Points race scales.
const peak = Math.max(...cumA.slice(0, rounds), ...cumB.slice(0, rounds), 1)
const maxY = Math.max(50, Math.ceil(peak / 50) * 50)
const px = (i: number) => (rounds <= 1 ? PAD_L : PAD_L + (i * PLOT_W) / (rounds - 1))
const py = (v: number) => PAD_T + PLOT_H - (v / maxY) * PLOT_H
const pyGrid = [0, 0.25, 0.5, 0.75, 1].map((f) => ({ y: py(maxY * f), label: Math.round(maxY * f) }))
const pxGrid = xTicks(rounds, hub.round_labels, px)
// Gap scales: symmetric around zero.
const maxAbs = Math.max(10, Math.ceil(Math.max(...gaps.map(Math.abs), 1) / 10) * 10)
const gy = (v: number) => PAD_T + GAP_PLOT_H / 2 - (v / maxAbs) * (GAP_PLOT_H / 2)
const gyGrid = [maxAbs, 0, -maxAbs].map((v) => ({ y: gy(v), label: v > 0 ? `+${v}` : String(v) }))
const gxGrid = xTicks(rounds, hub.round_labels, px)
const gapLeader = lastGap === 0 ? null : lastGap > 0 ? a : b
const gapCaption = gapLeader
? `${gapLeader.name_acronym} leads by ${fmtPts(Math.abs(lastGap))} pts after ${hub.round_labels[rounds - 1] ?? `R${rounds}`}.`
: 'Dead level on points.'
return (
<div className="rivalry" data-testid="champ-view-rivalry">
{pickers}
<div className="rivalry-h2h" data-testid="rivalry-h2h">
<div className="rivalry-h2h-score">
<span className="rivalry-h2h-code mono" style={{ color: colorA }}>
{a.name_acronym}
</span>
<span className="rivalry-h2h-num mono" data-testid="rivalry-h2h-num">
{tally ? `${tally.a}${tally.b}` : '—'}
</span>
<span className="rivalry-h2h-code mono" style={{ color: colorB }}>
{b.name_acronym}
</span>
</div>
<span className="rivalry-h2h-meta">
Race head-to-head · {tally?.rounds.length ?? 0} round{(tally?.rounds.length ?? 0) === 1 ? '' : 's'} counted
{tally && tally.skipped > 0 ? ` · ${tally.skipped} skipped` : ''}
</span>
{strip.length > 0 && (
<div className="rivalry-strip" data-testid="rivalry-strip">
<span className="rivalry-strip-label">Last {strip.length}</span>
{strip.map((r) => (
<span className="rivalry-chip" key={r.round} title={`${r.label}: ${a.name_acronym} P${r.posA} · ${b.name_acronym} P${r.posB}`}>
<span className="rivalry-chip-round mono">{r.label}</span>
<span className="rivalry-chip-winner mono" style={{ color: r.winner === 'a' ? colorA : colorB }}>
{r.winner === 'a' ? a.name_acronym : b.name_acronym}
</span>
</span>
))}
</div>
)}
</div>
<section className="rivalry-section" data-testid="rivalry-points-race">
<div className="champ-chart-head">
<span className="champ-chart-title mono">
Points race {a.name_acronym} vs {b.name_acronym}
</span>
<span className="champ-chart-meta">
Rounds 1{rounds} · {hub.season}
</span>
</div>
<div className="champ-chart">
<svg viewBox="0 0 1000 380" className="champ-chart-svg" preserveAspectRatio="none">
{pyGrid.map((g) => (
<g key={g.label}>
<line x1={PAD_L} y1={g.y} x2={930} y2={g.y} stroke="var(--border)" strokeWidth="1" />
<text x={40} y={g.y + 4} textAnchor="end" className="champ-chart-axis">
{g.label}
</text>
</g>
))}
{pxGrid.map((g, i) => (
<text key={i} x={g.x} y={372} textAnchor="middle" className="champ-chart-axis">
{g.label}
</text>
))}
{[
{ slot: 'a', d: a, color: colorA, dash: '0', values: cumA.slice(0, rounds) },
{ slot: 'b', d: b, color: colorB, dash: sameTeam ? '5 4' : '0', values: cumB.slice(0, rounds) },
].map((s) => (
<g key={s.slot}>
<polyline
points={polyline(s.values, px, py)}
fill="none"
stroke={s.color}
strokeWidth="2.4"
strokeDasharray={s.dash}
strokeLinejoin="round"
strokeLinecap="round"
/>
<circle cx={px(s.values.length - 1)} cy={py(s.values[s.values.length - 1])} r="3" fill={s.color} />
<text
x={px(s.values.length - 1) + 8}
y={py(s.values[s.values.length - 1]) + 4}
fill={s.color}
className="champ-chart-label"
>
{s.d.name_acronym}
</text>
</g>
))}
</svg>
</div>
</section>
<section className="rivalry-section" data-testid="rivalry-gap">
<div className="champ-chart-head">
<span className="champ-chart-title mono">
Gap over season {a.name_acronym} {b.name_acronym}
</span>
<span className="champ-chart-meta">{gapCaption}</span>
</div>
<div className="champ-chart">
<svg viewBox="0 0 1000 250" className="champ-chart-svg" preserveAspectRatio="none">
{gyGrid.map((g) => (
<g key={g.label}>
<line
x1={PAD_L}
y1={g.y}
x2={930}
y2={g.y}
stroke={g.label === '0' ? 'var(--border-2)' : 'var(--border)'}
strokeWidth={g.label === '0' ? 1.5 : 1}
/>
<text x={40} y={g.y + 4} textAnchor="end" className="champ-chart-axis">
{g.label}
</text>
</g>
))}
{gxGrid.map((g, i) => (
<text key={i} x={g.x} y={242} textAnchor="middle" className="champ-chart-axis">
{g.label}
</text>
))}
<polyline
points={polyline(gaps, px, gy)}
fill="none"
stroke={colorA}
strokeWidth="2.4"
strokeLinejoin="round"
strokeLinecap="round"
/>
<circle cx={px(gaps.length - 1)} cy={gy(lastGap)} r="3" fill={colorA} />
</svg>
</div>
<p className="rivalry-caption">
Above the zero line: {a.name_acronym} ahead. Below: {b.name_acronym} ahead.
</p>
</section>
</div>
)
}

View File

@@ -8,15 +8,46 @@ export type Tab =
| 'race_control' | 'race_control'
| 'data_status' | 'data_status'
const TABS: { id: Tab; label: string }[] = [ interface TabDef {
{ id: 'overview', label: 'Overview' }, id: Tab
{ id: 'race_story', label: 'Race Story' }, label: string
{ id: 'strategy', label: 'Strategy' }, }
{ id: 'compare', label: 'Compare' },
{ id: 'lap_data', label: 'Lap Data' }, interface TabGroup {
{ id: 'conditions', label: 'Conditions' }, id: string
{ id: 'race_control', label: 'Race Control' }, label: string
{ id: 'data_status', label: 'Data Status' }, tabs: TabDef[]
}
// Fan-facing hierarchy: Story first, then Analysis, then Data/Context. Every
// existing capability is preserved — only the grouping and ordering change.
const TAB_GROUPS: TabGroup[] = [
{
id: 'story',
label: 'Story',
tabs: [
{ id: 'overview', label: 'Overview' },
{ id: 'race_story', label: 'Race Story' },
],
},
{
id: 'analysis',
label: 'Analysis',
tabs: [
{ id: 'strategy', label: 'Strategy' },
{ id: 'compare', label: 'Compare' },
{ id: 'lap_data', label: 'Lap Data' },
],
},
{
id: 'context',
label: 'Data & Context',
tabs: [
{ id: 'conditions', label: 'Conditions' },
{ id: 'race_control', label: 'Race Control' },
{ id: 'data_status', label: 'Diagnostics' },
],
},
] ]
interface Props { interface Props {
@@ -26,17 +57,26 @@ interface Props {
export function TabBar({ active, onChange }: Props) { export function TabBar({ active, onChange }: Props) {
return ( return (
<div className="tab-bar" role="tablist"> <div className="tab-bar tab-bar-grouped" role="tablist" data-testid="rh-tabbar">
{TABS.map((t) => ( {TAB_GROUPS.map((group) => (
<button <div key={group.id} className="tab-group" data-testid={`rh-tabgroup-${group.id}`}>
key={t.id} <span className="tab-group-label mono" aria-hidden="true">
role="tab" {group.label}
aria-selected={active === t.id} </span>
className={`tab-btn${active === t.id ? ' active' : ''}`} <div className="tab-group-btns">
onClick={() => onChange(t.id)} {group.tabs.map((t) => (
> <button
{t.label} key={t.id}
</button> role="tab"
aria-selected={active === t.id}
className={`tab-btn${active === t.id ? ' active' : ''}`}
onClick={() => onChange(t.id)}
>
{t.label}
</button>
))}
</div>
</div>
))} ))}
</div> </div>
) )

View File

@@ -0,0 +1,55 @@
import { teamColor } from '../utils'
import '../styles/h2h.css'
export interface TeammateH2HProps {
teamName: string
teamColour: string
driverATla: string
driverBTla: string
winsA: number
winsB: number
/** e.g. "+1" when more than two drivers share the team. */
extraNote?: string
}
export function TeammateH2H({
teamName,
teamColour,
driverATla,
driverBTla,
winsA,
winsB,
extraNote,
}: TeammateH2HProps) {
const total = winsA + winsB || 1
const pctA = (winsA / total) * 100
const color = teamColor(teamColour)
return (
<div className="h2h-row" data-testid="teammate-h2h">
<div className="h2h-team">
<span className="h2h-team-name">{teamName}</span>
{extraNote ? <span className="h2h-extra mono">{extraNote}</span> : null}
</div>
<div className="h2h-bar-wrap">
<span className="h2h-tla h2h-tla--left mono">{driverATla}</span>
<div className="h2h-bar" data-testid="h2h-bar">
<span
className="h2h-bar-seg"
data-testid="h2h-bar-a"
style={{ width: `${pctA}%`, background: color }}
/>
<span
className="h2h-bar-seg h2h-bar-seg--muted"
data-testid="h2h-bar-b"
style={{ width: `${100 - pctA}%`, background: color }}
/>
<span className="h2h-score mono" data-testid="h2h-score">
{winsA}{winsB}
</span>
</div>
<span className="h2h-tla h2h-tla--right mono">{driverBTla}</span>
</div>
</div>
)
}

View File

@@ -2,34 +2,37 @@ import { useEffect, 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 { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api' import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage' import { sessionTypeAbbrev } from '../lib/coverage'
import {
resolveSessionState,
sessionStateDotClass,
sessionStateLabel,
} from '../lib/sessionState'
import { countryDecal, formatGpDateRange } from '../lib/gpIdentity' import { countryDecal, formatGpDateRange } from '../lib/gpIdentity'
import type { WeekendContext } from '../types'
interface Props { interface Props {
currentMeetingKey?: number currentMeetingKey?: number
currentSessionKey?: number currentSessionKey?: number
context?: WeekendContext | null
now?: Date
onClose: () => void onClose: () => void
} }
export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose }: Props) { export function WeekendSwitcher({
currentMeetingKey,
currentSessionKey,
context,
now: nowProp,
onClose,
}: Props) {
const navigate = useNavigate() const navigate = useNavigate()
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons }) const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons })
const [year, setYear] = useState<number | null>(null) const [year, setYear] = useState<number | null>(null)
const [openMeetingKey, setOpenMeetingKey] = useState<number | null>( const [openMeetingKey, setOpenMeetingKey] = useState<number | null>(
currentMeetingKey ?? null, currentMeetingKey ?? null,
) )
const now = nowProp ?? new Date()
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({ const weekendQuery = useQuery({
queryKey: ['weekend', openMeetingKey], queryKey: ['weekend', openMeetingKey],
@@ -37,6 +40,22 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
enabled: openMeetingKey != null, enabled: openMeetingKey != null,
}) })
useEffect(() => {
if (year != null) return
const currentYear = weekendQuery.data?.meeting?.year
if (currentYear) {
setYear(currentYear)
} else if (seasonsQuery.data?.length) {
setYear(seasonsQuery.data[0])
}
}, [seasonsQuery.data, weekendQuery.data, year])
const meetingsQuery = useQuery({
queryKey: ['meetings', year],
queryFn: () => fetchLocalMeetings(year!),
enabled: year != null,
})
const seasons = seasonsQuery.data ?? [] const seasons = seasonsQuery.data ?? []
const meetings = meetingsQuery.data ?? [] const meetings = meetingsQuery.data ?? []
const weekend = weekendQuery.data const weekend = weekendQuery.data
@@ -106,9 +125,16 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
{weekendQuery.isLoading && ( {weekendQuery.isLoading && (
<div className="rh-switcher-empty">loading sessions</div> <div className="rh-switcher-empty">loading sessions</div>
)} )}
{weekend && weekend.meeting_key === m.meeting_key && {weekend &&
weekend.sessions.map(({ session, source, datasets }) => { weekend.meeting_key === m.meeting_key &&
weekend.sessions.map((weekendSession) => {
const { session } = weekendSession
const active = session.session_key === currentSessionKey const active = session.session_key === currentSessionKey
const state = resolveSessionState({
weekendSession,
context,
now,
})
return ( return (
<button <button
key={session.session_key} key={session.session_key}
@@ -122,8 +148,11 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
</span> </span>
<span className="rh-switcher-sess-name">{session.session_name}</span> <span className="rh-switcher-sess-name">{session.session_name}</span>
<span className="rh-switcher-sess-cov mono"> <span className="rh-switcher-sess-cov mono">
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" /> <span
{formatCoverageHint(datasets)} className={`cc-cov-dot ${sessionStateDotClass(state)}`}
aria-hidden="true"
/>
{sessionStateLabel(state)}
</span> </span>
</button> </button>
) )

View File

@@ -0,0 +1,152 @@
import { Link } from '@tanstack/react-router'
import { ChevronRight, Flag as FlagIcon } from 'lucide-react'
import type {
WeekendBriefingItem,
WeekendChampionshipImpact,
WeekendContext,
WeekendViewState,
} from '../../types'
import {
BriefingStrip,
ChampionshipImpactCard,
ChampionshipRoundStrip,
CountdownDisplay,
EventPodium,
Flag,
} from './shared'
import { analysisSessionKey, meetingIdentity } from '../../lib/weekendContext'
import { parseScheduleTime } from '../../lib/schedule'
const EYEBROW: Record<string, string> = {
between_weekends: 'Between races',
post_weekend: 'Post-weekend',
season_complete: 'Season complete',
}
function formatSessionLine(name: string | undefined, start: string | undefined): string {
if (!name) return 'Schedule to be confirmed'
const date = start ? parseScheduleTime(start) : null
if (!date) return name
const when = date.toLocaleString('en-GB', {
weekday: 'long',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
return `${name} · ${when}`
}
export function BetweenRacesView({
context,
now,
view,
championship,
briefing,
}: {
context: WeekendContext
now: Date
view: WeekendViewState
championship?: WeekendChampionshipImpact
briefing: WeekendBriefingItem[]
}) {
const eyebrow = EYEBROW[view] ?? 'Between races'
const previous = context.previous_completed_session
const previousMeeting = meetingIdentity(previous?.meeting ?? context.previous_meeting)
const nextMeeting = meetingIdentity(context.next_meeting)
const nextSession = context.next_session?.session
const analysisKey = analysisSessionKey(context)
return (
<div className="wk-between" data-testid="weekend-between-races" data-state={view}>
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">{eyebrow}</div>
<div className="wk-top-grid">
{previousMeeting && (
<section className="wk-event-card wk-event-last" data-testid="wk-last-event">
<header className="wk-event-head">
<div className="wk-event-id">
<Flag code={previousMeeting.country_code} flag={previousMeeting.country_flag} />
<h2 className="wk-event-name">{previousMeeting.meeting_name}</h2>
</div>
<span className="wk-event-tag wk-tag-done mono">
<FlagIcon size={13} aria-hidden="true" /> Completed
</span>
</header>
<div className="wk-event-body">
<EventPodium sessionKey={analysisKey} />
<div className="wk-story-card">
<span className="wk-story-label">What decided it?</span>
{analysisKey ? (
<Link
to="/race-hub"
search={{ session_key: analysisKey }}
className="wk-cta wk-cta-primary"
data-testid="wk-explore-race-story"
>
Explore Race Story <ChevronRight size={15} aria-hidden="true" />
</Link>
) : (
<p className="wk-story-text" data-testid="wk-no-analysis">
Analysis for this session is not available locally yet.
</p>
)}
</div>
</div>
</section>
)}
{nextMeeting ? (
<section className="wk-event-card wk-event-next" data-testid="wk-next-event">
<header className="wk-event-head">
<div className="wk-event-id">
<Flag code={nextMeeting.country_code} flag={nextMeeting.country_flag} />
<h2 className="wk-event-name">{nextMeeting.meeting_name}</h2>
</div>
<span className="wk-event-tag wk-tag-next mono">Next event</span>
</header>
<div className="wk-next-body">
<CountdownDisplay target={nextSession?.date_start ?? nextMeeting.date_start} now={now} />
<div className="wk-next-session">
<span className="wk-next-label mono">Next session</span>
<span className="wk-next-value">
{formatSessionLine(nextSession?.session_name, nextSession?.date_start)}
</span>
</div>
<Link to="/preview" className="wk-cta wk-cta-primary wk-cta-wide" data-testid="wk-prepare">
Prepare for {nextMeeting.short_name}
<ChevronRight size={15} aria-hidden="true" />
</Link>
</div>
</section>
) : (
<section className="wk-event-card wk-event-next" data-testid="wk-season-complete-card">
<header className="wk-event-head">
<h2 className="wk-event-name">That&apos;s a wrap</h2>
<span className="wk-event-tag wk-tag-next mono">Off-season</span>
</header>
<div className="wk-next-body">
<p className="wk-season-complete-copy">
The {context.season} calendar is complete. Explore the season&apos;s races or revisit the championship
battle while the next schedule is confirmed.
</p>
<Link to="/explore" className="wk-cta wk-cta-primary wk-cta-wide" data-testid="wk-season-complete-explore">
Explore the season <ChevronRight size={15} aria-hidden="true" />
</Link>
</div>
</section>
)}
</div>
<div className="wk-mid-grid">
{championship && <ChampionshipImpactCard impact={championship} />}
<ChampionshipRoundStrip
round={context.championship_round}
total={context.total_championship_rounds}
/>
</div>
{briefing.length > 0 && <BriefingStrip items={briefing} />}
</div>
)
}

View File

@@ -0,0 +1,83 @@
import { Link } from '@tanstack/react-router'
import { ChevronRight } from 'lucide-react'
import type { WeekendBriefingItem, WeekendContext, WeekendViewState } from '../../types'
import { BriefingStrip, CountdownDisplay, EventPodium, SessionRail, railNodes } from './shared'
import { analysisSessionKey, meetingIdentity } from '../../lib/weekendContext'
import { parseScheduleTime } from '../../lib/schedule'
function nextSessionWhen(start: string | undefined): string {
const date = start ? parseScheduleTime(start) : null
if (!date) return 'Time to be confirmed'
return date.toLocaleString('en-GB', {
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
}
export function BetweenSessionsView({
context,
now,
view,
briefing,
}: {
context: WeekendContext
now: Date
view: WeekendViewState
briefing: WeekendBriefingItem[]
}) {
const settling = view === 'session_settling'
const focus = meetingIdentity(context.focus_meeting)
const previous = context.previous_completed_session
const previousName = previous?.session.session_name ?? 'Last session'
const next = context.next_session?.session
const analysisKey = analysisSessionKey(context)
const nodes = railNodes(previous, context.active_session, context.next_session)
return (
<div className="wk-sessions" data-testid="weekend-between-sessions" data-state={view}>
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">
{settling ? 'Session settling' : 'Between sessions'}
</div>
<header className="wk-sessions-head">
<h1 className="wk-sessions-title">{focus?.meeting_name ?? 'Race weekend'}</h1>
{focus?.circuit_short_name && <p className="wk-sessions-circuit mono">{focus.circuit_short_name}</p>}
</header>
<SessionRail nodes={nodes} />
{previous && (
<section className="wk-recap-card" data-testid="wk-last-session">
<span className="wk-recap-eyebrow mono">
{previousName} · {settling ? 'Settling' : 'Complete'}
</span>
<h2 className="wk-recap-title">What happened in {previousName}</h2>
<EventPodium sessionKey={analysisKey} />
</section>
)}
{next && (
<section className="wk-upnext-card" data-testid="wk-next-session">
<span className="wk-upnext-eyebrow mono">Up next</span>
<h2 className="wk-upnext-title">{next.session_name}</h2>
<CountdownDisplay target={next.date_start} now={now} compact />
<p className="wk-upnext-when mono">{nextSessionWhen(next.date_start)}</p>
{analysisKey && (
<Link
to="/race-hub"
search={{ session_key: analysisKey }}
className="wk-cta wk-cta-primary wk-cta-wide"
data-testid="wk-view-recap"
>
View {previousName} recap <ChevronRight size={15} aria-hidden="true" />
</Link>
)}
</section>
)}
{briefing.length > 0 && <BriefingStrip items={briefing} />}
</div>
)
}

View File

@@ -0,0 +1,47 @@
import { Link } from '@tanstack/react-router'
import { ChevronRight, Radio } from 'lucide-react'
import type { WeekendContext } from '../../types'
import { EventPodium, Flag, SessionRail, railNodes } from './shared'
import { analysisSessionKey, meetingIdentity } from '../../lib/weekendContext'
export function LiveHandoffView({ context }: { context: WeekendContext }) {
const focus = meetingIdentity(context.focus_meeting ?? context.active_session?.meeting)
const active = context.active_session?.session
const previous = context.previous_completed_session
const previousName = previous?.session.session_name ?? 'Last session'
const analysisKey = analysisSessionKey(context)
const nodes = railNodes(previous, context.active_session, context.next_session)
return (
<div className="wk-live" data-testid="weekend-live" data-state="session_live">
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">Session live</div>
<section className="wk-live-card" data-testid="wk-live-card">
<div className="wk-live-head">
<div className="wk-event-id">
<Flag code={focus?.country_code} flag={focus?.country_flag} />
<h1 className="wk-sessions-title">{focus?.meeting_name ?? 'Live session'}</h1>
</div>
<span className="wk-live-pulse" aria-hidden="true" />
</div>
{focus?.circuit_short_name && <p className="wk-sessions-circuit mono">{focus.circuit_short_name}</p>}
<p className="wk-live-lead">
{active?.session_name ? `${active.session_name} is on track now.` : 'A session is running now.'}
</p>
<Link to="/live" className="wk-cta wk-cta-primary wk-cta-wide" data-testid="wk-watch-live">
<Radio size={15} aria-hidden="true" /> Watch live timing <ChevronRight size={15} aria-hidden="true" />
</Link>
</section>
<SessionRail nodes={nodes} />
{previous && (
<section className="wk-recap-card" data-testid="wk-last-session">
<span className="wk-recap-eyebrow mono">{previousName} · Complete</span>
<h2 className="wk-recap-title">What happened in {previousName}</h2>
<EventPodium sessionKey={analysisKey} />
</section>
)}
</div>
)
}

View File

@@ -0,0 +1,56 @@
import { Link } from '@tanstack/react-router'
import { ChevronRight } from 'lucide-react'
import type { WeekendContext } from '../../types'
import { RacePreviewPage } from '../../pages/RacePreviewPage'
import { ChampionshipRoundStrip, CountdownDisplay, Flag, SessionRail, railNodes } from './shared'
import { meetingIdentity } from '../../lib/weekendContext'
/**
* PreSessionView folds the race preview surface into the Weekend home so there is
* no separate Preview destination. It reuses the existing preview page content and
* frames it with the next-session countdown and compact season navigation. It also
* backs the /preview alias, so saved preview links resolve here instead of looping.
*/
export function PreSessionView({ context, now }: { context: WeekendContext; now: Date }) {
const meeting = meetingIdentity(context.next_meeting ?? context.focus_meeting)
const next = context.next_session?.session
const nextStart = next?.date_start ?? meeting?.date_start
const nodes = railNodes(
context.previous_completed_session,
context.active_session,
context.next_session,
)
return (
<div className="wk-pre" data-testid="weekend-pre-session" data-state="pre_session">
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">Pre-session</div>
{meeting && (
<header className="wk-pre-head" data-testid="wk-pre-head">
<div className="wk-event-id">
<Flag code={meeting.country_code} flag={meeting.country_flag} />
<h1 className="wk-sessions-title">{meeting.meeting_name}</h1>
</div>
<div className="wk-pre-countdown">
<span className="wk-next-label mono">{next?.session_name ?? 'Next session'}</span>
<CountdownDisplay target={nextStart} now={now} />
</div>
<Link to="/live" className="wk-cta wk-cta-ghost" data-testid="wk-pre-live">
Live timing <ChevronRight size={14} aria-hidden="true" />
</Link>
</header>
)}
{nodes.length > 0 && <SessionRail nodes={nodes} />}
<div className="wk-pre-preview" data-testid="wk-pre-preview">
<RacePreviewPage />
</div>
<ChampionshipRoundStrip
round={context.championship_round}
total={context.total_championship_rounds}
/>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import { Link } from '@tanstack/react-router'
export function WeekendLoading() {
return (
<div className="wk-status wk-status-loading" data-testid="weekend-loading" aria-busy="true">
<span className="wk-status-eyebrow mono">box-box · weekend</span>
<p className="wk-status-title">Reading the weekend</p>
</div>
)
}
export function WeekendError({ message }: { message?: string }) {
return (
<div className="wk-status wk-status-error" data-testid="weekend-error" role="alert">
<span className="wk-status-eyebrow mono">box-box · weekend</span>
<p className="wk-status-title">Weekend unavailable</p>
<p className="wk-status-sub">{message ?? 'Something went wrong loading the weekend context.'}</p>
</div>
)
}
export function WeekendLimited({ message, season }: { message?: string; season?: number }) {
return (
<div className="wk-status wk-status-limited" data-testid="weekend-limited">
<span className="wk-status-eyebrow mono">box-box · weekend</span>
<p className="wk-status-title">Nothing to show yet</p>
<p className="wk-status-sub">
{message ?? `No weekend data for the ${season || 'current'} season is available right now.`}
</p>
<div className="wk-status-actions">
<Link to="/live" className="wk-cta wk-cta-ghost">Live timing</Link>
<Link to="/explore" className="wk-cta wk-cta-ghost">Explore</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,65 @@
import { Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ChevronRight } from 'lucide-react'
import { fetchWeekend } from '../../api'
import { meetingIdentity } from '../../lib/weekendContext'
/**
* Restores Race Hub → Weekend meeting/session context from the URL search
* contract. Deep-linkable and reload-safe — no hidden component memory.
*/
export function WeekendFocusBanner({
meetingKey,
sessionKey,
}: {
meetingKey?: number
sessionKey?: number
}) {
const hasFocus = (meetingKey != null && meetingKey > 0) || (sessionKey != null && sessionKey > 0)
const weekendQuery = useQuery({
queryKey: ['weekend', meetingKey],
queryFn: () => fetchWeekend(meetingKey!),
enabled: meetingKey != null && meetingKey > 0,
staleTime: 60_000,
})
if (!hasFocus) return null
const weekend = weekendQuery.data
const meeting = meetingIdentity(weekend?.meeting)
const session = weekend?.sessions.find((s) => s.session.session_key === sessionKey)?.session
const meetingLabel = meeting?.meeting_name ?? (meetingKey ? `Meeting ${meetingKey}` : 'Selected weekend')
const sessionLabel = session?.session_name ?? (sessionKey ? `Session ${sessionKey}` : null)
return (
<section
className="wk-focus"
data-testid="wk-focus-context"
data-meeting-key={meetingKey ?? undefined}
data-session-key={sessionKey ?? undefined}
aria-label="Selected weekend context"
>
<div className="wk-focus-copy">
<span className="wk-focus-eyebrow mono">From Race Hub</span>
<h2 className="wk-focus-title" data-testid="wk-focus-meeting">
{meetingLabel}
</h2>
{sessionLabel && (
<p className="wk-focus-session" data-testid="wk-focus-session">
Selected session · {sessionLabel}
</p>
)}
</div>
{sessionKey != null && sessionKey > 0 && (
<Link
to="/race-hub"
search={{ session_key: sessionKey }}
className="wk-cta wk-cta-primary wk-focus-cta"
data-testid="wk-return-analysis"
>
Continue analysis <ChevronRight size={15} aria-hidden="true" />
</Link>
)}
</section>
)
}

View File

@@ -0,0 +1,277 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { fetchRaceHub } from '../../api'
import { countryFlag } from '../../lib/gpIdentity'
import { parseScheduleTime } from '../../lib/schedule'
import { podiumFromResults } from '../../lib/weekendContext'
import { teamColor } from '../../utils'
import type {
ContextSession,
WeekendChampionshipImpact,
WeekendBriefingItem,
WeekendPodiumEntry,
} from '../../types'
export function Flag({ code, flag }: { code?: string; flag?: string }) {
const glyph = countryFlag({ country_code: code ?? '', country_flag: flag ?? '' })
if (!glyph) return null
return <span className="wk-flag" aria-hidden="true">{glyph}</span>
}
interface CountdownParts {
days: number
hours: number
minutes: number
seconds: number
reached: boolean
}
export function countdownParts(target: string | undefined, now: Date): CountdownParts | null {
const date = target ? parseScheduleTime(target) : null
if (!date) return null
const diff = date.getTime() - now.getTime()
const clamped = Math.max(0, diff)
const totalSeconds = Math.floor(clamped / 1000)
return {
days: Math.floor(totalSeconds / 86400),
hours: Math.floor((totalSeconds % 86400) / 3600),
minutes: Math.floor((totalSeconds % 3600) / 60),
seconds: totalSeconds % 60,
reached: diff <= 0,
}
}
export function CountdownDisplay({
target,
now,
compact,
}: {
target: string | undefined
now: Date
compact?: boolean
}) {
const parts = countdownParts(target, now)
if (!parts) {
return (
<span className="wk-countdown-tbc mono" data-testid="wk-countdown">
Schedule TBC
</span>
)
}
if (parts.reached) {
return <span className="wk-countdown-live" data-testid="wk-countdown">Starting now</span>
}
if (compact || parts.days === 0) {
const hh = String(parts.days * 24 + parts.hours).padStart(2, '0')
return (
<span className="wk-countdown-clock mono" data-testid="wk-countdown">
{hh}:{String(parts.minutes).padStart(2, '0')}:{String(parts.seconds).padStart(2, '0')}
</span>
)
}
return (
<span className="wk-countdown" data-testid="wk-countdown">
<span className="wk-count-unit"><b>{parts.days}</b>D</span>
<span className="wk-count-unit"><b>{String(parts.hours).padStart(2, '0')}</b>H</span>
<span className="wk-count-unit"><b>{String(parts.minutes).padStart(2, '0')}</b>M</span>
</span>
)
}
/**
* EventPodium fetches and renders the podium for a completed analysis session.
* When no analysable session key is available (e.g. an archived result with no
* local analysis), it renders an explicit empty state rather than fetching.
*/
export function EventPodium({ sessionKey }: { sessionKey?: number }) {
const enabled = typeof sessionKey === 'number' && sessionKey > 0
const query = useQuery({
queryKey: ['race-hub', sessionKey, 'podium'],
queryFn: () => fetchRaceHub(sessionKey as number),
enabled,
staleTime: 60_000,
})
const podium: WeekendPodiumEntry[] = podiumFromResults(query.data?.results ?? [])
if (!enabled) {
return (
<div className="wk-podium-empty" data-testid="wk-podium-empty">
Result not available yet.
</div>
)
}
if (podium.length === 0) {
return (
<div className="wk-podium-empty" data-testid="wk-podium-empty">
{query.isLoading ? 'Loading result…' : 'Result not available yet.'}
</div>
)
}
return (
<ol className="wk-podium" data-testid="wk-podium">
{podium.map((p) => (
<li key={p.driver_number} className="wk-podium-row" style={{ ['--wk-team' as string]: teamColor(p.team_colour) }}>
<span className="wk-podium-pos mono">{p.position}</span>
<span className="wk-podium-code">{p.name_acronym}</span>
<span className="wk-podium-gap mono">{p.gap}</span>
</li>
))}
</ol>
)
}
export function ChampionshipImpactCard({ impact }: { impact: WeekendChampionshipImpact }) {
const leader = impact.leaders[0]
return (
<section className="wk-champ" data-testid="wk-champ-impact">
<header className="wk-card-head">
<span className="wk-card-title">Championship impact</span>
<Link to="/championship" className="wk-card-link">Standings </Link>
</header>
<ol className="wk-champ-list">
{impact.leaders.map((d) => {
const gap = leader && d.position !== 1 ? leader.points - d.points : 0
return (
<li key={d.driver_number} className="wk-champ-row" style={{ ['--wk-team' as string]: teamColor(d.team_colour) }}>
<span className="wk-champ-pos mono">{d.position}</span>
<span className="wk-champ-code">{d.name_acronym}</span>
<span className="wk-champ-pts mono">{d.points}</span>
<span className="wk-champ-gap mono">{d.position === 1 ? '—' : `-${gap}`}</span>
</li>
)
})}
</ol>
{impact.note && <p className="wk-champ-note">{impact.note}</p>}
</section>
)
}
export function BriefingStrip({ items }: { items: WeekendBriefingItem[] }) {
if (items.length === 0) return null
return (
<section className="wk-briefing" data-testid="wk-briefing">
<header className="wk-card-head">
<span className="wk-card-title">Since the chequered flag</span>
<Link to="/briefing" className="wk-card-link">All briefing </Link>
</header>
<ul className="wk-briefing-list">
{items.map((item) => (
<li key={item.url} className="wk-briefing-item">
<a href={item.url} target="_blank" rel="noreferrer" className="wk-briefing-link">
{item.category && <span className="wk-briefing-cat mono">{item.category}</span>}
<span className="wk-briefing-title">{item.title}</span>
<span className="wk-briefing-source mono">{item.source}</span>
</a>
</li>
))}
</ul>
</section>
)
}
/**
* ChampionshipRoundStrip is a compact, progressively-disclosed round indicator.
* The canonical contract exposes only the current round and total, so the strip
* shows "Round N of M" and links out to Explore for the full calendar rather than
* reintroducing the whole calendar on the Weekend home.
*/
export function ChampionshipRoundStrip({ round, total }: { round: number; total: number }) {
if (round <= 0 || total <= 0) return null
const pct = Math.min(100, Math.round((round / total) * 100))
return (
<section className="wk-season-nav" data-testid="wk-season-nav" aria-label="Season progress">
<div className="wk-season-progress">
<div className="wk-season-progress-head">
<span className="wk-card-title">Season progress</span>
<Link to="/explore" className="wk-card-link" data-testid="wk-season-explore">
Full calendar
</Link>
</div>
<div
className="wk-season-bar"
role="progressbar"
aria-valuenow={round}
aria-valuemin={1}
aria-valuemax={total}
aria-label={`Round ${round} of ${total}`}
>
<span className="wk-season-bar-fill" style={{ width: `${pct}%` }} aria-hidden="true" />
</div>
<p className="wk-season-progress-label mono">
Round {round} of {total}
</p>
</div>
</section>
)
}
interface TimelineNode {
key: string
session_name: string
status: 'done' | 'live' | 'next'
}
/**
* SessionRail renders the previous/active/next sessions the canonical context
* exposes as a compact three-node rail. It is intentionally derived only from the
* canonical refs — the contract does not enumerate a full weekend session list.
*/
export function SessionRail({ nodes }: { nodes: TimelineNode[] }) {
if (nodes.length === 0) return null
return (
<ol className="wk-timeline" data-testid="wk-timeline" role="list">
{nodes.map((n) => (
<li key={n.key} className={`wk-timeline-node wk-timeline-${n.status}`} role="listitem">
<span className="wk-timeline-dot" aria-hidden="true" />
<span className="wk-timeline-name">{shortSessionName(n.session_name)}</span>
<span className="wk-timeline-state mono">{stateLabel(n.status)}</span>
</li>
))}
</ol>
)
}
/** Build the compact session rail from the canonical previous/active/next refs. */
export function railNodes(
previous: ContextSession | undefined,
active: ContextSession | undefined,
next: ContextSession | undefined,
): TimelineNode[] {
const nodes: TimelineNode[] = []
if (previous?.session.session_name) {
nodes.push({ key: `prev-${previous.session.session_key}`, session_name: previous.session.session_name, status: 'done' })
}
if (active?.session.session_name) {
nodes.push({ key: `live-${active.session.session_key}`, session_name: active.session.session_name, status: 'live' })
}
if (next?.session.session_name) {
nodes.push({ key: `next-${next.session.session_key}`, session_name: next.session.session_name, status: 'next' })
}
return nodes
}
function shortSessionName(name: string): string {
const map: Record<string, string> = {
'Practice 1': 'FP1',
'Practice 2': 'FP2',
'Practice 3': 'FP3',
Qualifying: 'Quali',
'Sprint Qualifying': 'SQ',
'Sprint Shootout': 'SQ',
}
return map[name] ?? name
}
function stateLabel(status: TimelineNode['status']): string {
switch (status) {
case 'done':
return 'Complete'
case 'live':
return 'Live'
default:
return 'Next'
}
}

View File

@@ -0,0 +1,89 @@
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchChampionshipHub, fetchNews, fetchWeekendContext } from '../api'
import { championshipImpact, briefingItems } from '../lib/weekendContext'
import type {
WeekendChampionshipImpact,
WeekendBriefingItem,
WeekendContext,
} from '../types'
export type WeekendLoadState = 'loading' | 'error' | 'ready'
export interface UseWeekendContextResult {
/** Canonical context, present only when loadState === 'ready'. */
context: WeekendContext | null
loadState: WeekendLoadState
error?: Error
/** Supplementary championship movers (not part of the #72 contract). */
championship?: WeekendChampionshipImpact
/** Supplementary briefing items (not part of the #72 contract). */
briefing: WeekendBriefingItem[]
now: Date
}
/**
* useWeekendContext reads the canonical /api/v1/weekend-context endpoint as the
* single source of truth for the Weekend home. When the canonical read succeeds
* it layers on two pieces of supplementary data that the contract intentionally
* omits — championship movers and the paddock briefing — and nothing else.
*
* It deliberately does NOT fan out to season / meetings / per-weekend / OpenF1
* session / live-state queries: those would defeat the local-first canonical read
* model and can hit the rate-limited OpenF1 REST surface during active sessions.
* Supplementary queries are gated on a successful canonical read so a failing or
* pending endpoint issues no extra requests.
*/
export function useWeekendContext(): UseWeekendContextResult {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [])
const nowDate = useMemo(() => new Date(now), [now])
const contextQuery = useQuery({
queryKey: ['weekend-context'],
queryFn: fetchWeekendContext,
staleTime: 30_000,
})
const canonical = contextQuery.data ?? null
const canonicalReady = canonical != null
const season = canonical?.season
// Supplementary data — only fetched once the canonical context has resolved,
// so a pending/failed canonical read never triggers a request fan-out.
const championshipQuery = useQuery({
queryKey: ['championship-hub', season ?? 'current'],
queryFn: () => fetchChampionshipHub(season),
enabled: canonicalReady,
staleTime: 60_000,
})
const newsQuery = useQuery({
queryKey: ['news', 6],
queryFn: () => fetchNews(6),
enabled: canonicalReady,
staleTime: 60_000,
})
const championship = useMemo(
() => championshipImpact(championshipQuery.data),
[championshipQuery.data],
)
const briefing = useMemo(() => briefingItems(newsQuery.data ?? []), [newsQuery.data])
let loadState: WeekendLoadState = 'loading'
if (contextQuery.isError) loadState = 'error'
else if (canonicalReady) loadState = 'ready'
return {
context: canonical,
loadState,
error: contextQuery.error instanceof Error ? contextQuery.error : undefined,
championship,
briefing,
now: nowDate,
}
}

View File

@@ -0,0 +1,132 @@
import type { Chapter } from '../types'
export function chapterKindLabel(kind: string): string {
switch (kind) {
case 'start':
return 'GO'
case 'safety_car':
return 'SC'
case 'virtual_safety_car':
return 'VSC'
case 'red_flag':
return 'RF'
case 'pit_phase':
return 'PIT'
case 'decisive_swing':
return '▲'
case 'finish':
return 'FIN'
default:
return kind.slice(0, 3).toUpperCase()
}
}
export function chapterLapRange(chapter: Chapter): string {
if (chapter.start_lap === chapter.end_lap) {
return `L${chapter.start_lap}`
}
return `L${chapter.start_lap}L${chapter.end_lap}`
}
/** Normalized scrub position (01) for a chapter's start time on the chart axis. */
export function chapterStartScrub(
chapter: Chapter,
tMin: number,
tRange: number,
): number | null {
if (!chapter.start_time || tRange <= 0) return null
const ms = new Date(chapter.start_time).getTime()
if (Number.isNaN(ms)) return null
return Math.max(0, Math.min(1, (ms - tMin) / tRange))
}
/** Normalized scrub position (01) for a chapter's end time on the chart axis. */
export function chapterEndScrub(
chapter: Chapter,
tMin: number,
tRange: number,
): number | null {
const raw = chapter.end_time ?? chapter.start_time
if (!raw || tRange <= 0) return null
const ms = new Date(raw).getTime()
if (Number.isNaN(ms)) return null
return Math.max(0, Math.min(1, (ms - tMin) / tRange))
}
/** Index of the chapter containing the current scrub position, if any.
* Uses the same 01 clamped bounds as chapterStartScrub/chapterEndScrub so
* chapters whose timestamps fall outside the position-sample window still
* activate when the scrubber is parked at the clamped edge. */
export function activeChapterIndex(
chapters: Chapter[],
scrubTime: number | null,
tMin: number,
tRange: number,
): number | null {
if (scrubTime === null || chapters.length === 0 || tRange <= 0) return null
for (let i = 0; i < chapters.length; i++) {
const start = chapterStartScrub(chapters[i], tMin, tRange)
const end = chapterEndScrub(chapters[i], tMin, tRange)
if (start === null || end === null) continue
const lo = Math.min(start, end)
const hi = Math.max(start, end)
if (scrubTime >= lo && scrubTime <= hi) return i
}
return null
}
/** Duration in ms each chapter should play during a ~90s tour. */
export function chapterTourDurations(chapters: Chapter[], totalMs = 90_000): number[] {
if (chapters.length === 0) return []
const perChapter = totalMs / chapters.length
return chapters.map(() => perChapter)
}
/** Decimated position axis labels, e.g. P1, P5, P10, P15, P20. */
export function decimatedPositionLabels(maxPos: number): number[] {
if (maxPos <= 1) return [1]
const labels = new Set<number>([1])
for (let pos = 5; pos < maxPos; pos += 5) {
labels.add(pos)
}
labels.add(maxPos)
return [...labels].sort((a, b) => a - b)
}
/** Subtle background fill for chapter time-bands on the position graph. */
export function chapterBandFill(kind: string): string {
switch (kind) {
case 'safety_car':
return 'rgba(255, 153, 0, 0.14)'
case 'virtual_safety_car':
return 'rgba(255, 204, 0, 0.12)'
case 'red_flag':
return 'rgba(230, 36, 41, 0.12)'
case 'pit_phase':
return 'rgba(96, 165, 250, 0.08)'
case 'decisive_swing':
return 'rgba(34, 197, 94, 0.08)'
case 'finish':
return 'rgba(255, 204, 0, 0.06)'
default:
return 'rgba(255, 255, 255, 0.03)'
}
}
/** Minimum vertical spacing between colliding labels (SVG units). */
export function deCollideYPositions(
items: ReadonlyArray<{ key: string | number; y: number }>,
minGap = 12,
): Map<string | number, number> {
if (items.length === 0) return new Map()
const sorted = [...items].sort((a, b) => a.y - b.y)
const adjusted = sorted.map((item) => ({ ...item }))
for (let i = 1; i < adjusted.length; i++) {
const prev = adjusted[i - 1]
const curr = adjusted[i]
if (curr.y - prev.y < minGap) {
curr.y = prev.y + minGap
}
}
return new Map(adjusted.map((item) => [item.key, item.y]))
}

293
frontend/src/lib/digest.ts Normal file
View File

@@ -0,0 +1,293 @@
import { meetingEndTime, meetingStartTime, mostRecentPastMeeting, nextUpcomingMeeting } from './schedule'
import type { ChampHubDriver, ChampHubTeam, Meeting, NewsItem } from '../types'
export interface GPWindow {
meeting_key: number
meeting_name: string
/** Inclusive lower bound: end of previous GP, or open for season opener. */
start: Date | null
/** Exclusive upper bound: start of next GP, or open after the final round. */
end: Date | null
}
export interface DigestTag {
kind: 'driver' | 'team'
key: string
label: string
colour: string
}
export type TaggedNewsItem = NewsItem & { tags: DigestTag[] }
export const RECENT_BUCKET_KEY = '__recent__'
interface MatchCandidate {
start: number
end: number
tag: DigestTag
}
interface TagPattern {
pattern: string
tag: DigestTag
}
function sortMeetings(meetings: Meeting[]): Meeting[] {
return [...meetings].sort((a, b) => {
const left = meetingStartTime(a)?.getTime() ?? 0
const right = meetingStartTime(b)?.getTime() ?? 0
if (left !== right) return left - right
return a.meeting_name.localeCompare(b.meeting_name)
})
}
/** Derive inter-race windows [prev GP end → next GP start] for each round. */
export function gpWindows(meetings: Meeting[], _now: Date): GPWindow[] {
const sorted = sortMeetings(meetings)
return sorted.map((meeting, index) => ({
meeting_key: meeting.meeting_key,
meeting_name: meeting.meeting_name,
start: index > 0 ? meetingEndTime(sorted[index - 1]) : null,
end: index < sorted.length - 1 ? meetingStartTime(sorted[index + 1]) : null,
}))
}
function itemPublishedAt(item: NewsItem): Date | null {
if (!item.published_at) return null
const parsed = Date.parse(item.published_at)
return Number.isNaN(parsed) ? null : new Date(parsed)
}
function itemInWindow(published: Date, window: GPWindow): boolean {
if (window.start && published < window.start) return false
if (window.end && published >= window.end) return false
return true
}
export function windowForDate(windows: GPWindow[], published: Date): GPWindow | null {
const matches = windows.filter((window) => itemInWindow(published, window))
if (matches.length === 0) return null
return matches.reduce((best, window) => {
const bestStart = best.start?.getTime() ?? Number.NEGATIVE_INFINITY
const windowStart = window.start?.getTime() ?? Number.NEGATIVE_INFINITY
return windowStart >= bestStart ? window : best
})
}
export interface GroupedDigest {
windows: Array<{ window: GPWindow; items: NewsItem[] }>
recent: NewsItem[]
}
/** Bucket news items into GP windows; undated items land in `recent`. */
export function groupByWindow(items: NewsItem[], windows: GPWindow[]): GroupedDigest {
const buckets = new Map<number, NewsItem[]>()
const recent: NewsItem[] = []
for (const item of items) {
const published = itemPublishedAt(item)
if (!published) {
recent.push(item)
continue
}
const match = windowForDate(windows, published)
if (!match) {
recent.push(item)
continue
}
const list = buckets.get(match.meeting_key) ?? []
list.push(item)
buckets.set(match.meeting_key, list)
}
const grouped = windows
.map((window) => ({
window,
items: buckets.get(window.meeting_key) ?? [],
}))
.filter((entry) => entry.items.length > 0)
return { windows: grouped, recent }
}
function driverLastName(fullName: string): string | null {
const parts = fullName.trim().split(/\s+/).filter(Boolean)
if (parts.length < 2) return null
return parts[parts.length - 1] ?? null
}
function buildTagPatterns(drivers: ChampHubDriver[], teams: ChampHubTeam[]): TagPattern[] {
const patterns: TagPattern[] = []
for (const team of teams) {
if (!team.team_name.trim()) continue
patterns.push({
pattern: team.team_name.toLowerCase(),
tag: {
kind: 'team',
key: `team:${team.team_name}`,
label: team.team_name,
colour: team.team_colour,
},
})
}
for (const driver of drivers) {
const colour = driver.team_colour
const base = {
kind: 'driver' as const,
colour,
}
if (driver.full_name.trim()) {
patterns.push({
pattern: driver.full_name.toLowerCase(),
tag: {
...base,
key: `driver:${driver.name_acronym}`,
label: driver.name_acronym,
},
})
const lastName = driverLastName(driver.full_name)
if (lastName) {
patterns.push({
pattern: lastName.toLowerCase(),
tag: {
...base,
key: `driver:${driver.name_acronym}`,
label: driver.name_acronym,
},
})
}
}
if (driver.name_acronym.trim()) {
patterns.push({
pattern: driver.name_acronym.toLowerCase(),
tag: {
...base,
key: `driver:${driver.name_acronym}`,
label: driver.name_acronym,
},
})
}
}
patterns.sort((a, b) => b.pattern.length - a.pattern.length)
return patterns
}
function overlaps(a: MatchCandidate, b: MatchCandidate): boolean {
return a.start < b.end && b.start < a.end
}
/**
* Tag items via case-insensitive substring match on title + summary.
* Longest pattern wins on overlapping spans (v1 string match — no NLP).
*
* Known limitations: common-word last names, team names inside other words,
* and driver TLAs that appear in unrelated acronyms can false-positive.
*/
export function tagItems(
items: NewsItem[],
drivers: ChampHubDriver[],
teams: ChampHubTeam[],
): TaggedNewsItem[] {
const patterns = buildTagPatterns(drivers, teams)
return items.map((item) => {
const haystack = `${item.title} ${item.summary ?? ''}`.toLowerCase()
const accepted: MatchCandidate[] = []
for (const { pattern, tag } of patterns) {
if (!pattern) continue
let from = 0
while (from <= haystack.length - pattern.length) {
const index = haystack.indexOf(pattern, from)
if (index === -1) break
const candidate: MatchCandidate = {
start: index,
end: index + pattern.length,
tag,
}
if (!accepted.some((match) => overlaps(match, candidate))) {
accepted.push(candidate)
}
from = index + 1
}
}
const tagsByKey = new Map<string, DigestTag>()
for (const match of accepted) {
tagsByKey.set(match.tag.key, match.tag)
}
return {
...item,
tags: [...tagsByKey.values()].sort((a, b) => a.label.localeCompare(b.label)),
}
})
}
export function activeDigestWindow(
windows: GPWindow[],
meetings: Meeting[],
now: Date,
): GPWindow | null {
const next = nextUpcomingMeeting(meetings, now)
if (next) {
return windows.find((window) => window.meeting_key === next.meeting_key) ?? null
}
const recent = mostRecentPastMeeting(meetings, now)
if (recent) {
return windows.find((window) => window.meeting_key === recent.meeting_key) ?? null
}
return windows[0] ?? null
}
export function sinceLastLabel(meetings: Meeting[], now: Date): string {
const last = mostRecentPastMeeting(meetings, now)
if (last) return last.meeting_name
const next = nextUpcomingMeeting(meetings, now)
if (next) return `Before ${next.meeting_name}`
return 'Pre-season'
}
export function itemsForWindow(items: TaggedNewsItem[], window: GPWindow | null): TaggedNewsItem[] {
if (!window) return []
return items.filter((item) => {
const published = itemPublishedAt(item)
return published != null && itemInWindow(published, window)
})
}
export function topTags(items: TaggedNewsItem[], limit = 5): DigestTag[] {
const counts = new Map<string, { tag: DigestTag; count: number }>()
for (const item of items) {
for (const tag of item.tags) {
const current = counts.get(tag.key)
if (current) current.count += 1
else counts.set(tag.key, { tag, count: 1 })
}
}
return [...counts.values()]
.sort((a, b) => b.count - a.count || a.tag.label.localeCompare(b.tag.label))
.slice(0, limit)
.map((entry) => entry.tag)
}
export function filterByTag(items: TaggedNewsItem[], tagKey: string | null): TaggedNewsItem[] {
if (!tagKey) return items
return items.filter((item) => item.tags.some((tag) => tag.key === tagKey))
}
export function tagColour(colour: string): string {
if (!colour) return 'var(--text-3)'
return colour.startsWith('#') ? colour : `#${colour}`
}
export function sortWindowBucketsNewestFirst<T extends { window: GPWindow }>(buckets: T[]): T[] {
return [...buckets].sort((a, b) => {
const left = a.window.start?.getTime() ?? 0
const right = b.window.start?.getTime() ?? 0
return right - left
})
}

View File

@@ -0,0 +1,69 @@
import type { DriverSummaryRound } from '../types'
/** One round mapped for the grid-vs-finish chart and results table. */
export interface RoundDelta {
round: number // 1-based round index within the completed season
label: string // short GP label, e.g. "Bahrain"
grid: number | null
finish: number | null
/** grid finish: positive = places gained on Sunday, null when unknowable. */
delta: number | null
points: number
status: 'classified' | 'dnf' | 'dns' | 'dsq' | 'absent'
}
/** Strip the "Grand Prix" boilerplate so round labels fit chart axes. */
export function shortGpLabel(meetingName: string): string {
const short = meetingName
.replace(/\s*grand prix\s*/i, ' ')
.replace(/\s+gp\s*$/i, ' ')
.trim()
return short || meetingName
}
function roundStatus(r: DriverSummaryRound): RoundDelta['status'] {
if (r.dns) return 'dns'
if (r.dsq) return 'dsq'
if (r.dnf) return 'dnf'
if (r.race_position <= 0 && r.grid_position <= 0) return 'absent'
return 'classified'
}
/**
* Map summary rounds to grid-vs-finish deltas. Positions of 0 mean "no data"
* (driver absent, pit-lane start, …) and become null; a delta is only computed
* when both ends are known and the driver actually started the race.
*/
export function gridFinishDeltas(rounds: DriverSummaryRound[]): RoundDelta[] {
return rounds.map((r, i) => {
const status = roundStatus(r)
const grid = r.grid_position > 0 ? r.grid_position : null
const finish = r.race_position > 0 ? r.race_position : null
const delta =
grid != null && finish != null && status !== 'dns' && status !== 'absent'
? grid - finish
: null
return {
round: i + 1,
label: shortGpLabel(r.meeting_name),
grid,
finish,
delta,
points: r.points,
status,
}
})
}
/** 'P4' for known positions, '—' when unknown. */
export function formatPosition(pos: number | null): string {
return pos != null && pos > 0 ? `P${pos}` : '—'
}
/** '+3' (gained), '2' (lost), '=' (held), '—' (unknown). */
export function formatDelta(delta: number | null): string {
if (delta == null) return '—'
if (delta > 0) return `+${delta}`
if (delta < 0) return `${Math.abs(delta)}`
return '='
}

View File

@@ -47,7 +47,9 @@ export function countryDecal(meeting: Meeting | undefined | null): string {
return name.slice(0, 3).toUpperCase() || '—' return name.slice(0, 3).toUpperCase() || '—'
} }
export function countryFlag(meeting: Meeting | undefined | null): string { export function countryFlag(
meeting: Pick<Meeting, 'country_code' | 'country_flag'> | undefined | null,
): string {
if (meeting?.country_flag && !/^https?:\/\//i.test(meeting.country_flag)) return meeting.country_flag if (meeting?.country_flag && !/^https?:\/\//i.test(meeting.country_flag)) return meeting.country_flag
const code = meeting?.country_code?.toUpperCase() const code = meeting?.country_code?.toUpperCase()
const iso2 = code ? CODE3_TO_2[code] : '' const iso2 = code ? CODE3_TO_2[code] : ''

47
frontend/src/lib/h2h.ts Normal file
View File

@@ -0,0 +1,47 @@
import type { ChampHubDriver } from '../types'
export interface TeammatePair {
teamName: string
teamColour: string
driverA: ChampHubDriver
driverB: ChampHubDriver
/** Drivers on the team beyond the top two (shown as +N). */
extraCount: number
/** Absolute win gap — smaller means a closer battle. */
closeness: number
}
/** Pair teammates per team for H2H display; skip single-driver teams. */
export function teammatePairs(drivers: ReadonlyArray<ChampHubDriver>): TeammatePair[] {
const byTeam = new Map<string, ChampHubDriver[]>()
for (const d of drivers) {
const list = byTeam.get(d.team_name) ?? []
list.push(d)
byTeam.set(d.team_name, list)
}
const pairs: TeammatePair[] = []
for (const [teamName, members] of byTeam) {
if (members.length < 2) continue
const sorted = [...members].sort((a, b) => b.points - a.points)
const driverA = sorted[0]
const driverB = sorted[1]
const winsA = driverA.teammate_wins
const winsB = driverB.teammate_wins
pairs.push({
teamName,
teamColour: driverA.team_colour,
driverA,
driverB,
extraCount: members.length - 2,
closeness: Math.abs(winsA - winsB),
})
}
return pairs.sort((a, b) => {
if (a.closeness !== b.closeness) return a.closeness - b.closeness
return a.teamName.localeCompare(b.teamName)
})
}

123
frontend/src/lib/preview.ts Normal file
View File

@@ -0,0 +1,123 @@
import type { ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session } from '../types'
import {
currentAndNextSession,
nextUpcomingMeeting,
sessionStartTime,
sortSessionsByStart,
} from './schedule'
export const RACE_WIN_POINTS = 25
export const SPRINT_WIN_POINTS = 8
export const SPRINT_WEEKEND_MAX_POINTS = RACE_WIN_POINTS + SPRINT_WIN_POINTS
export function pickPreviewMeeting(meetings: Meeting[], now: Date): Meeting | null {
const active = meetings.filter((m) => !m.is_cancelled)
return nextUpcomingMeeting(active, now)
}
export function findPriorYearMeetingByCircuit(
priorYearMeetings: Meeting[],
circuitKey: number | undefined,
): Meeting | null {
if (!circuitKey) return null
return priorYearMeetings.find((m) => m.circuit_key === circuitKey && !m.is_cancelled) ?? null
}
export function findRaceSession(sessions: Session[]): Session | null {
const sorted = sortSessionsByStart(sessions)
return (
sorted.find((s) => {
const type = s.session_type?.toLowerCase() ?? ''
return type.includes('race') && !type.includes('sprint')
}) ?? null
)
}
export function isSprintWeekend(sessions: Session[]): boolean {
return sessions.some((s) => {
const label = `${s.session_type} ${s.session_name}`.toLowerCase()
return /\bsprint\b/.test(label) && !label.includes('sprint qualifying') && !label.includes('sprint shootout')
})
}
export interface PodiumPlace {
position: number
name_acronym: string
full_name: string
team_name: string
team_colour: string
}
export interface PolePosition {
name_acronym: string
full_name: string
team_name: string
team_colour: string
}
export function extractPodium(results: EnrichedResult[]): PodiumPlace[] {
return results
.filter((r) => r.position >= 1 && r.position <= 3 && !r.dns && !r.dsq)
.sort((a, b) => a.position - b.position)
.map((r) => ({
position: r.position,
name_acronym: r.name_acronym,
full_name: r.full_name,
team_name: r.team_name,
team_colour: r.team_colour,
}))
}
export function extractPole(grid: EnrichedGrid[]): PolePosition | null {
const pole = grid.find((g) => g.position === 1)
if (!pole) return null
return {
name_acronym: pole.name_acronym,
full_name: pole.full_name,
team_name: pole.team_name,
team_colour: pole.team_colour,
}
}
export interface TitleFightDriver {
name_acronym: string
full_name: string
team_colour: string
points: number
position: number
gapToLeader: number
gapAfterRaceWin: number
gapAfterSprintWeekendMax: number
}
export function buildTitleFightContext(hub: ChampionshipHub): TitleFightDriver[] {
if (hub.drivers.length === 0) return []
const leader = hub.drivers[0]
return hub.drivers.slice(0, 3).map((d) => ({
name_acronym: d.name_acronym,
full_name: d.full_name,
team_colour: d.team_colour,
points: d.points,
position: d.position,
gapToLeader: leader.points - d.points,
gapAfterRaceWin: leader.points - (d.points + RACE_WIN_POINTS),
gapAfterSprintWeekendMax: leader.points - (d.points + SPRINT_WEEKEND_MAX_POINTS),
}))
}
export function countdownTargetSession(sessions: Session[], now: Date): Session | null {
const sorted = sortSessionsByStart(sessions)
const { next } = currentAndNextSession(sorted, now)
if (next) return next
for (const session of sorted) {
const start = sessionStartTime(session)
if (start && start > now) return session
}
return null
}
export function formatPointsGap(gap: number): string {
if (gap === 0) return 'LEADER'
const sign = gap > 0 ? '+' : ''
return `${sign}${Number.isInteger(gap) ? gap : gap.toFixed(1)}`
}

View File

@@ -0,0 +1,75 @@
import type { ReplayCarPosition, ReplayFrame, TrackBounds } from '../types'
import { canvasToSvg, normalizeRawPoint } from './trackmap'
export interface ReplayFramePair {
previous: ReplayFrame | null
next: ReplayFrame | null
}
export function lookupReplayFramePair(
frames: ReadonlyArray<ReplayFrame>,
tMs: number,
): ReplayFramePair {
if (frames.length === 0) return { previous: null, next: null }
if (tMs <= frames[0].t) return { previous: frames[0], next: frames[0] }
const last = frames[frames.length - 1]
if (tMs >= last.t) return { previous: last, next: last }
let lo = 0
let hi = frames.length - 1
while (lo <= hi) {
const mid = Math.floor((lo + hi) / 2)
const frame = frames[mid]
if (frame.t === tMs) return { previous: frame, next: frame }
if (frame.t < tMs) {
lo = mid + 1
} else {
hi = mid - 1
}
}
return { previous: frames[Math.max(0, hi)], next: frames[Math.min(frames.length - 1, lo)] }
}
export function interpolateReplayCars(
frames: ReadonlyArray<ReplayFrame>,
tMs: number,
): Record<string, ReplayCarPosition> {
const { previous, next } = lookupReplayFramePair(frames, tMs)
if (!previous && !next) return {}
if (!previous) return next?.cars ?? {}
if (!next || previous.t === next.t) return previous.cars
const span = next.t - previous.t
const fraction = span <= 0 ? 0 : clamp01((tMs - previous.t) / span)
const cars: Record<string, ReplayCarPosition> = {}
const numbers = new Set([...Object.keys(previous.cars), ...Object.keys(next.cars)])
for (const number of numbers) {
const before = previous.cars[number]
const after = next.cars[number]
if (before && after) {
cars[number] = {
x: before.x + (after.x - before.x) * fraction,
y: before.y + (after.y - before.y) * fraction,
}
} else if (before) {
cars[number] = before
} else if (after) {
cars[number] = after
}
}
return cars
}
export function replayCarToSvg(
car: ReplayCarPosition,
bounds: TrackBounds,
): ReplayCarPosition {
return canvasToSvg(normalizeRawPoint({ x: car.x, y: car.y }, bounds))
}
function clamp01(value: number): number {
if (!Number.isFinite(value)) return 0
return Math.min(1, Math.max(0, value))
}

View File

@@ -0,0 +1,14 @@
import type { ReplayFramesResponse, TrackOutline } from '../types'
/** Whether replay map can render (probe uses existing frames + outline queries). */
export function isReplayMapAvailable(
replay: ReplayFramesResponse | null | undefined,
outline: TrackOutline | null | undefined,
hasError: boolean,
): boolean {
if (hasError) return false
const frames = replay?.frames ?? []
if (frames.length < 2) return false
if (!outline?.points?.length) return false
return true
}

View File

@@ -0,0 +1,61 @@
// Pure helpers for the championship rivalry compare view.
/** One round that counted toward the head-to-head tally. */
export interface H2HRound {
round: number // 1-based season round
label: string
posA: number
posB: number
winner: 'a' | 'b'
}
export interface H2HTally {
a: number
b: number
/** Rounds counted in the tally, in season order. */
rounds: H2HRound[]
/** Rounds skipped because either driver had no finishing position. */
skipped: number
}
/**
* Per-round points gap (a b) across the rounds both cumulative series
* cover. Positive values mean driver A is ahead.
*/
export function gapSeries(a: number[], b: number[]): number[] {
const n = Math.min(a.length, b.length)
const out: number[] = []
for (let i = 0; i < n; i++) out.push(a[i] - b[i])
return out
}
/**
* Race head-to-head: per round, the lower finishing position wins. Rounds
* where either driver has no position (0 / missing) are skipped from the
* tally, as are equal positions (defensive — races can't tie).
*/
export function h2hTally(posA: number[], posB: number[], labels: string[]): H2HTally {
const n = Math.max(posA.length, posB.length)
const rounds: H2HRound[] = []
let a = 0
let b = 0
let skipped = 0
for (let i = 0; i < n; i++) {
const pa = posA[i] ?? 0
const pb = posB[i] ?? 0
if (pa <= 0 || pb <= 0 || pa === pb) {
skipped++
continue
}
const winner = pa < pb ? 'a' : 'b'
if (winner === 'a') a++
else b++
rounds.push({ round: i + 1, label: labels[i] ?? `R${i + 1}`, posA: pa, posB: pb, winner })
}
return { a, b, rounds, skipped }
}
/** Last n counted rounds of a tally, for the mini strip. */
export function lastRounds(tally: H2HTally, n: number): H2HRound[] {
return tally.rounds.slice(-n)
}

View File

@@ -0,0 +1,40 @@
/** Shared URL search contracts for deep-linkable Weekend ↔ Race Hub navigation. */
export type WeekendSearch = {
meeting_key?: number
session_key?: number
}
export type RaceHubSearch = {
session_key?: number
}
function parsePositiveInt(value: unknown): number | undefined {
const n = Number(value)
return Number.isFinite(n) && n > 0 ? n : undefined
}
export function parseWeekendSearch(search: Record<string, unknown>): WeekendSearch {
const meeting_key = parsePositiveInt(search.meeting_key)
const session_key = parsePositiveInt(search.session_key)
return {
...(meeting_key ? { meeting_key } : {}),
...(session_key ? { session_key } : {}),
}
}
export function parseRaceHubSearch(search: Record<string, unknown>): RaceHubSearch {
const session_key = parsePositiveInt(search.session_key)
return session_key ? { session_key } : {}
}
/** Build Weekend `/` search so a Race Hub return is reload-safe and deep-linkable. */
export function weekendFocusSearch(
meetingKey?: number | null,
sessionKey?: number | null,
): WeekendSearch {
return {
...(meetingKey && meetingKey > 0 ? { meeting_key: meetingKey } : {}),
...(sessionKey && sessionKey > 0 ? { session_key: sessionKey } : {}),
}
}

View File

@@ -0,0 +1,194 @@
import type { ContextSession, WeekendContext, WeekendSession } from '../types'
import { isSessionComplete } from './coverage'
import { sessionStartTime } from './schedule'
/**
* User-facing lifecycle state for a weekend session.
*
* Live comes only from Weekend Context's FIA-backed active identity — never from
* the scheduled start/end window alone. Preparing / partial / ready / unavailable
* come from structured `availability.local_analysis` when a context ref exists,
* otherwise from local dataset coverage after the scheduled start.
*/
export type SessionState =
| 'upcoming'
| 'live'
| 'preparing'
| 'partial'
| 'ready'
| 'unavailable'
| 'cancelled'
export interface SessionStateInput {
weekendSession?: WeekendSession
context?: WeekendContext | null
now: Date
}
function contextRefFor(
context: WeekendContext | null | undefined,
sessionKey: number | undefined,
): ContextSession | undefined {
if (!context || !sessionKey) return undefined
const refs = [
context.active_session,
context.default_analysis_session,
context.previous_completed_session,
context.next_session,
]
return refs.find((ref) => ref?.session.session_key === sessionKey)
}
function fromAvailability(ref: ContextSession): SessionState | undefined {
const { schedule, live_session, local_analysis } = ref.availability
if (live_session === 'active') return 'live'
if (schedule === 'unavailable' || local_analysis === 'unavailable') return 'unavailable'
if (local_analysis === 'not_applicable') return 'upcoming'
if (local_analysis === 'pending') return 'preparing'
if (local_analysis === 'partial') return 'partial'
if (local_analysis === 'complete') return 'ready'
return undefined
}
/**
* Resolve fan-facing session state. Prefer Weekend Context availability; never
* assert Live from wall-clock schedule alone.
*/
export function resolveSessionState({
weekendSession,
context,
now,
}: SessionStateInput): SessionState {
if (weekendSession?.source === 'cancelled') return 'cancelled'
const sessionKey = weekendSession?.session.session_key
const active = context?.active_session
if (
active &&
sessionKey &&
active.session.session_key === sessionKey &&
active.availability.live_session === 'active'
) {
return 'live'
}
const ref = contextRefFor(context, sessionKey)
if (ref) {
const fromCtx = fromAvailability(ref)
if (fromCtx) return fromCtx
}
// Settling temporal state with no analysis yet — even without a matching ref.
if (
context?.temporal_state === 'session_settling' &&
weekendSession &&
weekendSession.source === 'none'
) {
return 'preparing'
}
if (!weekendSession) return 'unavailable'
const start = sessionStartTime(weekendSession.session)
if (start && start > now) return 'upcoming'
if (isSessionComplete(weekendSession.datasets)) return 'ready'
if (weekendSession.source === 'none') return 'preparing'
if (weekendSession.source === 'partial') return 'partial'
return 'ready'
}
/** @deprecated Prefer resolveSessionState with Weekend Context. */
export function sessionState(session: WeekendSession, now: Date): SessionState {
return resolveSessionState({ weekendSession: session, now })
}
/** Short label suitable for chips and the session switcher. */
export function sessionStateLabel(state: SessionState): string {
switch (state) {
case 'upcoming':
return 'Upcoming'
case 'live':
return 'Live'
case 'preparing':
return 'Preparing'
case 'partial':
return 'Partial'
case 'ready':
return 'Ready'
case 'unavailable':
return 'Unavailable'
case 'cancelled':
return 'Cancelled'
}
}
/** Longer, sentence-style description for headers and empty states. */
export function sessionStateDescription(state: SessionState): string {
switch (state) {
case 'upcoming':
return 'Session has not started yet.'
case 'live':
return 'Session is running now.'
case 'preparing':
return 'Analysis is being prepared — local data is still settling.'
case 'partial':
return 'Partial analysis available — some datasets are still missing.'
case 'ready':
return 'Full analysis is ready.'
case 'unavailable':
return 'Analysis is not available for this session.'
case 'cancelled':
return 'This session was cancelled.'
}
}
/**
* Class suffix used for the coverage dot, so the rail can colour a session by
* its lifecycle state rather than only by data source.
*/
export function sessionStateDotClass(state: SessionState): string {
switch (state) {
case 'ready':
return 'rh-state-ready'
case 'partial':
return 'rh-state-partial'
case 'live':
return 'rh-state-live'
case 'upcoming':
return 'rh-state-upcoming'
case 'unavailable':
return 'rh-state-unavailable'
case 'cancelled':
return 'rh-state-cancelled'
default:
return 'rh-state-preparing'
}
}
/** Whether a session should render the pre-session (expected availability) view. */
export function isPreSession(state: SessionState): boolean {
return state === 'upcoming'
}
/** Settling / empty post-session — not yet analysable. */
export function isPreparing(state: SessionState): boolean {
return state === 'preparing'
}
export function isUnavailable(state: SessionState): boolean {
return state === 'unavailable' || state === 'cancelled'
}
/** Show fan analysis with a partial banner; keep available capabilities. */
export function isPartialAnalysis(state: SessionState): boolean {
return state === 'partial'
}
/** Canonical default analysis session key from Weekend Context, if any. */
export function defaultAnalysisSessionKey(
context: WeekendContext | null | undefined,
): number | undefined {
const key = context?.default_analysis_session?.session.session_key
return key && key > 0 ? key : undefined
}

View File

@@ -0,0 +1,181 @@
import type {
ChampionshipHub,
ContextSession,
EnrichedResult,
Meeting,
NewsItem,
Session,
WeekendBriefingItem,
WeekendChampionshipImpact,
WeekendChampionshipMover,
WeekendContext,
WeekendPodiumEntry,
WeekendViewState,
} from '../types'
/**
* resolveViewState maps the canonical `temporal_state` onto the rendered Weekend
* view. It is a total function: every valid canonical `temporal_state` resolves
* to a concrete view, so a well-formed payload never falls through to a
* limited/empty placeholder. Loading and error are hook-level states that are
* not part of the #72 contract.
*/
export function resolveViewState(context: WeekendContext): WeekendViewState {
switch (context.temporal_state) {
case 'no_season':
return 'no_season'
case 'between_weekends':
return 'between_weekends'
case 'pre_session':
return 'pre_session'
case 'session_live':
return 'session_live'
case 'session_settling':
return 'session_settling'
case 'between_sessions':
return 'between_sessions'
case 'post_weekend':
return 'post_weekend'
case 'season_complete':
return 'season_complete'
default:
// Unknown/absent temporal_state is an invalid payload — treat as no_season
// rather than inventing a state. The hook still shows an explicit surface.
return 'no_season'
}
}
export interface MeetingIdentity {
meeting_key: number
meeting_name: string
short_name: string
country_code: string
country_flag: string
circuit_short_name: string
circuit_key?: number
date_start?: string
date_end?: string
}
/** Presentation identity for a meeting, tolerant of partial/absent fields. */
export function meetingIdentity(meeting: Meeting | undefined): MeetingIdentity | undefined {
if (!meeting) return undefined
return {
meeting_key: meeting.meeting_key,
meeting_name: meeting.meeting_name || meeting.location || 'Grand Prix',
short_name: (meeting.meeting_name || meeting.location || 'Grand Prix').replace(/ Grand Prix$/i, ''),
country_code: meeting.country_code,
country_flag: meeting.country_flag,
circuit_short_name: meeting.circuit_short_name || meeting.location || '',
circuit_key: meeting.circuit_key,
date_start: meeting.date_start || undefined,
date_end: meeting.date_end || undefined,
}
}
/** Session identity paired with its resolved meeting for a canonical ContextSession. */
export function sessionMeeting(ref: ContextSession | undefined): Meeting | undefined {
return ref?.meeting
}
export function sessionOf(ref: ContextSession | undefined): Session | undefined {
return ref?.session
}
/**
* analysisSessionKey resolves the session a user should open to read analysis for
* a completed event. It prefers the canonical default analysis session, falling
* back to the previous completed session. Returns undefined when nothing analysable
* is available (e.g. an archived result with no local analysis).
*/
export function analysisSessionKey(context: WeekendContext): number | undefined {
const key =
context.default_analysis_session?.session.session_key ??
context.previous_completed_session?.session.session_key
return key && key > 0 ? key : undefined
}
/** True when a ContextSession has local analysis worth linking to. */
export function hasLocalAnalysis(ref: ContextSession | undefined): boolean {
const status = ref?.availability.local_analysis
return status === 'complete' || status === 'partial'
}
/** Countdown target for a session reference — its scheduled start, if known. */
export function sessionStart(ref: ContextSession | undefined): string | undefined {
const start = ref?.session.date_start
return start ? start : undefined
}
export function podiumFromResults(results: EnrichedResult[]): WeekendPodiumEntry[] {
return [...results]
.filter((r) => r.position >= 1)
.sort((a, b) => a.position - b.position)
.slice(0, 3)
.map((r) => ({
position: r.position,
driver_number: r.driver_number,
name_acronym: r.name_acronym,
team_name: r.team_name,
team_colour: r.team_colour,
gap: formatResultGap(r),
}))
}
function formatResultGap(r: EnrichedResult): string {
if (r.dnf) return 'DNF'
if (r.dns) return 'DNS'
if (r.dsq) return 'DSQ'
if (r.position === 1) {
if (typeof r.duration === 'number' && r.duration > 0) return formatDuration(r.duration)
return 'Winner'
}
const gap = Array.isArray(r.gap_to_leader) ? r.gap_to_leader[r.gap_to_leader.length - 1] : r.gap_to_leader
if (typeof gap === 'number') return `+${gap.toFixed(3)}`
if (typeof gap === 'string' && gap) return gap.startsWith('+') ? gap : `+${gap}`
return '—'
}
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${s.toFixed(3).padStart(6, '0')}`
return `${m}:${s.toFixed(3).padStart(6, '0')}`
}
/**
* championshipImpact derives the top-of-standings movers from the championship
* hub. This is supplementary data layered onto the canonical context, not part of
* the #72 contract.
*/
export function championshipImpact(hub: ChampionshipHub | undefined): WeekendChampionshipImpact | undefined {
if (!hub || hub.drivers.length === 0) return undefined
const leaders: WeekendChampionshipMover[] = hub.drivers.slice(0, 3).map((d) => {
const cumulative = d.cumulative ?? []
const delta =
cumulative.length >= 2 ? cumulative[cumulative.length - 1] - cumulative[cumulative.length - 2] : undefined
return {
position: d.position,
driver_number: d.driver_number,
name_acronym: d.name_acronym,
team_colour: d.team_colour,
points: d.points,
delta,
}
})
const note = hub.last_race ? `Standings after ${hub.last_race}.` : undefined
return { leaders, note }
}
/** At most three briefing items, mapped from the news feed. */
export function briefingItems(news: NewsItem[]): WeekendBriefingItem[] {
return news.slice(0, 3).map((item) => ({
category: item.category,
title: item.title,
url: item.url,
source: item.source,
published_at: item.published_at,
image_url: item.og_image_url,
}))
}

View File

@@ -1,13 +1,33 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchNews, fetchNewsArticle, markNewsRead } from '../api' import {
fetchChampionshipHub,
fetchNews,
fetchNewsArticle,
fetchSeasonMeetings,
fetchSeasons,
markNewsRead,
} from '../api'
import {
activeDigestWindow,
filterByTag,
groupByWindow,
gpWindows,
itemsForWindow,
sinceLastLabel,
sortWindowBucketsNewestFirst,
tagColour,
tagItems,
topTags,
type DigestTag,
type TaggedNewsItem,
} from '../lib/digest'
import { stripHtml, timeAgo } from '../utils' import { stripHtml, timeAgo } from '../utils'
import type { ArticleContent, NewsItem } from '../types' import type { ArticleContent, NewsItem } from '../types'
import '../styles/digest.css'
type Category = 'all' | 'official' | 'news' | 'video' type Category = 'all' | 'official' | 'news' | 'video'
const PAGE_SIZE = 16
const CATEGORY_LABELS: Record<Category, string> = { const CATEGORY_LABELS: Record<Category, string> = {
all: 'All', all: 'All',
official: 'Official', official: 'Official',
@@ -71,6 +91,31 @@ function CategoryTabs({
) )
} }
function TagChip({
tag,
active,
onClick,
className = 'digest-tag-chip',
}: {
tag: DigestTag
active?: boolean
onClick?: () => void
className?: string
}) {
return (
<button
type="button"
className={`${className}${active ? ' active' : ''}`}
style={{ '--tag-colour': tagColour(tag.colour) } as React.CSSProperties}
onClick={onClick}
aria-pressed={active}
>
<span className="digest-tag-dot" aria-hidden="true" />
{tag.label}
</button>
)
}
function OGImage({ url, title }: { url?: string; title: string }) { function OGImage({ url, title }: { url?: string; title: string }) {
const [failed, setFailed] = useState(false) const [failed, setFailed] = useState(false)
const initial = (title[0] ?? '?').toUpperCase() const initial = (title[0] ?? '?').toUpperCase()
@@ -98,11 +143,15 @@ function OGImage({ url, title }: { url?: string; title: string }) {
function BriefingCard({ function BriefingCard({
item, item,
isActive, isActive,
activeTag,
onSelect, onSelect,
onTagClick,
}: { }: {
item: NewsItem item: TaggedNewsItem
isActive: boolean isActive: boolean
activeTag: string | null
onSelect: (item: NewsItem) => void onSelect: (item: NewsItem) => void
onTagClick: (tag: DigestTag) => void
}) { }) {
const isRead = !!item.read_at const isRead = !!item.read_at
const isVideo = categoryOf(item) === 'video' const isVideo = categoryOf(item) === 'video'
@@ -129,6 +178,25 @@ function BriefingCard({
{stripHtml(item.og_description || item.summary || '')} {stripHtml(item.og_description || item.summary || '')}
</p> </p>
)} )}
{item.tags.length > 0 && (
<div className="bp-card-tags">
{item.tags.map((tag) => (
<button
key={tag.key}
type="button"
className={`bp-card-tag${activeTag === tag.key ? ' active' : ''}`}
style={{ '--tag-colour': tagColour(tag.colour) } as React.CSSProperties}
onClick={(e) => {
e.stopPropagation()
onTagClick(tag)
}}
>
<span className="digest-tag-dot" aria-hidden="true" />
{tag.label}
</button>
))}
</div>
)}
</div> </div>
</article> </article>
) )
@@ -146,7 +214,6 @@ function ReaderPanel({
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// Close on Escape
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose() if (e.key === 'Escape') onClose()
@@ -155,7 +222,6 @@ function ReaderPanel({
return () => document.removeEventListener('keydown', handler) return () => document.removeEventListener('keydown', handler)
}, [onClose]) }, [onClose])
// Fetch article when item changes
useEffect(() => { useEffect(() => {
if (!item) { if (!item) {
setArticle(null) setArticle(null)
@@ -175,9 +241,14 @@ function ReaderPanel({
.catch((err) => { setError(String(err)); setLoading(false) }) .catch((err) => { setError(String(err)); setLoading(false) })
}, [item?.url, item ? categoryOf(item) : '']) }, [item?.url, item ? categoryOf(item) : ''])
// Scroll panel to top when item changes
useEffect(() => { useEffect(() => {
panelRef.current?.scrollTo({ top: 0 }) const panel = panelRef.current
if (!panel) return
if (typeof panel.scrollTo === 'function') {
panel.scrollTo({ top: 0 })
} else {
panel.scrollTop = 0
}
}, [item?.url]) }, [item?.url])
const isOpen = item !== null const isOpen = item !== null
@@ -297,7 +368,6 @@ function ReaderPanel({
{article && !loading && article.content && ( {article && !loading && article.content && (
<div <div
className="bp-reader-body" className="bp-reader-body"
// readability strips scripts/iframes; sources are all known news outlets
// eslint-disable-next-line react/no-danger // eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: article.content }} dangerouslySetInnerHTML={{ __html: article.content }}
/> />
@@ -332,8 +402,9 @@ function ReaderPanel({
export function BriefingPage() { export function BriefingPage() {
const [activeCategory, setActiveCategory] = useState<Category>('all') const [activeCategory, setActiveCategory] = useState<Category>('all')
const [selectedItem, setSelectedItem] = useState<NewsItem | null>(null) const [selectedItem, setSelectedItem] = useState<NewsItem | null>(null)
const [page, setPage] = useState(1) const [activeTag, setActiveTag] = useState<string | null>(null)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const now = useMemo(() => new Date(), [])
const { data: allNews = [], isLoading, isError } = useQuery({ const { data: allNews = [], isLoading, isError } = useQuery({
queryKey: ['news'], queryKey: ['news'],
@@ -341,13 +412,74 @@ export function BriefingPage() {
staleTime: 60_000, staleTime: 60_000,
}) })
const filtered = allNews.filter( const seasonsQuery = useQuery({
(item) => activeCategory === 'all' || categoryOf(item) === activeCategory, queryKey: ['seasons'],
queryFn: fetchSeasons,
})
const latestSeason = seasonsQuery.data?.[0] ?? null
const meetingsQuery = useQuery({
queryKey: ['season-meetings', latestSeason],
queryFn: () => fetchSeasonMeetings(latestSeason!),
enabled: latestSeason != null,
})
const hubQuery = useQuery({
queryKey: ['championship-hub', latestSeason],
queryFn: () => fetchChampionshipHub(latestSeason!),
enabled: latestSeason != null,
})
const meetings = meetingsQuery.data ?? []
const hub = hubQuery.data
const taggedNews = useMemo(
() => tagItems(allNews, hub?.drivers ?? [], hub?.teams ?? []),
[allNews, hub],
) )
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const safePage = Math.min(page, pageCount) const categoryFiltered = useMemo(
const pageStart = (safePage - 1) * PAGE_SIZE () => taggedNews.filter(
const pagedItems = filtered.slice(pageStart, pageStart + PAGE_SIZE) (item) => activeCategory === 'all' || categoryOf(item) === activeCategory,
),
[taggedNews, activeCategory],
)
const tagFiltered = useMemo(
() => filterByTag(categoryFiltered, activeTag),
[categoryFiltered, activeTag],
)
const windows = useMemo(() => gpWindows(meetings, now), [meetings, now])
const grouped = useMemo(() => groupByWindow(tagFiltered, windows), [tagFiltered, windows])
const windowSections = useMemo(
() => sortWindowBucketsNewestFirst(grouped.windows),
[grouped.windows],
)
const activeWindow = useMemo(
() => activeDigestWindow(windows, meetings, now),
[windows, meetings, now],
)
const sinceLastItems = useMemo(
() => itemsForWindow(tagFiltered, activeWindow),
[tagFiltered, activeWindow],
)
const sinceLastTags = useMemo(() => topTags(sinceLastItems), [sinceLastItems])
const sinceLabel = sinceLastLabel(meetings, now)
const taggedByUrl = useMemo(() => {
const map = new Map<string, TaggedNewsItem>()
for (const item of tagFiltered) map.set(item.url, item)
return map
}, [tagFiltered])
const recentTagged = useMemo(() => {
const urls = new Set(grouped.recent.map((item) => item.url))
return tagFiltered.filter((item) => urls.has(item.url))
}, [grouped.recent, tagFiltered])
const counts: Record<Category, number> = { const counts: Record<Category, number> = {
all: allNews.length, all: allNews.length,
@@ -374,22 +506,14 @@ export function BriefingPage() {
const handleClose = useCallback(() => setSelectedItem(null), []) const handleClose = useCallback(() => setSelectedItem(null), [])
useEffect(() => { const handleTagClick = useCallback((tag: DigestTag) => {
setPage((current) => Math.min(current, pageCount)) setActiveTag((current) => (current === tag.key ? null : tag.key))
}, [pageCount]) setSelectedItem(null)
}, [])
const handlePageChange = useCallback(
(nextPage: number) => {
setPage(Math.min(Math.max(nextPage, 1), pageCount))
setSelectedItem(null)
window.scrollTo({ top: 0, behavior: 'smooth' })
},
[pageCount],
)
const unreadCount = allNews.filter((i) => !i.read_at).length const unreadCount = allNews.filter((i) => !i.read_at).length
const pageEnd = Math.min(pageStart + pagedItems.length, filtered.length) const hasDigest = meetings.length > 0
const showPagination = filtered.length > PAGE_SIZE const showEmpty = tagFiltered.length === 0 && grouped.recent.length === 0
return ( return (
<div className="bp-page" data-testid="briefing-page"> <div className="bp-page" data-testid="briefing-page">
@@ -408,74 +532,106 @@ export function BriefingPage() {
<CategoryTabs <CategoryTabs
active={activeCategory} active={activeCategory}
counts={counts} counts={counts}
onChange={(c) => { setActiveCategory(c); setSelectedItem(null); setPage(1) }} onChange={(c) => {
setActiveCategory(c)
setSelectedItem(null)
setActiveTag(null)
}}
/> />
{filtered.length === 0 ? ( {hasDigest && (
<div className="digest-sticky" data-testid="digest-sticky-header">
<div className="digest-sticky-head">
<h2 className="digest-sticky-title">Since {sinceLabel}</h2>
<span className="digest-sticky-count">
{sinceLastItems.length} item{sinceLastItems.length === 1 ? '' : 's'}
</span>
</div>
{(sinceLastTags.length > 0 || activeTag) && (
<div className="digest-sticky-tags">
{sinceLastTags.map((tag) => (
<TagChip
key={tag.key}
tag={tag}
active={activeTag === tag.key}
onClick={() => handleTagClick(tag)}
/>
))}
{activeTag && (
<button
type="button"
className="digest-filter-clear"
onClick={() => setActiveTag(null)}
data-testid="digest-filter-clear"
>
Clear filter
</button>
)}
</div>
)}
</div>
)}
{showEmpty ? (
<div className="bp-empty"> <div className="bp-empty">
No {activeCategory !== 'all' ? activeCategory : ''} items available. No {activeCategory !== 'all' ? activeCategory : ''} items available.
Run <code>box-box --ingest-news</code> to refresh feeds. {activeTag ? ' Try clearing the tag filter.' : ''}
{!activeTag && (
<>
{' '}
Run <code>box-box --ingest-news</code> to refresh feeds.
</>
)}
</div> </div>
) : ( ) : (
<> <>
{showPagination && ( {windowSections.map(({ window, items }) => (
<div className="bp-pagination bp-pagination-top"> <section
<span className="bp-pagination-meta mono"> key={window.meeting_key}
{pageStart + 1}-{pageEnd} of {filtered.length} className="digest-section"
</span> data-testid={`digest-window-${window.meeting_key}`}
<div className="bp-pagination-actions"> >
<button <div className="digest-section-head">
className="bp-page-btn" <h3 className="digest-section-title">{window.meeting_name}</h3>
onClick={() => handlePageChange(safePage - 1)} <span className="digest-section-count">{items.length}</span>
disabled={safePage === 1}
>
Previous
</button>
<span className="bp-page-current mono">
Page {safePage} / {pageCount}
</span>
<button
className="bp-page-btn"
onClick={() => handlePageChange(safePage + 1)}
disabled={safePage === pageCount}
>
Next
</button>
</div> </div>
</div> <div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
)} {items.map((item) => {
const tagged = taggedByUrl.get(item.url) ?? { ...item, tags: [] }
return (
<BriefingCard
key={item.url}
item={tagged}
isActive={selectedItem?.url === item.url}
activeTag={activeTag}
onSelect={handleSelect}
onTagClick={handleTagClick}
/>
)
})}
</div>
</section>
))}
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}> {recentTagged.length > 0 && (
{pagedItems.map((item) => ( <section className="digest-section digest-recent-section" data-testid="digest-recent">
<BriefingCard <div className="digest-section-head">
key={item.url} <h3 className="digest-section-title">Recent</h3>
item={item} <span className="digest-section-count">{recentTagged.length}</span>
isActive={selectedItem?.url === item.url} </div>
onSelect={handleSelect} <div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
/> {recentTagged.map((item) => (
))} <BriefingCard
</div> key={item.url}
item={item}
{showPagination && ( isActive={selectedItem?.url === item.url}
<div className="bp-pagination bp-pagination-bottom"> activeTag={activeTag}
<button onSelect={handleSelect}
className="bp-page-btn" onTagClick={handleTagClick}
onClick={() => handlePageChange(safePage - 1)} />
disabled={safePage === 1} ))}
> </div>
Previous </section>
</button>
<span className="bp-page-current mono">
Page {safePage} / {pageCount}
</span>
<button
className="bp-page-btn"
onClick={() => handlePageChange(safePage + 1)}
disabled={safePage === pageCount}
>
Next
</button>
</div>
)} )}
</> </>
)} )}

View File

@@ -1,13 +1,17 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { fetchChampionshipHub, fetchSeasons } from '../api' import { fetchChampionshipHub, fetchSeasons } from '../api'
import { teamColor } from '../utils' import { teamColor } from '../utils'
import type { ChampHubDriver, ChampionshipHub } from '../types' import type { ChampHubDriver, ChampionshipHub } from '../types'
import { ChampionshipSimulator } from '../components/ChampionshipSimulator' import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
import { RivalryCompare } from '../components/RivalryCompare'
import { Meaning } from '../components/Meaning' import { Meaning } from '../components/Meaning'
import { TeammateH2H } from '../components/TeammateH2H'
import { teammatePairs } from '../lib/h2h'
import { pointsGapMeaning } from '../lib/meaning' import { pointsGapMeaning } from '../lib/meaning'
type View = 'drivers' | 'constructors' | 'progression' | 'simulator' type View = 'drivers' | 'constructors' | 'progression' | 'rivalry' | 'simulator'
const GOLD = '#ffd700' const GOLD = '#ffd700'
const SILVER = '#c0c0c0' const SILVER = '#c0c0c0'
@@ -213,6 +217,14 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
> >
Progression Progression
</button> </button>
<button
type="button"
className={`champ-tab${view === 'rivalry' ? ' is-active' : ''}`}
onClick={() => setView('rivalry')}
data-testid="champ-tab-rivalry"
>
Rivalry
</button>
<button <button
type="button" type="button"
className={`champ-tab${view === 'simulator' ? ' is-active' : ''}`} className={`champ-tab${view === 'simulator' ? ' is-active' : ''}`}
@@ -242,10 +254,13 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
leaderPoints={leader.points} leaderPoints={leader.points}
titleMath={titleMath} titleMath={titleMath}
roundsLeft={hub.rounds_left} roundsLeft={hub.rounds_left}
drivers={drivers}
season={hub.season}
/> />
)} )}
{view === 'constructors' && <ConstructorsView hub={hub} />} {view === 'constructors' && <ConstructorsView hub={hub} />}
{view === 'progression' && <ProgressionView hub={hub} />} {view === 'progression' && <ProgressionView hub={hub} />}
{view === 'rivalry' && <RivalryCompare hub={hub} />}
{view === 'simulator' && <ChampionshipSimulator hub={hub} />} {view === 'simulator' && <ChampionshipSimulator hub={hub} />}
</div> </div>
) )
@@ -271,13 +286,19 @@ function DriversView({
leaderPoints, leaderPoints,
titleMath, titleMath,
roundsLeft, roundsLeft,
drivers,
season,
}: { }: {
enriched: EnrichedDriver[] enriched: EnrichedDriver[]
leaderPoints: number leaderPoints: number
titleMath: string titleMath: string
roundsLeft: number roundsLeft: number
drivers: ChampHubDriver[]
season: number
}) { }) {
const podium = enriched.slice(0, 3) const podium = enriched.slice(0, 3)
const h2hPairs = useMemo(() => teammatePairs(drivers), [drivers])
return ( return (
<div data-testid="champ-view-drivers"> <div data-testid="champ-view-drivers">
<div className="champ-podium"> <div className="champ-podium">
@@ -336,6 +357,29 @@ function DriversView({
<span className="champ-titlemath-text">{titleMath}</span> <span className="champ-titlemath-text">{titleMath}</span>
</div> </div>
{h2hPairs.length > 0 && (
<section className="h2h-section" data-testid="champ-teammate-battles">
<div className="h2h-section-head">
<h2 className="h2h-section-title">Teammate battles</h2>
<span className="h2h-section-sub">race finishes · closest first</span>
</div>
<div className="h2h-list">
{h2hPairs.map((pair) => (
<TeammateH2H
key={pair.teamName}
teamName={pair.teamName}
teamColour={pair.teamColour}
driverATla={pair.driverA.name_acronym}
driverBTla={pair.driverB.name_acronym}
winsA={pair.driverA.teammate_wins}
winsB={pair.driverB.teammate_wins}
extraNote={pair.extraCount > 0 ? `+${pair.extraCount}` : undefined}
/>
))}
</div>
</section>
)}
<div className="champ-scroll"> <div className="champ-scroll">
<table className="champ-table champ-table-drivers"> <table className="champ-table champ-table-drivers">
<thead> <thead>
@@ -360,12 +404,19 @@ function DriversView({
P{e.pos} P{e.pos}
</td> </td>
<td> <td>
<div className="champ-drv"> <Link
to="/drivers/$driverNumber"
params={{ driverNumber: String(e.d.driver_number) }}
search={{ year: season }}
className="champ-drv"
style={{ color: 'inherit', textDecoration: 'none' }}
data-testid={`champ-driver-link-${e.d.driver_number}`}
>
<span className="champ-drv-bar" style={{ background: e.color }} /> <span className="champ-drv-bar" style={{ background: e.color }} />
<span className="champ-drv-code mono">{e.d.name_acronym}</span> <span className="champ-drv-code mono">{e.d.name_acronym}</span>
<span className="champ-drv-name">{e.d.full_name}</span> <span className="champ-drv-name">{e.d.full_name}</span>
<span className="champ-drv-num mono">#{e.d.driver_number}</span> <span className="champ-drv-num mono">#{e.d.driver_number}</span>
</div> </Link>
</td> </td>
<td className="champ-td-team">{e.d.team_name}</td> <td className="champ-td-team">{e.d.team_name}</td>
<td className="r mono champ-td-pts">{fmtPts(e.d.points)}</td> <td className="r mono champ-td-pts">{fmtPts(e.d.points)}</td>

View File

@@ -1,451 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { useQueries, useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import {
fetchChampionshipHub,
fetchLiveState,
fetchLocalMeetings,
fetchRaceHub,
fetchSeasonMeetings,
fetchSeasons,
fetchSessions,
fetchWeekend,
} from '../api'
import { CommandCenterHero } from '../components/CommandCenterHero'
import { PaddockBriefing } from '../components/PaddockBriefing'
import { RACE_HUB_DATASETS, countWeekendStats, formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
import { countryAccent, countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
import { classifySessionStatus, heroState } from '../lib/hero'
import {
currentAndNextSession,
focusMeetingKind,
formatSessionScheduleTime,
meetingHasStarted,
mostRecentPastMeeting,
nextUpcomingMeeting,
pickFocusMeeting,
sortSessionsByStart,
} from '../lib/schedule'
import type { Meeting, Session, Weekend, WeekendSession } from '../types'
import { Trophy } from 'lucide-react'
const missingDatasets = Object.fromEntries(
RACE_HUB_DATASETS.map((dataset) => [dataset, { status: 'missing', source: 'none', count: 0 }]),
) as WeekendSession['datasets']
function meetingStatus(meeting: Meeting, focusKey: number | undefined, now: Date) {
if (meeting.meeting_key === focusKey) return 'focus'
if (meetingHasStarted(meeting, now)) return 'past'
return 'future'
}
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 CommandCenterPage() {
const [now, setNow] = useState(() => Date.now())
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: fetchSeasons,
})
const latestSeason = seasonsQuery.data?.[0] ?? null
const meetingsQuery = useQuery({
queryKey: ['meetings', latestSeason],
queryFn: () => fetchLocalMeetings(latestSeason!),
enabled: latestSeason != null,
})
const seasonMeetingsQuery = useQuery({
queryKey: ['season-meetings', latestSeason],
queryFn: () => fetchSeasonMeetings(latestSeason!),
enabled: latestSeason != null,
})
const localMeetings = meetingsQuery.data ?? []
const seasonMeetings = seasonMeetingsQuery.data?.length ? seasonMeetingsQuery.data : localMeetings
const focusMeetings = seasonMeetings.length > 0 ? seasonMeetings : localMeetings
const weekendQueries = useQueries({
queries: localMeetings.map((meeting) => ({
queryKey: ['weekend', meeting.meeting_key],
queryFn: () => fetchWeekend(meeting.meeting_key),
enabled: localMeetings.length > 0,
staleTime: 60_000,
})),
})
const liveQuery = useQuery({
queryKey: ['live-state'],
queryFn: fetchLiveState,
staleTime: 5_000,
})
const championshipQuery = useQuery({
queryKey: ['championship-hub', latestSeason],
queryFn: () => fetchChampionshipHub(latestSeason!),
enabled: latestSeason != null,
})
const champHub = championshipQuery.data
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [])
const nowDate = useMemo(() => new Date(now), [now])
const weekendsByKey = useMemo(() => {
const map = new Map<number, Weekend>()
localMeetings.forEach((meeting, i) => {
const data = weekendQueries[i]?.data
if (data) map.set(meeting.meeting_key, data)
})
return map
}, [localMeetings, weekendQueries])
const weekendList = useMemo(() => weekendQueries.map((q) => q.data), [weekendQueries])
const meetingStats = countWeekendStats(weekendList)
const focusMeeting = pickFocusMeeting(focusMeetings, nowDate)
const focusWeekend = focusMeeting ? weekendsByKey.get(focusMeeting.meeting_key) : undefined
const openF1SessionsQuery = useQuery({
queryKey: ['sessions', focusMeeting?.meeting_key, 'openf1'],
queryFn: () => fetchSessions(focusMeeting!.meeting_key, 'openf1'),
enabled: focusMeeting != null && focusWeekend == null,
staleTime: 60_000,
})
const openF1WeekendSessions: WeekendSession[] = useMemo(
() =>
(openF1SessionsQuery.data ?? []).map((session) => ({
session,
source: 'none',
datasets: missingDatasets,
})),
[openF1SessionsQuery.data],
)
const focusWeekendSessions = focusWeekend?.sessions ?? openF1WeekendSessions
const focusKind = focusMeeting ? focusMeetingKind(focusMeeting, nowDate) : null
const focusSessions: Session[] = sortSessionsByStart(focusWeekendSessions.map((s) => s.session))
const { current: currentSession, next: nextSession } = currentAndNextSession(focusSessions, nowDate)
const analysisSession = pickAnalysisSession(focusWeekend)
const actionSession =
analysisSession?.session ??
currentSession ??
nextSession ??
focusWeekendSessions.find((s) => s.session.session_key === focusWeekend?.default_session_key)?.session ??
focusWeekendSessions[0]?.session
const analysisSessionKey = actionSession?.session_key
const liveActive = liveQuery.data?.is_live === true
const heroStateKind = heroState({
now: nowDate,
liveActive,
currentSession,
focusKind,
})
const lastPastMeeting = useMemo(
() => mostRecentPastMeeting(focusMeetings, nowDate),
[focusMeetings, nowDate],
)
const lastPastWeekend = lastPastMeeting ? weekendsByKey.get(lastPastMeeting.meeting_key) : undefined
const lastRaceAnalysis = pickAnalysisSession(lastPastWeekend)
const lastRaceSessionKey = lastRaceAnalysis?.session.session_key
const lastRaceHubQuery = useQuery({
queryKey: ['race-hub', lastRaceSessionKey, 'hero-podium'],
queryFn: () => fetchRaceHub(lastRaceSessionKey!),
enabled: lastRaceSessionKey != null && heroStateKind === 'between',
staleTime: 60_000,
})
const nextMeetingForHero = useMemo(() => {
if (heroStateKind !== 'between') return null
return nextUpcomingMeeting(focusMeetings, nowDate)
}, [heroStateKind, focusMeetings, nowDate])
const lastRacePodium = lastRaceHubQuery.data?.results ?? []
const lastRaceName = champHub?.last_race ?? lastPastMeeting?.meeting_name ?? ''
if (seasonsQuery.isLoading) {
return <div className="page loading-state">loading command center</div>
}
if (seasonsQuery.isError) {
return (
<div className="page error-box">
{seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'}
</div>
)
}
const seasons = seasonsQuery.data ?? []
if (seasons.length === 0) {
return (
<div className="cc-page cc-empty" data-testid="command-center-empty">
<div className="cc-empty-band">
<span className="cc-empty-eyebrow mono">box-box · command center</span>
<h1 className="cc-empty-title">No local data yet</h1>
<p className="cc-empty-sub">
Ingest a race weekend from the CLI to populate this screen with live status, next-session
countdowns, and analysis links.
</p>
</div>
<div className="cc-empty-actions">
<Link to="/live" className="cc-action cc-action-live">
<span className="cc-action-label">Live Timing</span>
<span className="cc-action-meta">Standby</span>
</Link>
<Link to="/admin" className="cc-action">
<span className="cc-action-label">Admin · Data Health</span>
<span className="cc-action-meta">Ingestion guidance</span>
</Link>
</div>
</div>
)
}
const accent = countryAccent(focusMeeting ?? null)
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
return (
<div className="cc-page" data-testid="command-center" style={accentStyle}>
<div className="cc-topbar">
<span className="cc-topbar-label mono">box-box · command center</span>
<span className="cc-topbar-meta mono">
{latestSeason} season · {meetingStats.full}/{meetingStats.total || 0} weekends full
</span>
<span className="cc-live-pill" data-testid="cc-live-status">
<span className={`cc-live-dot ${liveActive ? 'live' : ''}`} />
{liveActive ? 'Live session active' : 'No live session'}
</span>
</div>
{!focusMeeting && (
<div className="missing-notice">
No meetings ingested for {latestSeason}. Run{' '}
<code>box-box --ingest-year {latestSeason}</code>
</div>
)}
{focusMeeting && focusKind && (
<CommandCenterHero
state={heroStateKind}
now={nowDate}
accent={accent}
liveActive={liveActive}
liveData={liveQuery.data?.data}
focusMeeting={focusMeeting}
focusKind={focusKind}
sessions={focusWeekendSessions}
currentSession={currentSession}
nextSession={nextSession}
analysisSessionKey={analysisSessionKey}
analysisSessionName={actionSession?.session_name}
lastRaceName={lastRaceName}
lastRacePodium={lastRacePodium}
lastRaceSessionKey={lastRaceSessionKey}
nextMeeting={nextMeetingForHero}
/>
)}
<div className="cc-dashboard-grid">
<div className="cc-dashboard-main">
<div
className="cc-ambient-aura"
aria-hidden="true"
style={focusMeeting ? { '--aura-accent': countryAccent(focusMeeting) } as React.CSSProperties : undefined}
/>
<div className="cc-dashboard-panel">
{seasonMeetings.length > 0 && (
<section className="cc-season-calendar" data-testid="cc-season-calendar">
<div className="sec-header">
<span className="sec-title">Season Calendar</span>
<span className="sec-meta mono">
{seasonMeetings.length} rounds · {meetingStats.full}/{meetingStats.total || 0} local
</span>
</div>
<div className="cc-calendar-grid" role="list">
{seasonMeetings.map((meeting, index) => {
const weekend = weekendsByKey.get(meeting.meeting_key)
const target = pickAnalysisSession(weekend)
const targetKey = target?.session.session_key ?? weekend?.default_session_key
const status = meetingStatus(meeting, focusMeeting?.meeting_key, nowDate)
const cardAccent = countryAccent(meeting)
return (
<Link
key={meeting.meeting_key}
to="/race-hub"
search={targetKey ? { session_key: targetKey } : {}}
className={`cc-calendar-card cc-calendar-${status}`}
data-testid={`cc-calendar-${meeting.meeting_key}`}
role="listitem"
style={{ '--gp-card-accent': cardAccent } as React.CSSProperties}
>
<div className="cc-calendar-accent" aria-hidden="true" />
<div className="cc-calendar-top mono">
<span className="cc-calendar-round">R{String(index + 1).padStart(2, '0')}</span>
<span className={`cc-cov-dot cc-cov-${weekend?.source ?? 'none'}`} aria-hidden="true" />
</div>
<div className="cc-calendar-id">
{countryFlag(meeting) && <span className="cc-calendar-flag">{countryFlag(meeting)}</span>}
</div>
<span className="cc-calendar-decal mono">{countryDecal(meeting)}</span>
<div className="cc-calendar-copy">
<div className="cc-calendar-name">{meeting.meeting_name}</div>
<div className="cc-calendar-circuit mono">{meeting.circuit_short_name || meeting.location}</div>
<div className="cc-calendar-date mono">{formatGpDateRange(meeting)}</div>
</div>
</Link>
)
})}
</div>
{seasonMeetingsQuery.isError && (
<div className="cc-side-empty">Using local meetings because the full calendar could not load.</div>
)}
</section>
)}
</div>
</div>
<div className="cc-dashboard-sidebar">
{champHub && (
<section className="cc-champ-snapshot glass-panel" data-testid="cc-champ-snapshot" style={{ padding: 'var(--s5)', borderRadius: '12px' }}>
<div className="cc-champ-header">
<span>Championship Leaders</span>
<Link to="/championship" className="cc-champ-link">Full Standings </Link>
</div>
<div className="cc-champ-group-label">Drivers</div>
<div className="cc-champ-list">
{champHub.drivers.slice(0, 3).map((d) => (
<div key={d.driver_number} className="cc-champ-row">
<div className="cc-champ-left">
<div className="cc-champ-color" style={{ color: `#${d.team_colour}`, background: 'currentColor' }} />
<span className="cc-champ-name">{d.name_acronym}</span>
</div>
<div className="cc-champ-form" title="Last 5 races">
<FormSparkline form={d.form} color={`#${d.team_colour}`} />
</div>
<div className="cc-champ-right">
<span className="cc-champ-gap">
{d.position === 1 ? '—' : `-${champHub.drivers[0].points - d.points}`}
</span>
<span className="cc-champ-pts">{d.points}</span>
</div>
</div>
))}
</div>
<div className="cc-champ-group-label">Constructors</div>
<div className="cc-champ-list">
{champHub.teams.slice(0, 3).map((t, i) => (
<div key={t.team_name} className="cc-champ-row">
<div className="cc-champ-left">
<div className="cc-champ-color" style={{ color: `#${t.team_colour}`, background: 'currentColor' }} />
<span className="cc-champ-name">{t.team_name}</span>
</div>
<div className="cc-champ-form" style={{ opacity: 0.4, fontSize: '11px', display: 'flex', gap: '4px', alignItems: 'center' }} title={`${t.wins} Wins`}>
<Trophy size={12} /> {t.wins}
</div>
<div className="cc-champ-right">
<span className="cc-champ-gap">
{i === 0 ? '—' : `-${champHub.teams[0].points - t.points}`}
</span>
<span className="cc-champ-pts">{t.points}</span>
</div>
</div>
))}
</div>
</section>
)}
{focusWeekendSessions.length > 0 && (
<section className="cc-schedule" data-testid="cc-schedule">
<div className="sec-header">
<span className="sec-title">Weekend Schedule</span>
<span className="sec-meta mono">{focusWeekendSessions.length} sessions</span>
</div>
<div className="cc-session-strip" role="list">
{focusWeekendSessions.map(({ session, source, datasets }) => {
const status = classifySessionStatus(session, nowDate)
const isNext = nextSession?.session_key === session.session_key
const isCurrent = currentSession?.session_key === session.session_key
const isLive = isCurrent && liveActive
return (
<Link
key={session.session_key}
to="/race-hub"
search={{ session_key: session.session_key }}
className={`cc-session-card ui-card interactive cc-status-${status}${isNext ? ' is-next' : ''}${
isLive ? ' is-live glass-panel' : ''
}`}
data-testid={`cc-session-${session.session_key}`}
role="listitem"
>
<div className="cc-session-card-head">
<span className="cc-session-abbrev mono">
{sessionTypeAbbrev(session.session_type, session.session_name)}
</span>
<span className={`cc-session-status cc-status-pill-${status}`}>
{isCurrent ? 'On track' : status === 'done' ? 'Done' : isNext ? 'Next' : 'Upcoming'}
</span>
</div>
<div className="cc-session-name">{session.session_name}</div>
<div className="cc-session-time mono">{formatSessionScheduleTime(session.date_start)}</div>
<div className="cc-session-cov mono">
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
{formatCoverageHint(datasets)}
</div>
</Link>
)
})}
</div>
</section>
)}
<PaddockBriefing />
</div>
</div>
</div>
)
}
function FormSparkline({ form, color }: { form: number[], color: string }) {
if (!form || form.length === 0) return null
const max = Math.max(...form, 26) // 26 is standard max for a race win
const width = 45
const height = 14
const step = width / Math.max(form.length - 1, 1)
const points = form.map((val, i) => {
const x = i * step
const y = height - (val / max) * height
return `${x},${y}`
}).join(' ')
return (
<svg width={width} height={height} style={{ overflow: 'visible' }}>
<polyline
fill="none"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
points={points}
/>
</svg>
)
}

View File

@@ -0,0 +1,369 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { fetchDriverSummary, fetchSeasons } from '../api'
import { teamColor } from '../utils'
import { countryFlag } from '../lib/gpIdentity'
import {
formatDelta,
formatPosition,
gridFinishDeltas,
type RoundDelta,
} from '../lib/driverProfile'
import type { DriverSummary } from '../types'
import '../styles/driver-profile.css'
interface Props {
driverNumber: number
year?: number
}
function fmtPts(n: number): string {
return Number.isInteger(n) ? String(n) : n.toFixed(1)
}
export function DriverProfilePage({ driverNumber, year }: Props) {
// Without an explicit ?year=, default to the latest ingested season; if the
// seasons list is empty/unavailable the backend falls back to the current year.
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: fetchSeasons,
enabled: year == null,
})
const resolvedYear = year ?? seasonsQuery.data?.[0]
const seasonsSettled = year != null || !seasonsQuery.isLoading
const summaryQuery = useQuery({
queryKey: ['driver-summary', driverNumber, resolvedYear ?? 'latest'],
queryFn: () => fetchDriverSummary(driverNumber, resolvedYear),
enabled: seasonsSettled && driverNumber > 0,
staleTime: 5 * 60_000,
})
if (driverNumber <= 0) {
return <div className="page error-box">Invalid driver number</div>
}
if (!seasonsSettled || summaryQuery.isLoading) {
return <div className="page loading-state">loading driver profile</div>
}
if (summaryQuery.isError) {
return (
<div className="page error-box">
{summaryQuery.error instanceof Error
? summaryQuery.error.message
: 'Failed to load driver profile'}
</div>
)
}
const summary = summaryQuery.data
if (!summary) {
return <div className="page error-box">No driver data</div>
}
return <DriverProfileBody summary={summary} />
}
function DriverProfileBody({ summary }: { summary: DriverSummary }) {
const color = teamColor(summary.team_colour)
const deltas = gridFinishDeltas(summary.rounds)
return (
<div className="dp-page" data-testid="driver-profile">
<header className="dp-header" data-testid="dp-header" style={{ borderLeftColor: color }}>
<div className="dp-identity">
<div className="dp-title-row">
<span className="dp-code mono">{summary.name_acronym || `#${summary.driver_number}`}</span>
<span className="dp-num mono" style={{ color }}>
#{summary.driver_number}
</span>
</div>
<h1 className="dp-name">{summary.full_name || `Driver ${summary.driver_number}`}</h1>
<div className="dp-team">
<span className="dp-team-swatch" style={{ background: color }} />
{summary.team_name || 'Unknown team'}
<span className="dp-season mono">{summary.season}</span>
</div>
</div>
<div className="dp-stat-rail">
<div className="dp-stat">
<div className="dp-stat-value mono">{summary.position > 0 ? `P${summary.position}` : '—'}</div>
<div className="dp-stat-label">Championship</div>
</div>
<div className="dp-stat">
<div className="dp-stat-value mono">{fmtPts(summary.points)}</div>
<div className="dp-stat-label">Points</div>
</div>
<div className="dp-stat">
<div className="dp-stat-value mono">{summary.wins}</div>
<div className="dp-stat-label">Wins</div>
</div>
<div className="dp-stat">
<div className="dp-stat-value mono">{summary.podiums}</div>
<div className="dp-stat-label">Podiums</div>
</div>
<div className="dp-stat">
<div className="dp-stat-value mono">{summary.poles}</div>
<div className="dp-stat-label">Poles</div>
</div>
</div>
<Link to="/championship" className="dp-back mono">
Championship
</Link>
</header>
<section className="dp-section" data-testid="dp-form">
<h2 className="dp-section-title mono">Season form</h2>
{summary.cumulative.length > 0 ? (
<div className="dp-form-grid">
<CumulativeLine
cumulative={summary.cumulative}
labels={summary.round_labels}
color={color}
/>
<FormStrip form={summary.form} color={color} />
</div>
) : (
<div className="dp-empty">No completed rounds yet.</div>
)}
</section>
<section className="dp-section" data-testid="dp-quali-race">
<h2 className="dp-section-title mono">Quali vs race</h2>
{deltas.some((d) => d.delta != null) ? (
<GridVsRaceChart deltas={deltas} color={color} />
) : (
<div className="dp-empty">No grid-vs-finish data yet.</div>
)}
</section>
<section className="dp-section" data-testid="dp-rounds">
<h2 className="dp-section-title mono">Track by track</h2>
{summary.rounds.length > 0 ? (
<RoundsTable summary={summary} deltas={deltas} />
) : (
<div className="dp-empty">No completed rounds yet.</div>
)}
</section>
</div>
)
}
const LINE_W = 640
const LINE_H = 180
const LINE_PL = 44
const LINE_PR = 16
const LINE_PT = 12
const LINE_PB = 24
function CumulativeLine({
cumulative,
labels,
color,
}: {
cumulative: number[]
labels: string[]
color: string
}) {
const n = cumulative.length
const maxY = Math.max(25, ...cumulative)
const plotW = LINE_W - LINE_PL - LINE_PR
const plotH = LINE_H - LINE_PT - LINE_PB
const x = (i: number) => (n <= 1 ? LINE_PL + plotW / 2 : LINE_PL + (i * plotW) / (n - 1))
const y = (v: number) => LINE_PT + plotH - (v / maxY) * plotH
const points = cumulative.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ')
const tickStep = Math.max(1, Math.ceil(n / 8))
const ticks: { x: number; label: string }[] = []
for (let i = 0; i < n; i += tickStep) {
ticks.push({ x: x(i), label: labels[i] ?? `R${i + 1}` })
}
return (
<figure className="dp-chart">
<figcaption className="dp-chart-caption mono">Cumulative points</figcaption>
<svg viewBox={`0 0 ${LINE_W} ${LINE_H}`} className="dp-chart-svg" role="img" aria-label="Cumulative points per round">
{[0, 0.5, 1].map((f) => {
const v = Math.round(maxY * f)
return (
<g key={f}>
<line x1={LINE_PL} y1={y(v)} x2={LINE_W - LINE_PR} y2={y(v)} className="dp-gridline" />
<text x={LINE_PL - 6} y={y(v) + 3} textAnchor="end" className="dp-axis">
{v}
</text>
</g>
)
})}
{ticks.map((t) => (
<text key={t.label} x={t.x} y={LINE_H - 8} textAnchor="middle" className="dp-axis">
{t.label}
</text>
))}
<polyline
points={points}
fill="none"
stroke={color}
strokeWidth="2.2"
strokeLinejoin="round"
strokeLinecap="round"
/>
<circle cx={x(n - 1)} cy={y(cumulative[n - 1])} r="3.2" fill={color} />
</svg>
</figure>
)
}
function FormStrip({ form, color }: { form: number[]; color: string }) {
const max = Math.max(25, ...form)
return (
<div className="dp-form-strip">
<div className="dp-chart-caption mono">Last {form.length} races</div>
<div className="dp-form-bars">
{form.map((pts, i) => (
<div className="dp-form-bar" key={i} title={`${fmtPts(pts)} pts`}>
<div
className="dp-form-bar-fill"
style={{ height: `${Math.max(4, (pts / max) * 100)}%`, background: color }}
/>
<span className="dp-form-bar-val mono">{fmtPts(pts)}</span>
</div>
))}
</div>
</div>
)
}
const DELTA_H = 190
const DELTA_PT = 14
const DELTA_PB = 30
function GridVsRaceChart({ deltas, color }: { deltas: RoundDelta[]; color: string }) {
const maxAbs = Math.max(1, ...deltas.map((d) => Math.abs(d.delta ?? 0)))
const colW = 34
const w = Math.max(240, deltas.length * colW)
const plotH = DELTA_H - DELTA_PT - DELTA_PB
const zeroY = DELTA_PT + plotH / 2
const scale = plotH / 2 / maxAbs
return (
<figure className="dp-chart">
<figcaption className="dp-chart-caption mono">
Positions gained () / lost () from grid to flag, per round
</figcaption>
<div className="dp-chart-scroll">
<svg
viewBox={`0 0 ${w} ${DELTA_H}`}
width={w}
height={DELTA_H}
className="dp-delta-svg"
role="img"
aria-label="Positions gained or lost per round"
>
<line x1={0} y1={zeroY} x2={w} y2={zeroY} className="dp-zeroline" />
{deltas.map((d, i) => {
const cx = i * colW + colW / 2
if (d.delta == null) {
return (
<g key={d.round}>
<text x={cx} y={zeroY - 6} textAnchor="middle" className="dp-axis">
·
</text>
<text x={cx} y={DELTA_H - 8} textAnchor="middle" className="dp-axis">
R{d.round}
</text>
</g>
)
}
const h = Math.abs(d.delta) * scale
const yTop = d.delta >= 0 ? zeroY - h : zeroY
return (
<g key={d.round}>
<title>{`${d.label}: ${formatPosition(d.grid)}${formatPosition(d.finish)} (${formatDelta(d.delta)})`}</title>
<rect
x={cx - 8}
y={yTop}
width={16}
height={Math.max(1.5, h)}
rx={2}
fill={color}
opacity={d.delta >= 0 ? 0.95 : 0.45}
/>
<text
x={cx}
y={d.delta >= 0 ? yTop - 4 : yTop + h + 11}
textAnchor="middle"
className="dp-delta-val mono"
>
{formatDelta(d.delta)}
</text>
<text x={cx} y={DELTA_H - 8} textAnchor="middle" className="dp-axis">
R{d.round}
</text>
</g>
)
})}
</svg>
</div>
</figure>
)
}
function statusNote(d: RoundDelta): string {
if (d.status === 'dnf') return 'DNF'
if (d.status === 'dns') return 'DNS'
if (d.status === 'dsq') return 'DSQ'
if (d.status === 'absent') return '—'
return ''
}
function RoundsTable({ summary, deltas }: { summary: DriverSummary; deltas: RoundDelta[] }) {
return (
<div className="dp-scroll">
<table className="dp-table">
<thead>
<tr>
<th className="l">Rnd</th>
<th className="l">Grand Prix</th>
<th className="r">Grid</th>
<th className="r">Race</th>
<th className="r">Δ</th>
<th className="r">Pts</th>
<th className="l"></th>
</tr>
</thead>
<tbody>
{summary.rounds.map((r, i) => {
const d = deltas[i]
return (
<tr key={r.meeting_key}>
<td className="mono dp-td-dim">R{i + 1}</td>
<td>
<span className="dp-flag" aria-hidden="true">
{countryFlag({ country_code: r.country_code, country_flag: '' })}
</span>
{r.meeting_name}
</td>
<td className="r mono">{formatPosition(d.grid)}</td>
<td className="r mono">{formatPosition(d.finish)}</td>
<td
className="r mono"
style={{
color:
d.delta == null
? 'var(--text-3)'
: d.delta > 0
? 'var(--green)'
: d.delta < 0
? 'var(--red)'
: 'var(--text-2)',
}}
>
{formatDelta(d.delta)}
</td>
<td className="r mono">{fmtPts(r.points)}</td>
<td className="dp-td-dim mono">{statusNote(d)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,156 @@
import { useMemo } from 'react'
import { useQueries, useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import {
fetchChampionshipHub,
fetchLocalMeetings,
fetchSeasonMeetings,
fetchSeasons,
fetchWeekend,
} from '../api'
import { countryFlag, formatGpDateRange } from '../lib/gpIdentity'
import { meetingHasStarted } from '../lib/schedule'
import { teamColor } from '../utils'
import type { Weekend } from '../types'
import '../styles/weekend.css'
function analysisKey(weekend: Weekend | undefined): number | undefined {
if (!weekend) return undefined
const race = weekend.sessions.find((s) => s.session.session_type?.toLowerCase().includes('race'))
return race?.session.session_key ?? weekend.default_session_key
}
export function ExplorePage() {
const now = useMemo(() => new Date(), [])
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons })
const season = seasonsQuery.data?.[0] ?? null
const localMeetingsQuery = useQuery({
queryKey: ['meetings', season, 'local'],
queryFn: () => fetchLocalMeetings(season!),
enabled: season != null,
})
const seasonMeetingsQuery = useQuery({
queryKey: ['season-meetings', season],
queryFn: () => fetchSeasonMeetings(season!),
enabled: season != null,
})
const championshipQuery = useQuery({
queryKey: ['championship-hub', season],
queryFn: () => fetchChampionshipHub(season!),
enabled: season != null,
})
const localMeetings = localMeetingsQuery.data ?? []
const meetings = seasonMeetingsQuery.data?.length ? seasonMeetingsQuery.data : localMeetings
const weekendQueries = useQueries({
queries: localMeetings.map((m) => ({
queryKey: ['weekend', m.meeting_key],
queryFn: () => fetchWeekend(m.meeting_key),
enabled: localMeetings.length > 0,
staleTime: 60_000,
})),
})
const weekendsByKey = useMemo(() => {
const map = new Map<number, Weekend>()
localMeetings.forEach((m, i) => {
const data = weekendQueries[i]?.data
if (data) map.set(m.meeting_key, data)
})
return map
}, [localMeetings, weekendQueries])
const drivers = championshipQuery.data?.drivers ?? []
const activeMeetings = meetings.filter((m) => !m.is_cancelled)
if (seasonsQuery.isLoading) {
return <div className="wk-status" data-testid="explore-loading"><p className="wk-status-title">Loading</p></div>
}
return (
<main className="wk-page wk-explore" data-testid="explore-page">
<div className="wk-eyebrow mono">Explore · {season ?? 'season'}</div>
<section className="wk-explore-section" aria-labelledby="explore-calendar-title">
<header className="wk-card-head">
<span className="wk-card-title" id="explore-calendar-title">Season races</span>
<span className="wk-card-link mono">{activeMeetings.length} rounds</span>
</header>
{activeMeetings.length === 0 ? (
<p className="wk-status-sub" data-testid="explore-calendar-empty">No season calendar available yet.</p>
) : (
<ol className="wk-explore-grid" role="list">
{activeMeetings.map((meeting, index) => {
const weekend = weekendsByKey.get(meeting.meeting_key)
const target = analysisKey(weekend)
const done = meetingHasStarted(meeting, now)
const card = (
<>
<div className="wk-explore-top mono">
<span>R{String(index + 1).padStart(2, '0')}</span>
<span className="wk-explore-flag">
{countryFlag(meeting) || meeting.country_code}
</span>
</div>
<div className="wk-explore-name">{meeting.meeting_name}</div>
<div className="wk-explore-meta mono">{meeting.circuit_short_name || meeting.location}</div>
<div className="wk-explore-meta mono">{formatGpDateRange(meeting)}</div>
<span className={`wk-explore-state ${done ? 'is-done' : 'is-upcoming'}`}>
{done ? 'Completed' : 'Upcoming'}
</span>
</>
)
return (
<li key={meeting.meeting_key} className="wk-explore-item" role="listitem">
{target ? (
<Link
to="/race-hub"
search={{ session_key: target }}
className="wk-explore-card is-link"
data-testid={`explore-race-${meeting.meeting_key}`}
>
{card}
</Link>
) : (
<div className="wk-explore-card" data-testid={`explore-race-${meeting.meeting_key}`}>{card}</div>
)}
</li>
)
})}
</ol>
)}
</section>
<section className="wk-explore-section" aria-labelledby="explore-drivers-title">
<header className="wk-card-head">
<span className="wk-card-title" id="explore-drivers-title">Drivers</span>
<Link to="/championship" className="wk-card-link">Championship </Link>
</header>
{drivers.length === 0 ? (
<p className="wk-status-sub" data-testid="explore-drivers-empty">Driver data not available yet.</p>
) : (
<ol className="wk-explore-drivers" role="list">
{drivers.map((d) => (
<li key={d.driver_number} role="listitem">
<Link
to="/drivers/$driverNumber"
params={{ driverNumber: String(d.driver_number) }}
search={season ? { year: season } : {}}
className="wk-driver-chip"
style={{ ['--wk-team' as string]: teamColor(d.team_colour) }}
data-testid={`explore-driver-${d.driver_number}`}
>
<span className="wk-driver-pos mono">{d.position}</span>
<span className="wk-driver-code">{d.name_acronym}</span>
<span className="wk-driver-pts mono">{d.points}</span>
</Link>
</li>
))}
</ol>
)}
</section>
</main>
)
}

View File

@@ -1,12 +1,8 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router' import { Link, useNavigate } from '@tanstack/react-router'
import { import { fetchRaceHub, fetchWeekend, fetchWeekendContext } from '../api'
fetchLocalMeetings, import { weekendFocusSearch } from '../lib/routeSearch'
fetchRaceHub,
fetchSeasons,
fetchWeekend,
} from '../api'
import { DatasetStrip } from '../components/DatasetStrip' import { DatasetStrip } from '../components/DatasetStrip'
import { RaceStoryCanvas } from '../components/RaceStoryCanvas' import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
import { TabBar, type Tab } from '../components/TabBar' import { TabBar, type Tab } from '../components/TabBar'
@@ -17,77 +13,64 @@ import { CompareView } from '../components/CompareView'
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 { OverviewView } from '../components/OverviewView'
import {
PartialAnalysisBanner,
PreSessionView,
SessionPhaseView,
} from '../components/PreSessionView'
import { WeekendSwitcher } from '../components/WeekendSwitcher' import { WeekendSwitcher } from '../components/WeekendSwitcher'
import { SourceBadge } from '../components/SourceBadge' import { SourceBadge } from '../components/SourceBadge'
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity' import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage' import { sessionTypeAbbrev } from '../lib/coverage'
import { formatSessionScheduleTime, sortSessionsByStart } from '../lib/schedule'
import { import {
formatSessionScheduleTime, defaultAnalysisSessionKey,
pickFocusMeeting, isPartialAnalysis,
sortSessionsByStart, isPreSession,
} from '../lib/schedule' isPreparing,
import type { Weekend, WeekendSession } from '../types' isUnavailable,
resolveSessionState,
sessionStateDotClass,
sessionStateLabel,
} from '../lib/sessionState'
import type { WeekendSession } from '../types'
interface Props { interface Props {
sessionKey: number sessionKey: number
} }
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 [activeTab, setActiveTab] = useState<Tab>('overview') const [activeTab, setActiveTab] = useState<Tab>('overview')
const [switcherOpen, setSwitcherOpen] = useState(false) const [switcherOpen, setSwitcherOpen] = useState(false)
const [showDiagnostics, setShowDiagnostics] = useState(false)
const [phaseDiagnostics, setPhaseDiagnostics] = useState(false)
const [now, setNow] = useState(() => new Date())
// ─── Auto-redirect when no session_key is supplied ─── // Keep schedule-adjacent UI (countdown) moving; Live/completion come from context.
const seasonsQuery = useQuery({ useEffect(() => {
queryKey: ['seasons'], const id = window.setInterval(() => setNow(new Date()), 1000)
queryFn: fetchSeasons, return () => window.clearInterval(id)
enabled: sessionKey === 0, }, [])
})
const contextQuery = useQuery({
const latestSeason = seasonsQuery.data?.[0] ?? null queryKey: ['weekend-context'],
queryFn: fetchWeekendContext,
const meetingsQuery = useQuery({ staleTime: 15_000,
queryKey: ['meetings', latestSeason], refetchInterval: 30_000,
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,
}) })
const context = contextQuery.data ?? null
// Bare `/race-hub` resolves only through canonical Weekend Context.
useEffect(() => { useEffect(() => {
if (sessionKey !== 0) return if (sessionKey !== 0) return
const weekend = fallbackWeekendQuery.data if (!contextQuery.isSuccess || !context) return
if (!weekend) return const target = defaultAnalysisSessionKey(context)
const target = pickAnalysisSession(weekend)?.session.session_key
?? weekend.default_session_key
?? weekend.sessions[0]?.session.session_key
if (target) { if (target) {
navigate({ to: '/race-hub', search: { session_key: target }, replace: true }) navigate({ to: '/race-hub', search: { session_key: target }, replace: true })
} }
}, [sessionKey, fallbackWeekendQuery.data, navigate]) }, [sessionKey, contextQuery.isSuccess, context, navigate])
// ─── Active session payload ───
const raceHubQuery = useQuery({ const raceHubQuery = useQuery({
queryKey: ['race-hub', sessionKey], queryKey: ['race-hub', sessionKey],
queryFn: () => fetchRaceHub(sessionKey), queryFn: () => fetchRaceHub(sessionKey),
@@ -108,37 +91,86 @@ export function RaceHubPage({ sessionKey }: Props) {
const accent = countryAccent(data?.meeting ?? null) const accent = countryAccent(data?.meeting ?? null)
const accentStyle = { '--gp-accent': accent } as React.CSSProperties const accentStyle = { '--gp-accent': accent } as React.CSSProperties
// ─── No session_key: show resolving state, fall back to switcher if no local data ─── const openDiagnostics = () => {
setActiveTab('data_status')
setShowDiagnostics(true)
setPhaseDiagnostics(true)
}
// ─── No session_key: resolve via Weekend Context ───
if (sessionKey === 0) { if (sessionKey === 0) {
if (seasonsQuery.isLoading || meetingsQuery.isLoading || fallbackWeekendQuery.isLoading) { if (contextQuery.isLoading) {
return ( return (
<div className="rh-page" style={accentStyle}> <div className="rh-page" style={accentStyle}>
<div className="loading-state">resolving latest local weekend</div> <div className="loading-state">resolving weekend context</div>
</div> </div>
) )
} }
const seasons = seasonsQuery.data ?? [] if (contextQuery.isError) {
if (seasons.length === 0) {
return ( return (
<div className="rh-page rh-empty" data-testid="race-hub-empty" style={accentStyle}> <div className="rh-page" data-testid="race-hub-error" style={accentStyle}>
<div className="rh-empty-band"> <div className="rh-recover">
<span className="rh-empty-eyebrow mono">box-box · race hub</span> <div className="error-box">
<h1 className="rh-empty-title">No local sessions yet</h1> {contextQuery.error instanceof Error
<p className="rh-empty-sub"> ? contextQuery.error.message
The Race Hub reads from local ingest only. Once a weekend is ingested : 'Failed to load weekend context.'}
it will open here automatically. </div>
</p> <div className="rh-recover-actions">
<div className="rh-empty-actions"> <button
<a href="/admin" className="rh-empty-action">Open Admin · Data Health</a> type="button"
<a href="/" className="rh-empty-action">Back to Command Center</a> className="rh-recover-btn primary"
onClick={() => contextQuery.refetch()}
data-testid="rh-retry"
>
Retry
</button>
<Link to="/" search={{}} className="rh-recover-btn" data-testid="rh-back-weekend">
Back to Weekend
</Link>
</div> </div>
</div> </div>
</div> </div>
) )
} }
if (context && !defaultAnalysisSessionKey(context)) {
return (
<div className="rh-page rh-empty" data-testid="race-hub-no-analysis" style={accentStyle}>
<div className="rh-empty-band">
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
<h1 className="rh-empty-title">No completed session to analyse yet</h1>
<p className="rh-empty-sub">
Weekend Context has no default analysis session. Pick a past session
to review, or check back once a session completes with local analysis.
</p>
<div className="rh-empty-actions">
<button
type="button"
className="rh-empty-action"
onClick={() => setSwitcherOpen(true)}
>
Browse Weekends
</button>
<Link to="/" search={{}} className="rh-empty-action" data-testid="rh-back-weekend">
Back to Weekend
</Link>
</div>
</div>
{switcherOpen && (
<WeekendSwitcher
currentMeetingKey={context.focus_meeting?.meeting_key}
context={context}
now={now}
onClose={() => setSwitcherOpen(false)}
/>
)}
</div>
)
}
return ( return (
<div className="rh-page" style={accentStyle}> <div className="rh-page" style={accentStyle}>
<div className="loading-state">resolving latest local weekend</div> <div className="loading-state">resolving weekend context</div>
</div> </div>
) )
} }
@@ -152,12 +184,39 @@ export function RaceHubPage({ sessionKey }: Props) {
) )
} }
if (raceHubQuery.isError || !data) { if (raceHubQuery.isError || !data) {
const backMeeting =
context?.focus_meeting?.meeting_key ??
context?.previous_completed_session?.meeting?.meeting_key
const backSearch = weekendFocusSearch(backMeeting, sessionKey)
return ( return (
<div className="rh-page" style={accentStyle}> <div className="rh-page" data-testid="race-hub-error" style={accentStyle}>
<div className="error-box"> <div className="rh-recover">
{raceHubQuery.error instanceof Error <div className="error-box">
? raceHubQuery.error.message {raceHubQuery.error instanceof Error
: `Failed to load session ${sessionKey}.`} ? raceHubQuery.error.message
: `Failed to load session ${sessionKey}.`}
</div>
<div className="rh-recover-actions">
<button
type="button"
className="rh-recover-btn primary"
onClick={() => raceHubQuery.refetch()}
data-testid="rh-retry"
>
Retry
</button>
<Link
to="/"
search={backSearch}
className="rh-recover-btn"
data-testid="rh-back-weekend"
data-meeting-key={backMeeting ?? undefined}
data-session-key={sessionKey}
>
Back to Weekend
</Link>
</div>
</div> </div>
</div> </div>
) )
@@ -168,12 +227,30 @@ export function RaceHubPage({ sessionKey }: Props) {
const sessionMeta = weekend const sessionMeta = weekend
? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w])) ? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w]))
: {} : {}
const activeSessionMeta = sessionMeta[sessionKey] const activeSessionMeta: WeekendSession | undefined = sessionMeta[sessionKey]
const activeState = resolveSessionState({
weekendSession: activeSessionMeta,
context,
now,
})
const preSession = isPreSession(activeState)
const preparing = isPreparing(activeState)
const unavailable = isUnavailable(activeState)
const partial = isPartialAnalysis(activeState)
return ( return (
<div className="rh-page" data-testid="race-hub" style={accentStyle}> <div className="rh-page" data-testid="race-hub" style={accentStyle}>
{/* Topbar */}
<div className="rh-topbar"> <div className="rh-topbar">
<Link
to="/"
search={weekendFocusSearch(meetingKey, sessionKey)}
className="rh-back-weekend"
data-testid="rh-back-weekend"
data-meeting-key={meetingKey ?? undefined}
data-session-key={sessionKey}
>
Back to Weekend
</Link>
<span className="rh-topbar-label mono"> <span className="rh-topbar-label mono">
box-box · race hub box-box · race hub
{data.meeting?.year ? ` · ${data.meeting.year}` : ''} {data.meeting?.year ? ` · ${data.meeting.year}` : ''}
@@ -195,11 +272,12 @@ export function RaceHubPage({ sessionKey }: Props) {
<WeekendSwitcher <WeekendSwitcher
currentMeetingKey={meetingKey} currentMeetingKey={meetingKey}
currentSessionKey={sessionKey} currentSessionKey={sessionKey}
context={context}
now={now}
onClose={() => setSwitcherOpen(false)} onClose={() => setSwitcherOpen(false)}
/> />
)} )}
{/* GP Identity band */}
{data.meeting && ( {data.meeting && (
<section className="rh-identity" data-testid="rh-identity"> <section className="rh-identity" data-testid="rh-identity">
<div className="rh-identity-accent" aria-hidden="true" /> <div className="rh-identity-accent" aria-hidden="true" />
@@ -220,12 +298,16 @@ export function RaceHubPage({ sessionKey }: Props) {
</section> </section>
)} )}
{/* Session rail */}
{sessions.length > 0 && ( {sessions.length > 0 && (
<nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail"> <nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail">
{sessions.map((session) => { {sessions.map((session) => {
const meta = sessionMeta[session.session_key] const meta = sessionMeta[session.session_key]
const active = session.session_key === sessionKey const active = session.session_key === sessionKey
const state = resolveSessionState({
weekendSession: meta,
context,
now,
})
return ( return (
<button <button
key={session.session_key} key={session.session_key}
@@ -247,123 +329,166 @@ export function RaceHubPage({ sessionKey }: Props) {
<span className="rh-session-time mono"> <span className="rh-session-time mono">
{formatSessionScheduleTime(session.date_start)} {formatSessionScheduleTime(session.date_start)}
</span> </span>
{meta && ( <span className="rh-session-cov mono">
<span className="rh-session-cov mono"> <span
<span className={`cc-cov-dot ${sessionStateDotClass(state)}`}
className={`cc-cov-dot cc-cov-${meta.source}`} aria-hidden="true"
aria-hidden="true" />
/> {sessionStateLabel(state)}
{formatCoverageHint(meta.datasets)} </span>
</span>
)}
</button> </button>
) )
})} })}
</nav> </nav>
)} )}
{/* Active session sub-bar */}
{data.session && ( {data.session && (
<div className="rh-active-bar" data-testid="rh-active-bar"> <div className="rh-active-bar" data-testid="rh-active-bar">
<span className="rh-active-name">{data.session.session_name}</span> <span className="rh-active-name">{data.session.session_name}</span>
<span className="rh-active-meta mono"> <span className="rh-active-meta mono">
{formatSessionScheduleTime(data.session.date_start)} {formatSessionScheduleTime(data.session.date_start)}
</span> </span>
{activeSessionMeta && ( <span className="rh-active-cov mono" data-testid="rh-active-state">
<span className="rh-active-cov mono"> <span
<span className={`cc-cov-dot ${sessionStateDotClass(activeState)}`}
className={`cc-cov-dot cc-cov-${activeSessionMeta.source}`} aria-hidden="true"
aria-hidden="true" />
/> {sessionStateLabel(activeState)}
{formatCoverageHint(activeSessionMeta.datasets)} datasets local </span>
</span>
)}
<span className="rh-active-key mono">key {sessionKey}</span> <span className="rh-active-key mono">key {sessionKey}</span>
</div> </div>
)} )}
<DatasetStrip datasets={data.datasets} /> {preSession && data.session ? (
<PreSessionView
<TabBar active={activeTab} onChange={setActiveTab} /> session={data.session}
sessionName={data.session.session_name}
{activeTab === 'overview' && <OverviewView data={data} />} now={now}
/>
{activeTab === 'race_story' && ( ) : preparing || unavailable ? (
<div className="data-section"> <>
<RaceStoryCanvas data={data} /> <SessionPhaseView
</div> state={
)} unavailable
? activeState === 'cancelled'
{activeTab === 'strategy' && ( ? 'cancelled'
<div className="data-section"> : 'unavailable'
<div className="sec-header"> : 'preparing'
<span className="sec-title">Race Strategy</span> }
</div> sessionName={data.session?.session_name ?? `Session ${sessionKey}`}
<StrategyView onOpenDiagnostics={openDiagnostics}
results={data.results}
stints={data.stints}
pit_stops={data.pit_stops}
hasStints={data.datasets['stints']?.status === 'available'}
/> />
</div> {phaseDiagnostics && (
)} <div className="data-section" data-testid="rh-phase-diagnostics">
<div className="sec-header">
<span className="sec-title">Diagnostics</span>
</div>
<DatasetStatusView datasets={data.datasets} />
<div style={{ marginTop: 'var(--s5)' }} data-testid="rh-dataset-strip">
<DatasetStrip datasets={data.datasets} />
</div>
</div>
)}
</>
) : (
<>
{partial && <PartialAnalysisBanner onOpenDiagnostics={openDiagnostics} />}
<TabBar active={activeTab} onChange={setActiveTab} />
{activeTab === 'compare' && ( {activeTab === 'overview' && <OverviewView data={data} />}
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Driver Compare</span>
</div>
<CompareView
sessionKey={sessionKey}
results={data.results}
drivers={data.drivers}
/>
</div>
)}
{activeTab === 'lap_data' && ( {activeTab === 'race_story' && (
<div className="data-section"> <div className="data-section">
<div className="sec-header"> <RaceStoryCanvas data={data} />
<span className="sec-title">Lap Data</span> </div>
{data.laps.length > 0 && ( )}
<span className="sec-meta mono">{data.laps.length} samples</span>
)}
</div>
<LapsView laps={data.laps} drivers={data.drivers} />
</div>
)}
{activeTab === 'conditions' && ( {activeTab === 'strategy' && (
<div className="data-section"> <div className="data-section">
<div className="sec-header"> <div className="sec-header">
<span className="sec-title">Conditions</span> <span className="sec-title">Race Strategy</span>
{data.weather.length > 0 && ( </div>
<span className="sec-meta mono">{data.weather.length} samples</span> <StrategyView
)} results={data.results}
</div> stints={data.stints}
<WeatherView weather={data.weather} /> pit_stops={data.pit_stops}
</div> hasStints={data.datasets['stints']?.status === 'available'}
)} />
</div>
)}
{activeTab === 'race_control' && ( {activeTab === 'compare' && (
<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">Driver Compare</span>
{data.race_control.length > 0 && ( </div>
<span className="sec-meta mono">{data.race_control.length} messages</span> <CompareView
)} sessionKey={sessionKey}
</div> results={data.results}
<RaceControlView messages={data.race_control} /> drivers={data.drivers}
</div> />
)} </div>
)}
{activeTab === 'data_status' && ( {activeTab === 'lap_data' && (
<div className="data-section"> <div className="data-section">
<div className="sec-header"> <div className="sec-header">
<span className="sec-title">Data Status</span> <span className="sec-title">Lap Data</span>
</div> {data.laps.length > 0 && (
<DatasetStatusView datasets={data.datasets} /> <span className="sec-meta mono">{data.laps.length} samples</span>
</div> )}
</div>
<LapsView laps={data.laps} drivers={data.drivers} />
</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 === '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">Diagnostics</span>
<button
type="button"
className={`rh-diagnostics-toggle${showDiagnostics ? ' active' : ''}`}
onClick={() => setShowDiagnostics((v) => !v)}
aria-expanded={showDiagnostics}
data-testid="rh-diagnostics-toggle"
>
{showDiagnostics ? 'Hide dataset coverage' : 'Show dataset coverage'}
</button>
</div>
<DatasetStatusView datasets={data.datasets} />
{showDiagnostics && (
<div style={{ marginTop: 'var(--s5)' }} data-testid="rh-dataset-strip">
<DatasetStrip datasets={data.datasets} />
</div>
)}
</div>
)}
</>
)} )}
</div> </div>
) )

View File

@@ -0,0 +1,442 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
fetchChampionshipHub,
fetchMeetings,
fetchResults,
fetchSeasons,
fetchSessions,
fetchStartingGrid,
fetchTrackOutline,
} from '../api'
import { countryAccent, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
import {
buildTitleFightContext,
countdownTargetSession,
extractPodium,
extractPole,
findPriorYearMeetingByCircuit,
findRaceSession,
formatPointsGap,
isSprintWeekend,
pickPreviewMeeting,
RACE_WIN_POINTS,
SPRINT_WEEKEND_MAX_POINTS,
} from '../lib/preview'
import { formatCountdown, formatSessionScheduleTime, sessionStartTime, sortSessionsByStart } from '../lib/schedule'
import { buildOutlinePath } from '../lib/trackmap'
import { teamColor } from '../utils'
import type { Meeting, Session, TrackOutline } from '../types'
import '../styles/preview.css'
function SectionState({
loading,
error,
empty,
emptyMessage,
children,
}: {
loading?: boolean
error?: Error | null
empty?: boolean
emptyMessage?: string
children: ReactNode
}) {
if (loading) {
return <div className="preview-section-state">Loading</div>
}
if (error) {
return (
<div className="preview-section-state error">
{error instanceof Error ? error.message : 'Failed to load'}
</div>
)
}
if (empty) {
return <div className="preview-section-state">{emptyMessage ?? 'No data available'}</div>
}
return <>{children}</>
}
function TrackOutlineCard({
outline,
loading,
error,
accent,
}: {
outline: TrackOutline | null | undefined
loading: boolean
error: Error | null
accent: string
}) {
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
return (
<section className="preview-card" data-testid="preview-track-card">
<h2 className="preview-card-title">Circuit</h2>
<SectionState
loading={loading}
error={error}
empty={!outline || !outlinePath}
emptyMessage="Track outline unavailable for this circuit"
>
<div className="preview-track-stage" style={{ ['--preview-accent' as string]: accent }}>
<svg className="preview-track-svg" viewBox="0 0 100 100" role="img" aria-label="Circuit outline">
<path className="preview-track-shadow" d={outlinePath} />
<path className="preview-track-outline" d={outlinePath} />
</svg>
</div>
</SectionState>
</section>
)
}
function LastYearCard({
year,
loading,
error,
podium,
pole,
isFirstTime,
}: {
year: number | null
loading: boolean
error: Error | null
podium: ReturnType<typeof extractPodium>
pole: ReturnType<typeof extractPole>
isFirstTime: boolean
}) {
return (
<section className="preview-card" data-testid="preview-last-year-card">
<h2 className="preview-card-title">Last year here</h2>
{year != null && !isFirstTime && <p className="preview-card-sub">{year} race weekend</p>}
<SectionState
loading={loading}
error={error}
empty={isFirstTime || podium.length === 0}
emptyMessage={
isFirstTime
? 'First time on the calendar — no prior race at this circuit'
: 'No race results available for the previous visit'
}
>
<div className="preview-podium">
{podium.map((place) => (
<div key={place.position} className="preview-podium-row">
<span className="preview-podium-pos">P{place.position}</span>
<span className="preview-podium-code" style={{ color: teamColor(place.team_colour) }}>
{place.name_acronym}
</span>
<span className="preview-podium-team">{place.team_name}</span>
</div>
))}
</div>
{pole && (
<p className="preview-pole">
Pole: <strong style={{ color: teamColor(pole.team_colour) }}>{pole.name_acronym}</strong>{' '}
({pole.team_name})
</p>
)}
</SectionState>
</section>
)
}
function TitleFightCard({
loading,
error,
drivers,
sprintWeekend,
season,
}: {
loading: boolean
error: Error | null
drivers: ReturnType<typeof buildTitleFightContext>
sprintWeekend: boolean
season: number | null
}) {
return (
<section className="preview-card preview-card--wide" data-testid="preview-title-fight-card">
<h2 className="preview-card-title">Title fight context</h2>
{season != null && <p className="preview-card-sub">{season} drivers&apos; championship · top 3</p>}
<SectionState
loading={loading}
error={error}
empty={drivers.length === 0}
emptyMessage="Championship standings not available"
>
<table className="preview-title-table">
<thead>
<tr>
<th>Driver</th>
<th>Pts</th>
<th>Gap</th>
<th>If win (+{RACE_WIN_POINTS})</th>
</tr>
</thead>
<tbody>
{drivers.map((d) => (
<tr key={d.name_acronym}>
<td>
<span style={{ color: teamColor(d.team_colour), fontWeight: 600 }}>{d.name_acronym}</span>
</td>
<td className="mono">{d.points}</td>
<td className="mono">{formatPointsGap(d.gapToLeader)}</td>
<td className="mono">
{d.position === 1 ? 'LEADER' : formatPointsGap(d.gapAfterRaceWin)}
</td>
</tr>
))}
</tbody>
</table>
{sprintWeekend && (
<p className="preview-sprint-note" data-testid="preview-sprint-note">
Sprint weekend: up to {SPRINT_WEEKEND_MAX_POINTS} pts available (race win {RACE_WIN_POINTS} + sprint win
8). Max gain would reduce gaps by {SPRINT_WEEKEND_MAX_POINTS} vs a standard race win.
</p>
)}
</SectionState>
</section>
)
}
function PreviewHeader({
meeting,
sessions,
countdownSession,
now,
accent,
}: {
meeting: Meeting
sessions: Session[]
countdownSession: Session | null
now: Date
accent: string
}) {
const countdownTarget = countdownSession ? sessionStartTime(countdownSession) : null
return (
<header
className="preview-header"
data-testid="preview-header"
style={{ ['--preview-accent' as string]: accent }}
>
<div className="preview-header-accent" />
<div className="preview-header-body">
<div className="preview-header-top">
<div>
<h1 className="preview-gp-name">
{countryFlag(meeting) && <span className="preview-gp-flag">{countryFlag(meeting)}</span>}
{meeting.meeting_name}
</h1>
<p className="preview-circuit">
{meeting.circuit_short_name}
{meeting.location ? ` · ${meeting.location}` : ''}
{formatGpDateRange(meeting) ? ` · ${formatGpDateRange(meeting)}` : ''}
</p>
</div>
{countdownTarget && (
<div className="preview-countdown" data-testid="preview-countdown">
<span className="preview-countdown-label">
{countdownSession?.session_name ?? 'Next session'}
</span>
<span className="preview-countdown-value">{formatCountdown(countdownTarget, now)}</span>
</div>
)}
</div>
{sessions.length > 0 && (
<div className="preview-schedule" data-testid="preview-schedule">
{sessions.map((session) => (
<div key={session.session_key} className="preview-schedule-item">
<span className="preview-schedule-name">{session.session_name}</span>
<span className="preview-schedule-time">{formatSessionScheduleTime(session.date_start)}</span>
</div>
))}
</div>
)}
</div>
</header>
)
}
export function RacePreviewPage() {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [])
const nowDate = useMemo(() => new Date(now), [now])
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: fetchSeasons,
})
const latestSeason = seasonsQuery.data?.[0] ?? null
const meetingsQuery = useQuery({
queryKey: ['meetings', latestSeason, 'auto'],
queryFn: () => fetchMeetings(latestSeason!, 'auto'),
enabled: latestSeason != null,
})
const previewMeeting = useMemo(
() => pickPreviewMeeting(meetingsQuery.data ?? [], nowDate),
[meetingsQuery.data, nowDate],
)
const sessionsQuery = useQuery({
queryKey: ['sessions', previewMeeting?.meeting_key, 'auto'],
queryFn: () => fetchSessions(previewMeeting!.meeting_key, 'auto'),
enabled: previewMeeting != null,
})
const sessions = useMemo(
() => sortSessionsByStart(sessionsQuery.data ?? []),
[sessionsQuery.data],
)
const countdownSession = useMemo(() => countdownTargetSession(sessions, nowDate), [sessions, nowDate])
const sprintWeekend = useMemo(() => isSprintWeekend(sessions), [sessions])
const priorYear = previewMeeting ? previewMeeting.year - 1 : null
const priorMeetingsQuery = useQuery({
queryKey: ['meetings', priorYear, 'auto'],
queryFn: () => fetchMeetings(priorYear!, 'auto'),
enabled: priorYear != null && previewMeeting != null,
})
const priorMeeting = useMemo(
() => findPriorYearMeetingByCircuit(priorMeetingsQuery.data ?? [], previewMeeting?.circuit_key),
[priorMeetingsQuery.data, previewMeeting?.circuit_key],
)
const priorSessionsQuery = useQuery({
queryKey: ['sessions', priorMeeting?.meeting_key, 'auto'],
queryFn: () => fetchSessions(priorMeeting!.meeting_key, 'auto'),
enabled: priorMeeting != null,
})
const priorRaceSession = useMemo(
() => findRaceSession(priorSessionsQuery.data ?? []),
[priorSessionsQuery.data],
)
const priorResultsQuery = useQuery({
queryKey: ['results', priorRaceSession?.session_key, 'auto'],
queryFn: () => fetchResults(priorRaceSession!.session_key, 'auto'),
enabled: priorRaceSession != null,
})
const priorGridQuery = useQuery({
queryKey: ['grid', priorRaceSession?.session_key, 'auto'],
queryFn: () => fetchStartingGrid(priorRaceSession!.session_key, 'auto'),
enabled: priorRaceSession != null,
})
const trackOutlineQuery = useQuery({
queryKey: ['track-outline', previewMeeting?.circuit_key, previewMeeting?.year],
queryFn: () => fetchTrackOutline(previewMeeting!.circuit_key!, previewMeeting!.year),
enabled: previewMeeting?.circuit_key != null && previewMeeting.circuit_key > 0,
})
const championshipQuery = useQuery({
queryKey: ['championship-hub', latestSeason],
queryFn: () => fetchChampionshipHub(latestSeason!),
enabled: latestSeason != null,
})
const podium = useMemo(() => extractPodium(priorResultsQuery.data ?? []), [priorResultsQuery.data])
const pole = useMemo(() => extractPole(priorGridQuery.data ?? []), [priorGridQuery.data])
const titleFight = useMemo(
() => (championshipQuery.data ? buildTitleFightContext(championshipQuery.data) : []),
[championshipQuery.data],
)
const accent = countryAccent(previewMeeting)
const isFirstTimeCircuit = priorMeeting == null && priorYear != null && !priorMeetingsQuery.isLoading
if (seasonsQuery.isLoading || meetingsQuery.isLoading) {
return <div className="page loading-state" data-testid="preview-loading">loading preview</div>
}
if (seasonsQuery.isError || meetingsQuery.isError) {
const err = seasonsQuery.error ?? meetingsQuery.error
return (
<div className="page error-box" data-testid="preview-error">
{err instanceof Error ? err.message : 'Failed to load preview'}
</div>
)
}
if (!previewMeeting) {
return (
<div className="preview-page" data-testid="preview-season-over">
<div className="preview-empty">
<span className="preview-empty-eyebrow mono">box-box · race preview</span>
<h1 className="preview-empty-title">Season complete</h1>
<p className="preview-empty-sub">
No upcoming races on the {latestSeason ?? 'current'} calendar. Check back when the next season schedule is
published.
</p>
</div>
{titleFight.length > 0 && (
<TitleFightCard
loading={championshipQuery.isLoading}
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
drivers={titleFight}
sprintWeekend={false}
season={latestSeason}
/>
)}
</div>
)
}
return (
<div className="preview-page" data-testid="preview-page">
<PreviewHeader
meeting={previewMeeting}
sessions={sessions}
countdownSession={countdownSession}
now={nowDate}
accent={accent}
/>
<div className="preview-grid">
<TrackOutlineCard
outline={trackOutlineQuery.data}
loading={trackOutlineQuery.isLoading}
error={trackOutlineQuery.isError ? (trackOutlineQuery.error as Error) : null}
accent={accent}
/>
<LastYearCard
year={priorMeeting?.year ?? priorYear}
loading={priorMeetingsQuery.isLoading || priorSessionsQuery.isLoading || priorResultsQuery.isLoading}
error={
priorMeetingsQuery.isError
? (priorMeetingsQuery.error as Error)
: priorResultsQuery.isError
? (priorResultsQuery.error as Error)
: null
}
podium={podium}
pole={pole}
isFirstTime={isFirstTimeCircuit}
/>
</div>
<TitleFightCard
loading={championshipQuery.isLoading}
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
drivers={titleFight}
sprintWeekend={sprintWeekend}
season={latestSeason}
/>
</div>
)
}

View File

@@ -0,0 +1,113 @@
import { useWeekendContext } from '../hooks/useWeekendContext'
import { BetweenRacesView } from '../components/weekend/BetweenRacesView'
import { BetweenSessionsView } from '../components/weekend/BetweenSessionsView'
import { LiveHandoffView } from '../components/weekend/LiveHandoffView'
import { PreSessionView } from '../components/weekend/PreSessionView'
import { WeekendError, WeekendLimited, WeekendLoading } from '../components/weekend/StatusViews'
import { WeekendFocusBanner } from '../components/weekend/WeekendFocusBanner'
import { resolveViewState } from '../lib/weekendContext'
import type {
WeekendBriefingItem,
WeekendChampionshipImpact,
WeekendContext,
WeekendViewState,
} from '../types'
import '../styles/weekend.css'
interface RenderArgs {
context: WeekendContext
now: Date
championship?: WeekendChampionshipImpact
briefing: WeekendBriefingItem[]
/** When true (the /preview alias), foreground the preparation surface. */
preview: boolean
}
function renderState(view: WeekendViewState, args: RenderArgs) {
const { context, now, championship, briefing, preview } = args
// The /preview alias always resolves to the preparation surface as long as
// there is a next event to prepare for, regardless of the temporal state. This
// keeps saved /preview links and the "Prepare for …" CTA meaningful instead of
// redirecting straight back to the same between-races screen.
if (preview && context.next_meeting) {
return <PreSessionView context={context} now={now} />
}
switch (view) {
case 'no_season':
return <WeekendLimited season={context.season} />
case 'between_weekends':
case 'post_weekend':
case 'season_complete':
return (
<BetweenRacesView
context={context}
now={now}
view={view}
championship={championship}
briefing={briefing}
/>
)
case 'between_sessions':
case 'session_settling':
return <BetweenSessionsView context={context} now={now} view={view} briefing={briefing} />
case 'session_live':
return <LiveHandoffView context={context} />
case 'pre_session':
return <PreSessionView context={context} now={now} />
default:
return <WeekendLimited season={context.season} />
}
}
export function WeekendPage({
preview = false,
focusMeetingKey,
focusSessionKey,
}: {
preview?: boolean
/** Restored from `/?meeting_key=` when returning from Race Hub analysis. */
focusMeetingKey?: number
/** Restored from `/?session_key=` when returning from Race Hub analysis. */
focusSessionKey?: number
}) {
const { context, loadState, error, championship, briefing, now } = useWeekendContext()
const hasFocus =
(focusMeetingKey != null && focusMeetingKey > 0) ||
(focusSessionKey != null && focusSessionKey > 0)
if (loadState === 'loading') {
return (
<main className="wk-page" data-testid="weekend-page" data-state="loading">
<WeekendLoading />
</main>
)
}
if (loadState === 'error' || context == null) {
return (
<main className="wk-page" data-testid="weekend-page" data-state="error">
<WeekendError message={error?.message} />
</main>
)
}
const view = resolveViewState(context)
return (
<main
className="wk-page"
data-testid="weekend-page"
data-state={view}
data-temporal-state={context.temporal_state}
data-preview={preview ? 'true' : undefined}
data-meeting-key={focusMeetingKey ?? undefined}
data-session-key={focusSessionKey ?? undefined}
>
{hasFocus && (
<WeekendFocusBanner meetingKey={focusMeetingKey} sessionKey={focusSessionKey} />
)}
{renderState(view, { context, now, championship, briefing, preview })}
</main>
)
}

View File

@@ -1,14 +1,17 @@
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router' import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router'
import { Nav } from './components/Nav' import { Nav } from './components/Nav'
import { CommandCenterPage } from './pages/CommandCenterPage' import { WeekendPage } from './pages/WeekendPage'
import { ExplorePage } from './pages/ExplorePage'
import { RaceHubPage } from './pages/RaceHubPage' import { RaceHubPage } from './pages/RaceHubPage'
import { DataLibraryPage } from './pages/DataLibraryPage' import { DataLibraryPage } from './pages/DataLibraryPage'
import { LiveTimingPage } from './pages/LiveTimingPage' import { LiveTimingPage } from './pages/LiveTimingPage'
import { BriefingPage } from './pages/BriefingPage' import { BriefingPage } from './pages/BriefingPage'
import { ChampionshipPage } from './pages/ChampionshipPage' import { ChampionshipPage } from './pages/ChampionshipPage'
import { DriverProfilePage } from './pages/DriverProfilePage'
import { parseRaceHubSearch, parseWeekendSearch } from './lib/routeSearch'
type RaceHubSearch = { type DriverProfileSearch = {
session_key?: number year?: number
} }
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({
@@ -20,19 +23,31 @@ const rootRoute = createRootRoute({
), ),
}) })
export const commandCenterRoute = createRoute({ // Weekend is the adaptive home. `/` renders the current temporal state.
// Optional meeting_key/session_key search restores Race Hub return context
// (deep-linkable, reload-safe — no hidden component memory).
export const weekendRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/', path: '/',
component: CommandCenterPage, validateSearch: (search: Record<string, unknown>) => parseWeekendSearch(search),
component: function WeekendRoute() {
const { meeting_key, session_key } = weekendRoute.useSearch()
return (
<WeekendPage focusMeetingKey={meeting_key} focusSessionKey={session_key} />
)
},
})
export const exploreRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/explore',
component: ExplorePage,
}) })
export const raceHubRoute = createRoute({ export const raceHubRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/race-hub', path: '/race-hub',
validateSearch: (search: Record<string, unknown>): RaceHubSearch => { validateSearch: (search: Record<string, unknown>) => parseRaceHubSearch(search),
const sessionKey = Number(search.session_key)
return Number.isFinite(sessionKey) && sessionKey > 0 ? { session_key: sessionKey } : {}
},
component: function RaceHubRoute() { component: function RaceHubRoute() {
const { session_key } = raceHubRoute.useSearch() const { session_key } = raceHubRoute.useSearch()
return <RaceHubPage sessionKey={session_key ?? 0} /> return <RaceHubPage sessionKey={session_key ?? 0} />
@@ -64,20 +79,49 @@ export const championshipRoute = createRoute({
component: ChampionshipPage, component: ChampionshipPage,
}) })
export const driverProfileRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/drivers/$driverNumber',
validateSearch: (search: Record<string, unknown>): DriverProfileSearch => {
const year = Number(search.year)
return Number.isFinite(year) && year > 0 ? { year } : {}
},
component: function DriverProfileRoute() {
const { driverNumber } = driverProfileRoute.useParams()
const { year } = driverProfileRoute.useSearch()
return <DriverProfilePage driverNumber={Number(driverNumber) || 0} year={year} />
},
})
export const briefingRoute = createRoute({ export const briefingRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/briefing', path: '/briefing',
component: BriefingPage, component: BriefingPage,
}) })
// Preview folds into the Weekend home. /preview is a stable alias that renders the
// Weekend page in its preparation surface (PreSessionView) whenever there is a next
// event — so saved preview links and the "Prepare for …" CTA reach real preview
// content instead of redirecting back to the same between-races screen.
export const previewRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/preview',
component: function PreviewRoute() {
return <WeekendPage preview />
},
})
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
commandCenterRoute, weekendRoute,
exploreRoute,
raceHubRoute, raceHubRoute,
adminRoute, adminRoute,
dataLibraryRoute, dataLibraryRoute,
liveTimingRoute, liveTimingRoute,
championshipRoute, championshipRoute,
driverProfileRoute,
briefingRoute, briefingRoute,
previewRoute,
]) ])
export const router = createRouter({ routeTree }) export const router = createRouter({ routeTree })

View File

@@ -142,6 +142,53 @@ a { color: inherit; text-decoration: none; }
background: var(--surface-2); background: var(--surface-2);
} }
/* ── Mobile bottom nav ── */
.app-bottom-nav {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
height: 60px;
padding-bottom: env(safe-area-inset-bottom, 0);
background: rgba(18, 18, 18, 0.92);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.bottom-nav-link {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
font-size: 10px;
font-weight: 600;
color: var(--text-3);
}
.bottom-nav-link.active { color: var(--red); }
.bottom-nav-link:hover { color: var(--text-2); }
@media (max-width: 640px) {
/* On mobile the bottom bar is the single primary navigation system. The top
bar's Primary links are hidden so the two are never both shown; the top bar
keeps only the logo and the Admin operator utility. */
.app-bottom-nav { display: flex; }
.app-nav .nav-links { display: none; }
.app-nav { justify-content: space-between; }
/* Keep page content clear of the fixed bottom bar. */
.wk-page { padding-bottom: calc(60px + env(safe-area-inset-bottom, 0) + var(--s4)); }
}
/* ── Focus visibility (accessibility) ── */
:focus-visible {
outline: 2px solid var(--red);
outline-offset: 2px;
border-radius: 3px;
}
/* ── Page ── */ /* ── Page ── */
.page { .page {
max-width: 1040px; max-width: 1040px;
@@ -1773,6 +1820,163 @@ a { color: inherit; text-decoration: none; }
.tab-btn:hover { color: var(--text-2); } .tab-btn:hover { color: var(--text-2); }
.tab-btn.active { color: var(--text); border-bottom-color: var(--red); } .tab-btn.active { color: var(--text); border-bottom-color: var(--red); }
/* ── Grouped analysis navigation ── */
.tab-bar-grouped {
gap: var(--s5);
align-items: flex-end;
}
.tab-group {
display: flex;
flex-direction: column;
gap: 2px;
flex-shrink: 0;
}
.tab-group + .tab-group {
border-left: 1px solid var(--border);
padding-left: var(--s5);
}
.tab-group-label {
font-size: 9px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-3);
opacity: 0.7;
padding: 0 14px;
}
.tab-group-btns {
display: flex;
}
/* ── Session lifecycle state dots ── */
.rh-state-ready { background: var(--green); }
.rh-state-partial { background: var(--yellow); }
.rh-state-live { background: var(--red); }
.rh-state-upcoming { background: var(--text-3); }
.rh-state-preparing { background: var(--text-3); opacity: 0.5; }
.rh-state-unavailable { background: var(--text-3); opacity: 0.4; }
.rh-state-cancelled { background: var(--text-3); opacity: 0.35; }
/* ── Pre-session (expected availability) view ── */
.rh-presession {
display: flex;
flex-direction: column;
gap: var(--s5);
}
.rh-presession-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(--s3);
}
.rh-presession-eyebrow {
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-3);
}
.rh-presession-title {
font-size: 22px;
font-weight: 700;
margin: 0;
}
.rh-presession-sub { color: var(--text-2); max-width: 60ch; }
.rh-presession-countdown {
font-size: 15px;
color: var(--text);
margin-top: var(--s3);
}
.rh-partial-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s3);
margin: 0 var(--s5) var(--s3);
padding: var(--s3) var(--s4);
border: 1px solid color-mix(in srgb, var(--yellow) 45%, var(--border));
background: color-mix(in srgb, var(--yellow) 12%, transparent);
color: var(--text-2);
font-size: 12px;
}
.rh-partial-banner-link {
appearance: none;
background: transparent;
border: 0;
color: var(--text);
text-decoration: underline;
cursor: pointer;
font: inherit;
}
.rh-expected-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--s4);
}
.rh-expected-card {
padding: var(--s4);
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
display: flex;
align-items: center;
gap: var(--s3);
color: var(--text-2);
font-size: 13px;
}
.rh-expected-card .rh-expected-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-3);
opacity: 0.6;
}
/* ── Loading / error recovery ── */
.rh-recover {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--s4);
padding: var(--s6) var(--s5);
}
.rh-recover-actions {
display: flex;
gap: var(--s3);
flex-wrap: wrap;
}
.rh-recover-btn {
padding: 8px 14px;
font-size: 12px;
font-weight: 600;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
color: var(--text);
cursor: pointer;
text-decoration: none;
}
.rh-recover-btn:hover { border-color: var(--gp-accent); }
.rh-recover-btn.primary { border-color: var(--red); color: var(--text); }
/* ── Diagnostics (secondary) action ── */
.rh-diagnostics-toggle {
align-self: flex-start;
margin-left: auto;
padding: 6px 12px;
font-size: 11px;
font-weight: 600;
color: var(--text-3);
background: none;
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
}
.rh-diagnostics-toggle:hover { color: var(--text-2); border-color: var(--gp-accent); }
.rh-diagnostics-toggle.active { color: var(--text); }
/* ── Dataset status view ── */ /* ── Dataset status view ── */
.ds-legend { .ds-legend {
display: flex; display: flex;
@@ -2622,6 +2826,31 @@ a { color: inherit; text-decoration: none; }
.rh-topbar-spacer { flex: 1; } .rh-topbar-spacer { flex: 1; }
.rh-back-weekend {
flex-shrink: 0;
padding: 4px 10px;
font-size: 10px;
font-weight: 700;
font-family: var(--f-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-2);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 2px;
transition: color 0.1s, border-color 0.1s, background 0.1s;
}
.rh-back-weekend:hover,
.rh-back-weekend:focus-visible {
color: var(--text);
border-color: var(--gp-accent);
background: var(--surface-h);
outline: none;
}
.rh-back-weekend:focus-visible {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--gp-accent) 45%, transparent);
}
.rh-switcher-toggle { .rh-switcher-toggle {
padding: 4px 10px; padding: 4px 10px;
font-size: 10px; font-size: 10px;
@@ -3197,7 +3426,10 @@ a { color: inherit; text-decoration: none; }
.rh-overview-row { grid-template-columns: 1fr; } .rh-overview-row { grid-template-columns: 1fr; }
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.rh-page { padding: var(--s4); gap: var(--s4); } .rh-page { padding: var(--s4); gap: var(--s4); padding-bottom: 88px; }
.rh-topbar { flex-wrap: wrap; gap: var(--s3); }
.rh-topbar-spacer { display: none; }
.rh-back-weekend { order: -1; }
.rh-identity-body { padding: var(--s3) var(--s4); gap: var(--s3); } .rh-identity-body { padding: var(--s3) var(--s4); gap: var(--s3); }
.rh-identity-decal { font-size: 28px; } .rh-identity-decal { font-size: 28px; }
.rh-identity-name { font-size: 17px; } .rh-identity-name { font-size: 17px; }

View File

@@ -0,0 +1,208 @@
.chapter-strip {
display: flex;
flex-direction: column;
gap: var(--s3);
}
.chapter-strip-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s4);
flex-wrap: wrap;
}
.chapter-strip-title {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
}
.chapter-strip-actions {
display: flex;
align-items: center;
gap: var(--s2);
}
.chapter-tour-btn {
font-family: var(--f-mono);
font-size: 11px;
padding: var(--s1) var(--s3);
border: 1px solid var(--border);
border-radius: var(--s1);
background: var(--surface);
color: var(--text-2);
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.chapter-tour-btn:hover,
.chapter-tour-btn.active {
background: var(--surface-h);
border-color: var(--border-2);
color: var(--text);
}
.chapter-strip-scroll-wrap {
position: relative;
}
.chapter-strip-scroll-wrap::before,
.chapter-strip-scroll-wrap::after {
content: '';
position: absolute;
top: 0;
bottom: var(--s2);
width: 28px;
pointer-events: none;
z-index: 1;
}
.chapter-strip-scroll-wrap::before {
left: 0;
background: linear-gradient(to right, var(--bg), transparent);
}
.chapter-strip-scroll-wrap::after {
right: 0;
background: linear-gradient(to left, var(--bg), transparent);
}
.chapter-strip-scroll {
display: flex;
gap: var(--s3);
overflow-x: auto;
padding-bottom: var(--s2);
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: var(--border-2) transparent;
}
.chapter-strip-scroll::-webkit-scrollbar {
height: 4px;
}
.chapter-strip-scroll::-webkit-scrollbar-track {
background: transparent;
}
.chapter-strip-scroll::-webkit-scrollbar-thumb {
background: var(--border-2);
border-radius: 999px;
}
.chapter-card {
flex: 0 0 min(240px, 72vw);
min-height: 88px;
display: flex;
flex-direction: column;
gap: var(--s2);
padding: var(--s3) var(--s4);
border: 1px solid var(--border);
border-radius: var(--s2);
background: var(--surface);
text-align: left;
cursor: pointer;
scroll-snap-align: center;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
}
.chapter-card:hover {
background: var(--surface-h);
border-color: var(--border-2);
}
.chapter-card.active {
border-color: var(--accent, var(--red));
background: var(--surface-h);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, var(--red)) 35%, transparent);
}
.chapter-card.tour-active {
border-color: var(--yellow);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--yellow) 40%, transparent);
}
.chapter-card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s3);
}
.chapter-kind {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 20px;
padding: 0 var(--s2);
border-radius: var(--s1);
font-family: var(--f-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
background: var(--bg);
color: var(--text-2);
border: 1px solid var(--border);
}
.chapter-card.active .chapter-kind {
color: var(--text);
border-color: var(--border-2);
}
.chapter-kind--safety_car,
.chapter-kind--virtual_safety_car {
color: var(--orange, #f90);
}
.chapter-kind--red_flag {
color: var(--red);
}
.chapter-kind--decisive_swing {
color: var(--green, #22c55e);
}
.chapter-kind--finish {
color: var(--yellow);
}
.chapter-lap-range {
font-family: var(--f-mono);
font-size: 10px;
color: var(--text-3);
white-space: nowrap;
}
.chapter-headline {
font-size: 13px;
line-height: 1.35;
color: var(--text);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
flex: 1;
}
.chapter-strip-empty-card {
padding: var(--s6) var(--s5);
margin: 0;
}
.chapter-strip-empty-card .empty-state-icon {
display: block;
margin: 0 auto var(--s3);
color: var(--text-3);
}
@media (max-width: 900px) {
.chapter-strip-header {
align-items: flex-start;
}
}

View File

@@ -0,0 +1,178 @@
/* ── Calendar-aware paddock digest ── */
.digest-sticky {
position: sticky;
top: 0;
z-index: 20;
margin-bottom: var(--s5);
padding: var(--s4) var(--s5);
background: color-mix(in srgb, var(--bg) 92%, transparent);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 6px;
}
.digest-sticky-head {
display: flex;
align-items: baseline;
gap: var(--s3);
flex-wrap: wrap;
margin-bottom: var(--s3);
}
.digest-sticky-title {
font-size: 15px;
font-weight: 600;
color: var(--text);
margin: 0;
}
.digest-sticky-count {
font-size: 11px;
color: var(--text-3);
font-family: var(--f-mono);
letter-spacing: 0.04em;
}
.digest-sticky-tags {
display: flex;
flex-wrap: wrap;
gap: var(--s2);
align-items: center;
}
.digest-tag-chip {
display: inline-flex;
align-items: center;
gap: var(--s2);
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border-2);
background: var(--surface);
color: var(--text-2);
font-size: 11px;
font-family: var(--f-mono);
cursor: pointer;
transition: border-color 0.1s, color 0.1s, background 0.1s;
}
.digest-tag-chip:hover {
color: var(--text);
background: var(--surface-h);
}
.digest-tag-chip.active {
color: var(--text);
border-color: var(--tag-colour, var(--red));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--tag-colour, var(--red)) 35%, transparent);
}
.digest-tag-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--tag-colour, var(--text-3));
flex-shrink: 0;
}
.digest-filter-clear {
padding: 2px 8px;
border: 1px dashed var(--border-2);
border-radius: 999px;
background: none;
color: var(--text-3);
font-size: 11px;
font-family: var(--f-mono);
cursor: pointer;
}
.digest-filter-clear:hover {
color: var(--text);
border-color: var(--border-2);
}
.digest-section {
margin-bottom: var(--s6);
}
.digest-section-head {
display: flex;
align-items: baseline;
gap: var(--s3);
margin-bottom: var(--s4);
padding-bottom: var(--s3);
border-bottom: 1px solid var(--border);
}
.digest-section-title {
font-size: 13px;
font-weight: 600;
color: var(--text);
margin: 0;
letter-spacing: 0.02em;
}
.digest-section-count {
font-size: 11px;
color: var(--text-3);
font-family: var(--f-mono);
}
.digest-recent-section .digest-section-title {
color: var(--text-2);
}
.bp-card-tags {
display: flex;
flex-wrap: wrap;
gap: var(--s2);
margin-top: var(--s2);
}
.bp-card-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 6px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text-2);
font-size: 10px;
font-family: var(--f-mono);
cursor: pointer;
}
.bp-card-tag:hover {
color: var(--text);
border-color: var(--border-2);
}
.bp-card-tag.active {
border-color: var(--tag-colour, var(--red));
}
.cc-brief-digest-meta {
display: flex;
align-items: center;
gap: var(--s2);
margin-bottom: var(--s3);
font-size: 11px;
color: var(--text-3);
font-family: var(--f-mono);
}
.cc-brief-digest-tags {
display: flex;
flex-wrap: wrap;
gap: var(--s2);
}
.cc-brief-tag {
font-size: 10px;
padding: 1px 6px;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--text-2);
font-family: var(--f-mono);
}

View File

@@ -0,0 +1,289 @@
/* Driver profile page (/drivers/$driverNumber) */
.dp-page {
max-width: 1100px;
margin: 0 auto;
padding: var(--s6) var(--s5);
display: flex;
flex-direction: column;
gap: var(--s6);
}
/* --- header --- */
.dp-header {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: var(--s5);
padding: var(--s5);
padding-left: var(--s6);
border: 1px solid var(--border);
border-left: 4px solid var(--red);
border-radius: var(--r2);
background: var(--surface);
}
.dp-title-row {
display: flex;
align-items: baseline;
gap: var(--s3);
}
.dp-code {
font-size: 13px;
letter-spacing: 0.12em;
color: var(--text-2);
text-transform: uppercase;
}
.dp-num {
font-size: 13px;
font-weight: 700;
}
.dp-name {
margin: var(--s2) 0;
font-size: 30px;
line-height: 1.1;
letter-spacing: -0.01em;
}
.dp-team {
display: flex;
align-items: center;
gap: var(--s3);
font-size: 13px;
color: var(--text-2);
}
.dp-team-swatch {
width: 10px;
height: 10px;
border-radius: 2px;
}
.dp-season {
color: var(--text-3);
}
.dp-stat-rail {
display: flex;
gap: var(--s5);
}
.dp-stat {
min-width: 72px;
}
.dp-stat-value {
font-size: 22px;
font-weight: 700;
}
.dp-stat-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-3);
}
.dp-back {
position: absolute;
top: var(--s4);
right: var(--s5);
font-size: 12px;
color: var(--text-3);
text-decoration: none;
}
.dp-back:hover {
color: var(--text);
}
/* --- sections --- */
.dp-section {
display: flex;
flex-direction: column;
gap: var(--s4);
}
.dp-section-title {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-2);
}
.dp-empty {
padding: var(--s5);
border: 1px dashed var(--border);
border-radius: var(--r2);
color: var(--text-3);
font-size: 13px;
}
/* --- charts --- */
.dp-form-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(200px, 1fr);
gap: var(--s5);
align-items: stretch;
}
@media (max-width: 760px) {
.dp-form-grid {
grid-template-columns: 1fr;
}
}
.dp-chart {
margin: 0;
padding: var(--s4);
border: 1px solid var(--border);
border-radius: var(--r2);
background: var(--surface);
min-width: 0;
}
.dp-chart-caption {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-3);
margin-bottom: var(--s3);
}
.dp-chart-svg {
display: block;
width: 100%;
height: auto;
}
.dp-chart-scroll {
overflow-x: auto;
}
.dp-gridline {
stroke: var(--border);
stroke-width: 1;
}
.dp-zeroline {
stroke: var(--text-3);
stroke-width: 1;
stroke-dasharray: 3 3;
}
.dp-axis {
fill: var(--text-3);
font-family: var(--f-mono);
font-size: 10px;
}
.dp-delta-svg {
display: block;
}
.dp-delta-val {
fill: var(--text-2);
font-size: 10px;
}
/* --- form strip --- */
.dp-form-strip {
padding: var(--s4);
border: 1px solid var(--border);
border-radius: var(--r2);
background: var(--surface);
display: flex;
flex-direction: column;
}
.dp-form-bars {
flex: 1;
display: flex;
align-items: flex-end;
gap: var(--s3);
min-height: 110px;
}
.dp-form-bar {
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: center;
gap: var(--s2);
}
.dp-form-bar-fill {
width: 100%;
max-width: 34px;
border-radius: 2px 2px 0 0;
opacity: 0.9;
}
.dp-form-bar-val {
font-size: 11px;
color: var(--text-2);
}
/* --- rounds table --- */
.dp-scroll {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--r2);
background: var(--surface);
}
.dp-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.dp-table th {
padding: var(--s3) var(--s4);
font-family: var(--f-mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-3);
border-bottom: 1px solid var(--border);
}
.dp-table td {
padding: var(--s3) var(--s4);
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.dp-table tbody tr:last-child td {
border-bottom: none;
}
.dp-table th.l,
.dp-table td.l {
text-align: left;
}
.dp-table th.r,
.dp-table td.r {
text-align: right;
}
.dp-td-dim {
color: var(--text-3);
}
.dp-flag {
margin-right: var(--s3);
}

130
frontend/src/styles/h2h.css Normal file
View File

@@ -0,0 +1,130 @@
/* Teammate head-to-head bars (issue #23). */
.h2h-section {
margin: 20px 0 24px;
padding: 16px 18px;
background: var(--surface-1, rgba(255, 255, 255, 0.03));
border: 1px solid var(--border);
border-radius: 8px;
}
.h2h-section-head {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 14px;
}
.h2h-section-title {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-2);
}
.h2h-section-sub {
font-size: 11px;
font-family: var(--f-mono);
color: var(--text-3);
}
.h2h-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.h2h-row {
display: grid;
grid-template-columns: minmax(100px, 140px) 1fr;
align-items: center;
gap: 12px;
}
.h2h-team {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.h2h-team-name {
font-size: 12px;
color: var(--text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.h2h-extra {
font-size: 10px;
color: var(--text-3);
flex-shrink: 0;
}
.h2h-bar-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.h2h-tla {
font-size: 11px;
font-weight: 700;
color: var(--text);
flex-shrink: 0;
width: 28px;
}
.h2h-tla--left {
text-align: right;
}
.h2h-tla--right {
text-align: left;
}
.h2h-bar {
position: relative;
flex: 1;
display: flex;
height: 22px;
border-radius: 4px;
overflow: hidden;
background: rgba(255, 255, 255, 0.04);
}
.h2h-bar-seg {
height: 100%;
transition: width 0.2s ease;
}
.h2h-bar-seg--muted {
opacity: 0.35;
}
.h2h-score {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
pointer-events: none;
}
@media (max-width: 640px) {
.h2h-row {
grid-template-columns: 1fr;
gap: 4px;
}
.h2h-bar-wrap {
width: 100%;
}
}

View File

@@ -0,0 +1,309 @@
.preview-page {
display: flex;
flex-direction: column;
gap: var(--s6);
padding: var(--s6);
max-width: 1200px;
margin: 0 auto;
}
.preview-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--s4);
min-height: 320px;
text-align: center;
padding: var(--s8);
background: var(--surface);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
}
.preview-empty-eyebrow {
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-3);
}
.preview-empty-title {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
.preview-empty-sub {
color: var(--text-2);
max-width: 36rem;
margin: 0;
}
.preview-header {
display: flex;
background: var(--surface);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
overflow: hidden;
min-height: 140px;
}
.preview-header-accent {
width: 4px;
flex-shrink: 0;
background: var(--preview-accent, var(--gp-accent));
box-shadow: 0 0 12px var(--preview-accent, var(--gp-accent));
}
.preview-header-body {
flex: 1;
padding: var(--s5);
display: flex;
flex-direction: column;
gap: var(--s4);
min-width: 0;
}
.preview-header-top {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: var(--s4);
}
.preview-gp-name {
font-size: 1.4rem;
font-weight: 600;
margin: 0;
display: flex;
align-items: center;
gap: var(--s3);
}
.preview-gp-flag {
font-size: 1.5rem;
line-height: 1;
}
.preview-circuit {
color: var(--text-2);
font-size: 0.9rem;
margin: 0;
}
.preview-countdown {
text-align: right;
}
.preview-countdown-label {
display: block;
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-3);
margin-bottom: var(--s1);
}
.preview-countdown-value {
font-family: var(--mono);
font-size: 1.1rem;
font-variant-numeric: tabular-nums;
color: var(--text-1);
}
.preview-schedule {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--s2);
}
.preview-schedule-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--s2) var(--s3);
background: rgba(255, 255, 255, 0.03);
border-radius: 4px;
font-size: 0.8rem;
}
.preview-schedule-name {
font-weight: 600;
color: var(--text-1);
}
.preview-schedule-time {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--text-2);
}
.preview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--s5);
}
.preview-card {
background: var(--surface);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: var(--s5);
display: flex;
flex-direction: column;
gap: var(--s4);
min-height: 200px;
}
.preview-card-title {
font-size: 0.75rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-3);
margin: 0;
}
.preview-card-sub {
font-size: 0.85rem;
color: var(--text-2);
margin: calc(-1 * var(--s2)) 0 0;
}
.preview-track-stage {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
min-height: 180px;
padding: var(--s4);
}
.preview-track-svg {
width: 100%;
max-width: 280px;
height: auto;
}
.preview-track-outline {
fill: none;
stroke: var(--preview-accent, var(--gp-accent));
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0.9;
}
.preview-track-shadow {
fill: none;
stroke: rgba(0, 0, 0, 0.5);
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
.preview-podium {
display: flex;
flex-direction: column;
gap: var(--s3);
}
.preview-podium-row {
display: flex;
align-items: center;
gap: var(--s3);
font-size: 0.9rem;
}
.preview-podium-pos {
width: 1.5rem;
font-family: var(--mono);
font-weight: 600;
color: var(--text-3);
}
.preview-podium-code {
font-weight: 600;
min-width: 2.5rem;
}
.preview-podium-team {
color: var(--text-2);
font-size: 0.8rem;
}
.preview-pole {
padding-top: var(--s3);
border-top: 1px solid rgba(255, 255, 255, 0.06);
font-size: 0.85rem;
color: var(--text-2);
}
.preview-pole strong {
color: var(--text-1);
}
.preview-title-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.preview-title-table th,
.preview-title-table td {
padding: var(--s2) var(--s3);
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.preview-title-table th {
font-size: 0.7rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
font-weight: 500;
}
.preview-title-table td.mono {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
}
.preview-sprint-note {
font-size: 0.8rem;
color: var(--text-2);
margin: 0;
padding: var(--s3);
background: rgba(255, 255, 255, 0.03);
border-radius: 4px;
}
.preview-section-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-3);
font-size: 0.85rem;
text-align: center;
padding: var(--s5);
}
.preview-section-state.error {
color: var(--red, #e74c3c);
}
@media (max-width: 640px) {
.preview-page {
padding: var(--s4);
}
.preview-header-top {
flex-direction: column;
}
.preview-countdown {
text-align: left;
}
}

View File

@@ -0,0 +1,157 @@
.race-story-empty-card {
padding: var(--s6) var(--s5);
margin: 0;
}
.race-story-empty-card .empty-state-icon {
display: block;
margin: 0 auto var(--s3);
color: var(--text-3);
}
.rs-replay-shell {
display: grid;
grid-template-columns: 1fr;
gap: var(--s5);
align-items: stretch;
}
.rs-replay-shell--split {
grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.8fr);
}
.rs-replay-main {
min-width: 0;
}
.rs-chart-container {
--rs-chart-max-width: 100%;
}
.rs-chart-container--full {
--rs-chart-max-width: 100%;
}
.rs-segmented-control {
display: inline-flex;
align-items: stretch;
flex-wrap: wrap;
gap: 0;
margin-top: var(--s3);
border: 1px solid var(--border-2);
border-radius: var(--s1);
background: var(--surface);
overflow: hidden;
}
.rs-segment {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 30px;
padding: 0 var(--s4);
font-family: var(--f-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text-3);
background: transparent;
border: none;
border-right: 1px solid var(--border);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.rs-segment:last-child {
border-right: none;
}
.rs-segment:hover:not(:disabled) {
color: var(--text);
background: var(--surface-h);
}
.rs-segment:focus-visible {
outline: 2px solid var(--red);
outline-offset: -2px;
z-index: 1;
}
.rs-segment.active {
color: var(--text);
background: rgba(230, 36, 41, 0.12);
box-shadow: inset 0 -2px 0 var(--red);
}
.rs-segment:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.rs-segment-divider {
width: 1px;
align-self: stretch;
background: var(--border);
flex-shrink: 0;
}
.rs-replay-map-slot {
min-width: 0;
}
.rs-position-chart-svg {
width: 100%;
min-width: 280px;
max-width: var(--rs-chart-max-width, 640px);
display: block;
}
.rs-chart-axis-label {
fill: var(--text-3);
font-family: var(--f-mono);
font-size: 9px;
}
.rs-chart-grid-line {
stroke: var(--border);
stroke-width: 0.5;
}
.rs-chart-lap-tick {
stroke: var(--border);
stroke-width: 1;
}
@media (max-width: 900px) {
.rs-replay-shell--split {
grid-template-columns: 1fr;
}
.rs-segmented-control {
width: 100%;
}
.rs-segment {
flex: 1 1 auto;
}
}
@media (max-width: 560px) {
.rs-segmented-control {
flex-direction: column;
align-items: stretch;
}
.rs-segment {
border-right: none;
border-bottom: 1px solid var(--border);
}
.rs-segment:last-child {
border-bottom: none;
}
.rs-segment-divider {
display: none;
}
}

View File

@@ -0,0 +1,91 @@
.replay-map-panel {
min-height: 244px;
height: 100%;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--s2);
}
.replay-map-stage {
position: relative;
min-height: 244px;
height: 100%;
}
.replay-map-svg {
width: 100%;
height: 100%;
min-height: 244px;
display: block;
background: radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.04), transparent 62%);
}
.replay-map-outline-shadow,
.replay-map-outline {
fill: none;
vector-effect: non-scaling-stroke;
stroke-linecap: round;
stroke-linejoin: round;
}
.replay-map-outline-shadow {
stroke: rgba(255, 255, 255, 0.08);
stroke-width: 5;
}
.replay-map-outline {
stroke: var(--text-2);
stroke-width: 1.8;
}
.replay-car {
cursor: pointer;
outline: none;
}
.replay-car circle {
stroke: var(--bg);
stroke-width: 0.8;
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.55));
}
.replay-car text {
fill: #fff;
font-family: var(--f-mono);
font-size: 2.6px;
font-weight: 800;
text-anchor: middle;
pointer-events: none;
paint-order: stroke;
stroke: rgba(0, 0, 0, 0.75);
stroke-width: 0.7;
}
.replay-car:not(.replay-car-pinned) text {
opacity: 0;
}
.replay-car:hover text,
.replay-car:focus-visible text,
.replay-car-pinned text {
opacity: 1;
}
.replay-car-pinned circle {
stroke: #fff;
stroke-width: 1.2;
}
.replay-map-empty-card {
min-height: 244px;
display: grid;
place-content: center;
padding: var(--s6);
margin: 0;
}
.replay-map-empty-card .empty-state-icon {
display: block;
margin: 0 auto var(--s3);
color: var(--text-3);
}

View File

@@ -0,0 +1,134 @@
.rivalry {
display: flex;
flex-direction: column;
gap: var(--s5);
}
.rivalry-pickers {
display: flex;
flex-wrap: wrap;
gap: var(--s4);
align-items: flex-end;
}
.rivalry-vs {
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
padding-bottom: var(--s3);
}
.rivalry-picker {
display: flex;
flex-direction: column;
gap: var(--s2);
min-width: 200px;
}
.rivalry-picker-label {
font-family: var(--f-mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
}
.rivalry-picker-select {
font-family: var(--f-mono);
font-size: 13px;
padding: var(--s2) var(--s3);
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface-2);
color: var(--text);
}
.rivalry-picker-select:focus {
outline: 2px solid var(--red);
outline-offset: 1px;
}
.rivalry-h2h {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s4) var(--s5);
padding: var(--s4) var(--s5);
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface-2);
}
.rivalry-h2h-score {
display: flex;
align-items: baseline;
gap: var(--s3);
}
.rivalry-h2h-code {
font-size: 14px;
font-weight: 700;
}
.rivalry-h2h-num {
font-size: 24px;
font-weight: 700;
color: var(--text);
}
.rivalry-h2h-meta {
font-family: var(--f-mono);
font-size: 11px;
color: var(--text-3);
}
.rivalry-strip {
display: flex;
align-items: center;
gap: var(--s2);
margin-left: auto;
}
.rivalry-strip-label {
font-family: var(--f-mono);
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
margin-right: var(--s2);
}
.rivalry-chip {
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
padding: var(--s1) var(--s3);
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
}
.rivalry-chip-round {
font-size: 9px;
color: var(--text-3);
}
.rivalry-chip-winner {
font-size: 11px;
font-weight: 700;
}
.rivalry-section {
display: flex;
flex-direction: column;
gap: var(--s3);
}
.rivalry-caption {
font-family: var(--f-mono);
font-size: 11px;
color: var(--text-3);
margin: 0;
}

View File

@@ -0,0 +1,413 @@
/* ── Weekend adaptive home ── */
.wk-page {
max-width: 1280px;
margin: 0 auto;
padding: var(--s6) var(--s6) 64px;
width: 100%;
}
.wk-focus {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s5);
margin-bottom: var(--s5);
padding: var(--s5);
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--red);
border-radius: 8px;
}
.wk-focus-copy { display: flex; flex-direction: column; gap: var(--s2); min-width: 0; }
.wk-focus-eyebrow {
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-3);
}
.wk-focus-title {
font-size: 18px;
font-weight: 700;
line-height: 1.2;
}
.wk-focus-session {
font-size: 13px;
color: var(--text-2);
}
.wk-focus-cta { flex-shrink: 0; }
.wk-eyebrow {
display: flex;
align-items: center;
gap: var(--s4);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text-2);
margin-bottom: var(--s5);
}
.wk-eyebrow::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(90deg, var(--red), transparent);
}
.wk-card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--s4);
margin-bottom: var(--s4);
}
.wk-card-title {
font-size: 13px;
font-weight: 700;
color: var(--text);
}
.wk-card-link {
font-size: 11px;
color: var(--text-2);
white-space: nowrap;
}
.wk-card-link:hover { color: var(--text); }
.wk-flag { font-size: 22px; line-height: 1; }
/* ── CTAs ── */
.wk-cta {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--s3);
padding: var(--s4) var(--s5);
font-size: 13px;
font-weight: 600;
border-radius: 6px;
transition: background 0.12s, border-color 0.12s, transform 0.12s;
}
.wk-cta-primary { background: var(--red); color: #fff; }
.wk-cta-primary:hover { background: #ff3b3f; }
.wk-cta-ghost {
background: transparent;
color: var(--text-2);
border: 1px solid var(--border-2);
}
.wk-cta-ghost:hover { color: var(--text); border-color: var(--text-3); }
.wk-cta-wide { width: 100%; }
/* ── Between-races top grid ── */
.wk-top-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--s5);
margin-bottom: var(--s5);
}
.wk-mid-grid {
display: grid;
grid-template-columns: minmax(0, 340px) minmax(0, 1fr);
gap: var(--s5);
margin-bottom: var(--s5);
}
.wk-event-card {
min-width: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: var(--s5);
display: flex;
flex-direction: column;
gap: var(--s5);
}
.wk-event-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s4);
}
.wk-event-id { display: flex; align-items: center; gap: var(--s4); min-width: 0; }
.wk-event-name {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.01em;
overflow: hidden;
text-overflow: ellipsis;
}
.wk-event-tag {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
white-space: nowrap;
}
.wk-tag-done { color: var(--text-2); }
.wk-tag-next { color: var(--red); }
.wk-event-body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 200px);
gap: var(--s5);
align-items: stretch;
}
/* ── Podium ── */
.wk-podium { list-style: none; display: flex; flex-direction: column; gap: var(--s3); }
.wk-podium-row {
display: grid;
grid-template-columns: 20px minmax(0, 1fr) auto;
align-items: center;
gap: var(--s4);
padding: var(--s3) var(--s4);
background: var(--surface-h);
border-left: 3px solid var(--wk-team, var(--border-2));
border-radius: 4px;
}
.wk-podium-pos { font-weight: 700; color: var(--text-2); }
.wk-podium-code { font-weight: 700; color: var(--wk-team, var(--text)); overflow: hidden; text-overflow: ellipsis; }
.wk-podium-gap { color: var(--text-2); font-size: 12px; }
.wk-podium-empty { color: var(--text-3); font-size: 12px; padding: var(--s4); }
.wk-story-card {
display: flex;
flex-direction: column;
gap: var(--s3);
padding: var(--s4);
background: rgba(230, 36, 41, 0.05);
border: 1px solid rgba(230, 36, 41, 0.25);
border-radius: 8px;
}
.wk-story-label { font-size: 13px; font-weight: 700; color: var(--red); }
.wk-story-text { font-size: 12px; color: var(--text-2); flex: 1; }
/* ── Next event ── */
.wk-next-body { display: flex; flex-direction: column; gap: var(--s5); }
.wk-countdown { display: flex; gap: var(--s5); align-items: baseline; }
.wk-count-unit { font-size: 13px; color: var(--text-2); font-family: var(--f-mono); }
.wk-count-unit b { font-size: 40px; color: var(--red); font-weight: 700; margin-right: 2px; }
.wk-countdown-clock { font-size: 32px; font-weight: 700; color: var(--blue); }
.wk-countdown-live { font-size: 20px; font-weight: 700; color: var(--red); }
.wk-next-session { display: flex; flex-direction: column; gap: 2px; }
.wk-next-label { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-3); }
.wk-next-value { font-size: 15px; font-weight: 600; }
.wk-season-complete-copy { font-size: 13px; color: var(--text-2); }
/* ── Championship impact ── */
.wk-champ, .wk-briefing, .wk-season-nav {
min-width: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: var(--s5);
}
.wk-champ-list { list-style: none; display: flex; flex-direction: column; gap: var(--s3); }
.wk-champ-row {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto auto;
align-items: center;
gap: var(--s4);
padding: var(--s2) var(--s3);
border-left: 3px solid var(--wk-team, var(--border-2));
}
.wk-champ-pos { color: var(--text-3); }
.wk-champ-code { font-weight: 700; color: var(--wk-team, var(--text)); }
.wk-champ-pts { font-weight: 700; }
.wk-champ-gap { color: var(--text-3); font-size: 11px; min-width: 34px; text-align: right; }
.wk-champ-note { margin-top: var(--s4); font-size: 11px; color: var(--text-3); }
/* ── Season progress strip ── */
.wk-countdown-tbc { font-size: 14px; color: var(--text-3); }
.wk-season-progress { display: flex; flex-direction: column; gap: var(--s3); }
.wk-season-progress-head { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); }
.wk-season-bar {
position: relative;
height: 8px;
border-radius: 999px;
background: var(--surface-3);
overflow: hidden;
}
.wk-season-bar-fill {
position: absolute;
inset: 0 auto 0 0;
background: var(--red);
border-radius: 999px;
}
.wk-season-progress-label { font-size: 11px; color: var(--text-3); }
/* ── Briefing ── */
.wk-briefing-list { list-style: none; display: flex; flex-direction: column; }
.wk-briefing-item { border-top: 1px solid var(--border); }
.wk-briefing-item:first-child { border-top: none; }
.wk-briefing-link {
display: grid;
grid-template-columns: minmax(0, auto) minmax(0, 1fr) auto;
align-items: center;
gap: var(--s4);
padding: var(--s4) 0;
}
.wk-briefing-link:hover .wk-briefing-title { color: var(--red); }
.wk-briefing-cat { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--red); white-space: nowrap; }
.wk-briefing-title { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wk-briefing-source { font-size: 11px; color: var(--text-3); white-space: nowrap; }
/* ── Between-sessions / live ── */
.wk-sessions, .wk-live, .wk-pre { display: flex; flex-direction: column; gap: var(--s5); }
.wk-sessions-head { display: flex; flex-direction: column; gap: 2px; }
.wk-sessions-title { font-size: 28px; font-weight: 800; letter-spacing: -0.01em; }
.wk-sessions-circuit { font-size: 12px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; }
.wk-timeline {
list-style: none;
display: flex;
gap: var(--s3);
overflow-x: auto;
padding-bottom: var(--s3);
scrollbar-width: thin;
}
.wk-timeline-node {
flex: 1 0 auto;
min-width: 72px;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s2);
padding: var(--s3);
border: 1px solid var(--border);
border-radius: 6px;
}
.wk-timeline-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--surface-3); }
.wk-timeline-done .wk-timeline-dot { background: var(--green); }
.wk-timeline-live .wk-timeline-dot { background: var(--red); box-shadow: 0 0 0 3px rgba(230,36,41,0.25); }
.wk-timeline-next { border-color: var(--red); }
.wk-timeline-next .wk-timeline-dot { border: 2px solid var(--red); }
.wk-timeline-name { font-size: 12px; font-weight: 600; }
.wk-timeline-state { font-size: 9px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-3); }
.wk-timeline-done .wk-timeline-state { color: var(--green); }
.wk-timeline-live .wk-timeline-state, .wk-timeline-next .wk-timeline-state { color: var(--red); }
.wk-recap-card, .wk-upnext-card, .wk-live-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: var(--s5);
display: flex;
flex-direction: column;
gap: var(--s4);
}
.wk-recap-eyebrow, .wk-upnext-eyebrow { font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--green); }
.wk-recap-title, .wk-upnext-title { font-size: 20px; font-weight: 700; }
.wk-upnext-when { font-size: 12px; color: var(--text-3); }
.wk-upnext-card { border-color: rgba(230,36,41,0.3); }
.wk-live-card { border-color: rgba(230,36,41,0.35); }
.wk-live-head { display: flex; align-items: center; justify-content: space-between; }
.wk-live-pulse { width: 12px; height: 12px; border-radius: 50%; background: var(--red); box-shadow: 0 0 0 4px rgba(230,36,41,0.2); }
.wk-live-lead { font-size: 14px; color: var(--text-2); }
/* ── Pre-session ── */
.wk-pre-head {
display: flex;
align-items: center;
gap: var(--s5);
flex-wrap: wrap;
}
.wk-pre-countdown { display: flex; flex-direction: column; gap: 2px; margin-left: auto; }
.wk-pre-preview { min-width: 0; }
/* The folded preview page ships its own max-width page; reset it inside Weekend. */
.wk-pre-preview .preview-page { max-width: 100%; padding: 0; }
/* ── Status views ── */
.wk-status {
max-width: 640px;
margin: 64px auto;
text-align: center;
display: flex;
flex-direction: column;
gap: var(--s4);
align-items: center;
}
.wk-status-eyebrow { font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--text-3); }
.wk-status-title { font-size: 22px; font-weight: 700; }
.wk-status-sub { font-size: 13px; color: var(--text-2); }
.wk-status-actions { display: flex; gap: var(--s4); margin-top: var(--s4); }
.wk-status-error .wk-status-title { color: var(--red); }
/* ── Responsive ── */
@media (max-width: 900px) {
.wk-top-grid, .wk-mid-grid { grid-template-columns: 1fr; }
.wk-event-body { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
.wk-page { padding: var(--s5) var(--s5) 88px; }
.wk-focus {
flex-direction: column;
align-items: stretch;
}
.wk-focus-cta { width: 100%; justify-content: center; }
.wk-event-name { font-size: 20px; }
.wk-sessions-title { font-size: 22px; }
.wk-count-unit b { font-size: 32px; }
.wk-pre-head { flex-direction: column; align-items: flex-start; }
.wk-pre-countdown { margin-left: 0; }
}
/* ── Explore ── */
.wk-explore { display: flex; flex-direction: column; gap: var(--s6); }
.wk-explore-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: var(--s5);
}
.wk-explore-grid {
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--s4);
}
.wk-explore-card {
display: flex;
flex-direction: column;
gap: var(--s2);
padding: var(--s4);
min-width: 0;
background: var(--surface-h);
border: 1px solid var(--border);
border-radius: 8px;
height: 100%;
}
.wk-explore-card.is-link { transition: border-color 0.12s, background 0.12s; }
.wk-explore-card.is-link:hover { border-color: var(--text-3); background: var(--surface-2); }
.wk-explore-top { display: flex; justify-content: space-between; align-items: center; font-size: 11px; color: var(--text-3); }
.wk-explore-flag { font-size: 18px; }
.wk-explore-name { font-size: 14px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; }
.wk-explore-meta { font-size: 11px; color: var(--text-3); }
.wk-explore-state { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; margin-top: var(--s2); }
.wk-explore-state.is-done { color: var(--green); }
.wk-explore-state.is-upcoming { color: var(--text-3); }
.wk-explore-drivers { list-style: none; display: flex; flex-wrap: wrap; gap: var(--s3); }
.wk-driver-chip {
display: inline-flex;
align-items: center;
gap: var(--s3);
padding: var(--s3) var(--s4);
background: var(--surface-h);
border: 1px solid var(--border);
border-left: 3px solid var(--wk-team, var(--border-2));
border-radius: 6px;
transition: border-color 0.12s;
}
.wk-driver-chip:hover { border-color: var(--text-3); }
.wk-driver-pos { color: var(--text-3); font-size: 11px; }
.wk-driver-code { font-weight: 700; color: var(--wk-team, var(--text)); }
.wk-driver-pts { color: var(--text-2); font-size: 12px; }
/* ── Reduced motion ── */
@media (prefers-reduced-motion: reduce) {
.wk-cta, .wk-briefing-title, .wk-live-pulse, .wk-timeline-live .wk-timeline-dot { transition: none; }
}

View File

@@ -0,0 +1,211 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BriefingPage } from '../pages/BriefingPage'
import type { ChampHubDriver, ChampHubTeam, ChampionshipHub, Meeting, NewsItem } from '../types'
vi.mock('../api', () => ({
fetchNews: vi.fn(),
fetchNewsArticle: vi.fn(),
markNewsRead: vi.fn(),
fetchSeasons: vi.fn(),
fetchSeasonMeetings: vi.fn(),
fetchChampionshipHub: vi.fn(),
}))
import {
fetchNews,
fetchNewsArticle,
fetchSeasons,
fetchSeasonMeetings,
fetchChampionshipHub,
markNewsRead,
} from '../api'
const mockFetchNews = vi.mocked(fetchNews)
const mockFetchNewsArticle = vi.mocked(fetchNewsArticle)
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchSeasonMeetings = vi.mocked(fetchSeasonMeetings)
const mockFetchHub = vi.mocked(fetchChampionshipHub)
const mockMarkNewsRead = vi.mocked(markNewsRead)
const bahrain: Meeting = {
meeting_key: 1,
meeting_name: 'Bahrain',
meeting_official_name: 'Bahrain GP',
location: 'Sakhir',
country_name: 'Bahrain',
country_code: 'BRN',
country_flag: '',
circuit_short_name: 'Sakhir',
date_start: '2025-03-14T00:00:00+00:00',
date_end: '2025-03-16T23:59:59+00:00',
year: 2025,
}
const monaco: Meeting = {
meeting_key: 2,
meeting_name: 'Monaco',
meeting_official_name: 'Monaco GP',
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-25T23:59:59+00:00',
year: 2025,
}
const drivers: ChampHubDriver[] = [
{
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
points: 100,
position: 1,
wins: 3,
podiums: 5,
poles: 2,
form: [25, 18, 25],
cumulative: [25, 43, 68, 100],
teammate_wins: 4,
teammate_losses: 1,
round_positions: [],
},
{
driver_number: 4,
name_acronym: 'NOR',
full_name: 'Lando Norris',
team_name: 'McLaren',
team_colour: 'ff8000',
points: 80,
position: 2,
wins: 1,
podiums: 4,
poles: 1,
form: [18, 25, 18],
cumulative: [18, 36, 54, 80],
teammate_wins: 3,
teammate_losses: 2,
round_positions: [],
},
]
const teams: ChampHubTeam[] = [
{ team_name: 'Red Bull', team_colour: '3671c6', points: 150, position: 1, wins: 4 },
{ team_name: 'McLaren', team_colour: 'ff8000', points: 120, position: 2, wins: 2 },
]
const hub: ChampionshipHub = {
season: 2025,
round: 2,
total_rounds: 2,
rounds_left: 0,
last_race: 'Monaco GP',
round_labels: ['R1', 'R2'],
drivers,
teams,
}
const newsItems: NewsItem[] = [
{
source: 'autosport-f1',
title: 'Verstappen sets the pace in Bahrain',
url: 'https://example.com/bahrain-ver',
published_at: '2025-03-15T10:00:00Z',
fetched_at: '2025-03-15T11:00:00Z',
summary: 'Red Bull dominate opening practice',
},
{
source: 'racefans-f1',
title: 'Norris targets Monaco upgrade',
url: 'https://example.com/monaco-nor',
published_at: '2025-04-20T10:00:00Z',
fetched_at: '2025-04-20T11:00:00Z',
summary: 'McLaren bring new floor',
},
{
source: 'bbc-f1',
title: 'Undated paddock rumour',
url: 'https://example.com/undated',
fetched_at: '2025-04-21T11:00:00Z',
},
]
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
return render(
<QueryClientProvider client={queryClient}>
<BriefingPage />
</QueryClientProvider>,
)
}
describe('BriefingPage digest layout', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.setSystemTime(new Date('2025-04-10T12:00:00Z'))
mockFetchSeasons.mockResolvedValue([2025])
mockFetchSeasonMeetings.mockResolvedValue([bahrain, monaco])
mockFetchHub.mockResolvedValue(hub)
mockFetchNews.mockResolvedValue(newsItems)
mockFetchNewsArticle.mockResolvedValue({ title: 'Article', content: '<p>Body</p>' })
mockMarkNewsRead.mockResolvedValue(undefined)
})
afterEach(() => {
vi.useRealTimers()
})
it('renders sticky since-last header, GP sections, and recent bucket', async () => {
renderPage()
await waitFor(() => {
expect(screen.getByTestId('digest-sticky-header')).toHaveTextContent('Since Bahrain')
})
expect(screen.getByTestId('digest-window-2')).toHaveTextContent('Monaco')
expect(screen.getByTestId('digest-window-1')).toHaveTextContent('Bahrain')
expect(screen.getByTestId('digest-recent')).toHaveTextContent('Undated paddock rumour')
expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
expect(screen.getByText('Norris targets Monaco upgrade')).toBeInTheDocument()
})
it('filters the digest when a tag chip is clicked', async () => {
renderPage()
await waitFor(() => {
expect(screen.getByTestId('digest-sticky-header')).toBeInTheDocument()
})
const sticky = screen.getByTestId('digest-sticky-header')
const norTag = within(sticky).getByRole('button', { name: /^NOR$/i })
fireEvent.click(norTag)
await waitFor(() => {
expect(screen.queryByText('Verstappen sets the pace in Bahrain')).not.toBeInTheDocument()
})
expect(screen.getByText('Norris targets Monaco upgrade')).toBeInTheDocument()
expect(screen.getByTestId('digest-filter-clear')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('digest-filter-clear'))
await waitFor(() => {
expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
})
})
it('preserves category tabs', async () => {
renderPage()
await waitFor(() => {
expect(screen.getByRole('tab', { name: /All/i })).toBeInTheDocument()
})
expect(screen.getByRole('tab', { name: /News/i })).toBeInTheDocument()
})
})

View File

@@ -29,6 +29,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
poles: 4, poles: 4,
form: [25, 18, 25, 15, 25], form: [25, 18, 25, 15, 25],
cumulative: [25, 43, 68, 83, 108, 200], cumulative: [25, 43, 68, 83, 108, 200],
round_positions: [1, 2, 1, 3, 1, 1],
teammate_wins: 9, teammate_wins: 9,
teammate_losses: 1, teammate_losses: 1,
...over, ...over,
@@ -47,6 +48,7 @@ const drivers: ChampHubDriver[] = [
position: 2, position: 2,
wins: 3, wins: 3,
cumulative: [18, 36, 54, 80, 120, 160], cumulative: [18, 36, 54, 80, 120, 160],
round_positions: [2, 1, 2, 2, 2, 2],
}), }),
driver({ driver({
driver_number: 16, driver_number: 16,
@@ -58,6 +60,7 @@ const drivers: ChampHubDriver[] = [
position: 3, position: 3,
wins: 1, wins: 1,
cumulative: [15, 28, 40, 60, 90, 120], cumulative: [15, 28, 40, 60, 90, 120],
round_positions: [3, 3, 3, 0, 3, 3],
}), }),
] ]
@@ -129,6 +132,22 @@ describe('ChampionshipPage', () => {
expect(screen.getByText('Cumulative points', { exact: false })).toBeInTheDocument() expect(screen.getByText('Cumulative points', { exact: false })).toBeInTheDocument()
}) })
it('switches to the rivalry view with the top two drivers preselected', async () => {
renderPage()
await waitFor(() => expect(screen.getByTestId('championship')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('champ-tab-rivalry'))
expect(screen.getByTestId('champ-view-rivalry')).toBeInTheDocument()
// Default pair is the top two in the standings: VER vs NOR.
expect(screen.getByTestId('rivalry-pick-a')).toHaveValue('1')
expect(screen.getByTestId('rivalry-pick-b')).toHaveValue('4')
// VER beats NOR in rounds 1, 3, 5, 6 → 42.
expect(screen.getByTestId('rivalry-h2h-num')).toHaveTextContent('42')
expect(screen.getByTestId('rivalry-points-race')).toBeInTheDocument()
expect(screen.getByTestId('rivalry-gap')).toBeInTheDocument()
})
it('switches to the simulator view and projects standings', async () => { it('switches to the simulator view and projects standings', async () => {
renderPage() renderPage()
@@ -150,4 +169,61 @@ describe('ChampionshipPage', () => {
}) })
expect(screen.getByText('No championship data')).toBeInTheDocument() expect(screen.getByText('No championship data')).toBeInTheDocument()
}) })
it('renders teammate battles section ordered by closest split', async () => {
const h2hDrivers: ChampHubDriver[] = [
driver({ driver_number: 1, name_acronym: 'VER', team_name: 'Red Bull', points: 200, position: 1 }),
driver({
driver_number: 4,
name_acronym: 'NOR',
full_name: 'Lando Norris',
team_name: 'McLaren',
team_colour: 'ff8000',
points: 160,
position: 2,
teammate_wins: 6,
teammate_losses: 5,
cumulative: [18, 36, 54, 80, 120, 160],
}),
driver({
driver_number: 81,
name_acronym: 'PIA',
full_name: 'Oscar Piastri',
team_name: 'McLaren',
team_colour: 'ff8000',
points: 140,
position: 3,
teammate_wins: 5,
teammate_losses: 6,
cumulative: [12, 28, 45, 70, 110, 140],
}),
driver({
driver_number: 11,
name_acronym: 'PER',
full_name: 'Sergio Perez',
team_name: 'Red Bull',
team_colour: '3671c6',
points: 60,
position: 4,
teammate_wins: 1,
teammate_losses: 9,
cumulative: [5, 12, 20, 35, 50, 60],
}),
]
mockFetchHub.mockResolvedValue({ ...hub, drivers: h2hDrivers })
renderPage()
await waitFor(() => {
expect(screen.getByTestId('champ-teammate-battles')).toBeInTheDocument()
})
expect(screen.getByText('Teammate battles')).toBeInTheDocument()
const rows = screen.getAllByTestId('teammate-h2h')
expect(rows).toHaveLength(2)
// McLaren 65 is closer than Red Bull 91 — McLaren row first.
expect(rows[0]).toHaveTextContent('McLaren')
expect(rows[0]).toHaveTextContent('65')
expect(rows[1]).toHaveTextContent('Red Bull')
expect(rows[1]).toHaveTextContent('91')
})
}) })

View File

@@ -17,6 +17,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
poles: 0, poles: 0,
form: [], form: [],
cumulative: [], cumulative: [],
round_positions: [],
teammate_wins: 0, teammate_wins: 0,
teammate_losses: 0, teammate_losses: 0,
...over, ...over,

View File

@@ -0,0 +1,128 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChapterStrip } from '../components/ChapterStrip'
import type { Chapter } from '../types'
const chapters: Chapter[] = [
{
kind: 'start',
title: 'Start',
headline: 'Lights out — the field charges into Turn 1',
start_lap: 1,
end_lap: 1,
start_time: '2025-05-25T13:00:00Z',
end_time: '2025-05-25T13:01:00Z',
driver_numbers: [],
},
{
kind: 'safety_car',
title: 'Safety Car (L12-L15)',
headline: 'Sainz incident brings out the Safety Car — leaders dive for the pits',
start_lap: 12,
end_lap: 15,
start_time: '2025-05-25T13:12:00Z',
end_time: '2025-05-25T13:15:00Z',
driver_numbers: [55],
},
]
const tMin = new Date('2025-05-25T13:00:00Z').getTime()
const tMax = new Date('2025-05-25T13:20:00Z').getTime()
const tRange = tMax - tMin
function renderStrip(
overrides: Partial<{
scrubTime: number | null
onChapterClick: (index: number, scrub: number) => void
}> = {},
) {
const onChapterClick = overrides.onChapterClick ?? vi.fn()
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={queryClient}>
<ChapterStrip
chapters={chapters}
scrubTime={overrides.scrubTime ?? null}
tMin={tMin}
tRange={tRange}
tourActive={false}
tourChapterIndex={null}
onChapterClick={onChapterClick}
onTourToggle={vi.fn()}
/>
</QueryClientProvider>,
)
return { onChapterClick }
}
describe('ChapterStrip', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders chapter headlines and lap ranges', () => {
renderStrip()
expect(screen.getByTestId('chapter-strip')).toBeInTheDocument()
expect(screen.getByText('Lights out — the field charges into Turn 1')).toBeInTheDocument()
expect(screen.getByText('L12L15')).toBeInTheDocument()
})
it('renders an empty-state card when there are no chapters', () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={queryClient}>
<ChapterStrip
chapters={[]}
scrubTime={null}
tMin={tMin}
tRange={tRange}
tourActive={false}
tourChapterIndex={null}
onChapterClick={vi.fn()}
onTourToggle={vi.fn()}
/>
</QueryClientProvider>,
)
expect(screen.getByTestId('chapter-strip-empty')).toBeInTheDocument()
})
it('highlights the active chapter from scrub time', () => {
const scrub = (new Date('2025-05-25T13:13:00Z').getTime() - tMin) / tRange
renderStrip({ scrubTime: scrub })
expect(screen.getByTestId('chapter-card-1')).toHaveClass('active')
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
})
it('calls click handler to jump scrubber', () => {
const onChapterClick = vi.fn<(index: number, scrub: number) => void>()
renderStrip({ onChapterClick })
fireEvent.click(screen.getByTestId('chapter-card-1'))
expect(onChapterClick).toHaveBeenCalledTimes(1)
const [index, scrub] = onChapterClick.mock.calls[0]
expect(index).toBe(1)
expect(scrub).toBeCloseTo(0.6, 2)
})
it('highlights an explicitly selected chapter even when scrub is outside its raw window', () => {
// Scrub parked at chart start; chapter 1's raw times are mid-race, but selection wins.
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={queryClient}>
<ChapterStrip
chapters={chapters}
scrubTime={0}
tMin={tMin}
tRange={tRange}
tourActive={false}
tourChapterIndex={null}
selectedChapterIndex={1}
onChapterClick={vi.fn()}
onTourToggle={vi.fn()}
/>
</QueryClientProvider>,
)
expect(screen.getByTestId('chapter-card-1')).toHaveClass('active')
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
})
})

View File

@@ -1,244 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
import { CommandCenterPage } from '../pages/CommandCenterPage'
import type { DatasetInfo, Meeting, Weekend } from '../types'
vi.mock('../api', () => ({
fetchSeasons: vi.fn(),
fetchLocalMeetings: vi.fn(),
fetchSeasonMeetings: vi.fn(),
fetchSessions: vi.fn(),
fetchWeekend: vi.fn(),
fetchLiveState: vi.fn(),
fetchChampionshipHub: vi.fn(),
fetchRaceHub: vi.fn(),
}))
import {
fetchSeasons,
fetchLocalMeetings,
fetchSeasonMeetings,
fetchSessions,
fetchWeekend,
fetchLiveState,
fetchChampionshipHub,
fetchRaceHub,
} from '../api'
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
const mockFetchSeasonMeetings = vi.mocked(fetchSeasonMeetings)
const mockFetchSessions = vi.mocked(fetchSessions)
const mockFetchWeekend = vi.mocked(fetchWeekend)
const mockFetchLiveState = vi.mocked(fetchLiveState)
const mockFetchChampionshipHub = vi.mocked(fetchChampionshipHub)
const mockFetchRaceHub = vi.mocked(fetchRaceHub)
const meeting: Meeting = {
meeting_key: 1229,
meeting_name: 'Monaco',
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 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: 2 },
pit_stops: { status: 'available', source: 'local', count: 1 },
positions: { status: 'available', source: 'local', count: 3 },
race_control: { status: 'available', source: 'local', count: 1 },
weather: { status: 'available', source: 'local', count: 1 },
laps: { status: 'available', source: 'local', count: 1 },
}
const weekend: Weekend = {
source: 'local',
meeting_key: 1229,
meeting,
default_session_key: 9472,
sessions: [
{
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',
},
source: 'local',
datasets: fullDatasets,
},
],
}
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
const rootRoute = createRootRoute({
component: () => (
<QueryClientProvider client={queryClient}>
<CommandCenterPage />
</QueryClientProvider>
),
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: CommandCenterPage,
})
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
return render(<RouterProvider router={router} />)
}
describe('CommandCenterPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchLiveState.mockResolvedValue({ is_live: false, data: null })
mockFetchSessions.mockResolvedValue([])
mockFetchChampionshipHub.mockResolvedValue({
season: 2025,
round: 1,
total_rounds: 1,
rounds_left: 0,
last_race: 'Monaco',
round_labels: ['R1'],
drivers: [],
teams: [],
})
mockFetchRaceHub.mockResolvedValue({
source: 'local',
session_key: 9472,
datasets: fullDatasets,
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: 7200,
gap_to_leader: null,
number_of_laps: 78,
points: 25,
session_key: 9472,
meeting_key: 1229,
},
],
starting_grid: [],
drivers: [],
stints: [],
pit_stops: [],
positions: [],
race_control: [],
weather: [],
laps: [],
})
})
it('shows empty state when no seasons are ingested', async () => {
mockFetchSeasons.mockResolvedValue([])
renderPage()
await waitFor(() => {
expect(screen.getByTestId('command-center-empty')).toBeInTheDocument()
})
expect(screen.getByText('No local data yet')).toBeInTheDocument()
})
it('shows weekend identity band and schedule when data exists', async () => {
mockFetchSeasons.mockResolvedValue([2025])
mockFetchLocalMeetings.mockResolvedValue([meeting])
mockFetchSeasonMeetings.mockResolvedValue([meeting])
mockFetchWeekend.mockResolvedValue(weekend)
renderPage()
await waitFor(() => {
expect(screen.getByTestId('command-center')).toBeInTheDocument()
})
await waitFor(() => {
expect(screen.getByTestId('cc-session-9472')).toBeInTheDocument()
})
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar')
expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01')
expect(screen.getByText('No live session')).toBeInTheDocument()
expect(screen.getByTestId('hero-last-race-link')).toHaveTextContent('Monaco')
})
it('uses OpenF1 calendar metadata to focus the current weekend when local ingest is behind', async () => {
const canada = {
...meeting,
meeting_key: 1301,
meeting_name: 'Canada',
country_name: 'Canada',
country_code: 'CAN',
circuit_short_name: 'Montreal',
date_start: '2026-05-22T00:00:00+00:00',
date_end: '2026-05-24T23:59:59+00:00',
year: 2026,
}
const monaco = {
...meeting,
meeting_key: 1302,
meeting_name: 'Monaco',
date_start: '2026-06-05T00:00:00+00:00',
date_end: '2026-06-07T23:59:59+00:00',
year: 2026,
}
vi.setSystemTime(new Date('2026-06-06T15:15:00Z'))
mockFetchSeasons.mockResolvedValue([2026])
mockFetchLocalMeetings.mockResolvedValue([canada])
mockFetchSeasonMeetings.mockResolvedValue([canada, monaco])
mockFetchWeekend.mockResolvedValue({ ...weekend, meeting: canada, meeting_key: canada.meeting_key })
mockFetchSessions.mockResolvedValue([
{
session_key: 9602,
session_name: 'Qualifying',
session_type: 'Qualifying',
meeting_key: monaco.meeting_key,
date_start: '2026-06-06T15:00:00+00:00',
date_end: '2026-06-06T16:00:00+00:00',
gmt_offset: '02:00:00',
},
])
renderPage()
await waitFor(() => {
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
})
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Live now')
expect(screen.getByTestId('cc-session-9602')).toHaveTextContent('On track')
expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live')
vi.useRealTimers()
})
})

View File

@@ -0,0 +1,160 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
import { DriverProfilePage } from '../pages/DriverProfilePage'
import type { DriverSummary } from '../types'
vi.mock('../api', () => ({
fetchSeasons: vi.fn(),
fetchDriverSummary: vi.fn(),
}))
import { fetchSeasons, fetchDriverSummary } from '../api'
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchSummary = vi.mocked(fetchDriverSummary)
const summary: DriverSummary = {
season: 2025,
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
headshot_url: '',
points: 50,
position: 1,
wins: 2,
podiums: 2,
poles: 1,
form: [25, 25],
cumulative: [25, 50],
round_labels: ['R1', 'R2'],
rounds: [
{
meeting_key: 1201,
meeting_name: 'Bahrain Grand Prix',
country_code: 'BHR',
country_name: 'Bahrain',
race_position: 1,
grid_position: 1,
quali_position: 1,
points: 25,
dnf: false,
dns: false,
dsq: false,
},
{
meeting_key: 1202,
meeting_name: 'Saudi Arabian Grand Prix',
country_code: 'SAU',
country_name: 'Saudi Arabia',
race_position: 1,
grid_position: 4,
quali_position: 4,
points: 25,
dnf: false,
dns: false,
dsq: false,
},
],
}
function renderPage(props: { driverNumber: number; year?: number }) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const rootRoute = createRootRoute({
component: () => (
<QueryClientProvider client={queryClient}>
<DriverProfilePage driverNumber={props.driverNumber} year={props.year} />
</QueryClientProvider>
),
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => null,
})
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
return render(<RouterProvider router={router} />)
}
describe('DriverProfilePage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchSeasons.mockResolvedValue([2025])
mockFetchSummary.mockResolvedValue(summary)
})
it('renders header, form, quali-vs-race, and rounds sections', async () => {
renderPage({ driverNumber: 1, year: 2025 })
await waitFor(() => {
expect(screen.getByTestId('driver-profile')).toBeInTheDocument()
})
// Header: identity + season stats.
expect(screen.getByTestId('dp-header')).toBeInTheDocument()
expect(screen.getByText('Max Verstappen')).toBeInTheDocument()
expect(screen.getByText('#1')).toBeInTheDocument()
expect(screen.getByText('Red Bull', { exact: false })).toBeInTheDocument()
// 'P1' / '50' also appear in the rounds table and chart axis.
expect(screen.getAllByText('P1').length).toBeGreaterThan(0)
expect(screen.getAllByText('50').length).toBeGreaterThan(0)
// Season form strip.
expect(screen.getByTestId('dp-form')).toBeInTheDocument()
expect(screen.getByText('Cumulative points')).toBeInTheDocument()
// Quali-vs-race chart shows the delta from the Saudi round (P4 → P1 = +3).
expect(screen.getByTestId('dp-quali-race')).toBeInTheDocument()
expect(screen.getAllByText('+3').length).toBeGreaterThan(0)
// Track-by-track table rows with flags.
const rounds = screen.getByTestId('dp-rounds')
expect(rounds).toBeInTheDocument()
expect(screen.getByText('Bahrain Grand Prix')).toBeInTheDocument()
expect(screen.getByText('Saudi Arabian Grand Prix')).toBeInTheDocument()
expect(rounds.textContent).toContain('🇧🇭')
expect(rounds.textContent).toContain('🇸🇦')
// With an explicit year, the seasons list is not needed.
expect(mockFetchSeasons).not.toHaveBeenCalled()
expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025)
})
it('defaults to the latest season when no year is given', async () => {
renderPage({ driverNumber: 1 })
await waitFor(() => {
expect(screen.getByTestId('driver-profile')).toBeInTheDocument()
})
expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025)
})
it('shows the API error message on failure', async () => {
mockFetchSummary.mockRejectedValue(new Error('API 404: Not Found'))
renderPage({ driverNumber: 99, year: 2025 })
await waitFor(() => {
expect(screen.getByText('API 404: Not Found')).toBeInTheDocument()
})
})
it('shows empty states when no rounds are completed', async () => {
mockFetchSummary.mockResolvedValue({
...summary,
form: [],
cumulative: [],
round_labels: [],
rounds: [],
})
renderPage({ driverNumber: 1, year: 2025 })
await waitFor(() => {
expect(screen.getByTestId('driver-profile')).toBeInTheDocument()
})
expect(screen.getAllByText('No completed rounds yet.').length).toBe(2)
expect(screen.getByText('No grid-vs-finish data yet.')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import {
Outlet,
RouterProvider,
createRouter,
createRootRoute,
createRoute,
createMemoryHistory,
} from '@tanstack/react-router'
import { Nav } from '../components/Nav'
function renderNav() {
const rootRoute = createRootRoute({
component: () => (
<>
<Nav />
<Outlet />
</>
),
})
const stub = (p: string, id: string) =>
createRoute({ getParentRoute: () => rootRoute, path: p, component: () => <div data-testid={id} /> })
const router = createRouter({
routeTree: rootRoute.addChildren([
stub('/', 'home'),
stub('/championship', 'championship'),
stub('/briefing', 'briefing'),
stub('/explore', 'explore'),
stub('/admin', 'admin'),
]),
history: createMemoryHistory({ initialEntries: ['/'] }),
})
return render(<RouterProvider router={router} />)
}
const DESTINATIONS = ['Weekend', 'Championship', 'Briefing', 'Explore']
describe('Nav — one primary navigation system per breakpoint', () => {
it('exposes exactly two Primary landmarks (one per breakpoint), never more', async () => {
renderNav()
await waitFor(() => expect(screen.getAllByRole('navigation', { name: 'Primary' })).toHaveLength(2))
})
it('every Primary landmark contains all four destinations', async () => {
renderNav()
await waitFor(() => expect(screen.getAllByRole('navigation', { name: 'Primary' }).length).toBe(2))
const primaries = screen.getAllByRole('navigation', { name: 'Primary' })
for (const nav of primaries) {
for (const label of DESTINATIONS) {
expect(within(nav).getByRole('link', { name: new RegExp(`^${label}$`) })).toBeInTheDocument()
}
}
})
it('keeps Admin out of every Primary landmark (operator utility only)', async () => {
renderNav()
await waitFor(() => expect(screen.getAllByRole('navigation', { name: 'Primary' }).length).toBe(2))
const primaries = screen.getAllByRole('navigation', { name: 'Primary' })
for (const nav of primaries) {
expect(within(nav).queryByRole('link', { name: /Admin/i })).not.toBeInTheDocument()
}
// Admin lives in the operator utilities toolbar.
const toolbar = screen.getByRole('toolbar', { name: 'Operator utilities' })
expect(within(toolbar).getByRole('link', { name: /Admin/i })).toBeInTheDocument()
})
})

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react' import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { import {
@@ -9,21 +9,38 @@ import {
createRoute, createRoute,
} from '@tanstack/react-router' } from '@tanstack/react-router'
import { RaceHubPage } from '../pages/RaceHubPage' import { RaceHubPage } from '../pages/RaceHubPage'
import type { DatasetInfo, Meeting, RaceHub, Session, Weekend } from '../types' import type {
DatasetInfo,
Meeting,
RaceHub,
Session,
Weekend,
WeekendContext,
} from '../types'
vi.mock('../api', () => ({ vi.mock('../api', () => ({
fetchRaceHub: vi.fn(), fetchRaceHub: vi.fn(),
fetchSeasons: vi.fn(), fetchSeasons: vi.fn(),
fetchLocalMeetings: vi.fn(), fetchLocalMeetings: vi.fn(),
fetchWeekend: vi.fn(), fetchWeekend: vi.fn(),
fetchWeekendContext: vi.fn(),
})) }))
import { fetchRaceHub, fetchSeasons, fetchLocalMeetings, fetchWeekend } from '../api' import {
fetchRaceHub,
fetchSeasons,
fetchLocalMeetings,
fetchWeekend,
fetchWeekendContext,
} from '../api'
const mockFetchRaceHub = vi.mocked(fetchRaceHub) const mockFetchRaceHub = vi.mocked(fetchRaceHub)
const mockFetchSeasons = vi.mocked(fetchSeasons) const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings) const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
const mockFetchWeekend = vi.mocked(fetchWeekend) const mockFetchWeekend = vi.mocked(fetchWeekend)
const mockFetchWeekendContext = vi.mocked(fetchWeekendContext)
const NOW = new Date('2025-06-01T00:00:00Z')
const meeting: Meeting = { const meeting: Meeting = {
meeting_key: 1229, meeting_key: 1229,
@@ -59,6 +76,16 @@ const qualSession: Session = {
gmt_offset: '02:00:00', gmt_offset: '02:00:00',
} }
const futureSession: Session = {
session_key: 9600,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1300,
date_start: '2099-05-25T13:00:00+00:00',
date_end: '2099-05-25T15:00:00+00:00',
gmt_offset: '02:00:00',
}
const fullDatasets: 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 },
@@ -155,6 +182,7 @@ const raceHub: RaceHub = {
is_pit_out_lap: false, is_pit_out_lap: false,
}, },
], ],
chapters: [],
} }
const weekend: Weekend = { const weekend: Weekend = {
@@ -168,6 +196,41 @@ const weekend: Weekend = {
], ],
} }
const weekendContext: WeekendContext = {
season: 2025,
temporal_state: 'post_weekend',
focus_meeting: meeting,
previous_meeting: meeting,
default_analysis_session: {
session: raceSession,
meeting,
availability: {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'complete',
freshness: 'fresh',
limitations: [],
},
},
previous_completed_session: {
session: raceSession,
meeting,
availability: {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'complete',
freshness: 'fresh',
limitations: [],
},
},
championship_round: 8,
total_championship_rounds: 24,
}
function renderRaceHub(sessionKey: number) { function renderRaceHub(sessionKey: number) {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }, defaultOptions: { queries: { retry: false } },
@@ -180,6 +243,19 @@ function renderRaceHub(sessionKey: number) {
</QueryClientProvider> </QueryClientProvider>
), ),
}) })
const weekendRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
validateSearch: (search: Record<string, unknown>) => {
const meetingKey = Number(search.meeting_key)
const sk = Number(search.session_key)
return {
...(Number.isFinite(meetingKey) && meetingKey > 0 ? { meeting_key: meetingKey } : {}),
...(Number.isFinite(sk) && sk > 0 ? { session_key: sk } : {}),
}
},
component: () => <div data-testid="weekend-stub" />,
})
const raceHubRoute = createRoute({ const raceHubRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/race-hub', path: '/race-hub',
@@ -193,11 +269,10 @@ function renderRaceHub(sessionKey: number) {
}, },
}) })
const router = createRouter({ const router = createRouter({
routeTree: rootRoute.addChildren([raceHubRoute]), routeTree: rootRoute.addChildren([weekendRoute, raceHubRoute]),
history: undefined, history: undefined,
}) })
// Navigate to the URL before mounting
router.navigate({ to: '/race-hub', search: sessionKey ? { session_key: sessionKey } : {} }) router.navigate({ to: '/race-hub', search: sessionKey ? { session_key: sessionKey } : {} })
return render(<RouterProvider router={router} />) return render(<RouterProvider router={router} />)
} }
@@ -205,10 +280,17 @@ function renderRaceHub(sessionKey: number) {
describe('RaceHubPage', () => { describe('RaceHubPage', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.useFakeTimers({ shouldAdvanceTime: true })
vi.setSystemTime(NOW)
mockFetchSeasons.mockResolvedValue([2025]) mockFetchSeasons.mockResolvedValue([2025])
mockFetchLocalMeetings.mockResolvedValue([meeting]) mockFetchLocalMeetings.mockResolvedValue([meeting])
mockFetchWeekend.mockResolvedValue(weekend) mockFetchWeekend.mockResolvedValue(weekend)
mockFetchRaceHub.mockResolvedValue(raceHub) mockFetchRaceHub.mockResolvedValue(raceHub)
mockFetchWeekendContext.mockResolvedValue(weekendContext)
})
afterEach(() => {
vi.useRealTimers()
}) })
it('renders the workspace identity band, session rail, and overview for a known session', async () => { it('renders the workspace identity band, session rail, and overview for a known session', async () => {
@@ -223,9 +305,9 @@ describe('RaceHubPage', () => {
) )
expect(screen.getByTestId('rh-session-9471')).toBeInTheDocument() expect(screen.getByTestId('rh-session-9471')).toBeInTheDocument()
// Overview is default
expect(screen.getByTestId('rh-overview')).toBeInTheDocument() expect(screen.getByTestId('rh-overview')).toBeInTheDocument()
expect(screen.getByText('Winner')).toBeInTheDocument() expect(screen.getByText('Winner')).toBeInTheDocument()
expect(screen.queryByText('Local Coverage')).not.toBeInTheDocument()
}) })
it('exposes Race Story sub-controls for classification, grid, and positions', async () => { it('exposes Race Story sub-controls for classification, grid, and positions', async () => {
@@ -235,17 +317,43 @@ describe('RaceHubPage', () => {
fireEvent.click(screen.getByRole('tab', { name: 'Race Story' })) fireEvent.click(screen.getByRole('tab', { name: 'Race Story' }))
expect(screen.getByText('VER')).toBeInTheDocument() expect(screen.getByText('VER')).toBeInTheDocument()
}) })
it('keeps Data Status accessible and free of inline CLI guidance', async () => { it('keeps Diagnostics accessible behind a secondary action, free of inline CLI guidance', async () => {
renderRaceHub(9472) renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument()) await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
fireEvent.click(screen.getByRole('tab', { name: 'Data Status' })) fireEvent.click(screen.getByRole('tab', { name: 'Diagnostics' }))
expect(screen.getByTestId('rh-data-status')).toBeInTheDocument() expect(screen.getByTestId('rh-data-status')).toBeInTheDocument()
expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument() expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument()
expect(screen.queryByTestId('rh-dataset-strip')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('rh-diagnostics-toggle'))
expect(screen.getByTestId('rh-dataset-strip')).toBeInTheDocument()
})
it('does not render the raw dataset strip before fan-facing content', async () => {
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
expect(screen.getByTestId('rh-overview')).toBeInTheDocument()
expect(screen.queryByTestId('rh-dataset-strip')).not.toBeInTheDocument()
})
it('groups analysis navigation into Story, Analysis, and Data & Context', async () => {
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
expect(screen.getByTestId('rh-tabgroup-story')).toBeInTheDocument()
expect(screen.getByTestId('rh-tabgroup-analysis')).toBeInTheDocument()
expect(screen.getByTestId('rh-tabgroup-context')).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Strategy' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Compare' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Diagnostics' })).toBeInTheDocument()
}) })
it('toggles the inline weekend switcher', async () => { it('toggles the inline weekend switcher', async () => {
@@ -255,4 +363,213 @@ describe('RaceHubPage', () => {
fireEvent.click(screen.getByTestId('rh-switch-weekend')) fireEvent.click(screen.getByTestId('rh-switch-weekend'))
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument() expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
}) })
it('resolves bare /race-hub through canonical Weekend Context default analysis', async () => {
mockFetchWeekendContext.mockResolvedValue({
...weekendContext,
default_analysis_session: {
session: qualSession,
meeting,
availability: {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'complete',
freshness: 'fresh',
limitations: [],
},
},
})
renderRaceHub(0)
await waitFor(() => expect(mockFetchRaceHub).toHaveBeenCalledWith(9471))
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9600)
})
it('shows no-analysis fallback when Weekend Context has no default analysis', async () => {
mockFetchWeekendContext.mockResolvedValue({
...weekendContext,
default_analysis_session: undefined,
previous_completed_session: undefined,
})
renderRaceHub(0)
await waitFor(() => expect(screen.getByTestId('race-hub-no-analysis')).toBeInTheDocument())
expect(mockFetchRaceHub).not.toHaveBeenCalled()
})
it('renders a pre-session view instead of empty analysis for a future session', async () => {
mockFetchRaceHub.mockResolvedValue({
...raceHub,
session_key: 9600,
source: 'none',
session: futureSession,
meeting: { ...meeting, meeting_key: 1300 },
results: [],
starting_grid: [],
datasets: {},
})
mockFetchWeekend.mockResolvedValue({
source: 'none',
meeting_key: 1300,
meeting: { ...meeting, meeting_key: 1300 },
sessions: [{ session: futureSession, source: 'none', datasets: {} }],
})
renderRaceHub(9600)
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
expect(await screen.findByTestId('rh-presession')).toBeInTheDocument()
expect(screen.queryByTestId('rh-overview')).not.toBeInTheDocument()
expect(screen.queryByText('Winner')).not.toBeInTheDocument()
})
it('renders a preparing view for a settling session with no local analysis', async () => {
mockFetchWeekend.mockResolvedValue({
...weekend,
sessions: [
{ session: raceSession, source: 'none', datasets: {} },
],
})
mockFetchWeekendContext.mockResolvedValue({
...weekendContext,
temporal_state: 'session_settling',
default_analysis_session: undefined,
previous_completed_session: {
session: raceSession,
meeting,
availability: {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'pending',
freshness: 'fresh',
limitations: [],
},
},
})
mockFetchRaceHub.mockResolvedValue({
...raceHub,
source: 'none',
results: [],
starting_grid: [],
datasets: {},
})
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('rh-preparing')).toBeInTheDocument())
expect(screen.queryByTestId('rh-overview')).not.toBeInTheDocument()
})
it('renders unavailable distinctly from request errors', async () => {
mockFetchWeekend.mockResolvedValue({
...weekend,
sessions: [{ session: raceSession, source: 'none', datasets: {} }],
})
mockFetchWeekendContext.mockResolvedValue({
...weekendContext,
previous_completed_session: {
session: raceSession,
meeting,
availability: {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'unavailable',
freshness: 'stale',
limitations: ['analysis_blocked'],
},
},
default_analysis_session: {
session: raceSession,
meeting,
availability: {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'unavailable',
freshness: 'stale',
limitations: ['analysis_blocked'],
},
},
})
mockFetchRaceHub.mockResolvedValue({
...raceHub,
source: 'none',
results: [],
datasets: {},
})
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('rh-unavailable')).toBeInTheDocument())
expect(screen.queryByTestId('race-hub-error')).not.toBeInTheDocument()
})
it('labels a completed but partial session as Partial and keeps analysis', async () => {
mockFetchWeekend.mockResolvedValue({
...weekend,
sessions: [
{ session: qualSession, source: 'local', datasets: fullDatasets },
{ session: raceSession, source: 'partial', datasets: { drivers: fullDatasets.drivers } },
],
})
mockFetchWeekendContext.mockResolvedValue({
...weekendContext,
default_analysis_session: {
session: raceSession,
meeting,
availability: {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'partial',
freshness: 'fresh',
limitations: [],
},
},
})
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('rh-active-state')).toBeInTheDocument())
expect(screen.getByTestId('rh-active-state')).toHaveTextContent('Partial')
expect(screen.getByTestId('rh-partial-banner')).toBeInTheDocument()
expect(screen.getByTestId('rh-overview')).toBeInTheDocument()
})
it('offers retry and back-to-Weekend on an error', async () => {
mockFetchRaceHub.mockRejectedValue(new Error('boom'))
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('race-hub-error')).toBeInTheDocument())
expect(screen.getByTestId('rh-retry')).toBeInTheDocument()
const back = screen.getByTestId('rh-back-weekend')
expect(back).toHaveAttribute('href', expect.stringMatching(/meeting_key=1229/))
expect(back).toHaveAttribute('href', expect.stringMatching(/session_key=9472/))
expect(back.getAttribute('href')).toMatch(/^\//)
mockFetchRaceHub.mockResolvedValue(raceHub)
fireEvent.click(screen.getByTestId('rh-retry'))
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
})
it('exposes a Back to Weekend link that carries meeting and session context', async () => {
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
const back = screen.getByRole('link', { name: 'Back to Weekend' })
expect(back).toHaveAttribute('data-testid', 'rh-back-weekend')
expect(back).toHaveAttribute('href', expect.stringMatching(/meeting_key=1229/))
expect(back).toHaveAttribute('href', expect.stringMatching(/session_key=9472/))
})
}) })

View File

@@ -0,0 +1,277 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
import { RacePreviewPage } from '../pages/RacePreviewPage'
import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session, TrackOutline } from '../types'
vi.mock('../api', () => ({
fetchSeasons: vi.fn(),
fetchMeetings: vi.fn(),
fetchSessions: vi.fn(),
fetchResults: vi.fn(),
fetchStartingGrid: vi.fn(),
fetchTrackOutline: vi.fn(),
fetchChampionshipHub: vi.fn(),
}))
import {
fetchSeasons,
fetchMeetings,
fetchSessions,
fetchResults,
fetchStartingGrid,
fetchTrackOutline,
fetchChampionshipHub,
} from '../api'
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchMeetings = vi.mocked(fetchMeetings)
const mockFetchSessions = vi.mocked(fetchSessions)
const mockFetchResults = vi.mocked(fetchResults)
const mockFetchStartingGrid = vi.mocked(fetchStartingGrid)
const mockFetchTrackOutline = vi.mocked(fetchTrackOutline)
const mockFetchChampionshipHub = vi.mocked(fetchChampionshipHub)
const upcomingMeeting: Meeting = {
meeting_key: 100,
meeting_name: 'Monaco',
meeting_official_name: 'Monaco GP',
location: 'Monaco',
country_name: 'Monaco',
country_code: 'MON',
country_flag: '',
circuit_key: 10,
circuit_short_name: 'Monaco',
date_start: '2099-05-22T00:00:00+00:00',
date_end: '2099-05-24T23:59:59+00:00',
year: 2099,
}
const priorMeeting: Meeting = {
...upcomingMeeting,
meeting_key: 90,
year: 2098,
date_start: '2098-05-22T00:00:00+00:00',
date_end: '2098-05-24T23:59:59+00:00',
}
const sessions: Session[] = [
{
session_key: 1,
session_name: 'FP1',
session_type: 'Practice',
meeting_key: 100,
date_start: '2099-05-22T10:00:00+00:00',
date_end: '2099-05-22T11:00:00+00:00',
gmt_offset: '02:00:00',
},
{
session_key: 2,
session_name: 'Sprint',
session_type: 'Sprint',
meeting_key: 100,
date_start: '2099-05-23T10:00:00+00:00',
date_end: '2099-05-23T11:00:00+00:00',
gmt_offset: '02:00:00',
},
{
session_key: 3,
session_name: 'Race',
session_type: 'Race',
meeting_key: 100,
date_start: '2099-05-24T13:00:00+00:00',
date_end: '2099-05-24T15:00:00+00:00',
gmt_offset: '02:00:00',
},
]
const priorRaceSession: Session = {
session_key: 50,
session_name: 'Race',
session_type: 'Race',
meeting_key: 90,
date_start: '2098-05-24T13:00:00+00:00',
date_end: '2098-05-24T15:00:00+00:00',
gmt_offset: '02:00:00',
}
const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
points: 200,
position: 1,
wins: 5,
podiums: 8,
poles: 4,
form: [25],
cumulative: [200],
round_positions: [1],
teammate_wins: 9,
teammate_losses: 1,
...over,
})
const hub: ChampionshipHub = {
season: 2099,
round: 5,
total_rounds: 24,
rounds_left: 19,
last_race: 'Monaco GP',
round_labels: ['R1'],
drivers: [
hubDriver({}),
hubDriver({ driver_number: 4, name_acronym: 'NOR', points: 160, position: 2 }),
hubDriver({ driver_number: 16, name_acronym: 'LEC', points: 120, position: 3 }),
],
teams: [],
}
const results: EnrichedResult[] = [
{
driver_number: 1,
position: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
dnf: false,
dns: false,
dsq: false,
duration: 100,
gap_to_leader: 0,
number_of_laps: 78,
points: 25,
session_key: 50,
meeting_key: 90,
},
]
const grid: EnrichedGrid[] = [
{
driver_number: 4,
position: 1,
name_acronym: 'NOR',
full_name: 'Lando Norris',
team_name: 'McLaren',
team_colour: 'ff8000',
session_key: 50,
meeting_key: 90,
lap_duration: 70,
},
]
const outline: TrackOutline = {
circuit_key: 10,
points: [
{ x: 0.1, y: 0.2 },
{ x: 0.5, y: 0.5 },
{ x: 0.9, y: 0.8 },
],
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
}
function renderPage() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const rootRoute = createRootRoute({
component: () => (
<QueryClientProvider client={queryClient}>
<RacePreviewPage />
</QueryClientProvider>
),
})
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: RacePreviewPage })
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
return render(<RouterProvider router={router} />)
}
describe('RacePreviewPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchSeasons.mockResolvedValue([2099])
mockFetchChampionshipHub.mockResolvedValue(hub)
mockFetchTrackOutline.mockResolvedValue(outline)
mockFetchResults.mockResolvedValue(results)
mockFetchStartingGrid.mockResolvedValue(grid)
})
it('renders upcoming race preview with sections', async () => {
mockFetchMeetings.mockImplementation(async (year: number) => {
if (year === 2099) return [upcomingMeeting]
if (year === 2098) return [priorMeeting]
return []
})
mockFetchSessions.mockImplementation(async (meetingKey: number) => {
if (meetingKey === 100) return sessions
if (meetingKey === 90) return [priorRaceSession]
return []
})
renderPage()
await waitFor(() => {
expect(screen.getByTestId('preview-page')).toBeInTheDocument()
})
await waitFor(() => {
expect(screen.getByTestId('preview-countdown')).toBeInTheDocument()
})
expect(screen.getByTestId('preview-header')).toHaveTextContent('Monaco')
expect(screen.getByTestId('preview-schedule')).toHaveTextContent('FP1')
expect(screen.getByTestId('preview-track-card')).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('VER')
})
expect(screen.getByTestId('preview-title-fight-card')).toHaveTextContent('NOR')
expect(screen.getByTestId('preview-sprint-note')).toBeInTheDocument()
})
it('renders season-over state when no upcoming meeting', async () => {
mockFetchMeetings.mockResolvedValue([
{
...upcomingMeeting,
date_start: '2020-05-22T00:00:00+00:00',
date_end: '2020-05-24T23:59:59+00:00',
},
])
renderPage()
await waitFor(() => {
expect(screen.getByTestId('preview-season-over')).toBeInTheDocument()
})
expect(screen.getByText('Season complete')).toBeInTheDocument()
expect(screen.getByTestId('preview-title-fight-card')).toBeInTheDocument()
expect(screen.queryByTestId('preview-header')).not.toBeInTheDocument()
})
it('shows first-time circuit empty state', async () => {
const newCircuit: Meeting = {
...upcomingMeeting,
meeting_key: 200,
circuit_key: 999,
meeting_name: 'New GP',
}
mockFetchMeetings.mockImplementation(async (year: number) => {
if (year === 2099) return [newCircuit]
if (year === 2098) return [priorMeeting]
return []
})
mockFetchSessions.mockResolvedValue(sessions)
mockFetchTrackOutline.mockResolvedValue(null)
renderPage()
await waitFor(() => {
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('First time on the calendar')
})
})
})

View File

@@ -0,0 +1,291 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
import type { Chapter, RaceHub, ReplayFramesResponse, TrackOutline } from '../types'
vi.mock('../api', () => ({
fetchReplayFrames: vi.fn(),
fetchTrackOutline: vi.fn(),
}))
import { fetchReplayFrames, fetchTrackOutline } from '../api'
const mockFetchReplayFrames = vi.mocked(fetchReplayFrames)
const mockFetchTrackOutline = vi.mocked(fetchTrackOutline)
const outline: TrackOutline = {
circuit_key: 1,
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
points: [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: 1 },
],
}
const replay: ReplayFramesResponse = {
session_key: 99,
interval_ms: 5000,
start_time: '2025-05-25T13:00:00Z',
frames: [
{ t: 0, cars: { '1': { x: 10, y: 20 } } },
{ t: 5000, cars: { '1': { x: 50, y: 50 } } },
],
}
const chapters: Chapter[] = [
{
kind: 'start',
title: 'Start',
headline: 'Lights out',
start_lap: 1,
end_lap: 1,
start_time: '2025-05-25T13:00:00Z',
end_time: '2025-05-25T13:01:00Z',
driver_numbers: [],
},
{
kind: 'safety_car',
title: 'Safety Car',
headline: 'Incident brings out the Safety Car',
start_lap: 2,
end_lap: 2,
start_time: '2025-05-25T13:05:00Z',
end_time: '2025-05-25T13:05:00Z',
driver_numbers: [],
},
]
const raceHub: RaceHub = {
source: 'local',
session_key: 99,
datasets: {
positions: { status: 'available', source: 'local', count: 2 },
},
meeting: {
meeting_key: 1,
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_key: 1,
circuit_short_name: 'Monaco',
date_start: '2025-05-23T00:00:00Z',
date_end: '2025-05-25T00:00:00Z',
year: 2025,
},
session: {
session_key: 99,
session_name: 'Race',
session_type: 'Race',
circuit_key: 1,
meeting_key: 1,
date_start: '2025-05-25T13:00:00Z',
date_end: '2025-05-25T15:00:00Z',
gmt_offset: '02:00:00',
},
drivers: [],
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: null,
gap_to_leader: null,
number_of_laps: 78,
points: 25,
session_key: 99,
meeting_key: 1,
},
],
starting_grid: [
{
driver_number: 1,
position: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull Racing',
team_colour: '3671C6',
session_key: 99,
meeting_key: 1,
lap_duration: null,
},
],
stints: [],
pit_stops: [],
positions: [
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:00:00Z', position: 1 },
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:05:00Z', position: 1 },
],
race_control: [],
weather: [],
laps: [],
chapters,
}
function renderCanvas(overrides: Partial<RaceHub> = {}) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={queryClient}>
<RaceStoryCanvas data={{ ...raceHub, ...overrides }} />
</QueryClientProvider>,
)
}
describe('RaceStoryCanvas replay map', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchReplayFrames.mockResolvedValue(replay)
mockFetchTrackOutline.mockResolvedValue(outline)
})
it('probes replay frames on mount and opens the map when data is available', async () => {
renderCanvas()
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalledWith(99, 5000))
expect(mockFetchTrackOutline).toHaveBeenCalledWith(1, 2025)
const mapToggle = await screen.findByTestId('replay-map-toggle')
fireEvent.click(mapToggle)
expect(await screen.findByTestId('replay-track-map')).toBeInTheDocument()
expect(screen.getByTestId('replay-map-slot')).toBeInTheDocument()
})
it('hides the map toggle when replay frames are unavailable', async () => {
mockFetchReplayFrames.mockResolvedValue({ ...replay, frames: [] })
renderCanvas()
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalled())
await waitFor(() => expect(screen.queryByTestId('replay-map-toggle')).not.toBeInTheDocument())
})
it('uses full-width chart layout when the map is closed', async () => {
renderCanvas()
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalled())
const chart = screen.getByTestId('position-chart')
expect(chart).toHaveClass('rs-chart-container--full')
expect(screen.queryByTestId('replay-map-slot')).not.toBeInTheDocument()
expect(document.querySelector('.rs-replay-shell--split')).not.toBeInTheDocument()
})
it('uses lap timing rather than pre-race position samples for the chart scale', () => {
const laps = Array.from({ length: 20 }, (_, index) => ({
session_key: 99,
driver_number: 1,
meeting_key: 1,
lap_number: index + 1,
date_start: `2025-05-25T13:${String(index).padStart(2, '0')}:00Z`,
lap_duration: 60,
is_pit_out_lap: false,
}))
renderCanvas({
results: [{ ...raceHub.results[0], number_of_laps: 20 }],
laps,
positions: [
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T12:00:00Z', position: 1 },
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:20:00Z', position: 1 },
],
})
// L10 is the end of the tenth 60s lap, exactly halfway through the race.
const lapTenLabel = screen.getByText('L10')
expect(lapTenLabel.previousElementSibling).toHaveAttribute('x1', '316')
// The pre-race position is retained as the starting grid at the left edge.
expect(document.querySelector('.rs-driver-line')).toHaveAttribute('points', '40,8 592,8 592,8')
})
it('maps cursor positions from rendered pixels to the SVG viewBox', () => {
renderCanvas()
const svg = document.querySelector('.rs-position-chart-svg')!
vi.spyOn(svg, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
width: 1280,
height: 360,
top: 0,
right: 1280,
bottom: 360,
left: 0,
toJSON: () => ({}),
})
const pointerMove = new Event('pointermove', { bubbles: true })
Object.defineProperty(pointerMove, 'clientX', { value: 640 })
fireEvent(screen.getByTestId('position-chart-interaction'), pointerMove)
// 640px is the middle of a 1280px rendered chart, which is x=320 in its 640-unit viewBox.
expect(document.querySelector('.rs-playhead')).toHaveAttribute('x1', '320')
})
it('syncs active chapter highlight when a chapter card is clicked', async () => {
renderCanvas()
fireEvent.click(screen.getByTestId('chapter-card-1'))
await waitFor(() => expect(screen.getByTestId('chapter-card-1')).toHaveClass('active'))
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
})
it('highlights out-of-window chapters after click (clamped scrub + selection)', async () => {
// Position samples start at 13:05; start chapter ends at 13:01 — outside the window.
renderCanvas({
positions: [
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:05:00Z', position: 1 },
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:10:00Z', position: 1 },
],
chapters: [
{
kind: 'start',
title: 'Start',
headline: 'Lights out before samples',
start_lap: 1,
end_lap: 1,
start_time: '2025-05-25T13:00:00Z',
end_time: '2025-05-25T13:01:00Z',
driver_numbers: [],
},
{
kind: 'finish',
title: 'Finish',
headline: 'Flag after samples',
start_lap: 78,
end_lap: 78,
start_time: '2025-05-25T13:20:00Z',
end_time: '2025-05-25T13:21:00Z',
driver_numbers: [],
},
],
})
fireEvent.click(screen.getByTestId('chapter-card-0'))
await waitFor(() => expect(screen.getByTestId('chapter-card-0')).toHaveClass('active'))
expect(screen.getByTestId('chapter-card-1')).not.toHaveClass('active')
fireEvent.click(screen.getByTestId('chapter-card-1'))
await waitFor(() => expect(screen.getByTestId('chapter-card-1')).toHaveClass('active'))
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
})
it('renders the empty-state card when positions are unavailable', () => {
renderCanvas({
datasets: { positions: { status: 'missing', source: 'local', count: 0 } },
positions: [],
chapters: [],
})
expect(screen.getByTestId('race-story-no-positions')).toBeInTheDocument()
expect(screen.getByText('Lap-by-lap positions not available')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ReplayTrackMap } from '../components/ReplayTrackMap'
import type { EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types'
const outline: TrackOutline = {
circuit_key: 1,
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
points: [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: 1 },
],
}
const replay: ReplayFramesResponse = {
session_key: 99,
interval_ms: 5000,
start_time: '2025-05-25T13:00:00Z',
frames: [
{ t: 0, cars: { '1': { x: 0, y: 0 } } },
{ t: 5000, cars: { '1': { x: 50, y: 50 } } },
],
}
const results: EnrichedResult[] = [
{
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: null,
gap_to_leader: null,
number_of_laps: 78,
points: 25,
session_key: 99,
meeting_key: 1,
},
]
describe('ReplayTrackMap', () => {
it('renders an empty-state card when replay frames are missing', () => {
render(<ReplayTrackMap outline={outline} replay={{ ...replay, frames: [] }} tMs={0} drivers={[]} results={results} />)
expect(screen.getByTestId('replay-map-no-frames')).toBeInTheDocument()
expect(screen.getByText('Historical GPS unavailable')).toBeInTheDocument()
})
it('renders car labels from result metadata', () => {
render(<ReplayTrackMap outline={outline} replay={replay} tMs={2500} drivers={[]} results={results} />)
expect(screen.getByLabelText(/VER replay position/i)).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,114 @@
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { RivalryCompare } from '../components/RivalryCompare'
import type { ChampHubDriver, ChampionshipHub } from '../types'
function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
return {
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
points: 100,
position: 1,
wins: 2,
podiums: 3,
poles: 1,
form: [25, 18, 25],
cumulative: [25, 43, 68],
round_positions: [1, 2, 1],
teammate_wins: 3,
teammate_losses: 0,
...over,
}
}
function makeHub(drivers: ChampHubDriver[]): ChampionshipHub {
return {
season: 2025,
round: 3,
total_rounds: 10,
rounds_left: 7,
last_race: 'Japan GP',
round_labels: ['R1', 'R2', 'R3'],
drivers,
teams: [],
}
}
const hub = makeHub([
driver({}),
driver({
driver_number: 4,
name_acronym: 'NOR',
full_name: 'Lando Norris',
team_name: 'McLaren',
team_colour: 'ff8000',
points: 90,
position: 2,
cumulative: [18, 43, 61],
round_positions: [2, 1, 2],
}),
driver({
driver_number: 16,
name_acronym: 'LEC',
full_name: 'Charles Leclerc',
team_name: 'Ferrari',
team_colour: 'e8002d',
points: 50,
position: 3,
cumulative: [15, 28, 40],
round_positions: [3, 0, 3],
}),
])
describe('RivalryCompare', () => {
it('defaults to the top two drivers and shows the H2H score', () => {
render(<RivalryCompare hub={hub} />)
expect(screen.getByTestId('champ-view-rivalry')).toBeInTheDocument()
expect(screen.getByTestId('rivalry-pick-a')).toHaveValue('1')
expect(screen.getByTestId('rivalry-pick-b')).toHaveValue('4')
// VER wins R1 and R3, NOR wins R2.
expect(screen.getByTestId('rivalry-h2h-num')).toHaveTextContent('21')
expect(screen.getByText('3 rounds counted', { exact: false })).toBeInTheDocument()
expect(screen.getByTestId('rivalry-points-race')).toBeInTheDocument()
expect(screen.getByTestId('rivalry-gap')).toBeInTheDocument()
// Last gap: 68 61 = +7 → VER ahead.
expect(screen.getByText('VER leads by 7 pts', { exact: false })).toBeInTheDocument()
})
it('recomputes when a different driver is picked and skips missing rounds', () => {
render(<RivalryCompare hub={hub} />)
fireEvent.change(screen.getByTestId('rivalry-pick-b'), { target: { value: '16' } })
// VER vs LEC: R2 skipped (LEC has no position), VER wins R1 and R3.
expect(screen.getByTestId('rivalry-h2h-num')).toHaveTextContent('20')
expect(screen.getByText('2 rounds counted · 1 skipped', { exact: false })).toBeInTheDocument()
})
it('shows an empty state with fewer than two drivers', () => {
render(<RivalryCompare hub={makeHub([driver({})])} />)
expect(screen.getByTestId('champ-view-rivalry')).toHaveTextContent('at least two drivers')
})
it('shows an empty message when no rounds are completed', () => {
const empty = makeHub([
driver({ cumulative: [], round_positions: [], form: [] }),
driver({
driver_number: 4,
name_acronym: 'NOR',
cumulative: [],
round_positions: [],
form: [],
}),
])
empty.round = 0
empty.round_labels = []
render(<RivalryCompare hub={empty} />)
expect(screen.getByTestId('champ-view-rivalry')).toHaveTextContent('No completed rounds yet')
})
})

View File

@@ -3,7 +3,7 @@ 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 workspace tabs', () => { it('renders all Race Hub workspace tabs grouped into a hierarchy', () => {
render(<TabBar active="overview" onChange={() => {}} />) render(<TabBar active="overview" onChange={() => {}} />)
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Race Story' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Race Story' })).toBeInTheDocument()
@@ -12,7 +12,11 @@ describe('TabBar', () => {
expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Conditions' })).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: 'Data Status' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Diagnostics' })).toBeInTheDocument()
expect(screen.getByTestId('rh-tabgroup-story')).toBeInTheDocument()
expect(screen.getByTestId('rh-tabgroup-analysis')).toBeInTheDocument()
expect(screen.getByTestId('rh-tabgroup-context')).toBeInTheDocument()
}) })
it('marks the active tab with aria-selected', () => { it('marks the active tab with aria-selected', () => {

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import { TeammateH2H } from '../components/TeammateH2H'
describe('TeammateH2H', () => {
it('renders score and TLAs', () => {
render(
<TeammateH2H
teamName="Red Bull"
teamColour="3671c6"
driverATla="VER"
driverBTla="PER"
winsA={9}
winsB={1}
/>,
)
expect(screen.getByText('VER')).toBeInTheDocument()
expect(screen.getByText('PER')).toBeInTheDocument()
expect(screen.getByTestId('h2h-score')).toHaveTextContent('91')
expect(screen.getByText('Red Bull')).toBeInTheDocument()
})
it('sets proportional bar segment widths from wins', () => {
render(
<TeammateH2H
teamName="McLaren"
teamColour="ff8000"
driverATla="NOR"
driverBTla="PIA"
winsA={6}
winsB={4}
/>,
)
const barA = screen.getByTestId('h2h-bar-a')
const barB = screen.getByTestId('h2h-bar-b')
expect(barA).toHaveStyle({ width: '60%' })
expect(barB).toHaveStyle({ width: '40%' })
})
it('shows extra driver note when provided', () => {
render(
<TeammateH2H
teamName="Red Bull"
teamColour="3671c6"
driverATla="VER"
driverBTla="PER"
winsA={5}
winsB={3}
extraNote="+1"
/>,
)
expect(screen.getByText('+1')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,357 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
Outlet,
RouterProvider,
createRouter,
createRootRoute,
createRoute,
createMemoryHistory,
} from '@tanstack/react-router'
import { WeekendPage } from '../pages/WeekendPage'
import type {
ChampionshipHub,
ContextAvailability,
ContextSession,
Meeting,
RaceHub,
Session,
TemporalState,
WeekendContext,
} from '../types'
import { parseWeekendSearch } from '../lib/routeSearch'
vi.mock('../api', () => ({
fetchWeekendContext: vi.fn(),
fetchChampionshipHub: vi.fn(),
fetchNews: vi.fn(),
fetchRaceHub: vi.fn(),
fetchWeekend: vi.fn(),
// Consumed transitively by RacePreviewPage (folded into PreSessionView):
fetchSeasons: vi.fn(),
fetchMeetings: vi.fn(),
fetchSessions: vi.fn(),
fetchResults: vi.fn(),
fetchStartingGrid: vi.fn(),
fetchTrackOutline: vi.fn(),
}))
import {
fetchWeekendContext,
fetchChampionshipHub,
fetchNews,
fetchRaceHub,
fetchWeekend,
fetchSeasons,
fetchMeetings,
fetchSessions,
fetchResults,
fetchStartingGrid,
fetchTrackOutline,
} from '../api'
const mockContext = vi.mocked(fetchWeekendContext)
const mockHub = vi.mocked(fetchChampionshipHub)
const mockNews = vi.mocked(fetchNews)
const mockRaceHub = vi.mocked(fetchRaceHub)
const mockWeekend = vi.mocked(fetchWeekend)
const mockSeasons = vi.mocked(fetchSeasons)
const mockMeetings = vi.mocked(fetchMeetings)
const mockSessions = vi.mocked(fetchSessions)
const mockResults = vi.mocked(fetchResults)
const mockGrid = vi.mocked(fetchStartingGrid)
const mockTrack = vi.mocked(fetchTrackOutline)
function availability(overrides: Partial<ContextAvailability> = {}): ContextAvailability {
return {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'complete',
freshness: 'fresh',
limitations: [],
...overrides,
}
}
function meeting(overrides: Partial<Meeting> = {}): Meeting {
return {
meeting_key: 1,
meeting_name: 'British Grand Prix',
meeting_official_name: 'FORMULA 1 BRITISH GRAND PRIX',
location: 'Silverstone',
country_name: 'United Kingdom',
country_code: 'GBR',
country_flag: '',
circuit_key: 2,
circuit_short_name: 'Silverstone',
date_start: '2026-07-03T09:00:00Z',
date_end: '2026-07-05T16:00:00Z',
year: 2026,
...overrides,
}
}
function session(overrides: Partial<Session> = {}): Session {
return {
session_key: 11,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1,
date_start: '2026-07-05T14:00:00Z',
date_end: '2026-07-05T16:00:00Z',
gmt_offset: '',
...overrides,
}
}
function ctxSession(overrides: Partial<ContextSession> = {}): ContextSession {
return {
session: session(),
meeting: meeting(),
availability: availability(),
...overrides,
}
}
function context(overrides: Partial<WeekendContext> = {}): WeekendContext {
return {
season: 2026,
temporal_state: 'between_weekends',
championship_round: 5,
total_championship_rounds: 24,
...overrides,
}
}
const hub: ChampionshipHub = {
season: 2026,
round: 5,
total_rounds: 24,
rounds_left: 19,
last_race: 'British GP',
round_labels: [],
drivers: [
{ driver_number: 1, name_acronym: 'VER', full_name: 'Max', team_name: 'RB', team_colour: '3671c6', points: 120, position: 1, wins: 4, podiums: 5, poles: 3, form: [], cumulative: [95, 120], teammate_wins: 0, teammate_losses: 0, round_positions: [] },
],
teams: [],
}
const raceHub = {
session_key: 11,
results: [
{ driver_number: 1, position: 1, name_acronym: 'VER', full_name: 'Max', team_name: 'RB', team_colour: '3671c6', dnf: false, dns: false, dsq: false, duration: 5400, gap_to_leader: null },
],
} as unknown as RaceHub
function renderAt(path: string) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const rootRoute = createRootRoute({
component: () => (
<QueryClientProvider client={queryClient}>
<Outlet />
</QueryClientProvider>
),
})
const homeRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
validateSearch: (s: Record<string, unknown>) => parseWeekendSearch(s),
component: function HomeRoute() {
const { meeting_key, session_key } = homeRoute.useSearch()
return <WeekendPage focusMeetingKey={meeting_key} focusSessionKey={session_key} />
},
})
const previewRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/preview',
component: () => <WeekendPage preview />,
})
const raceHubRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/race-hub',
validateSearch: (s: Record<string, unknown>) => {
const sk = Number(s.session_key)
return Number.isFinite(sk) && sk > 0 ? { session_key: sk } : {}
},
component: () => <div data-testid="race-hub-stub" />,
})
const stub = (p: string, id: string) =>
createRoute({ getParentRoute: () => rootRoute, path: p, component: () => <div data-testid={id} /> })
const router = createRouter({
routeTree: rootRoute.addChildren([
homeRoute,
previewRoute,
raceHubRoute,
stub('/live', 'live-stub'),
stub('/explore', 'explore-stub'),
stub('/championship', 'championship-stub'),
stub('/briefing', 'briefing-stub'),
]),
history: createMemoryHistory({ initialEntries: [path] }),
})
return render(<RouterProvider router={router} />)
}
describe('WeekendPage canonical contract rendering', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.setSystemTime(new Date('2026-07-08T12:00:00Z'))
mockHub.mockResolvedValue(hub)
mockNews.mockResolvedValue([])
mockRaceHub.mockResolvedValue(raceHub)
mockWeekend.mockResolvedValue({
source: 'local',
meeting_key: 1,
meeting: meeting(),
sessions: [
{
session: session({ session_key: 11, session_name: 'Race' }),
source: 'local',
datasets: {},
},
],
default_session_key: 11,
})
mockSeasons.mockResolvedValue([2026])
mockMeetings.mockResolvedValue([meeting()])
mockSessions.mockResolvedValue([session()])
mockResults.mockResolvedValue([])
mockGrid.mockResolvedValue([])
mockTrack.mockResolvedValue(null)
})
afterEach(() => {
vi.useRealTimers()
})
it('shows the loading surface before the canonical context resolves', async () => {
mockContext.mockReturnValue(new Promise(() => {}))
renderAt('/')
await waitFor(() => expect(screen.getByTestId('weekend-loading')).toBeInTheDocument())
})
it('shows an explicit error surface when the canonical endpoint fails', async () => {
mockContext.mockRejectedValue(new Error('API 500: boom'))
renderAt('/')
await waitFor(() => expect(screen.getByTestId('weekend-error')).toBeInTheDocument())
expect(screen.getByTestId('weekend-error')).toHaveTextContent('boom')
})
const stateCases: Array<[TemporalState, string]> = [
['between_weekends', 'weekend-between-races'],
['post_weekend', 'weekend-between-races'],
['season_complete', 'weekend-between-races'],
['pre_session', 'weekend-pre-session'],
['between_sessions', 'weekend-between-sessions'],
['session_settling', 'weekend-between-sessions'],
['session_live', 'weekend-live'],
]
it.each(stateCases)('renders a designed surface for canonical temporal_state %s', async (temporal, testid) => {
mockContext.mockResolvedValue(
context({
temporal_state: temporal,
previous_completed_session: ctxSession({ session: session({ session_key: 11, session_name: 'Race' }) }),
default_analysis_session: ctxSession({ session: session({ session_key: 11 }) }),
next_meeting: meeting({ meeting_key: 2, meeting_name: 'Hungarian Grand Prix', date_start: '2026-07-24T09:00:00Z' }),
next_session: ctxSession({ session: session({ session_key: 21, session_name: 'Practice 1', date_start: '2026-07-24T09:00:00Z' }) }),
active_session: temporal === 'session_live'
? ctxSession({ session: session({ session_key: 12, session_name: 'Race' }), availability: availability({ live_session: 'active' }) })
: undefined,
focus_meeting: meeting(),
}),
)
renderAt('/')
await waitFor(() => expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-temporal-state', temporal))
expect(screen.getByTestId(testid)).toBeInTheDocument()
})
it('renders the limited surface for no_season and never falls through to it for a valid payload', async () => {
mockContext.mockResolvedValue(context({ temporal_state: 'no_season', championship_round: 0, total_championship_rounds: 0 }))
renderAt('/')
await waitFor(() => expect(screen.getByTestId('weekend-limited')).toBeInTheDocument())
})
it('between-races pairs the completed analysis CTA with the next-event countdown and championship impact', async () => {
mockContext.mockResolvedValue(
context({
previous_completed_session: ctxSession({ session: session({ session_key: 11, session_name: 'Race' }) }),
default_analysis_session: ctxSession({ session: session({ session_key: 11 }) }),
next_meeting: meeting({ meeting_key: 2, meeting_name: 'Hungarian Grand Prix', date_start: '2026-07-24T09:00:00Z' }),
next_session: ctxSession({ session: session({ session_key: 21, session_name: 'Practice 1', date_start: '2026-07-24T09:00:00Z' }) }),
}),
)
renderAt('/')
await waitFor(() => expect(screen.getByTestId('wk-last-event')).toBeInTheDocument())
expect(screen.getByTestId('wk-next-event')).toBeInTheDocument()
const story = screen.getByTestId('wk-explore-race-story')
expect(story).toHaveAttribute('href', expect.stringContaining('session_key=11'))
expect(screen.getByTestId('wk-prepare')).toHaveAttribute('href', '/preview')
await waitFor(() => expect(screen.getByTestId('wk-champ-impact')).toBeInTheDocument())
expect(screen.getByTestId('wk-season-nav')).toHaveTextContent('Round 5 of 24')
})
it('does not fan out to season/meeting/OpenF1/live queries when the canonical context succeeds', async () => {
mockContext.mockResolvedValue(context({ temporal_state: 'between_weekends' }))
renderAt('/')
await waitFor(() => expect(screen.getByTestId('weekend-between-races')).toBeInTheDocument())
// Only supplementary championship + news reads are allowed; no season /
// meetings / sessions / live fan-out from the Weekend home hook.
expect(mockSeasons).not.toHaveBeenCalled()
expect(mockSessions).not.toHaveBeenCalled()
// fetchMeetings may still be reached only through the folded preview surface,
// which is not mounted in the between-races state.
expect(mockMeetings).not.toHaveBeenCalled()
})
it('supplementary reads stay dormant while the canonical context is pending', async () => {
mockContext.mockReturnValue(new Promise(() => {}))
renderAt('/')
await waitFor(() => expect(screen.getByTestId('weekend-loading')).toBeInTheDocument())
expect(mockHub).not.toHaveBeenCalled()
expect(mockNews).not.toHaveBeenCalled()
})
it('the /preview alias renders the preparation surface instead of looping back', async () => {
mockContext.mockResolvedValue(
context({
temporal_state: 'between_weekends',
next_meeting: meeting({ meeting_key: 2, meeting_name: 'Hungarian Grand Prix', date_start: '2026-07-24T09:00:00Z' }),
next_session: ctxSession({ session: session({ session_key: 21, session_name: 'Practice 1', date_start: '2026-07-24T09:00:00Z' }) }),
}),
)
renderAt('/preview')
await waitFor(() => expect(screen.getByTestId('weekend-pre-session')).toBeInTheDocument())
expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-preview', 'true')
// The between-races surface must NOT be what /preview renders.
expect(screen.queryByTestId('weekend-between-races')).not.toBeInTheDocument()
})
it('restores Race Hub meeting/session focus from the Weekend URL search contract', async () => {
mockContext.mockResolvedValue(
context({
temporal_state: 'season_complete',
previous_completed_session: ctxSession({ session: session({ session_key: 11, session_name: 'Race' }) }),
default_analysis_session: ctxSession({ session: session({ session_key: 11 }) }),
}),
)
renderAt('/?meeting_key=1&session_key=11')
await waitFor(() => expect(screen.getByTestId('wk-focus-context')).toBeInTheDocument())
expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-meeting-key', '1')
expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-session-key', '11')
await waitFor(() =>
expect(screen.getByTestId('wk-focus-meeting')).toHaveTextContent('British Grand Prix'),
)
await waitFor(() =>
expect(screen.getByTestId('wk-focus-session')).toHaveTextContent('Race'),
)
const continueAnalysis = screen.getByRole('link', { name: /Continue analysis/i })
expect(continueAnalysis).toHaveAttribute('href', expect.stringContaining('session_key=11'))
expect(mockWeekend).toHaveBeenCalledWith(1)
})
})

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest'
import {
activeChapterIndex,
chapterBandFill,
chapterKindLabel,
chapterLapRange,
chapterStartScrub,
chapterTourDurations,
deCollideYPositions,
decimatedPositionLabels,
} from '../lib/chapters'
import type { Chapter } from '../types'
const sampleChapters: Chapter[] = [
{
kind: 'start',
title: 'Start',
headline: 'Lights out — the field charges into Turn 1',
start_lap: 1,
end_lap: 1,
start_time: '2025-05-25T13:00:00Z',
end_time: '2025-05-25T13:01:00Z',
driver_numbers: [],
},
{
kind: 'safety_car',
title: 'Safety Car (L12-L15)',
headline: 'Sainz incident brings out the Safety Car — leaders dive for the pits',
start_lap: 12,
end_lap: 15,
start_time: '2025-05-25T13:12:00Z',
end_time: '2025-05-25T13:15:00Z',
driver_numbers: [55],
},
]
describe('chapters lib', () => {
it('labels chapter kinds', () => {
expect(chapterKindLabel('safety_car')).toBe('SC')
expect(chapterKindLabel('finish')).toBe('FIN')
})
it('formats lap ranges', () => {
expect(chapterLapRange(sampleChapters[0])).toBe('L1')
expect(chapterLapRange(sampleChapters[1])).toBe('L12L15')
})
it('maps chapter start time to scrub position', () => {
const tMin = new Date('2025-05-25T13:00:00Z').getTime()
const tMax = new Date('2025-05-25T13:20:00Z').getTime()
const scrub = chapterStartScrub(sampleChapters[1], tMin, tMax - tMin)
expect(scrub).toBeCloseTo(0.6, 2)
})
it('finds active chapter from scrub time', () => {
const tMin = new Date('2025-05-25T13:00:00Z').getTime()
const tMax = new Date('2025-05-25T13:20:00Z').getTime()
const tRange = tMax - tMin
const scrub = (new Date('2025-05-25T13:13:00Z').getTime() - tMin) / tRange
expect(activeChapterIndex(sampleChapters, scrub, tMin, tRange)).toBe(1)
})
it('activates chapters whose timestamps clamp outside the position window', () => {
// Position samples only cover 13:0513:10; chapters sit before/after that window.
const tMin = new Date('2025-05-25T13:05:00Z').getTime()
const tMax = new Date('2025-05-25T13:10:00Z').getTime()
const tRange = tMax - tMin
const outOfWindow: Chapter[] = [
{
kind: 'start',
title: 'Start',
headline: 'Lights out',
start_lap: 1,
end_lap: 1,
start_time: '2025-05-25T13:00:00Z',
end_time: '2025-05-25T13:01:00Z',
driver_numbers: [],
},
{
kind: 'finish',
title: 'Finish',
headline: 'Chequered flag',
start_lap: 78,
end_lap: 78,
start_time: '2025-05-25T13:20:00Z',
end_time: '2025-05-25T13:21:00Z',
driver_numbers: [],
},
]
expect(chapterStartScrub(outOfWindow[0], tMin, tRange)).toBe(0)
expect(chapterStartScrub(outOfWindow[1], tMin, tRange)).toBe(1)
expect(activeChapterIndex(outOfWindow, 0, tMin, tRange)).toBe(0)
expect(activeChapterIndex(outOfWindow, 1, tMin, tRange)).toBe(1)
expect(activeChapterIndex(outOfWindow, 0.5, tMin, tRange)).toBeNull()
})
it('splits 90s evenly across chapters', () => {
expect(chapterTourDurations(sampleChapters)).toEqual([45_000, 45_000])
})
it('decimates position axis labels', () => {
expect(decimatedPositionLabels(22)).toEqual([1, 5, 10, 15, 20, 22])
})
it('returns chapter band fills by kind', () => {
expect(chapterBandFill('safety_car')).toContain('rgba')
expect(chapterBandFill('virtual_safety_car')).toContain('rgba')
})
it('de-collides overlapping label y positions', () => {
const adjusted = deCollideYPositions(
[
{ key: 'a', y: 10 },
{ key: 'b', y: 12 },
{ key: 'c', y: 30 },
],
12,
)
expect(adjusted.get('a')).toBe(10)
expect(adjusted.get('b')).toBe(22)
expect(adjusted.get('c')).toBe(34)
})
})

View File

@@ -0,0 +1,266 @@
import { describe, it, expect } from 'vitest'
import {
activeDigestWindow,
filterByTag,
groupByWindow,
gpWindows,
itemsForWindow,
sinceLastLabel,
sortWindowBucketsNewestFirst,
tagItems,
topTags,
windowForDate,
} from '../lib/digest'
import type { ChampHubDriver, ChampHubTeam, Meeting, NewsItem } from '../types'
const meeting = (overrides: Partial<Meeting> = {}): Meeting => ({
meeting_key: 1,
meeting_name: 'Bahrain',
meeting_official_name: 'Bahrain GP',
location: 'Sakhir',
country_name: 'Bahrain',
country_code: 'BRN',
country_flag: '',
circuit_short_name: 'Sakhir',
date_start: '2025-03-14T00:00:00+00:00',
date_end: '2025-03-16T23:59:59+00:00',
year: 2025,
...overrides,
})
const news = (overrides: Partial<NewsItem> = {}): NewsItem => ({
source: 'autosport-f1',
title: 'Headline',
url: `https://example.com/${Math.random()}`,
fetched_at: '2025-04-01T12:00:00Z',
...overrides,
})
const driver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
points: 100,
position: 1,
wins: 3,
podiums: 5,
poles: 2,
form: [25, 18, 25],
cumulative: [25, 43, 68, 100],
teammate_wins: 4,
teammate_losses: 1,
round_positions: [],
...over,
})
const team = (over: Partial<ChampHubTeam>): ChampHubTeam => ({
team_name: 'Red Bull',
team_colour: '3671c6',
points: 150,
position: 1,
wins: 4,
...over,
})
describe('gpWindows', () => {
const bahrain = meeting({ meeting_key: 1, meeting_name: 'Bahrain' })
const monaco = meeting({
meeting_key: 2,
meeting_name: 'Monaco',
date_start: '2025-05-23T00:00:00+00:00',
date_end: '2025-05-25T23:59:59+00:00',
})
const canada = meeting({
meeting_key: 3,
meeting_name: 'Canada',
date_start: '2025-06-13T00:00:00+00:00',
date_end: '2025-06-15T23:59:59+00:00',
})
it('derives inter-race windows mid-season', () => {
const now = new Date('2025-04-10T12:00:00Z')
const windows = gpWindows([bahrain, monaco, canada], now)
expect(windows).toHaveLength(3)
expect(windows[0].start).toBeNull()
expect(windows[0].end?.toISOString()).toBe(new Date('2025-05-23T00:00:00+00:00').toISOString())
expect(windows[1].start?.toISOString()).toBe(new Date('2025-03-16T23:59:59+00:00').toISOString())
expect(windows[1].end?.toISOString()).toBe(new Date('2025-06-13T00:00:00+00:00').toISOString())
expect(windows[2].start?.toISOString()).toBe(new Date('2025-05-25T23:59:59+00:00').toISOString())
expect(windows[2].end).toBeNull()
})
it('handles before the first race of the season', () => {
const now = new Date('2025-01-01T00:00:00Z')
const windows = gpWindows([bahrain, monaco], now)
expect(windows[0].start).toBeNull()
expect(activeDigestWindow(windows, [bahrain, monaco], now)?.meeting_name).toBe('Bahrain')
expect(sinceLastLabel([bahrain, monaco], now)).toBe('Before Bahrain')
})
it('handles after the final race of the season', () => {
const now = new Date('2025-12-01T00:00:00Z')
const windows = gpWindows([bahrain, monaco], now)
expect(activeDigestWindow(windows, [bahrain, monaco], now)?.meeting_name).toBe('Monaco')
expect(sinceLastLabel([bahrain, monaco], now)).toBe('Monaco')
expect(windows[1].end).toBeNull()
})
})
describe('groupByWindow', () => {
const bahrain = meeting({ meeting_key: 1 })
const monaco = meeting({
meeting_key: 2,
date_start: '2025-05-23T00:00:00+00:00',
date_end: '2025-05-25T23:59:59+00:00',
})
const windows = gpWindows([bahrain, monaco], new Date('2025-04-10T12:00:00Z'))
it('buckets dated items into the matching GP window', () => {
const items = [
news({ url: 'https://a', published_at: '2025-03-10T10:00:00Z', title: 'Pre-Bahrain' }),
news({ url: 'https://b', published_at: '2025-04-05T10:00:00Z', title: 'Between races' }),
news({ url: 'https://c', published_at: '2025-05-24T10:00:00Z', title: 'Monaco weekend' }),
]
const grouped = groupByWindow(items, windows)
const bahrainItems = grouped.windows.find((entry) => entry.window.meeting_key === 1)?.items ?? []
const monacoItems = grouped.windows.find((entry) => entry.window.meeting_key === 2)?.items ?? []
expect(bahrainItems).toHaveLength(1)
expect(bahrainItems[0].title).toBe('Pre-Bahrain')
expect(monacoItems).toHaveLength(2)
expect(monacoItems.map((item) => item.title)).toEqual(['Between races', 'Monaco weekend'])
})
it('puts undated items in the recent bucket', () => {
const items = [
news({ url: 'https://u', title: 'Undated story' }),
news({ url: 'https://d', published_at: '2025-04-05T10:00:00Z' }),
]
const grouped = groupByWindow(items, windows)
expect(grouped.recent).toHaveLength(1)
expect(grouped.recent[0].url).toBe('https://u')
})
it('sorts window buckets newest first for display', () => {
const items = [
news({ published_at: '2025-03-10T10:00:00Z' }),
news({ published_at: '2025-05-24T10:00:00Z' }),
]
const grouped = groupByWindow(items, windows)
const sorted = sortWindowBucketsNewestFirst(grouped.windows)
expect(sorted[0].window.meeting_key).toBe(2)
expect(sorted[1].window.meeting_key).toBe(1)
})
})
describe('tagItems', () => {
const drivers = [
driver({ name_acronym: 'VER', full_name: 'Max Verstappen', team_name: 'Red Bull' }),
driver({
driver_number: 4,
name_acronym: 'NOR',
full_name: 'Lando Norris',
team_name: 'McLaren',
team_colour: 'ff8000',
}),
]
const teams = [
team({ team_name: 'Red Bull' }),
team({ team_name: 'McLaren', team_colour: 'ff8000' }),
]
it('tags drivers and teams case-insensitively', () => {
const [item] = tagItems(
[news({ title: 'ver leads mclaren in practice', summary: 'Norris close behind' })],
drivers,
teams,
)
const labels = item.tags.map((tag) => tag.label).sort()
expect(labels).toEqual(['McLaren', 'NOR', 'VER'])
})
it('returns no tags when nothing matches', () => {
const [item] = tagItems(
[news({ title: 'Generic paddock update' })],
drivers,
teams,
)
expect(item.tags).toEqual([])
})
it('prefers the longest overlapping match', () => {
const [item] = tagItems(
[news({ title: 'Max Verstappen extends championship lead' })],
drivers,
teams,
)
expect(item.tags).toHaveLength(1)
expect(item.tags[0].label).toBe('VER')
})
it('matches a driver last name inside prose', () => {
const [item] = tagItems(
[news({ title: 'Verstappen was untouchable in qualifying' })],
drivers,
teams,
)
expect(item.tags.some((tag) => tag.label === 'VER')).toBe(true)
})
})
describe('digest helpers', () => {
it('collects top tags by frequency', () => {
const tagged = tagItems(
[
news({ title: 'VER wins', url: 'https://a' }),
news({ title: 'VER dominates', url: 'https://b' }),
news({ title: 'Norris podium', url: 'https://c' }),
],
[driver({}), driver({ driver_number: 4, name_acronym: 'NOR', full_name: 'Lando Norris', team_name: 'McLaren' })],
[team({}), team({ team_name: 'McLaren', team_colour: 'ff8000' })],
)
expect(topTags(tagged, 2).map((tag) => tag.label)).toEqual(['VER', 'NOR'])
})
it('filters items by tag key', () => {
const tagged = tagItems(
[
news({ title: 'VER wins', url: 'https://a' }),
news({ title: 'Neutral headline', url: 'https://b' }),
],
[driver({})],
[team({})],
)
const filtered = filterByTag(tagged, 'driver:VER')
expect(filtered).toHaveLength(1)
expect(filtered[0].url).toBe('https://a')
})
it('selects items for the active digest window', () => {
const bahrain = meeting({ meeting_key: 1 })
const monaco = meeting({
meeting_key: 2,
date_start: '2025-05-23T00:00:00+00:00',
date_end: '2025-05-25T23:59:59+00:00',
})
const now = new Date('2025-04-10T12:00:00Z')
const windows = gpWindows([bahrain, monaco], now)
const active = activeDigestWindow(windows, [bahrain, monaco], now)
const tagged = tagItems(
[
news({ published_at: '2025-04-05T10:00:00Z', url: 'https://in' }),
news({ published_at: '2025-03-10T10:00:00Z', url: 'https://out' }),
],
[],
[],
)
const inWindow = itemsForWindow(tagged, active)
expect(inWindow).toHaveLength(1)
expect(inWindow[0].url).toBe('https://in')
expect(windowForDate(windows, new Date('2025-04-05T10:00:00Z'))?.meeting_key).toBe(2)
})
})

View File

@@ -0,0 +1,104 @@
import { describe, it, expect } from 'vitest'
import {
formatDelta,
formatPosition,
gridFinishDeltas,
shortGpLabel,
} from '../lib/driverProfile'
import type { DriverSummaryRound } from '../types'
function round(over: Partial<DriverSummaryRound>): DriverSummaryRound {
return {
meeting_key: 1201,
meeting_name: 'Bahrain Grand Prix',
country_code: 'BHR',
country_name: 'Bahrain',
race_position: 1,
grid_position: 1,
quali_position: 1,
points: 25,
dnf: false,
dns: false,
dsq: false,
...over,
}
}
describe('gridFinishDeltas', () => {
it('computes positive delta for places gained', () => {
const [d] = gridFinishDeltas([round({ grid_position: 5, race_position: 2, points: 18 })])
expect(d.grid).toBe(5)
expect(d.finish).toBe(2)
expect(d.delta).toBe(3)
expect(d.points).toBe(18)
expect(d.status).toBe('classified')
expect(d.round).toBe(1)
})
it('computes negative delta for places lost', () => {
const [d] = gridFinishDeltas([round({ grid_position: 1, race_position: 4 })])
expect(d.delta).toBe(-3)
})
it('returns null delta when the grid slot is unknown (position 0)', () => {
const [d] = gridFinishDeltas([round({ grid_position: 0, race_position: 6 })])
expect(d.grid).toBeNull()
expect(d.finish).toBe(6)
expect(d.delta).toBeNull()
})
it('flags DNS rounds and never computes a delta for them', () => {
const [d] = gridFinishDeltas([round({ dns: true, grid_position: 8, race_position: 0 })])
expect(d.status).toBe('dns')
expect(d.delta).toBeNull()
})
it('marks rounds the driver did not enter as absent', () => {
const [d] = gridFinishDeltas([round({ grid_position: 0, race_position: 0, points: 0 })])
expect(d.status).toBe('absent')
expect(d.grid).toBeNull()
expect(d.finish).toBeNull()
expect(d.delta).toBeNull()
})
it('still computes the delta for a classified DNF with known positions', () => {
const [d] = gridFinishDeltas([round({ dnf: true, grid_position: 3, race_position: 15 })])
expect(d.status).toBe('dnf')
expect(d.delta).toBe(-12)
})
it('numbers rounds sequentially and shortens labels', () => {
const ds = gridFinishDeltas([
round({}),
round({ meeting_name: 'Saudi Arabian Grand Prix', meeting_key: 1202 }),
])
expect(ds.map((d) => d.round)).toEqual([1, 2])
expect(ds[1].label).toBe('Saudi Arabian')
})
})
describe('shortGpLabel', () => {
it('strips "Grand Prix" wherever it appears', () => {
expect(shortGpLabel('Bahrain Grand Prix')).toBe('Bahrain')
expect(shortGpLabel('Grand Prix of Monaco')).toBe('of Monaco')
})
it('falls back to the original name when stripping empties it', () => {
expect(shortGpLabel('Grand Prix')).toBe('Grand Prix')
})
})
describe('formatPosition / formatDelta', () => {
it('formats positions', () => {
expect(formatPosition(4)).toBe('P4')
expect(formatPosition(null)).toBe('—')
expect(formatPosition(0)).toBe('—')
})
it('formats deltas', () => {
expect(formatDelta(3)).toBe('+3')
expect(formatDelta(-2)).toBe('2')
expect(formatDelta(0)).toBe('=')
expect(formatDelta(null)).toBe('—')
})
})

View File

@@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest'
import { teammatePairs } from '../lib/h2h'
import type { ChampHubDriver } from '../types'
function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
return {
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
points: 200,
position: 1,
wins: 5,
podiums: 8,
poles: 4,
form: [],
cumulative: [],
teammate_wins: 9,
teammate_losses: 1,
round_positions: [],
...over,
}
}
describe('teammatePairs', () => {
it('returns empty for no drivers', () => {
expect(teammatePairs([])).toEqual([])
})
it('skips teams with a single driver', () => {
const drivers = [
driver({ driver_number: 1, team_name: 'Red Bull' }),
driver({ driver_number: 44, name_acronym: 'HAM', team_name: 'Mercedes', points: 50 }),
]
expect(teammatePairs(drivers)).toHaveLength(0)
})
it('pairs two-driver teams', () => {
const drivers = [
driver({ driver_number: 1, name_acronym: 'VER', points: 200, teammate_wins: 9, teammate_losses: 1 }),
driver({
driver_number: 11,
name_acronym: 'PER',
full_name: 'Sergio Perez',
points: 60,
teammate_wins: 1,
teammate_losses: 9,
}),
]
const pairs = teammatePairs(drivers)
expect(pairs).toHaveLength(1)
expect(pairs[0].teamName).toBe('Red Bull')
expect(pairs[0].driverA.name_acronym).toBe('VER')
expect(pairs[0].driverB.name_acronym).toBe('PER')
expect(pairs[0].extraCount).toBe(0)
expect(pairs[0].closeness).toBe(8)
})
it('uses top two by points for teams with 3+ drivers', () => {
const drivers = [
driver({ driver_number: 1, name_acronym: 'VER', team_name: 'Red Bull', points: 200 }),
driver({
driver_number: 11,
name_acronym: 'PER',
team_name: 'Red Bull',
points: 60,
teammate_wins: 1,
teammate_losses: 9,
}),
driver({
driver_number: 99,
name_acronym: 'LAW',
full_name: 'Liam Lawson',
team_name: 'Red Bull',
points: 10,
teammate_wins: 0,
teammate_losses: 0,
}),
]
const pairs = teammatePairs(drivers)
expect(pairs).toHaveLength(1)
expect(pairs[0].driverA.name_acronym).toBe('VER')
expect(pairs[0].driverB.name_acronym).toBe('PER')
expect(pairs[0].extraCount).toBe(1)
})
it('sorts by closest battle first', () => {
const drivers = [
driver({
driver_number: 1,
name_acronym: 'VER',
team_name: 'Red Bull',
points: 200,
teammate_wins: 10,
teammate_losses: 0,
}),
driver({
driver_number: 11,
name_acronym: 'PER',
team_name: 'Red Bull',
points: 60,
teammate_wins: 0,
teammate_losses: 10,
}),
driver({
driver_number: 4,
name_acronym: 'NOR',
team_name: 'McLaren',
team_colour: 'ff8000',
points: 160,
teammate_wins: 6,
teammate_losses: 5,
}),
driver({
driver_number: 81,
name_acronym: 'PIA',
full_name: 'Oscar Piastri',
team_name: 'McLaren',
team_colour: 'ff8000',
points: 140,
teammate_wins: 5,
teammate_losses: 6,
}),
]
const pairs = teammatePairs(drivers)
expect(pairs).toHaveLength(2)
expect(pairs[0].teamName).toBe('McLaren')
expect(pairs[0].closeness).toBe(1)
expect(pairs[1].teamName).toBe('Red Bull')
expect(pairs[1].closeness).toBe(10)
})
})

View File

@@ -0,0 +1,227 @@
import { describe, it, expect } from 'vitest'
import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session } from '../types'
import {
buildTitleFightContext,
countdownTargetSession,
extractPodium,
extractPole,
findPriorYearMeetingByCircuit,
findRaceSession,
formatPointsGap,
isSprintWeekend,
pickPreviewMeeting,
RACE_WIN_POINTS,
SPRINT_WEEKEND_MAX_POINTS,
} from '../lib/preview'
const meeting = (overrides: Partial<Meeting> = {}): Meeting => ({
meeting_key: 1,
meeting_name: 'Monaco',
meeting_official_name: 'Monaco GP',
location: 'Monaco',
country_name: 'Monaco',
country_code: 'MON',
country_flag: '',
circuit_key: 10,
circuit_short_name: 'Monaco',
date_start: '2026-05-22T00:00:00+00:00',
date_end: '2026-05-24T23:59:59+00:00',
year: 2026,
...overrides,
})
const session = (overrides: Partial<Session> = {}): Session => ({
session_key: 100,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1,
date_start: '2026-05-24T13:00:00+00:00',
date_end: '2026-05-24T15:00:00+00:00',
gmt_offset: '02:00:00',
...overrides,
})
const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
points: 200,
position: 1,
wins: 5,
podiums: 8,
poles: 4,
form: [25],
cumulative: [200],
round_positions: [1],
teammate_wins: 9,
teammate_losses: 1,
...over,
})
describe('preview lib', () => {
it('picks the next upcoming meeting', () => {
const now = new Date('2026-01-01T00:00:00Z')
const meetings = [
meeting({ meeting_key: 1, date_start: '2026-05-22T00:00:00+00:00' }),
meeting({ meeting_key: 2, meeting_name: 'Spain', date_start: '2026-06-12T00:00:00+00:00' }),
]
expect(pickPreviewMeeting(meetings, now)?.meeting_key).toBe(1)
})
it('returns null when no upcoming meeting exists', () => {
const now = new Date('2027-01-01T00:00:00Z')
const meetings = [meeting({ date_start: '2026-05-22T00:00:00+00:00' })]
expect(pickPreviewMeeting(meetings, now)).toBeNull()
})
it('skips cancelled meetings', () => {
const now = new Date('2026-01-01T00:00:00Z')
const meetings = [meeting({ is_cancelled: true })]
expect(pickPreviewMeeting(meetings, now)).toBeNull()
})
it('matches prior-year meeting by circuit_key', () => {
const prior = [
meeting({ meeting_key: 50, year: 2025, circuit_key: 10 }),
meeting({ meeting_key: 51, year: 2025, circuit_key: 20, meeting_name: 'Spain' }),
]
expect(findPriorYearMeetingByCircuit(prior, 10)?.meeting_key).toBe(50)
expect(findPriorYearMeetingByCircuit(prior, 99)).toBeNull()
expect(findPriorYearMeetingByCircuit(prior, undefined)).toBeNull()
})
it('finds the grand prix race session', () => {
const sessions = [
session({ session_key: 1, session_name: 'FP1', session_type: 'Practice', date_start: '2026-05-22T10:00:00+00:00' }),
session({ session_key: 2, session_name: 'Sprint', session_type: 'Sprint', date_start: '2026-05-23T10:00:00+00:00' }),
session({ session_key: 3, session_name: 'Race', session_type: 'Race', date_start: '2026-05-24T13:00:00+00:00' }),
]
expect(findRaceSession(sessions)?.session_key).toBe(3)
})
it('detects sprint weekends', () => {
const sprint = [
session({ session_type: 'Practice' }),
session({ session_name: 'Sprint', session_type: 'Sprint' }),
session({ session_type: 'Race' }),
]
const normal = [session({ session_type: 'Practice' }), session({ session_type: 'Race' })]
expect(isSprintWeekend(sprint)).toBe(true)
expect(isSprintWeekend(normal)).toBe(false)
})
it('extracts podium and pole', () => {
const results: EnrichedResult[] = [
{
driver_number: 1,
position: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
dnf: false,
dns: false,
dsq: false,
duration: 100,
gap_to_leader: 0,
number_of_laps: 78,
points: 25,
session_key: 1,
meeting_key: 1,
},
{
driver_number: 4,
position: 2,
name_acronym: 'NOR',
full_name: 'Lando Norris',
team_name: 'McLaren',
team_colour: 'ff8000',
dnf: false,
dns: false,
dsq: false,
duration: 101,
gap_to_leader: 1,
number_of_laps: 78,
points: 18,
session_key: 1,
meeting_key: 1,
},
{
driver_number: 16,
position: 3,
name_acronym: 'LEC',
full_name: 'Charles Leclerc',
team_name: 'Ferrari',
team_colour: 'e8002d',
dnf: false,
dns: false,
dsq: false,
duration: 102,
gap_to_leader: 2,
number_of_laps: 78,
points: 15,
session_key: 1,
meeting_key: 1,
},
]
const grid: EnrichedGrid[] = [
{
driver_number: 4,
position: 1,
name_acronym: 'NOR',
full_name: 'Lando Norris',
team_name: 'McLaren',
team_colour: 'ff8000',
session_key: 1,
meeting_key: 1,
lap_duration: 70,
},
]
const podium = extractPodium(results)
expect(podium.map((p) => p.name_acronym)).toEqual(['VER', 'NOR', 'LEC'])
expect(extractPole(grid)?.name_acronym).toBe('NOR')
})
it('builds title fight gaps for top 3', () => {
const hub: ChampionshipHub = {
season: 2026,
round: 5,
total_rounds: 24,
rounds_left: 19,
last_race: 'Monaco GP',
round_labels: ['R1'],
drivers: [
hubDriver({ points: 200, position: 1 }),
hubDriver({ driver_number: 4, name_acronym: 'NOR', points: 160, position: 2 }),
hubDriver({ driver_number: 16, name_acronym: 'LEC', points: 120, position: 3 }),
],
teams: [],
}
const rows = buildTitleFightContext(hub)
expect(rows).toHaveLength(3)
expect(rows[0].gapToLeader).toBe(0)
expect(rows[1].gapToLeader).toBe(40)
expect(rows[1].gapAfterRaceWin).toBe(15)
expect(rows[2].gapAfterSprintWeekendMax).toBe(200 - (120 + SPRINT_WEEKEND_MAX_POINTS))
expect(RACE_WIN_POINTS).toBe(25)
})
it('formats points gaps', () => {
expect(formatPointsGap(0)).toBe('LEADER')
expect(formatPointsGap(40)).toBe('+40')
})
it('picks countdown target as next future session', () => {
const sessions = [
session({ session_key: 1, session_name: 'FP1', session_type: 'Practice', date_start: '2026-05-22T10:00:00+00:00', date_end: '2026-05-22T11:00:00+00:00' }),
session({ session_key: 2, session_name: 'Race', session_type: 'Race', date_start: '2026-05-24T13:00:00+00:00', date_end: '2026-05-24T15:00:00+00:00' }),
]
const now = new Date('2026-05-23T12:00:00Z')
expect(countdownTargetSession(sessions, now)?.session_key).toBe(2)
})
})

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import type { ReplayFrame, TrackBounds } from '../types'
import { interpolateReplayCars, lookupReplayFramePair, replayCarToSvg } from '../lib/replay'
const frames: ReplayFrame[] = [
{ t: 0, cars: { '1': { x: 0, y: 0 } } },
{ t: 5000, cars: { '1': { x: 10, y: 20 }, '4': { x: 40, y: 80 } } },
{ t: 10000, cars: { '1': { x: 20, y: 40 } } },
]
describe('replay helpers', () => {
it('looks up boundary frame pairs', () => {
expect(lookupReplayFramePair(frames, -1)).toEqual({ previous: frames[0], next: frames[0] })
expect(lookupReplayFramePair(frames, 10000)).toEqual({ previous: frames[2], next: frames[2] })
expect(lookupReplayFramePair([], 1000)).toEqual({ previous: null, next: null })
})
it('interpolates cars between frames and keeps one-sided samples visible', () => {
const cars = interpolateReplayCars(frames, 2500)
expect(cars['1']).toEqual({ x: 5, y: 10 })
expect(cars['4']).toEqual({ x: 40, y: 80 })
})
it('clamps interpolation outside the replay range', () => {
expect(interpolateReplayCars(frames, -500)['1']).toEqual({ x: 0, y: 0 })
expect(interpolateReplayCars(frames, 12000)['1']).toEqual({ x: 20, y: 40 })
})
it('maps replay GPS through the shared track-map coordinate transform', () => {
const bounds: TrackBounds = { minX: 0, maxX: 100, minY: 0, maxY: 200 }
expect(replayCarToSvg({ x: 50, y: 50 }, bounds)).toEqual({ x: 50, y: 75 })
})
})

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { isReplayMapAvailable } from '../lib/replayMap'
import type { ReplayFramesResponse, TrackOutline } from '../types'
const outline: TrackOutline = {
circuit_key: 1,
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
points: [{ x: 0, y: 0 }],
}
const replay: ReplayFramesResponse = {
session_key: 1,
interval_ms: 5000,
start_time: '2025-05-25T13:00:00Z',
frames: [
{ t: 0, cars: {} },
{ t: 5000, cars: {} },
],
}
describe('isReplayMapAvailable', () => {
it('returns true when frames and outline are present', () => {
expect(isReplayMapAvailable(replay, outline, false)).toBe(true)
})
it('returns false when frames are sparse', () => {
expect(isReplayMapAvailable({ ...replay, frames: [{ t: 0, cars: {} }] }, outline, false)).toBe(false)
})
it('returns false on query error', () => {
expect(isReplayMapAvailable(replay, outline, true)).toBe(false)
})
})

View File

@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest'
import { gapSeries, h2hTally, lastRounds } from '../lib/rivalry'
describe('gapSeries', () => {
it('computes per-round a b', () => {
expect(gapSeries([25, 43, 68], [18, 36, 54])).toEqual([7, 7, 14])
})
it('handles negative gaps (b ahead)', () => {
expect(gapSeries([10, 20], [18, 36])).toEqual([-8, -16])
})
it('truncates to the shorter series', () => {
expect(gapSeries([25, 43, 68], [18])).toEqual([7])
expect(gapSeries([], [18, 36])).toEqual([])
})
})
describe('h2hTally', () => {
it('tallies lower-position wins per round', () => {
const t = h2hTally([1, 2, 1], [2, 1, 3], ['R1', 'R2', 'R3'])
expect(t.a).toBe(2)
expect(t.b).toBe(1)
expect(t.skipped).toBe(0)
expect(t.rounds.map((r) => r.winner)).toEqual(['a', 'b', 'a'])
expect(t.rounds[0]).toEqual({ round: 1, label: 'R1', posA: 1, posB: 2, winner: 'a' })
})
it('skips rounds where either driver has no position', () => {
// R2: a missing (0). R3: b missing (0). Only R1 and R4 count.
const t = h2hTally([1, 0, 5, 3], [4, 2, 0, 1], ['R1', 'R2', 'R3', 'R4'])
expect(t.a).toBe(1)
expect(t.b).toBe(1)
expect(t.skipped).toBe(2)
expect(t.rounds.map((r) => r.round)).toEqual([1, 4])
})
it('handles arrays of different lengths (missing tail = skipped)', () => {
const t = h2hTally([1, 2, 3], [2], ['R1', 'R2', 'R3'])
expect(t.a).toBe(1)
expect(t.b).toBe(0)
expect(t.skipped).toBe(2)
})
it('skips equal positions defensively and falls back on labels', () => {
const t = h2hTally([2, 1], [2, 3], ['R1'])
expect(t.a).toBe(1)
expect(t.b).toBe(0)
expect(t.skipped).toBe(1)
expect(t.rounds[0].label).toBe('R2')
})
it('returns an empty tally for empty inputs', () => {
const t = h2hTally([], [], [])
expect(t).toEqual({ a: 0, b: 0, rounds: [], skipped: 0 })
})
})
describe('lastRounds', () => {
it('returns the last n counted rounds in order', () => {
const t = h2hTally([1, 1, 1, 2, 1, 1, 2], [2, 2, 2, 1, 2, 2, 1], [])
const last = lastRounds(t, 5)
expect(last).toHaveLength(5)
expect(last.map((r) => r.round)).toEqual([3, 4, 5, 6, 7])
})
it('returns fewer when the tally has fewer counted rounds', () => {
const t = h2hTally([1, 0], [2, 0], [])
expect(lastRounds(t, 5)).toHaveLength(1)
})
})

View File

@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest'
import { parseRaceHubSearch, parseWeekendSearch, weekendFocusSearch } from '../lib/routeSearch'
describe('routeSearch', () => {
it('parses positive weekend meeting/session keys and drops invalid values', () => {
expect(parseWeekendSearch({ meeting_key: '1229', session_key: '9000' })).toEqual({
meeting_key: 1229,
session_key: 9000,
})
expect(parseWeekendSearch({ meeting_key: '0', session_key: '-1', foo: 'bar' })).toEqual({})
expect(parseWeekendSearch({})).toEqual({})
})
it('parses race-hub session keys', () => {
expect(parseRaceHubSearch({ session_key: '9472' })).toEqual({ session_key: 9472 })
expect(parseRaceHubSearch({ session_key: 'nope' })).toEqual({})
})
it('builds Weekend focus search for Race Hub returns', () => {
expect(weekendFocusSearch(1229, 9000)).toEqual({ meeting_key: 1229, session_key: 9000 })
expect(weekendFocusSearch(null, 9000)).toEqual({ session_key: 9000 })
expect(weekendFocusSearch(1229, undefined)).toEqual({ meeting_key: 1229 })
})
})

View File

@@ -0,0 +1,146 @@
import { describe, it, expect } from 'vitest'
import {
resolveSessionState,
sessionState,
sessionStateLabel,
} from '../lib/sessionState'
import type {
ContextAvailability,
ContextSession,
DatasetInfo,
Session,
WeekendContext,
WeekendSession,
} from '../types'
const NOW = new Date('2025-06-01T00:00:00Z')
function mk(
overrides: Partial<Session>,
source: WeekendSession['source'],
datasets: Record<string, DatasetInfo> = {},
): WeekendSession {
return {
session: {
session_key: 1,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1,
date_start: '2025-05-25T13:00:00+00:00',
date_end: '2025-05-25T15:00:00+00:00',
gmt_offset: '00:00:00',
...overrides,
},
source,
datasets,
}
}
const FULL: Record<string, DatasetInfo> = Object.fromEntries(
[
'meeting',
'session',
'drivers',
'results',
'starting_grid',
'stints',
'pit_stops',
'positions',
'race_control',
'weather',
'laps',
].map((k) => [k, { status: 'available', source: 'local', count: 1 }]),
)
function availability(overrides: Partial<ContextAvailability> = {}): ContextAvailability {
return {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'complete',
freshness: 'fresh',
limitations: [],
...overrides,
}
}
function contextSession(
session: Session,
avail: Partial<ContextAvailability> = {},
): ContextSession {
return { session, availability: availability(avail) }
}
function context(overrides: Partial<WeekendContext> = {}): WeekendContext {
return {
temporal_state: 'post_weekend',
championship_round: 1,
total_championship_rounds: 1,
...overrides,
}
}
describe('sessionState', () => {
it('marks a future session as upcoming', () => {
const s = mk({ date_start: '2099-05-25T13:00:00+00:00', date_end: '2099-05-25T15:00:00+00:00' }, 'none')
expect(sessionState(s, NOW)).toBe('upcoming')
})
it('does not mark Live from schedule alone', () => {
const start = new Date(NOW.getTime() - 60_000).toISOString()
const end = new Date(NOW.getTime() + 60_000).toISOString()
const s = mk({ date_start: start, date_end: end }, 'none')
expect(sessionState(s, NOW)).toBe('preparing')
})
it('marks Live only when Weekend Context active identity matches', () => {
const start = new Date(NOW.getTime() - 60_000).toISOString()
const end = new Date(NOW.getTime() + 60_000).toISOString()
const s = mk({ session_key: 42, date_start: start, date_end: end }, 'none')
const ctx = context({
temporal_state: 'session_live',
active_session: contextSession(s.session, { live_session: 'active' }),
})
expect(resolveSessionState({ weekendSession: s, context: ctx, now: NOW })).toBe('live')
})
it('marks a finished session with full local data as ready', () => {
const s = mk({}, 'local', FULL)
expect(sessionState(s, NOW)).toBe('ready')
})
it('marks a finished session with no data as preparing', () => {
const s = mk({}, 'none', {})
expect(sessionState(s, NOW)).toBe('preparing')
})
it('marks a finished session with partial data as partial', () => {
const s = mk({}, 'partial', { drivers: { status: 'available', source: 'local', count: 20 } })
expect(sessionState(s, NOW)).toBe('partial')
})
it('marks unavailable from context availability', () => {
const s = mk({ session_key: 7 }, 'none')
const ctx = context({
previous_completed_session: contextSession(s.session, {
local_analysis: 'unavailable',
}),
})
expect(resolveSessionState({ weekendSession: s, context: ctx, now: NOW })).toBe(
'unavailable',
)
})
it('marks a cancelled session as cancelled', () => {
const s = mk({}, 'cancelled')
expect(sessionState(s, NOW)).toBe('cancelled')
})
it('uses user language labels rather than coverage counts', () => {
expect(sessionStateLabel('ready')).toBe('Ready')
expect(sessionStateLabel('upcoming')).toBe('Upcoming')
expect(sessionStateLabel('partial')).toBe('Partial')
expect(sessionStateLabel('unavailable')).toBe('Unavailable')
})
})

View File

@@ -29,6 +29,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
poles: 0, poles: 0,
form: [], form: [],
cumulative: [], cumulative: [],
round_positions: [],
teammate_wins: 0, teammate_wins: 0,
teammate_losses: 0, teammate_losses: 0,
...over, ...over,

View File

@@ -0,0 +1,223 @@
import { describe, it, expect } from 'vitest'
import {
analysisSessionKey,
briefingItems,
championshipImpact,
hasLocalAnalysis,
meetingIdentity,
podiumFromResults,
resolveViewState,
} from '../lib/weekendContext'
import type {
ChampionshipHub,
ContextAvailability,
ContextSession,
EnrichedResult,
Meeting,
NewsItem,
Session,
TemporalState,
WeekendContext,
} from '../types'
function availability(overrides: Partial<ContextAvailability> = {}): ContextAvailability {
return {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'complete',
freshness: 'fresh',
limitations: [],
...overrides,
}
}
function meeting(overrides: Partial<Meeting> = {}): Meeting {
return {
meeting_key: 1,
meeting_name: 'British Grand Prix',
meeting_official_name: 'FORMULA 1 BRITISH GRAND PRIX',
location: 'Silverstone',
country_name: 'United Kingdom',
country_code: 'GBR',
country_flag: '',
circuit_key: 2,
circuit_short_name: 'Silverstone',
date_start: '2026-07-03T09:00:00Z',
date_end: '2026-07-05T16:00:00Z',
year: 2026,
...overrides,
}
}
function session(overrides: Partial<Session> = {}): Session {
return {
session_key: 11,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1,
date_start: '2026-07-05T14:00:00Z',
date_end: '2026-07-05T16:00:00Z',
gmt_offset: '',
...overrides,
}
}
function ctxSession(overrides: Partial<ContextSession> = {}): ContextSession {
return {
session: session(),
meeting: meeting(),
availability: availability(),
...overrides,
}
}
function context(overrides: Partial<WeekendContext> = {}): WeekendContext {
return {
season: 2026,
temporal_state: 'between_weekends',
championship_round: 1,
total_championship_rounds: 24,
...overrides,
}
}
describe('resolveViewState', () => {
const cases: Array<[TemporalState, string]> = [
['no_season', 'no_season'],
['between_weekends', 'between_weekends'],
['pre_session', 'pre_session'],
['session_live', 'session_live'],
['session_settling', 'session_settling'],
['between_sessions', 'between_sessions'],
['post_weekend', 'post_weekend'],
['season_complete', 'season_complete'],
]
it.each(cases)('maps canonical temporal_state %s to view %s', (temporal, expected) => {
expect(resolveViewState(context({ temporal_state: temporal }))).toBe(expected)
})
it('treats an unknown/absent temporal_state as no_season rather than inventing a state', () => {
expect(resolveViewState(context({ temporal_state: 'garbage' as TemporalState }))).toBe('no_season')
})
})
describe('analysisSessionKey', () => {
it('prefers the default analysis session', () => {
const ctx = context({
default_analysis_session: ctxSession({ session: session({ session_key: 32 }) }),
previous_completed_session: ctxSession({ session: session({ session_key: 11 }) }),
})
expect(analysisSessionKey(ctx)).toBe(32)
})
it('falls back to the previous completed session', () => {
const ctx = context({
previous_completed_session: ctxSession({ session: session({ session_key: 11 }) }),
})
expect(analysisSessionKey(ctx)).toBe(11)
})
it('returns undefined when there is no analysable session', () => {
expect(analysisSessionKey(context())).toBeUndefined()
})
it('returns undefined for a zero/synthetic session key', () => {
const ctx = context({
previous_completed_session: ctxSession({ session: session({ session_key: 0 }) }),
})
expect(analysisSessionKey(ctx)).toBeUndefined()
})
})
describe('hasLocalAnalysis', () => {
it('is true for complete or partial local analysis', () => {
expect(hasLocalAnalysis(ctxSession({ availability: availability({ local_analysis: 'complete' }) }))).toBe(true)
expect(hasLocalAnalysis(ctxSession({ availability: availability({ local_analysis: 'partial' }) }))).toBe(true)
})
it('is false for pending/not_applicable/absent', () => {
expect(hasLocalAnalysis(ctxSession({ availability: availability({ local_analysis: 'pending' }) }))).toBe(false)
expect(hasLocalAnalysis(ctxSession({ availability: availability({ local_analysis: 'not_applicable' }) }))).toBe(false)
expect(hasLocalAnalysis(undefined)).toBe(false)
})
})
describe('meetingIdentity', () => {
it('derives a short name and tolerates partial fields', () => {
const id = meetingIdentity(meeting({ meeting_name: 'Monaco Grand Prix', circuit_short_name: '', location: 'Monaco' }))
expect(id?.short_name).toBe('Monaco')
expect(id?.circuit_short_name).toBe('Monaco')
})
it('returns undefined for an absent meeting', () => {
expect(meetingIdentity(undefined)).toBeUndefined()
})
it('drops empty date strings', () => {
const id = meetingIdentity(meeting({ date_start: '', date_end: '' }))
expect(id?.date_start).toBeUndefined()
expect(id?.date_end).toBeUndefined()
})
})
describe('championshipImpact', () => {
const hub: ChampionshipHub = {
season: 2026,
round: 2,
total_rounds: 24,
rounds_left: 22,
last_race: 'British GP',
round_labels: ['R1', 'R2'],
drivers: [
{ driver_number: 1, name_acronym: 'VER', full_name: 'Max', team_name: 'RB', team_colour: '3671c6', points: 50, position: 1, wins: 2, podiums: 2, poles: 1, form: [], cumulative: [25, 50], teammate_wins: 0, teammate_losses: 0, round_positions: [] },
{ driver_number: 4, name_acronym: 'NOR', full_name: 'Lando', team_name: 'McL', team_colour: 'ff8000', points: 40, position: 2, wins: 1, podiums: 2, poles: 0, form: [], cumulative: [18, 40], teammate_wins: 0, teammate_losses: 0, round_positions: [] },
],
teams: [],
}
it('maps top-3 movers with deltas and a note', () => {
const impact = championshipImpact(hub)
expect(impact?.leaders).toHaveLength(2)
expect(impact?.leaders[0].delta).toBe(25)
expect(impact?.note).toContain('British GP')
})
it('returns undefined for an empty hub', () => {
expect(championshipImpact(undefined)).toBeUndefined()
expect(championshipImpact({ ...hub, drivers: [] })).toBeUndefined()
})
})
describe('briefingItems', () => {
it('caps at three items and maps fields', () => {
const news: NewsItem[] = Array.from({ length: 5 }, (_, i) => ({
source: 'src',
title: `Item ${i}`,
url: `https://x/${i}`,
fetched_at: '2026-07-06T00:00:00Z',
category: 'news',
og_image_url: 'img',
}))
const items = briefingItems(news)
expect(items).toHaveLength(3)
expect(items[0]).toMatchObject({ title: 'Item 0', image_url: 'img' })
})
})
describe('podiumFromResults', () => {
it('sorts by position, caps at three, and formats gaps', () => {
const results: EnrichedResult[] = [
{ driver_number: 44, position: 2, name_acronym: 'HAM', full_name: 'Lewis', team_name: 'Ferrari', team_colour: 'e8002d', dnf: false, dns: false, dsq: false, duration: null, gap_to_leader: 5.123 } as EnrichedResult,
{ driver_number: 1, position: 1, name_acronym: 'VER', full_name: 'Max', team_name: 'RB', team_colour: '3671c6', dnf: false, dns: false, dsq: false, duration: 5400, gap_to_leader: null } as EnrichedResult,
{ driver_number: 16, position: 3, name_acronym: 'LEC', full_name: 'Charles', team_name: 'Ferrari', team_colour: 'e8002d', dnf: false, dns: false, dsq: false, duration: null, gap_to_leader: '+10.5' } as EnrichedResult,
{ driver_number: 55, position: 4, name_acronym: 'SAI', full_name: 'Carlos', team_name: 'W', team_colour: 'fff', dnf: false, dns: false, dsq: false, duration: null, gap_to_leader: 20 } as EnrichedResult,
]
const podium = podiumFromResults(results)
expect(podium.map((p) => p.name_acronym)).toEqual(['VER', 'HAM', 'LEC'])
expect(podium[1].gap).toBe('+5.123')
expect(podium[2].gap).toBe('+10.5')
})
})

View File

@@ -12,6 +12,7 @@ export interface Meeting {
country_name: string country_name: string
country_code: string country_code: string
country_flag: string country_flag: string
circuit_key?: number
circuit_short_name: string circuit_short_name: string
date_start: string date_start: string
date_end: string date_end: string
@@ -23,6 +24,7 @@ export interface Session {
session_key: number session_key: number
session_name: string session_name: string
session_type: string session_type: string
circuit_key?: number
meeting_key: number meeting_key: number
date_start: string date_start: string
date_end: string date_end: string
@@ -89,6 +91,18 @@ export interface RaceHub {
race_control: RaceControlMessage[] race_control: RaceControlMessage[]
weather: WeatherSample[] weather: WeatherSample[]
laps: Lap[] laps: Lap[]
chapters: Chapter[]
}
export interface Chapter {
kind: 'start' | 'safety_car' | 'virtual_safety_car' | 'red_flag' | 'pit_phase' | 'decisive_swing' | 'finish' | string
title: string
headline: string
start_lap: number
end_lap: number
start_time?: string
end_time?: string
driver_numbers: number[]
} }
export interface Stint { export interface Stint {
@@ -347,6 +361,23 @@ export interface TrackOutline {
bounds: TrackBounds bounds: TrackBounds
} }
export interface ReplayCarPosition {
x: number
y: number
}
export interface ReplayFrame {
t: number
cars: Record<string, ReplayCarPosition>
}
export interface ReplayFramesResponse {
session_key: number
interval_ms: number
start_time: string
frames: ReplayFrame[]
}
export interface ChampHubDriver { export interface ChampHubDriver {
driver_number: number driver_number: number
name_acronym: string name_acronym: string
@@ -360,6 +391,7 @@ export interface ChampHubDriver {
poles: number poles: number
form: number[] form: number[]
cumulative: number[] cumulative: number[]
round_positions: number[]
teammate_wins: number teammate_wins: number
teammate_losses: number teammate_losses: number
} }
@@ -383,6 +415,140 @@ export interface ChampionshipHub {
teams: ChampHubTeam[] teams: ChampHubTeam[]
} }
export interface DriverSummaryRound {
meeting_key: number
meeting_name: string
country_code: string
country_name: string
race_position: number
grid_position: number
quali_position?: number
points: number
dnf: boolean
dns: boolean
dsq: boolean
}
export interface DriverSummary {
season: number
driver_number: number
name_acronym: string
full_name: string
team_name: string
team_colour: string
headshot_url: string
points: number
position: number
wins: number
podiums: number
poles: number
form: number[]
cumulative: number[]
round_labels: string[]
rounds: DriverSummaryRound[]
}
// ── Weekend Context ──
// Canonical local-first contract for the adaptive Weekend home, served verbatim
// by /api/v1/weekend-context (backend story #72, internal/query/context.go).
// The frontend consumes this shape as the single source of truth; view-model
// derivation lives in lib/weekendContext.ts.
// TemporalState mirrors query.TemporalState exactly (the JSON `temporal_state`).
export type TemporalState =
| 'no_season'
| 'between_weekends'
| 'pre_session'
| 'session_live'
| 'session_settling'
| 'between_sessions'
| 'post_weekend'
| 'season_complete'
// ContextAvailability mirrors query.ContextAvailability. Every field is present
// in a canonical payload except the optional `observed_at`.
export interface ContextAvailability {
schedule: string
live_transport: string
live_session: string
archive: string
local_analysis: string
freshness: string
observed_at?: string
limitations: string[]
}
// ContextSession mirrors query.ContextSession: a session identity coupled with
// its meeting and structured availability contract.
export interface ContextSession {
session: Session
meeting?: Meeting
availability: ContextAvailability
}
// WeekendContext mirrors query.WeekendContext (the canonical endpoint body).
export interface WeekendContext {
season?: number
temporal_state: TemporalState
previous_meeting?: Meeting
focus_meeting?: Meeting
next_meeting?: Meeting
previous_completed_session?: ContextSession
active_session?: ContextSession
next_session?: ContextSession
default_analysis_session?: ContextSession
championship_round: number
total_championship_rounds: number
}
// ── Weekend view model (client-only) ──
// The rendered Weekend home layers a small set of non-canonical UI states
// (loading/error) plus supplementary data (championship movers, briefing) on top
// of the canonical context. None of these are part of the #72 contract.
export type WeekendViewState =
| 'loading'
| 'error'
| 'no_season'
| 'between_weekends'
| 'pre_session'
| 'session_live'
| 'session_settling'
| 'between_sessions'
| 'post_weekend'
| 'season_complete'
export interface WeekendChampionshipMover {
position: number
driver_number: number
name_acronym: string
team_colour: string
points: number
delta?: number
}
export interface WeekendChampionshipImpact {
leaders: WeekendChampionshipMover[]
note?: string
}
export interface WeekendBriefingItem {
category?: string
title: string
url: string
source: string
published_at?: string
image_url?: string
}
export interface WeekendPodiumEntry {
position: number
driver_number: number
name_acronym: string
team_name: string
team_colour: string
gap: string
}
export interface NewsItem { export interface NewsItem {
source: string source: string
title: string title: string

View File

@@ -0,0 +1,629 @@
package chapters
import (
"fmt"
"math"
"sort"
"strings"
"time"
"github.com/AmanTahiliani/box-box/internal/models"
)
const (
KindStart = "start"
KindSafetyCar = "safety_car"
KindVirtualSafetyCar = "virtual_safety_car"
KindRedFlag = "red_flag"
KindPitPhase = "pit_phase"
KindDecisiveSwing = "decisive_swing"
KindFinish = "finish"
pitPhaseWindowLaps = 3
pitPhaseShare = 0.30
minPitPhaseStops = 2
maxDecisiveSwings = 3
decisiveAfterLap = 5
structuralPriority = 110
flagPriority = 100
pitPhasePriority = 50
decisivePriority = 40
)
type RaceControl = models.RaceControl
type PositionSample = models.Position
type Lap = models.Lap
// Chapter is a deterministic replay segment derived from timing and race-control data.
type Chapter struct {
Kind string `json:"kind"`
Title string `json:"title"`
Headline string `json:"headline"`
StartLap int `json:"start_lap"`
EndLap int `json:"end_lap"`
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
DriverNumbers []int `json:"driver_numbers"`
}
// Detect builds replay chapters from already-loaded race-hub datasets.
func Detect(rc []RaceControl, positions []PositionSample, laps []Lap, totalLaps int) []Chapter {
totalLaps = normalizeTotalLaps(totalLaps, laps, rc)
if totalLaps <= 0 && len(rc) == 0 && len(positions) == 0 && len(laps) == 0 {
return []Chapter{}
}
if totalLaps <= 0 {
totalLaps = 1
}
lapIndex := buildLapIndex(laps)
chapters := []Chapter{
{
Kind: KindStart,
Title: "Start",
StartLap: 1,
EndLap: minInt(1, totalLaps),
StartTime: lapIndex.lapStart(1),
EndTime: lapIndex.lapEnd(1),
},
}
chapters = append(chapters, detectFlagPeriods(rc, lapIndex, totalLaps)...)
chapters = append(chapters, detectPitPhases(laps, lapIndex)...)
chapters = append(chapters, detectDecisiveSwings(positions, lapIndex, totalLaps)...)
chapters = append(chapters, detectFinish(rc, lapIndex, totalLaps))
return resolveConflicts(chapters)
}
func normalizeTotalLaps(totalLaps int, laps []Lap, rc []RaceControl) int {
for _, l := range laps {
if l.LapNumber > totalLaps {
totalLaps = l.LapNumber
}
}
for _, msg := range rc {
if msg.LapNumber != nil && *msg.LapNumber > totalLaps {
totalLaps = *msg.LapNumber
}
}
return totalLaps
}
type lapIndex struct {
byLap map[int]string
events []lapEvent
}
type lapEvent struct {
lap int
at time.Time
}
func buildLapIndex(laps []Lap) lapIndex {
idx := lapIndex{byLap: map[int]string{}}
for _, l := range laps {
if l.LapNumber <= 0 || l.DateStart == "" {
continue
}
if _, ok := idx.byLap[l.LapNumber]; !ok {
idx.byLap[l.LapNumber] = l.DateStart
}
at, ok := parseTime(l.DateStart)
if ok {
idx.events = append(idx.events, lapEvent{lap: l.LapNumber, at: at})
}
}
sort.Slice(idx.events, func(i, j int) bool {
if idx.events[i].at.Equal(idx.events[j].at) {
return idx.events[i].lap < idx.events[j].lap
}
return idx.events[i].at.Before(idx.events[j].at)
})
return idx
}
func (idx lapIndex) lapStart(lap int) string {
return idx.byLap[lap]
}
func (idx lapIndex) lapEnd(lap int) string {
if v := idx.byLap[lap+1]; v != "" {
return v
}
return idx.byLap[lap]
}
func (idx lapIndex) lapForTime(raw string) int {
at, ok := parseTime(raw)
if !ok || len(idx.events) == 0 {
return 0
}
lap := 0
for _, event := range idx.events {
if event.at.After(at) {
break
}
lap = event.lap
}
if lap == 0 {
return idx.events[0].lap
}
return lap
}
type flagState struct {
startLap int
startTime string
}
func detectFlagPeriods(rc []RaceControl, idx lapIndex, totalLaps int) []Chapter {
var chapters []Chapter
active := map[string]flagState{}
for _, msg := range rc {
kind, ok := flagKind(msg)
if !ok && greenFlagClear(msg) {
for activeKind, st := range active {
lap := messageLap(msg, idx)
if lap <= 0 {
lap = st.startLap
}
endLap := clampLap(lap, st.startLap, totalLaps)
chapters = append(chapters, Chapter{
Kind: activeKind,
Title: flagTitle(activeKind, st.startLap, endLap),
StartLap: st.startLap,
EndLap: endLap,
StartTime: st.startTime,
EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)),
})
delete(active, activeKind)
}
continue
}
if !ok {
continue
}
lap := messageLap(msg, idx)
if lap <= 0 {
lap = 1
}
if flagCleared(msg) {
st, ok := active[kind]
if !ok {
continue
}
endLap := clampLap(lap, st.startLap, totalLaps)
chapters = append(chapters, Chapter{
Kind: kind,
Title: flagTitle(kind, st.startLap, endLap),
StartLap: st.startLap,
EndLap: endLap,
StartTime: st.startTime,
EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)),
})
delete(active, kind)
continue
}
if flagStarted(msg) {
active[kind] = flagState{
startLap: clampLap(lap, 1, totalLaps),
startTime: firstNonEmpty(msg.Date, idx.lapStart(lap)),
}
}
}
for kind, st := range active {
endLap := totalLaps
chapters = append(chapters, Chapter{
Kind: kind,
Title: flagTitle(kind, st.startLap, endLap),
StartLap: st.startLap,
EndLap: endLap,
StartTime: st.startTime,
EndTime: idx.lapEnd(endLap),
})
}
return chapters
}
func flagKind(msg RaceControl) (string, bool) {
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
if strings.Contains(text, "VSC") || strings.Contains(text, "VIRTUAL SAFETY CAR") {
return KindVirtualSafetyCar, true
}
if strings.Contains(text, "RED FLAG") || string(msg.Flag) == string(models.FlagRed) {
return KindRedFlag, true
}
if strings.Contains(text, "SAFETY CAR") || msg.Category == models.CategorySafetyCar {
return KindSafetyCar, true
}
return "", false
}
func flagStarted(msg RaceControl) bool {
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
if strings.Contains(text, "CLEAR") || strings.Contains(text, "ENDING") || strings.Contains(text, "IN THIS LAP") || strings.Contains(text, "GREEN") {
return false
}
return strings.Contains(text, "DEPLOY") ||
strings.Contains(text, "RED FLAG") ||
strings.Contains(text, "VIRTUAL SAFETY CAR") ||
strings.Contains(text, "VSC") ||
strings.Contains(text, "SAFETY CAR") ||
string(msg.Flag) == string(models.FlagRed)
}
func flagCleared(msg RaceControl) bool {
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
return strings.Contains(text, "CLEAR") ||
strings.Contains(text, "ENDING") ||
strings.Contains(text, "IN THIS LAP") ||
strings.Contains(text, "GREEN")
}
func greenFlagClear(msg RaceControl) bool {
text := upperText(string(msg.Flag), msg.Message)
return strings.Contains(text, "GREEN")
}
func flagTitle(kind string, startLap, endLap int) string {
name := "Flag period"
switch kind {
case KindSafetyCar:
name = "Safety Car"
case KindVirtualSafetyCar:
name = "Virtual Safety Car"
case KindRedFlag:
name = "Red Flag"
}
return fmt.Sprintf("%s (L%d-L%d)", name, startLap, endLap)
}
func detectPitPhases(laps []Lap, idx lapIndex) []Chapter {
type pitOut struct {
lap int
driver int
}
var stops []pitOut
for _, l := range laps {
if l.IsPitOutLap && l.LapNumber > 0 {
stops = append(stops, pitOut{lap: l.LapNumber, driver: l.DriverNumber})
}
}
if len(stops) < minPitPhaseStops {
return nil
}
sort.Slice(stops, func(i, j int) bool {
if stops[i].lap == stops[j].lap {
return stops[i].driver < stops[j].driver
}
return stops[i].lap < stops[j].lap
})
needed := int(math.Ceil(float64(len(stops)) * pitPhaseShare))
if needed < minPitPhaseStops {
needed = minPitPhaseStops
}
var windows []Chapter
for i := 0; i < len(stops); i++ {
start := stops[i].lap
end := start + pitPhaseWindowLaps - 1
drivers := map[int]bool{}
count := 0
for _, stop := range stops {
if stop.lap < start || stop.lap > end {
continue
}
count++
drivers[stop.driver] = true
}
if count < needed {
continue
}
ch := Chapter{
Kind: KindPitPhase,
Title: fmt.Sprintf("Pit phase (L%d-L%d)", start, end),
StartLap: start,
EndLap: end,
StartTime: idx.lapStart(start),
EndTime: idx.lapEnd(end),
DriverNumbers: sortedDriverNumbers(drivers),
}
if len(windows) > 0 && ch.StartLap <= windows[len(windows)-1].EndLap+1 {
last := &windows[len(windows)-1]
if ch.EndLap > last.EndLap {
last.EndLap = ch.EndLap
last.EndTime = idx.lapEnd(last.EndLap)
}
drivers := sliceToSet(last.DriverNumbers)
for _, driver := range ch.DriverNumbers {
drivers[driver] = true
}
last.DriverNumbers = sortedDriverNumbers(drivers)
last.Title = fmt.Sprintf("Pit phase (L%d-L%d)", last.StartLap, last.EndLap)
continue
}
windows = append(windows, ch)
}
return windows
}
type swingCandidate struct {
chapter Chapter
significance int
}
func detectDecisiveSwings(positions []PositionSample, idx lapIndex, totalLaps int) []Chapter {
if len(positions) == 0 || len(idx.events) == 0 || totalLaps <= decisiveAfterLap {
return nil
}
snapshots := buildPositionSnapshots(positions, idx, totalLaps)
if len(snapshots) == 0 {
return nil
}
final := snapshots[totalLaps]
if len(final) == 0 {
for lap := totalLaps - 1; lap >= 1; lap-- {
if len(snapshots[lap]) > 0 {
final = snapshots[lap]
break
}
}
}
var candidates []swingCandidate
seenDriver := map[int]bool{}
for lap := decisiveAfterLap + 1; lap <= totalLaps; lap++ {
prev := snapshots[lap-1]
curr := snapshots[lap]
if len(prev) == 0 || len(curr) == 0 {
continue
}
for driver, pos := range curr {
prevPos, ok := prev[driver]
if !ok || prevPos <= pos || pos > 5 || pos <= 0 || seenDriver[driver] {
continue
}
finalPos, ok := final[driver]
if !ok || finalPos > pos {
continue
}
overtaken := driverAtPosition(curr, prevPos, driver)
drivers := []int{driver}
if overtaken != 0 {
drivers = append(drivers, overtaken)
}
candidates = append(candidates, swingCandidate{
chapter: Chapter{
Kind: KindDecisiveSwing,
Title: fmt.Sprintf("Decisive swing: #%d to P%d (L%d)", driver, pos, lap),
StartLap: lap,
EndLap: lap,
StartTime: idx.lapStart(lap),
EndTime: idx.lapEnd(lap),
DriverNumbers: drivers,
},
significance: (prevPos-pos)*10 + (6 - pos),
})
seenDriver[driver] = true
}
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].significance == candidates[j].significance {
return candidates[i].chapter.StartLap < candidates[j].chapter.StartLap
}
return candidates[i].significance > candidates[j].significance
})
if len(candidates) > maxDecisiveSwings {
candidates = candidates[:maxDecisiveSwings]
}
out := make([]Chapter, 0, len(candidates))
for _, c := range candidates {
out = append(out, c.chapter)
}
return out
}
func buildPositionSnapshots(positions []PositionSample, idx lapIndex, totalLaps int) map[int]map[int]int {
byLap := map[int][]PositionSample{}
for _, p := range positions {
if p.Position <= 0 {
continue
}
lap := idx.lapForTime(p.Date)
if lap <= 0 || lap > totalLaps {
continue
}
byLap[lap] = append(byLap[lap], p)
}
last := map[int]int{}
snapshots := map[int]map[int]int{}
for lap := 1; lap <= totalLaps; lap++ {
for _, p := range byLap[lap] {
last[p.DriverNumber] = p.Position
}
if len(last) == 0 {
continue
}
cp := make(map[int]int, len(last))
for driver, pos := range last {
cp[driver] = pos
}
snapshots[lap] = cp
}
return snapshots
}
func driverAtPosition(snapshot map[int]int, pos int, exclude int) int {
for driver, driverPos := range snapshot {
if driver != exclude && driverPos == pos {
return driver
}
}
return 0
}
func detectFinish(rc []RaceControl, idx lapIndex, totalLaps int) Chapter {
finishLap := totalLaps
finishTime := idx.lapEnd(totalLaps)
for _, msg := range rc {
text := upperText(string(msg.Flag), msg.Message)
if strings.Contains(text, "CHEQUER") || string(msg.Flag) == string(models.FlagChequered) {
if lap := messageLap(msg, idx); lap > 0 {
finishLap = lap
}
finishTime = firstNonEmpty(msg.Date, finishTime)
}
}
startLap := finishLap - 1
if startLap < 1 {
startLap = 1
}
return Chapter{
Kind: KindFinish,
Title: fmt.Sprintf("Finish (L%d-L%d)", startLap, finishLap),
StartLap: startLap,
EndLap: finishLap,
StartTime: idx.lapStart(startLap),
EndTime: finishTime,
}
}
func resolveConflicts(chapters []Chapter) []Chapter {
normalized := make([]Chapter, 0, len(chapters))
for _, ch := range chapters {
if ch.StartLap <= 0 {
ch.StartLap = 1
}
if ch.EndLap <= 0 {
ch.EndLap = ch.StartLap
}
if ch.EndLap < ch.StartLap {
ch.EndLap = ch.StartLap
}
if ch.DriverNumbers == nil {
ch.DriverNumbers = []int{}
}
normalized = append(normalized, ch)
}
sort.SliceStable(normalized, func(i, j int) bool {
if normalized[i].StartLap == normalized[j].StartLap {
return priority(normalized[i].Kind) > priority(normalized[j].Kind)
}
return normalized[i].StartLap < normalized[j].StartLap
})
out := make([]Chapter, 0, len(normalized))
for _, ch := range normalized {
if len(out) == 0 {
out = append(out, ch)
continue
}
last := &out[len(out)-1]
if ch.StartLap > last.EndLap {
out = append(out, ch)
continue
}
if isFlag(last.Kind) && priority(ch.Kind) < priority(last.Kind) {
continue
}
if priority(ch.Kind) > priority(last.Kind) {
if last.StartLap < ch.StartLap {
last.EndLap = ch.StartLap - 1
out = append(out, ch)
} else {
*last = ch
}
continue
}
if ch.EndLap > last.EndLap {
ch.StartLap = last.EndLap + 1
if ch.StartLap <= ch.EndLap {
out = append(out, ch)
}
}
}
return out
}
func isFlag(kind string) bool {
return kind == KindSafetyCar || kind == KindVirtualSafetyCar || kind == KindRedFlag
}
func priority(kind string) int {
switch kind {
case KindStart, KindFinish:
return structuralPriority
case KindSafetyCar, KindVirtualSafetyCar, KindRedFlag:
return flagPriority
case KindPitPhase:
return pitPhasePriority
case KindDecisiveSwing:
return decisivePriority
default:
return 0
}
}
func messageLap(msg RaceControl, idx lapIndex) int {
if msg.LapNumber != nil && *msg.LapNumber > 0 {
return *msg.LapNumber
}
return idx.lapForTime(msg.Date)
}
func clampLap(lap, minLap, maxLap int) int {
if lap < minLap {
return minLap
}
if maxLap > 0 && lap > maxLap {
return maxLap
}
return lap
}
func parseTime(raw string) (time.Time, bool) {
if raw == "" {
return time.Time{}, false
}
at, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}, false
}
return at, true
}
func upperText(parts ...string) string {
return strings.ToUpper(strings.Join(parts, " "))
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func sortedDriverNumbers(drivers map[int]bool) []int {
out := make([]int, 0, len(drivers))
for driver := range drivers {
out = append(out, driver)
}
sort.Ints(out)
return out
}
func sliceToSet(values []int) map[int]bool {
out := make(map[int]bool, len(values))
for _, value := range values {
out[value] = true
}
return out
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -0,0 +1,218 @@
package chapters
import (
"fmt"
"testing"
"github.com/AmanTahiliani/box-box/internal/models"
)
func TestDetectStructuralChapters(t *testing.T) {
chapters := Detect(nil, nil, testLaps(10, nil), 10)
if len(chapters) < 2 {
t.Fatalf("chapters len = %d, want at least start and finish", len(chapters))
}
if got := chapters[0]; got.Kind != KindStart || got.StartLap != 1 || got.EndLap != 1 {
t.Fatalf("start chapter = %+v, want L1-L1", got)
}
got := chapters[len(chapters)-1]
if got.Kind != KindFinish || got.StartLap != 9 || got.EndLap != 10 {
t.Fatalf("finish chapter = %+v, want L9-L10", got)
}
}
func TestDetectReturnsEmptyWithoutData(t *testing.T) {
if chapters := Detect(nil, nil, nil, 0); len(chapters) != 0 {
t.Fatalf("chapters = %+v, want empty", chapters)
}
}
func TestDetectFlagPeriods(t *testing.T) {
tests := []struct {
name string
start RaceControl
end RaceControl
wantKind string
wantTitle string
}{
{
name: "safety car",
start: rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
end: rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
wantKind: KindSafetyCar,
wantTitle: "Safety Car (L12-L15)",
},
{
name: "virtual safety car",
start: rc(22, models.CategoryOther, "", "VSC DEPLOYED"),
end: rc(24, models.CategoryOther, "", "VSC ENDING"),
wantKind: KindVirtualSafetyCar,
wantTitle: "Virtual Safety Car (L22-L24)",
},
{
name: "red flag",
start: rc(31, models.CategoryFlag, models.FlagRed, "RED FLAG"),
end: rc(33, models.CategoryFlag, models.FlagGreen, "GREEN FLAG"),
wantKind: KindRedFlag,
wantTitle: "Red Flag (L31-L33)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
chapters := Detect([]RaceControl{tt.start, tt.end}, nil, testLaps(40, nil), 40)
got := findKind(chapters, tt.wantKind)
if got == nil {
t.Fatalf("chapters = %+v, want %s", chapters, tt.wantKind)
}
if got.StartLap != rcLap(tt.start) || got.EndLap != rcLap(tt.end) || got.Title != tt.wantTitle {
t.Fatalf("flag chapter = %+v, want %s", *got, tt.wantTitle)
}
})
}
}
func TestDetectPitPhaseFromPitOutLapCluster(t *testing.T) {
pitOuts := map[int][]int{
5: {1},
10: {2},
20: {3},
21: {4},
22: {5},
30: {6},
35: {7},
40: {8},
45: {9},
50: {10},
}
chapters := Detect(nil, nil, testLaps(55, pitOuts), 55)
got := findKind(chapters, KindPitPhase)
if got == nil {
t.Fatalf("chapters = %+v, want pit phase", chapters)
}
if got.StartLap != 20 || got.EndLap != 22 {
t.Fatalf("pit phase = %+v, want L20-L22", *got)
}
if len(got.DriverNumbers) != 3 || got.DriverNumbers[0] != 3 || got.DriverNumbers[2] != 5 {
t.Fatalf("pit phase drivers = %v, want [3 4 5]", got.DriverNumbers)
}
}
func TestDetectDecisiveSwingPersistsToFinish(t *testing.T) {
positions := []PositionSample{
pos(1, 1, 1),
pos(1, 16, 4),
pos(1, 55, 3),
pos(6, 16, 3),
pos(6, 55, 4),
pos(8, 44, 5),
pos(8, 63, 6),
pos(10, 44, 6),
}
chapters := Detect(nil, positions, testLaps(12, nil), 12)
got := findKind(chapters, KindDecisiveSwing)
if got == nil {
t.Fatalf("chapters = %+v, want decisive swing", chapters)
}
if got.StartLap != 6 || got.EndLap != 6 {
t.Fatalf("swing lap = %+v, want L6", *got)
}
if len(got.DriverNumbers) != 2 || got.DriverNumbers[0] != 16 || got.DriverNumbers[1] != 55 {
t.Fatalf("swing drivers = %v, want [16 55]", got.DriverNumbers)
}
}
func TestDetectFlagPeriodsWinOverConflictingChapters(t *testing.T) {
pitOuts := map[int][]int{
12: {1, 2},
13: {3, 4},
14: {5, 6},
}
rcs := []RaceControl{
rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
}
chapters := Detect(rcs, nil, testLaps(20, pitOuts), 20)
if got := findKind(chapters, KindSafetyCar); got == nil || got.StartLap != 12 || got.EndLap != 15 {
t.Fatalf("chapters = %+v, want safety car L12-L15", chapters)
}
if got := findKind(chapters, KindPitPhase); got != nil {
t.Fatalf("pit phase = %+v, want omitted under safety car", *got)
}
for i := 1; i < len(chapters); i++ {
if chapters[i].StartLap <= chapters[i-1].EndLap {
t.Fatalf("chapters overlap at %d: %+v then %+v", i, chapters[i-1], chapters[i])
}
}
}
func rcLap(r RaceControl) int {
if r.LapNumber == nil {
return 0
}
return *r.LapNumber
}
func findKind(chapters []Chapter, kind string) *Chapter {
for i := range chapters {
if chapters[i].Kind == kind {
return &chapters[i]
}
}
return nil
}
func testLaps(total int, pitOuts map[int][]int) []Lap {
var laps []Lap
for lap := 1; lap <= total; lap++ {
drivers := []int{1}
if pitDrivers := pitOuts[lap]; len(pitDrivers) > 0 {
drivers = pitDrivers
}
for _, driver := range drivers {
laps = append(laps, Lap{
DriverNumber: driver,
LapNumber: lap,
DateStart: lapTime(lap),
IsPitOutLap: containsDriver(pitOuts[lap], driver),
})
}
}
return laps
}
func rc(lap int, category models.RaceControlCategory, flag models.Flag, message string) RaceControl {
return RaceControl{
Category: category,
Flag: flag,
Message: message,
LapNumber: &lap,
Date: lapTime(lap),
}
}
func pos(lap int, driver int, position int) PositionSample {
return PositionSample{
DriverNumber: driver,
Position: position,
Date: lapTime(lap),
}
}
func lapTime(lap int) string {
minute := lap - 1
return fmt.Sprintf("2025-05-25T13:%02d:00Z", minute)
}
func containsDriver(drivers []int, driver int) bool {
for _, candidate := range drivers {
if candidate == driver {
return true
}
}
return false
}

View File

@@ -0,0 +1,233 @@
package chapters
import (
"fmt"
"strings"
"github.com/AmanTahiliani/box-box/internal/models"
)
// DriverIdentityInput carries session driver fields used for headline templates.
type DriverIdentityInput struct {
DriverNumber int
NameAcronym string
FullName string
TeamName string
}
type driverIdentity struct {
display string
team string
acronym string
}
// BuildDriverMap indexes driver identity from session drivers and enriched results.
func BuildDriverMap(drivers []models.Driver, results []DriverIdentityInput) map[int]driverIdentity {
out := map[int]driverIdentity{}
for _, d := range drivers {
if d.DriverNumber <= 0 {
continue
}
out[d.DriverNumber] = driverIdentity{
display: driverDisplayName(d.LastName, d.FullName, d.NameAcronym, d.BroadcastName),
team: d.TeamName,
acronym: firstNonEmpty(d.NameAcronym, d.BroadcastName),
}
}
for _, r := range results {
if r.DriverNumber <= 0 {
continue
}
if _, ok := out[r.DriverNumber]; ok {
continue
}
out[r.DriverNumber] = driverIdentity{
display: driverDisplayName("", r.FullName, r.NameAcronym, ""),
team: r.TeamName,
acronym: r.NameAcronym,
}
}
return out
}
// ApplyHeadlines fills Headline on each chapter using deterministic templates.
func ApplyHeadlines(chapters []Chapter, drivers map[int]driverIdentity, rc []RaceControl, winnerNumber int) []Chapter {
out := make([]Chapter, len(chapters))
copy(out, chapters)
for i := range out {
out[i].Headline = headlineFor(out[i], drivers, rc, winnerNumber)
}
return out
}
func headlineFor(ch Chapter, drivers map[int]driverIdentity, rc []RaceControl, winnerNumber int) string {
switch ch.Kind {
case KindStart:
return pickVariant(ch.StartLap,
"Lights out — the field charges into Turn 1",
"Race start — Lap 1 shuffle at the front",
)
case KindSafetyCar:
if name := driverName(ch, drivers, incidentDriver(rc, ch.StartLap)); name != "" {
return pickVariant(ch.StartLap,
fmt.Sprintf("%s incident brings out the Safety Car — leaders dive for the pits", name),
fmt.Sprintf("Safety Car deployed after %s stops on track", name),
)
}
case KindVirtualSafetyCar:
if name := driverName(ch, drivers, incidentDriver(rc, ch.StartLap)); name != "" {
return pickVariant(ch.StartLap,
fmt.Sprintf("%s off track triggers the Virtual Safety Car", name),
fmt.Sprintf("Virtual Safety Car — %s loses control on Lap %d", name, ch.StartLap),
)
}
return pickVariant(ch.StartLap,
fmt.Sprintf("Virtual Safety Car deployed on Lap %d", ch.StartLap),
fmt.Sprintf("VSC period — field backs off on L%dL%d", ch.StartLap, ch.EndLap),
)
case KindRedFlag:
if name := driverName(ch, drivers, incidentDriver(rc, ch.StartLap)); name != "" {
return pickVariant(ch.StartLap,
fmt.Sprintf("%s crash forces a red flag on Lap %d", name, ch.StartLap),
fmt.Sprintf("Red flag after %s incident on Lap %d", name, ch.StartLap),
)
}
return pickVariant(ch.StartLap,
fmt.Sprintf("Red flag — session halted on Lap %d", ch.StartLap),
fmt.Sprintf("Race suspended under red flag on L%dL%d", ch.StartLap, ch.EndLap),
)
case KindPitPhase:
names := driverNames(ch.DriverNumbers, drivers, 3)
if len(names) >= 2 {
return pickVariant(ch.StartLap,
fmt.Sprintf("Mass pit-window scramble — %s and %s box on L%dL%d", names[0], names[1], ch.StartLap, ch.EndLap),
fmt.Sprintf("Undercut window opens — %s leads the pit rush on L%dL%d", names[0], ch.StartLap, ch.EndLap),
)
}
if len(names) == 1 {
return pickVariant(ch.StartLap,
fmt.Sprintf("%s pits under green — strategy window opens on L%d", names[0], ch.StartLap),
fmt.Sprintf("Pit phase on L%dL%d — %s among the first to stop", ch.StartLap, ch.EndLap, names[0]),
)
}
case KindDecisiveSwing:
if len(ch.DriverNumbers) >= 2 {
attacker := driverName(ch, drivers, ch.DriverNumbers[0])
defender := driverName(ch, drivers, ch.DriverNumbers[1])
pos := swingPosition(ch.Title)
if attacker != "" && defender != "" && pos > 0 {
return pickVariant(ch.StartLap,
fmt.Sprintf("%s overtakes %s for P%d", attacker, defender, pos),
fmt.Sprintf("%s charges past %s into P%d on Lap %d", attacker, defender, pos, ch.StartLap),
)
}
}
if len(ch.DriverNumbers) >= 1 {
attacker := driverName(ch, drivers, ch.DriverNumbers[0])
pos := swingPosition(ch.Title)
if attacker != "" && pos > 0 {
return pickVariant(ch.StartLap,
fmt.Sprintf("%s moves up to P%d on Lap %d", attacker, pos, ch.StartLap),
fmt.Sprintf("Decisive swing — %s climbs to P%d", attacker, pos),
)
}
}
case KindFinish:
winner := driverName(ch, drivers, winnerNumber)
if winner != "" {
return pickVariant(ch.StartLap,
fmt.Sprintf("%s crosses the line to take the win", winner),
fmt.Sprintf("Chequered flag — %s wins the race", winner),
)
}
}
return ch.Title
}
func pickVariant(seed int, variants ...string) string {
if len(variants) == 0 {
return ""
}
if seed < 0 {
seed = -seed
}
return variants[seed%len(variants)]
}
func driverName(_ Chapter, drivers map[int]driverIdentity, number int) string {
if number <= 0 {
return ""
}
if id, ok := drivers[number]; ok && id.display != "" {
return id.display
}
return ""
}
func driverNames(numbers []int, drivers map[int]driverIdentity, limit int) []string {
out := make([]string, 0, limit)
for _, number := range numbers {
if name := driverName(Chapter{}, drivers, number); name != "" {
out = append(out, name)
if len(out) >= limit {
break
}
}
}
return out
}
func driverDisplayName(lastName, fullName, acronym, broadcast string) string {
if lastName != "" {
return lastName
}
if fullName != "" {
parts := strings.Fields(fullName)
if len(parts) > 0 {
return parts[len(parts)-1]
}
return fullName
}
return firstNonEmpty(acronym, broadcast)
}
func incidentDriver(rc []RaceControl, startLap int) int {
for _, msg := range rc {
if msg.DriverNumber == nil || *msg.DriverNumber <= 0 {
continue
}
lap := 0
if msg.LapNumber != nil {
lap = *msg.LapNumber
}
if lap < startLap-1 || lap > startLap+1 {
continue
}
text := upperText(msg.Message, string(msg.Category))
if strings.Contains(text, "DEPLOY") ||
strings.Contains(text, "CLEAR") ||
strings.Contains(text, "ENDING") ||
strings.Contains(text, "GREEN") ||
strings.Contains(text, "CHEQUER") {
continue
}
return *msg.DriverNumber
}
return 0
}
func swingPosition(title string) int {
// Title format: "Decisive swing: #16 to P3 (L6)"
idx := strings.Index(title, "P")
if idx < 0 || idx+1 >= len(title) {
return 0
}
pos := 0
for i := idx + 1; i < len(title); i++ {
if title[i] < '0' || title[i] > '9' {
break
}
pos = pos*10 + int(title[i]-'0')
}
return pos
}

View File

@@ -0,0 +1,199 @@
package chapters
import (
"testing"
"github.com/AmanTahiliani/box-box/internal/models"
)
func TestHeadlineStart(t *testing.T) {
ch := Chapter{Kind: KindStart, Title: "Start", StartLap: 1, EndLap: 1}
got := headlineFor(ch, nil, nil, 0)
want := "Race start — Lap 1 shuffle at the front"
if got != want {
t.Fatalf("headline = %q, want %q", got, want)
}
}
func TestHeadlineStartVariantByLap(t *testing.T) {
ch := Chapter{Kind: KindStart, Title: "Start", StartLap: 2, EndLap: 2}
got := headlineFor(ch, nil, nil, 0)
want := "Lights out — the field charges into Turn 1"
if got != want {
t.Fatalf("headline = %q, want %q", got, want)
}
}
func TestHeadlineSafetyCarWithDriver(t *testing.T) {
drivers := BuildDriverMap(nil, []DriverIdentityInput{
{DriverNumber: 55, NameAcronym: "SAI", FullName: "Carlos Sainz", TeamName: "Ferrari"},
})
rc := []RaceControl{
{
DriverNumber: intPtr(55),
LapNumber: intPtr(12),
Message: "CAR 55 STOPPED ON TRACK",
},
rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
}
ch := Chapter{
Kind: KindSafetyCar,
Title: "Safety Car (L12-L15)",
StartLap: 12,
EndLap: 15,
}
got := headlineFor(ch, drivers, rc, 0)
want := "Sainz incident brings out the Safety Car — leaders dive for the pits"
if got != want {
t.Fatalf("headline = %q, want %q", got, want)
}
}
func TestHeadlineSafetyCarFallbackWithoutDriver(t *testing.T) {
ch := Chapter{
Kind: KindSafetyCar,
Title: "Safety Car (L12-L15)",
StartLap: 12,
EndLap: 15,
}
got := headlineFor(ch, nil, nil, 0)
if got != ch.Title {
t.Fatalf("headline = %q, want fallback %q", got, ch.Title)
}
}
func TestHeadlineVirtualSafetyCar(t *testing.T) {
drivers := BuildDriverMap(nil, []DriverIdentityInput{
{DriverNumber: 16, NameAcronym: "LEC", FullName: "Charles Leclerc", TeamName: "Ferrari"},
})
rc := []RaceControl{
{
DriverNumber: intPtr(16),
LapNumber: intPtr(22),
Message: "CAR 16 OFF TRACK",
},
rc(22, models.CategoryOther, "", "VSC DEPLOYED"),
rc(24, models.CategoryOther, "", "VSC ENDING"),
}
ch := Chapter{
Kind: KindVirtualSafetyCar,
Title: "Virtual Safety Car (L22-L24)",
StartLap: 22,
EndLap: 24,
}
got := headlineFor(ch, drivers, rc, 0)
want := "Leclerc off track triggers the Virtual Safety Car"
if got != want {
t.Fatalf("headline = %q, want %q", got, want)
}
}
func TestHeadlineRedFlag(t *testing.T) {
drivers := BuildDriverMap(nil, []DriverIdentityInput{
{DriverNumber: 63, NameAcronym: "RUS", FullName: "George Russell", TeamName: "Mercedes"},
})
rc := []RaceControl{
{
DriverNumber: intPtr(63),
LapNumber: intPtr(31),
Message: "INCIDENT INVOLVING CAR 63",
},
rc(31, models.CategoryFlag, models.FlagRed, "RED FLAG"),
rc(33, models.CategoryFlag, models.FlagGreen, "GREEN FLAG"),
}
ch := Chapter{
Kind: KindRedFlag,
Title: "Red Flag (L31-L33)",
StartLap: 31,
EndLap: 33,
}
got := headlineFor(ch, drivers, rc, 0)
want := "Red flag after Russell incident on Lap 31"
if got != want {
t.Fatalf("headline = %q, want %q", got, want)
}
}
func TestHeadlinePitPhase(t *testing.T) {
drivers := BuildDriverMap(nil, []DriverIdentityInput{
{DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull"},
{DriverNumber: 44, NameAcronym: "HAM", FullName: "Lewis Hamilton", TeamName: "Mercedes"},
{DriverNumber: 16, NameAcronym: "LEC", FullName: "Charles Leclerc", TeamName: "Ferrari"},
})
ch := Chapter{
Kind: KindPitPhase,
Title: "Pit phase (L20-L22)",
StartLap: 20,
EndLap: 22,
DriverNumbers: []int{1, 44, 16},
}
got := headlineFor(ch, drivers, nil, 0)
want := "Mass pit-window scramble — Verstappen and Hamilton box on L20L22"
if got != want {
t.Fatalf("headline = %q, want %q", got, want)
}
}
func TestHeadlineDecisiveSwing(t *testing.T) {
drivers := BuildDriverMap(nil, []DriverIdentityInput{
{DriverNumber: 16, NameAcronym: "LEC", FullName: "Charles Leclerc", TeamName: "Ferrari"},
{DriverNumber: 55, NameAcronym: "SAI", FullName: "Carlos Sainz", TeamName: "Ferrari"},
})
ch := Chapter{
Kind: KindDecisiveSwing,
Title: "Decisive swing: #16 to P3 (L6)",
StartLap: 6,
EndLap: 6,
DriverNumbers: []int{16, 55},
}
got := headlineFor(ch, drivers, nil, 0)
want := "Leclerc overtakes Sainz for P3"
if got != want {
t.Fatalf("headline = %q, want %q", got, want)
}
}
func TestHeadlineFinish(t *testing.T) {
drivers := BuildDriverMap(nil, []DriverIdentityInput{
{DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull"},
})
ch := Chapter{
Kind: KindFinish,
Title: "Finish (L57-L58)",
StartLap: 57,
EndLap: 58,
}
got := headlineFor(ch, drivers, nil, 1)
want := "Chequered flag — Verstappen wins the race"
if got != want {
t.Fatalf("headline = %q, want %q", got, want)
}
}
func TestHeadlineFinishFallbackWithoutWinner(t *testing.T) {
ch := Chapter{
Kind: KindFinish,
Title: "Finish (L57-L58)",
StartLap: 57,
EndLap: 58,
}
got := headlineFor(ch, nil, nil, 0)
if got != ch.Title {
t.Fatalf("headline = %q, want fallback %q", got, ch.Title)
}
}
func TestApplyHeadlinesPreservesChapterFields(t *testing.T) {
input := []Chapter{
{Kind: KindStart, Title: "Start", StartLap: 1, EndLap: 1},
}
got := ApplyHeadlines(input, nil, nil, 0)
if len(got) != 1 || got[0].StartLap != 1 || got[0].Headline == "" {
t.Fatalf("ApplyHeadlines = %+v, want headline on start chapter", got)
}
}
func intPtr(v int) *int {
return &v
}

Some files were not shown because too many files have changed in this diff Show More