Compare commits

...

77 Commits

Author SHA1 Message Date
Aman Tahiliani
480e6ca860 Merge pull request #99 from AmanTahiliani/feat/issue-98-keep-completed-analysis-as-the-race-hub-
Keep completed analysis as the Race Hub default until the next Grand Prix is imminent (#98)
2026-07-29 03:58:30 -04:00
AmanTahiliani
81deed4c75 fix(#98): rearm capped race hub refresh 2026-07-29 03:56:22 -04:00
AmanTahiliani
d6d0558c72 fix(#98): expose weekend switcher before sessions 2026-07-29 03:52:51 -04:00
AmanTahiliani
b884ba8885 fix(#98): preserve bare race hub handoff 2026-07-29 03:46:57 -04:00
AmanTahiliani
56ea860101 feat(#98): Keep completed analysis as the Race Hub default until the next Grand Prix is imminent
Implemented by opencode via .agents/dev dispatch.
2026-07-29 03:28:42 -04:00
Aman Tahiliani
7c7886e6c6 Merge pull request #95 from AmanTahiliani/fix/live-stint-history-merge
fix(live): merge stint deltas instead of replacing stint history
2026-07-29 01:25:12 -04:00
Aman Tahiliani
71f81924ee Merge pull request #94 from AmanTahiliani/feat/frontend-design-uxpass
Live page design pass: give the timing tower the fold back
2026-07-29 01:25:09 -04:00
Aman Tahiliani
7b86698b22 Merge pull request #93 from AmanTahiliani/feat/issue-76-availability-truth-backend
Harvest availability and freshness truth, backend only (re-cut of #76)
2026-07-29 01:25:06 -04:00
Aman Tahiliani
e88e0885ec Merge pull request #92 from AmanTahiliani/feat/issue-72-canonical-weekend-context
Harvest canonical Weekend Context API onto main (re-cut of #72)
2026-07-29 01:24:54 -04:00
AmanTahiliani
255b296ecc fix(live): merge stint deltas instead of replacing stint history
The F1 feed sends TimingAppData stints as sparse deltas keyed by stint
index — a mid-stint update is just {"1": {"TotalLaps": 14}}. The parser
replaced the whole stint slice with whatever a delta carried, so pit
history collapsed to a single entry and tyre age was pinned near zero for
the entire race. A partial delta was also dropped outright, because the
parser required a Compound field that mid-stint updates do not send.

Observed live at lap 49 of the 70-lap 2026 Hungarian GP: all 22 drivers
reported exactly one stint with age 0 or 3, after most had pitted twice.
With the fix, the same feed at lap 51 yields 3 stints for 15 drivers and
2 for 6, ages spread 1-30 — e.g. car 1 as MEDIUM 17 / HARD 22 / HARD 11.

Stints now merge by index, and Compound, New and TotalLaps each apply only
when the delta actually carries them.

Also stop folding non-numeric keys into index 0 in indexedRawValues. The
feed's "_kf" key-frame marker parsed as 0 and overwrote the first entry;
only the CurrentTyres path guarded against it, leaving the other five
callers exposed.

This feeds the tyre column, the deg model, stint history and the pit
window, all of which were reading near-zero tyre age all race.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 10:16:38 -04:00
AmanTahiliani
888378e210 fix(live): give the timing tower the fold back
The tyre deg panel expanded by default during races and rendered a
placeholder row per driver until enough clean laps existed to fit a
degradation model. For the first third of a race that was ~525px of
'warming up' rows above the Timing Tower, pushing the tower — the thing
the page exists for — off the fold entirely.

The panel now opens when it has something to say rather than because the
session is a race, and says 'collecting clean laps' while it waits. A
reader who toggles it keeps their choice.

Also:
- Hoist degradationModel into a memo shared by the readiness check and the
  rows. It is O(laps) per driver and previously re-ran on every render at
  feed rate.
- Order the side rail most-synthesized to most-raw, so a reader arriving
  mid-session gets 'what just happened' before the regulatory log.
- Fade the trailing edge of the nav below 560px. It scrolls with a hidden
  scrollbar, so the cut-off item read as a clipping bug rather than as
  scrollable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 09:23:17 -04:00
AmanTahiliani
77a6b0f2dd feat(#76): harvest request-scoped availability and freshness truth
Backend-only re-cut of the #76 availability work onto main, stacked on the
canonical Weekend Context API. Adds request-scoped freshness reporting so
aggregate responses cannot report fresh when a component is stale, plus
local-first driver summary resolution and cache/pacing truth.

The frontend half of #76 is deliberately excluded: it is built on the
Weekend shell that failed owner review, including the full-width Partial
banner treatment. Availability presentation is re-cut with the shell in #89.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 09:14:37 -04:00
AmanTahiliani
d03e480e03 test(#90): regress analysis selection against later unrun race
Reproduces the production shape from the v0.4.0 release blocker: a run
British race and an unrun Belgian race two weekends apart, both carrying
result rows. Analysis must resolve to the race that actually happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 09:11:03 -04:00
AmanTahiliani
0b9c3fff0a fix: make weekend context transitions truthful 2026-07-26 09:08:38 -04:00
AmanTahiliani
adfa934f9d feat: add canonical weekend context API 2026-07-26 09:08:38 -04:00
Aman Tahiliani
c475011c49 Merge pull request #88 from AmanTahiliani/feat/issue-87-live-correct-fp-q-timing-truth-hierarchy
[Live] Correct FP/Q timing truth, hierarchy, and mobile navigation (#87)
2026-07-17 12:08:45 -04:00
AmanTahiliani
5a6323d3b4 fix(live): keep red-flagged sessions active 2026-07-17 12:04:13 -04:00
AmanTahiliani
ed3b8cf628 feat(#87): [Live] Correct FP/Q timing truth, hierarchy, and mobile navigation
Implemented by claude via .agents/dev dispatch.
2026-07-17 11:53:38 -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
AmanTahiliani
91199ff5c0 chore: add F1 live socket probe 2026-07-04 07:22:25 -04:00
AmanTahiliani
144e93ee44 fix: hide live track map while GPS unavailable 2026-07-04 07:17:28 -04:00
Aman Tahiliani
f4f5071e26 Merge pull request #53 from AmanTahiliani/feat/issue-52-fix-stale-live-tab-sessions-and-add-last
Fix stale Live tab sessions and add last-session archive view (#52)
2026-07-04 01:44:21 -04:00
AmanTahiliani
a1d71900d1 fix: separate live archive snapshots
Parse and expose SessionStatus from the official live feed, treating Started/Resumed as active and terminal or missing statuses as inactive archive candidates.

Keep /api/v1/live/state and SSE snapshot data reserved for active sessions while exposing memory-only last_snapshot, last_positions, and last_snapshot_at for the explicit Live tab archive view.
2026-07-04 01:40:58 -04:00
Aman Tahiliani
34b060238a Merge pull request #51 from AmanTahiliani/feat/issue-47-disable-or-repair-agy-headless-harness
Disable or repair agy headless harness path (#47)
2026-07-04 01:17:56 -04:00
Aman Tahiliani
a9920f0dd4 Merge pull request #49 from AmanTahiliani/feat/issue-46-handle-null-reference-laps-in-delta-time
Handle null reference laps in delta-time graph (#46)
2026-07-04 01:17:53 -04:00
Aman Tahiliani
7c8a75f7ca Merge pull request #48 from AmanTahiliani/feat/issue-45-restore-pit-stop-markers-on-strategy-st
Restore pit-stop markers on strategy stint timeline (#45)
2026-07-04 01:17:51 -04:00
Aman Tahiliani
e0174f7bf1 Merge pull request #50 from AmanTahiliani/feat/issue-44-web-track-outline-cache-warming-for-web-o
Web track-outline cache warming for web-only deployments (#44)
2026-07-04 01:17:49 -04:00
AmanTahiliani
2d6fa531f2 fix(compare): gap deltas beyond reference laps 2026-07-04 01:16:01 -04:00
AmanTahiliani
8fd358748d fix(agents): disable agy implementation dispatch
Codex handoff could not write .agents in its sandbox, so the orchestrator applied the intended fail-fast change: agy remains available for dry-run prompt inspection but non-dry-run dispatch exits before worktree or PR side effects.
2026-07-04 01:11:03 -04:00
AmanTahiliani
b0fd252096 feat(cli): add track outline cache warmer
Spike: existing TUI prefetch stored outlines under time.Now().Year(), so the CLI uses a new explicit year-aware prefetch path and the TUI wrapper now derives the year from meetings when available.
2026-07-04 01:06:19 -04:00
AmanTahiliani
ad379e0f07 fix(compare): gap delta graph when reference lap is null
Reference-null laps now emit null deltas for all drivers and exclude that
lap window from aligned cumulative totals so later comparisons stay sane.
Challenger-null behavior is unchanged. Compare tab meta documents the policy.

Closes #46

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 01:04:55 -04:00
AmanTahiliani
9ee14e5c89 fix(frontend): restore pit-stop markers on strategy stint timeline (#45)
Wire Race Hub pit_stops into TyreStintTimeline via optional per-row
pitStops laps; render vertical markers at stint boundaries with driver/lap
tooltips. Matches pre-extraction positioning ((lap-1)/totalLaps).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 01:04:13 -04:00
Aman Tahiliani
cc4337be88 Merge pull request #43 from AmanTahiliani/phase-1
Phase 1 delivery
2026-07-04 00:37:54 -04:00
AmanTahiliani
addfd6d24d fix(compare): reset driver pair on session change 2026-07-04 00:37:12 -04:00
AmanTahiliani
b5d070e116 docs: add phase 1 PR screenshots 2026-07-04 00:32:20 -04:00
AmanTahiliani
233eefaf12 test: update phase 1 visual snapshots 2026-07-04 00:28:09 -04:00
Aman Tahiliani
51b0238b09 Merge pull request #42 from AmanTahiliani/feat/issue-18-annotate-every-number-with-meaning-ux-p
"Annotate every number with meaning" UX pass (#18)
2026-07-04 00:25:39 -04:00
AmanTahiliani
24bcac8038 feat(frontend): annotate key numbers with meaning (#18)
Add shared Meaning primitive and pure interpretation helpers for interval,
tyre age, and championship gap columns on the live tower, tyre deg panel,
and championship hub. Thresholds are exported consts (undercut window tied
to PIT_LOSS_SECONDS from #13).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 00:24:02 -04:00
Aman Tahiliani
93ac0ccebb Merge pull request #41 from AmanTahiliani/feat/issue-10-telemetry-compare-overlay-speed-throttle
Telemetry compare overlay (speed/throttle/brake + delta) (#10)
2026-07-04 00:20:52 -04:00
AmanTahiliani
66bf649729 feat(race-hub): add Compare tab with telemetry traces and lap deltas (#10)
Ship driver comparison in the race hub: two pickers (default top finishers),
best-lap telemetry overlays via TelemetryTraceChart, and cumulative pace
delta via DeltaTimeGraph with pit-lap captions. Car data is filtered to each
driver's fastest lap client-side (OpenF1 returns full-session samples).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 00:18:10 -04:00
Aman Tahiliani
2ddc930efe Merge pull request #40 from AmanTahiliani/feat/issue-11-what-just-happened-synthesized-event-rai
"What just happened" synthesized event rail (#11)
2026-07-04 00:10:06 -04:00
AmanTahiliani
7d0b25b66e fix(test): align events fixture with LiveSessionMeta.Path / TeamRadio from phase-1 2026-07-04 00:09:51 -04:00
AmanTahiliani
bc18a20829 Merge phase-1 (radio ticker, tyre-deg panel, hero) into event-rail branch 2026-07-04 00:08:24 -04:00
Aman Tahiliani
2795ccf11b Merge pull request #39 from AmanTahiliani/feat/issue-13-live-tyre-degradation-pit-window-panel
Live tyre-degradation & pit-window panel (#13)
2026-07-04 00:07:11 -04:00
AmanTahiliani
2d49a8bd00 feat(#11): "What just happened" synthesized event rail
Implemented by claude via .agents/dev dispatch.
2026-07-04 00:00:34 -04:00
161 changed files with 17125 additions and 597 deletions

View File

@@ -24,7 +24,7 @@ bin/dev CLI: `dev implement <issue#> --harness <name> [--dry-run]`
- **Groom** (interactive, Claude): `/groom <issue#>` runs a seeded grill-me → writes a
Ready spec into the issue body → sets Effort/Priority → leaves Stage at `Research`.
You review and flip to `Ready`.
- **Implement** (any harness): `.agents/bin/dev implement <issue#> --harness <name>`
- **Implement** (recommended harnesses: `codex` or `cursor`): `.agents/bin/dev implement <issue#> --harness <name>`
(or `/implement …` in Claude to supervise) → isolated worktree → runs the harness
headless on the spec → build gate → opens a PR → sets Stage `In Review`.
- **Review + merge**: use the `review` skill from a harness different from the
@@ -42,8 +42,17 @@ local adapter state.
## Adding / fixing a harness
Edit one function in `harnesses.sh`: `harness_<name> <workdir> <promptfile>`, running the
tool non-interactively in `<workdir>` on the prompt. `claude`/`codex`/`opencode` are
wired; `pi`/`cursor` are stubs — confirm their headless flags before trusting.
tool non-interactively in `<workdir>` on the prompt. For current implementation
dispatch, prefer `codex` or `cursor`; `claude` and `opencode` remain available, and
`pi` still needs flag verification before trusting.
`agy` / Antigravity is deliberately disabled for non-dry-run dispatch as of
2026-07-04. Phase 1 testing found the headless path unreliable: with
`--new-project` it ignored the prompt and tried to scaffold, while without it the CLI
could resume a stale conversation and hang past the print timeout. Keep using
`.agents/bin/dev implement <issue#> --harness agy --dry-run` for prompt inspection
only; real dispatch should use `codex` or `cursor` until a fresh Antigravity
headless invocation is verified and documented.
Always `--dry-run` a new harness first: it renders the exact prompt and plan, touching
nothing (no worktree, PR, or state change).

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# .agents/bin/dev — box-box agentic dev CLI. Works from any harness's shell.
#
# dev implement <issue#> --harness <claude|codex|opencode|pi|cursor> [--dry-run] [--base <branch>]
# dev implement <issue#> --harness <codex|cursor|claude|opencode|pi|agy> [--dry-run] [--base <branch>]
#
# Grooming is driven interactively via the Claude Code /groom skill; this CLI covers
# the implement lane (dispatch a Ready issue to a harness → worktree → gate → PR).
@@ -15,7 +15,7 @@ box-box dev CLI
dev implement <issue#> --harness <name> [--dry-run] [--base <branch>]
harnesses: claude, codex, opencode (supported) · pi, cursor (verify flags in .agents/harnesses.sh)
harnesses: codex, cursor (recommended) · claude, opencode (available) · agy (dry-run only, disabled for dispatch) · pi (verify flags)
--dry-run render the prompt + plan, touch nothing (no worktree/PR/state change)
--base base branch for the worktree/PR (default: main)
EOF

View File

@@ -11,6 +11,18 @@
# ---- MUST-HAVE ----
harness_disabled_reason() { # <name> -> reason on stdout; 0 means disabled
case "$1" in
agy)
cat <<'EOF'
agy is disabled for implementation dispatch as of 2026-07-04: Antigravity headless mode was observed to ignore prompts with --new-project, resume stale conversations without it, and hang past print timeouts. Use codex or cursor until a fresh headless invocation is verified.
EOF
return 0
;;
*) return 1 ;;
esac
}
harness_claude() { # Claude Code — print mode, auto-accept edits
local dir="$1" prompt="$2"
( cd "$dir" && claude -p "$(cat "$prompt")" --permission-mode acceptEdits )
@@ -31,14 +43,11 @@ harness_cursor() { # Cursor CLI agent — composer-2.5, hea
( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --model composer-2.5 --force --trust )
}
harness_agy() { # Antigravity CLI — UNRELIABLE headless (2026-07): with
# --new-project it ignores the prompt and asks to scaffold a project; without it, it
# resumes the previous conversation (silently keeping its old model — --model only
# applies to new conversations) and can hang past the print timeout. Do not trust for
# dispatch until fixed upstream; verify with a trivial prompt first.
local dir="$1" prompt="$2"
( cd "$dir" && agy --print --print-timeout 60m \
--model="Gemini 3.1 Pro (High)" --dangerously-skip-permissions "$(cat "$prompt")" )
harness_agy() { # Antigravity CLI — disabled until headless is verified
local reason
reason="$(harness_disabled_reason agy)"
echo "harness_agy: $reason" >&2
return 2
}
# ---- NICE-TO-HAVE (verify the exact invocation for your version before trusting) ----

View File

@@ -52,6 +52,15 @@ dispatch() { # <issue#> <harness> [--dry-run] [--base <branch>]
if ! declare -f "harness_$harness" >/dev/null 2>&1; then
echo "no adapter for harness '$harness' — add harness_$harness() to .agents/harnesses.sh" >&2; return 2
fi
if [ "$dry" != 1 ] && declare -f harness_disabled_reason >/dev/null 2>&1; then
local disabled_reason
if disabled_reason="$(harness_disabled_reason "$harness")"; then
echo "harness '$harness' is disabled for non-dry-run dispatch." >&2
echo " $disabled_reason" >&2
echo " Use --dry-run for prompt inspection, or dispatch with --harness codex/cursor." >&2
return 2
fi
fi
local repo_root title body slug branch wt prompt
repo_root="$(git rev-parse --show-toplevel)" || return 1

View File

@@ -1,6 +1,6 @@
---
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]
---

View File

@@ -1,6 +1,6 @@
---
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]
---

View File

@@ -1,6 +1,6 @@
---
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>
---

View File

@@ -1,6 +1,6 @@
---
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]
---

View File

@@ -1,6 +1,6 @@
---
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>
---

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT"
# shellcheck source=/dev/null
source "$ROOT/.agents/lib/dispatch.sh"
issue_title() { echo "Disable agy harness smoke"; }
issue_body() { echo "## Spec"; echo; echo "Smoke prompt body"; }
get_field() { echo "Ready"; }
set_stage() { echo "unexpected set_stage $*" >&2; return 99; }
run_gate() { echo "unexpected run_gate $*" >&2; return 99; }
unexpected_git_file="$(mktemp "${TMPDIR:-/tmp}/boxbox-agy-git.XXXX")"
rm -f "$unexpected_git_file"
git() {
if [ "${1:-}" = "rev-parse" ]; then
command git "$@"
return
fi
echo "unexpected git $*" >&2
touch "$unexpected_git_file"
return 99
}
set +e
non_dry_output="$(dispatch 47 agy 2>&1)"
non_dry_status=$?
set -e
[ "$non_dry_status" -eq 2 ] || {
echo "expected agy non-dry-run to exit 2, got $non_dry_status" >&2
echo "$non_dry_output" >&2
exit 1
}
[[ "$non_dry_output" == *"harness 'agy' is disabled"* ]] || {
echo "expected disabled-harness message" >&2
echo "$non_dry_output" >&2
exit 1
}
[ ! -e "$unexpected_git_file" ] || {
echo "agy non-dry-run reached git before failing" >&2
echo "$non_dry_output" >&2
exit 1
}
dry_output="$(dispatch 47 agy --dry-run 2>&1)"
[[ "$dry_output" == *"[dry-run] no worktree / harness / PR / state change"* ]] || {
echo "expected agy dry-run to render dispatch preview" >&2
echo "$dry_output" >&2
exit 1
}
[[ "$dry_output" == *"Smoke prompt body"* ]] || {
echo "expected agy dry-run prompt body" >&2
echo "$dry_output" >&2
exit 1
}

View File

@@ -27,6 +27,7 @@ func main() {
ingestMeeting := flag.Int("ingest-meeting", 0, "Ingest meeting metadata and Race Hub datasets for all sessions")
ingestSession := flag.Int("ingest-session", 0, "Ingest Race Hub datasets for a session key")
ingestNews := flag.Bool("ingest-news", false, "Refresh RSS/Atom paddock briefing feeds")
prefetchTrackOutlines := flag.Int("prefetch-track-outlines", 0, "Warm the web track-outline cache for a season year (for web-only hosts, run before --web so /api/v1/track-outline can serve live maps)")
dryRun := flag.Bool("dry-run", false, "Preview ingestion without writing domain rows")
force := flag.Bool("force", false, "Re-ingest datasets even if already tracked in the session_coverage table as completed")
coverageYear := flag.Int("coverage", 0, "Show season coverage report for the given year")
@@ -76,11 +77,21 @@ func main() {
if *ingestNews {
ingestFlags++
}
if *prefetchTrackOutlines != 0 {
ingestFlags++
}
if ingestFlags > 0 {
if ingestFlags > 1 {
fmt.Fprintln(os.Stderr, "box-box: only one of --ingest-year, --backfill-season, --ingest-meeting, --ingest-session, or --ingest-news may be set")
fmt.Fprintln(os.Stderr, "box-box: only one of --ingest-year, --backfill-season, --ingest-meeting, --ingest-session, --ingest-news, or --prefetch-track-outlines may be set")
os.Exit(1)
}
if *prefetchTrackOutlines != 0 {
if err := runTrackOutlinePrefetch(client, *prefetchTrackOutlines); err != nil {
fmt.Fprintf(os.Stderr, "box-box track outline prefetch error: %v\n", err)
os.Exit(1)
}
return
}
if *ingestNews {
if err := runNewsIngestion(*dryRun, *dbPath); err != nil {
fmt.Fprintf(os.Stderr, "box-box ingest error: %v\n", err)
@@ -88,7 +99,7 @@ func main() {
}
return
}
yearVal := *ingestYear
if *backfillSeason != 0 {
yearVal = *backfillSeason
@@ -174,6 +185,35 @@ func runIngestion(client *api.OpenF1Client, year, meetingKey, sessionKey int, fo
return err
}
func runTrackOutlinePrefetch(client *api.OpenF1Client, year int) error {
log.SetOutput(os.Stderr)
fmt.Fprintf(os.Stderr, "track outlines: warming HTTP cache %s for %d\n", api.DefaultCacheDBPath(), year)
meetings, err := client.GetMeetingsForYear(year)
if err != nil {
return fmt.Errorf("fetch meetings for %d: %w", year, err)
}
result := client.PrefetchTrackOutlinesForYear(year, meetings)
fmt.Printf(
"track outlines %d: cached %d/%d unique circuit(s) before, %d/%d after; %d skipped, %d fetched, %d failed\n",
result.Year,
result.CachedBefore,
result.UniqueCircuits,
result.CachedAfter,
result.UniqueCircuits,
result.Skipped,
result.Fetched,
result.Failed,
)
if result.CachedAfter == 0 {
return fmt.Errorf("cached zero track outlines for %d", year)
}
return nil
}
func runNewsIngestion(dryRun bool, dbPath string) error {
log.SetOutput(os.Stderr)
@@ -275,7 +315,7 @@ func runCoverageReport(year int, dbPath string) error {
}
fmt.Printf("\n--- Season %d Coverage Report ---\n\n", year)
fmt.Printf("%-35s | %-5s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s\n",
fmt.Printf("%-35s | %-5s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s\n",
"Meeting / Session (Key)", "ID", "DR", "SR", "SG", "ST", "PS", "PO", "RC", "WE", "LA")
fmt.Println(strings.Repeat("-", 82))

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

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

@@ -1,14 +1,21 @@
import type {
ArticleContent,
CarDataSample,
ChampionshipHub,
DriverSummary,
EnrichedGrid,
EnrichedResult,
LapsComparisonResponse,
LiveStateResponse,
LiveSessionMeta,
Meeting,
NewsItem,
RaceHub,
ReplayFramesResponse,
Session,
TrackOutline,
Weekend,
WeekendContext,
} from './types'
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
@@ -38,7 +45,11 @@ export async function fetchLocalMeetings(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) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
@@ -46,6 +57,47 @@ export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
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[]> {
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
if (!res.ok) {
@@ -63,6 +115,14 @@ export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
return res.json()
}
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> {
const params = new URLSearchParams({ source: 'auto' })
if (year) params.set('year', year.toString())
@@ -74,6 +134,19 @@ export async function fetchChampionshipHub(year?: number): Promise<ChampionshipH
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> {
const res = await fetch('/api/v1/live/state')
if (!res.ok) {
@@ -126,3 +199,32 @@ export async function markNewsRead(articleUrl: string): Promise<void> {
body: JSON.stringify({ url: articleUrl }),
})
}
export async function fetchTelemetry(
sessionKey: number,
driverNumber: number,
): Promise<CarDataSample[]> {
const res = await fetch(
`/api/v1/telemetry?session_key=${sessionKey}&driver_number=${driverNumber}`,
)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
const data = await res.json()
return Array.isArray(data) ? data : []
}
export async function fetchLapsComparison(
sessionKey: number,
drivers?: number[],
): Promise<LapsComparisonResponse> {
const params = new URLSearchParams({ session_key: String(sessionKey) })
if (drivers?.length) {
params.set('drivers', drivers.join(','))
}
const res = await fetch(`/api/v1/laps/comparison?${params}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}

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,253 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchLapsComparison, fetchTelemetry } from '../api'
import {
buildBestLapTraceSeries,
compareDriverOptions,
comparisonToDeltaSeries,
defaultCompareDriverNumbers,
formatPitLapsCaption,
} from '../lib/compare'
import type { Driver, EnrichedResult } from '../types'
import { teamColor } from '../utils'
import { DriverCell } from './DriverCell'
import { TelemetryTraceChart } from './charts/TelemetryTraceChart'
import { DeltaTimeGraph } from './charts/DeltaTimeGraph'
import '../styles/compare-view.css'
interface Props {
sessionKey: number
results: EnrichedResult[]
drivers: Driver[]
}
function SectionState({
loading,
error,
empty,
emptyMessage,
children,
}: {
loading: boolean
error: Error | null
empty: boolean
emptyMessage: string
children: React.ReactNode
}) {
if (loading) {
return <div className="loading-state">loading</div>
}
if (error) {
return <div className="error-box">{error.message}</div>
}
if (empty) {
return <div className="missing-notice">{emptyMessage}</div>
}
return <>{children}</>
}
export function CompareView({ sessionKey, results, drivers }: Props) {
const driverOptions = useMemo(
() => compareDriverOptions(drivers, results),
[drivers, results],
)
const initialPair = useMemo(
() => defaultCompareDriverNumbers(results, drivers),
[results, drivers],
)
const previousSessionKey = useRef(sessionKey)
const [driverA, setDriverA] = useState<number | null>(initialPair?.[0] ?? null)
const [driverB, setDriverB] = useState<number | null>(initialPair?.[1] ?? null)
useEffect(() => {
if (previousSessionKey.current !== sessionKey) {
previousSessionKey.current = sessionKey
setDriverA(initialPair?.[0] ?? null)
setDriverB(initialPair?.[1] ?? null)
return
}
if (driverA != null && driverB != null) return
if (!initialPair) return
setDriverA(initialPair[0])
setDriverB(initialPair[1])
}, [sessionKey, initialPair, driverA, driverB])
const pair = useMemo((): [number, number] | null => {
if (driverA == null || driverB == null || driverA === driverB) return null
return [driverA, driverB]
}, [driverA, driverB])
const comparisonQuery = useQuery({
queryKey: ['laps-comparison', sessionKey, pair?.[0], pair?.[1]],
queryFn: () => fetchLapsComparison(sessionKey, pair!),
enabled: pair != null,
staleTime: 60_000,
})
const telemetryAQuery = useQuery({
queryKey: ['telemetry', sessionKey, pair?.[0]],
queryFn: () => fetchTelemetry(sessionKey, pair![0]),
enabled: pair != null,
staleTime: 60_000,
})
const telemetryBQuery = useQuery({
queryKey: ['telemetry', sessionKey, pair?.[1]],
queryFn: () => fetchTelemetry(sessionKey, pair![1]),
enabled: pair != null,
staleTime: 60_000,
})
const comparison = comparisonQuery.data
const referenceLabel = useMemo(() => {
if (!pair) return undefined
const meta = comparison?.drivers.find((d) => d.driver_number === pair[0])
const session = drivers.find((d) => d.driver_number === pair[0])
return meta?.name_acronym || session?.name_acronym
}, [pair, comparison, drivers])
const deltaSeries = useMemo(() => {
if (!comparison || !pair) return []
return comparisonToDeltaSeries(comparison, pair, drivers)
}, [comparison, pair, drivers])
const pitCaption = useMemo(() => {
if (!comparison || !pair) return null
return formatPitLapsCaption(comparison.pit_laps, pair, comparison, drivers)
}, [comparison, pair, drivers])
const traceSeries = useMemo(() => {
if (!pair || !comparison) return []
const out = []
for (const dn of pair) {
const comp = comparison.drivers.find((d) => d.driver_number === dn)
const session = drivers.find((d) => d.driver_number === dn)
const label = comp?.name_acronym || session?.name_acronym || `#${dn}`
const color = teamColor(comp?.team_colour || session?.team_colour)
const carData =
dn === pair[0] ? (telemetryAQuery.data ?? []) : (telemetryBQuery.data ?? [])
const series = buildBestLapTraceSeries(carData, comp?.laps ?? [], label, color)
if (series) out.push(series)
}
return out
}, [pair, comparison, drivers, telemetryAQuery.data, telemetryBQuery.data])
const telemetryLoading = telemetryAQuery.isLoading || telemetryBQuery.isLoading
const telemetryError = telemetryAQuery.error ?? telemetryBQuery.error
if (driverOptions.length < 2) {
return (
<div className="missing-notice" data-testid="compare-view-empty">
Need at least two drivers in this session to compare.
</div>
)
}
const driverAInfo = driverOptions.find((d) => d.driver_number === driverA)
const driverBInfo = driverOptions.find((d) => d.driver_number === driverB)
return (
<div className="compare-view" data-testid="compare-view">
<div className="compare-pickers">
<div className="compare-picker">
<span className="compare-picker-label">Reference</span>
<select
className="compare-picker-select"
value={driverA ?? ''}
onChange={(e) => setDriverA(Number(e.target.value))}
data-testid="compare-picker-a"
>
{driverOptions.map((d) => (
<option key={d.driver_number} value={d.driver_number}>
{d.name_acronym} · #{d.driver_number}
</option>
))}
</select>
{driverAInfo && (
<DriverCell
acronym={driverAInfo.name_acronym}
number={driverAInfo.driver_number}
colour={driverAInfo.team_colour}
/>
)}
</div>
<div className="compare-picker">
<span className="compare-picker-label">Challenger</span>
<select
className="compare-picker-select"
value={driverB ?? ''}
onChange={(e) => setDriverB(Number(e.target.value))}
data-testid="compare-picker-b"
>
{driverOptions.map((d) => (
<option key={d.driver_number} value={d.driver_number}>
{d.name_acronym} · #{d.driver_number}
</option>
))}
</select>
{driverBInfo && (
<DriverCell
acronym={driverBInfo.name_acronym}
number={driverBInfo.driver_number}
colour={driverBInfo.team_colour}
/>
)}
</div>
</div>
{driverA === driverB && (
<div className="analysis-notice">
<strong>Pick two different drivers</strong> to run a comparison.
</div>
)}
<section className="compare-section" data-testid="compare-telemetry-section">
<div>
<div className="compare-section-title">Best lap telemetry</div>
<div className="compare-section-meta">
Speed, throttle, and brake traces for each driver&apos;s fastest lap
</div>
</div>
<SectionState
loading={telemetryLoading}
error={telemetryError instanceof Error ? telemetryError : null}
empty={!telemetryLoading && !telemetryError && traceSeries.length === 0}
emptyMessage="No telemetry samples for the best laps. Car data may not be available for this session."
>
<TelemetryTraceChart series={traceSeries} />
</SectionState>
</section>
<section className="compare-section" data-testid="compare-pace-section">
<div>
<div className="compare-section-title">Race pace</div>
<div className="compare-section-meta">
Cumulative lap-time delta vs {referenceLabel ?? 'reference'}. Deltas are plotted
only where the reference lap is valid; gaps appear when the reference has no lap
time.
</div>
</div>
<SectionState
loading={comparisonQuery.isLoading}
error={comparisonQuery.error instanceof Error ? comparisonQuery.error : null}
empty={!comparisonQuery.isLoading && !comparisonQuery.error && deltaSeries.length < 2}
emptyMessage="No lap comparison data for the selected drivers."
>
<DeltaTimeGraph series={deltaSeries} referenceLabel={referenceLabel} />
{pitCaption && (
<p className="compare-pit-caption" data-testid="compare-pit-caption">
{pitCaption}
</p>
)}
</SectionState>
</section>
</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

@@ -0,0 +1,26 @@
import type { ReactNode } from 'react'
import '../styles/meaning.css'
export interface MeaningProps {
value: ReactNode
meaning?: string | null
/** Long-form explanation for the native tooltip; falls back to meaning. */
title?: string | null
tone?: 'good' | 'bad' | 'neutral' | 'warn'
}
export function Meaning({ value, meaning, title, tone }: MeaningProps) {
if (!meaning) {
return <>{value}</>
}
const tooltip = title ?? meaning
const toneClass = tone ? `meaning-caption--${tone}` : ''
return (
<span className="meaning" title={tooltip}>
<span className="meaning-value">{value}</span>
<span className={`meaning-caption ${toneClass}`.trim()}>{meaning}</span>
</span>
)
}

View File

@@ -1,6 +1,17 @@
import { Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { fetchLiveState } from '../api'
import { isLiveSessionActive } from '../lib/live'
export function Nav() {
const { data: liveState } = useQuery({
queryKey: ['live-state'],
queryFn: fetchLiveState,
staleTime: 5_000,
refetchInterval: 30_000,
})
const liveActive = isLiveSessionActive(liveState)
return (
<nav className="app-nav">
<Link to="/" className="nav-logo">
@@ -10,12 +21,23 @@ export function Nav() {
<Link to="/" activeProps={{ className: 'active' }} activeOptions={{ exact: true }}>
Command
</Link>
<Link to="/live" activeProps={{ className: 'active' }}>
<Link
to="/live"
className={liveActive ? 'nav-live nav-live-on' : 'nav-live'}
activeProps={{ className: 'active' }}
data-testid="nav-live"
data-live-active={liveActive ? 'true' : 'false'}
>
{liveActive && <span className="nav-live-dot" aria-hidden="true" />}
Live
{liveActive && <span className="sr-only"> session active</span>}
</Link>
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
Race Hub
</Link>
<Link to="/preview" activeProps={{ className: 'active' }}>
Preview
</Link>
<Link to="/championship" activeProps={{ className: 'active' }}>
Championship
</Link>

View File

@@ -1,7 +1,23 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
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 '../styles/digest.css'
const SOURCE_DISPLAY: Record<string, string> = {
'fia': 'FIA',
@@ -14,14 +30,53 @@ const SOURCE_DISPLAY: Record<string, string> = {
}
export function PaddockBriefing() {
const now = useMemo(() => new Date(), [])
const { data: news, isLoading, isError } = useQuery({
queryKey: ['news'],
queryFn: () => fetchNews(100),
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 preview = news?.slice(0, 5) ?? []
const preview = sinceItems.length > 0 ? sinceItems.slice(0, 5) : (tagged.slice(0, 5))
return (
<section className="cc-briefing" data-testid="paddock-briefing">
@@ -37,6 +92,27 @@ export function PaddockBriefing() {
</Link>
</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>}
{isError && <div className="briefing-state error-box">Failed to load briefing</div>}

View File

@@ -1,6 +1,36 @@
import { useState, useMemo, useRef, useCallback } from 'react'
import type { EnrichedResult, EnrichedGrid, PositionSample, Lap, Session } from '../types'
import { useEffect, useMemo, useRef, useState } from 'react'
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 {
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 {
data: {
@@ -12,38 +42,256 @@ interface Props {
race_control?: any[]
pit_stops?: any[]
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) {
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 [scrubTime, setScrubTime] = 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 tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 })
// Position Evolution Chart Logic
const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions])
const hasChartData = hasPositions && allTimes.length > 0
const chartTiming = useMemo(() => raceChartTiming(positions, laps, results), [laps, positions, results])
const hasChartData = hasPositions && chartTiming !== null
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 displayResults = results
if (hasChartData) {
const tMin = new Date(allTimes[0]).getTime()
const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
const tRange = Math.max(tMax - tMin, 1)
if (hasChartData && chartTiming) {
const { tMin, tRange } = chartTiming
const normaliseTime = (time: number) => Math.max(0, Math.min(1, (time - tMin) / tRange))
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
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, [])
byDriver.get(p.driver_number)!.push({
t: (new Date(p.date).getTime() - tMin) / tRange,
t: normaliseTime(time),
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()) {
samples.sort((a, b) => a.t - b.t)
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 (t <= samples[0].t) return samples[0].pos
if (t >= samples[samples.length - 1].t) return samples[samples.length - 1].pos
for (let i = 0; i < samples.length - 1; i++) {
if (samples[i].t <= t && samples[i+1].t >= t) {
const dt = samples[i+1].t - samples[i].t
if (samples[i].t <= t && samples[i + 1].t >= t) {
const dt = samples[i + 1].t - samples[i].t
if (dt === 0) return samples[i].pos
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
@@ -84,43 +332,46 @@ export function RaceStoryCanvas({ data }: Props) {
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 acronymByDriver = new Map(results.map((r) => [r.driver_number, r.name_acronym]))
const positionLabels = decimatedPositionLabels(maxPos)
const W = 640
const H = 180
const PL = 40
const PR = 48
const PT = 8
const PB = 20
const W = CHART_W
const H = CHART_H
const PL = CHART_PL
const PR = CHART_PR
const PT = CHART_PT
const PB = CHART_PB
const plotW = W - PL - PR
const plotH = H - PT - PB
const toX = (t: number) => PL + t * plotW
const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH
const winner = results.find(r => r.position === 1)
const winnerLaps = winner ? laps.filter(l => l.driver_number === winner.driver_number) : []
const lapTicks: { lap: number, t: number }[] = []
const winner = results.find((r) => r.position === 1)
const winnerLaps = winner ? laps.filter((l) => l.driver_number === winner.driver_number) : []
const lapTicks: { lap: number; t: number }[] = []
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) {
const t = (new Date(lap.date_start).getTime() - tMin) / tRange
if (t >= 0 && t <= 1) {
lapTicks.push({ lap: lap.lap_number, t })
}
const lapStart = new Date(lap.date_start).getTime()
const nextLapStart = winnerLaps[i + 1] ? new Date(winnerLaps[i + 1].date_start).getTime() : NaN
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' }[] = []
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())
for (const msg of rc) {
const t = new Date(msg.date).getTime()
const m = msg.message?.toUpperCase() || ''
const cat = msg.category?.toUpperCase() || ''
if (m.includes('VIRTUAL SAFETY CAR DEPLOYED') || cat === 'VIRTUALSAFETYCAR') {
if (!activeSC) activeSC = { start: t, type: 'VSC' }
} 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 })
}
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>) => {
setIsPlaying(false)
stopChapterTour()
clearChapterSelection()
if (!svgRef.current) return
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))
setScrubTime(t)
}
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
ref={svgRef}
className="rs-position-chart-svg"
viewBox={`0 0 ${W} ${H}`}
style={{ width: '100%', minWidth: 280, maxWidth: W, display: 'block' }}
role="img"
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) => {
const startT = (sc.start - tMin) / tRange
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}>
<line
x1={PL}
x2={W - PR}
y1={toY(pos)}
y2={toY(pos)}
stroke="var(--border)"
strokeWidth={0.5}
className="rs-chart-grid-line"
/>
<text
x={PL - 4}
y={toY(pos) + 4}
textAnchor="end"
fill="var(--text-3)"
fontSize={8}
fontFamily="var(--f-mono)"
className="rs-chart-axis-label"
>
P{pos}
</text>
</g>
))}
{lapTicks.map(tick => (
{lapTicks.map((tick) => (
<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} />
<text x={toX(tick.t)} y={H - PB + 14} textAnchor="middle" fill="var(--text-3)" fontSize={9} fontFamily="var(--f-mono)">
<line
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}
</text>
</g>
@@ -210,11 +519,12 @@ export function RaceStoryCanvas({ data }: Props) {
const last = samples[samples.length - 1]
const isHovered = hoverDriver === dNum
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 (
<g
<g
key={dNum}
style={{ opacity: isFaded ? 0.2 : 1, transition: 'opacity 0.2s' }}
onMouseEnter={() => setHoverDriver(dNum)}
@@ -230,30 +540,31 @@ export function RaceStoryCanvas({ data }: Props) {
className="rs-driver-line"
pathLength={1}
/>
{driverPits.map((p, i) => {
const t = (new Date(p.date).getTime() - tMin) / tRange
if (t < 0 || t > 1) return null
const time = new Date(p.date).getTime()
if (!Number.isFinite(time) || time < tMin || time > tMin + tRange) return null
const t = normaliseTime(time)
const pos = getInterpPos(samples, t)
if (pos === null) return null
return (
<circle
key={`pit-${i}`}
cx={toX(t)}
cy={toY(pos)}
r={3}
fill="var(--bg)"
stroke={color}
<circle
key={`pit-${i}`}
cx={toX(t)}
cy={toY(pos)}
r={3}
fill="var(--bg)"
stroke={color}
strokeWidth={2}
className="rs-pit-dot"
/>
)
})}
{last && (
<text
x={toX(last.t) + 6}
y={toY(last.pos) + 4}
y={labelY + 4}
fill={color}
fontSize={isHovered ? 11 : 9}
fontFamily="var(--f-mono)"
@@ -287,25 +598,110 @@ export function RaceStoryCanvas({ data }: Props) {
width={plotW}
height={plotH}
fill="transparent"
data-testid="position-chart-interaction"
onPointerMove={handlePointerMove}
onPointerLeave={() => setScrubTime(null)}
onPointerLeave={() => {
if (!isPlaying) {
clearChapterSelection()
setScrubTime(null)
}
}}
style={{ cursor: 'crosshair', touchAction: 'none' }}
/>
</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>
)
}
return (
<div className="race-story-canvas">
{hasChartData ? (
chartContent
) : (
<div className="analysis-notice">
<strong>Lap-by-lap positions not available.</strong> This session does not
have ingested position samples in <code>/api/v1/race-hub</code>.
</div>
{hasChartData && chartTiming && chapters.length > 0 && (
<ChapterStrip
chapters={chapters}
scrubTime={scrubTime}
tMin={chartTiming.tMin}
tRange={chartTiming.tRange}
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 && (
<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 currentPos = scrubTime !== null ? i + 1 : r.position
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
if (scrubTime !== null) {
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]
currentPoints = currentPos <= ptsArray.length ? ptsArray[currentPos - 1] : 0
}
return (
<div
key={r.driver_number}
<div
key={r.driver_number}
className={`rs-driver-row ${hoverDriver === r.driver_number ? 'rs-driver-row-hover' : ''}`}
onMouseEnter={() => setHoverDriver(r.driver_number)}
onMouseLeave={() => setHoverDriver(null)}
>
<div className="rs-driver-left">
<div className={`rs-pos-col ${pClass}`}>
{currentPos}
</div>
<div className={`rs-pos-col ${pClass}`}>{currentPos}</div>
<div className="rs-driver-cell">
<div
className="rs-driver-color"
style={{ background: r.team_colour ? `#${r.team_colour}` : 'var(--border)' }}
<div
className="rs-driver-color"
style={{ background: r.team_colour ? `#${r.team_colour}` : 'var(--border)' }}
/>
<div className="rs-driver-identity">
<span className="rs-driver-name">{r.name_acronym || r.driver_number}</span>
@@ -354,12 +749,12 @@ export function RaceStoryCanvas({ data }: Props) {
</span>
<span className="rs-metric-label">Grid</span>
</div>
<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 className="rs-metric-label">{isWinner ? 'Time' : 'Gap'}</span>
</div>
<div className="rs-metric" style={{ width: '40px' }}>
<span style={{ color: Number(currentPoints) > 0 ? 'var(--text)' : 'var(--text-3)' }}>
{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

@@ -12,7 +12,7 @@ interface Props {
hasStints: boolean
}
export function StrategyView({ results, stints, pit_stops: _pitStops, hasStints }: Props) {
export function StrategyView({ results, stints, pit_stops, hasStints }: Props) {
if (!hasStints) {
return (
<div>
@@ -88,6 +88,10 @@ export function StrategyView({ results, stints, pit_stops: _pitStops, hasStints
lapEnd: s.lap_end,
isNew: s.tyre_age_at_start === 0,
})),
pitStops: pit_stops
.filter((p) => p.driver_number === driver.driver_number)
.map((p) => p.lap_number)
.sort((a, b) => a - b),
}))
return (

View File

@@ -2,6 +2,7 @@ export type Tab =
| 'overview'
| 'race_story'
| 'strategy'
| 'compare'
| 'lap_data'
| 'conditions'
| 'race_control'
@@ -11,6 +12,7 @@ const TABS: { id: Tab; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'race_story', label: 'Race Story' },
{ id: 'strategy', label: 'Strategy' },
{ id: 'compare', label: 'Compare' },
{ id: 'lap_data', label: 'Lap Data' },
{ id: 'conditions', label: 'Conditions' },
{ id: 'race_control', label: 'Race Control' },

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

@@ -47,7 +47,7 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
}
return (
<div className="rh-switcher" data-testid="rh-switcher">
<div id="rh-weekend-switcher" className="rh-switcher" data-testid="rh-switcher">
<div className="rh-switcher-head">
<span className="sec-title">Switch Weekend</span>
<div className="rh-switcher-years">

View File

@@ -12,6 +12,8 @@ export interface StintTimelineRow {
label: string
color: string
stints: StintTimelineStint[]
/** Lap numbers where the driver pitted; optional — rows without stops render normally. */
pitStops?: number[]
}
interface TyreStintTimelineProps {
@@ -57,6 +59,14 @@ function stintBarW(stint: StintTimelineStint, totalLaps: number): number {
return Math.max(2, (stintLength(stint) / totalLaps) * BAR_W)
}
function pitMarkerX(lapNumber: number, totalLaps: number): number {
return LEFT + ((lapNumber - 1) / totalLaps) * BAR_W
}
function pitMarkerTitle(driverLabel: string, lapNumber: number): string {
return `${driverLabel} pit stop · L${lapNumber}`
}
function axisTicks(totalLaps: number): number[] {
const ticks: number[] = []
for (let lap = 0; lap <= totalLaps; lap += 10) {
@@ -130,6 +140,21 @@ export function TyreStintTimeline({ rows, totalLaps }: TyreStintTimelineProps) {
<title>{stintTitle(stint)}</title>
</rect>
))}
{(row.pitStops ?? []).map((lapNumber, pi) => (
<line
key={`pit-${pi}`}
x1={pitMarkerX(lapNumber, safeTotal)}
x2={pitMarkerX(lapNumber, safeTotal)}
y1={BAR_Y - 3}
y2={BAR_Y + BAR_H + 3}
className="stint-timeline__pit-marker"
data-testid="pit-marker"
data-lap={lapNumber}
>
<title>{pitMarkerTitle(row.label, lapNumber)}</title>
</line>
))}
</g>
)
})}

View File

@@ -0,0 +1,72 @@
import { useAutoAnimate } from '@formkit/auto-animate/react'
import {
Flag,
LogOut,
Megaphone,
OctagonX,
Swords,
Timer,
TrendingDown,
TrendingUp,
Wrench,
Zap,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { LiveDriverInfo } from '../../types'
import type { LiveEvent, LiveEventKind } from '../../lib/events'
import { teamColor } from '../../utils'
import '../../styles/event-rail.css'
interface Props {
events: LiveEvent[]
driverInfo?: Record<string, LiveDriverInfo>
}
const KIND_ICONS: Record<LiveEventKind, LucideIcon> = {
overtake: Swords,
'position-gain': TrendingUp,
'position-loss': TrendingDown,
'pit-in': Wrench,
'pit-out': LogOut,
'personal-best': Timer,
'fastest-lap': Zap,
retirement: OctagonX,
'track-status': Flag,
'race-control': Megaphone,
}
export function EventRail({ events, driverInfo }: Props) {
const [listRef] = useAutoAnimate<HTMLDivElement>()
return (
<section className="event-rail panel-glass" data-testid="event-rail">
<div className="sec-header sticky-header">
<span className="sec-title">What Just Happened</span>
{events.length > 0 && <span className="sec-meta">{events.length} events</span>}
</div>
{events.length === 0 ? (
<div className="missing-notice">Events will appear here as the session unfolds.</div>
) : (
<div className="event-rail-list" ref={listRef}>
{events.map((event) => {
const Icon = KIND_ICONS[event.kind] ?? Megaphone
const colour = driverInfo?.[event.racingNumbers[0]]?.TeamColour
const accent = event.racingNumbers.length > 0 ? teamColor(colour) : 'var(--border-2)'
return (
<div className={`event-rail-row event-kind-${event.kind}`} key={event.id} style={{ borderLeftColor: accent }}>
<span className="event-rail-icon" aria-hidden="true">
<Icon size={14} />
</span>
<div className="event-rail-body">
<span className="event-rail-headline">{event.headline}</span>
{event.detail && <span className="event-rail-detail">{event.detail}</span>}
</div>
{event.lap > 0 && <span className="event-rail-lap">L{event.lap}</span>}
</div>
)
})}
</div>
)}
</section>
)
}

View File

@@ -80,7 +80,9 @@ export function RaceControlFeed({ messages, driverInfo }: Props) {
<section className="live-rc panel-glass">
<div className="sec-header sticky-header">
<span className="sec-title">Race Control</span>
{messages.length > 0 && <span className="sec-meta">{messages.length} messages</span>}
<span className="sec-meta" data-testid="rc-timezone">
{messages.length > 0 ? `${messages.length} messages · ` : ''}times UTC
</span>
</div>
{latest.length === 0 ? (
<div className="missing-notice">No race control messages in the current live snapshot.</div>
@@ -102,7 +104,7 @@ export function RaceControlFeed({ messages, driverInfo }: Props) {
return (
<div className={`live-rc-row${flashClass}`} key={key}>
<span className="rc-time">{message.Time || '--:--'}</span>
<span className="rc-time" title="UTC">{message.Time || '--:--'}</span>
{message.Lap > 0 && <span className="rc-lap">L{message.Lap}</span>}
{message.Flag
? <span className={`rc-flag ${rcFlagClass(message.Flag)}`}>{message.Flag}</span>

View File

@@ -5,18 +5,20 @@ import { WeatherStrip } from './WeatherStrip'
interface Props {
isLive: boolean
isArchive?: boolean
snapshot: LiveStreamData
rows: LiveTimingRow[]
connection: 'connected' | 'connecting' | 'disconnected' | 'error'
now: number
}
export function SessionBanner({ isLive, snapshot, rows, connection, now }: Props) {
export function SessionBanner({ isLive, isArchive = false, snapshot, rows, connection, now }: Props) {
const session = snapshot.Session
const clock = extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now)
const display = liveSessionDisplay(session, rows)
const atRiskLabel =
display.atRiskStart && display.atRiskEnd ? `P${display.atRiskStart}-P${display.atRiskEnd} at risk` : ''
const stateLabel = isLive ? 'live' : isArchive ? 'archive' : 'stale'
return (
<section className="live-banner">
@@ -36,10 +38,12 @@ export function SessionBanner({ isLive, snapshot, rows, connection, now }: Props
<div className="live-banner-meta">
{display.advanceCount && <span>{display.advanceCount} advance</span>}
{atRiskLabel && <span>{atRiskLabel}</span>}
<span>
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
</span>
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{isLive ? 'live' : 'stale'}</span>
{display.isRace && (
<span data-testid="live-lap-counter">
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
</span>
)}
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{stateLabel}</span>
</div>
</div>
</div>

View File

@@ -1,9 +1,10 @@
import { useState, Fragment } from 'react'
import { useState, useMemo, Fragment } from 'react'
import { teamColor } from '../../utils'
import { useAutoAnimate } from '@formkit/auto-animate/react'
import type { LiveSessionMeta, LiveStintData } from '../../types'
import type { LiveTimingRow } from '../../lib/live'
import {
bestLapGaps,
driverCode,
liveSessionDisplay,
positionDelta,
@@ -12,6 +13,9 @@ import {
tyreLabel,
} from '../../lib/live'
import type { GapHistoryMap } from '../../lib/gapHistory'
import { parseIntervalSeconds } from '../../lib/gapHistory'
import { intervalMeaning } from '../../lib/meaning'
import { Meaning } from '../Meaning'
import { GapSparkline } from './GapSparkline'
import { StintHistory } from './StintHistory'
import { Pin } from 'lucide-react'
@@ -59,6 +63,10 @@ export function TimingTower({
const isQuali = sessionDisplay.isQualifying || !isRace
const columnCount = 7 + (isRace ? 3 : 0) + (isQuali ? 3 : 0)
// Practice/qualifying: derive a display-only gap to P1 from valid best laps
// when the upstream feed omits GapToLeader. Empty for races (feed is truth).
const practiceGaps = useMemo(() => bestLapGaps(isRace ? [] : rows), [isRace, rows])
return (
<div className="scroll-x">
<table className="data-table live-tower" style={{ minWidth: 760 }}>
@@ -68,9 +76,9 @@ export function TimingTower({
{isRace && <th>Δ</th>}
<th>Driver</th>
<th>Tyre</th>
<th>Last Lap</th>
<th
className="interactive"
<th className={isRace ? undefined : 'hide-mobile'}>Last Lap</th>
<th
className="interactive"
onClick={() => setGapMode(g => g === 'interval' ? 'leader' : 'interval')}
title="Click to toggle Gap vs Interval"
style={{ cursor: 'pointer', textDecoration: 'underline' }}
@@ -78,10 +86,10 @@ export function TimingTower({
{gapMode === 'interval' && isRace ? 'Interval' : 'Gap to P1'}
</th>
{isRace && <th>Trend</th>}
{isQuali && <th>S1</th>}
{isQuali && <th>S2</th>}
{isQuali && <th>S3</th>}
<th className="hide-mobile">Best</th>
{isQuali && <th className="hide-mobile">S1</th>}
{isQuali && <th className="hide-mobile">S2</th>}
{isQuali && <th className="hide-mobile">S3</th>}
<th className={isRace ? 'hide-mobile' : undefined}>Best</th>
{isRace && <th className="hide-mobile r">Laps</th>}
<th className="r"></th>
</tr>
@@ -99,8 +107,20 @@ export function TimingTower({
driver.Cutoff
const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition
const gapText = gapMode === 'interval' && isRace ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader
let gapText: string
if (isRace) {
gapText = gapMode === 'interval' ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader
} else if (driver.GapToLeader) {
gapText = driver.GapToLeader
} else {
const computed = practiceGaps[row.RacingNumber]
gapText = computed ? (computed.isLeader ? '—' : computed.gap) : ''
}
const intervalAnnotation =
gapMode === 'interval' && isRace && row.Position > 1
? intervalMeaning(parseIntervalSeconds(gapText))
: null
const renderSector = (idx: number) => {
const sec = driver.Sectors?.[idx]
if (!sec) return '-'
@@ -146,10 +166,20 @@ export function TimingTower({
<td>
<span className={`tyre-badge ${tyreClass(row.Tyre)}`}>{tyreLabel(row.Tyre)}</span>
</td>
<td className={driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono'}>
<td className={[
isRace ? '' : 'hide-mobile',
driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono',
].filter(Boolean).join(' ')}>
{driver.LastLapTime || '-'}
</td>
<td className="mono">{gapText || '-'}</td>
<td className="mono">
<Meaning
value={gapText || '-'}
meaning={intervalAnnotation?.caption}
title={intervalAnnotation?.title}
tone={intervalAnnotation?.tone}
/>
</td>
{isRace && (
<td className="spark-cell">
@@ -157,11 +187,14 @@ export function TimingTower({
</td>
)}
{isQuali && <td>{renderSector(0)}</td>}
{isQuali && <td>{renderSector(1)}</td>}
{isQuali && <td>{renderSector(2)}</td>}
{isQuali && <td className="hide-mobile">{renderSector(0)}</td>}
{isQuali && <td className="hide-mobile">{renderSector(1)}</td>}
{isQuali && <td className="hide-mobile">{renderSector(2)}</td>}
<td className={`hide-mobile ${driver.BestLapOB ? 'mono lap-ob' : 'mono'}`}>
<td className={[
isRace ? 'hide-mobile' : '',
driver.BestLapOB ? 'mono lap-ob' : 'mono',
].filter(Boolean).join(' ')}>
{driver.BestLapTime || '-'}
</td>
@@ -194,6 +227,16 @@ export function TimingTower({
<div className="mono">{driver.NumberOfLaps || 0}</div>
</div>
)}
{!isRace && driver.Sectors?.some((sec) => sec?.Value) && (
<div data-testid="expanded-sectors">
<div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>SECTORS</div>
<div className="mono" style={{ display: 'flex', gap: '10px' }}>
{renderSector(0)}
{renderSector(1)}
{renderSector(2)}
</div>
</div>
)}
{driver.SpeedTrap && (
<div>
<div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>SPEED TRAP</div>

View File

@@ -13,6 +13,8 @@ import {
recordStintSamples,
stintInputFromRow,
} from '../../lib/tyredeg'
import { tyreAgeMeaning } from '../../lib/meaning'
import { Meaning } from '../Meaning'
import '../../styles/tyredeg.css'
const TOP_DRIVER_COUNT = 10
@@ -50,7 +52,9 @@ function StintSparkline({ seconds }: { seconds: number[] }) {
}
export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
const [collapsed, setCollapsed] = useState(false)
const isRace = isRaceSession(sessionType)
const [collapsed, setCollapsed] = useState(true)
const [readerChose, setReaderChose] = useState(false)
const [stints, setStints] = useState<StintHistoryMap>({})
// One lap-history update per received snapshot (rows is rebuilt per snapshot).
@@ -59,8 +63,6 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
setStints((prev) => recordStintSamples(prev, rows.map(stintInputFromRow)))
}, [rows])
const isRace = isRaceSession(sessionType)
const visible = useMemo(
() =>
rows.filter(
@@ -72,6 +74,30 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
[rows, pinned],
)
// One linear fit per driver per snapshot, shared by the readiness check and
// the rows below. degradationModel is O(laps) and this runs at feed rate.
const models = useMemo(() => {
const out: Record<string, ReturnType<typeof degradationModel>> = {}
for (const row of visible) {
out[row.RacingNumber] = degradationModel(stints[row.RacingNumber]?.samples ?? [])
}
return out
}, [visible, stints])
// Before any stint has enough clean laps to fit, every row reads "warming
// up" — a full-height panel of placeholders that pushed the Timing Tower off
// the fold for the first third of a race. Stay collapsed until there is
// something to say, then open. A reader who has toggled it keeps their choice.
const hasSignal = useMemo(
() => visible.some((row) => models[row.RacingNumber] != null),
[visible, models],
)
useEffect(() => {
if (readerChose) return
setCollapsed(!(isRace && hasSignal))
}, [isRace, hasSignal, readerChose])
if (visible.length === 0) return null
return (
@@ -79,25 +105,40 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
<button
type="button"
className="sec-header tyredeg-toggle"
onClick={() => setCollapsed((prev) => !prev)}
onClick={() => {
setReaderChose(true)
setCollapsed((prev) => !prev)
}}
aria-expanded={!collapsed}
>
<span className="sec-title">Tyre Deg &amp; Pit Window</span>
{isRace && <span className="sec-meta">rejoin assumes ~{PIT_LOSS_SECONDS}s pit loss</span>}
{!hasSignal ? (
<span className="sec-meta">collecting clean laps</span>
) : (
isRace && <span className="sec-meta">rejoin assumes ~{PIT_LOSS_SECONDS}s pit loss</span>
)}
<span className="tyredeg-chevron" aria-hidden="true">{collapsed ? '▸' : '▾'}</span>
</button>
{!collapsed && (
<div className="tyredeg-rows">
{visible.map((row) => {
const model = degradationModel(stints[row.RacingNumber]?.samples ?? [])
const model = models[row.RacingNumber]
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age)
return (
<div className="tyredeg-row" key={row.RacingNumber} data-testid="tyredeg-row">
<span className="tyredeg-pos mono">P{row.Position}</span>
<span className="drv-bar" style={{ background: teamColor(row.Info?.TeamColour) }} />
<span className="drv-code">{driverCode(row)}</span>
<span className={`tyre-badge ${compoundClass(row.Tyre?.Compound)}`}>{tyreLabel(row.Tyre)}</span>
<span className={`tyre-badge ${compoundClass(row.Tyre?.Compound)}`}>
<Meaning
value={tyreLabel(row.Tyre)}
meaning={ageAnnotation?.caption}
title={ageAnnotation?.title}
tone={ageAnnotation?.tone}
/>
</span>
{model ? (
<>
<span className={`tyredeg-trend tyredeg-trend-${model.trend}`}>

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

202
frontend/src/lib/compare.ts Normal file
View File

@@ -0,0 +1,202 @@
// Driver comparison mapping — pure functions for telemetry traces and lap deltas.
import type { TelemetryTraceSeries } from '../components/charts/TelemetryTraceChart'
import type { DeltaSeries } from '../lib/delta'
import type {
CarDataSample,
ComparisonLap,
Driver,
EnrichedResult,
LapsComparisonResponse,
} from '../types'
import { compareFinishPosition, teamColor } from '../utils'
function sortDriversByResults(drivers: Driver[], results: EnrichedResult[]): Driver[] {
const order = new Map(
[...results]
.filter((r) => r.position > 0)
.sort((a, b) => compareFinishPosition(a.position, b.position))
.map((r, i) => [r.driver_number, i]),
)
return [...drivers].sort((a, b) => {
const ao = order.get(a.driver_number) ?? 999
const bo = order.get(b.driver_number) ?? 999
if (ao !== bo) return ao - bo
return a.driver_number - b.driver_number
})
}
/** Default compare pair: top two classified finishers, else first two session drivers. */
export function defaultCompareDriverNumbers(
results: EnrichedResult[],
drivers: Driver[],
): [number, number] | null {
const classified = [...results]
.filter((r) => r.position > 0)
.sort((a, b) => compareFinishPosition(a.position, b.position))
if (classified.length >= 2) {
return [classified[0].driver_number, classified[1].driver_number]
}
if (drivers.length >= 2) {
const sorted = sortDriversByResults(drivers, results)
return [sorted[0].driver_number, sorted[1].driver_number]
}
if (classified.length === 1 && drivers.length >= 1) {
const other = drivers.find((d) => d.driver_number !== classified[0].driver_number)
if (other) return [classified[0].driver_number, other.driver_number]
}
return null
}
/** Lap records → per-lap duration array (index 0 = lap 1); null for pit/out laps. */
export function lapsToLapTimes(laps: ComparisonLap[]): (number | null)[] {
if (laps.length === 0) return []
const maxLap = laps.reduce((max, lap) => Math.max(max, lap.lap_number), 0)
const times: (number | null)[] = Array.from({ length: maxLap }, () => null)
for (const lap of laps) {
const idx = lap.lap_number - 1
if (idx < 0) continue
if (lap.is_pit_out_lap || lap.lap_duration == null || lap.lap_duration <= 0) {
times[idx] = null
} else {
times[idx] = lap.lap_duration
}
}
return times
}
export function findBestLap(laps: ComparisonLap[]): ComparisonLap | null {
let best: ComparisonLap | null = null
for (const lap of laps) {
if (lap.lap_duration == null || lap.lap_duration <= 0) continue
if (!best || (best.lap_duration ?? Infinity) > lap.lap_duration) {
best = lap
}
}
return best
}
/** Keep car-data samples whose timestamps fall within a lap window. */
export function filterCarDataToLap(
samples: CarDataSample[],
lap: ComparisonLap,
): CarDataSample[] {
if (!lap.date_start || samples.length === 0) return samples
const start = new Date(lap.date_start).getTime()
if (!Number.isFinite(start)) return samples
const end = start + (lap.lap_duration ?? 0) * 1000 + 500
return samples.filter((s) => {
const t = new Date(s.date).getTime()
return Number.isFinite(t) && t >= start && t <= end
})
}
export function carDataToTraceSeries(
samples: CarDataSample[],
label: string,
color: string,
): TelemetryTraceSeries {
return {
label,
color,
samples: samples.map((s) => ({
speed: s.speed,
throttle: s.throttle,
brake: s.brake,
})),
}
}
export function buildBestLapTraceSeries(
carData: CarDataSample[],
laps: ComparisonLap[],
label: string,
color: string,
): TelemetryTraceSeries | null {
const best = findBestLap(laps)
if (!best) return null
const filtered = filterCarDataToLap(carData, best)
if (filtered.length === 0) return null
return carDataToTraceSeries(filtered, label, color)
}
function driverMeta(
driverNumber: number,
comparison: LapsComparisonResponse | undefined,
drivers: Driver[],
): { label: string; color: string; laps: ComparisonLap[] } {
const comp = comparison?.drivers.find((d) => d.driver_number === driverNumber)
const session = drivers.find((d) => d.driver_number === driverNumber)
return {
label: comp?.name_acronym || session?.name_acronym || `#${driverNumber}`,
color: teamColor(comp?.team_colour || session?.team_colour),
laps: comp?.laps ?? [],
}
}
export function comparisonToDeltaSeries(
comparison: LapsComparisonResponse,
driverNumbers: [number, number],
drivers: Driver[],
): DeltaSeries[] {
return driverNumbers.map((dn) => {
const meta = driverMeta(dn, comparison, drivers)
return {
label: meta.label,
color: meta.color,
lapTimes: lapsToLapTimes(meta.laps),
}
})
}
export function formatPitLapsCaption(
pitLaps: Record<string, number[]>,
driverNumbers: [number, number],
comparison: LapsComparisonResponse | undefined,
drivers: Driver[],
): string | null {
const parts: string[] = []
for (const dn of driverNumbers) {
const laps = pitLaps[String(dn)]
if (!laps?.length) continue
const meta = driverMeta(dn, comparison, drivers)
parts.push(`${meta.label}: L${laps.join(', L')}`)
}
return parts.length > 0 ? `Pit stops — ${parts.join(' · ')}` : null
}
export function compareDriverOptions(
drivers: Driver[],
results: EnrichedResult[],
): Driver[] {
if (drivers.length > 0) {
return sortDriversByResults(drivers, results)
}
return results.map((r) => ({
driver_number: r.driver_number,
name_acronym: r.name_acronym,
full_name: r.full_name,
first_name: '',
last_name: '',
team_name: r.team_name,
team_colour: r.team_colour,
headshot_url: '',
broadcast_name: r.full_name,
session_key: r.session_key,
meeting_key: r.meeting_key,
}))
}

View File

@@ -21,15 +21,31 @@ export function formatDeltaSeconds(delta: number): string {
return `${sign}${delta.toFixed(1)}s`
}
function buildCumulative(lapTimes: ReadonlyArray<number | null>): number[] {
/**
* Cumulative lap time aligned to reference-valid laps only.
* Laps where the reference is null are skipped for every series so later deltas
* do not compare against a frozen baseline while challengers keep accumulating.
*/
function buildAlignedCumulative(
lapTimes: ReadonlyArray<number | null>,
referenceLapTimes: ReadonlyArray<number | null>,
): number[] {
const cumulative: number[] = []
let running = 0
for (const lap of lapTimes) {
if (lap !== null) {
const length = Math.max(lapTimes.length, referenceLapTimes.length)
for (let i = 0; i < length; i++) {
if (referenceLapTimes[i] == null) {
cumulative.push(running)
continue
}
const lap = lapTimes[i]
if (lap != null) {
running += lap
}
cumulative.push(running)
}
return cumulative
}
@@ -47,7 +63,8 @@ function resolveReference(
/**
* Compute per-lap cumulative time delta for each non-reference driver.
* Positive = behind reference; negative = ahead.
* Null laps carry cumulative forward but emit null in deltas (skip when plotting).
* Deltas are only emitted where the reference lap is valid; reference-null laps
* gap every series. Challenger-null laps gap only that driver's line.
*/
export function computeCumulativeDeltas(
series: ReadonlyArray<DeltaSeries>,
@@ -56,24 +73,22 @@ export function computeCumulativeDeltas(
const reference = resolveReference(series, referenceLabel)
if (!reference) return []
const refCumulative = buildCumulative(reference.lapTimes)
const refLapTimes = reference.lapTimes
const refCumulative = buildAlignedCumulative(refLapTimes, refLapTimes)
return series
.filter((s) => s.label !== reference.label)
.map((driver) => {
const driverCumulative = buildCumulative(driver.lapTimes)
const driverCumulative = buildAlignedCumulative(driver.lapTimes, refLapTimes)
const lapCount = Math.max(driver.lapTimes.length, refCumulative.length)
const deltas: (number | null)[] = []
for (let i = 0; i < lapCount; i++) {
if (driver.lapTimes[i] === null) {
if (refLapTimes[i] == null || driver.lapTimes[i] == null) {
deltas.push(null)
continue
}
const refValue = refCumulative[i] ?? refCumulative[refCumulative.length - 1] ?? 0
const driverValue =
driverCumulative[i] ?? driverCumulative[driverCumulative.length - 1] ?? 0
deltas.push(driverValue - refValue)
deltas.push(driverCumulative[i] - refCumulative[i])
}
return {

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 '='
}

318
frontend/src/lib/events.ts Normal file
View File

@@ -0,0 +1,318 @@
// "What just happened" event synthesis for the live page.
// Pure functions only — no React, no side effects — so everything is unit-testable.
// Mirrors the gapHistory/battles precedent: successive LiveStreamData snapshots
// in, typed events out; the page owns the buffer, this module owns the diffing.
import type {
LiveDriverData,
LiveRCMessage,
LiveSessionMeta,
LiveStreamData,
} from '../types'
export const MAX_EVENTS = 30
export type LiveEventKind =
| 'overtake'
| 'position-gain'
| 'position-loss'
| 'pit-in'
| 'pit-out'
| 'personal-best'
| 'fastest-lap'
| 'retirement'
| 'track-status'
| 'race-control'
export interface LiveEvent {
id: string
kind: LiveEventKind
lap: number
timestamp: string
headline: string
detail?: string
racingNumbers: string[]
}
/**
* Identity of the running session, used to reset the event buffer when a new
* session starts. LiveSessionMeta carries no numeric session key, so the
* meeting + session name/type triple stands in for one.
*/
export function sessionSignature(session: LiveSessionMeta | null | undefined): string {
if (!session) return ''
return [session.MeetingName, session.SessionName, session.SessionType].join('|')
}
/**
* Prepend freshly diffed events onto the newest-first buffer, dropping any
* whose id is already present (re-renders and replayed diffs must not
* duplicate) and evicting the oldest events beyond `cap`.
*/
export function appendEvents(
buffer: ReadonlyArray<LiveEvent>,
incoming: ReadonlyArray<LiveEvent>,
cap = MAX_EVENTS,
): LiveEvent[] {
const seen = new Set(buffer.map((event) => event.id))
const fresh = incoming.filter((event) => {
if (seen.has(event.id)) return false
seen.add(event.id)
return true
})
if (fresh.length === 0) return [...buffer]
// Within one diff the array is oldest-first; reverse so the last-produced
// event ends up at the top of the newest-first rail.
return [...fresh.reverse(), ...buffer].slice(0, cap)
}
const TRACK_STATUS_HEADLINES: Record<string, string> = {
'1': 'Green flag — track clear',
'2': 'Yellow flag',
'4': 'Safety Car deployed',
'5': 'Red flag — session stopped',
'6': 'Virtual Safety Car deployed',
'7': 'Virtual Safety Car ending',
}
const RC_CATEGORIES = new Set(['flag', 'safetycar', 'drs'])
/**
* Diff two successive live snapshots into a chronological list of synthesized
* events. Callers are expected to feed genuinely consecutive snapshots of the
* same session; diffing across a session boundary is the caller's job to
* prevent (see sessionSignature).
*/
export function diffSnapshots(prev: LiveStreamData, next: LiveStreamData): LiveEvent[] {
if (!prev || !next || prev === next) return []
const events: LiveEvent[] = []
const lap = next.CurrentLap || 0
const timestamp = next.Clock || ''
const code = (racingNumber: string): string =>
next.DriverInfo?.[racingNumber]?.Tla ||
prev.DriverInfo?.[racingNumber]?.Tla ||
`#${racingNumber}`
diffTrackStatus(prev, next, lap, timestamp, events)
diffRaceControl(prev, next, events)
const prevDrivers = prev.Drivers ?? {}
const nextDrivers = next.Drivers ?? {}
const numbers = Object.keys(nextDrivers).filter((number) => prevDrivers[number])
// Racing numbers whose position loss is already explained by an overtake
// event, so we don't also report a generic "drops to Pn".
const overtakenNumbers = new Set<string>()
for (const number of numbers) {
const before = prevDrivers[number]
const after = nextDrivers[number]
if (!before.Retired && after.Retired) {
events.push({
id: `ret:${number}`,
kind: 'retirement',
lap,
timestamp,
headline: `${code(number)} retires`,
detail: before.Position > 0 ? `Was running P${before.Position}` : undefined,
racingNumbers: [number],
})
}
if (!before.InPit && after.InPit && !after.Retired) {
const fromPosition = before.Position > 0 ? ` from P${before.Position}` : ''
events.push({
id: `pit-in:${number}:l${lap}`,
kind: 'pit-in',
lap,
timestamp,
headline: `${code(number)} pits${fromPosition}`,
racingNumbers: [number],
})
}
if (before.InPit && !after.InPit && !after.Retired) {
const compound = next.Tyres?.[number]?.Compound || ''
const stops = Math.max(0, (next.Stints?.[number]?.length ?? 0) - 1)
const parts = [compound, stops > 0 ? `${ordinal(stops)} stop` : ''].filter(Boolean)
const rejoined = after.Position > 0 ? ` P${after.Position}` : ''
events.push({
id: `pit-out:${number}:l${lap}`,
kind: 'pit-out',
lap,
timestamp,
headline: `${code(number)} rejoins${rejoined}${parts.length > 0 ? ` (${parts.join(', ')})` : ''}`,
racingNumbers: [number],
})
}
diffBestLap(number, before, after, code, lap, timestamp, events)
}
// Position gains, with overtake attribution via position swaps.
for (const number of numbers) {
const before = prevDrivers[number]
const after = nextDrivers[number]
if (before.Position <= 0 || after.Position <= 0) continue
if (after.Position >= before.Position) continue
if (after.InPit || after.Retired) continue
const overtaken = numbers.find((other) => {
if (other === number) return false
const otherBefore = prevDrivers[other]
const otherAfter = nextDrivers[other]
return (
otherBefore.Position === after.Position &&
otherAfter.Position > otherBefore.Position &&
!otherAfter.InPit &&
!otherAfter.Retired
)
})
if (overtaken) {
overtakenNumbers.add(overtaken)
events.push({
id: `overtake:${number}:${overtaken}:p${after.Position}:l${lap}`,
kind: 'overtake',
lap,
timestamp,
headline: `${code(number)} overtakes ${code(overtaken)} for P${after.Position}`,
detail: before.Position - after.Position > 1 ? `Up from P${before.Position}` : undefined,
racingNumbers: [number, overtaken],
})
} else {
events.push({
id: `pos:${number}:${before.Position}-${after.Position}:l${lap}`,
kind: 'position-gain',
lap,
timestamp,
headline: `${code(number)} moves up to P${after.Position} (from P${before.Position})`,
racingNumbers: [number],
})
}
}
// Position losses not already explained by an overtake or a pit visit.
for (const number of numbers) {
const before = prevDrivers[number]
const after = nextDrivers[number]
if (before.Position <= 0 || after.Position <= 0) continue
if (after.Position <= before.Position) continue
if (overtakenNumbers.has(number)) continue
if (after.InPit || after.PitOut || after.Retired || before.InPit) continue
events.push({
id: `pos:${number}:${before.Position}-${after.Position}:l${lap}`,
kind: 'position-loss',
lap,
timestamp,
headline: `${code(number)} drops to P${after.Position} (from P${before.Position})`,
racingNumbers: [number],
})
}
return events
}
function diffTrackStatus(
prev: LiveStreamData,
next: LiveStreamData,
lap: number,
timestamp: string,
events: LiveEvent[],
): void {
if (!prev.TrackStatus || !next.TrackStatus) return
if (prev.TrackStatus === next.TrackStatus) return
events.push({
id: `track:${next.TrackStatus}:l${lap}`,
kind: 'track-status',
lap,
timestamp,
headline: TRACK_STATUS_HEADLINES[next.TrackStatus] ?? `Track status ${next.TrackStatus}`,
racingNumbers: [],
})
}
function diffRaceControl(prev: LiveStreamData, next: LiveStreamData, events: LiveEvent[]): void {
const prevMessages = prev.RCMessages ?? []
const seen = new Set(prevMessages.map(rcMessageKey))
for (const message of next.RCMessages ?? []) {
if (!message.Message || seen.has(rcMessageKey(message))) continue
if (!RC_CATEGORIES.has((message.Category || '').toLowerCase())) continue
events.push({
id: `rc:${rcMessageKey(message)}`,
kind: 'race-control',
lap: message.Lap || 0,
timestamp: message.Time || '',
headline: humanizeRaceControl(message.Message),
racingNumbers: carNumbersIn(message.Message),
})
}
}
function diffBestLap(
number: string,
before: LiveDriverData,
after: LiveDriverData,
code: (racingNumber: string) => string,
lap: number,
timestamp: string,
events: LiveEvent[],
): void {
if (!after.LastLapTime) return
const isNewLap = after.LastLapTime !== before.LastLapTime
if (after.LastLapOB && (isNewLap || !before.LastLapOB)) {
events.push({
id: `ob:${number}:${after.LastLapTime}`,
kind: 'fastest-lap',
lap,
timestamp,
headline: `${code(number)} sets the fastest lap — ${after.LastLapTime}`,
racingNumbers: [number],
})
return
}
if (after.LastLapPB && !after.LastLapOB && (isNewLap || !before.LastLapPB)) {
events.push({
id: `pb:${number}:${after.LastLapTime}`,
kind: 'personal-best',
lap,
timestamp,
headline: `${code(number)} sets a personal best — ${after.LastLapTime}`,
racingNumbers: [number],
})
}
}
function rcMessageKey(message: LiveRCMessage): string {
return `${message.Time}:${message.Message}`
}
/** "SAFETY CAR DEPLOYED" -> "Safety car deployed", keeping known acronyms upper-cased. */
export function humanizeRaceControl(message: string): string {
const lower = message.trim().toLowerCase()
if (!lower) return ''
const sentence = lower.charAt(0).toUpperCase() + lower.slice(1)
return sentence.replace(/\b(drs|vsc|sc|fia)\b/gi, (acronym) => acronym.toUpperCase())
}
function carNumbersIn(message: string): string[] {
const numbers: string[] = []
for (const match of message.matchAll(/CAR (\d+)/gi)) {
if (!numbers.includes(match[1])) numbers.push(match[1])
}
return numbers
}
function ordinal(n: number): string {
const rem10 = n % 10
const rem100 = n % 100
if (rem10 === 1 && rem100 !== 11) return `${n}st`
if (rem10 === 2 && rem100 !== 12) return `${n}nd`
if (rem10 === 3 && rem100 !== 13) return `${n}rd`
return `${n}th`
}

View File

@@ -47,7 +47,9 @@ export function countryDecal(meeting: Meeting | undefined | null): string {
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
const code = meeting?.country_code?.toUpperCase()
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)
})
}

View File

@@ -268,6 +268,63 @@ export function driverCode(row: LiveTimingRow): string {
return row.Info?.Tla || row.RacingNumber
}
/**
* Parse an F1 lap-time string ("1:45.944", "45.944") into total seconds.
* Returns null for empty/invalid values so callers never invent a gap.
*/
export function parseLapTimeSeconds(value: string | null | undefined): number | null {
if (!value) return null
const match = value.trim().match(/^(?:(\d+):)?([0-5]?\d(?:\.\d+)?)$/)
if (!match) return null
const minutes = match[1] ? Number(match[1]) : 0
const seconds = Number(match[2])
if (!Number.isFinite(minutes) || !Number.isFinite(seconds)) return null
return minutes * 60 + seconds
}
export interface BestLapGap {
isLeader: boolean
gap: string
}
/**
* Display-only gap-to-P1 for practice/qualifying, derived from each driver's
* valid best lap. Only drivers with a parseable best lap get an entry, and the
* fastest is flagged as the leader. Never fabricates a gap from a missing or
* invalid lap — the upstream interval remains the source of truth for races.
*/
export function bestLapGaps(rows: ReadonlyArray<LiveTimingRow>): Record<string, BestLapGap> {
let leaderNumber = ''
let best = Infinity
for (const row of rows) {
const seconds = parseLapTimeSeconds(row.Driver.BestLapTime)
if (seconds === null) continue
if (seconds < best) {
best = seconds
leaderNumber = row.RacingNumber
}
}
const out: Record<string, BestLapGap> = {}
if (!Number.isFinite(best)) return out
for (const row of rows) {
const seconds = parseLapTimeSeconds(row.Driver.BestLapTime)
if (seconds === null) continue
out[row.RacingNumber] =
row.RacingNumber === leaderNumber
? { isLeader: true, gap: '' }
: { isLeader: false, gap: `+${(seconds - best).toFixed(3)}` }
}
return out
}
/** True when the live feed reports an in-progress session with timing data. */
export function isLiveSessionActive(
state: { is_live?: boolean; data?: unknown } | null | undefined,
): boolean {
return Boolean(state?.is_live && state.data)
}
export function trackStatusLabel(status: string): string {
return TRACK_STATUS_LABELS[status] || status || 'UNKNOWN'
}

146
frontend/src/lib/meaning.ts Normal file
View File

@@ -0,0 +1,146 @@
// Pure interpretation helpers for pairing numbers with their "so-what".
// Thresholds are exported consts so they are cheap to tune in one place.
import { PIT_LOSS_SECONDS } from './tyredeg'
/** Gaps under this (seconds) are DRS attack range. */
export const INTERVAL_DRS_MAX_SECONDS = 1.0
/** Lower bound of the undercut window (seconds); contiguous with DRS range. */
export const INTERVAL_UNDERCUT_MIN_SECONDS = INTERVAL_DRS_MAX_SECONDS
/**
* Upper bound of the undercut window (seconds). Kept well below typical pit
* loss ({@link PIT_LOSS_SECONDS}s) — only a few seconds matter for strategy.
*/
export const INTERVAL_UNDERCUT_MAX_SECONDS = Math.min(3.0, PIT_LOSS_SECONDS / 7)
/**
* Rough per-compound cliff lap estimates (dry compounds). Wet/intermediate
* values are conservative — deg varies wildly with conditions.
*/
export const TYRE_CLIFF_LAPS: Readonly<Record<string, number>> = {
SOFT: 18,
MEDIUM: 28,
HARD: 38,
INTERMEDIATE: 20,
WET: 15,
}
/** Default cliff when compound is unknown. */
export const TYRE_CLIFF_DEFAULT_LAPS = 25
/** Championship max points per race (winner). */
export const MAX_POINTS_PER_ROUND = 25
export interface MeaningAnnotation {
caption: string
title: string
tone?: 'good' | 'bad' | 'neutral' | 'warn'
}
function cliffLaps(compound: string | null | undefined): number {
if (!compound) return TYRE_CLIFF_DEFAULT_LAPS
return TYRE_CLIFF_LAPS[compound.toUpperCase()] ?? TYRE_CLIFF_DEFAULT_LAPS
}
/**
* Interval / gap-to-ahead meaning for the live timing tower.
* Returns null for leader gaps, out-of-range values, or unparsable input.
*/
export function intervalMeaning(seconds: number | null | undefined): MeaningAnnotation | null {
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return null
if (seconds < INTERVAL_DRS_MAX_SECONDS) {
return {
caption: 'DRS range',
title: `Within ${INTERVAL_DRS_MAX_SECONDS}s — DRS enabled next straight`,
tone: 'good',
}
}
if (seconds >= INTERVAL_UNDERCUT_MIN_SECONDS && seconds <= INTERVAL_UNDERCUT_MAX_SECONDS) {
return {
caption: 'undercut window',
title: `${INTERVAL_UNDERCUT_MIN_SECONDS}${INTERVAL_UNDERCUT_MAX_SECONDS}s — pit now could gain a position (vs ~${PIT_LOSS_SECONDS}s stop)`,
tone: 'warn',
}
}
return null
}
/**
* Tyre-age meaning for deg / stint panels.
*/
export function tyreAgeMeaning(
compound: string | null | undefined,
age: number | null | undefined,
): MeaningAnnotation | null {
if (age == null || !Number.isFinite(age) || age < 0) return null
const cliff = cliffLaps(compound)
const freshEnd = Math.ceil(cliff * 0.25)
const midEnd = Math.ceil(cliff * 0.65)
if (age <= freshEnd) {
return {
caption: 'fresh',
title: `${age} lap${age === 1 ? '' : 's'} on ${compound ?? 'tyre'} — early stint grip`,
tone: 'good',
}
}
if (age <= midEnd) {
return {
caption: 'mid-life',
title: `${age} laps — tyre in its working window before cliff (~${cliff} laps)`,
tone: 'neutral',
}
}
const lapsToCliff = cliff - age
if (lapsToCliff <= 0) {
return {
caption: 'past cliff',
title: `${age} laps — beyond typical ${compound ?? 'tyre'} cliff (~${cliff} laps)`,
tone: 'bad',
}
}
return {
caption: `~${lapsToCliff} laps to cliff`,
title: `${age} of ~${cliff} laps before deg cliff on ${compound ?? 'tyre'}`,
tone: 'warn',
}
}
/**
* Points gap to the driver directly ahead — catchable-or-not v1.
*/
export function pointsGapMeaning(
gapToAhead: number | null | undefined,
roundsLeft: number,
driverAhead?: string | null,
): MeaningAnnotation | null {
if (gapToAhead == null || !Number.isFinite(gapToAhead) || gapToAhead <= 0) return null
if (!Number.isFinite(roundsLeft) || roundsLeft <= 0) return null
const maxCatchable = roundsLeft * MAX_POINTS_PER_ROUND
const ahead = driverAhead?.trim() || 'ahead'
if (gapToAhead > maxCatchable) {
return {
caption: 'out of reach',
title: `+${gapToAhead} pts with ${roundsLeft} round${roundsLeft === 1 ? '' : 's'} left (max ${maxCatchable} available)`,
tone: 'bad',
}
}
const perRound = Math.ceil(gapToAhead / roundsLeft)
return {
caption: `~${perRound} pts/round`,
title: `Needs ~${perRound} pts per round on ${ahead} to catch (${gapToAhead} pts in ${roundsLeft} round${roundsLeft === 1 ? '' : 's'})`,
tone: perRound <= 10 ? 'good' : 'warn',
}
}

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

@@ -145,6 +145,15 @@ export function formatSessionScheduleTime(value: string): string {
})
}
export const MAX_BROWSER_TIMEOUT = 2_147_483_647
export function refreshDeadlineDelay(refreshAt: string | undefined, now = Date.now()): number | null {
if (!refreshAt) return null
const deadline = Date.parse(refreshAt)
if (Number.isNaN(deadline)) return null
return Math.min(Math.max(0, deadline - now), MAX_BROWSER_TIMEOUT)
}
export type FocusMeetingKind = 'current' | 'next' | 'recent' | 'fallback'
export function focusMeetingKind(meeting: Meeting, now: Date): FocusMeetingKind {

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 { 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 type { ArticleContent, NewsItem } from '../types'
import '../styles/digest.css'
type Category = 'all' | 'official' | 'news' | 'video'
const PAGE_SIZE = 16
const CATEGORY_LABELS: Record<Category, string> = {
all: 'All',
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 }) {
const [failed, setFailed] = useState(false)
const initial = (title[0] ?? '?').toUpperCase()
@@ -98,11 +143,15 @@ function OGImage({ url, title }: { url?: string; title: string }) {
function BriefingCard({
item,
isActive,
activeTag,
onSelect,
onTagClick,
}: {
item: NewsItem
item: TaggedNewsItem
isActive: boolean
activeTag: string | null
onSelect: (item: NewsItem) => void
onTagClick: (tag: DigestTag) => void
}) {
const isRead = !!item.read_at
const isVideo = categoryOf(item) === 'video'
@@ -129,6 +178,25 @@ function BriefingCard({
{stripHtml(item.og_description || item.summary || '')}
</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>
</article>
)
@@ -146,7 +214,6 @@ function ReaderPanel({
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
@@ -155,7 +222,6 @@ function ReaderPanel({
return () => document.removeEventListener('keydown', handler)
}, [onClose])
// Fetch article when item changes
useEffect(() => {
if (!item) {
setArticle(null)
@@ -175,9 +241,14 @@ function ReaderPanel({
.catch((err) => { setError(String(err)); setLoading(false) })
}, [item?.url, item ? categoryOf(item) : ''])
// Scroll panel to top when item changes
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])
const isOpen = item !== null
@@ -297,7 +368,6 @@ function ReaderPanel({
{article && !loading && article.content && (
<div
className="bp-reader-body"
// readability strips scripts/iframes; sources are all known news outlets
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: article.content }}
/>
@@ -332,8 +402,9 @@ function ReaderPanel({
export function BriefingPage() {
const [activeCategory, setActiveCategory] = useState<Category>('all')
const [selectedItem, setSelectedItem] = useState<NewsItem | null>(null)
const [page, setPage] = useState(1)
const [activeTag, setActiveTag] = useState<string | null>(null)
const queryClient = useQueryClient()
const now = useMemo(() => new Date(), [])
const { data: allNews = [], isLoading, isError } = useQuery({
queryKey: ['news'],
@@ -341,13 +412,74 @@ export function BriefingPage() {
staleTime: 60_000,
})
const filtered = allNews.filter(
(item) => activeCategory === 'all' || categoryOf(item) === activeCategory,
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 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 pageStart = (safePage - 1) * PAGE_SIZE
const pagedItems = filtered.slice(pageStart, pageStart + PAGE_SIZE)
const categoryFiltered = useMemo(
() => taggedNews.filter(
(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> = {
all: allNews.length,
@@ -374,22 +506,14 @@ export function BriefingPage() {
const handleClose = useCallback(() => setSelectedItem(null), [])
useEffect(() => {
setPage((current) => Math.min(current, pageCount))
}, [pageCount])
const handlePageChange = useCallback(
(nextPage: number) => {
setPage(Math.min(Math.max(nextPage, 1), pageCount))
setSelectedItem(null)
window.scrollTo({ top: 0, behavior: 'smooth' })
},
[pageCount],
)
const handleTagClick = useCallback((tag: DigestTag) => {
setActiveTag((current) => (current === tag.key ? null : tag.key))
setSelectedItem(null)
}, [])
const unreadCount = allNews.filter((i) => !i.read_at).length
const pageEnd = Math.min(pageStart + pagedItems.length, filtered.length)
const showPagination = filtered.length > PAGE_SIZE
const hasDigest = meetings.length > 0
const showEmpty = tagFiltered.length === 0 && grouped.recent.length === 0
return (
<div className="bp-page" data-testid="briefing-page">
@@ -408,74 +532,106 @@ export function BriefingPage() {
<CategoryTabs
active={activeCategory}
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">
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>
) : (
<>
{showPagination && (
<div className="bp-pagination bp-pagination-top">
<span className="bp-pagination-meta mono">
{pageStart + 1}-{pageEnd} of {filtered.length}
</span>
<div className="bp-pagination-actions">
<button
className="bp-page-btn"
onClick={() => handlePageChange(safePage - 1)}
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>
{windowSections.map(({ window, items }) => (
<section
key={window.meeting_key}
className="digest-section"
data-testid={`digest-window-${window.meeting_key}`}
>
<div className="digest-section-head">
<h3 className="digest-section-title">{window.meeting_name}</h3>
<span className="digest-section-count">{items.length}</span>
</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' : ''}`}>
{pagedItems.map((item) => (
<BriefingCard
key={item.url}
item={item}
isActive={selectedItem?.url === item.url}
onSelect={handleSelect}
/>
))}
</div>
{showPagination && (
<div className="bp-pagination bp-pagination-bottom">
<button
className="bp-page-btn"
onClick={() => handlePageChange(safePage - 1)}
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>
{recentTagged.length > 0 && (
<section className="digest-section digest-recent-section" data-testid="digest-recent">
<div className="digest-section-head">
<h3 className="digest-section-title">Recent</h3>
<span className="digest-section-count">{recentTagged.length}</span>
</div>
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
{recentTagged.map((item) => (
<BriefingCard
key={item.url}
item={item}
isActive={selectedItem?.url === item.url}
activeTag={activeTag}
onSelect={handleSelect}
onTagClick={handleTagClick}
/>
))}
</div>
</section>
)}
</>
)}

View File

@@ -1,11 +1,17 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { fetchChampionshipHub, fetchSeasons } from '../api'
import { teamColor } from '../utils'
import type { ChampHubDriver, ChampionshipHub } from '../types'
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
import { RivalryCompare } from '../components/RivalryCompare'
import { Meaning } from '../components/Meaning'
import { TeammateH2H } from '../components/TeammateH2H'
import { teammatePairs } from '../lib/h2h'
import { pointsGapMeaning } from '../lib/meaning'
type View = 'drivers' | 'constructors' | 'progression' | 'simulator'
type View = 'drivers' | 'constructors' | 'progression' | 'rivalry' | 'simulator'
const GOLD = '#ffd700'
const SILVER = '#c0c0c0'
@@ -138,6 +144,8 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
color: teamColor(d.team_colour),
gapLeader: i === 0 ? 'LEADER' : `+${fmtPts(gapLeaderNum)}`,
gapAhead: gapAheadNum == null ? '—' : `+${fmtPts(gapAheadNum)}`,
gapAheadNum,
driverAhead: i === 0 ? null : drivers[i - 1].name_acronym,
spark: sparkPoints(d.form),
h2h: `${d.teammate_wins}${d.teammate_losses}`,
h2hWin: d.teammate_wins >= d.teammate_losses,
@@ -209,6 +217,14 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
>
Progression
</button>
<button
type="button"
className={`champ-tab${view === 'rivalry' ? ' is-active' : ''}`}
onClick={() => setView('rivalry')}
data-testid="champ-tab-rivalry"
>
Rivalry
</button>
<button
type="button"
className={`champ-tab${view === 'simulator' ? ' is-active' : ''}`}
@@ -233,10 +249,18 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
</div>
{view === 'drivers' && (
<DriversView enriched={enriched} leaderPoints={leader.points} titleMath={titleMath} />
<DriversView
enriched={enriched}
leaderPoints={leader.points}
titleMath={titleMath}
roundsLeft={hub.rounds_left}
drivers={drivers}
season={hub.season}
/>
)}
{view === 'constructors' && <ConstructorsView hub={hub} />}
{view === 'progression' && <ProgressionView hub={hub} />}
{view === 'rivalry' && <RivalryCompare hub={hub} />}
{view === 'simulator' && <ChampionshipSimulator hub={hub} />}
</div>
)
@@ -248,6 +272,8 @@ interface EnrichedDriver {
color: string
gapLeader: string
gapAhead: string
gapAheadNum: number | null
driverAhead: string | null
spark: string
h2h: string
h2hWin: boolean
@@ -259,12 +285,20 @@ function DriversView({
enriched,
leaderPoints,
titleMath,
roundsLeft,
drivers,
season,
}: {
enriched: EnrichedDriver[]
leaderPoints: number
titleMath: string
roundsLeft: number
drivers: ChampHubDriver[]
season: number
}) {
const podium = enriched.slice(0, 3)
const h2hPairs = useMemo(() => teammatePairs(drivers), [drivers])
return (
<div data-testid="champ-view-drivers">
<div className="champ-podium">
@@ -323,6 +357,29 @@ function DriversView({
<span className="champ-titlemath-text">{titleMath}</span>
</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">
<table className="champ-table champ-table-drivers">
<thead>
@@ -347,17 +404,36 @@ function DriversView({
P{e.pos}
</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-code mono">{e.d.name_acronym}</span>
<span className="champ-drv-name">{e.d.full_name}</span>
<span className="champ-drv-num mono">#{e.d.driver_number}</span>
</div>
</Link>
</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-muted">{e.gapLeader}</td>
<td className="r mono champ-td-dim">{e.gapAhead}</td>
<td className="r mono champ-td-dim">
{(() => {
const gapAnnotation = pointsGapMeaning(e.gapAheadNum, roundsLeft, e.driverAhead)
return (
<Meaning
value={e.gapAhead}
meaning={gapAnnotation?.caption}
title={gapAnnotation?.title}
tone={gapAnnotation?.tone}
/>
)
})()}
</td>
<td className="c mono" style={{ color: e.d.wins > 0 ? 'var(--text)' : 'var(--text-3)' }}>
{e.d.wins}
</td>

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

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchLiveState, fetchLiveTrackOutline } from '../api'
import { fetchLiveState } from '../api'
import type { LivePosition, LiveStreamData } from '../types'
import {
loadPinnedDrivers,
@@ -15,21 +15,27 @@ import type { VisibleSectorState } from '../lib/live'
import type { GapHistoryMap } from '../lib/gapHistory'
import { recordGapSamples } from '../lib/gapHistory'
import { battleNumbers, detectBattles } from '../lib/battles'
import type { LiveEvent } from '../lib/events'
import { appendEvents, diffSnapshots, sessionSignature } from '../lib/events'
import { SessionBanner } from '../components/live/SessionBanner'
import { TrackStatusBanner } from '../components/live/TrackStatusBanner'
import { TimingTower } from '../components/live/TimingTower'
import { BattleChips } from '../components/live/BattleChips'
import { PinnedDrivers } from '../components/live/PinnedDrivers'
import { RaceControlFeed } from '../components/live/RaceControlFeed'
import { EventRail } from '../components/live/EventRail'
import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
import { TrackMap } from '../components/live/TrackMap'
import { TyreDegPanel } from '../components/live/TyreDegPanel'
import { Radio } from 'lucide-react'
import { Archive, Radio } from 'lucide-react'
type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
export function LiveTimingPage() {
const [snapshot, setSnapshot] = useState<LiveStreamData | null>(null)
const [activeSnapshot, setActiveSnapshot] = useState<LiveStreamData | null>(null)
const [archiveSnapshot, setArchiveSnapshot] = useState<LiveStreamData | null>(null)
const [archivePositions, setArchivePositions] = useState<Record<string, LivePosition>>({})
const [archiveSnapshotAt, setArchiveSnapshotAt] = useState<string | null>(null)
const [archiveMode, setArchiveMode] = useState(false)
const [isLive, setIsLive] = useState(false)
const [streamStatus, setStreamStatus] = useState<StreamStatus>('connecting')
const [now, setNow] = useState(Date.now())
@@ -37,6 +43,13 @@ export function LiveTimingPage() {
const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers())
const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({})
const [positions, setPositions] = useState<Record<string, LivePosition>>({})
const [events, setEvents] = useState<LiveEvent[]>([])
const prevSnapshotRef = useRef<LiveStreamData | null>(null)
const sessionSigRef = useRef('')
const isLiveRef = useRef(false)
const archiveModeRef = useRef(false)
const snapshot = isLive ? activeSnapshot : archiveMode ? archiveSnapshot : null
const hasArchive = Boolean(archiveSnapshot)
const { data, isLoading, isError, error } = useQuery({
queryKey: ['live-state'],
@@ -44,24 +57,31 @@ export function LiveTimingPage() {
staleTime: 5_000,
})
const trackOutlineQuery = useQuery({
queryKey: [
'live-track-outline',
snapshot?.Session?.MeetingName ?? '',
snapshot?.Session?.CircuitName ?? '',
],
queryFn: () => fetchLiveTrackOutline(snapshot!.Session),
enabled: Boolean(snapshot?.Session?.MeetingName || snapshot?.Session?.CircuitName),
staleTime: Infinity,
retry: false,
})
useEffect(() => {
if (!data) return
setIsLive(data.is_live)
setSnapshot(data.data)
const nextLive = data.is_live && Boolean(data.data)
setIsLive(nextLive)
isLiveRef.current = nextLive
if (nextLive && data.data) {
setActiveSnapshot(data.data)
setArchiveMode(false)
return
}
setActiveSnapshot(null)
setArchiveSnapshot(data.last_snapshot ?? null)
setArchivePositions(data.last_positions ?? {})
setArchiveSnapshotAt(data.last_snapshot_at ?? null)
setArchiveMode((current) => current && Boolean(data.last_snapshot))
}, [data])
useEffect(() => {
archiveModeRef.current = archiveMode
if (archiveMode) {
setPositions(archivePositions)
}
}, [archiveMode, archivePositions])
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(timer)
@@ -84,8 +104,23 @@ export function LiveTimingPage() {
events.addEventListener('snapshot', (event) => {
const state = parseLiveStateEvent(event.data)
if (!state || cancelled) return
setIsLive(state.is_live)
setSnapshot(state.data)
const nextLive = state.is_live && Boolean(state.data)
setIsLive(nextLive)
if (nextLive && state.data) {
if (!isLiveRef.current || archiveModeRef.current) {
setPositions({})
}
isLiveRef.current = true
setActiveSnapshot(state.data)
setArchiveMode(false)
} else {
isLiveRef.current = false
setActiveSnapshot(null)
setArchiveSnapshot(state.last_snapshot ?? null)
setArchivePositions(state.last_positions ?? {})
setArchiveSnapshotAt(state.last_snapshot_at ?? null)
setArchiveMode((current) => current && Boolean(state.last_snapshot))
}
setStreamStatus('connected')
})
@@ -114,6 +149,23 @@ export function LiveTimingPage() {
}
}, [])
// Synthesize "what just happened" events from successive snapshots; the
// buffer resets whenever a different session starts streaming.
useEffect(() => {
if (!snapshot) return
const signature = sessionSignature(snapshot.Session)
const prev = prevSnapshotRef.current
prevSnapshotRef.current = snapshot
if (signature !== sessionSigRef.current) {
sessionSigRef.current = signature
setEvents([])
return
}
if (!prev || prev === snapshot) return
const fresh = diffSnapshots(prev, snapshot)
if (fresh.length > 0) setEvents((current) => appendEvents(current, fresh))
}, [snapshot])
const rawRows = useMemo(() => sortLiveTimingRows(snapshot), [snapshot])
useEffect(() => {
@@ -151,6 +203,19 @@ export function LiveTimingPage() {
setPinned((prev) => togglePin(prev, racingNumber))
}
const handleViewArchive = () => {
if (!archiveSnapshot) return
setIsLive(false)
setArchiveMode(true)
setPositions(archivePositions)
}
const archiveTimestamp = archiveSnapshotAt ? new Date(archiveSnapshotAt) : null
const archiveLabel =
archiveTimestamp && !Number.isNaN(archiveTimestamp.getTime())
? `Archived snapshot from ${archiveTimestamp.toLocaleString()}`
: 'Archived live timing snapshot'
return (
<div className="page live-page" data-testid="live-page">
{isError && (
@@ -159,12 +224,18 @@ export function LiveTimingPage() {
</div>
)}
{streamStatus === 'disconnected' && snapshot && (
{streamStatus === 'disconnected' && snapshot && !archiveMode && (
<div className="live-status-strip live-status-warn">
Stream disconnected showing last received snapshot
</div>
)}
{archiveMode && snapshot && (
<div className="live-status-strip live-status-archive" data-testid="live-archive-strip">
{archiveLabel} live updates are paused for this archive view
</div>
)}
{isLoading && !snapshot && (
<div className="loading-state">connecting to live timing</div>
)}
@@ -179,22 +250,27 @@ export function LiveTimingPage() {
<p className="empty-state-desc" style={{ color: 'var(--text-2)' }}>
The telemetry feed is currently offline. <br /><br /> Check the <a href="/" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Command Center</a> for the weekend schedule or explore historical data in the <a href="/race-hub" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Race Hub</a>.
</p>
{hasArchive && (
<button type="button" className="live-archive-btn" onClick={handleViewArchive}>
<Archive size={15} />
View Last Session
</button>
)}
</div>
)}
{snapshot && (
<>
<SessionBanner isLive={isLive} snapshot={snapshot} rows={rows} connection={streamStatus} now={now} />
<SessionBanner
isLive={isLive}
isArchive={archiveMode}
snapshot={snapshot}
rows={rows}
connection={streamStatus}
now={now}
/>
<TrackStatusBanner status={snapshot.TrackStatus} />
<PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} />
<TrackMap
outline={trackOutlineQuery.data}
positions={positions}
telemetry={snapshot.Telemetry}
drivers={snapshot.Drivers}
driverInfo={snapshot.DriverInfo}
loading={trackOutlineQuery.isLoading}
/>
<TyreDegPanel rows={rows} sessionType={snapshot.Session?.SessionType} pinned={pinned} />
<div className="live-columns">
<div className="live-tower-col">
@@ -213,7 +289,10 @@ export function LiveTimingPage() {
session={snapshot.Session}
/>
</div>
{/* Rail runs most-synthesized to most-raw: a reader arriving
mid-session wants "what did I miss" before the regulatory log. */}
<div className="live-rc-col">
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
<TeamRadioTicker
captures={snapshot.TeamRadio ?? []}
driverInfo={snapshot.DriverInfo}

View File

@@ -1,18 +1,14 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import {
fetchLocalMeetings,
fetchRaceHub,
fetchSeasons,
fetchWeekend,
} from '../api'
import { fetchRaceHub, fetchWeekend, fetchWeekendContext } from '../api'
import { DatasetStrip } from '../components/DatasetStrip'
import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
import { TabBar, type Tab } from '../components/TabBar'
import { DatasetStatusView } from '../components/DatasetStatusView'
import { StrategyView } from '../components/StrategyView'
import { LapsView } from '../components/LapsView'
import { CompareView } from '../components/CompareView'
import { RaceControlView } from '../components/RaceControlView'
import { WeatherView } from '../components/WeatherView'
import { OverviewView } from '../components/OverviewView'
@@ -21,76 +17,72 @@ import { SourceBadge } from '../components/SourceBadge'
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
import {
MAX_BROWSER_TIMEOUT,
formatCountdown,
formatSessionScheduleTime,
pickFocusMeeting,
refreshDeadlineDelay,
sortSessionsByStart,
} from '../lib/schedule'
import type { Weekend, WeekendSession } from '../types'
import type { ContextSession, Weekend } from '../types'
interface Props {
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) {
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<Tab>('overview')
const [switcherOpen, setSwitcherOpen] = useState(false)
const [now, setNow] = useState(() => Date.now())
const [refreshGeneration, setRefreshGeneration] = useState(0)
// ─── Auto-redirect when no session_key is supplied ───
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: fetchSeasons,
// The server owns bare Race Hub selection so every open tab crosses the
// one-hour handoff at the same instant.
const contextQuery = useQuery({
queryKey: ['weekend-context'],
queryFn: fetchWeekendContext,
enabled: sessionKey === 0,
})
const { refetch: refetchContext } = contextQuery
const latestSeason = seasonsQuery.data?.[0] ?? null
const meetingsQuery = useQuery({
queryKey: ['meetings', latestSeason],
queryFn: () => fetchLocalMeetings(latestSeason!),
enabled: sessionKey === 0 && latestSeason != null,
})
const focusMeeting = useMemo(() => {
if (sessionKey !== 0 || !meetingsQuery.data) return null
return pickFocusMeeting(meetingsQuery.data, new Date())
}, [sessionKey, meetingsQuery.data])
const fallbackWeekendQuery = useQuery({
queryKey: ['weekend', focusMeeting?.meeting_key],
queryFn: () => fetchWeekend(focusMeeting!.meeting_key),
enabled: sessionKey === 0 && focusMeeting != null,
const context = contextQuery.data
const preSession = sessionKey === 0 && context?.race_hub_pre_session === true
const preSessionRef = context?.race_hub_default_session
const preSessionMeetingKey = preSessionRef?.meeting?.meeting_key
const preSessionWeekendQuery = useQuery({
queryKey: ['weekend', preSessionMeetingKey],
queryFn: () => fetchWeekend(preSessionMeetingKey!),
enabled: preSession && preSessionMeetingKey != null && preSessionMeetingKey > 0,
})
useEffect(() => {
if (sessionKey !== 0) return
const weekend = fallbackWeekendQuery.data
if (!weekend) return
const target = pickAnalysisSession(weekend)?.session.session_key
?? weekend.default_session_key
?? weekend.sessions[0]?.session.session_key
if (target) {
navigate({ to: '/race-hub', search: { session_key: target }, replace: true })
}
}, [sessionKey, fallbackWeekendQuery.data, navigate])
const delay = refreshDeadlineDelay(context?.race_hub_refresh_at)
if (delay == null) return
const rearmAfterRefetch = delay === MAX_BROWSER_TIMEOUT
const timer = window.setTimeout(() => {
void refetchContext().finally(() => {
if (rearmAfterRefetch) setRefreshGeneration((generation) => generation + 1)
})
}, delay)
return () => window.clearTimeout(timer)
}, [sessionKey, context?.race_hub_refresh_at, refetchContext, refreshGeneration])
useEffect(() => {
if (!preSession) return
const timer = window.setInterval(() => setNow(Date.now()), 1_000)
return () => window.clearInterval(timer)
}, [preSession])
// A bare route retains canonical context ownership while rendering its
// completed analysis selection. Explicit URLs remain user-owned.
const selectedSessionKey = sessionKey || context?.race_hub_default_session?.session.session_key || 0
// ─── Active session payload ───
const raceHubQuery = useQuery({
queryKey: ['race-hub', sessionKey],
queryFn: () => fetchRaceHub(sessionKey),
enabled: sessionKey > 0,
queryKey: ['race-hub', selectedSessionKey],
queryFn: () => fetchRaceHub(selectedSessionKey),
enabled: selectedSessionKey > 0 && (sessionKey > 0 || !preSession),
staleTime: 30_000,
})
@@ -107,25 +99,36 @@ export function RaceHubPage({ sessionKey }: Props) {
const accent = countryAccent(data?.meeting ?? null)
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
// ─── No session_key: show resolving state, fall back to switcher if no local data ───
// ─── No session_key: resolve exclusively through canonical Weekend Context ───
if (sessionKey === 0) {
if (seasonsQuery.isLoading || meetingsQuery.isLoading || fallbackWeekendQuery.isLoading) {
if (contextQuery.isLoading || (preSession && preSessionWeekendQuery.isLoading)) {
return (
<div className="rh-page" style={accentStyle}>
<div className="loading-state">resolving latest local weekend</div>
</div>
)
}
const seasons = seasonsQuery.data ?? []
if (seasons.length === 0) {
if (preSession && preSessionRef) {
return (
<RaceHubPreSession
session={preSessionRef}
weekend={preSessionWeekendQuery.data}
now={now}
switcherOpen={switcherOpen}
onToggleSwitcher={() => setSwitcherOpen((open) => !open)}
onCloseSwitcher={() => setSwitcherOpen(false)}
/>
)
}
if (!selectedSessionKey) {
return (
<div className="rh-page rh-empty" data-testid="race-hub-empty" style={accentStyle}>
<div className="rh-empty-band">
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
<h1 className="rh-empty-title">No local sessions yet</h1>
<h1 className="rh-empty-title">No completed local analysis yet</h1>
<p className="rh-empty-sub">
The Race Hub reads from local ingest only. Once a weekend is ingested
it will open here automatically.
Race Hub opens completed local analysis between weekends. Check Data Health
to ingest a completed session.
</p>
<div className="rh-empty-actions">
<a href="/admin" className="rh-empty-action">Open Admin · Data Health</a>
@@ -135,18 +138,13 @@ export function RaceHubPage({ sessionKey }: Props) {
</div>
)
}
return (
<div className="rh-page" style={accentStyle}>
<div className="loading-state">resolving latest local weekend</div>
</div>
)
}
// ─── Loading / error for the requested session_key ───
// ─── Loading / error for the selected session ───
if (raceHubQuery.isLoading) {
return (
<div className="rh-page" style={accentStyle}>
<div className="loading-state">loading session {sessionKey}</div>
<div className="loading-state">loading session {selectedSessionKey}</div>
</div>
)
}
@@ -156,7 +154,7 @@ export function RaceHubPage({ sessionKey }: Props) {
<div className="error-box">
{raceHubQuery.error instanceof Error
? raceHubQuery.error.message
: `Failed to load session ${sessionKey}.`}
: `Failed to load session ${selectedSessionKey}.`}
</div>
</div>
)
@@ -167,7 +165,7 @@ export function RaceHubPage({ sessionKey }: Props) {
const sessionMeta = weekend
? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w]))
: {}
const activeSessionMeta = sessionMeta[sessionKey]
const activeSessionMeta = sessionMeta[selectedSessionKey]
return (
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
@@ -193,7 +191,7 @@ export function RaceHubPage({ sessionKey }: Props) {
{switcherOpen && (
<WeekendSwitcher
currentMeetingKey={meetingKey}
currentSessionKey={sessionKey}
currentSessionKey={selectedSessionKey}
onClose={() => setSwitcherOpen(false)}
/>
)}
@@ -224,7 +222,7 @@ export function RaceHubPage({ sessionKey }: Props) {
<nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail">
{sessions.map((session) => {
const meta = sessionMeta[session.session_key]
const active = session.session_key === sessionKey
const active = session.session_key === selectedSessionKey
return (
<button
key={session.session_key}
@@ -277,7 +275,7 @@ export function RaceHubPage({ sessionKey }: Props) {
{formatCoverageHint(activeSessionMeta.datasets)} datasets local
</span>
)}
<span className="rh-active-key mono">key {sessionKey}</span>
<span className="rh-active-key mono">key {selectedSessionKey}</span>
</div>
)}
@@ -307,6 +305,19 @@ export function RaceHubPage({ sessionKey }: Props) {
</div>
)}
{activeTab === 'compare' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Driver Compare</span>
</div>
<CompareView
sessionKey={selectedSessionKey}
results={data.results}
drivers={data.drivers}
/>
</div>
)}
{activeTab === 'lap_data' && (
<div className="data-section">
<div className="sec-header">
@@ -354,3 +365,72 @@ export function RaceHubPage({ sessionKey }: Props) {
</div>
)
}
function RaceHubPreSession({
session,
weekend,
now,
switcherOpen,
onToggleSwitcher,
onCloseSwitcher,
}: {
session: ContextSession
weekend?: Weekend
now: number
switcherOpen: boolean
onToggleSwitcher: () => void
onCloseSwitcher: () => void
}) {
const meeting = session.meeting
const sessions = sortSessionsByStart((weekend?.sessions ?? []).map((entry) => entry.session))
const target = new Date(session.session.date_start)
const accent = countryAccent(meeting ?? null)
const pendingLiveEvidence = target.getTime() <= now
return (
<div className="rh-page rh-empty" data-testid="race-hub-pre-session" style={{ '--gp-accent': accent } as React.CSSProperties}>
<div className="rh-topbar">
<span className="rh-topbar-label mono">box-box · race hub</span>
<span className="rh-topbar-spacer" />
<button
type="button"
className={`rh-switcher-toggle${switcherOpen ? ' active' : ''}`}
onClick={onToggleSwitcher}
aria-expanded={switcherOpen}
aria-controls="rh-weekend-switcher"
data-testid="rh-switch-weekend"
>
{switcherOpen ? 'Close' : 'Switch Weekend'}
</button>
</div>
{switcherOpen && (
<WeekendSwitcher
currentMeetingKey={meeting?.meeting_key}
currentSessionKey={session.session.session_key}
onClose={onCloseSwitcher}
/>
)}
<section className="rh-empty-band">
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
<h1 className="rh-empty-title">{meeting?.meeting_name ?? 'Next race weekend'}</h1>
<p className="rh-empty-sub">
{pendingLiveEvidence
? `${session.session.session_name} is scheduled; awaiting live timing.`
: <>{session.session.session_name} begins in <span className="mono">{formatCountdown(target, new Date(now))}</span></>}
</p>
{sessions.length > 0 && (
<div className="preview-schedule" data-testid="rh-pre-session-schedule">
{sessions.map((scheduled) => (
<div key={scheduled.session_key} className="preview-schedule-item">
<span className="preview-schedule-name">{scheduled.session_name}</span>
<span className="preview-schedule-time">{formatSessionScheduleTime(scheduled.date_start)}</span>
</div>
))}
</div>
)}
</section>
</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

@@ -6,11 +6,17 @@ import { DataLibraryPage } from './pages/DataLibraryPage'
import { LiveTimingPage } from './pages/LiveTimingPage'
import { BriefingPage } from './pages/BriefingPage'
import { ChampionshipPage } from './pages/ChampionshipPage'
import { DriverProfilePage } from './pages/DriverProfilePage'
import { RacePreviewPage } from './pages/RacePreviewPage'
type RaceHubSearch = {
session_key?: number
}
type DriverProfileSearch = {
year?: number
}
const rootRoute = createRootRoute({
component: () => (
<>
@@ -64,12 +70,32 @@ export const championshipRoute = createRoute({
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({
getParentRoute: () => rootRoute,
path: '/briefing',
component: BriefingPage,
})
export const previewRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/preview',
component: RacePreviewPage,
})
const routeTree = rootRoute.addChildren([
commandCenterRoute,
raceHubRoute,
@@ -77,7 +103,9 @@ const routeTree = rootRoute.addChildren([
dataLibraryRoute,
liveTimingRoute,
championshipRoute,
driverProfileRoute,
briefingRoute,
previewRoute,
])
export const router = createRouter({ routeTree })

View File

@@ -89,6 +89,16 @@ a { color: inherit; text-decoration: none; }
}
.app-nav::-webkit-scrollbar { display: none; }
/* The nav scrolls horizontally with its scrollbar hidden. Below the width
where the links stop fitting, fade the trailing edge so the cut-off item
reads as "scroll for more" instead of as a clipping bug. */
@media (max-width: 560px) {
.app-nav {
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent 100%);
mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent 100%);
}
}
.nav-logo {
font-family: var(--f-mono);
font-size: 15px;
@@ -112,6 +122,30 @@ a { color: inherit; text-decoration: none; }
.nav-links a:hover { color: var(--text); background: var(--surface-h); }
.nav-links a.active { color: var(--text); background: var(--surface-2); }
/* Live nav gets a pulsing marker only while a session is on air. */
.nav-live { display: inline-flex; align-items: center; gap: 6px; }
.nav-links a.nav-live-on { color: var(--text); font-weight: 600; }
.nav-live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--red);
box-shadow: 0 0 0 0 rgba(225, 6, 0, 0.4);
animation: pulse-live 1.6s infinite;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.nav-utility {
margin-left: auto;
display: flex;
@@ -808,6 +842,12 @@ a { color: inherit; text-decoration: none; }
color: var(--yellow);
}
.live-status-archive {
background: rgba(70, 140, 255, 0.08);
border: 1px solid rgba(70, 140, 255, 0.24);
color: #8bb7ff;
}
/* ── Live empty state ── */
.live-empty-status {
display: flex;
@@ -815,6 +855,29 @@ a { color: inherit; text-decoration: none; }
margin-bottom: var(--s4);
}
.live-archive-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--s2);
margin-top: var(--s5);
min-height: 36px;
padding: 0 var(--s4);
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
color: var(--text);
font-family: var(--f-mono);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.live-archive-btn:hover {
border-color: rgba(255, 255, 255, 0.28);
background: rgba(255, 255, 255, 0.1);
}
.mono { font-family: var(--f-mono); }
.live-conn,

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,78 @@
.compare-view {
display: flex;
flex-direction: column;
gap: var(--s5);
}
.compare-pickers {
display: flex;
flex-wrap: wrap;
gap: var(--s4);
align-items: flex-end;
}
.compare-picker {
display: flex;
flex-direction: column;
gap: var(--s3);
min-width: 180px;
}
.compare-picker-label {
font-family: var(--f-mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
}
.compare-picker-select {
font-family: var(--f-mono);
font-size: 13px;
padding: var(--s2) var(--s3);
border: 1px solid var(--border);
border-radius: var(--r1);
background: var(--surface-2);
color: var(--text);
}
.compare-picker-select:focus {
outline: 2px solid var(--gp-accent, var(--red));
outline-offset: 1px;
}
.compare-section {
display: flex;
flex-direction: column;
gap: var(--s3);
}
.compare-section-title {
font-family: var(--f-mono);
font-size: 12px;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-2);
}
.compare-section-meta {
font-family: var(--f-mono);
font-size: 11px;
color: var(--text-3);
}
.compare-pit-caption {
font-family: var(--f-mono);
font-size: 11px;
color: var(--text-3);
margin: 0;
}
.compare-stale-notice {
font-size: 12px;
color: var(--amber, #f59e0b);
background: rgba(245, 158, 11, 0.08);
border: 1px solid rgba(245, 158, 11, 0.25);
border-radius: var(--r1);
padding: var(--s2) var(--s3);
}

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

View File

@@ -0,0 +1,67 @@
/* ── "What just happened" event rail (live page) ── */
.event-rail {
margin-top: var(--s5);
}
.event-rail-list {
display: flex;
flex-direction: column;
gap: var(--s3);
padding-top: var(--s4);
}
.event-rail-row {
display: flex;
align-items: flex-start;
gap: var(--s4);
padding: var(--s3) var(--s4);
border-left: 3px solid var(--border-2);
background: rgba(255, 255, 255, 0.03);
border-radius: 0 4px 4px 0;
}
.event-rail-icon {
display: inline-flex;
align-items: center;
color: var(--text-2);
padding-top: 1px;
flex-shrink: 0;
}
.event-kind-fastest-lap .event-rail-icon { color: var(--purple); }
.event-kind-personal-best .event-rail-icon { color: var(--green); }
.event-kind-overtake .event-rail-icon,
.event-kind-position-gain .event-rail-icon { color: var(--green); }
.event-kind-position-loss .event-rail-icon { color: var(--yellow); }
.event-kind-retirement .event-rail-icon,
.event-kind-track-status .event-rail-icon { color: var(--red); }
.event-kind-pit-in .event-rail-icon,
.event-kind-pit-out .event-rail-icon { color: var(--blue); }
.event-rail-body {
display: flex;
flex-direction: column;
gap: var(--s1);
min-width: 0;
flex: 1;
}
.event-rail-headline {
font-size: 12px;
color: var(--text);
line-height: 1.4;
}
.event-rail-detail {
font-size: 11px;
color: var(--text-2);
}
.event-rail-lap {
font-family: var(--f-mono);
font-size: 10px;
color: var(--text-3);
flex-shrink: 0;
padding-top: 2px;
}

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,48 @@
/* Compact value + muted meaning caption (issue #18). */
.meaning {
display: inline-flex;
flex-direction: column;
align-items: inherit;
gap: 1px;
line-height: 1.2;
}
.meaning-value {
/* inherits table cell mono styling from parent */
}
.meaning-caption {
font-size: 10px;
font-family: var(--f-mono);
color: var(--text-3);
letter-spacing: 0.02em;
white-space: nowrap;
}
.meaning-caption--good {
color: var(--green);
}
.meaning-caption--bad {
color: var(--red);
}
.meaning-caption--warn {
color: var(--yellow, #e8c547);
}
.meaning-caption--neutral {
color: var(--text-3);
}
/* Table cells: right-align caption under numeric values */
td.r .meaning,
.champ-td-dim .meaning,
.champ-td-muted .meaning {
align-items: flex-end;
}
.tyredeg-row .meaning {
align-items: flex-start;
}

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

@@ -40,6 +40,13 @@
stroke-dasharray: 2 1;
}
.stint-timeline__pit-marker {
stroke: var(--text);
stroke-width: 1.5;
opacity: 0.7;
pointer-events: stroke;
}
.stint-timeline__axis-tick {
font-family: var(--f-mono);
font-size: 9px;

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,
form: [25, 18, 25, 15, 25],
cumulative: [25, 43, 68, 83, 108, 200],
round_positions: [1, 2, 1, 3, 1, 1],
teammate_wins: 9,
teammate_losses: 1,
...over,
@@ -47,6 +48,7 @@ const drivers: ChampHubDriver[] = [
position: 2,
wins: 3,
cumulative: [18, 36, 54, 80, 120, 160],
round_positions: [2, 1, 2, 2, 2, 2],
}),
driver({
driver_number: 16,
@@ -58,6 +60,7 @@ const drivers: ChampHubDriver[] = [
position: 3,
wins: 1,
cumulative: [15, 28, 40, 60, 90, 120],
round_positions: [3, 3, 3, 0, 3, 3],
}),
]
@@ -112,6 +115,7 @@ describe('ChampionshipPage', () => {
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
expect(screen.getByText('Monaco GP', { exact: false })).toBeInTheDocument()
expect(screen.getByTestId('champ-titlemath')).toHaveTextContent('mathematically win the title')
expect(screen.getAllByText('~10 pts/round').length).toBeGreaterThan(0)
})
it('switches to constructors and progression views', async () => {
@@ -128,6 +132,22 @@ describe('ChampionshipPage', () => {
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 () => {
renderPage()
@@ -149,4 +169,61 @@ describe('ChampionshipPage', () => {
})
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,
form: [],
cumulative: [],
round_positions: [],
teammate_wins: 0,
teammate_losses: 0,
...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

@@ -156,6 +156,7 @@ describe('CommandCenterPage', () => {
race_control: [],
weather: [],
laps: [],
chapters: [],
})
})

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

@@ -189,6 +189,52 @@ describe('TimingTower', () => {
expect(screen.getByText(/no driver timing rows/i)).toBeInTheDocument()
})
it('annotates DRS-range intervals in race mode', () => {
const raceRows = [
makeRow('1', 1, 'VER'),
makeRow('4', 2, 'NOR', { Interval: '+0.4', GapToLeader: '+0.4' }),
]
render(
<TimingTower
rows={raceRows}
session={{
MeetingName: 'Monaco Grand Prix',
CircuitName: 'Monaco',
SessionType: 'Race',
SessionName: 'Race',
Path: '',
}}
/>,
)
expect(screen.getByText('DRS range')).toBeInTheDocument()
})
it('derives a gap to P1 from best laps when the practice feed omits GapToLeader', () => {
const practiceRows = [
makeRow('1', 1, 'VER', { BestLapTime: '1:45.944', GapToLeader: '' }),
makeRow('4', 2, 'NOR', { BestLapTime: '1:46.134', GapToLeader: '' }),
makeRow('16', 3, 'LEC', { BestLapTime: '', GapToLeader: '' }),
]
render(
<TimingTower
rows={practiceRows}
session={{
MeetingName: 'Belgian Grand Prix',
CircuitName: 'Spa',
SessionType: 'Practice',
SessionName: 'Practice 2',
Path: '',
}}
/>,
)
// Leader shows a clear leader marker, not a fabricated gap.
expect(screen.getByText('VER').closest('tr')).toHaveTextContent('—')
expect(screen.getByText('+0.190')).toBeInTheDocument()
// A driver without a valid best lap gets no invented gap.
const lecRow = screen.getByText('LEC').closest('tr')!
expect(lecRow).not.toHaveTextContent('+')
})
it('renders the SQ1 cutoff after P17 and marks rows below as at risk', () => {
const sprintRows = Array.from({ length: 22 }, (_, index) =>
makeRow(String(index + 1), index + 1, `D${index + 1}`),

View File

@@ -0,0 +1,189 @@
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 { LiveTimingPage } from '../pages/LiveTimingPage'
import type { LiveStateResponse, LiveStreamData } from '../types'
vi.mock('../api', () => ({
fetchLiveState: vi.fn(),
fetchLiveTrackOutline: vi.fn(),
}))
import { fetchLiveState, fetchLiveTrackOutline } from '../api'
const mockFetchLiveState = vi.mocked(fetchLiveState)
const mockFetchLiveTrackOutline = vi.mocked(fetchLiveTrackOutline)
class MockEventSource {
onopen: (() => void) | null = null
onerror: (() => void) | null = null
constructor() {
setTimeout(() => this.onopen?.(), 0)
}
addEventListener() {}
close() {}
}
const archivedSnapshot: LiveStreamData = {
Drivers: {
'1': {
RacingNumber: '1',
Position: 1,
PrevPosition: 1,
GapToLeader: '',
Interval: '',
LastLapTime: '1:21.345',
LastLapPB: false,
LastLapOB: false,
BestLapTime: '1:20.987',
BestLapPB: false,
BestLapOB: false,
BestLapNum: 22,
InPit: false,
PitOut: false,
Retired: false,
KnockedOut: false,
Cutoff: false,
OnFlyingLap: false,
NumberOfLaps: 30,
SpeedTrap: '',
Sectors: [],
},
},
DriverInfo: {
'1': {
RacingNumber: '1',
BroadcastName: 'M VERSTAPPEN',
Tla: 'VER',
TeamName: 'Red Bull Racing',
TeamColour: '3671C6',
FirstName: 'Max',
LastName: 'Verstappen',
},
},
Tyres: {
'1': { Compound: 'HARD', New: false, Age: 12 },
},
Telemetry: {},
RCMessages: [],
Weather: {
AirTemp: 22,
TrackTemp: 41,
Humidity: 58,
WindSpeed: 3,
WindDir: 180,
Rainfall: false,
},
Session: {
MeetingName: 'Testonia Grand Prix',
CircuitName: 'Testring',
SessionType: 'Race',
SessionName: 'Race',
Path: '',
},
TeamRadio: [],
SessionStatus: 'Finished',
TrackStatus: '1',
CurrentLap: 57,
TotalLaps: 57,
Clock: '',
ClockRefTime: '',
ClockExtrapolating: false,
Stints: {},
}
function renderPage(response: LiveStateResponse) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
mockFetchLiveState.mockResolvedValue(response)
mockFetchLiveTrackOutline.mockResolvedValue({
circuit_key: 1,
points: [],
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
})
return render(
<QueryClientProvider client={queryClient}>
<LiveTimingPage />
</QueryClientProvider>,
)
}
describe('LiveTimingPage archive mode', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.defineProperty(window, 'EventSource', {
value: MockEventSource,
writable: true,
configurable: true,
})
})
it('keeps archived snapshots behind the View Last Session action', async () => {
renderPage({
is_live: false,
data: null,
last_snapshot: archivedSnapshot,
last_positions: {
'1': { x: 10, y: 20, z: 0, status: 'OnTrack' },
},
last_snapshot_at: '2026-07-04T14:00:00Z',
})
expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active')
expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /view last session/i }))
await waitFor(() => {
expect(screen.getByTestId('live-archive-strip')).toHaveTextContent('Archived snapshot')
})
expect(screen.getByText('Timing Tower')).toBeInTheDocument()
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
expect(screen.getByText('archive')).toBeInTheDocument()
})
it('does not show the archive action when no snapshot is retained', async () => {
renderPage({ is_live: false, data: null })
expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active')
expect(screen.queryByRole('button', { name: /view last session/i })).not.toBeInTheDocument()
})
it('renders the live session for a red-flag paused snapshot instead of the inactive empty state', async () => {
renderPage({
is_live: true,
data: {
...archivedSnapshot,
SessionStatus: 'Inactive',
TrackStatus: '2',
Clock: '00:03:27',
Session: {
MeetingName: 'Belgian Grand Prix',
CircuitName: 'Spa-Francorchamps',
SessionType: 'Practice',
SessionName: 'Practice 2',
Path: '',
},
},
})
expect(await screen.findByText('Timing Tower')).toBeInTheDocument()
expect(screen.queryByTestId('live-empty')).not.toBeInTheDocument()
expect(screen.queryByTestId('live-archive-strip')).not.toBeInTheDocument()
})
it('temporarily omits the track map while live GPS is unavailable', async () => {
renderPage({
is_live: true,
data: { ...archivedSnapshot, SessionStatus: 'Started' },
})
expect(await screen.findByText('Timing Tower')).toBeInTheDocument()
expect(screen.queryByText('Track Map')).not.toBeInTheDocument()
expect(mockFetchLiveTrackOutline).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Meaning } from '../components/Meaning'
describe('Meaning', () => {
it('renders bare value when meaning is null', () => {
render(<Meaning value="+1.234" meaning={null} />)
expect(screen.getByText('+1.234')).toBeInTheDocument()
expect(screen.queryByText('DRS range')).not.toBeInTheDocument()
})
it('renders value with caption and tooltip title', () => {
render(
<Meaning
value="+0.4"
meaning="DRS range"
title="Within 1.0s — DRS enabled next straight"
tone="good"
/>,
)
const value = screen.getByText('+0.4')
expect(value).toBeInTheDocument()
expect(screen.getByText('DRS range')).toHaveClass('meaning-caption--good')
expect(value.closest('.meaning')).toHaveAttribute('title', 'Within 1.0s — DRS enabled next straight')
})
})

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { act, render, screen, waitFor, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
Outlet,
@@ -9,21 +9,24 @@ import {
createRoute,
} from '@tanstack/react-router'
import { RaceHubPage } from '../pages/RaceHubPage'
import type { DatasetInfo, Meeting, RaceHub, Session, Weekend } from '../types'
import { MAX_BROWSER_TIMEOUT } from '../lib/schedule'
import type { ContextAvailability, DatasetInfo, Meeting, RaceHub, Session, Weekend, WeekendContext } from '../types'
vi.mock('../api', () => ({
fetchRaceHub: vi.fn(),
fetchSeasons: vi.fn(),
fetchLocalMeetings: vi.fn(),
fetchWeekend: vi.fn(),
fetchWeekendContext: vi.fn(),
}))
import { fetchRaceHub, fetchSeasons, fetchLocalMeetings, fetchWeekend } from '../api'
import { fetchLocalMeetings, fetchRaceHub, fetchSeasons, fetchWeekend, fetchWeekendContext } from '../api'
const mockFetchRaceHub = vi.mocked(fetchRaceHub)
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
const mockFetchWeekend = vi.mocked(fetchWeekend)
const mockFetchWeekendContext = vi.mocked(fetchWeekendContext)
const meeting: Meeting = {
meeting_key: 1229,
@@ -155,6 +158,7 @@ const raceHub: RaceHub = {
is_pit_out_lap: false,
},
],
chapters: [],
}
const weekend: Weekend = {
@@ -168,6 +172,26 @@ const weekend: Weekend = {
],
}
const availability: ContextAvailability = {
source: 'local',
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'complete',
freshness: 'local',
limitations: [],
}
const analysisContext: WeekendContext = {
temporal_state: 'between_weekends',
default_analysis_session: { session: raceSession, meeting, availability },
race_hub_default_session: { session: raceSession, meeting, availability },
race_hub_pre_session: false,
championship_round: 1,
total_championship_rounds: 24,
}
function renderRaceHub(sessionKey: number) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
@@ -192,23 +216,28 @@ function renderRaceHub(sessionKey: number) {
return <RaceHubPage sessionKey={session_key ?? 0} />
},
})
window.history.pushState({}, '', sessionKey ? `/race-hub?session_key=${sessionKey}` : '/race-hub')
const router = createRouter({
routeTree: rootRoute.addChildren([raceHubRoute]),
history: undefined,
})
// Navigate to the URL before mounting
router.navigate({ to: '/race-hub', search: sessionKey ? { session_key: sessionKey } : {} })
return render(<RouterProvider router={router} />)
return { queryClient, ...render(<RouterProvider router={router} />) }
}
describe('RaceHubPage', () => {
beforeEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
mockFetchSeasons.mockResolvedValue([2025])
mockFetchLocalMeetings.mockResolvedValue([meeting])
mockFetchWeekend.mockResolvedValue(weekend)
mockFetchRaceHub.mockResolvedValue(raceHub)
mockFetchWeekendContext.mockResolvedValue(analysisContext)
})
afterEach(() => {
vi.useRealTimers()
})
it('renders the workspace identity band, session rail, and overview for a known session', async () => {
@@ -255,4 +284,122 @@ describe('RaceHubPage', () => {
fireEvent.click(screen.getByTestId('rh-switch-weekend'))
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
})
it('uses the server-selected completed analysis session for bare Race Hub without changing the URL', async () => {
renderRaceHub(0)
await waitFor(() => expect(mockFetchRaceHub).toHaveBeenCalledWith(9472))
expect(mockFetchWeekendContext).toHaveBeenCalledTimes(1)
})
it('renders the intentional pre-session state without analysis cards', async () => {
mockFetchWeekendContext.mockResolvedValue({
...analysisContext,
race_hub_default_session: {
session: { ...raceSession, session_key: 9473, session_name: 'Practice 1', session_type: 'Practice', date_start: '2099-05-23T13:00:00Z' },
meeting,
availability,
},
race_hub_pre_session: true,
race_hub_refresh_at: '2099-05-23T13:00:00Z',
})
renderRaceHub(0)
expect(await screen.findByTestId('race-hub-pre-session')).toBeInTheDocument()
expect(screen.getByTestId('rh-pre-session-schedule')).toBeInTheDocument()
expect(screen.queryByText('Winner')).not.toBeInTheDocument()
expect(mockFetchRaceHub).not.toHaveBeenCalled()
})
it('opens the weekend switcher from pre-session and navigates to the selected explicit session', async () => {
mockFetchWeekendContext.mockResolvedValue({
...analysisContext,
race_hub_default_session: {
session: { ...raceSession, session_key: 9473, session_name: 'Practice 1', session_type: 'Practice', date_start: '2099-05-23T13:00:00Z' },
meeting,
availability,
},
race_hub_pre_session: true,
race_hub_refresh_at: '2099-05-23T13:00:00Z',
})
renderRaceHub(0)
const switchWeekend = await screen.findByTestId('rh-switch-weekend')
expect(switchWeekend).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(switchWeekend)
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
expect(switchWeekend).toHaveAttribute('aria-expanded', 'true')
fireEvent.click(await screen.findByTestId('rh-switcher-session-9471'))
await waitFor(() => expect(window.location.search).toBe('?session_key=9471'))
})
it('shows recovery instead of selecting an empty future session', async () => {
mockFetchWeekendContext.mockResolvedValue({
...analysisContext,
race_hub_default_session: undefined,
race_hub_pre_session: false,
})
renderRaceHub(0)
expect(await screen.findByTestId('race-hub-empty')).toHaveTextContent('No completed local analysis yet')
expect(mockFetchRaceHub).not.toHaveBeenCalled()
})
it('hands a bare route from completed analysis to pre-session at the supplied refresh boundary', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
const handoff = new Date(Date.now() + 10_000).toISOString()
const pendingContext: WeekendContext = {
...analysisContext,
race_hub_default_session: {
session: { ...raceSession, session_key: 9473, session_name: 'Practice 1', session_type: 'Practice', date_start: handoff },
meeting,
availability,
},
race_hub_pre_session: true,
race_hub_refresh_at: new Date(Date.now() + 16_000).toISOString(),
}
mockFetchWeekendContext
.mockResolvedValueOnce({ ...analysisContext, race_hub_refresh_at: handoff })
.mockResolvedValueOnce(pendingContext)
renderRaceHub(0)
await screen.findByTestId('race-hub')
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
expect(await screen.findByTestId('race-hub-pre-session')).toBeInTheDocument()
expect(mockFetchWeekendContext).toHaveBeenCalledTimes(2)
expect(mockFetchRaceHub).toHaveBeenCalledWith(9472)
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9473)
})
it('re-arms a bare route refresh after a capped browser timer', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
mockFetchWeekendContext.mockResolvedValue({
...analysisContext,
race_hub_refresh_at: new Date(Date.now() + MAX_BROWSER_TIMEOUT + 1_000).toISOString(),
})
renderRaceHub(0)
await screen.findByTestId('race-hub')
await act(async () => { await vi.advanceTimersByTimeAsync(MAX_BROWSER_TIMEOUT) })
await waitFor(() => expect(mockFetchWeekendContext).toHaveBeenCalledTimes(2))
await act(async () => { await vi.advanceTimersByTimeAsync(1_000) })
await waitFor(() => expect(mockFetchWeekendContext).toHaveBeenCalledTimes(3))
})
it('keeps an explicit session URL stable across the canonical refresh boundary', async () => {
vi.useFakeTimers()
renderRaceHub(9472)
await act(async () => { await vi.advanceTimersByTimeAsync(60_000) })
expect(mockFetchWeekendContext).not.toHaveBeenCalled()
expect(mockFetchRaceHub).toHaveBeenCalledWith(9472)
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9473)
})
})

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

@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import { SessionBanner } from '../components/live/SessionBanner'
import type { LiveStreamData } from '../types'
function makeSnapshot(sessionType: string, sessionName: string): LiveStreamData {
return {
Drivers: {},
DriverInfo: {},
Tyres: {},
RCMessages: [],
Weather: { AirTemp: 0, TrackTemp: 0, Humidity: 0, WindSpeed: 0, WindDir: 0, Rainfall: false },
Session: {
MeetingName: 'Belgian Grand Prix',
CircuitName: 'Spa-Francorchamps',
SessionType: sessionType,
SessionName: sessionName,
Path: '',
},
TeamRadio: [],
TrackStatus: '1',
CurrentLap: 0,
TotalLaps: 0,
Clock: '00:45:00',
ClockRefTime: '',
ClockExtrapolating: false,
Stints: {},
}
}
describe('SessionBanner', () => {
it('never renders a race lap counter for a practice session', () => {
render(
<SessionBanner
isLive
snapshot={makeSnapshot('Practice', 'Practice 2')}
rows={[]}
connection="connected"
now={0}
/>,
)
expect(screen.queryByTestId('live-lap-counter')).not.toBeInTheDocument()
// Session identity and clock stay intact.
expect(screen.getByText('Belgian Grand Prix')).toBeInTheDocument()
expect(screen.getByTestId('live-clock')).toHaveTextContent('00:45:00')
})
it('shows the lap counter for a race session', () => {
const snapshot = { ...makeSnapshot('Race', 'Race'), CurrentLap: 12, TotalLaps: 44 }
render(
<SessionBanner
isLive
snapshot={snapshot}
rows={[]}
connection="connected"
now={0}
/>,
)
expect(screen.getByTestId('live-lap-counter')).toHaveTextContent('L12/44')
})
})

View File

@@ -106,6 +106,17 @@ describe('StrategyView — stints available', () => {
)
expect(screen.queryByText(/Stints not available/i)).not.toBeInTheDocument()
})
it('maps pit_stops into timeline pit markers for the matching driver', () => {
const { container } = render(
<StrategyView results={results} stints={stints} pit_stops={pitStops} hasStints={true} />
)
const markers = container.querySelectorAll('[data-testid="pit-marker"]')
expect(markers).toHaveLength(1)
expect(markers[0]).toHaveAttribute('data-lap', '19')
const titles = [...container.querySelectorAll('title')].map((t) => t.textContent)
expect(titles).toContain('HAM pit stop · L19')
})
})
describe('StrategyView — stints missing', () => {

View File

@@ -8,6 +8,7 @@ describe('TabBar', () => {
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Race Story' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Strategy' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Compare' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Conditions' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument()

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

@@ -47,14 +47,35 @@ function snapshotRows(lap: number, lastLapTime: string): LiveTimingRow[] {
}
describe('TyreDegPanel', () => {
it('shows a warming-up placeholder until enough clean laps accumulate', () => {
it('stays collapsed while every stint is still warming up', () => {
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
const panel = screen.getByTestId('tyredeg-panel')
// A panel of "warming up" placeholders carries no information and used to
// push the Timing Tower off the fold for the first third of a race.
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
expect(panel).toHaveTextContent('collecting clean laps')
})
it('shows a warming-up placeholder on each row once expanded', () => {
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
const panel = screen.getByTestId('tyredeg-panel')
expect(panel).toHaveTextContent('VER')
expect(panel).toHaveTextContent('M +5')
expect(panel).toHaveTextContent('fresh')
expect(panel).toHaveTextContent('warming up')
})
it('annotates tyre age meaning on stint rows', () => {
const rows = [
makeRow('1', 1, 'VER', { NumberOfLaps: 10, LastLapTime: '1:30.000' }, { Compound: 'MEDIUM', Age: 12 }),
]
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={[]} />)
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
expect(screen.getByText('mid-life')).toBeInTheDocument()
})
it('renders slope and rejoin estimate once laps accumulate across snapshots', () => {
const { rerender } = render(
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
@@ -72,13 +93,46 @@ describe('TyreDegPanel', () => {
expect(panel).toHaveTextContent('→ ~P2')
})
it('hides the rejoin estimate outside race sessions and collapses on toggle', () => {
it('collapses by default outside race sessions and hides the rejoin estimate when expanded', () => {
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Qualifying" pinned={[]} />)
const panel = screen.getByTestId('tyredeg-panel')
// Practice/qualifying starts collapsed so the Timing Tower stays above the fold.
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
expect(panel).not.toHaveTextContent('~P')
})
it('opens itself during a race as soon as a stint has signal', () => {
const { rerender } = render(
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
)
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
for (let lap = 2; lap <= 6; lap++) {
const time = `1:30.${String((lap - 1) * 100).padStart(3, '0')}`
rerender(<TyreDegPanel rows={snapshotRows(lap, time)} sessionType="Race" pinned={[]} />)
}
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
})
it('keeps the reader\'s own collapse choice when signal arrives', () => {
const { rerender } = render(
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
)
// Reader opens it early, then closes it again — that decision must stick
// even once the panel would otherwise auto-open.
const toggle = screen.getByRole('button', { name: /tyre deg/i })
fireEvent.click(toggle)
fireEvent.click(toggle)
for (let lap = 2; lap <= 6; lap++) {
const time = `1:30.${String((lap - 1) * 100).padStart(3, '0')}`
rerender(<TyreDegPanel rows={snapshotRows(lap, time)} sessionType="Race" pinned={[]} />)
}
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
})
@@ -87,6 +141,7 @@ describe('TyreDegPanel', () => {
makeRow(String(index + 1), index + 1, `D${index + 1}`),
)
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={['14']} />)
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(11)
expect(screen.getByText('D14')).toBeInTheDocument()
expect(screen.queryByText('D12')).not.toBeInTheDocument()

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,439 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { CompareView } from '../components/CompareView'
import type { Driver, EnrichedResult, LapsComparisonResponse } from '../types'
import {
buildBestLapTraceSeries,
carDataToTraceSeries,
comparisonToDeltaSeries,
defaultCompareDriverNumbers,
filterCarDataToLap,
findBestLap,
formatPitLapsCaption,
lapsToLapTimes,
} from '../lib/compare'
import { computeCumulativeDeltas } from '../lib/delta'
vi.mock('../api', () => ({
fetchTelemetry: vi.fn(),
fetchLapsComparison: vi.fn(),
}))
import { fetchTelemetry, fetchLapsComparison } from '../api'
const mockFetchTelemetry = vi.mocked(fetchTelemetry)
const mockFetchLapsComparison = vi.mocked(fetchLapsComparison)
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: 9472,
meeting_key: 1229,
},
{
driver_number: 44,
position: 2,
name_acronym: 'HAM',
full_name: 'Lewis Hamilton',
team_name: 'Ferrari',
team_colour: 'E8002D',
dnf: false,
dns: false,
dsq: false,
duration: null,
gap_to_leader: 5.1,
number_of_laps: 78,
points: 18,
session_key: 9472,
meeting_key: 1229,
},
]
const drivers: Driver[] = [
{
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
first_name: 'Max',
last_name: 'Verstappen',
team_name: 'Red Bull Racing',
team_colour: '3671C6',
headshot_url: '',
broadcast_name: 'M VERSTAPPEN',
session_key: 9472,
meeting_key: 1229,
},
{
driver_number: 44,
name_acronym: 'HAM',
full_name: 'Lewis Hamilton',
first_name: 'Lewis',
last_name: 'Hamilton',
team_name: 'Ferrari',
team_colour: 'E8002D',
headshot_url: '',
broadcast_name: 'L HAMILTON',
session_key: 9472,
meeting_key: 1229,
},
]
const comparison: LapsComparisonResponse = {
session_key: 9472,
sc_periods: [],
pit_laps: { '44': [19, 40] },
drivers: [
{
driver_number: 1,
name_acronym: 'VER',
team_colour: '3671C6',
laps: [
{
session_key: 9472,
driver_number: 1,
meeting_key: 1229,
lap_number: 1,
date_start: '2025-05-25T13:00:00.000Z',
lap_duration: 92.1,
is_pit_out_lap: false,
},
{
session_key: 9472,
driver_number: 1,
meeting_key: 1229,
lap_number: 2,
date_start: '2025-05-25T13:01:32.100Z',
lap_duration: 90.5,
is_pit_out_lap: false,
},
],
},
{
driver_number: 44,
name_acronym: 'HAM',
team_colour: 'E8002D',
laps: [
{
session_key: 9472,
driver_number: 44,
meeting_key: 1229,
lap_number: 1,
date_start: '2025-05-25T13:00:01.000Z',
lap_duration: 93.0,
is_pit_out_lap: false,
},
{
session_key: 9472,
driver_number: 44,
meeting_key: 1229,
lap_number: 2,
date_start: '2025-05-25T13:01:34.000Z',
lap_duration: null,
is_pit_out_lap: true,
},
{
session_key: 9472,
driver_number: 44,
meeting_key: 1229,
lap_number: 3,
date_start: '2025-05-25T13:03:10.000Z',
lap_duration: 91.2,
is_pit_out_lap: false,
},
],
},
],
}
const nextSessionResults: EnrichedResult[] = [
{
driver_number: 16,
position: 1,
name_acronym: 'LEC',
full_name: 'Charles Leclerc',
team_name: 'Ferrari',
team_colour: 'E8002D',
dnf: false,
dns: false,
dsq: false,
duration: null,
gap_to_leader: null,
number_of_laps: 57,
points: 25,
session_key: 9550,
meeting_key: 1234,
},
{
driver_number: 55,
position: 2,
name_acronym: 'SAI',
full_name: 'Carlos Sainz',
team_name: 'Williams',
team_colour: '64C4FF',
dnf: false,
dns: false,
dsq: false,
duration: null,
gap_to_leader: 3.2,
number_of_laps: 57,
points: 18,
session_key: 9550,
meeting_key: 1234,
},
]
const nextSessionDrivers: Driver[] = [
{
driver_number: 16,
name_acronym: 'LEC',
full_name: 'Charles Leclerc',
first_name: 'Charles',
last_name: 'Leclerc',
team_name: 'Ferrari',
team_colour: 'E8002D',
headshot_url: '',
broadcast_name: 'C LECLERC',
session_key: 9550,
meeting_key: 1234,
},
{
driver_number: 55,
name_acronym: 'SAI',
full_name: 'Carlos Sainz',
first_name: 'Carlos',
last_name: 'Sainz',
team_name: 'Williams',
team_colour: '64C4FF',
headshot_url: '',
broadcast_name: 'C SAINZ',
session_key: 9550,
meeting_key: 1234,
},
]
function renderCompareView(
props: {
sessionKey?: number
results?: EnrichedResult[]
drivers?: Driver[]
} = {},
) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
return render(
<QueryClientProvider client={client}>
<CompareView
sessionKey={props.sessionKey ?? 9472}
results={props.results ?? results}
drivers={props.drivers ?? drivers}
/>
</QueryClientProvider>,
)
}
describe('compare mapping helpers', () => {
it('defaults to top two finishers', () => {
expect(defaultCompareDriverNumbers(results, drivers)).toEqual([1, 44])
})
it('maps comparison laps to delta series with null pit laps', () => {
const series = comparisonToDeltaSeries(comparison, [1, 44], drivers)
expect(series).toHaveLength(2)
expect(series[0].label).toBe('VER')
expect(series[1].lapTimes[1]).toBeNull()
const deltas = computeCumulativeDeltas(series, 'VER')
expect(deltas[0].deltas[0]).toBeCloseTo(0.9)
})
it('maps car data to trace series', () => {
const trace = carDataToTraceSeries(
[
{
speed: 300,
throttle: 100,
brake: 0,
date: '',
driver_number: 1,
drs: 0,
meeting_key: 1,
n_gear: 8,
rpm: 12000,
session_key: 1,
},
],
'VER',
'#3671C6',
)
expect(trace.samples[0]).toEqual({ speed: 300, throttle: 100, brake: 0 })
})
it('filters car data to best lap window', () => {
const best = findBestLap(comparison.drivers[0].laps)
expect(best?.lap_number).toBe(2)
const samples = [
{
date: '2025-05-25T13:01:32.100Z',
speed: 280,
throttle: 90,
brake: 0,
driver_number: 1,
drs: 0,
meeting_key: 1229,
n_gear: 7,
rpm: 11000,
session_key: 9472,
},
{
date: '2025-05-25T13:00:00.000Z',
speed: 200,
throttle: 50,
brake: 10,
driver_number: 1,
drs: 0,
meeting_key: 1229,
n_gear: 4,
rpm: 9000,
session_key: 9472,
},
]
const filtered = filterCarDataToLap(samples, best!)
expect(filtered).toHaveLength(1)
expect(filtered[0].speed).toBe(280)
})
it('builds best-lap trace series from car data and laps', () => {
const series = buildBestLapTraceSeries(
[
{
date: '2025-05-25T13:01:33.000Z',
speed: 310,
throttle: 100,
brake: 0,
driver_number: 1,
drs: 10,
meeting_key: 1229,
n_gear: 8,
rpm: 12000,
session_key: 9472,
},
],
comparison.drivers[0].laps,
'VER',
'#3671C6',
)
expect(series?.samples).toHaveLength(1)
expect(series?.samples[0].speed).toBe(310)
})
it('maps laps to lap time arrays with nulls', () => {
expect(lapsToLapTimes(comparison.drivers[1].laps)).toEqual([93.0, null, 91.2])
})
it('formats pit lap captions', () => {
expect(formatPitLapsCaption(comparison.pit_laps, [1, 44], comparison, drivers)).toBe(
'Pit stops — HAM: L19, L40',
)
})
})
describe('CompareView', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchLapsComparison.mockResolvedValue(comparison)
mockFetchTelemetry.mockResolvedValue([
{
date: '2025-05-25T13:01:33.000Z',
speed: 310,
throttle: 100,
brake: 0,
driver_number: 1,
drs: 10,
meeting_key: 1229,
n_gear: 8,
rpm: 12000,
session_key: 9472,
},
])
})
it('renders pickers defaulting to top two finishers', async () => {
renderCompareView()
expect(screen.getByTestId('compare-picker-a')).toHaveValue('1')
expect(screen.getByTestId('compare-picker-b')).toHaveValue('44')
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
expect(screen.getAllByText('HAM').length).toBeGreaterThan(0)
})
it('resets the selected pair when the mounted session changes', async () => {
const { rerender } = renderCompareView()
fireEvent.change(screen.getByTestId('compare-picker-a'), { target: { value: '44' } })
expect(screen.getByTestId('compare-picker-a')).toHaveValue('44')
rerender(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<CompareView
sessionKey={9550}
results={nextSessionResults}
drivers={nextSessionDrivers}
/>
</QueryClientProvider>,
)
await waitFor(() => {
expect(screen.getByTestId('compare-picker-a')).toHaveValue('16')
expect(screen.getByTestId('compare-picker-b')).toHaveValue('55')
})
})
it('renders telemetry and pace sections with mocked queries', async () => {
renderCompareView()
await waitFor(() => {
expect(mockFetchLapsComparison).toHaveBeenCalledWith(9472, [1, 44])
})
await waitFor(() => {
expect(screen.getByTestId('telemetry-trace')).toBeInTheDocument()
})
expect(screen.getByTestId('delta-time-graph')).toBeInTheDocument()
expect(screen.getByTestId('compare-pit-caption')).toHaveTextContent('HAM: L19, L40')
})
it('shows error state when comparison fetch fails', async () => {
mockFetchLapsComparison.mockRejectedValue(new Error('comparison unavailable'))
renderCompareView()
await waitFor(() => {
expect(screen.getByText('comparison unavailable')).toBeInTheDocument()
})
})
it('shows empty state when fewer than two drivers', () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={client}>
<CompareView sessionKey={9472} results={[]} drivers={[drivers[0]]} />
</QueryClientProvider>,
)
expect(screen.getByTestId('compare-view-empty')).toBeInTheDocument()
})
})

View File

@@ -47,7 +47,7 @@ describe('computeCumulativeDeltas', () => {
expect(result[0].deltas[2]).toBeCloseTo(2)
})
it('emits null for missing lap times while carrying cumulative forward', () => {
it('emits null for challenger missing lap times while carrying cumulative forward', () => {
const withNull: DeltaSeries = {
label: 'NOR',
color: '#FF8000',
@@ -60,6 +60,41 @@ describe('computeCumulativeDeltas', () => {
expect(result[0].deltas[2]).toBeCloseTo(-92)
})
it('gaps all drivers when the reference lap is null and resumes without that window', () => {
const refWithNull: DeltaSeries = {
label: 'VER',
color: '#3671C6',
lapTimes: [90, null, 92],
}
const validChallenger: DeltaSeries = {
label: 'HAM',
color: '#E8002D',
lapTimes: [89, 91, 90],
}
const result = computeCumulativeDeltas([refWithNull, validChallenger])
expect(result[0].deltas[0]).toBeCloseTo(-1)
expect(result[0].deltas[1]).toBeNull()
// Lap 3 excludes the reference-null window for both: (89+90) - (90+92) = -3
expect(result[0].deltas[2]).toBeCloseTo(-3)
})
it('gaps challenger laps beyond a shorter reference series', () => {
const shortReference: DeltaSeries = {
label: 'VER',
color: '#3671C6',
lapTimes: [90, 91],
}
const longerChallenger: DeltaSeries = {
label: 'HAM',
color: '#E8002D',
lapTimes: [89, 92, 90],
}
const result = computeCumulativeDeltas([shortReference, longerChallenger])
expect(result[0].deltas[0]).toBeCloseTo(-1)
expect(result[0].deltas[1]).toBeCloseTo(0)
expect(result[0].deltas[2]).toBeNull()
})
it('returns an empty array when only one series is provided', () => {
expect(computeCumulativeDeltas([reference])).toEqual([])
})
@@ -97,6 +132,19 @@ describe('DeltaTimeGraph', () => {
expect(screen.queryByTestId('delta-line-VER')).not.toBeInTheDocument()
})
it('splits polylines at reference-null laps', () => {
const refWithNull: DeltaSeries = {
label: 'VER',
color: '#3671C6',
lapTimes: [90, null, 92],
}
const { container } = render(
<DeltaTimeGraph series={[refWithNull, challenger]} />,
)
const lines = container.querySelectorAll('.delta-graph-driver-line')
expect(lines.length).toBeGreaterThan(1)
})
it('shows a crosshair tooltip on hover', () => {
vi.spyOn(SVGSVGElement.prototype, 'getBoundingClientRect').mockReturnValue({
x: 0,
@@ -118,4 +166,31 @@ describe('DeltaTimeGraph', () => {
expect(screen.getByText(/Lap 1/)).toBeInTheDocument()
vi.restoreAllMocks()
})
it('omits tooltip rows on reference-null laps', () => {
vi.spyOn(SVGSVGElement.prototype, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
width: 640,
height: 220,
right: 640,
bottom: 220,
toJSON: () => ({}),
})
const refWithNull: DeltaSeries = {
label: 'VER',
color: '#3671C6',
lapTimes: [90, null, 92],
}
const { container } = render(
<DeltaTimeGraph series={[refWithNull, challenger]} />,
)
const hoverLayer = container.querySelector('.delta-graph-hover-layer')
fireEvent.mouseMove(hoverLayer!, { clientX: 352, clientY: 100 })
expect(screen.getByTestId('delta-crosshair')).toBeInTheDocument()
expect(screen.queryByTestId('delta-tooltip')).not.toBeInTheDocument()
vi.restoreAllMocks()
})
})

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('—')
})
})

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