Compare commits

...

161 Commits

Author SHA1 Message Date
Aman Tahiliani
3583a80d42 Merge pull request #85 from AmanTahiliani/fix/issue-76-qa-availability-truth
fix(#76): truthful availability metadata across primary routes
2026-07-12 21:18:20 -04:00
AmanTahiliani
a0ff8fa5a7 fix(#76): preserve pacing and empty-state truth 2026-07-12 21:01:42 -04:00
AmanTahiliani
c0d1cf2892 fix(#76): preserve worst availability truth and inactive SSE recovery
Aggregate conflicting freshness by severity so Local cannot mask Stale/Partial,
treat authoritative inactive SSE as valid state, honor Weekend focus headers,
and prove Limited Retry busy accessibility with deferred supplements.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 20:55:52 -04:00
AmanTahiliani
0bba213652 fix(#76): prevent false-fresh aggregate responses 2026-07-12 20:50:18 -04:00
AmanTahiliani
01d291507d fix(#76): correct frontend availability, retry, and live recovery semantics
Make response freshness React Query-safe, retry the failed Weekend/Preview/Briefing resources with busy gating, preserve distinct embedded Preview notices, and clear fatal Live errors once SSE supplies usable timing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 20:43:51 -04:00
AmanTahiliani
eabf87a2b8 fix(#76): keep embedded Preview from stacking freshness notices
Weekend shell already discloses context availability; a second Preview banner
from shared championship headers produced conflicting Partial/Stale vocabulary.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 20:32:44 -04:00
AmanTahiliani
5534ebf82c fix(#76): surface truthful availability across primary routes 2026-07-12 20:29:02 -04:00
AmanTahiliani
dafb9b6dc5 fix(#76): report request-scoped data freshness 2026-07-12 20:29:02 -04:00
Aman Tahiliani
1a9e08c0ab Merge pull request #84 from AmanTahiliani/fix/issue-83-prod-race-story-smoke
fix(#83): align prod Race Story smoke with final running order outcome
2026-07-12 20:22:33 -04:00
AmanTahiliani
14d9bd3dd4 fix(#83): align prod Race Story smoke with final running order outcome
Replace the removed "Final Classification" literal with an accessible
race-story-outcome region and assert VER is P1 in the built SPA smoke.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 20:13:07 -04:00
Aman Tahiliani
15d81c8eab Merge pull request #78 from AmanTahiliani/feat/issue-76-primary-route-resilience-and-bounded-loa
Primary-route resilience and bounded loading states (#76)
2026-07-12 20:03:51 -04:00
AmanTahiliani
2651569453 fix(#76): wrap ExplorePage fetchers for AbortSignal queryFns
Passing apiFetch-backed helpers directly as React Query queryFn breaks
TypeScript because the context object is not an AbortSignal.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 19:55:16 -04:00
AmanTahiliani
ad0eb26e05 fix(#76): preserve Race Hub retry testid after RouteState migrate
Keep rh-retry discoverable for existing Race Hub recovery tests and accept
AbortSignal on bare-route default analysis fetch assertions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 19:54:16 -04:00
AmanTahiliani
5ea8a4a9c4 fix(#76): make apiFetch dedupe abort-safe and migrate Command Center
Subscriber-aware in-flight dedupe so one caller's abort cannot poison
remounted consumers under Strict Mode; wire `/` to shared RouteState
retry and point the Weekend E2E at the real landing route.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 19:53:47 -04:00
AmanTahiliani
b3d1730324 feat(#76): bound primary-route fetches and local-first driver summary
Add shared apiFetch timeouts/abort/typed errors with in-flight dedupe, a
RouteState UI for loading/empty/timeout/error+retry, and wire it through
Weekend (Data Health), Championship, Driver Profile, Briefing, Live, and
Race Hub. Driver summary is local-first with bounded optional OpenF1
enrichment so a hung remote call cannot block the profile.

Spike note: parseSourceMode defaults to openf1; driver summary now treats
omitted ?source= as auto so local season data is preferred.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 19:53:39 -04:00
Aman Tahiliani
e8a1f15f8b Merge pull request #81 from AmanTahiliani/feat/issue-74-live-settling-analysis-handoff
Live → settling → analysis handoff (#74)
2026-07-12 19:50:53 -04:00
AmanTahiliani
e66a874755 fix(#74): settle on just-finished session and coherent feed health
Prefer previous_completed_session for Live settling link/poll/readiness so an
older ready default cannot short-circuit handoff; show reconnecting when phase
is disconnected even if browser SSE stays open; refresh Live visuals and add
active responsive snapshots against the Weekend shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 19:46:31 -04:00
AmanTahiliani
ad870c040c fix(#74): align Weekend deep-link E2E with live-inactive handoff
After rebasing onto the Weekend shell, /live no longer renders live-empty;
assert the inactive handoff surface instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 19:46:31 -04:00
AmanTahiliani
dbe3ac644d fix(#74): address PR #81 review blockers for live handoff
Distinguish FIA feed drops from true session end via terminal SessionStatus,
consume canonical /api/v1/weekend-context (post-#72 rebase), poll until
analysis-ready, and restore missing live-state styles plus transition/E2E/visual coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 19:46:31 -04:00
AmanTahiliani
1543a09ff1 feat(#74): Live → settling → analysis handoff
Implemented by claude via .agents/dev dispatch.
2026-07-12 19:46:16 -04:00
Aman Tahiliani
408de2da0c Merge pull request #82 from AmanTahiliani/feat/issue-75-race-hub-defaults-and-fan-facing-analysi
Race Hub defaults and fan-facing analysis hierarchy (#75)
2026-07-12 19:45:28 -04:00
AmanTahiliani
469028b07a fix(#75): preserve Race Hub meeting/session on Weekend return
Add an explicit Back to Weekend link that deep-links to /?meeting_key&session_key,
restore that focus on the adaptive Weekend home with a Continue analysis CTA, and
regenerate Race Hub visual baselines against the integrated #80 nav.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #57 remains: Sprint points now included (ANT 158->179)
2026-07-10 23:53:11 -04:00
Aman Tahiliani
e51cabba62 docs(verify): add real app screenshots with sprint fix - ANT 179, RUS 154, HAM 147
- Running /tmp/boxbox-server --db /tmp/boxbox-verify.db (localChampionshipHub)
- /verify.html shows championship hub table with fixed points
- /api/v1/championship/hub?year=2026&source=local returns 179/154/147
- Captured via google-chrome --headless --screenshot
- Add Dockerfile + docker-compose.yml for future prod-parity verification
2026-07-10 23:50:05 -04:00
Aman Tahiliani
fc6fc36d8f docs: add verification screenshots for sprint fix #57
- before 158 vs after 179
- code fix snippet
- go test passing 25+8=33
- verification output showing 179,154,147 totals
2026-07-10 23:33:57 -04:00
Aman Tahiliani
16d82afad5 fix(championship): include Sprint points in standings
- GetChampionshipInputs now sums Race + Sprint sessions per meeting
- Bahrain 1282 / Saudi 1283 cancelled (Force Majeure) - intentional Round 9/24
- 2026 sprints up to British GP: 11240 Chinese, 11275 Miami, 11286 Canadian, 11321 British
  Totals ANT +21, RUS +26, HAM +18 etc.
- Adds regression test TestGetChampionshipInputsIncludesSprintPoints (25+8=33)
- Fixes #57
2026-07-10 23:32:12 -04:00
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
4813b958a0 Merge remote-tracking branch 'origin/phase-1' into feat/issue-13-live-tyre-degradation-pit-window-panel 2026-07-04 00:06:42 -04:00
Aman Tahiliani
a97e3caa4f Merge pull request #38 from AmanTahiliani/feat/issue-14-session-aware-command-center-hero
Session-aware Command Center hero (#14)
2026-07-04 00:06:12 -04:00
Aman Tahiliani
655df9845b Merge pull request #37 from AmanTahiliani/feat/issue-15-telemetry-trace-chart-component
Telemetry trace chart component (#15)
2026-07-04 00:03:46 -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
AmanTahiliani
4e62e93ea2 feat(#13): Live tyre-degradation & pit-window panel
Implemented by claude via .agents/dev dispatch.
2026-07-04 00:00:19 -04:00
AmanTahiliani
eacd5f26e9 feat(#15): Telemetry trace chart component
Implemented by claude via .agents/dev dispatch.
2026-07-03 23:59:39 -04:00
AmanTahiliani
ba7f653e3c feat(frontend): add session-aware Command Center hero (#14)
Replace the static weekend band with a three-state hero (live, upcoming,
between weekends) driven by heroState() and existing page queries. Between-weekend
podium uses the existing race-hub endpoint for the last ingested race session
since championship hub does not expose finish positions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 23:59:38 -04:00
Aman Tahiliani
f2bf1fd0fc Merge pull request #36 from AmanTahiliani/feat/issue-12-team-radio-ticker
Team-radio ticker (#12)
2026-07-03 23:57:41 -04:00
AmanTahiliani
6c72c12821 feat(live): add team radio ticker
Subscribe to TeamRadio on the F1 live feed, parse defensive capture snapshots and patches, and relay capped captures through live snapshots with the SessionInfo static path. Add the frontend CDN URL helper and ticker using one shared audio element.
2026-07-03 23:56:19 -04:00
AmanTahiliani
98126a06f9 docs(agents): mark agy adapter unreliable for headless dispatch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 23:53:13 -04:00
Aman Tahiliani
47f0cd6d12 Merge pull request #35 from AmanTahiliani/feat/issue-17-delta-time-graph-component
Delta-time graph component (#17)
2026-07-03 23:52:25 -04:00
Aman Tahiliani
26642e3ff6 Merge pull request #34 from AmanTahiliani/feat/issue-16-tyre-stint-timeline-component
Tyre-stint timeline component (#16)
2026-07-03 23:52:13 -04:00
AmanTahiliani
0977861806 feat(frontend): add DeltaTimeGraph cumulative delta chart (#17)
Introduce a reusable SVG chart primitive for lap-by-lap cumulative time
delta vs a reference driver, with pure math in lib/delta.ts, dedicated
styles, null-lap gap handling, and Vitest coverage for math and rendering.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 23:48:50 -04:00
AmanTahiliani
c5a7ad51eb feat(frontend): add reusable TyreStintTimeline component (#16)
Extract stint timeline SVG from StrategyView into a data-source-agnostic
chart primitive with compound legend, lap-axis ticks, and hover titles.
StrategyView maps race-hub stint data into the new props contract.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 23:48:09 -04:00
AmanTahiliani
75ca5f4deb chore(agents): wire agy + cursor harness adapters (verified headless flags)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 23:44:35 -04:00
Aman Tahiliani
094620fa08 chore: add agentic development toolkit (#33) 2026-07-03 23:18:31 -04:00
Aman Tahiliani
7263949260 feat(live): add web track map (#32)
Subscribe to SignalR Position.z and CarData.z, decode DEFLATE payloads, stream throttled positions over SSE, and render live car dots against cached track outline bounds with tap telemetry.

Alignment spike: no checked-in or locally cached real Position.z samples were available in this isolated worktree; verified both streams expose the official raw F1 X/Y/Z coordinate contract and implemented shared-bounds normalization against prefetched OpenF1 outlines. Fallback build-outline-from-stream was not taken.

Playwright remains out of scope for live rendering because BOXBOX_DISABLE_LIVE is used there; coverage is via parser, web handler/SSE, pure transform, and seeded component tests.
2026-07-03 19:34:09 -04:00
AmanTahiliani
5408a45bbd Refresh live sprint qualifying timing 2026-07-03 12:43:45 -04:00
AmanTahiliani
b7696c11de docs: streamline README and split guides 2026-07-03 03:11:16 -04:00
AmanTahiliani
8e3d4cea3f docs: refresh project homepage 2026-07-03 03:07:34 -04:00
AmanTahiliani
c973c03689 Show cancelled schedule status 2026-07-03 02:57:18 -04:00
AmanTahiliani
85a8b6ad44 feat: redesign Live Timing page with dynamic animations and premium dashboard layout 2026-07-03 02:47:04 -04:00
AmanTahiliani
cc5184c4b6 feat(ui): redesign command center layout and add championship snapshot widget 2026-07-03 02:25:20 -04:00
AmanTahiliani
8d5ab4d7a7 feat(ui): Session-aware live timing enhancements 2026-07-03 01:46:35 -04:00
AmanTahiliani
c3ad7743e6 feat(web): add F1-themed favicon, fonts, and PWA polish 2026-07-03 01:13:39 -04:00
AmanTahiliani
127628e4dd Speed up championship hub loading 2026-07-03 01:11:31 -04:00
AmanTahiliani
c989aace10 Check off Championship Simulator in plan.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 00:49:29 -04:00
AmanTahiliani
3c6115e815 Make Playwright runs hermetic and fix stale race-hub specs
Six suite failures had rotted in: three race-hub specs targeted the
sub-tabbed Race Story UI deleted in the Phase 22 redesign, and the
command-center specs depended on the real OpenF1 calendar, whose focus
weekend drifted away from the seeded Monaco session as time passed.

The e2e/visual configs now point BOXBOX_OPENF1_BASE_URL (new override)
at an unreachable address so runs are hermetic and date-stable; the
race-hub specs were rewritten against the current RaceStoryCanvas UI,
and the one drifted mobile snapshot regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 00:48:07 -04:00
AmanTahiliani
7f0c338a57 Add OpenF1 request pacing, 429 retry, and hub completeness guard
Concurrent fan-outs could burst past the free-tier rate limit; callers
swallowed the 429s as missing data, and the championship hub cached the
resulting partial season for its full TTL (observed live: 8 of 22
rounds, zero wins/podiums/form).

- requestPacer spaces live requests (350ms anonymous, 100ms with key);
  cache hits never wait
- get() retries 429s up to 3 times honouring Retry-After (capped 10s)
- hub tracks per-meeting fetch failures and caches incomplete
  aggregates for only 2 minutes instead of 15min/24h

Verified against the live API: 2026 hub now reports all 22 rounds with
wins/podiums/form populated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 00:47:52 -04:00
AmanTahiliani
5cfaed30ba Add championship what-if simulator
Fourth view on the championship page: assign P1-P10 per remaining
round and see projected standings update live, with position deltas
and mathematically alive/eliminated title states. Pure projection
logic in lib/simulator.ts (2025 points system, standard GPs only);
scenarios persist to localStorage per season. 169 frontend tests
passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 00:31:09 -04:00
AmanTahiliani
e5cb4e4ed2 Add e2e coverage for the live timing page
Mocks /api/v1/live/state with a race snapshot to exercise the tower,
flag banner, weather strip, battle detection, stint history, and
driver pinning; also covers the no-session empty state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 00:25:07 -04:00
AmanTahiliani
c708b6697d Upgrade live timing page for race weekends
- Track status flag banner (green/yellow/SC/VSC/red, defensive mapping)
- Weather strip: air/track temp, humidity, wind with compass, rain badge
- Gap trend sparklines from a per-driver interval ring buffer
- Battle detection: consecutive cars within 1.0s chained and highlighted
- Stint history column with compound sequence per driver
- Pinnable drivers (max 3) with focus cards, persisted to localStorage

Pure logic lives in lib/gapHistory.ts and lib/battles.ts with unit
tests; 137 frontend tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 00:21:23 -04:00
AmanTahiliani
7a5e0a323d Update CLAUDE.md and plan.md to document the web layer
CLAUDE.md previously described only the TUI; it now covers the web
server (internal/web), React frontend, ingestion CLI, and the vitest/
playwright test commands. plan.md Phase 3 framing updated and the
championship hub recorded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 00:15:01 -04:00
AmanTahiliani
282b5a8c24 Parallelize championship hub fetches and cache aggregated response
Cold-cache hub loads previously issued ~70 sequential OpenF1 calls.
Per-meeting fetches now fan out 5-wide (order preserved), and the
aggregated response is cached in memory: 15min TTL for the current
season, 24h for past seasons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 00:14:07 -04:00
AmanTahiliani
2220d5d30a Add championship hub with derived stats and progression views
New /api/v1/championship/hub endpoint aggregates official standings with
wins, podiums, poles, recent form, teammate head-to-head, and per-round
cumulative points. ChampionshipPage renders drivers, constructors, and
progression views.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 00:09:47 -04:00
AmanTahiliani
91e2d13ab7 Implement SignalR Core support and add session fetching for live timing 2026-06-21 19:53:42 -07:00
AmanTahiliani
024accfb3d Add season calendar to command center 2026-05-25 18:14:55 -04:00
AmanTahiliani
a2aa772838 Add race control visuals and driver names in laps view 2026-05-25 15:55:36 -04:00
AmanTahiliani
3eb74a9083 Paginate briefing cards and add video previews 2026-05-25 15:27:34 -04:00
AmanTahiliani
e3453de788 feat(paddock): initial paddock briefing pages and store changes 2026-05-25 15:16:00 -04:00
AmanTahiliani
e060bcba24 fix(web): resolve practice/qualifying partial session bug by flagging starting_grid as N/A 2026-05-25 14:33:14 -04:00
AmanTahiliani
937674f808 feat(backend): implement session coverage, resumable ingestion, deep year backfill, backoff rate-limit, and dynamic cache TTLs 2026-05-25 13:52:43 -04:00
AmanTahiliani
bc1c551a82 feat(ui): interactive race story canvas and layout refinements 2026-05-25 13:44:51 -04:00
AmanTahiliani
c25822dee0 Phase 22b: Interactive Race Story Canvas 2026-05-25 13:20:55 -04:00
AmanTahiliani
c172bb59b1 Phase 22: Race Story Deepening 2026-05-25 13:10:47 -04:00
AmanTahiliani
7ce2b1ace4 Add Paddock Briefing UI (Phase 21) 2026-05-25 13:04:29 -04:00
AmanTahiliani
3bd169c55c Rework Race Hub as weekend workspace 2026-05-25 12:35:59 -04:00
AmanTahiliani
ee88a07aa1 Add paddock briefing feed ingestion 2026-05-25 12:30:21 -04:00
AmanTahiliani
84a8827244 Add paddock briefing RSS backend spike 2026-05-25 12:02:18 -04:00
AmanTahiliani
79b0b9f469 Rework command center for race weekends 2026-05-25 11:51:16 -04:00
AmanTahiliani
a0f135aac1 Update operator documentation 2026-05-25 11:26:52 -04:00
AmanTahiliani
9532206522 Polish live timing UI 2026-05-25 10:45:35 -04:00
AmanTahiliani
e539abcc5f Add command center screen 2026-05-25 10:29:31 -04:00
AmanTahiliani
571edb9b5a Add visual regression coverage 2026-05-25 10:17:07 -04:00
AmanTahiliani
10729a906c Add production web smoke coverage 2026-05-25 10:04:11 -04:00
AmanTahiliani
17c94d83ad Tighten race result ingestion ordering 2026-05-25 10:03:57 -04:00
AmanTahiliani
404029ff24 Add MVP completion checklist 2026-05-25 02:58:36 -04:00
AmanTahiliani
e410a01db1 Complete React live and Race Hub views 2026-05-25 02:56:06 -04:00
AmanTahiliani
5eb0983ffb Preserve optional analytics partial ingestion 2026-05-25 02:51:50 -04:00
AmanTahiliani
95060b07a5 Serve built React app from web mode 2026-05-25 02:49:31 -04:00
AmanTahiliani
0992fc03e8 Add data library UI 2026-05-25 02:45:02 -04:00
AmanTahiliani
f2339a00a9 Add weekend ingestion orchestration 2026-05-25 02:38:27 -04:00
AmanTahiliani
2c9db0213c Add local data navigation UI 2026-05-25 02:31:35 -04:00
AmanTahiliani
1661f8dec3 Add local navigation API 2026-05-25 02:25:06 -04:00
AmanTahiliani
b5d87775a6 Add Race Hub analytics visuals 2026-05-25 02:19:53 -04:00
AmanTahiliani
dfd0cf7c12 Tighten Phase 8 handoff prompt 2026-05-25 02:06:08 -04:00
AmanTahiliani
c7438f0ed8 Add analytics data foundation 2026-05-25 02:04:16 -04:00
AmanTahiliani
5470b0df38 Expand React race hub analytics 2026-05-25 01:20:04 -04:00
AmanTahiliani
32d500f5af Add React race hub frontend 2026-05-25 01:10:44 -04:00
AmanTahiliani
e08255db70 Add local-first race hub API 2026-05-25 00:49:13 -04:00
AmanTahiliani
517c6b987b Add local-first refactor foundation 2026-05-25 00:45:46 -04:00
386 changed files with 66136 additions and 969 deletions

58
.agents/README.md Normal file
View File

@@ -0,0 +1,58 @@
# `.agents/` — harness-agnostic agentic dev toolkit
Portable skills, prompts, and scripts that drive the box-box development lifecycle.
Any harness (Claude, Codex, opencode, …) can read these — the canonical workflows
live here, not in a tool-specific folder. The shared project context every harness
reads is `AGENTS.md` (→ `CLAUDE.md`).
## Layout
```
skills/ Codex/open agent skills: groom, write-spec, implement, review, lenses
personas/ grill.md (base) + lens overlays (architect, …) — the interrogation voices
prompts/ ready-spec.md (groomed spec), implement.md/review.md dispatch prompts
lib/gh.sh GitHub issue + Project (#2) state helpers: issue_*, set_stage/effort/priority
lib/dispatch.sh dispatch(): Ready issue → worktree → harness → gate → PR
harnesses.sh headless adapters (one fn per harness) + run_gate — the ONLY tool-specific code
bin/dev CLI: `dev implement <issue#> --harness <name> [--dry-run]`
```
## The lifecycle
`Icebox → Research → Ready → In Progress → In Review → Done` (the Project `Stage` field).
- **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** (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
implementer to create a local review packet and PR comment, then you merge.
## Skills and harnesses
`.agents/skills` is the canonical home for reusable workflows. Codex discovers
repo skills from that path directly, and Claude can use the same files through
`.claude/skills -> ../.agents/skills`. Other harnesses can read the same
`SKILL.md` files explicitly or enter the workflow through `.agents/bin/dev`.
Do not put canonical workflow instructions under `.claude/`; that directory is
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. 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).

45
.agents/bin/dev Executable file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# .agents/bin/dev — box-box agentic dev CLI. Works from any harness's shell.
#
# 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).
set -o pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../.." && pwd)"
usage() {
cat >&2 <<EOF
box-box dev CLI
dev implement <issue#> --harness <name> [--dry-run] [--base <branch>]
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
exit 2
}
cmd="${1:-}"; shift 2>/dev/null || true
case "$cmd" in
implement)
issue="${1:-}"; shift 2>/dev/null || true
harness=""; passthru=()
while [ $# -gt 0 ]; do
case "$1" in
--harness) harness="${2:-}"; shift 2 ;;
--dry-run) passthru+=(--dry-run); shift ;;
--base) passthru+=(--base "${2:-}"); shift 2 ;;
*) echo "unknown arg: $1" >&2; usage ;;
esac
done
[ -n "$issue" ] && [ -n "$harness" ] || usage
# shellcheck source=/dev/null
source "$ROOT/.agents/lib/dispatch.sh"
dispatch "$issue" "$harness" ${passthru[@]+"${passthru[@]}"}
;;
""|-h|--help|help) usage ;;
*) echo "unknown command: $cmd" >&2; usage ;;
esac

73
.agents/harnesses.sh Normal file
View File

@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# .agents/harnesses.sh — headless harness adapters.
#
# ONE function per harness: harness_<name> <workdir> <promptfile>
# Each runs the harness NON-INTERACTIVELY inside <workdir>, feeding it the rendered
# prompt, expected to edit files (and ideally commit). This is the ONLY harness-specific
# code in the toolkit — tune the exact flags here per tool/version.
#
# Autonomy note: these run unattended, so each uses the tool's "just do it" mode
# (accept edits / full-auto). Only run harnesses you trust on code you can review via PR.
# ---- 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"
local model="${BOXBOX_CLAUDE_MODEL:-opus}"
( cd "$dir" && claude -p "$(cat "$prompt")" --model "$model" --effort high --permission-mode acceptEdits )
}
harness_codex() { # OpenAI Codex CLI — non-interactive exec, full auto
local dir="$1" prompt="$2"
( cd "$dir" && codex exec --full-auto "$(cat "$prompt")" )
}
harness_opencode() { # opencode — non-interactive run
local dir="$1" prompt="$2"
( cd "$dir" && opencode run "$(cat "$prompt")" )
}
harness_cursor() { # Cursor CLI agent — composer-2.5, headless full-auto
local dir="$1" prompt="$2"
local model="${BOXBOX_CURSOR_MODEL:-composer-2.5}"
( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --model "$model" --force --trust )
}
harness_agy() { # Antigravity CLI — disabled until headless is verified
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) ----
harness_pi() { # pi — CONFIRM headless CLI + flags
local dir="$1" prompt="$2"
( cd "$dir" && pi run "$(cat "$prompt")" ) # placeholder — verify
}
# ---- build/typecheck gate (fast, local) ----
# Returns non-zero on failure. This is a smoke gate — CI runs the full suite. Tune freely.
run_gate() {
local dir="$1"
( cd "$dir" && go build ./... ) || return 1
if [ -d "$dir/frontend/node_modules" ]; then
( cd "$dir/frontend" && npx tsc --noEmit ) || return 1
else
echo " (gate: frontend deps absent in worktree — tsc/vitest deferred to CI)" >&2
fi
return 0
}

130
.agents/lib/dispatch.sh Normal file
View File

@@ -0,0 +1,130 @@
#!/usr/bin/env bash
# .agents/lib/dispatch.sh — implement a Ready issue with a chosen harness in an
# isolated git worktree, run the build gate, and open a PR.
#
# Source it, then: dispatch <issue#> <harness> [--dry-run] [--base <branch>]
# (or use the CLI: .agents/bin/dev implement <issue#> --harness <name> [--dry-run])
_AGENTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=/dev/null
source "$_AGENTS_DIR/lib/gh.sh"
# shellcheck source=/dev/null
source "$_AGENTS_DIR/harnesses.sh"
_slug() { echo "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//' | cut -c1-40; }
_render_prompt() { # <issue#> <title> <body>
echo "# Implement: $2"
echo
cat "$_AGENTS_DIR/prompts/implement.md"
echo; echo "---"; echo
echo "## Spec — issue #$1"
echo
echo "$3"
}
_pr_body() { # <issue#> <harness> <gate>
cat <<EOF
Implements #$1.
- **Harness:** $2 (dispatched via \`.agents/bin/dev\`)
- **Local gate** (\`go build\` + \`tsc --noEmit\`): **$3**
- Full test suite + independent review run in CI / by a reviewer harness.
See #$1 for the groomed spec, Test Plan, and Definition of Done.
Closes #$1
EOF
}
dispatch() { # <issue#> <harness> [--dry-run] [--base <branch>]
local issue="$1" harness="$2"; shift 2 || { echo "usage: dispatch <issue#> <harness> [--dry-run] [--base <branch>]"; return 2; }
local dry=0 base="main"
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) dry=1 ;;
--base) base="$2"; shift ;;
*) echo "dispatch: unknown flag '$1'" >&2; return 2 ;;
esac; shift
done
# adapter must exist
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
title="$(issue_title "$issue")" || { echo "issue #$issue not found on $REPO" >&2; return 1; }
body="$(issue_body "$issue")"
slug="$(_slug "$title")"
branch="feat/issue-${issue}-${slug}"
wt="$repo_root/.worktrees/issue-${issue}"
prompt="$(mktemp "${TMPDIR:-/tmp}/boxbox-prompt-${issue}.XXXX")"
_render_prompt "$issue" "$title" "$body" > "$prompt"
echo "── dispatch #$issue$harness ──"
echo " title : $title"
echo " branch : $branch"
echo " worktree : $wt"
echo " base : $base"
echo " prompt : $prompt"
if [ "$dry" = 1 ]; then
echo " [dry-run] no worktree / harness / PR / state change. Prompt preview:"
sed 's/^/ | /' "$prompt"
return 0
fi
# preflight: warn (don't block) if not Ready
local stage; stage="$(get_field "$issue" Stage)"
[ "$stage" = "Ready" ] || echo " ⚠ Stage is '$stage' (not Ready) — dispatching anyway"
# isolated worktree
if [ -d "$wt" ]; then
echo " worktree exists — reusing"
else
git -C "$repo_root" worktree add -b "$branch" "$wt" "$base" || return 1
fi
set_stage "$issue" "In Progress"
echo " running $harness (headless)…"
( harness_"$harness" "$wt" "$prompt" ); local hrc=$?
echo " $harness exited ($hrc)"
# fallback commit: guarantee a PR-able branch even if the harness didn't commit
if [ -n "$(git -C "$wt" status --porcelain)" ]; then
git -C "$wt" add -A
git -C "$wt" commit -q -m "feat(#$issue): $title
Implemented by $harness via .agents/dev dispatch." && echo " committed leftover changes"
fi
# gate
local gate="passed"
run_gate "$wt" || gate="FAILED"
echo " gate: $gate"
# PR (only if there are commits ahead of base)
if [ -n "$(git -C "$wt" log "$base..$branch" --oneline 2>/dev/null)" ]; then
git -C "$wt" push -u origin "$branch" || { echo " push failed — inspect $wt" >&2; return 1; }
local draft=""; [ "$gate" = "FAILED" ] && draft="--draft"
local pr
pr="$(gh pr create -R "$REPO" --head "$branch" --base "$base" $draft \
--title "$title (#$issue)" --body "$(_pr_body "$issue" "$harness" "$gate")")" || { echo " gh pr create failed" >&2; return 1; }
echo " PR: $pr${draft:+ (draft — gate failed)}"
set_stage "$issue" "In Review"
else
echo " no commits on $branch — leaving Stage 'In Progress'. Inspect the worktree: $wt" >&2
fi
}

68
.agents/lib/gh.sh Normal file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# .agents/lib/gh.sh — harness-agnostic helpers for the box-box agentic dev lifecycle.
#
# Source it, then call the functions:
# source .agents/lib/gh.sh
# issue_json 9 ; set_stage 9 Research ; set_effort 9 M
#
# Requires: gh (authed, with `project` scope), jq.
# Config is overridable via env vars.
REPO="${BOXBOX_REPO:-AmanTahiliani/box-box}"
PROJECT_OWNER="${BOXBOX_PROJECT_OWNER:-AmanTahiliani}"
PROJECT_NUMBER="${BOXBOX_PROJECT_NUMBER:-2}"
_BOXBOX_CACHE="${TMPDIR:-/tmp}/boxbox-agent"
mkdir -p "$_BOXBOX_CACHE" 2>/dev/null
# ---------- issues ----------
issue_json() { gh issue view "$1" -R "$REPO" --json number,title,body,labels,url,state; }
issue_body() { gh issue view "$1" -R "$REPO" --json body -q .body; }
issue_title() { gh issue view "$1" -R "$REPO" --json title -q .title; }
issue_url() { gh issue view "$1" -R "$REPO" --json url -q .url; }
set_issue_body() { gh issue edit "$1" -R "$REPO" --body-file "$2"; } # <issue#> <file>
add_comment() { gh issue comment "$1" -R "$REPO" --body-file "$2"; } # <issue#> <file>
# Native sub-issue children of an epic (issue numbers, one per line).
sub_issues() {
gh api graphql -H "GraphQL-Features: sub_issues" -f query='
query($owner:String!,$repo:String!,$num:Int!){
repository(owner:$owner,name:$repo){
issue(number:$num){ subIssues(first:50){ nodes{ number } } } } }' \
-F owner="${REPO%/*}" -F repo="${REPO#*/}" -F num="$1" \
-q '.data.repository.issue.subIssues.nodes[].number'
}
# ---------- project fields (cached per shell invocation) ----------
_fields_file="$_BOXBOX_CACHE/fields.json"
_items_file="$_BOXBOX_CACHE/items.json"
_pid_file="$_BOXBOX_CACHE/project_id"
_refresh_fields() { gh project field-list "$PROJECT_NUMBER" --owner "$PROJECT_OWNER" --format json > "$_fields_file"; }
_refresh_items() { gh project item-list "$PROJECT_NUMBER" --owner "$PROJECT_OWNER" --format json --limit 200 > "$_items_file"; }
project_refresh() { _refresh_fields; _refresh_items; } # call once at the start of a session to get fresh state
_project_id() { [ -s "$_pid_file" ] || gh project view "$PROJECT_NUMBER" --owner "$PROJECT_OWNER" --format json | jq -r .id > "$_pid_file"; cat "$_pid_file"; }
_field_id() { [ -s "$_fields_file" ] || _refresh_fields; jq -r --arg n "$1" '.fields[]|select(.name==$n)|.id' "$_fields_file"; }
_option_id() { [ -s "$_fields_file" ] || _refresh_fields; jq -r --arg f "$1" --arg o "$2" '.fields[]|select(.name==$f)|.options[]?|select(.name==$o)|.id' "$_fields_file"; }
_item_id() { [ -s "$_items_file" ] || _refresh_items; jq -r --arg n "$1" '.items[]|select(.content.number==($n|tonumber))|.id' "$_items_file"; }
# set_field <issue#> <FieldName> <OptionName> (single-select fields: Stage/Priority/Effort/Phase)
set_field() {
local item opt fld pid
item="$(_item_id "$1")"; fld="$(_field_id "$2")"; opt="$(_option_id "$2" "$3")"; pid="$(_project_id)"
if [ -z "$item" ] || [ -z "$fld" ] || [ -z "$opt" ]; then
echo "set_field: could not resolve issue=$1 field=$2 option=$3 (item=$item field=$fld opt=$opt)" >&2; return 1
fi
gh project item-edit --id "$item" --project-id "$pid" --field-id "$fld" --single-select-option-id "$opt" >/dev/null \
&& echo "set #$1 $2=$3"
}
set_stage() { set_field "$1" Stage "$2"; }
set_priority() { set_field "$1" Priority "$2"; }
set_effort() { set_field "$1" Effort "$2"; }
# get_field <issue#> <FieldName> -> current value (single-word field names only)
get_field() {
[ -s "$_items_file" ] || _refresh_items
jq -r --arg n "$1" --arg f "$2" '.items[]|select(.content.number==($n|tonumber))|.[($f|ascii_downcase)] // "-"' "$_items_file"
}

View File

@@ -0,0 +1,35 @@
# Lens overlay: Architect
Apply this **on top of** the base grill (`grill.md`). Bias every question toward
technical soundness and fit with the existing box-box architecture.
## box-box context to hold
- **Backend (Go):** `internal/api` (OpenF1 client, cache, 37 endpoints), `internal/web`
(REST handlers + SSE hub, route table in `server.go`), `internal/store` (domain
SQLite), `internal/query` (read models over the domain DB), `internal/ingest`,
`internal/live` (SignalR).
- **Frontend (React+Vite+TS):** TanStack Router/Query, `src/api.ts` (typed fetchers),
`src/types.ts` (payload mirrors), `src/lib` (client helpers), `src/pages`,
`src/components` (incl. `components/live`).
- **Patterns to respect** (see CLAUDE.md "How To Extend"): ServeMux longest-prefix
route ordering in `server.go`, cache TTL tiers, `?source=openf1|local|auto`
resolution, two-phase standings load, lazy tab loads, stale-data fallback banner.
## Grill especially on
- **Reuse vs new:** does existing code already do this (a TUI equivalent in
`internal/ui/*.go`, a query model, an `api.ts` fetcher)? Port vs rebuild.
- **Data flow & source:** OpenF1 live vs domain DB vs cache; payload size; rate
limits; how `?source` is handled.
- **Seams:** which files/modules change; new endpoint (mind registration order!) vs
extend an existing one; new component vs extend; where shared logic lives
(`frontend/src/lib/*`).
- **Testability:** how does this land in `go test` / `vitest` / hermetic Playwright?
What seam makes it testable without live OpenF1?
- **Risk:** domain-DB migrations, perf on large sessions, backward compat, and
failure / stale-data behavior.
Keep questions concrete and decision-shaped — e.g. *"port the GPS normalization from
`internal/ui/trackmap.go`, or recompute in a shared `frontend/src/lib/trackmap.ts` so
it's unit-testable?"* — each with your recommendation.

51
.agents/personas/grill.md Normal file
View File

@@ -0,0 +1,51 @@
# Persona: The Grill
You run a **grill-me** session. Instead of the human prompting you, **you interrogate
the human** until you share a design concept for one specific piece of work (a GitHub
issue or epic). The shared understanding — not the document — is the real output.
## Before you ask anything
Load the full context of the target:
- Read the issue title + body (and any notes already on it).
- Read `CLAUDE.md`.
- Explore the code paths the work implicates.
## Rules
1. **One question at a time.** Walk each branch of the design tree and resolve
dependencies in order — a later question often depends on an earlier answer.
2. **Recommend, consequence-first.** Every question carries your recommended answer
and a short "why". **Calibrate to a technically fluent reader who does not know
*this project's* internals.** Assume general engineering literacy (APIs, streaming,
latency, front/back-end, caching, etc.) and don't explain those. **Do** unpack
anything project-specific: internal file/type/endpoint names, bespoke architecture
choices, and why they matter *here* — a few words is enough, no lectures. Above all,
lead each option with the **practical consequence** a decision can be made on
(effort, risk, what ships sooner, how it feels to use), so the reader can choose
without needing the implementation detail. Recommendation first, with why it's the
better call **for them**.
- *Example — keep the mechanism, but lead with the tradeoff:* "**A (recommended):**
reuse the existing SSE snapshot — cheapest to build, but cars jump a little
between updates. **B:** a dedicated ~4Hz position stream — more work now, but
motion is smooth and it sets up interpolation later." (Names the real mechanism;
the choice is still obvious from the consequences.)
3. **Hybrid asking.**
- Decision with clear discrete options → present a **structured choice**, the
recommendation first. *(In Claude Code: use the AskUserQuestion tool; put the
recommended option first and end its label with "(Recommended)".)* Write each
option's description in the plain-language, consequence-first style from rule 2 —
the label can be terse, but the description must be understandable on its own.
- Genuinely open-ended → ask in **prose**.
4. **Explore before you ask.** If the codebase or the issue already answers a
question, do **not** ask — state what you found and the assumption you're
proceeding with, then move on. Only ask about real forks the human must decide.
5. **Stay in scope.** Grill the design of *this* work, not the whole app. Note
out-of-scope temptations instead of chasing them.
## Termination
Stop when no unresolved branches remain and you could write the spec yourself with no
open questions. Summarize the shared design concept in 36 bullets, confirm it with
the human, then hand off to `write-spec`.

View File

@@ -0,0 +1,21 @@
You are an autonomous coding agent working in an **isolated git worktree** on the
**box-box** repo. Implement the groomed spec below as a single, focused, story-sized
change — then commit it.
## Ground rules
- Read `AGENTS.md` / `CLAUDE.md` first and follow the project's conventions exactly
(architecture, route-registration order in `server.go`, cache TTL tiers,
`api.ts`/`types.ts` mirrors, test layout).
- Implement ONLY this story's scope. Honor the spec's **Out of Scope** — do not build
deferred items, even if tempting.
- If the spec has an **early spike / risk** step, do that FIRST and note the result in
your commit message (and adjust the approach if the spike says to).
- Add or extend tests per the **Test Plan**. Make the relevant suites pass:
`go build ./...`, `go test ./...`, and in `frontend/`: `npm run test`, `tsc --noEmit`.
- Keep the change reviewable and story-sized. **Commit your work** with a clear,
conventional message when done (the dispatcher opens the PR).
- Satisfy every item in the spec's **Definition of Done**.
If something in the spec is ambiguous or turns out to be wrong once you're in the code,
make the smallest reasonable decision, implement it, and call it out clearly in the
commit message / PR so the reviewer can catch it — do not silently expand scope.

View File

@@ -0,0 +1,27 @@
## Context
{one paragraph: the problem and why it matters, grounded in the grooming}
## Acceptance Criteria
- [ ] {observable behaviour 1}
- [ ] {observable behaviour 2}
## Technical Approach
- **Files to touch:** {real paths}
- **Data source:** {OpenF1 live | domain DB | cache | ...}
- **Endpoints/components:** {new or extended — note route-registration order if new}
- **Key decisions:** {the forks resolved during the grill}
## Test Plan
- {cases mapped to the real suites: go test · vitest · hermetic Playwright}
## Out of Scope
- {explicitly deferred}
## Definition of Done
- [ ] Tests added and green (`go test` · `vitest` · `tsc --noEmit` · hermetic Playwright as applicable)
- [ ] Matches CLAUDE.md conventions (route order, cache TTLs, `api.ts`/`types.ts` mirrors)
- [ ] No console / preview errors (UI verified in preview)
- [ ] Story-sized PR, linked to this issue
---
_Groomed {date} · Effort {S|M|L} · Priority {P0|P1|P2} · via /groom_

20
.agents/prompts/review.md Normal file
View File

@@ -0,0 +1,20 @@
You are an independent reviewer for a box-box pull request. Review the PR against
the linked GitHub issue spec, not against your own preferred scope.
## Ground rules
- Use a harness different from the implementer when possible.
- Read `AGENTS.md` / `CLAUDE.md`, the PR body/diff, and the linked issue body.
- Run the relevant local gates and capture logs under `.review/issue-<n>-pr-<pr>/logs`.
- For UI changes, create a local visual packet under `.review/issue-<n>-pr-<pr>/`
with desktop and mobile screenshots for the affected routes.
- If screenshots should appear inline on GitHub, publish only review artifacts to
a separate artifact branch, never to `main` or the product PR branch.
- Post a PR comment with pass/fail status, acceptance-criteria alignment, local
artifact paths, screenshot links when available, and caveats.
- Do not merge. The human owns the merge gate.
## Output
- A local packet with `summary.md`, optional `index.html`, screenshots, logs, and
any visual diffs.
- A GitHub PR comment that makes the review visually scannable.
- A clear recommendation: pass, pass with caveats, or needs changes.

View File

@@ -0,0 +1,42 @@
---
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.'
argument-hint: <issue-number> [--lens architect]
---
# /groom — refine one issue into a Ready spec
Groom issue **$ARGUMENTS** on the box-box roadmap (Project #2): drive it from the
backlog into a fully-specified Ready ticket through a grill-me session.
## 0. Setup
- Parse the first token of the arguments as the **issue number**. An optional
`--lens <name>` pulls in a lens overlay (currently: `architect`).
- Run: `source .agents/lib/gh.sh && project_refresh` (fresh field/item state).
- Load context **before asking anything**: `issue_json <n>`, read `CLAUDE.md`, and
explore the code paths the issue implicates.
- Move it into grooming if it isn't already there: `set_stage <n> Research`.
## 1. Grill
- Read `.agents/personas/grill.md`. If `--lens <name>` was given, also read
`.agents/personas/<name>.md` and apply it on top.
- Run the grill exactly per those rules: **one question at a time**, recommendation
first, **hybrid** asking (AskUserQuestion for discrete decisions with the
recommended option first and labelled "(Recommended)"; prose for open-ended), and
**explore the code to self-answer** wherever possible — only ask about genuine forks.
- Track the resolved decisions as you go.
## 2. Synthesize
- When no open branches remain, summarize the shared design concept in 36 bullets and
confirm it with the user.
- Then invoke the **write-spec** skill for issue `<n>`, handing it the resolved
decisions, so it renders `.agents/prompts/ready-spec.md` into the issue body and
sets Effort + Priority.
## 3. Hand back (human gate)
- Do **not** auto-advance to Ready — that's the user's call. Report that the spec is
written, Stage is `Research`, and they should review the issue and flip Stage →
`Ready` when satisfied (`set_stage <n> Ready`).
- Print the issue URL (`issue_url <n>`).
Stay focused on THIS issue's design throughout. Note but don't chase out-of-scope ideas.

View File

@@ -0,0 +1,32 @@
---
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.'
argument-hint: <issue-number> --harness <claude|codex|opencode|pi|cursor> [--dry-run]
---
# /implement — dispatch an issue to a harness (supervised)
Supervise the implementation of issue **$ARGUMENTS**. You are SUPERVISING, not coding —
a fresh harness does the work in its own isolated worktree with clean context. Do not
edit project files yourself.
## Steps
1. Parse: `<issue-number> --harness <name> [--dry-run] [--base <branch>]`.
2. **Preflight (report, don't hard-block):** `source .agents/lib/gh.sh` and check
`get_field <n> Stage` is `Ready` and the issue body has an "## Acceptance Criteria"
section (a groomed spec). If it's not Ready or has no spec, say so and recommend
`/groom <n>` first — proceed only if the user confirms.
3. **Dispatch:** run `.agents/bin/dev implement <n> --harness <name> [flags]`. For a
first run against an unfamiliar harness, suggest `--dry-run` first so the user can
eyeball the prompt.
4. **Report the outcome:** branch, worktree path, gate result (pass/FAILED → draft PR),
and the PR URL. On success, Stage will be `In Review`.
5. If no PR was created (no changes, or push failed), surface exactly why and point at
the worktree (`.worktrees/issue-<n>`) so the user can inspect. Diagnose from the
dispatcher output; recommend a fix or re-run — don't silently take over the coding.
## Notes
- The harness adapters and the gate live in `.agents/harnesses.sh` — the single place
to tune per-tool flags.
- From any harness shell, run the same thing directly:
`.agents/bin/dev implement <n> --harness <name>`.

View File

@@ -0,0 +1,22 @@
---
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.'
argument-hint: <issue-number>
---
# /lens-architect — architecture lens
Target: issue **$ARGUMENTS** (box-box, Project #2).
1. `source .agents/lib/gh.sh`; load context (`issue_json <n>`, `CLAUDE.md`, and the
relevant code paths).
2. Read `.agents/personas/grill.md` + `.agents/personas/architect.md` and run a focused
grill from the architecture lens: hybrid asking (AskUserQuestion for discrete
decisions, recommendation first; prose otherwise), recommend every answer, and
explore the code to self-answer before asking.
3. When aligned, write an **"## Architecture review"** summary (decisions taken,
files/seams affected, risks, the test seam) to a temp file and `add_comment <n> <file>`.
4. Print the issue URL.
If invoked from **within /groom**, skip the comment — instead return the architecture
decisions inline so groom can fold them into the spec.

View File

@@ -0,0 +1,53 @@
---
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.'
argument-hint: <pr-number> [--harness <name>] [--publish-screenshots]
---
# /review — local independent PR review
Review PR **$ARGUMENTS** for box-box using the local-only lifecycle. You are
reviewing, not implementing. Do not merge the PR.
## 0. Setup
- Parse the PR number. Optional `--harness <name>` records which reviewer harness is
acting; optional `--publish-screenshots` allows pushing visual artifacts to a
dedicated artifact branch.
- Read `.agents/prompts/review.md`, `AGENTS.md`, the PR metadata/diff, and the linked
issue body.
- Identify the implementer harness from the PR body when present. If it matches the
reviewer harness, call that out as a reduced independence caveat.
- Create `.review/issue-<issue>-pr-<pr>/logs`, `screenshots`, and `artifacts`.
## 1. Verify
- Run the smallest meaningful gates first, then broaden based on risk:
`go test` for touched Go packages, `npm run test` and `npm run build` for frontend
changes, and hermetic Playwright when user-facing routes changed.
- Save all command output to `.review/issue-<issue>-pr-<pr>/logs`.
- Inspect the diff for spec conformance, scope leaks, missing tests, and known project
conventions from `AGENTS.md`.
## 2. Visual Packet
- If the PR changes UI, start a local seeded or mocked preview and capture desktop and
mobile screenshots for affected routes.
- Prefer hermetic mocks/seeded data over live external state.
- Save screenshots under `.review/issue-<issue>-pr-<pr>/screenshots`.
- Create a concise `summary.md`; create `index.html` when screenshots exist.
## 3. Publish
- If `--publish-screenshots` is present, publish only review artifacts to a dedicated
branch such as `review-artifacts/pr-<pr>/` and use raw GitHub URLs in the comment.
- Post a PR comment with:
- reviewer harness and implementer harness
- result: pass, pass with caveats, or needs changes
- local packet path
- gates run and results
- acceptance-criteria checklist
- screenshots or artifact links when available
- caveats that the human must inspect
- If the result is pass/pass-with-caveats, set the linked issue's custom Project
`Stage` to `In Review` using `.agents/lib/gh.sh`. Do not set `Done`.
## 4. Hand Back
- Tell the human exactly what to open locally and what decision remains theirs.
- Do not merge or delete worktrees.

View File

@@ -0,0 +1,30 @@
---
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.'
argument-hint: <issue-number>
---
# /write-spec — write the Ready spec into an issue
Target issue: **$ARGUMENTS** (box-box, Project #2).
## Steps
1. `source .agents/lib/gh.sh`
2. Gather the agreed design decisions: from the current grooming conversation if one
is in progress; otherwise ask the user for the key points, or read the issue and
explore the code to draft them and confirm.
3. Read `.agents/prompts/ready-spec.md` and fill every placeholder:
- Concrete, **behavioural** acceptance criteria (checkboxes).
- Technical approach grounded in **real files/paths** and the chosen data source.
- Test plan mapped to the actual suites (`go test` · `vitest` · hermetic Playwright).
- Explicit out-of-scope.
- Keep the Definition of Done checklist verbatim.
- Stamp the footer: date (from the current-date context), Effort (S/M/L),
Priority (P0P2).
4. Write it into the issue body: save the filled template to a temp file under the
scratchpad and `set_issue_body <n> <file>`. The spec is the single source of truth —
only preserve prior body text that captures decisions the spec doesn't.
5. Set fields: `set_effort <n> <S|M|L>` and `set_priority <n> <P0|P1|P2>`.
6. Print the issue URL and a one-line summary of what was written.
Do **not** change Stage — `/groom` owns state transitions.

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
}

1
.claude/skills Symbolic link
View File

@@ -0,0 +1 @@
../.agents/skills

23
.gitignore vendored
View File

@@ -7,6 +7,7 @@ build/
# Log files # Log files
*.log *.log
.DS_Store
# SQLite database files # SQLite database files
*.db *.db
@@ -15,3 +16,25 @@ build/
# Old file cache # Old file cache
.cache/ .cache/
# Frontend dependencies/build output
frontend/node_modules/
frontend/dist/
# Local Claude workspace settings
.claude/*
!.claude/skills
# Playwright
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
# agentic dev: isolated implementation worktrees
.worktrees/
# agentic dev: local review packets
.review/

View File

@@ -4,17 +4,30 @@
```bash ```bash
go build -o box-box ./cmd/main.go # Build binary go build -o box-box ./cmd/main.go # Build binary
go run cmd/main.go # Run directly go run cmd/main.go # Run TUI
go test ./... # All tests go run cmd/main.go --web --port 8080 # Run web server (default port 8080)
go run cmd/main.go --ingest-year 2025 # Ingest a season into the domain DB (also: --ingest-meeting, --ingest-session, --ingest-news, --coverage)
go test ./... # All Go tests
go test -v ./internal/api # API integration tests (requires internet, rate-limit aware) go test -v ./internal/api # API integration tests (requires internet, rate-limit aware)
OPENF1_API_KEY=key go run cmd/main.go # Run with paid tier (enables live session access) OPENF1_API_KEY=key go run cmd/main.go # Run with paid tier (enables live session access)
# Frontend (run inside frontend/)
npm run dev # Vite dev server on :5173, proxies /api to :8080 (override with BOXBOX_API_PORT)
npm run build # tsc --noEmit && vite build -> frontend/dist
npm run test # Vitest unit tests (frontend/src/test/); npm run test:watch for watch mode
# E2E / visual (run at repo root; Playwright auto-starts a seeded Go server + Vite dev server)
npm run test:e2e # Playwright e2e (tests/*.spec.ts, playwright.config.ts)
npm run test:visual # Visual snapshots (tests/visual/, playwright.visual.config.ts)
npm run test:visual:update # Regenerate visual snapshots
# :prod variants (test:e2e:prod, test:visual:prod) run against the *.prod.config.ts configs
``` ```
## Project Overview ## Project Overview
**box-box** is an F1 Terminal UI (TUI) dashboard built in Go with Bubble Tea. It shows live timing, standings, race calendar, driver telemetry, track maps, and race replay — all sourced from the OpenF1 API. **box-box** is an F1 dashboard in Go with two frontends sharing the same data layer: a Bubble Tea TUI and a web app (`--web` flag) — a Go HTTP server in `internal/web/` serving a React SPA plus a REST/SSE API. It shows live timing, standings, race calendar, driver telemetry, track maps, and race replay — sourced from the OpenF1 API and a local domain SQLite DB filled by the ingestion CLI.
**Status**: Pre-beta, actively developed. All layers (API, models, UI) are fully implemented. **Status**: Pre-beta, actively developed.
## Tech Stack ## Tech Stack
@@ -23,12 +36,15 @@ OPENF1_API_KEY=key go run cmd/main.go # Run with paid tier (enables live sessio
- **Bubbles** — TUI components (spinner, viewport, table) - **Bubbles** — TUI components (spinner, viewport, table)
- **OpenF1 API** — F1 data at `https://api.openf1.org` - **OpenF1 API** — F1 data at `https://api.openf1.org`
- **gorilla/websocket** — Official F1 SignalR live feed - **gorilla/websocket** — Official F1 SignalR live feed
- **modernc.org/sqlite** — HTTP response caching with TTL - **modernc.org/sqlite** — HTTP response cache + domain DB
- **React 18 + Vite + TypeScript** — Web frontend (`frontend/`), TanStack Router + TanStack Query
- **Vitest + Testing Library** — Frontend unit tests; **Playwright** — e2e and visual tests (repo root)
## File Map ## File Map
``` ```
cmd/main.go Entry point (package main). Inits client, launches TUI. cmd/main.go Entry point. Flags: --web/--port (web server), --ingest-* /
--backfill-season/--coverage (ingestion CLI), --db. Default: TUI.
internal/api/ internal/api/
client.go OpenF1Client: HTTP client, 15s timeout, optional Bearer auth client.go OpenF1Client: HTTP client, 15s timeout, optional Bearer auth
@@ -56,6 +72,40 @@ internal/ui/
battles.go Sub-view: Auto-detected on-track battles with gap sparkline battles.go Sub-view: Auto-detected on-track battles with gap sparkline
pitwindow.go Sub-view: Pit stop rejoin position calculator pitwindow.go Sub-view: Pit stop rejoin position calculator
replay.go Sub-view: Lap-by-lap race replay scrubber replay.go Sub-view: Lap-by-lap race replay scrubber
internal/store/ Domain SQLite DB (~/.local/share/box-box/boxbox.db), season/session data
internal/ingest/ OpenF1 -> domain DB ingestion (driven by cmd/main.go --ingest-* flags)
internal/query/ Read models over the domain DB, used by web handlers
internal/news/ RSS/Atom paddock briefing feed ingestion (--ingest-news)
internal/live/ Shared F1 SignalR live feed client + LiveStreamData types
internal/web/
server.go HTTP server: route table, CORS/logging middleware, SPA file server.
Serves frontend/dist if found (walks up from cwd), else embedded assets/
api.go REST handlers under /api/v1/ (results, laps, telemetry, championship
hub aggregation, news + readability article extraction, ...)
live.go SSEHub + SignalR bridge: relays official F1 feed to SSE subscribers
racehub.go /api/v1/race-hub: per-session payload assembled from the domain DB
navigation.go Local-first navigation: /api/v1/seasons, meetings, sessions, weekend
source.go ?source=openf1|local|auto data-source resolution
assets/ Embedded fallback SPA (legacy vanilla JS; used when no frontend/dist)
frontend/ React + Vite + TypeScript SPA
src/main.tsx Entry: QueryClientProvider + RouterProvider
src/router.tsx TanStack Router: / (command center), /race-hub, /live, /championship,
/briefing, /data-library (also /admin alias)
src/api.ts Typed fetch wrappers for /api/v1/ endpoints
src/types.ts TypeScript mirrors of API payloads
src/pages/ CommandCenterPage, RaceHubPage, LiveTimingPage, ChampionshipPage,
BriefingPage, DataLibraryPage
src/components/ Shared components (Nav, TabBar, race hub views, live/ timing tower)
src/lib/ Client helpers: live SSE parsing, schedule, coverage, GP identity
src/test/ Vitest + Testing Library unit tests
tests/ Playwright e2e specs; tests/visual/ visual snapshot specs
playwright*.config.ts Dev/prod e2e + visual configs (webServer blocks seed a temp domain DB
and start Go API + Vite automatically)
scripts/seed-e2e-db/ Seeds the throwaway domain DB used by Playwright runs
``` ```
## Architecture ## Architecture
@@ -66,6 +116,17 @@ Each tab is a sub-model with `Init()`, `Update(msg)`, `View()`. The root `AppMod
Async work (API calls, WebSocket) returns `tea.Cmd` that emits typed messages back to Update. Use `tea.Batch()` for parallel fetches. Async work (API calls, WebSocket) returns `tea.Cmd` that emits typed messages back to Update. Use `tea.Batch()` for parallel fetches.
### Web Layer
`box-box --web` starts an HTTP server (default `:8080`) with three surfaces:
- **REST API at `/api/v1/...`** — Handlers in `internal/web/api.go` wrap `OpenF1Client`; navigation/race-hub endpoints read the domain DB via `internal/query` (empty responses if the DB is missing). `?source=openf1|local|auto` picks the data source where supported. Register routes in `routes()` in `server.go` — Go ServeMux longest-prefix matching means more specific paths (e.g. `/api/v1/laps/comparison`) must be registered before their prefixes.
- **SSE live stream** — `internal/web/live.go` runs a background SignalR connection to the official F1 feed (exponential-backoff reconnect; disabled with `BOXBOX_DISABLE_LIVE=1`). An `SSEHub` broadcasts snapshots to browsers on `/api/v1/live/stream`, with `/api/v1/live/state` for the initial snapshot and a 20s heartbeat. `LiveTimingPage.tsx` consumes it; parsing helpers live in `frontend/src/lib/live.ts`.
- **Embedded SPA** — Static file server with SPA fallback to `index.html`. Prefers a `frontend/dist` directory found by walking up from cwd (so `npm run build` output is served without rebuilding Go); otherwise serves the legacy assets embedded via `//go:embed assets`.
- **Championship hub** — `/api/v1/championship/hub` aggregates official standings with derived stats (wins, podiums, poles, last-5 form, teammate head-to-head) and per-round cumulative points, computed from all season race results.
Frontend dev loop: run `go run cmd/main.go --web` and `npm run dev` in `frontend/` — Vite proxies `/api` to the Go server.
### Key Patterns ### Key Patterns
- **Two-phase standings load**: `GetLatestDriverChampionship()` -> extract SessionKey -> `GetDriversForSession(sessionKey)` -> join by DriverNumber for names/colors - **Two-phase standings load**: `GetLatestDriverChampionship()` -> extract SessionKey -> `GetDriversForSession(sessionKey)` -> join by DriverNumber for names/colors
@@ -104,13 +165,20 @@ Replay: `h`/`l` or arrows scrub laps
- **New message type**: Define in `messages.go`, handle in relevant model's `Update()` - **New message type**: Define in `messages.go`, handle in relevant model's `Update()`
- **New keybinding**: Define in `keys.go`, handle in relevant model's `Update()` - **New keybinding**: Define in `keys.go`, handle in relevant model's `Update()`
- **New styles**: Add to `styles.go`, reference F1 palette constants - **New styles**: Add to `styles.go`, reference F1 palette constants
- **New web page/route**: Create page in `frontend/src/pages/`, register route in `frontend/src/router.tsx`, add nav link in `frontend/src/components/Nav.tsx`, add fetchers to `src/api.ts` and payload types to `src/types.ts`, add a test in `frontend/src/test/`
- **New web API endpoint**: Add handler in `internal/web/api.go` (or a new file in `internal/web/`), register it in `routes()` in `server.go` (mind prefix ordering), add a handler test alongside (see `championship_hub_test.go`)
## Testing ## Testing
Tests in `openf1_test.go` hit the real OpenF1 API. They use `skipOnRateLimit(t, err)` to gracefully skip on HTTP 429. Require internet. - Go: tests in `openf1_test.go` hit the real OpenF1 API and use `skipOnRateLimit(t, err)` to skip on HTTP 429 (require internet). `internal/web` handler tests run offline.
- Frontend: Vitest + Testing Library in `frontend/src/test/` (`npm run test` inside `frontend/`).
- E2E/visual: Playwright at repo root (`npm run test:e2e`, `npm run test:visual`). Configs seed a temp domain DB and start the Go server with `BOXBOX_DISABLE_LIVE=1` and `BOXBOX_OPENF1_BASE_URL` pointed at an unreachable address (hermetic: no live OpenF1 dependency, date-stable) plus a Vite dev server — no manual setup needed.
## Environment ## Environment
- `OPENF1_API_KEY` — Optional Bearer token for paid tier (live session WebSocket access) - `OPENF1_API_KEY` — Optional Bearer token for paid tier (live session WebSocket access)
- Logs go to `box-box.log` in project root (prevents TUI pollution) - `BOXBOX_DISABLE_LIVE=1` — Skip the background SignalR live feed in web mode (used by e2e)
- Cache at `~/.cache/box-box/cache.db` (SQLite WAL mode, auto-created) - `BOXBOX_OPENF1_BASE_URL` — Override the OpenF1 API root (default `https://api.openf1.org`; e2e sets an unreachable address for hermetic runs)
- `BOXBOX_API_PORT` — Go API port that the Vite dev proxy targets (default 8080)
- Logs: TUI writes `box-box.log` in project root; web/ingest modes log to stderr
- HTTP cache at `~/.cache/box-box/cache.db`; domain DB at `~/.local/share/box-box/boxbox.db` (override with `--db`)

108
README.md
View File

@@ -1,92 +1,66 @@
# 🏎️ box-box # box-box
![Status-Pre-Beta](https://img.shields.io/badge/Status-Pre--Beta-yellow?style=for-the-badge) > "Box, box. Box, box." Every F1 race engineer, ever.
> "Box, box. Box, box." — Every F1 Race Engineer, ever. **box-box** is an unofficial F1 race-weekend command center: live timing, Race Hub analytics, championship context, paddock briefing feeds, and local historical data in one Go + React app, with a preserved Bubble Tea TUI.
**⚠️ Disclaimer: This project is currently in a pre-beta state.** Features may be incomplete, and you might encounter bugs as we fine-tune the engine. Live demo: [box-box.amantahiliani.com](https://box-box.amantahiliani.com/)
**box-box** is a high-performance Formula 1 Terminal User Interface (TUI) built for fans who live in the command line. Get real-time standings, race calendars, and deep-dive driver stats without ever leaving your terminal. ![box-box Command Center](docs/assets/command-center.jpg)
![box-box TUI Preview](https://img.shields.io/badge/UI-Bubble%20Tea-00ADD8?style=for-the-badge&logo=go) ## What It Does
![Data-OpenF1](https://img.shields.io/badge/Data-OpenF1-FF1801?style=for-the-badge)
## ✨ Features - **Command Center**: current race-weekend home with GP identity, live status, schedule, championship leaders, and direct analysis links.
- **Race Hub**: session workspace for overview, race story, strategy, laps, weather, race control, and dataset coverage.
- **Live Timing**: official F1 SignalR feed bridged through the Go server to the browser via SSE.
- **Championship View**: standings, form, teammate context, cumulative points, and a simulator.
- **Paddock Briefing**: RSS/Atom news ingestion for a local race-weekend briefing surface.
- **Local-first history**: OpenF1 data ingested into a SQLite domain database for fast historical browsing.
- **Terminal Mode**: Bubble Tea TUI with standings, calendar, driver profiles, live timing, track map, battles, pit window, and replay.
- 🏆 **Live Standings**: Keep track of the Driver and Constructor Championships. ## Quickstart
- 📅 **Race Calendar**: The full 2025 schedule at your fingertips.
- 🏎️ **Race Details**: Deep dive into session results, starting grids, and lap data.
- 👤 **Driver Profiles**: Detailed stats for every driver on the grid.
- 🔴 **Official Live Timing**: Real-time F1 timing tower via the official SignalR feed — gaps, intervals, tyre age, sector times, DRS, and track status.
- ⚔️ **Battle Tracker**: Auto-detects on-track duels within DRS range with gap sparklines and tyre strategy comparison.
- 🔧 **Pit Window Calculator**: Predicts rejoin position if a driver pits now, using per-circuit pit loss times.
-**Race Replay**: Lap-by-lap scrubber for completed races — relive the whole field's evolution with pit annotations and race control messages.
- 🗺️ **ASCII Track Map**: Live car positions on a terminal-rendered track outline, team-coloured.
- 🔌 **Offline-ish**: Fast, lightweight, and powered by the wonderful [OpenF1 API](https://openf1.org).
## 🚀 Quick Start
### Prerequisites
- [Go](https://go.dev/doc/install) 1.21 or higher.
### Installation
```bash ```bash
# Clone the repository
git clone https://github.com/AmanTahiliani/box-box.git git clone https://github.com/AmanTahiliani/box-box.git
cd box-box cd box-box
# Build and run npm install
go run cmd/main.go npm install --prefix frontend
npm run build --prefix frontend
go run ./cmd/main.go --web
# http://localhost:8080
``` ```
## 🎮 Controls For a local frontend development loop with seeded data, see [docs/getting-started.md](docs/getting-started.md).
| Key | Action | ## Project Shape
| Area | What lives there |
| --- | --- | | --- | --- |
| `1` | Switch to **Home** | | `cmd/main.go` | Entry point for TUI, web server, and ingestion CLI |
| `2` | Switch to **Standings** | | `internal/web/` | Go REST API, SSE live bridge, SPA serving |
| `3` | Switch to **Calendar** | | `internal/live/` | Official F1 SignalR client shared by Web and TUI |
| `4` | Switch to **Race Details** | | `internal/store/`, `internal/ingest/`, `internal/query/` | Local SQLite domain database, ingestion, and read models |
| `5` | Switch to **Drivers** | | `internal/ui/` | Bubble Tea TUI |
| `6` | Switch to **Live Timing** | | `frontend/` | React + Vite + TypeScript web app |
| `7` | Switch to **Track Map** | | `tests/` | Playwright e2e and visual coverage |
| `tab` / `shift+tab` | Next / Previous tab |
| `j`/`↓` | Navigate down |
| `k`/`↑` | Navigate up |
| `enter` | Select/Inspect item |
| `b` / `esc` | Go back / collapse |
| `s` | Toggle sector times (Live tab) |
| `b` | Toggle Battle Tracker (Live tab) |
| `p` | Toggle Pit Window Calculator (Live tab) |
| `r` | Enter Race Replay (Race Detail tab, Race sessions) |
| `←`/`h` · `→`/`l` | Scrub laps in Replay |
| `y` | Cycle season year |
| `q` / `ctrl+c` | Exit |
## 🛠️ Tech Stack The Web UI is local-first and should call the Go APIs under `/api/v1/...`; it should not read OpenF1 directly.
- **[Bubble Tea](https://github.com/charmbracelet/bubbletea)**: The TUI engine. ## Documentation
- **[Lipgloss](https://github.com/charmbracelet/lipgloss)**: For that sleek F1 styling.
- **[Bubbles](https://github.com/charmbracelet/bubbles)**: Common TUI components.
- **[OpenF1 API](https://api.openf1.org)**: The data source (Free, no API key needed).
## 🚥 Development - [Getting Started](docs/getting-started.md): install, build, run modes, TUI keybindings, and web routes.
- [Data and Operations](docs/data-and-operations.md): ingestion, environment variables, local files, and live timing notes.
- [Testing](docs/testing.md): Go, frontend, e2e, and visual regression commands.
- [Architecture Notes](documentations/refactor/README.md): deeper design rationale, data-source decisions, and phase history.
Want to tinker under the hood? ## Status
```bash Pre-beta and actively developed. The Web UI is the primary surface; the TUI is preserved and still useful for terminal workflows. Live timing depends on F1 broadcasting timing data, so it is only fully active during live sessions.
# Run tests
go test ./...
# View API integration tests (requires internet) ## License
go test -v ./internal/api
```
## 📜 License
MIT © [Aman Tahiliani](https://github.com/AmanTahiliani) MIT © [Aman Tahiliani](https://github.com/AmanTahiliani)
--- *Unofficial project; not associated with Formula 1 or the FIA.*
*Disclaimer: This project is unofficial and not associated with Formula 1 or the FIA in any way.*

View File

@@ -1,13 +1,19 @@
package main package main
import ( import (
"context"
"flag" "flag"
"fmt" "fmt"
"log" "log"
"net/http"
"os" "os"
"strings"
"time" "time"
"github.com/AmanTahiliani/box-box/internal/api" "github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/ingest"
"github.com/AmanTahiliani/box-box/internal/news"
"github.com/AmanTahiliani/box-box/internal/store"
"github.com/AmanTahiliani/box-box/internal/ui" "github.com/AmanTahiliani/box-box/internal/ui"
"github.com/AmanTahiliani/box-box/internal/web" "github.com/AmanTahiliani/box-box/internal/web"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -16,23 +22,113 @@ import (
func main() { func main() {
webMode := flag.Bool("web", false, "Start web companion server instead of TUI") webMode := flag.Bool("web", false, "Start web companion server instead of TUI")
port := flag.Int("port", 8080, "Port for web server (used with --web)") port := flag.Int("port", 8080, "Port for web server (used with --web)")
ingestYear := flag.Int("ingest-year", 0, "Ingest OpenF1 meetings for a season year")
backfillSeason := flag.Int("backfill-season", 0, "Trigger full-season backfill/deep-ingestion for the given year")
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")
dbPath := flag.String("db", "", "Domain database path (default: ~/.local/share/box-box/boxbox.db)")
flag.Parse() flag.Parse()
if *coverageYear != 0 {
if err := runCoverageReport(*coverageYear, *dbPath); err != nil {
fmt.Fprintf(os.Stderr, "coverage report error: %v\n", err)
os.Exit(1)
}
return
}
// BOXBOX_OPENF1_BASE_URL overrides the upstream OpenF1 API root. E2E runs
// point it at an unreachable address so tests stay hermetic and
// deterministic regardless of wall-clock date or network state.
baseURL := os.Getenv("BOXBOX_OPENF1_BASE_URL")
if baseURL == "" {
baseURL = "https://api.openf1.org"
}
var client *api.OpenF1Client var client *api.OpenF1Client
if apiKey := os.Getenv("OPENF1_API_KEY"); apiKey != "" { if apiKey := os.Getenv("OPENF1_API_KEY"); apiKey != "" {
client = api.NewOpenF1ClientWithKey("https://api.openf1.org", 15*time.Second, apiKey) client = api.NewOpenF1ClientWithKey(baseURL, 15*time.Second, apiKey)
} else { } else {
client = api.NewOpenF1Client("https://api.openf1.org", 15*time.Second) client = api.NewOpenF1Client(baseURL, 15*time.Second)
} }
defer client.Close() defer client.Close()
// Clean up old file-based cache (one-time migration). // Clean up old file-based cache (one-time migration).
go api.CleanupOldFileCache() go api.CleanupOldFileCache()
ingestFlags := 0
if *ingestYear != 0 {
ingestFlags++
}
if *backfillSeason != 0 {
ingestFlags++
}
if *ingestMeeting != 0 {
ingestFlags++
}
if *ingestSession != 0 {
ingestFlags++
}
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, --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)
os.Exit(1)
}
return
}
yearVal := *ingestYear
if *backfillSeason != 0 {
yearVal = *backfillSeason
}
if err := runIngestion(client, yearVal, *ingestMeeting, *ingestSession, *force, *dryRun, *dbPath); err != nil {
fmt.Fprintf(os.Stderr, "box-box ingest error: %v\n", err)
os.Exit(1)
}
return
}
if *webMode { if *webMode {
log.SetOutput(os.Stderr) // web mode logs to stderr, not file log.SetOutput(os.Stderr) // web mode logs to stderr, not file
fmt.Printf("box-box web → http://localhost:%d\n", *port) fmt.Printf("box-box web → http://localhost:%d\n", *port)
srv := web.NewServer(client, *port)
var domainStore *store.Store
db := *dbPath
if db == "" {
db = store.DefaultDBPath()
}
if st, err := store.Open(db); err != nil {
log.Printf("web: domain database unavailable (%s): %v", db, err)
} else {
domainStore = st
defer domainStore.Close()
}
srv := web.NewServer(client, *port, domainStore)
log.Fatal(srv.Start()) log.Fatal(srv.Start())
return return
} }
@@ -56,3 +152,217 @@ func main() {
os.Exit(1) os.Exit(1)
} }
} }
func runIngestion(client *api.OpenF1Client, year, meetingKey, sessionKey int, force, dryRun bool, dbPath string) error {
log.SetOutput(os.Stderr)
path := dbPath
if path == "" {
path = store.DefaultDBPath()
}
st, err := store.Open(path)
if err != nil {
return fmt.Errorf("open domain database: %w", err)
}
defer st.Close()
opts := ingest.DefaultOptions()
opts.DryRun = dryRun
opts.Force = force
opts.Progress = ingest.NewProgress(os.Stderr)
svc := ingest.NewService(st, ingest.NewOpenF1Source(client), opts)
switch {
case year != 0:
_, err = svc.IngestYear(year)
case meetingKey != 0:
_, err = svc.IngestMeeting(meetingKey)
case sessionKey != 0:
_, err = svc.IngestSession(sessionKey)
}
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)
path := dbPath
if path == "" {
path = store.DefaultDBPath()
}
var st *store.Store
if !dryRun {
var err error
st, err = store.Open(path)
if err != nil {
return fmt.Errorf("open domain database: %w", err)
}
defer st.Close()
}
if dryRun {
fmt.Fprintf(os.Stderr, "news: dry run, not writing to %s\n", path)
} else {
fmt.Fprintf(os.Stderr, "news: refreshing feeds into %s\n", path)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
result, err := news.Refresh(ctx, st, news.RefreshOptions{
Client: &http.Client{Timeout: 10 * time.Second},
DryRun: dryRun,
Progress: os.Stderr,
EnrichOG: !dryRun,
})
fmt.Fprintf(
os.Stderr,
"news: %d source(s) fetched, %d failed, %d item(s) fetched, %d upserted\n",
result.SourcesFetched,
result.SourcesFailed,
result.ItemsFetched,
result.ItemsUpserted,
)
return err
}
func runCoverageReport(year int, dbPath string) error {
path := dbPath
if path == "" {
path = store.DefaultDBPath()
}
st, err := store.Open(path)
if err != nil {
return fmt.Errorf("open domain database: %w", err)
}
defer st.Close()
rows, err := st.GetSeasonCoverage(year)
if err != nil {
return fmt.Errorf("get season coverage: %w", err)
}
if len(rows) == 0 {
fmt.Printf("No session coverage data found for year %d.\n", year)
return nil
}
type datasetStatus struct {
Status string
Count int
}
type sessionInfo struct {
MeetingName string
SessionName string
SessionKey int
Datasets map[string]datasetStatus
}
var sessions []sessionInfo
sessionMap := make(map[int]int)
for _, row := range rows {
idx, exists := sessionMap[row.SessionKey]
if !exists {
idx = len(sessions)
sessions = append(sessions, sessionInfo{
MeetingName: row.MeetingName,
SessionName: row.SessionName,
SessionKey: row.SessionKey,
Datasets: make(map[string]datasetStatus),
})
sessionMap[row.SessionKey] = idx
}
if row.Dataset != "" {
sessions[idx].Datasets[row.Dataset] = datasetStatus{
Status: row.Status,
Count: row.RowCount,
}
}
}
fmt.Printf("\n--- Season %d Coverage Report ---\n\n", year)
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))
for _, sess := range sessions {
statusChar := func(ds string) string {
dsStatus, ok := sess.Datasets[ds]
if !ok {
return "."
}
switch dsStatus.Status {
case "complete":
return "✓"
case "failed":
return "✗"
default:
return "."
}
}
nameCol := fmt.Sprintf("%s - %s (%d)", sess.MeetingName, sess.SessionName, sess.SessionKey)
if len(nameCol) > 35 {
nameCol = nameCol[:32] + "..."
}
fmt.Printf("%-35s | %-5d | %s | %s | %s | %s | %s | %s | %s | %s | %s\n",
nameCol,
sess.SessionKey,
statusChar("drivers"),
statusChar("session_result"),
statusChar("starting_grid"),
statusChar("stints"),
statusChar("pit_stops"),
statusChar("positions"),
statusChar("race_control"),
statusChar("weather"),
statusChar("laps"),
)
}
fmt.Println(strings.Repeat("-", 82))
fmt.Println("\nLegend:")
fmt.Println(" [✓] Complete [✗] Failed [.] Pending/Unattempted")
fmt.Println("Datasets:")
fmt.Println(" DR: drivers SR: session_result SG: starting_grid")
fmt.Println(" ST: stints PS: pit_stops PO: positions")
fmt.Println(" RC: race_control WE: weather LA: laps")
fmt.Println()
return nil
}

198
docs/PRODUCT_ROADMAP.md Normal file
View File

@@ -0,0 +1,198 @@
# box-box — Product & UX Roadmap
> An F1-geek + product/UX analysis of box-box as an F1 companion, and a
> prioritized plan to make it richer than "data in tables" — for both live race
> weekends and the quiet stretch between rounds.
## Where the app is today (honest read)
box-box has a strong data layer with two front doors (TUI + web) and six web
surfaces:
| Surface | What it does today | Where it's thin |
|---|---|---|
| **Command Center** (`/`) | Hero weekend + countdown, season calendar, championship snapshot, weekend schedule, news | Strong launcher, but tells you *when*, rarely *what to care about*. It's a menu, not a companion |
| **Race Hub** (`/race-hub`) | Per-session tabs: Overview, Race Story (position-evolution scrubber), Strategy (stints), Laps, Conditions, Race Control | `RaceStoryCanvas` is the best "beyond tables" work in the app. Everything else is still tables |
| **Live Timing** (`/live`) | SSE timing tower, battle chips, gap sparklines, pinned drivers, RC feed | Solid tower, but it's "a spreadsheet that updates" — no track map, no telemetry, no "what just happened" |
| **Championship** (`/championship`) | Standings + hub stats (wins/poles/form) + points simulator | Good; the simulator is a genuine differentiator |
| **Briefing** (`/briefing`) | 7 RSS feeds (FIA, BBC, Autosport, RaceFans, Guardian, RACER, F1 YouTube) + readability extraction | Passive reading list, not tied to the season narrative |
| **Data Library** (`/admin`) | Ingestion / coverage admin | Fine as-is |
**Biggest finding:** the app already *pulls* the richest data in F1 — GPS
`Location`, full car telemetry (throttle / brake / DRS / gear / rpm / speed),
team-radio audio, mini-sector `Segments`, speed traps, overtakes — and the web
app visualizes almost none of it. The **track map exists only in the TUI**
(`internal/ui/trackmap.go`); telemetry is fetched and shown to nobody. This is a
large latent asset.
## Core product thesis
An F1 companion serves two different jobs; the app currently treats them the same
(data, in tables, per session):
1. **On a race weekend — the "second screen."** The user is watching the
broadcast (or can't, and wants to *feel* it). They want: what's happening now,
why it matters, what to watch next. The broadcast supplies emotion; box-box
should supply **the data the broadcast doesn't show** — the delta the director
cut away from, the undercut developing, the tyre cliff approaching.
2. **Between races — "understand the season."** No live action. The user wants to
make sense of what happened and anticipate what's next: rewatch the story of
the last race, argue strategy, track the title fight, get smart before the next
round.
Everything below makes each job *feel* like a companion instead of a database.
---
## Race-weekend experience ("second screen")
### 1. Live Track Map — highest-leverage missing feature
Port the TUI's GPS outline (`trackmap.go`, `GetLocation`) to a web SVG dot-map:
cars as team-colored dots on the circuit outline, DRS zones highlighted, sectors
tinted by status. Turn mini-sectors purple/green live using the `Segments` data
already fetched. Tap a car → mini-telemetry readout. This is what turns "updating
spreadsheet" into "I'm watching the race."
### 2. "What just happened" synthesized event rail — the companion voice
Combine Race Control + overtakes + pit stops + position deltas into plain-English
beats: *"LAP 34 — VER pits (2.4s), rejoins P4 behind NOR — undercut on RUS is
live."* The inputs all exist; this is a synthesis layer, not new data. It's the
difference between *data* and *commentary*.
### 3. Telemetry compare overlay
On the tower / a driver panel, pick two drivers → overlaid speed/throttle/brake
traces + delta-time graph for their last comparable lap. *The* tifosi feature —
"where did Leclerc lose the lap." `/api/v1/laps/comparison` + car data already
exist; this needs a chart, not a table.
### 4. Live strategy / tyre-degradation view
Stints + tyre age + pit-lane times → live pit window and tyre-cliff panel, with
**undercut/overcut threat** indicators from gap-to-car-behind vs. pit-loss time.
The pit-window calculator exists in the TUI (`pitwindow.go`); bring it to the web
and make it live.
### 5. Team radio, surfaced
`GetTeamRadio` returns audio clips. Add a "Radio" ticker on the live page — play
button + driver + timestamp. Peak emotional content, and no rival dashboard has it
inline.
### 6. Session-aware Command Center
When a session is live, the home hero should pull the top battles, leader gap, and
last RC flag onto the front page instead of only saying "LIVE." Make the front
page reactive to the moment.
---
## Between-races experience ("understand the season")
### 7. Race replay as a first-class story
Grow `RaceStoryCanvas` from a scrubber into a narrative replay: auto-generated
"chapters" (start, first pit phase, VSC, decisive overtake, finish) with a
headline each (derived from RC + position swings), and scrub the position graph
and track map together. The "watch the race in 90 seconds" mode that makes people
open the app on a Tuesday.
### 8. Driver pages + rivalry view (a real gap)
No driver profile exists in the web app. Add a **driver page** (season form,
teammate H2H — already computed in the champ hub — quali vs. race pace, tyre
management, track-by-track) and a **rivalry view** (two drivers → cumulative
points, H2H, gap-over-season). Feeds the argument every fan has.
### 9. Next-race preview / "get smart" page
Between races the app goes quiet. Fill it: circuit characteristics, last year's
result (2023/24 is cached), typical strategy (1 vs 2 stop), DRS zones, weather
outlook, and the storylines (title-fight math to watch). Turn dead air into
anticipation.
### 10. Championship scenario narratives
Extend the simulator from "drag points around" to narrative permutations:
*"VER clinches if he outscores NOR by 9 this weekend"* / *"first race McLaren can
seal constructors'."* The stuff fans actually search for.
### 11. Briefing → calendar-aware season digest
Reframe the news reader into a paddock digest tied to the calendar: group by GP,
tag by team/driver, surface a "since last race" summary. Optionally an
LLM-generated weekly briefing (a `claude-haiku` summarization pass over ingested
RSS — cheap and on-brand).
---
## New / better data sources
- **Jolpica (Ergast successor, `api.jolpi.ca`)** — free historical results back to
1950: qualifying, pit stops, lap times, circuit metadata. Unlocks all-time
records, "best-ever at this track," and career stats OpenF1 (2023+) can't give.
High value for driver pages and the "get smart" preview.
- **OpenF1 weather timeseries** — already fetched; plot it as a session-long strip
(track temp / rain / wind) instead of a table. Weather narrates strategy.
- **Circuit metadata / DRS zones / corner names** — enriches the track map and
previews. Some is in OpenF1 circuit info; curate the rest once as static data.
- **Static per-race context** — a tiny curated JSON per round (tyre allocation,
notable stats) goes a long way for previews.
---
## Cross-cutting UX principles
1. **Replace tables with a shape wherever a shape carries the meaning.** Lap times
→ a trace with fastest lap marked. Stints → a horizontal tyre timeline. Gaps →
the sparkline already shipped. Keep tables only where data is genuinely tabular
(standings) — but annotate them.
2. **Always answer "so what?"** Every number sits next to its meaning (a gap next
to "undercut live," a tyre age next to "5 laps from the cliff").
3. **One primary "moment" per screen.** The Command Center should always have a
single obvious "here's what to watch/do now."
4. **Make between-races feel alive.** The app currently rewards you only on
Sundays. Previews, digests, replays, and rivalries give a reason to open it
midweek.
---
## Suggested sequencing (impact × effort)
**Phase 1 — turn live into a companion (highest impact; data already in hand)**
1. Web track map (SVG + GPS + mini-sectors)
2. Telemetry compare overlay (speed/throttle/brake + delta)
3. "What just happened" synthesized event rail
4. Team radio ticker
**Phase 2 — own the between-races window**
5. Narrative race replay (grow `RaceStoryCanvas`)
6. Driver pages + rivalry view
7. Next-race preview page
**Phase 3 — depth & reach**
8. Jolpica/Ergast historical integration + all-time records
9. Championship scenario narratives
10. Calendar-aware briefing digest (optional LLM summaries)
---
## Execution tracking (GitHub)
This roadmap is tracked on GitHub:
- **Project board:** https://github.com/users/AmanTahiliani/projects/2 ("box-box Roadmap")
- **Epics:** issues #2#8 (label `epic`), one per epic above, on Phase milestones
- **Stories:** issues #9#31, wired as native **sub-issues** under their epic (progress rolls up automatically)
- **Labels:** `epic`, `enabler`, `research`, `area:{live,viz,between-races,championship,data,ux}`
- **Milestones:** `Phase 1 — Live Companion`, `Phase 2 — Between-Races`, `Phase 3 — Depth & Reach`
**Board fields:** `Stage` (Icebox → Research → Ready → In Progress → In Review → Done),
`Priority` (P0P2), `Effort` (S/M/L), `Phase` (13).
**Working model — active vs. bank:**
- Only **12 epics active** at a time (currently **E1 Live Race Companion** + **E2 Viz
Primitives**); the other five epics are the theme-level idea bank.
- `Stage = Icebox` is the story-level bank. The 10 E1/E2 stories are seeded to
`Ready`; everything else is `Icebox`. Promote a handful to `Ready`/`In Progress`
per cycle; use `Research` to scope a vague idea before it's `Ready`.
**Board views** (built):
- **Backlog** — Table, all epics with expandable sub-issues: the full bank.
- **Board** — grouped by `Stage`, filtered `-stage:Icebox`: the active-WIP wall.
- **Roadmap** — Table grouped by `Phase` (Phase 1/2/3). Note: this is a
phase-grouped table, not a timeline. A true timeline roadmap needs a date or
iteration field (GitHub won't draw/persist a roadmap layout without one); add a
`Target date` field and populate it once phases have real target dates, then
switch this view to the Roadmap layout.

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -0,0 +1,75 @@
# Data and Operations
## Data Flow
| Layer | Role |
| --- | --- |
| OpenF1 REST | Backfill and ingestion source; optional paid tier via `OPENF1_API_KEY`. Also powers the TUI's on-demand reads and HTTP cache. |
| Domain SQLite (`boxbox.db`) | Local store for meetings, sessions, Race Hub datasets, and navigation APIs used by the Web UI. |
| HTTP cache SQLite (`cache.db`) | TTL cache for OpenF1 responses used by the TUI and legacy paths. Separate from the domain DB. |
| Official F1 SignalR | Live timing bridge in `internal/live`, exposed to Web via SSE and to the TUI. |
| Go server | `cmd/main.go`: TUI, `--web` API + static SPA, or CLI ingestion. |
| React frontend | `frontend/`: production build served from `frontend/dist` when present. |
The Web UI should call local-first Go APIs under `/api/v1/...`; do not add direct OpenF1 reads in the frontend.
## Ingest Historical Data and Briefing Feeds
Ingestion is a CLI mode on the same binary. Only one of `--ingest-year`, `--ingest-meeting`, `--ingest-session`, or `--ingest-news` may be set per run.
```bash
# Season: discover and store meeting metadata for 2023+
go run ./cmd/main.go --ingest-year 2025
# Full race weekend: all sessions + Race Hub datasets
go run ./cmd/main.go --ingest-meeting 1229
# Single session only
go run ./cmd/main.go --ingest-session 9472
# Preview without writing
go run ./cmd/main.go --dry-run --ingest-meeting 1229
# Refresh Paddock Briefing RSS/Atom feeds
go run ./cmd/main.go --ingest-news
# Custom DB path
go run ./cmd/main.go --ingest-meeting 1229 --db /tmp/boxbox.db
```
Use `--ingest-meeting` for a complete weekend. `--ingest-year` stores season meetings, not full session datasets. Optional analytics fetches may partially fail without aborting the whole run.
## Environment Variables
| Variable | Purpose |
| --- | --- |
| `OPENF1_API_KEY` | Optional Bearer token for paid OpenF1 behavior. |
| `BOXBOX_DISABLE_LIVE=1` | Skip the background SignalR live feed in web mode. Used by CI and local seeded UI work. |
| `BOXBOX_OPENF1_BASE_URL` | Override the OpenF1 API root. Defaults to `https://api.openf1.org`. |
| `BOXBOX_API_PORT` | Go API port used by the Vite dev proxy. Defaults to `8080`. |
Example:
```bash
export OPENF1_API_KEY=your_key_here
go run ./cmd/main.go --web
```
## Local Files
| Path | Purpose |
| --- | --- |
| `~/.local/share/box-box/boxbox.db` | Domain database, default `--db`. |
| `~/.cache/box-box/cache.db` | OpenF1 HTTP response cache for the TUI and client. |
| `box-box.log` | TUI application log in the project root. |
| `frontend/dist/` | Production React build. Generated output, do not commit. |
| `.playwright/*.db` | Seeded databases for automated tests. |
Web mode logs to stderr.
## Known Limitations
- Live timing only works when F1 is broadcasting timing data; there is no guaranteed live session for local development.
- E2E and visual tests use `BOXBOX_DISABLE_LIVE=1` and seeded SQLite, so they do not exercise full SignalR live behavior.
- `frontend/dist` is generated output; build before production web mode or `test:e2e:prod`.
- TUI historical views still use OpenF1 on demand with the HTTP cache; the Web UI's local-first model does not fully replace the TUI yet.

94
docs/getting-started.md Normal file
View File

@@ -0,0 +1,94 @@
# Getting Started
## Prerequisites
- [Go](https://go.dev/doc/install), using the module version in `go.mod`.
- [Node.js](https://nodejs.org/) 18+ and npm.
- Internet access for ingestion and TUI OpenF1 calls.
- For e2e and visual tests: `npx playwright install` after `npm install` at the repo root.
## Install
```bash
git clone https://github.com/AmanTahiliani/box-box.git
cd box-box
npm install # Playwright and repo-level test scripts
npm install --prefix frontend # Vite + React app
```
## Build
```bash
go build -o box-box ./cmd/main.go
npm run build --prefix frontend # writes frontend/dist
```
## Run: Web
Build the frontend first, then start web mode. Go walks up from the current directory to find `frontend/dist/index.html`; if missing, it serves embedded legacy assets.
```bash
npm run build --prefix frontend
go run ./cmd/main.go --web
# http://localhost:8080
```
Use a specific domain database or port:
```bash
go run ./cmd/main.go --web --db ~/.local/share/box-box/boxbox.db --port 8080
```
## Run: Web Dev
Vite proxies `/api` to the Go server. Set `BOXBOX_API_PORT` to match the Go `--port`.
Terminal 1: API with a seeded database:
```bash
go run ./scripts/seed-e2e-db/main.go --db /tmp/boxbox-dev.db
BOXBOX_DISABLE_LIVE=1 go run ./cmd/main.go --web --db /tmp/boxbox-dev.db --port 18080
```
Terminal 2: frontend:
```bash
BOXBOX_API_PORT=18080 npm run dev --prefix frontend
# http://localhost:5173
```
`BOXBOX_DISABLE_LIVE=1` skips starting the SignalR bridge, which is useful for CI and local UI work.
## Run: TUI
```bash
go run ./cmd/main.go
# or: ./box-box
```
Logs go to `box-box.log` in the project directory so the terminal stays clean.
## TUI Keybindings
| Key | Action |
| --- | --- |
| `1`-`7` | Home, Standings, Calendar, Race Detail, Drivers, Live, Track Map |
| `tab` / `shift+tab` | Next / previous tab |
| `j`/`k`, `enter`, `b`/`esc` | Navigate, select, back |
| `s`, `b`, `p` | Live: sectors, battles, pit window |
| `r` | Race replay in Race Detail race sessions |
| `y` | Cycle season year |
| `q` / `ctrl+c` | Quit |
## Web Routes
| Route | Purpose |
| --- | --- |
| `/` | Command Center: race-weekend home with GP identity, live status, schedule, and analysis links |
| `/race-hub?session_key=<key>` | Race Hub: session workspace with Overview, Race Story, Strategy, Lap Data, Conditions, Race Control, and Data Status tabs |
| `/admin` | Admin / Data Health: ingestion coverage, local data status, and suggested CLI commands |
| `/data-library` | Legacy alias for Admin / Data Health |
| `/live` | Live Timing: timing tower and race control via SSE when a session is live |
Example after seeding: `http://localhost:5173/race-hub?session_key=9472`.

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

59
docs/testing.md Normal file
View File

@@ -0,0 +1,59 @@
# Testing
## Go
Targeted offline-ish packages:
```bash
go test ./internal/live ./internal/models ./internal/store ./internal/ingest ./internal/query ./internal/web
```
All packages:
```bash
go test ./...
```
OpenF1 integration tests require network access and are rate-limit aware:
```bash
go test -v ./internal/api
```
## Frontend Unit Tests and Build
```bash
npm --prefix frontend test -- --run
npm --prefix frontend run build
```
## E2E
The default Playwright config starts a seeded Go server on port `18080` and Vite on `15173`.
```bash
npx playwright install # first time only
npm run test:e2e
```
Production serving mode builds around Go serving `frontend/dist`:
```bash
npm run test:e2e:prod
```
## Visual Regression
```bash
npm run test:visual
npm run test:visual:prod
```
After intentional UI changes:
```bash
npm run test:visual:update
npm run test:visual:prod:update
```
Snapshots live under `tests/visual/__snapshots__/`.

View File

@@ -0,0 +1,179 @@
# Data Sources
## Summary
`box-box` should treat data sources as inputs to a local product database, not as
page-level dependencies. The current app fetches too much data on demand from
OpenF1, which breaks down during free-tier lockouts and makes non-live screens
feel empty. The live mode succeeds because it uses the official F1 live timing
feed directly.
## Confirmed Sources
### OpenF1 REST API
Reference: https://openf1.org/docs/
Current usage:
- Meetings and sessions.
- Drivers.
- Championship standings.
- Session results and starting grid.
- Laps, stints, pit stops, positions, intervals.
- Race control, weather, overtakes.
- Car data, location, team radio metadata.
Strengths:
- Good historical/session data source.
- JSON over simple HTTP.
- Broad endpoint coverage.
- Query filtering by fields and time ranges.
Limitations:
- Free-tier access can be locked during live sessions.
- On-demand fetching is unreliable as a product behavior.
- API schema or access rules can change.
- High-volume endpoints can be expensive to fetch repeatedly.
Policy:
- Use OpenF1 primarily for ingestion and backfill.
- Do not make historical Web pages depend on fresh OpenF1 calls.
- Store successful fetches into the local domain database and raw payload log.
### Official F1 SignalR Live Feed
Endpoint: https://livetiming.formula1.com/signalr
Current code connects to the old ASP.NET SignalR protocol, negotiates a
connection token, opens a websocket, and subscribes to the `Streaming` hub.
Current subscribed topics:
- `Heartbeat`
- `TimingData`
- `DriverList`
- `LapCount`
- `ExtrapolatedClock`
- `TrackStatus`
- `RaceControlMessages`
- `WeatherData`
- `SessionInfo`
- `CurrentTyres`
- `TimingAppData`
- `TimingStats`
Strengths:
- Best current source for live timing.
- Provides race-control updates quickly.
- Avoids OpenF1 REST lockout during active sessions.
- Powers the strongest part of the existing app.
Limitations:
- Payloads are less formally documented than OpenF1.
- Topic schemas can drift.
- Testing live behavior is difficult outside active sessions.
- Current parser lives in `internal/ui`, which couples live source handling to
the TUI layer.
Policy:
- Treat SignalR as the authoritative live source while a session is active.
- Extract parsing and live-state logic into reusable backend/domain code.
- Forward live state to the Web UI through SSE initially.
- Research whether live snapshots/events should be persisted.
### Existing SQLite HTTP Cache
Current location: user cache directory under `box-box/cache.db`.
Current behavior:
- Stores raw HTTP responses by URL.
- Applies TTL rules based on URL patterns.
- Can return stale responses when OpenF1 fails.
- Stores track outlines in a structured table.
Strengths:
- Useful as a fallback.
- Already integrated with the OpenF1 client.
- Reduces repeated network calls.
Limitations:
- Not a queryable domain model.
- URL keys are poor product identifiers.
- Cannot easily power analytics, replay, ingestion status, or data provenance.
- Pruning/TTL behavior is cache-oriented, not history-oriented.
Policy:
- Keep the raw cache as a fallback layer.
- Do not use it as the primary application database.
- Add a separate domain schema for product features.
## Candidate Source
### Official F1 Static Archived Timing Files
Reference:
https://livef1.goktugocal.com/livetimingf1/data_topics.html
Examples in public references include:
- `SessionInfo.json`
- `ArchiveStatus.json`
- `TrackStatus.jsonStream`
- `SessionData.json`
- `TyreStintSeries.json`
- `SessionStatus.json`
- `TimingDataF1.json`
Potential strengths:
- Could provide replay-quality archived live timing.
- May fill gaps between OpenF1 REST data and SignalR live data.
- May support historical race reconstruction.
Known uncertainties:
- Session path mapping must be researched.
- Stability and access guarantees are unclear.
- Topic schemas and file availability may vary by year/session.
- Legal and operational usage expectations need review.
Policy for now:
- Do not make core architecture depend on this source yet.
- Assign a dedicated research track to validate feasibility.
- If adopted, ingest it through the same raw-plus-normalized source pipeline.
## Source Authority Tiers
1. Local SQLite domain database.
- Primary read source for Web UI historical and completed-session data.
2. Official F1 SignalR live feed.
- Primary source during active sessions.
3. OpenF1 REST ingestion/backfill.
- Primary source for populating local historical data.
4. Optional F1 static archive source.
- Research candidate for richer replay and archived live timing.
5. Raw HTTP cache fallback.
- Last-resort resilience layer, not a product data model.
## Open Questions
- Should SignalR snapshots/events be persisted during live sessions?
- If persisted, should live data become the authoritative record for that
session or a supplemental event stream?
- Which OpenF1 endpoints are essential for v1 local-first Race Hub?
- Can static archived timing files be mapped reliably from OpenF1 sessions?
- What data should be refreshed after a session ends, and when should it become
immutable?

View File

@@ -0,0 +1,216 @@
# Backend Architecture
## Summary
The backend should move from direct page handlers calling OpenF1 into a layered
local-first architecture. Source clients fetch data, ingestion persists it,
store/query packages expose domain reads, and Web handlers return read models
with source and freshness metadata.
## Proposed Package Boundaries
### `internal/store`
Owns SQLite as the local domain database.
Responsibilities:
- Schema creation and migrations.
- Typed upsert methods for domain records.
- Typed read methods for screens and backend services.
- Raw payload storage.
- Ingestion metadata and provenance.
- Transactions and batch writes.
Non-goals:
- Calling OpenF1 directly.
- Knowing Web UI route behavior.
- Rendering derived frontend-specific structures unless they are shared read
models.
### `internal/ingest`
Coordinates backfill, refresh, and opportunistic fetches.
Responsibilities:
- Ingest year, meeting, or session.
- Fetch required endpoints through source clients.
- Persist raw payloads and normalized rows.
- Track partial successes and failures.
- Support resumable, idempotent runs.
- Respect rate limits and free-tier constraints.
Default ingestion modes:
- CLI bulk ingestion for years, meetings, and sessions.
- Opportunistic small fetches in Web mode when a user opens missing data.
- Explicit refresh mode for completed data when needed.
Rate-limit defaults:
- Bulk ingestion must be resumable and idempotent.
- Bulk ingestion should default to conservative sequential fetching with a
delay between OpenF1 requests.
- Failed requests should use bounded exponential backoff with jitter.
- HTTP 429 and live-session lockout should pause or stop the current run rather
than tight-loop retries.
- `--dry-run` should show planned datasets and estimated request count before a
large ingest.
### OpenF1 Source Client Layer
The current `internal/api` client can remain, but it should become one source
adapter rather than the main application data layer.
Responsibilities:
- Build OpenF1 URLs.
- Apply auth headers when `OPENF1_API_KEY` exists.
- Decode source payloads into source/domain structs.
- Preserve stale fallback behavior where useful.
Future direction:
- Make source fetches observable by ingestion metadata.
- Avoid direct UI route dependency on source calls.
### Live Timing Bridge
The current live parser should be extracted out of `internal/ui` into reusable
backend/domain logic.
Responsibilities:
- Connect to official F1 SignalR.
- Parse topic payloads into typed live events/state.
- Maintain current live snapshot.
- Broadcast snapshots to Web clients through SSE.
- Feed TUI live mode without coupling parser code to Bubble Tea.
- Persist live events/snapshots as an append-only stream once the bridge is
extracted.
Persistence policy:
- Live SignalR data should be stored separately from normalized post-session
OpenF1 records.
- Live data represents what was broadcast at the time, not necessarily the
corrected final historical record.
- A later reconciliation step can compare live stream data with OpenF1
post-session records.
### Web API Read Models
Web handlers should become thin adapters from query services to JSON.
Responsibilities:
- Validate route/query parameters.
- Call local-first query/read services.
- Return consistent response envelopes.
- Include source/freshness metadata.
Suggested response metadata:
- `source`: `local`, `api`, `cache`, `live`, or `missing`.
- `last_ingested_at`.
- `is_stale`.
- `missing_datasets`.
- `errors` where partial data is returned.
### CLI Ingestion Commands
CLI commands should make bulk ingestion explicit and user-controlled.
Candidate commands/flags:
- `--ingest-year 2024`
- `--ingest-meeting <meeting_key>`
- `--ingest-session <session_key>`
- `--refresh`
- `--dry-run`
CLI output should include:
- What will be fetched.
- What is already local.
- What succeeded.
- What failed.
- Whether the run is resumable.
## Local-First Read Behavior
Default rule:
1. Read from local domain DB.
2. If missing and request scope is small, optionally fetch from OpenF1.
3. Persist successful fetches.
4. Return local/read-model data with metadata.
5. If OpenF1 is unavailable, return partial local data and clear missing/stale
metadata rather than an empty page.
Examples:
- Opening a completed race with all local data should perform no OpenF1 calls.
- Opening a completed race with missing weather may opportunistically fetch only
weather.
- Opening a whole season should not silently trigger a large backfill.
- During live-session lockout, historical pages should still render from local
data.
## Opportunistic Fetch Policy
Allowed by default:
- Single meeting sessions.
- Single session results/grid/weather/race control.
- Small metadata gaps needed to render a screen.
Not allowed by default:
- Full season backfills.
- High-volume telemetry/location/car data.
- Repeated refresh loops during API lockout.
- Silent destructive refresh of completed local data.
## Migration Strategy
The existing SQLite HTTP cache should remain operational during the refactor.
The new domain database should be introduced without requiring users to delete
their current cache.
Default migration stance:
- Keep the current cache tables and stale fallback behavior intact.
- Introduce domain tables through `internal/store`.
- Prefer a separate domain database file at first if it materially reduces
migration risk; using the same SQLite file remains acceptable if table names
and migrations are carefully isolated.
- Do not attempt to transform arbitrary URL-keyed cache entries into domain rows
automatically.
- New ingestion runs should populate domain tables from fresh source fetches or
explicitly supported raw payloads.
- Web routes can migrate endpoint by endpoint from source-first to local-first.
## Failure Modes
The backend should explicitly represent:
- Local data available.
- Local data partial.
- Local data missing.
- OpenF1 locked/unavailable.
- Stale cache fallback used.
- Live feed connected/disconnected.
- Ingestion partial failure.
The Web UI should be able to show these states without guesswork.
## Open Questions
- Should API response envelopes be introduced globally or per endpoint during
migration?
- How should source schema drift be detected and surfaced?
- What is the minimum dataset required for a Race Hub to be considered
complete?

View File

@@ -0,0 +1,195 @@
# Database Design
## Summary
SQLite should become the local source of truth for historical and completed
session data. The design should store both raw source payloads and normalized
domain rows. Raw payloads preserve source fidelity and make reprocessing
possible. Normalized rows power fast product queries, analytics, and stable Web
screens.
## Storage Strategy
Use two layers:
1. Raw source storage.
- Preserve fetched payloads exactly enough to reprocess later.
- Track source, endpoint/topic, parameters, fetch time, status, and errors.
2. Normalized domain tables.
- Queryable application data keyed by F1 identifiers.
- Built from successful source payloads.
- Safe to upsert idempotently.
## Raw Payload Tables
Candidate tables:
- `source_payloads`
- `id`
- `source`
- `resource`
- `request_key`
- `url_or_topic`
- `params_json`
- `payload_json`
- `fetched_at`
- `status`
- `error`
- `schema_version`
- `ingestion_runs`
- `id`
- `scope_type`
- `scope_key`
- `started_at`
- `finished_at`
- `status`
- `refresh`
- `summary_json`
- `ingestion_items`
- `id`
- `run_id`
- `dataset`
- `meeting_key`
- `session_key`
- `status`
- `source`
- `started_at`
- `finished_at`
- `error`
## Normalized Domain Tables
Core calendar/session tables:
- `meetings`
- `sessions`
- `circuits`
Participant tables:
- `drivers`
- `session_drivers`
- `teams` or team snapshots by season/session.
Classification and standings:
- `session_results`
- `starting_grids`
- `driver_championship_standings`
- `constructor_championship_standings`
Race/session analysis:
- `laps`
- `stints`
- `pit_stops`
- `positions`
- `intervals`
- `race_control_messages`
- `weather_samples`
- `overtakes`
Telemetry and spatial data:
- `car_data_samples`
- `location_samples`
- `track_outlines`
Media metadata:
- `team_radio_messages`
Derived/read-model candidates:
- `session_dataset_status`
- `race_key_moments`
- `driver_session_summaries`
- `race_lap_snapshots`
Derived tables should be added only when query cost or UI complexity justifies
them. Start with normalized source tables and build read models in Go unless
performance argues otherwise.
## Provenance and Freshness
Each normalized dataset should be traceable to source ingestion metadata.
Track:
- Source: OpenF1, SignalR, static archive, manual, cache.
- First ingested time.
- Last ingested time.
- Last successful refresh.
- Last error.
- Completion status.
- Whether stale fallback was used.
This metadata supports the Data Library screen and makes partial data honest.
## Immutability Policy
Completed historical sessions:
- Treat as immutable after successful ingestion.
- Do not refetch unless `--refresh` is explicitly requested.
- Allow reprocessing from raw payloads if schema or read models change.
Current/future sessions:
- Treat as refreshable.
- Allow opportunistic metadata fetches.
- Avoid high-volume refreshes without explicit action.
Live sessions:
- SignalR is authoritative for live state.
- Persist live data as an append-only event/snapshot stream after the live
bridge is extracted.
- Keep live data separate from normalized post-session OpenF1 records until
reconciliation is designed.
- Treat live data as the record of what was seen during the session, not as the
corrected final historical truth.
## Migration And File Layout
The current project already creates a SQLite cache database for raw HTTP
responses. The domain database should be introduced without breaking that cache.
Default stance:
- Existing cache tables are infrastructure, not product domain state.
- New domain tables should be owned by `internal/store`.
- A separate domain DB file is the lower-risk first implementation unless a
schema design pass shows strong reasons to reuse the same file.
- If the same file is reused, domain tables must be namespaced clearly and
migrations must avoid touching the current `cache` table except through
deliberate cache work.
- Do not auto-migrate URL-keyed cache entries into domain rows.
- Use explicit ingestion to populate the new domain tables.
## High-Volume Data
High-volume tables need careful indexing and retention decisions:
- `car_data_samples`
- `location_samples`
- `positions`
- `intervals`
- `laps` for full-season analysis
Initial policy:
- Ingest high-volume telemetry only when explicitly requested.
- Keep Race Hub v1 focused on results, strategy, laps, race control, weather,
positions, and track outlines.
## Research Questions
- Exact indexes for Race Hub, Live Replay, Driver Explorer, and Standings.
- Whether `positions` and `intervals` should be downsampled or stored in full.
- Whether `car_data_samples` and `location_samples` should be optional datasets.
- How to map official F1 static archive sessions to OpenF1 `session_key`.
- Whether to use SQLite FTS for race-control/team-radio search.
- How to version schema migrations without adding unnecessary framework weight.

View File

@@ -0,0 +1,231 @@
# Web UI Product
## Summary
The Web UI should become the primary way to use `box-box`. The product should
feel like an F1 operations room: fast, dense when needed, precise, and native to
race-weekend workflows. It should work well on phone and iPad, while still
scaling into a richer desktop dashboard.
## Product Priorities
- Race-weekend first.
- Live Timing and Race Hub receive the highest polish.
- Historical pages should be local-first and reliable.
- Data availability should be visible, not mysterious.
- Density should be configurable.
- TUI live mode remains supported but does not require Web feature parity.
## Core Screens
### Command Center
Default landing screen.
Shows:
- Current or upcoming race weekend.
- Next session countdown.
- Live session state.
- Weekend schedule.
- Weather snapshot.
- Championship context.
- Local data availability.
- Shortcuts into Live Timing, Weekend, Race Hub, Standings, and Data Library.
### Season Calendar
Year-based browsing screen.
Shows:
- All meetings for the selected year.
- Round, country, circuit, date range.
- Upcoming/live/completed state.
- Local ingestion status.
- Key outcomes after completion: winner, pole, fastest lap where available.
- Filters for missing data, completed races, sprint weekends, and upcoming
rounds.
### Weekend Page
One workspace per Grand Prix weekend.
Shows:
- Meeting metadata.
- Circuit and location.
- Session cards.
- Schedule and status.
- Dataset completeness.
- Entry points into each session view.
### Race / Session Hub
Main historical analysis workspace.
For races, prioritize the strategy story:
- Final classification.
- Starting grid and grid delta.
- Stint chart with compounds and pit stops.
- Safety car and VSC overlays.
- Position evolution.
- Lap-time comparison.
- Race-control timeline.
- Weather timeline.
- Driver race execution summaries.
- Replay scrubber with lap-by-lap standings and events.
For practice and qualifying:
- Classification.
- Best laps and sector breakdown.
- Lap progression.
- Driver comparison.
- Session events and weather context.
### Live Timing
Primary active-session screen.
Shows:
- Timing tower.
- Session clock, lap count, and track status.
- Position, gap, interval, tyre, tyre age, pit state.
- Last lap, best lap, sector state, DRS/track status where available.
- Race-control messages.
- Battles.
- Pit window predictions.
- Pinned drivers.
- Visual in-app alerts.
### Live Track View
Initially a mode inside Live Timing.
Shows:
- Circuit outline.
- Live car positions.
- Team/driver coloring.
- Selected/pinned driver focus.
- Mini timing list.
- Track/flag context where available.
### Drivers
Driver explorer.
Shows:
- Current season driver list.
- Driver profile data.
- Team, number, and headshot where available.
- Season points and trend.
- Race-by-race result table.
- Teammate comparison.
- Tyre/stint tendencies.
- Live pinned-driver mode during active sessions.
### Standings
Championship context screen.
Shows:
- Driver standings.
- Constructor standings.
- Points gaps.
- Movement since previous race.
- Race-by-race points accumulation.
- What changed after a selected Grand Prix.
### Data Library
Local data transparency screen.
Shows:
- Seasons available locally.
- Weekend and session dataset completeness.
- Missing datasets.
- Last ingested timestamps.
- Source/staleness state.
- Suggested ingestion commands.
- API lockout and stale cache explanations.
### Settings
Local app preferences.
Shows:
- Density mode.
- Theme accents.
- Preferred season.
- Pinned drivers.
- API key status.
- Data/cache path.
- Live alert preferences.
## Navigation Model
Primary flow:
```text
Season -> Weekend -> Session / Race Hub
```
Live shortcut:
```text
Command Center -> Live Timing -> Track / Battles / Pit Window / Race Control
```
Data/support flow:
```text
Data Library -> ingestion status / missing data
```
Candidate routes:
- `/`
- `/season/:year`
- `/weekend/:meetingKey`
- `/session/:sessionKey`
- `/live`
- `/drivers`
- `/drivers/:driverNumber`
- `/standings/:year`
- `/data`
- `/settings`
## Responsive Expectations
Phone:
- Stacked panels.
- Sticky session/status header.
- Bottom navigation.
- Swipeable live panels.
- Compact timing rows.
iPad:
- Split-pane layout.
- Timing plus side panel.
- Touch-friendly controls.
- Comfortable chart inspection.
Desktop:
- Dense multi-column operations layout.
- Persistent side panels.
- More simultaneous context.
Density modes should influence row height, visible columns, chart spacing, and
panel compactness.

View File

@@ -0,0 +1,152 @@
# Frontend Stack
## Summary
The next Web UI should move from embedded Alpine/static assets to a real React
application. The target is a local-first, data-heavy, live-updating race
dashboard served by the Go backend.
## Current Web UI
Current stack:
- Go `net/http` server.
- Go `embed.FS` static assets.
- Plain HTML/CSS/JavaScript.
- Alpine.js from CDN.
- D3.js from CDN.
- Hash routing.
- Raw `fetch`.
- `EventSource` for live SSE.
- No frontend build system.
- No TypeScript.
- No package-managed frontend dependencies.
This is a good prototype shape but not a good long-term foundation for the
planned Web UI.
## Recommended Stack
### Vite
Purpose:
- Frontend dev server.
- Fast TypeScript build.
- Production asset bundling.
- Clean integration with Go embedded static assets.
### React
Purpose:
- Component model for complex screens.
- Good fit for live timing, charts, tables, filters, replay controls, and
persistent interaction state.
### TypeScript
Purpose:
- Stronger contracts for OpenF1, local API, and live timing payloads.
- Safer refactors.
- Better developer experience across data-heavy UI.
### TanStack Query
Purpose:
- Server-state fetching and caching.
- Loading/error/stale states.
- Background refresh.
- Clear handling of local DB data, API fallback, and partial data.
### Router
Preferred candidates:
- TanStack Router for stronger type safety.
- React Router if simplicity and familiarity matter more.
Routes should model product workflows rather than mimic current hash routing.
### D3
Purpose:
- Bespoke F1 visuals:
- Strategy charts.
- Track maps.
- Position evolution.
- Lap-time comparison.
- Gap history.
- Telemetry traces.
D3 should be used where the visual is genuinely custom. Simpler chart libraries
can be considered later for generic charts.
### Zustand
Optional.
Purpose:
- Local UI preferences and cross-screen client state:
- Pinned drivers.
- Density mode.
- Selected comparison drivers.
- Visible live panels.
- Replay speed.
Avoid adding it until React state and URL state become awkward.
### Testing
Vitest:
- Formatting helpers.
- Data transforms.
- Race calculations.
- Chart input shaping.
Playwright:
- Page routing.
- Race Hub rendering.
- Live SSE behavior with mocked events.
- Responsive layouts.
- Data Library states.
## Why Not Astro As The App Shell
Astro is excellent when pages are mostly static and only specific islands need
JavaScript. `box-box` is primarily an interactive application:
- Live timing updates.
- SSE streams.
- Dense tables.
- Replay scrubbers.
- Driver pinning.
- Interactive charts.
- Local-first data states.
Astro could wrap React islands, but most important screens would become React
islands anyway. That adds split architecture without much benefit for this app.
Astro may still be useful for:
- Public docs.
- A marketing/project site.
- Static release notes.
For the product UI, Vite + React + TypeScript is the cleaner fit.
## Build Integration
Target behavior:
- During frontend development, Vite serves the React app.
- During normal `go run cmd/main.go --web`, Go serves compiled frontend assets.
- The backend remains responsible for SQLite, ingestion, OpenF1, SignalR, REST,
and SSE.

View File

@@ -0,0 +1,123 @@
# Visual Design Direction
## Summary
The visual direction should be F1-native without falling into generic dashboard
habits. The app should feel like an operations room for following a race
weekend: technical, fast, sharp, and legible. It should avoid AI-slop patterns
such as endless decorative cards, vague gradient panels, giant generic hero
sections, and meaningless visual chrome.
## Design North Star
Use the phrase "F1 Ops Room" as the working direction.
Qualities:
- Dense but controlled.
- High signal.
- Fast to scan.
- Precise typography.
- Strong hierarchy.
- Team color used as information, not decoration.
- Good on phone and iPad, not just desktop.
## Density
Density should be configurable:
- Compact: timing-wall mode, maximum data per viewport.
- Comfortable: default mode for most users.
- Touch: larger hit targets and panel spacing for phone/iPad.
Density affects:
- Table row height.
- Visible columns.
- Panel spacing.
- Chart label detail.
- Header size.
- Control grouping.
## Timing-Wall Ergonomics
Live timing should prioritize scan speed:
- Position and driver identity must be easy to locate.
- Gap/interval changes should be visually distinct.
- Pit state, retired state, and tyre state should be obvious.
- Race-control alerts should interrupt without becoming noisy.
- Pinned drivers should remain available across live views.
## Team Color Discipline
Team colors are useful data, but they can quickly become visual noise.
Rules:
- Use team color for identity markers, row accents, chart lines, and selected
driver focus.
- Avoid flooding large surfaces with saturated team color.
- Always preserve contrast and legibility.
- Avoid making the whole interface a rainbow unless the context is explicitly
comparative.
## Layout Principles
Prefer:
- Full-width information bands.
- Dense tables with strong alignment.
- Split panes.
- Sticky session headers.
- Bottom navigation on phone.
- Clear panel switching on smaller screens.
- Charts that explain race state, not just decorate.
Avoid:
- Card sludge: every concept boxed into a decorative card.
- Floating cards inside cards.
- Generic SaaS dashboard grids.
- Purple/blue gradient panels with no product meaning.
- Decorative orbs, bokeh, or random glow effects.
- Vague hero sections.
- Overly large typography inside operational surfaces.
## F1-Native References To Research
Research should study:
- Official F1 timing tower ergonomics.
- Broadcast graphics hierarchy.
- FIA timing/result sheet density.
- Race control message formatting.
- Pit wall and telemetry workstation patterns.
- Motorsport data overlays.
The goal is not to copy official F1 branding. The goal is to understand the
information hierarchy and pacing of motorsport interfaces.
## Mobile And iPad
The app should work well on phone and iPad because those are likely primary
second-screen devices during race sessions.
Phone:
- Prioritize Live Timing, alerts, pinned drivers, and quick switching.
- Use stacked panels and sticky status.
- Keep interactions thumb-friendly.
iPad:
- Use two-pane and three-pane layouts.
- Keep charts inspectable.
- Make side panels easy to swap.
Desktop:
- Allow dense multi-panel layouts.
- Show more simultaneous context.
- Preserve keyboard and pointer efficiency.

View File

@@ -0,0 +1,206 @@
# Research Agents Brief
## Summary
Before implementation tickets are written, dedicated research agents should
investigate the uncertain parts of the refactor. Their outputs should feed a
product/architecture planning pass that turns findings into phased work.
Each research track should separate confirmed facts, assumptions, risks, and
recommendations.
## 1. OpenF1 Contract Research
Objective:
- Document the exact OpenF1 endpoint contract needed by `box-box`.
Inputs:
- Existing `internal/api` client.
- OpenF1 docs: https://openf1.org/docs/
- Current app screens and planned Race Hub requirements.
Outputs:
- Endpoint inventory.
- Field/schema notes.
- Update cadence by endpoint.
- Auth/free-tier behavior.
- Rate-limit and lockout notes.
- Essential vs optional datasets for v1.
Key questions:
- Which endpoints are immutable after session completion?
- Which endpoints are high-volume enough to require explicit ingestion?
- What errors are returned during live-session lockout?
- Which endpoints can be filtered to reduce ingestion cost?
## 2. Official F1 Live Timing Research
Objective:
- Document the SignalR live feed contract and parser risks.
Inputs:
- Current `internal/ui/official_live.go`.
- SignalR endpoint: https://livetiming.formula1.com/signalr
- OpenF1.Data package notes:
https://www.nuget.org/packages/OpenF1.Data/1.0.87
Outputs:
- Topic inventory.
- Payload examples where available.
- Parser fragility notes.
- Recommended domain event/state model.
- Testing strategy for non-live periods.
Key questions:
- Are current subscribed topics sufficient for the planned Web live mode?
- Which topics should be parsed as events vs current state?
- How should disconnections and reconnections be represented?
- Should live snapshots/events be persisted?
## 3. Static Archive Feasibility Research
Objective:
- Determine whether official F1 static archived timing files should become a
supported source.
Inputs:
- LiveF1 data topic reference:
https://livef1.goktugocal.com/livetimingf1/data_topics.html
- Public static archive URL patterns.
- OpenF1 meeting/session metadata.
Outputs:
- Feasibility assessment.
- Session path mapping strategy.
- Available years/session types.
- Topic/file inventory.
- Risks and legal/operational considerations.
Key questions:
- Can OpenF1 sessions be mapped reliably to static archive paths?
- Are static archive files available consistently?
- Which files provide replay-quality timing?
- Is this source stable enough for v1 or later only?
## 4. SQLite Schema And Indexing Design
Objective:
- Turn the domain database design into a concrete schema proposal.
Inputs:
- `03-database-design.md`.
- Existing `internal/models/types.go`.
- Race Hub and Live Replay query requirements.
Outputs:
- Table definitions.
- Primary keys and foreign keys.
- Index proposal.
- Raw payload strategy.
- Migration strategy.
- High-volume data retention recommendations.
Design questions:
- Which tables need composite primary keys?
- Which read paths need covering indexes?
- Should telemetry/location be optional datasets?
- Should derived read-model tables exist in v1?
## 5. Backend API And Read-Model Design
Objective:
- Design the Web API shape that React will consume.
Inputs:
- Existing `internal/web/api.go`.
- Planned Web screens.
- Store/query requirements.
Outputs:
- Endpoint proposal.
- Response envelope proposal.
- Source/staleness metadata shape.
- Error/partial-data behavior.
- Migration strategy from existing endpoints.
Design questions:
- Should existing `/api/v1` routes be preserved and expanded?
- What metadata should every response include?
- How should partial data be represented?
- Which read models should be backend-computed vs frontend-computed?
## 6. F1-Native Visual System Research
Objective:
- Produce visual principles and examples for the React UI before components are
built.
Inputs:
- `06-visual-design-direction.md`.
- F1 broadcast timing graphics.
- FIA timing/result sheets.
- Motorsport telemetry and timing tools.
Outputs:
- Moodboard or written reference guide.
- Layout principles.
- Typography and density guidance.
- Color usage rules.
- Anti-pattern list.
Key questions:
- How should the app look F1-native without copying official branding?
- What visual hierarchy makes live timing fastest to scan?
- How should phone/iPad layouts differ from desktop?
- How can the UI avoid generic card-heavy dashboard design?
## 7. Testing Strategy Research
Objective:
- Define a test strategy for backend, ingestion, frontend, and live behavior.
Inputs:
- Existing tests.
- Planned store/ingestion architecture.
- Live feed limitations outside active sessions.
Outputs:
- Backend unit/integration test plan.
- Ingestion fixture strategy.
- Frontend Vitest and Playwright strategy.
- Mock SSE/live fixture plan.
- Manual acceptance checklist.
Key questions:
- How should live SignalR behavior be tested without an active session?
- What source payload fixtures are needed?
- Which scenarios require real OpenF1 integration tests?
- How should local DB migrations be tested?

View File

@@ -0,0 +1,186 @@
# V1 Scope And Phasing
## Summary
The refactor vision is intentionally broad, but the first shippable milestone
must be narrow. V1 should prove the new architecture without attempting to
finish every screen. The goal is a reliable local-first Race Hub and a cleaner
live foundation, with the existing app kept usable throughout the transition.
## V1 Goal
V1 is done when `box-box` can:
- Ingest one completed race weekend into a local domain database.
- Open a Web Race Hub for that weekend without relying on fresh OpenF1 calls.
- Show honest data availability metadata.
- Continue using the existing live timing capability through an extracted live
package.
- Preserve the current TUI live mode.
This is the first proof that the app has moved from "OpenF1 page client" to
"local-first F1 command center."
## V1 Product Scope
Included screens:
- Command Center, minimal version.
- Season or Weekend entry path, minimal version.
- Race / Session Hub for completed race sessions.
- Data Library, minimal version showing local dataset status.
- Existing Web Live Timing preserved, with backend live extraction started.
Race Hub v1 data:
- Meeting and session metadata.
- Drivers.
- Final classification.
- Starting grid.
- Laps.
- Stints.
- Pit stops.
- Positions.
- Race control.
- Weather.
- Track outline when available.
Race Hub v1 views:
- Classification.
- Grid delta.
- Strategy chart.
- Position evolution.
- Lap comparison.
- Race-control timeline.
- Weather timeline.
- Dataset status.
## V1 Non-Goals
Not required for v1:
- Full React replacement of every current Web screen.
- Full season backfill as a default workflow.
- Team radio audio playback.
- High-volume car telemetry ingestion by default.
- Full live-session replay from persisted SignalR data.
- Static archive ingestion.
- Browser/system notifications.
- TUI feature parity with the new Web Race Hub.
## TUI Scope
The TUI remains a supported live-session surface, especially because its live
mode is currently one of the strongest parts of the app. New historical,
analytics, and richer navigation work should target the Web UI first.
TUI requirements during v1:
- Continue compiling.
- Continue launching by default with `go run cmd/main.go`.
- Continue supporting live mode after SignalR extraction.
- Do not require Race Hub, Data Library, or React-era feature parity.
## Backend Phase Order
### Phase 1: Live Extraction
- Extract SignalR connection, topic parsing, live state, and live event types
out of `internal/ui` into a reusable package such as `internal/live`.
- Keep TUI and Web mode consuming the same live package.
- Add fixture-based tests for parser behavior where possible.
- Persist live events/snapshots as a separate append-only stream only after the
extracted package has stable event/state types.
- See [09 Phase 1 Live Extraction](09-phase-1-live-extraction.md) for the
original implementation brief.
### Phase 2: Store Foundation
- Add `internal/store`.
- Add schema/migration initialization.
- Add raw payload storage.
- Add ingestion metadata tables.
- Add normalized tables required for Race Hub v1.
- Keep existing HTTP cache behavior unchanged.
- See [10 Phase 2 Store Foundation](10-phase-2-store-foundation.md) for the
original implementation brief.
### Phase 3: Ingestion Foundation
- Add `internal/ingest`.
- Support session-level and meeting-level ingestion first.
- Add dry-run output.
- Add conservative request delay, bounded retry, and 429/live-lockout handling.
- Make ingestion idempotent and resumable.
- See [11 Phase 3 Ingestion Foundation](11-phase-3-ingestion-foundation.md) for
the original implementation brief.
### Phase 4: Local-First Web API
- Add local-first read services for Race Hub v1.
- Introduce response metadata for source, freshness, and missing datasets.
- Migrate selected Web endpoints from direct OpenF1 calls to local-first reads.
- Allow small opportunistic fetches only for missing screen-level data.
- See [12 Phase 4 Local-First Web API](12-phase-4-local-first-web-api.md) for
the original implementation brief.
### Phase 5: React Race Hub Slice
- Add Vite + React + TypeScript frontend foundation.
- Build the Race Hub v1 route and components.
- Use TanStack Query for server data.
- Use D3 for strategy, position evolution, and lap comparison visuals.
- Keep the old Web UI available until the replacement route is credible.
- This is the first frontend phase. Use Claude for this phase.
- See [13 Phase 5 React Race Hub](13-phase-5-react-race-hub.md) for the
original implementation brief.
## Ingestion Rate-Limit Defaults
All bulk ingestion should be polite by default:
- Sequential requests unless a later test proves safe concurrency.
- Configurable delay between requests.
- Bounded exponential backoff with jitter.
- Stop or pause on HTTP 429.
- Stop or pause on live-session lockout.
- Print enough progress to resume intentionally.
- Never silently launch a full-season backfill from normal Web browsing.
## Acceptance Criteria
V1 acceptance:
- A completed race session can be ingested from OpenF1 into SQLite.
- Re-opening that Race Hub uses local data without fresh OpenF1 calls.
- Missing datasets are visible in the API response and UI.
- API lockout or network failure does not blank a locally ingested Race Hub.
- Existing TUI live mode still works through the extracted live package.
- The Data Library can show the ingested weekend/session and dataset state.
## Follow-Up Phases
After v1:
- Expand ingestion to full seasons.
- Add static archive source if research validates it.
- Add richer live persistence and reconciliation.
- Build full Command Center, Standings, Drivers, and Settings.
- Improve mobile/iPad live layouts.
- Add broader Playwright coverage and visual regression checks.
### Phase 6: React Race Hub Analytics
- Add Race Hub tabs or segmented views.
- Keep classification and grid intact.
- Add Dataset Status, Strategy, and Position Evolution views.
- Use real local-first data where available and honest missing states otherwise.
- Continue frontend work with Claude.
### Phase 7: Analytics Data Foundation
- Return to Cursor for backend work.
- Add local-first store, ingestion, and Race Hub API support for stints,
positions, and related analytics datasets.
- Keep React Strategy/Position views honest until real data is available.

View File

@@ -0,0 +1,195 @@
# Phase 1 Live Extraction
## Purpose
Phase 1 creates a stable live timing foundation without changing the product
surface. The current live mode is the strongest part of `box-box`, but the core
SignalR connection and parsing code lives inside `internal/ui`. That creates a
bad dependency direction: the Web server imports TUI code only to access live
data types and `ConnectToF1LiveTiming`.
The goal is to extract the reusable live timing core into `internal/live`, keep
the TUI and Web UI working, and add fixture-based tests around the parsing
surface. This is a foundation phase, not a frontend redesign phase.
## Manager Decision
I agree with Claude that Race Hub is the safest first React product slice.
However, before React work starts, the live timing backend should be separated
from the TUI. The current Web UI already depends on live data through SSE, and
future React live screens will need that source without importing terminal UI
code.
Therefore Phase 1 is:
- Extract the live SignalR bridge into `internal/live`.
- Update TUI live mode to consume `internal/live`.
- Update Web SSE live mode to consume `internal/live`.
- Add tests for live message parsing/state updates.
- Do not add persistence, React, or new UI behavior yet.
## Current Coupling To Remove
Current state:
- `internal/ui/official_live.go` owns SignalR protocol types, live data types,
topic parsing, connection setup, and TUI rendering.
- `internal/web/live.go` imports `internal/ui` for `ui.LiveStreamData` and
`ui.ConnectToF1LiveTiming`.
Target state:
- `internal/live` owns reusable live data structures, SignalR protocol parsing,
connection setup, and state update logic.
- `internal/ui` owns Bubble Tea model state, keyboard behavior, and terminal
rendering.
- `internal/web` owns SSE clients, HTTP handlers, reconnect/backoff policy, and
JSON responses.
## Proposed Package Boundary
Add:
```text
internal/live/
types.go LiveStreamData, LiveDriverData, weather, race control, tyres
signalr.go negotiate/connect/subscribe to official F1 SignalR
parser.go raw message parsing and topic dispatch
state.go mutable live state accumulator and snapshot copying
parser_test.go fixture-driven tests
testdata/ small captured/synthetic SignalR messages
```
The exact file split can change during implementation, but the boundary should
stay clear: `internal/live` must not import `internal/ui` or Bubble Tea.
## API Shape
Keep a small API compatible with current callers:
```go
package live
type StreamData = LiveStreamData // or a normal exported type if clearer
func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error
```
Optional improvements are allowed only if they stay small and do not force broad
behavior changes:
```go
type Client struct {
// future room for custom http client, logger, topic list, clock, etc.
}
func (c *Client) Connect(dataChan chan LiveStreamData) error
```
If a `Client` is introduced, preserve the top-level
`ConnectToF1LiveTiming(dataChan)` as a convenience wrapper so TUI and Web changes
remain boring.
## What Moves From `internal/ui/official_live.go`
Move or duplicate-then-delete these reusable concerns into `internal/live`:
- `F1SignalRMessage`
- `F1TimingLine`
- `F1DriverListEntry`
- `LiveTyreData`
- `LiveRCMessage`
- `LiveWeatherData`
- `LiveSessionMeta`
- `LiveSectorData`
- `LiveDriverData`
- `LiveStintData`
- `LiveStreamData`
- `ConnectToF1LiveTiming`
- topic parsing and state accumulation helpers currently embedded in the
connection goroutine
- snapshot-copying logic used before sending updates
Keep these TUI-specific concerns in `internal/ui/official_live.go`:
- `OfficialLiveModel`
- Bubble Tea messages and commands
- viewport handling
- keybindings
- terminal render functions
- battle/pit-window display logic unless it is already pure and clearly useful
to share
## Tests
Live sessions are not always available, so Phase 1 tests must not depend on a
current race weekend. Add fixture-based tests in `internal/live`.
Minimum test coverage:
- Parse a SignalR `R` full-state message.
- Parse a SignalR `M` incremental update message.
- Handle known topics without panicking:
- `TimingData`
- `DriverList`
- `LapCount`
- `ExtrapolatedClock`
- `TrackStatus`
- `RaceControlMessages`
- `WeatherData`
- `SessionInfo`
- `CurrentTyres`
- `TimingAppData`
- `TimingStats`
- Preserve existing string/float/nested-value handling in timing fields.
- Ignore unknown topics without failing.
- Verify snapshots copy maps/slices so downstream consumers cannot mutate
internal accumulator state accidentally.
Fixtures can be small synthetic messages shaped like the official feed. They do
not need to be full captured race payloads.
## Acceptance Criteria
Phase 1 is complete when:
- `internal/web/live.go` no longer imports `internal/ui`.
- `internal/ui/official_live.go` compiles while consuming `internal/live`.
- The existing TUI live mode still uses the official F1 SignalR feed.
- The existing Web live SSE path still uses the official F1 SignalR feed.
- `go test ./...` passes.
- Parser tests run without internet access.
- No local database, React, or visual redesign work has been started as part of
this phase.
## Non-Goals
Do not include these in Phase 1:
- React/Vite frontend setup.
- SQLite domain database or migrations.
- OpenF1 ingestion refactor.
- Live event persistence.
- Race Hub implementation.
- Static archive research.
- Browser notification work.
- Major rewrite of TUI live rendering.
## Risks And Guardrails
- The live parser currently works in practice; avoid clever rewrites that change
behavior without tests.
- Official F1 SignalR topic schemas can drift. Keep parsing tolerant of missing,
empty, string, numeric, and nested values.
- Do not make Web reconnect/backoff policy part of `internal/live` yet. The Web
server can keep owning that operational behavior.
- Do not make the TUI import Web code. Shared logic should flow through
`internal/live`.
- Preserve existing logs and user-facing behavior unless a small compile-time
adjustment requires otherwise.
## Next Phase After This
After Phase 1, Phase 2 should start `internal/store` and the local SQLite domain
database. Live persistence should still wait until the live data/event types have
settled and the database provenance design is ready.

View File

@@ -0,0 +1,136 @@
# Phase 2 Store Foundation
## Purpose
Phase 2 introduces the local domain database foundation. The current SQLite
database is an HTTP response cache. That should remain intact, but it is not the
same thing as an app-owned F1 domain store.
The goal of this phase is to add `internal/store` with schema initialization,
migrations, provenance-aware raw payload storage, and a small set of typed
domain tables needed by Race Hub v1. This phase should not build ingestion
commands or change the Web UI yet.
## Manager Decision
Keep this phase boring and structural. Do not try to ingest a full weekend yet.
The deliverable is a tested store package that later phases can call.
Phase 2 should prove:
- the app can create/open a domain SQLite database;
- migrations are repeatable and idempotent;
- raw source payloads can be stored with provenance;
- basic meeting/session/driver/session result records can be upserted and read;
- existing HTTP cache behavior is untouched.
## Package Boundary
Add:
```text
internal/store/
db.go open/close database, pragmas, transaction helper
migrations.go embedded SQL migrations and schema versioning
models.go store-layer structs for v1 domain records
raw.go raw payload/provenance writes and reads
meetings.go typed meeting/session upserts and reads
results.go typed driver/result/grid-style records as initial slice
store_test.go temp-db migration and CRUD tests
```
The exact file split can change, but `internal/store` should not import
`internal/ui` or `internal/web`.
## Database Location
Use a conservative default path separate from the existing HTTP cache:
```text
~/.local/share/box-box/boxbox.db
```
Tests must use temporary databases, not the user's real home directory.
## Initial Schema Scope
Create tables for:
- `schema_migrations`
- `raw_payloads`
- `ingestion_runs`
- `meetings`
- `sessions`
- `drivers`
- `session_drivers`
- `session_results`
- `starting_grid`
It is acceptable to include additional Race Hub v1 tables if doing so is
straightforward, but do not overbuild high-volume telemetry yet.
## Raw Payload Strategy
`raw_payloads` should preserve source truth before normalization.
Recommended columns:
- source name, such as `openf1`
- endpoint or topic
- request key or URL
- meeting key when known
- session key when known
- payload JSON text/blob
- payload hash
- fetched timestamp
- provenance metadata JSON
Raw payload storage should be idempotent by source/request/hash or another
clear uniqueness rule.
## Domain Table Strategy
Use stable OpenF1 identifiers where available:
- `meeting_key`
- `session_key`
- `driver_number`
Prefer explicit upserts over blind inserts. Completed historical data should be
safe to re-run without duplicating rows.
## Tests
Minimum tests:
- opening a temp database applies migrations;
- migrations can be run twice;
- schema version is recorded;
- raw payload insert/read works and preserves provenance;
- duplicate raw payload writes do not create accidental duplicates;
- meeting/session/driver/result upserts are idempotent;
- basic Race Hub read helpers can retrieve inserted meeting/session/result data.
## Non-Goals
Do not include these in Phase 2:
- OpenF1 backfill orchestration.
- CLI ingestion commands.
- Web UI changes.
- React setup.
- Replacing existing `internal/api/cache.go`.
- High-volume telemetry tables for car data/location.
- Live SignalR persistence.
## Acceptance Criteria
Phase 2 is complete when:
- `internal/store` exists with tested migration and CRUD behavior.
- The package can create a fresh SQLite domain database.
- Running migrations repeatedly is safe.
- Store tests pass without internet access.
- `go test ./internal/store/...` passes.
- `go test ./...` either passes or only fails because existing OpenF1
integration tests cannot reach the network/API.

View File

@@ -0,0 +1,163 @@
# Phase 3 Ingestion Foundation
## Purpose
Phase 3 connects OpenF1 REST data to the local domain store introduced in Phase
2. The goal is to ingest a meeting or session intentionally, record provenance,
write raw payloads, normalize the initial Race Hub datasets, and make the work
idempotent and resumable.
This phase should still avoid Web UI replacement work. It creates the backend
path that later Race Hub APIs and React screens can trust.
## Manager Decision
Build ingestion as an explicit backend workflow first, not as an automatic Web
side effect. Normal browsing must not accidentally trigger a full weekend
backfill or burn through API quota.
Phase 3 should add:
- `internal/ingest` orchestration.
- OpenF1 source-to-store mapping for the Phase 2 tables.
- A small CLI command path for manual ingestion.
- Dry-run and progress output.
- conservative retry/rate-limit behavior.
## Package Boundary
Add:
```text
internal/ingest/
ingest.go orchestrator, options, result summary
openf1.go OpenF1 source adapter and model mapping
progress.go progress event/output helpers if useful
ingest_test.go fake-source/fake-store or temp-db tests
```
The package should depend on:
- `internal/api` for OpenF1 reads;
- `internal/store` for writes;
- `internal/models` for current OpenF1 response structs.
It should not depend on:
- `internal/ui`;
- `internal/web`;
- React/frontend code.
## Initial Ingestion Scope
Support these commands/workflows first:
- ingest meetings for a year;
- ingest sessions for a meeting;
- ingest a single session's Race Hub v1 datasets.
For a race session, ingest:
- meeting metadata when available;
- session metadata;
- drivers;
- session result;
- starting grid;
- raw payload records for each fetched endpoint.
If Cursor chooses to include laps, stints, pits, race control, or weather, the
store schema must support them first. Otherwise leave those datasets for Phase
4 or a Phase 3 follow-up. Do not jam JSON blobs into unrelated tables just to
claim coverage.
## CLI Shape
Extend `cmd/main.go` conservatively. Keep the default TUI and `--web` behavior
unchanged.
Recommended flags:
```bash
go run cmd/main.go --ingest-year 2025
go run cmd/main.go --ingest-meeting 1229
go run cmd/main.go --ingest-session 9472
go run cmd/main.go --ingest-session 9472 --dry-run
go run cmd/main.go --ingest-session 9472 --db /path/to/boxbox.db
```
This is acceptable as a first CLI slice. A richer subcommand framework can wait.
## Ingestion Behavior
Defaults:
- sequential requests;
- small delay between endpoint calls;
- bounded retry for transient failures;
- stop cleanly on OpenF1 live-session lockout;
- no silent full-season backfills;
- print progress and final summary;
- write raw payload provenance for each endpoint;
- upsert normalized records so reruns are safe.
## Raw Payload Provenance
Each fetched endpoint should record:
- source: `openf1`;
- endpoint name;
- request key;
- meeting key when known;
- session key when known;
- fetched timestamp;
- raw JSON payload;
- HTTP/API provenance when available;
- whether data came from stale cache if that signal is available.
If the current API client does not expose raw JSON easily, prefer a small source
adapter enhancement over duplicating HTTP logic wildly. Keep existing cache
behavior intact.
## Tests
Tests should avoid real network calls.
Minimum tests:
- ingesting a fake session writes drivers, results, grid rows, and raw payloads;
- rerunning the same ingestion does not duplicate normalized rows;
- dry-run does not write domain rows;
- source errors stop the run and record/report failure;
- live-session lockout is surfaced as a controlled failure;
- CLI flag parsing does not break default TUI/Web behavior if covered cheaply.
## Non-Goals
Do not include these in Phase 3:
- React/Vite frontend implementation.
- Web Race Hub API replacement.
- automatic Web-triggered backfill.
- live SignalR persistence.
- full-season default backfill.
- static archive ingestion.
- high-volume car telemetry ingestion.
## Acceptance Criteria
Phase 3 is complete when:
- `internal/ingest` exists and is covered by offline tests.
- A user can manually ingest a year, meeting, or session from the CLI.
- Rerunning ingestion is idempotent.
- Raw payloads and normalized records are both written.
- `go test ./internal/ingest/... ./internal/store/...` passes.
- `go build -o /tmp/box-box ./cmd/main.go` passes.
- `go test ./...` either passes or only fails because existing OpenF1
integration tests cannot reach the network/API.
## Next Phase After This
Phase 4 should add local-first backend read models and Web API endpoints for
Race Hub v1. It should make the Web API prefer local SQLite data and report
missing datasets honestly.

View File

@@ -0,0 +1,139 @@
# Phase 4 Local-First Web API
## Purpose
Phase 4 makes the Web API start behaving like a local-first product. Phases 2
and 3 created the domain store and explicit ingestion path; this phase adds
read models that prefer local SQLite data and report data availability honestly.
This is still a backend phase. Do not start React yet.
## Manager Decision
Build one credible local-first Race Hub API slice before replacing the frontend.
The current Web UI can keep working from the existing endpoints, but the backend
should expose store-backed responses that a future React Race Hub can trust.
Phase 4 should add:
- store-backed read models for ingested meetings, sessions, drivers, results,
and grid;
- dataset/status metadata so the UI knows what is local, missing, or stale;
- optional small API fallbacks only when explicitly requested;
- tests for local-first behavior without network.
## Package Boundary
Prefer adding a backend read-model layer instead of embedding SQL inside HTTP
handlers.
Recommended shape:
```text
internal/query/
racehub.go Race Hub read model assembly
metadata.go dataset availability/source metadata
query_test.go temp-db tests
```
Then wire `internal/web` to use that layer.
If the implementation keeps the read layer inside `internal/web` temporarily,
it must still avoid duplicating store SQL across handlers.
## Initial API Scope
Add a new Race Hub endpoint:
```text
GET /api/v1/race-hub?session_key=9472
```
Response should include:
- meeting;
- session;
- drivers;
- session results enriched with driver/team fields;
- starting grid enriched with driver/team fields;
- dataset availability metadata.
Recommended metadata shape:
```json
{
"source": "local",
"session_key": 9472,
"datasets": {
"meeting": {"status": "available", "source": "local"},
"session": {"status": "available", "source": "local"},
"drivers": {"status": "available", "source": "local", "count": 20},
"results": {"status": "missing", "source": "none", "count": 0},
"starting_grid": {"status": "available", "source": "local", "count": 20}
}
}
```
Exact field names can vary, but the response must make missing datasets visible
instead of silently returning empty app states.
## Existing Endpoint Policy
Do not rewrite every existing endpoint yet. It is enough to:
- add the new local-first Race Hub endpoint;
- optionally make `/api/v1/meetings`, `/api/v1/sessions`, `/api/v1/drivers`,
`/api/v1/results`, and `/api/v1/grid` read from local data when present;
- preserve old OpenF1 behavior when local data is absent unless the request asks
for local-only behavior.
Recommended query controls:
```text
?source=local local only; no OpenF1 fallback
?source=auto local first, existing OpenF1 fallback when missing
```
Default should be conservative for existing endpoints. The new Race Hub endpoint
can default to local-first with honest missing metadata.
## Server Wiring
`web.Server` currently only receives `*api.OpenF1Client`. Add an optional
`*store.Store` or query service so Web mode can read the domain DB.
CLI/server behavior should remain simple:
```bash
go run cmd/main.go --web
go run cmd/main.go --web --db /path/to/boxbox.db
```
If the DB does not exist or has no ingested data, Web mode should still start.
## Non-Goals
Do not include these in Phase 4:
- React/Vite frontend setup.
- replacing the current static Web UI;
- automatic ingestion from Web browsing;
- live SignalR persistence;
- laps/stints/pits/weather/race-control read models unless the store schema is
expanded and tested first.
## Acceptance Criteria
Phase 4 is complete when:
- a local-first Race Hub endpoint exists;
- it can return ingested session data without OpenF1 calls;
- it reports missing datasets explicitly;
- Web mode can be pointed at a domain DB with `--db`;
- offline tests cover the read model and HTTP handler behavior;
- focused tests and build pass.
## Next Phase After This
Phase 5 is the first frontend implementation phase. That is the point to switch
from Cursor to Claude for React/UI work.

View File

@@ -0,0 +1,105 @@
# Phase 5 React Race Hub
## Purpose
Phase 5 begins the production Web UI. The backend now has the foundation needed
for a local-first Race Hub: live timing is shared, a domain store exists,
ingestion can populate it, and `/api/v1/race-hub` can read from local data with
dataset metadata.
This is the point to switch from Cursor to Claude for frontend/UI work.
## Manager Decision
Start with a focused React Race Hub slice, not a full app rewrite. The goal is
to prove the chosen frontend stack, visual language, responsive layout, and API
contract against the new local-first backend.
Keep the old Web UI available until the React route is credible.
## Scope
Add a Vite + React + TypeScript frontend foundation and build a first Race Hub
route around:
- meeting/session header;
- dataset/source status strip;
- classification table;
- starting grid table;
- driver/team color treatment;
- missing dataset states;
- compact Race Hub navigation shell;
- responsive desktop, tablet, and phone layouts.
Use `/api/v1/race-hub?session_key=...` as the primary API.
## Stack Defaults
- Vite
- React
- TypeScript
- TanStack Query
- TanStack Router, unless integration cost argues for React Router
- D3 only for bespoke charts later; do not use it for basic layout tables
- Vitest for component/unit tests
- Playwright for at least one smoke path if practical
## Visual Direction
The temporary static mockups used during early product exploration have been
removed now that the production React routes exist. Use the implemented React
screens as the current source of truth, and keep this visual direction in mind
for future refinement.
The UI should feel like an F1 operations room:
- dense but readable;
- technical, not generic SaaS;
- restrained use of panels;
- no card sludge;
- no decorative gradient blobs;
- strong timing-table ergonomics;
- team colors used as data, not wallpaper;
- mobile views designed directly, not merely squeezed desktop.
## Integration Policy
Do not rip out the existing static Web UI on day one. Add the React app in a way
that can coexist while the route is built and tested.
Acceptable approaches:
- add a Vite app under a dedicated frontend directory and document the dev flow;
- serve built assets from Go only after the React slice is stable;
- expose a `/react` or equivalent route temporarily if needed.
The implementation should avoid large backend changes except for tiny API
contract fixes discovered while integrating.
## Non-Goals
Do not include these in Phase 5:
- full replacement of every existing Web screen;
- live timing React rewrite;
- ingest UI;
- settings UI;
- full season/calendar rebuild;
- new backend ingestion features;
- persistence of live SignalR events.
## Acceptance Criteria
Phase 5 is complete when:
- the React app can run locally;
- a Race Hub screen loads from `/api/v1/race-hub`;
- available and missing datasets are visibly distinct;
- the layout is usable on desktop and phone widths;
- tests or smoke checks cover the Race Hub happy path;
- the old Web UI still works.
## Next Phase After This
Phase 6 should expand the React app around the Race Hub: strategy chart,
position evolution, lap comparison, and richer Data Library/status workflows.

View File

@@ -0,0 +1,79 @@
# Phase 6 React Race Hub Analytics
## Purpose
Phase 6 expands the React Race Hub from a classification/grid slice into a more
useful race analysis surface. Phase 5 proved the React stack, API contract,
desktop layout, and phone table behavior. This phase should add the first
bespoke F1 analysis views without replacing the whole Web app.
This remains a frontend-led phase for Claude.
## Scope
Add Race Hub tabs or segmented views for:
- Overview / Classification
- Starting Grid
- Strategy
- Position Evolution
- Dataset Status
Strategy and position views should be built from local-first backend data only
when the backend exposes the needed datasets. If laps/stints/positions are not
yet available through `/api/v1/race-hub`, add clear missing states instead of
fake charts.
## Backend Contract
Current Race Hub API:
```text
GET /api/v1/race-hub?session_key=...
```
Current datasets:
- meeting
- session
- drivers
- results
- starting_grid
If analytics require laps, stints, pit stops, or position samples, keep backend
changes small and explicit. Do not reintroduce direct OpenF1 reads from the
React app.
## Design Direction
Improve the information hierarchy without drifting into generic dashboard UI:
- stronger timing-wall readability;
- compact controls;
- minimal panel framing;
- no decorative gradients or card sludge;
- team colors as data accents;
- mobile views that fit the active columns rather than relying on horizontal
scrolling.
## Non-Goals
Do not include these in Phase 6:
- live timing React rewrite;
- full season calendar rebuild;
- settings UI;
- ingest UI;
- static archive support;
- replacing the old Go-served Web UI entirely.
## Acceptance Criteria
Phase 6 is complete when:
- Race Hub has an ergonomic tab/segmented-view structure;
- classification and grid remain intact;
- analytics views show either real local data or honest missing states;
- desktop and phone layouts have been visually checked;
- frontend tests/build pass;
- Go build still passes.

View File

@@ -0,0 +1,52 @@
# Phase 7 Analytics Data Foundation
## Purpose
Phase 7 returns to backend work. Phase 6 added honest frontend placeholders for
strategy and position evolution, but the Race Hub API does not yet expose the
local datasets needed to draw those views.
The goal is to expand the local store, ingestion, and Race Hub read model with
the first analytics datasets.
## Scope
Add local-first support for:
- laps;
- stints;
- pit stops;
- race control;
- weather;
- positions, if volume and schema stay manageable.
Prioritize stints and positions because they unlock the Strategy and Position
Evolution views.
## Backend Work
Expected changes:
- add SQLite tables and migrations for the selected datasets;
- add store upsert/read methods;
- extend `internal/ingest` session ingestion;
- extend `internal/query.RaceHub`;
- extend `/api/v1/race-hub` metadata counts;
- keep raw payload provenance for every fetched endpoint.
## Guardrails
- Keep ingestion idempotent.
- Keep tests offline.
- Do not fetch OpenF1 directly from React.
- Do not persist high-volume car telemetry yet.
- If positions are too large for this phase, document the limit and implement
stints/pits first.
## Acceptance Criteria
- Store migrations and CRUD tests pass.
- Ingestion writes new datasets and raw payloads.
- Race Hub API exposes new datasets with metadata.
- Existing React placeholders can detect available stints/positions.
- Focused Go tests pass.

View File

@@ -0,0 +1,65 @@
# Phase 8 Analytics Visuals
## Purpose
Phase 7 added the backend data foundation for Race Hub analytics: stints, pit
stops, positions, race control, weather, and laps now flow through the local
SQLite store, ingestion, query layer, and `/api/v1/race-hub`.
Phase 8 returns to frontend work. The goal is to replace the Strategy and
Position placeholder states with useful, production-minded views that consume
the real local-first analytics arrays now present in the Race Hub payload.
## Scope
Build the first real analytics views for:
- race strategy from stints and pit stops;
- position evolution from position samples;
- lightweight supporting context from race control, weather, and laps where it
improves the view without making the screen noisy.
The work should stay inside the React Race Hub surface. Do not redesign the
whole application shell in this phase.
## Frontend Work
Expected changes:
- pass `stints`, `pit_stops`, `positions`, `race_control`, `weather`, and `laps`
into the relevant Race Hub components;
- replace "chart not yet implemented" placeholders with real visual treatment;
- preserve honest missing-data states for sessions that only have core datasets;
- keep the design dense, technical, and F1-native;
- add focused component/unit tests for available and missing analytics data;
- update Playwright coverage so seeded analytics views prove the real data path
works.
## Visual Direction
Prefer timing-wall clarity over dashboard decoration:
- stint bars should be compact and scan-friendly;
- team colors should identify drivers without overpowering compound colors;
- compound colors should be disciplined and legible;
- position evolution should make gain/loss and driver comparison obvious;
- avoid decorative cards, giant empty panels, vague gradients, and generic SaaS
chart chrome.
## Guardrails
- Do not fetch OpenF1 directly from React.
- Do not add a heavy charting library unless the local interaction genuinely
needs it; SVG/CSS is enough for this first slice.
- Do not hide missing datasets behind fake mock data in runtime views.
- Keep mobile and iPad layouts usable, not just desktop-polished.
- Keep backend changes out of scope unless a clear API bug is discovered.
## Acceptance Criteria
- Strategy tab renders real stint/pit information when analytics data exists.
- Position tab renders real position information when position samples exist.
- Missing-data sessions still show clear unavailable states.
- Existing Race Hub views keep working.
- Frontend tests and build pass.
- Playwright Race Hub e2e passes against the seeded local database.

View File

@@ -0,0 +1,52 @@
# Phase 9 Navigation Data API
## Purpose
Race Hub now has useful local-first session views, but it still depends on a
manual `session_key`. Phase 9 should make the backend expose enough local
navigation data for the Web UI to become race-weekend-first: season calendar,
meeting detail, sessions, and ingestion coverage.
This is a backend/read-model slice for Cursor. Keep the React redesign for the
following phase.
## Scope
Add local-first Web API endpoints/read models for:
- seasons or available years in the domain database;
- meetings for a year;
- one meeting/weekend with its sessions;
- per-session dataset coverage using the same dataset vocabulary as Race Hub;
- a sensible "latest available" or "default session" helper if it can be done
without guessing from remote API data.
## Backend Work
Expected changes:
- add query-layer read models in `internal/query` for calendar/weekend data;
- add store reads if existing methods are insufficient;
- add HTTP handlers in `internal/web`;
- keep responses local-first and deterministic;
- expose empty but well-shaped responses when the database has no ingested
meetings;
- add offline tests using temporary SQLite databases.
## Guardrails
- Do not fetch OpenF1 from these read endpoints.
- Do not make React depend on OpenF1 directly.
- Do not start frontend navigation implementation in this phase.
- Keep endpoint names stable and boring; this is app infrastructure, not a
product copywriting exercise.
- Keep the existing Race Hub API working unchanged.
## Acceptance Criteria
- Web API can list ingested years and meetings.
- Web API can return a meeting/weekend with sessions.
- Each session includes dataset coverage needed to guide users into Race Hub.
- Empty database behavior is explicit and tested.
- Focused Go tests pass.
- Existing frontend unit/build/e2e checks still pass.

View File

@@ -0,0 +1,47 @@
# Phase 10 Navigation UI
## Purpose
Phase 9 added local-first navigation APIs for seasons, weekends, sessions, and
dataset coverage. Phase 10 should use those APIs in the React Web UI so users
can browse ingested data instead of manually typing a `session_key`.
This is a frontend slice. Keep it functional and restrained; full visual polish
can come after the navigation workflow exists.
## Scope
Add React UI for:
- available seasons from `/api/v1/seasons`;
- locally ingested meetings for a selected year;
- one weekend view from `/api/v1/weekend?meeting_key=...`;
- session selection that routes into existing Race Hub views.
The existing Race Hub analytics views should stay intact.
## Product Behavior
- If local data exists, users should be able to reach Race Hub without knowing a
raw session key.
- Empty local database states should be explicit and calm.
- Weekend/session rows should show dataset coverage so users understand why a
session may be partial.
- Race Hub should continue accepting `session_key` in the URL for direct links.
## Guardrails
- Do not fetch OpenF1 directly from React.
- Do not redesign every screen.
- Do not remove the manual session key entry yet; keep it as a fallback.
- Do not add a large UI framework or chart dependency.
- Keep mobile and iPad usable.
## Acceptance Criteria
- Users can select a local year, meeting, and session.
- Selecting a session opens Race Hub for that session.
- Empty states are covered.
- Existing Race Hub e2e tests continue passing.
- Add focused frontend tests for navigation behavior where practical.
- Frontend tests and build pass.

View File

@@ -0,0 +1,40 @@
# Phase 11 Weekend Ingestion
## Purpose
Phase 10 made local data navigable in the Web UI, but the app still needs a
practical way to populate a complete weekend. Phase 11 should make ingestion
work at the same shape users browse: meeting/weekend first, then sessions.
This is a backend/CLI slice for Cursor.
## Scope
Add or refine CLI ingestion so a user can ingest a whole meeting/weekend into
the domain database without manually running one command per session.
The target workflow is:
- ingest meeting metadata and sessions for a `meeting_key`;
- for each session in that meeting, ingest Race Hub datasets;
- report per-session success, partial failure, and row counts clearly;
- keep raw payload provenance.
## Guardrails
- Do not remove single-session ingestion.
- Do not fetch data from React.
- Do not make failed optional analytics endpoints destroy already-ingested
meeting/session metadata.
- Keep tests offline with fake sources.
- Be careful with live/current sessions; completed historical sessions are the
primary target.
## Acceptance Criteria
- A single CLI path can ingest all sessions for a meeting.
- Ingestion summaries make per-session results clear.
- Existing `--ingest-session` behavior still works.
- Store/query/web/frontend tests still pass.
- Add focused ingestion tests for full-weekend orchestration and partial
failures where practical.

View File

@@ -0,0 +1,40 @@
# Phase 12 Data Library UI
## Purpose
The app can now ingest full weekends and browse local seasons, meetings, and
sessions. Phase 12 should make local data health visible in the Web UI so users
can understand what is stored, what is partial, and what command to run next.
This is a frontend slice. Keep it practical and built on the APIs already
available.
## Scope
Add a Data Library style surface that shows:
- local seasons and meetings;
- sessions per meeting;
- dataset coverage per session;
- clear empty states;
- suggested CLI commands for ingestion/backfill.
This can be a new route or a tab/section reachable from the existing Race Hub
shell, depending on the current router structure.
## Guardrails
- Do not fetch OpenF1 from React.
- Do not trigger ingestion from the browser.
- Keep Race Hub direct links working.
- Reuse existing local navigation APIs unless a small backend gap is genuinely
blocking.
- Keep styling dense, operational, and restrained.
## Acceptance Criteria
- User can inspect local data coverage without opening a specific session.
- Partial weekends/sessions are visibly distinct from complete ones.
- Empty database state explains the relevant CLI command.
- Existing Race Hub navigation continues to work.
- Frontend tests/build/e2e pass.

View File

@@ -0,0 +1,127 @@
# MVP Completion Checklist
## Status
The local-first Web UI MVP is now functionally assembled. The app can ingest
OpenF1 data into the SQLite domain store, serve local-first Race Hub and
navigation APIs, render the React Race Hub/Data Library/Live Timing routes, and
serve the built React app from Go web mode when `frontend/dist` is present.
This document replaces the temporary agent handoff prompts as the main
checkpoint for what has been completed and what remains.
## Completed
- Live SignalR code extracted into `internal/live` and reused by TUI and Web
mode.
- SQLite domain store added for local historical data.
- Session, meeting, and weekend ingestion paths added with dry-run support.
- Optional analytics ingestion failures are partial, not hard blockers.
- Local-first Race Hub API added with dataset availability metadata.
- Local-first season, meeting, and weekend navigation APIs added.
- React + TypeScript frontend added with TanStack Query and Router.
- Race Hub route added for classification, grid, strategy, positions, laps,
race control, weather, and dataset status.
- Admin / Data Health route added for local season/weekend coverage and CLI
guidance; `/data-library` remains a legacy alias.
- Live Timing route added for current backend live snapshot/SSE state.
- Go web mode serves the built React app from `frontend/dist` and falls back to
embedded legacy assets when no build is present.
## Verification Commands
Run these before cutting an MVP tag or handing the repo to another agent:
```bash
go test ./internal/live ./internal/models ./internal/store ./internal/ingest ./internal/query ./internal/web
npm --prefix frontend test -- --run
npm --prefix frontend run build
npm run test:e2e
npm run test:e2e:prod
npm run test:visual
npm run test:visual:prod
```
### Dev proxy smoke (Vite + Go API)
For a local manual smoke test with the Vite dev server proxying API calls:
```bash
go run ./scripts/seed-e2e-db/main.go --db /tmp/boxbox-mvp.db
BOXBOX_DISABLE_LIVE=1 go run ./cmd/main.go --web --db /tmp/boxbox-mvp.db --port 18080
BOXBOX_API_PORT=18080 npm run dev --prefix frontend -- --host 127.0.0.1 --port 15173 --strictPort
```
Then open:
- `http://127.0.0.1:15173/race-hub?session_key=9472`
- `http://127.0.0.1:15173/admin`
- `http://127.0.0.1:15173/data-library` (legacy alias)
- `http://127.0.0.1:15173/live`
### Production web smoke (Go serves built React)
Verify the same routes when Go serves `frontend/dist` directly (no Vite):
```bash
npm --prefix frontend run build
go run ./scripts/seed-e2e-db/main.go --db /tmp/boxbox-mvp.db
BOXBOX_DISABLE_LIVE=1 go run ./cmd/main.go --web --db /tmp/boxbox-mvp.db --port 18080
```
Then open:
- `http://127.0.0.1:18080/race-hub?session_key=9472`
- `http://127.0.0.1:18080/admin`
- `http://127.0.0.1:18080/data-library` (legacy alias)
- `http://127.0.0.1:18080/live`
Automated production-serving coverage:
```bash
npm run test:e2e:prod
```
This runs `playwright.prod.config.ts`, which builds the frontend, seeds
`.playwright/boxbox-prod-e2e.db`, starts Go web mode on port 18080, and
exercises Race Hub, Data Library, Live empty state, and nav links against the
built SPA.
### Visual regression (Playwright screenshots)
Screenshot baselines for Race Hub, Data Library, and Live (disabled-live empty
state) at desktop, tablet, and mobile viewports:
```bash
npm run test:visual
npm run test:visual:prod
```
Refresh baselines after intentional UI changes:
```bash
npm run test:visual:update
npm run test:visual:prod:update
```
Snapshots are stored under `tests/visual/__snapshots__/`. See
[22 Phase 14 Visual Regression](22-phase-14-visual-regression.md).
## Remaining Post-MVP Work
- Improve high-density mobile/iPad behavior for Live Timing and Race Hub tables.
- Add persisted live-event capture and reconciliation only after defining the
live storage model.
- Add track outline ingestion/read models to the React app if the local data
source is reliable enough.
- Expand from weekend/session ingestion toward safe full-season backfill.
- Add Drivers, Standings, and Settings as separate product phases.
- Revisit static archive feasibility after source mapping is proven.
## Notes
- `--ingest-year` currently discovers season meetings and sessions. Use
`--ingest-meeting <meeting_key>` for full weekend ingestion.
- The React app should continue avoiding direct OpenF1 reads. New Web UI routes
should call local-first Go APIs.
- The TUI live mode remains intentionally preserved. Historical Web UI parity
with the TUI is not required for this MVP.

View File

@@ -0,0 +1,55 @@
# Phase 14: Visual Regression and Responsive QA
## Goal
Add Playwright screenshot coverage for the MVP Web UI routes across desktop,
tablet, and mobile viewports before broader product expansion.
## Scope
Routes:
- `/race-hub?session_key=9472`
- `/data-library`
- `/live` (empty state with `BOXBOX_DISABLE_LIVE=1`)
Viewports (deterministic Chromium):
| Project | Size |
|---------|------|
| desktop | 1280×800 |
| tablet | 768×1024 |
| mobile | 390×844 |
## Commands
```bash
# Dev proxy (Vite + seeded Go API) — same stack as test:e2e
npm run test:visual
npm run test:visual:update
# Production serving (Go + frontend/dist) — canonical for committed snapshots
npm run test:visual:prod
npm run test:visual:prod:update
```
Snapshots live under `tests/visual/__snapshots__/{desktop,tablet,mobile}/`.
## Constraints
- Reuses `scripts/seed-e2e-db` and `BOXBOX_DISABLE_LIVE=1`; no live F1 session
or OpenF1 network calls.
- Screenshots are taken only after route-specific ready conditions (classification
loaded, data library detail visible, live empty state).
- Animations disabled; full-page captures; no loading-state screenshots.
## Out of Scope
- Live timing tower screenshots (requires an active session and live SignalR).
- Cross-browser matrix beyond Chromium.
- Pixel-perfect parity between Vite dev and production builds (use prod update
when refreshing committed baselines).
## Related
- [21 MVP Completion Checklist](21-mvp-completion-checklist.md)

View File

@@ -0,0 +1,48 @@
# Phase 15: Command Center V1
## Goal
Make the Web UI default route a useful local-first operations screen instead of
requiring users to know a raw Race Hub session key.
## Completed Scope
- Added `/` as the Command Center route.
- Added a top-level Command nav item while preserving Race Hub, Live, and Data
Library routes.
- Shows local season coverage, weekend coverage, local session counts, and live
availability.
- Selects a focus weekend from local data using current, upcoming, then recent
weekend priority.
- Provides quick actions into Live Timing, Race Hub for the default local
session, and Data Library.
- Lists recent local sessions with direct Race Hub links.
- Added unit coverage for schedule selection helpers and the Command Center
page.
- Added Playwright E2E and production smoke coverage for `/`.
- Added visual regression coverage for Command Center at desktop, tablet, and
mobile viewports.
## Constraints
- React continues to call only local-first Go APIs; no direct OpenF1 reads were
added.
- Live state remains read-only status from the existing Web live endpoint.
- The page stays dense and operational rather than becoming a marketing landing
page.
## Verification
```bash
npm --prefix frontend test -- --run
npm --prefix frontend run build
npm run test:e2e
npm run test:e2e:prod
npm run test:visual
npm run test:visual:prod
```
## Related
- [21 MVP Completion Checklist](21-mvp-completion-checklist.md)
- [22 Phase 14 Visual Regression](22-phase-14-visual-regression.md)

View File

@@ -0,0 +1,43 @@
# Phase 16: Live Timing Polish
## Goal
Improve the React Live Timing route as an operations screen while preserving the
existing official F1 SignalR bridge and TUI live behavior.
## Completed Scope
- Added pure helpers for position delta styling and race-control flag classes.
- Improved the timing tower with podium position styling, colored position
deltas, best-lap/lap-count columns, and compact status badges.
- Reworked the session banner so track status, lap count, clock, live/stale
state, and weather read as dense operational metadata.
- Improved race-control feed treatment with color-coded flag badges, category
labels for non-flag messages, and bounded scrolling.
- Reworked the live route layout into a two-column desktop view with timing
tower priority and race control alongside it.
- Improved empty and disconnected states without requiring a real live F1
session.
## Constraints
- No backend live bridge or TUI live code was changed.
- No persisted live storage was added.
- Tests continue to use disabled-live/empty-state coverage because an active F1
session is not guaranteed.
## Verification
```bash
npm --prefix frontend test -- --run
npm --prefix frontend run build
npm run test:e2e
npm run test:e2e:prod
npm run test:visual
npm run test:visual:prod
```
## Related
- [21 MVP Completion Checklist](21-mvp-completion-checklist.md)
- [23 Phase 15 Command Center](23-phase-15-command-center.md)

View File

@@ -0,0 +1,97 @@
# Phase 18: Fan Command Center and Admin Split
## Goal
Shift the Web UI from a local-data console toward an F1 fan race-weekend
command center, while keeping the dark, dense, ops-room aesthetic. Move
local-data and ingestion concerns into a thin Admin / Data Health area so they
no longer dominate the fan-facing first screen.
## Completed Scope
- Reworked `/` (`CommandCenterPage`) around the focus race weekend:
- GP identity band with country decal, GP name, location, circuit, date
range, status eyebrow (Live now / Current / Next / Recent), and a
country-derived left-edge accent strip.
- Countdown block — LIVE state, current-session label, next-session
`Xd HHh MMm SSs` countdown, or "Weekend finished" — anchored to the band.
- Primary actions row: Watch Live, Open Analysis (preselects race → qual →
first local session), Schedule jump.
- Session schedule as responsive cards instead of a table, with per-session
coverage dot, status pill, and direct Race Hub link (keeps the existing
`cc-session-{key}` testid).
- Recent local weekends rendered as a chip strip with country decals.
- Empty state reframed: short eyebrow, single-paragraph instruction, and
links to Live + Admin (no inline CLI on the fan-facing surface).
- Added `frontend/src/lib/gpIdentity.ts` for country accent, 3-letter decal,
and short date-range formatting.
- Reframed `DataLibraryPage` as **Admin · Data Health**: page header,
utility-style stats banner (seasons / full / partial / missing), and a
back-link to Command Center in the footer.
- Added `/admin` route rendering the same Data Health page. `/data-library`
remains as a legacy alias so existing links keep working.
- Updated `Nav` so primary fan destinations (Command / Live / Race Hub) sit
next to the logo, and a small monospace **Admin** chip is anchored to the
far right as a utility link.
## What Moved Into Admin / Data Health
- Five-stat coverage strip (Seasons / Full / Partial / Missing / Sessions
Local) — now lives in the admin banner.
- CLI ingest guidance — only shown under Admin and inside meeting detail.
- Per-meeting weekend table, per-session dataset status panel, and ingest
command blocks — unchanged content, but no longer reachable from the fan
nav directly.
## What Did Not Change
- Race Hub session-key table flow is unchanged (deeper rework is deferred).
- No new backend endpoints. Command Center still calls the existing local-first
APIs (`/api/v1/seasons`, `/api/v1/meetings`, `/api/v1/weekend`,
`/api/v1/live/state`).
- Live SignalR bridge, Live Timing page, and TUI live mode are untouched.
## Tests and Visual Coverage
Updated:
- `frontend/src/test/CommandCenterPage.test.tsx` — covers new band, decal,
empty-state copy, and analysis action label.
- `tests/command-center.spec.ts` — exercises new actions container and the
reframed admin route alongside existing routes.
- `tests/data-library.spec.ts` — renamed describe block, exercises both
`/admin` and the `/data-library` alias, uses the new Admin nav link.
- `tests/production-smoke.spec.ts` — covers `/admin`, the legacy
`/data-library` route, and the updated nav labels.
- `tests/visual/__snapshots__/{desktop,tablet,mobile}/{command-center,data-library}.png`
regenerated against the new layout.
## Verification
```bash
npm --prefix frontend test -- --run
npm --prefix frontend run build
npm run test:e2e
npm run test:e2e:prod
npm run test:visual
npm run test:visual:prod
```
All commands above were run in this branch and pass: 94 unit tests, 15 E2E,
6 prod smoke, 12 visual baseline, 12 prod visual baseline.
## Limitations and Follow-ups
- The Schedule action only renders when a next session exists. During a
live session the row shows two actions instead of three; intentional.
- Country accent palette in `gpIdentity.ts` is a hand-tuned subset of country
codes; unknown codes fall back to a neutral gray.
- Race Hub remains the next target — its session-selection flow is still the
most dated part of the fan path, especially on tablet.
## Related
- [21 MVP Completion Checklist](21-mvp-completion-checklist.md)
- [22 Phase 14 Visual Regression](22-phase-14-visual-regression.md)
- [23 Phase 15 Command Center](23-phase-15-command-center.md)
- [24 Phase 16 Live Timing Polish](24-phase-16-live-timing-polish.md)

View File

@@ -0,0 +1,128 @@
# Phase 19: Weekend Workspace / Race Hub Flow V1
## Goal
Rework `/race-hub` from an "admin-style table on top, analysis below" page
into a Weekend Workspace that feels like a modern session companion: a
compact GP identity band, a horizontal session rail, an Overview snapshot,
and fan-oriented analysis tabs. Keep `/race-hub?session_key=…` working,
keep ingestion/admin concerns out of the fan surface, and make mobile/iPad
first-class.
## Completed Scope
- Replaced the legacy Race Hub layout with a Weekend Workspace:
- **Topbar**: `box-box · race hub · <year>` eyebrow, weekend source badge,
and a `Switch Weekend` toggle that opens an inline switcher panel.
- **GP identity band**: country decal, GP name, location · circuit, date
range, with a left-edge `--gp-accent` strip derived from
`frontend/src/lib/gpIdentity.ts`.
- **Session rail**: horizontal segmented strip of session cards (FP1,
FP2, FP3, Q, Sprint, R …) showing abbreviation, name, time, source dot,
and coverage hint. Switching is a single click; selected chip pulses
with the GP accent.
- **Active session sub-bar**: compact line with session name, scheduled
time, coverage label, and `key <session_key>` for power users.
- New tab grouping (`frontend/src/components/TabBar.tsx`):
Overview · Race Story · Strategy · Lap Data · Conditions · Race Control ·
Data Status. Race Story bundles classification, starting grid, and
position evolution behind a sub-segmented control so the operational
feel is preserved without exploding the top-level tab list.
- New **Overview** tab (`components/OverviewView.tsx`): operational stat
cards (Winner / Pole / Fastest Lap / Podium) plus compact panels for
Conditions, latest Race Control messages, and a Local Coverage meter
that links to the relevant Data Status tab.
- Inline **Weekend Switcher** (`components/WeekendSwitcher.tsx`): season
tabs, meeting cards with country decals, and expandable per-meeting
session lists that navigate via `useNavigate`. Replaces the old fullscreen
`LocalDataNavigator` table on the Race Hub surface.
- Auto-resolution when `/race-hub` is opened without `session_key`: the
page resolves the focus weekend via the same `pickFocusMeeting` helper
Command Center uses and `navigate(replace: true)` to the focus
session (race → qualifying → first local session).
- **Data Status** tab now points at `/admin` for missing datasets instead of
inlining CLI commands. Admin remains the home for ingestion guidance.
- GP accent is plumbed through CSS custom property `--gp-accent`, used by
session chips, story sub-control underline, overview stat cards, and the
topbar `Switch Weekend` border.
## Route Behavior
- `/race-hub?session_key=9472` — unchanged contract; loads the workspace
for that session and opens Overview by default.
- `/race-hub` (no key) — resolves locally via `fetchSeasons`
`fetchLocalMeetings``pickFocusMeeting``fetchWeekend`, then
`navigate({ replace: true })` to the focus session's race/qualifying.
- `/data-library` and `/admin` remain untouched.
## What Did Not Change
- Backend APIs (`/api/v1/race-hub`, `/api/v1/seasons`, `/api/v1/meetings`,
`/api/v1/weekend`).
- Live SignalR bridge, `/live` page, TUI live mode.
- Command Center, Admin / Data Health flows.
- Existing chart and table components (`ClassificationTable`,
`StartingGridTable`, `StrategyView`, `PositionEvolutionView`, `LapsView`,
`RaceControlView`, `WeatherView`) are reused inside the new shell.
- The legacy `LocalDataNavigator` component is kept (still unit-tested) so
any future surfaces can reuse it, but it is no longer mounted on
`/race-hub`.
## Tests and Visual Coverage
Updated:
- `frontend/src/test/TabBar.test.tsx` — new tab list (Overview / Race Story
/ Strategy / Lap Data / Conditions / Race Control / Data Status).
- `frontend/src/test/DatasetStatusView.test.tsx` — rewritten against the
fan-facing dataset list (11/11), the new `Manage ingestion → /admin`
link, and the removal of inline CLI hints.
- `frontend/src/test/RaceHubPage.test.tsx` — new test file covering the
identity band, session rail, Race Story sub-controls, Data Status admin
link, and the inline weekend switcher.
- `tests/race-hub.spec.ts` — rewritten E2E spec covering Overview default,
Race Story sub-views, Strategy and Positions missing-data notices,
weekend switcher toggle, Data Status admin link, and the bare
`/race-hub` redirect.
- `tests/command-center.spec.ts`, `tests/data-library.spec.ts`,
`tests/production-smoke.spec.ts` — updated assertions to land on the
new workspace shell rather than the old "Final Classification" headline.
- `tests/visual/helpers.ts``gotoRaceHubReady` now waits for
`race-hub` + `rh-identity` + `rh-session-<key>` + `rh-overview`.
- `tests/visual/__snapshots__/{desktop,tablet,mobile}/race-hub.png`
regenerated. Command Center, Admin, and Live snapshots untouched.
## Verification
```bash
npm --prefix frontend test -- --run # 98 unit tests pass
npm --prefix frontend run build # tsc + vite, clean
npm run test:e2e # 18 E2E pass
npm run test:e2e:prod # 6 prod smoke pass
npm run test:visual # 12 baseline pass after regen
npm run test:visual:prod # 12 prod baseline pass after regen
```
## Limitations and Follow-ups
- The Race Story tab keeps three legacy datasets behind a sub-segmented
control. A future pass could merge classification + position evolution
into a single scrollable "story" canvas.
- Overview's "Fastest Lap" picks the minimum non-pit-out `lap_duration` from
the ingested laps payload. Sessions that don't ingest laps show "No data
ingested" — accurate, but a future phase could fall back to OpenF1's
`fastest_lap` field if/when that lands locally.
- The inline weekend switcher fetches the active meeting's `/weekend`
payload only when expanded. Switching seasons or browsing many
meetings does not pre-warm sibling weekend queries; this is intentional
to avoid the N×weekend fan-out that Command Center already pays.
- Visual baselines are regenerated against the current seeded e2e DB. If
the seeded session list grows, the desktop snapshot will widen.
- Country accents in `gpIdentity.ts` remain a hand-tuned subset; unknown
codes fall back to a neutral gray (same behavior as Command Center).
## Related
- [21 MVP Completion Checklist](21-mvp-completion-checklist.md)
- [22 Phase 14 Visual Regression](22-phase-14-visual-regression.md)
- [25 Phase 18 Fan Command Center](25-phase-18-fan-command-center.md)

View File

@@ -0,0 +1,110 @@
# Phase 19B Paddock Briefing RSS Backend Spike
## Goal
Prototype the backend foundation for a future fan-facing Paddock Briefing module
without scraping article pages or touching Race Hub UI. The spike keeps news as a
local-first cache: feeds can be fetched and normalized by backend code, stored in
SQLite, and read through a small API shape.
## Source Evaluation
Recommended first-party or publisher-owned feeds:
- FIA official RSS, `https://www.fia.com/rss/news`: keep as the official source
for federation announcements and regulatory context.
- BBC Sport F1, `https://feeds.bbci.co.uk/sport/formula1`: strong free headline
source for UK-centered coverage.
- Autosport F1, `https://www.autosport.com/rss/f1/news/`: useful motorsport
specialist feed; retain summaries only when provided by the feed.
- RaceFans F1, `https://www.racefans.net/category/f1-news/feed/`: useful
independent specialist feed with a clean WordPress RSS surface.
- Guardian Formula One,
`https://www.theguardian.com/sport/formulaone/rss`: broad editorial coverage
and stable RSS conventions.
Optional sources to evaluate before shipping:
- RACER F1, `https://racer.com/f1/feed`: reasonable supplemental specialist
feed.
- Formula 1 YouTube Atom,
`https://www.youtube.com/feeds/videos.xml?channel_id=UCB_qr75-ydFVKSF9Dmo6izg`:
video-only briefing cards, separate from article news.
- Motorsport.com F1, `https://www.motorsport.com/rss/f1/news/`: not included in
the default prototype list until terms and caching expectations are reviewed.
Avoid Formula1.com scraping or hidden endpoints, X/Twitter scraping, Reddit as a
primary news source, and feed aggregator products such as RSS.app or Feedspot.
## Legal And Product Caveats
Only fetch publisher-provided RSS/Atom XML. Do not fetch article bodies, bypass
paywalls, scrape Open Graph metadata, or store full article content. The product
surface should show source, title, canonical URL, publish time, category, and a
short feed-provided summary/snippet when available. Each card should link users
to the publisher site for the article.
Before enabling a source by default, review the publisher feed terms, robots/TOS
language around caching, and whether feed summaries are intended for display.
Keep TTLs conservative and make source attribution visible in the UI.
## Implemented Proof
This spike adds:
- `internal/news`: a standard-library RSS/Atom parser and polite fetch helper
with a 10-second default timeout and a box-box User-Agent.
- URL-based deduplication with UTM parameter stripping.
- `news_sources` and `news_items` tables in SQLite migration `003_news.sql`.
- Store/query methods for upserting cached feed metadata/items and listing
newest cached items.
- `GET /api/v1/news`, with optional `limit` and `source` query params.
- Unit tests using local XML fixtures only.
The endpoint is intentionally read-only against the local SQLite cache. It does
not fetch feeds during web requests, avoiding unexpected network work in the
product UI path. A later ingestion command can call `internal/news.Fetch`, upsert
sources/items, and mark `fetched_at`/`expires_at` according to a TTL policy.
## API Shape
`GET /api/v1/news?limit=25&source=racefans-f1`
Response:
```json
[
{
"source": "racefans-f1",
"title": "Example headline",
"url": "https://publisher.example/story",
"published_at": "2026-05-25T14:00:00Z",
"summary": "Feed-provided snippet",
"category": "news",
"fetched_at": "2026-05-25T14:10:00Z"
}
]
```
Default limit is 25; maximum accepted limit is 100.
## Follow-Up Frontend Plan
Add a Paddock Briefing surface outside Race Hub while Race Hub redesign work is
active. Recommended first UI slice:
- Query `/api/v1/news?limit=12`.
- Group by recency with source badges and external-link treatment.
- Show snippets only when present, with clear publisher attribution.
- Add source filters after the cache refresh command exists.
- Treat video feed items as a separate rail or filter, not mixed into hard-news
headlines by default.
## Open Questions
- Should news refresh live behind an explicit CLI command, opportunistic startup
refresh, or a manual button in an admin/data-health screen?
- What default TTL should each source use? A 15-30 minute TTL is reasonable for
race weekends; longer may be enough outside live sessions.
- Should the cache keep historical briefing items indefinitely, or prune after a
rolling window such as 30-90 days?

View File

@@ -0,0 +1,235 @@
# Orchestrator Handoff
You are taking over as the primary orchestration/coding agent for the box-box refactor.
Repo:
```text
/Users/aman/HomeBase/Programming/Projects/box-box
```
Branch:
```text
box-refactor
```
## Role
You are the engineering manager/orchestrator. Inspect before acting, keep changes scoped, review agent work before committing, prune stale docs after phases, and commit cleanly after each accepted phase. The user prefers Cursor for backend/test/hardening work and Claude for major frontend/product/design work, but you may implement directly when appropriate.
## Operating Rules
- Do not rush into implementation if the user wants to discuss.
- If implementing, keep phases small and commit-ready.
- Commit after each completed/reviewed phase.
- Never revert user/other-agent changes without explicit permission.
- Use `rg` for searches.
- Use `apply_patch` for manual edits.
- For frontend work, run browser or Playwright verification where practical.
- For review requests, lead with findings and file/line references.
- `frontend/dist` is ignored and should not be committed.
- Preserve TUI live mode and official F1 SignalR live behavior carefully.
## Project Direction
box-box started as a Go Bubble Tea F1 TUI backed mostly by OpenF1. The refactor direction is now:
- Web UI is the primary product surface.
- React + TypeScript frontend is the production Web UI stack.
- Go backend remains the API/server.
- Historical/completed-session data should be local-first from SQLite.
- OpenF1 ingestion is explicit via CLI, not fetched live on every page load.
- Official F1 SignalR remains the live source.
- Desired product feel: clean, dense, technical F1 operations room. Avoid card-heavy AI-slop.
## Important Commands
```bash
git status --short
git log --oneline -10
go test ./...
npm --prefix frontend test -- --run
npm --prefix frontend run build
npm run test:e2e
npm run test:e2e:prod
npm run test:visual
npm run test:visual:prod
```
Known test note: do not run Playwright suites that share the same seeded SQLite DB in parallel. Run prod E2E and prod visual sequentially, or they may hit `database is locked`.
## Recent Commits
- `a0f135a Update operator documentation`
- `79b0b9f Rework command center for race weekends`
- `84a8827 Add paddock briefing RSS backend spike`
- `ee88a07 Add paddock briefing feed ingestion`
- `Rework Race Hub as weekend workspace` (latest Phase 19 commit)
## Current State
Phase 19 and Phase 20 have been reviewed and committed. Start new work from a
clean tree unless `git status --short` shows user changes made after this
handoff.
## Completed Phase 19: Weekend Workspace / Race Hub Flow V1
Claude implemented, and Codex reviewed/committed, a Race Hub redesign that turns
`/race-hub` into a Weekend Workspace.
- New:
- `frontend/src/components/OverviewView.tsx`
- `frontend/src/components/WeekendSwitcher.tsx`
- `frontend/src/test/RaceHubPage.test.tsx`
- `documentations/refactor/26-phase-19-weekend-workspace.md`
- Modified:
- `frontend/src/pages/RaceHubPage.tsx`
- `frontend/src/components/TabBar.tsx`
- `frontend/src/components/DatasetStatusView.tsx`
- `frontend/src/styles/app.css`
- tests for TabBar, DatasetStatusView, race-hub, command-center, data-library, production-smoke
- `tests/visual/helpers.ts`
- race-hub visual snapshots
- root `README.md`
- `documentations/refactor/README.md`
UX changes:
- Race Hub is now a Weekend Workspace.
- Compact GP identity band with country decal/accent strip.
- Horizontal session rail replaces “big table then analysis below.”
- Active session context stays visible above tabs.
- Tabs regrouped into Overview, Race Story, Strategy, Lap Data, Conditions, Race Control, Data Status.
- New Overview tab with winner/pole/fastest/podium cards, condition chips, latest race control, and local coverage meter.
- Inline Switch Weekend panel replaces legacy LocalDataNavigator on Race Hub.
- Data Status links to `/admin`; no CLI/admin text on fan surface.
- Mobile/iPad improved with wrapping identity band, horizontal session rail, single-column stats.
- `/race-hub?session_key=9472` still works and loads Bahrain GP 2024 seeded session.
- Bare `/race-hub` now resolves to a focus weekend/session via `pickFocusMeeting` and navigation replace.
Verification run by Codex before commit:
- `npm --prefix frontend test -- --run`
- `npm --prefix frontend run build`
- `npm run test:e2e`
- `npm run test:e2e:prod`
- `npm run test:visual`
- `npm run test:visual:prod`
Small review fix included: `frontend/src/test/setup.ts` stubs
`window.scrollTo` so TanStack Router scroll restoration does not spam jsdom test
stderr.
## Completed Phase 20: Paddock Briefing Ingestion CLI
A backend subagent implemented, and Codex reviewed/committed, Phase 20 after the
RSS backend spike.
Phase 20 changes:
- Modified:
- `cmd/main.go`
- New:
- `internal/news/refresh.go`
- `internal/news/refresh_test.go`
- `documentations/refactor/29-phase-20-paddock-briefing-ingestion.md`
Behavior:
- Adds `--ingest-news` as a CLI mode.
- Keeps it mutually exclusive with `--ingest-year`, `--ingest-meeting`, and
`--ingest-session`.
- Reuses `--db` for the domain SQLite path.
- Reuses `--dry-run` to fetch and report feed counts without opening or writing
the domain database.
- Uses `internal/news.Refresh`, which fetches `DefaultSources`, upserts
`news_sources`, upserts URL-deduped `news_items`, records `fetched_at` and
`expires_at`, and continues through individual feed failures before returning
a summary error.
- Web requests still do not fetch feeds; `/api/v1/news` remains read-only
against SQLite.
Commands added:
```bash
go run ./cmd/main.go --ingest-news
go run ./cmd/main.go --dry-run --ingest-news
go run ./cmd/main.go --ingest-news --db /tmp/boxbox.db
```
Verification run by Codex before commit:
```bash
go test ./cmd/... ./internal/news ./internal/store
git diff --check
```
## Immediate Task
Start with a quick sync:
```bash
git status --short
git diff --stat
```
Then continue with the next requested phase. The most natural next phase is
Phase 21: Paddock Briefing UI, unless the user wants to deepen Race Story first.
## RSS / Paddock Briefing Context
Cursor completed and Codex committed a backend spike as `84a8827 Add paddock briefing RSS backend spike`.
Implemented:
- `internal/news`: RSS/Atom parser and fetch helper.
- SQLite tables:
- `news_sources`
- `news_items`
- Store/query methods for cached news.
- Read-only API:
- `GET /api/v1/news?limit=25&source=racefans-f1`
- No request-time network fetching.
- Unit tests use local XML fixtures.
Recommended feed sources:
- FIA official RSS
- BBC Sport F1
- Autosport F1
- RaceFans F1
- Guardian Formula One
Optional:
- Motorsport.com
- RACER
- Formula 1 YouTube Atom
Avoid:
- Formula1.com scraping/hidden endpoints
- X/Twitter scraping
- Reddit as primary source
- RSS.app/Feedspot as primary source
## Likely Next Phases After Phase 19 And 20
1. Phase 21: Paddock Briefing UI
- Claude/frontend.
- Add fan-facing briefing module, likely on Command Center first.
- Query `/api/v1/news`.
- Show source, title, age, category, short feed-provided snippet, external link.
- Keep publisher attribution visible.
- Avoid full article storage or scraping.
2. Phase 22: Race Story Deepening
- Claude/frontend or mixed.
- Collapse legacy classification/grid/position components into a more fluid Race Story canvas.
- Improve mobile scanning and session narrative.
3. Phase 23: Full Season Backfill / ingest hardening
- Cursor/backend.
- Safer season workflows, resumability, rate-limit controls, coverage reporting.

View File

@@ -0,0 +1,47 @@
# Phase 20 Paddock Briefing Ingestion CLI
## Goal
Turn the Phase 19B RSS backend spike into an explicit local refresh command for
the Paddock Briefing cache. Feed fetching remains a CLI-only operation; web
requests continue to read SQLite only.
## Implemented
- Added `--ingest-news` as a CLI ingestion mode on `cmd/main.go`.
- Reused `--db` path behavior from the existing OpenF1 ingestion flows.
- Reused `--dry-run` to fetch and report feed counts without opening or writing
the domain database.
- Added `internal/news.Refresh`, which:
- fetches `internal/news.DefaultSources` unless tests provide a custom list;
- uses `internal/news.Fetch` with a 10-second HTTP client timeout;
- upserts `news_sources` with `fetched_at` and `expires_at`;
- upserts URL-deduped `news_items`;
- continues after individual source failures and returns a summary error after
successful sources are stored.
## CLI
```bash
go run ./cmd/main.go --ingest-news
go run ./cmd/main.go --dry-run --ingest-news
go run ./cmd/main.go --ingest-news --db /tmp/boxbox.db
```
`--ingest-news` is mutually exclusive with `--ingest-year`, `--ingest-meeting`,
and `--ingest-session`.
## Verification
Tests use local `httptest.Server` feeds only. No live internet test is required
for the refresh logic.
```bash
go test ./internal/news ./internal/store
```
## Notes
The refresh TTL is currently 30 minutes for all sources. A future phase can add
source-specific TTLs, retention/pruning, or admin UI controls without changing
the read-only `/api/v1/news` contract.

View File

@@ -0,0 +1,128 @@
# box-box Refactor Brief
## Operator guide
For day-to-day build, run, ingest, test, and Web route commands, use the
top-level [README.md](../../README.md). This directory is for architecture,
phasing, and research — not the first stop for new contributors.
## Purpose
This directory captures the planning baseline for the next major evolution of
`box-box`. The current project has a strong live timing core, especially through
the official F1 live feed, but the rest of the app still behaves like an
on-demand OpenF1 client. That makes historical and session data unreliable,
especially during live-session API lockouts.
The refactor direction is to make the Web UI the primary product surface, make
historical data local-first, and preserve the TUI live mode that already works
well. These documents are intentionally strategic and research-ready. They are
not implementation tickets yet.
## Strategic Defaults
- Frontend: React + TypeScript, built with Vite.
- Backend: Go remains the application and API server.
- Storage: SQLite becomes a real local domain database, not only an HTTP cache.
- Historical ingestion: OpenF1 REST is the first ingestion/backfill source.
- Live timing: official F1 SignalR remains the live source.
- Ingestion model: explicit CLI backfill plus opportunistic small web fetches.
- TUI: preserve the current live mode; new historical/analytics work focuses on
the Web UI first.
- Product stance: race-weekend first, local-first, no rushed implementation.
- Live persistence: persist live SignalR events/snapshots as a separate
append-only stream once the live bridge is extracted; do not merge them into
post-session OpenF1 records without a reconciliation design.
- Migration: keep the current raw HTTP cache behavior intact while introducing
the new domain database incrementally.
## Documents
- [01 Data Sources](01-data-sources.md): current and candidate data sources,
source authority, limitations, and open questions.
- [02 Backend Architecture](02-backend-architecture.md): proposed backend
packages, local-first reads, ingestion policy, and live bridge boundaries.
- [03 Database Design](03-database-design.md): target SQLite strategy, raw
payload storage, normalized tables, provenance, and research questions.
- [04 Web UI Product](04-web-ui-product.md): screen architecture, navigation,
responsive behavior, and product priorities.
- [05 Frontend Stack](05-frontend-stack.md): React stack choice and supporting
libraries.
- [06 Visual Design Direction](06-visual-design-direction.md): F1-native visual
principles and anti-patterns to avoid.
- [07 Research Agents Brief](07-research-agents-brief.md): research tracks for
dedicated agents before ticket planning.
- [08 V1 Scope and Phasing](08-v1-scope-and-phasing.md): first shippable
milestone, non-goals, phase order, and early implementation sequence.
- [09 Phase 1 Live Extraction](09-phase-1-live-extraction.md): first coding
slice, package boundaries, tests, acceptance criteria, and non-goals.
- [10 Phase 2 Store Foundation](10-phase-2-store-foundation.md): second coding
slice for introducing the local SQLite domain store without changing product
behavior.
- [11 Phase 3 Ingestion Foundation](11-phase-3-ingestion-foundation.md): third
coding slice for OpenF1-to-store ingestion orchestration.
- [12 Phase 4 Local-First Web API](12-phase-4-local-first-web-api.md): fourth
coding slice for store-backed Race Hub read models and Web API metadata.
- [13 Phase 5 React Race Hub](13-phase-5-react-race-hub.md): first frontend
implementation slice for the production Web UI.
- [14 Phase 6 React Race Hub Analytics](14-phase-6-react-race-hub-analytics.md):
next frontend slice for strategy, position, and richer Race Hub views.
- [15 Phase 7 Analytics Data Foundation](15-phase-7-analytics-data-foundation.md):
backend slice for laps, stints, pits, race control, weather, and positions.
- [16 Phase 8 Analytics Visuals](16-phase-8-analytics-visuals.md): frontend
slice for turning the newly available analytics datasets into useful Race Hub
views.
- [17 Phase 9 Navigation Data API](17-phase-9-navigation-data-api.md): backend
slice for local-first season/weekend/session navigation so users do not need
raw session keys.
- [18 Phase 10 Navigation UI](18-phase-10-navigation-ui.md): frontend slice for
adding local-first season/weekend navigation around Race Hub.
- [19 Phase 11 Weekend Ingestion](19-phase-11-weekend-ingestion.md): backend
slice for making one command ingest a whole race weekend into the local DB.
- [20 Phase 12 Data Library UI](20-phase-12-data-library-ui.md): frontend slice
for showing local ingestion coverage and next CLI actions.
- [21 MVP Completion Checklist](21-mvp-completion-checklist.md): current
implementation status, verification commands, and remaining post-MVP work.
- [22 Phase 14 Visual Regression](22-phase-14-visual-regression.md): Playwright
screenshot coverage for MVP routes and responsive viewports.
- [23 Phase 15 Command Center](23-phase-15-command-center.md): default Web
entry screen for local coverage, weekend focus, live status, and next actions.
- [24 Phase 16 Live Timing Polish](24-phase-16-live-timing-polish.md): denser
React live timing layout, status treatment, and race-control polish.
- [25 Phase 18 Fan Command Center](25-phase-18-fan-command-center.md): reworks
`/` around race-weekend identity and splits ingestion/admin concerns into
the new `/admin` (Data Health) route.
- [26 Phase 19 Weekend Workspace](26-phase-19-weekend-workspace.md): rebuilds
`/race-hub` as a session-card-rail workspace with Overview / Race Story /
Strategy / Lap Data / Conditions / Race Control / Data Status tabs,
an inline weekend switcher, and GP-accent identity treatment.
- [27 Phase 19B Paddock Briefing RSS](27-phase-19b-paddock-briefing-rss.md):
backend spike for publisher-owned RSS/Atom feeds, local SQLite caching, and a
future fan-facing briefing API.
- [29 Phase 20 Paddock Briefing Ingestion](29-phase-20-paddock-briefing-ingestion.md):
backend CLI slice for refreshing RSS/Atom feeds into the local news cache.
## External References
- OpenF1 documentation: https://openf1.org/docs/
- Official F1 SignalR endpoint: https://livetiming.formula1.com/signalr
- LiveF1 timing topic reference:
https://livef1.goktugocal.com/livetimingf1/data_topics.html
- OpenF1.Data package notes on F1 SignalR:
https://www.nuget.org/packages/OpenF1.Data/1.0.87
## Current Repo Context
The existing application already has:
- A Go OpenF1 client in `internal/api`.
- A SQLite-backed raw HTTP cache and track outline persistence.
- A Bubble Tea TUI in `internal/ui`.
- A Go-served Web UI in `internal/web`.
- A live SignalR bridge extracted into `internal/live` and reused by both TUI
and Web mode through server-sent events.
The refactor should build on that progress instead of replacing it blindly.
The goal is to separate source fetching, domain persistence, query/read models,
and frontend experience so each layer can be improved without destabilizing the
others.

33
frontend/README.md Normal file
View File

@@ -0,0 +1,33 @@
# box-box Web Frontend
React Race Hub slice for the production Web UI.
## Commands
```bash
npm install
npm run dev
npm test -- --run
npm run build
```
The Vite dev server proxies `/api` requests to the Go server on
`http://localhost:8080`.
Run the backend separately:
```bash
go run cmd/main.go --web
```
Then open the React app, usually:
```text
http://localhost:5173/race-hub
```
Load an ingested session with:
```text
http://localhost:5173/race-hub?session_key=9472
```

32
frontend/index.html Normal file
View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#0d0d0d" />
<meta name="description" content="box-box: A premium Live F1 Dashboard and Timing Tower" />
<!-- Open Graph / Social -->
<meta property="og:title" content="box-box | Live F1 Dashboard" />
<meta property="og:description" content="Real-time F1 timing, telemetry, and race dashboard." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://box-box.amantahiliani.com/" />
<meta property="og:image" content="https://box-box.amantahiliani.com/favicon.jpg" />
<!-- Icons & PWA -->
<link rel="icon" type="image/jpeg" href="/favicon.jpg" />
<link rel="apple-touch-icon" href="/favicon.jpg" />
<link rel="manifest" href="/manifest.json" />
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700;800&display=swap" rel="stylesheet">
<title>box-box | Live F1 Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4465
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
frontend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "box-box-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@formkit/auto-animate": "^0.9.0",
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.81.0",
"lucide-react": "^1.23.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^25.0.1",
"typescript": "^5.7.2",
"vite": "^6.0.7",
"vitest": "^2.1.8"
}
}

BIN
frontend/public/favicon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

View File

@@ -0,0 +1,15 @@
{
"short_name": "box-box",
"name": "box-box | Live F1 Dashboard",
"icons": [
{
"src": "/favicon.jpg",
"type": "image/jpeg",
"sizes": "192x192 512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#0d0d0d",
"background_color": "#0d0d0d"
}

256
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,256 @@
import { apiFetch } from './lib/fetch'
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, signal?: AbortSignal): Promise<RaceHub> {
return apiFetch<RaceHub>(`/api/v1/race-hub?session_key=${sessionKey}`, {
signal,
dedupeKey: `race-hub:${sessionKey}`,
})
}
export async function fetchSeasons(signal?: AbortSignal): Promise<number[]> {
const years = await apiFetch<number[]>('/api/v1/seasons', {
signal,
dedupeKey: 'seasons',
})
return Array.isArray(years) ? years : []
}
export async function fetchLocalMeetings(year: number, signal?: AbortSignal): Promise<Meeting[]> {
const meetings = await apiFetch<Meeting[]>(`/api/v1/meetings?year=${year}&source=local`, {
signal,
dedupeKey: `meetings:local:${year}`,
})
return Array.isArray(meetings) ? meetings : []
}
export async function fetchSeasonMeetings(year: number, signal?: AbortSignal): Promise<Meeting[]> {
return fetchMeetings(year, 'openf1', signal)
}
export async function fetchMeetings(
year: number,
source = 'auto',
signal?: AbortSignal,
): Promise<Meeting[]> {
const meetings = await apiFetch<Meeting[]>(`/api/v1/meetings?year=${year}&source=${source}`, {
signal,
dedupeKey: `meetings:${source}:${year}`,
})
return Array.isArray(meetings) ? meetings : []
}
export async function fetchResults(
sessionKey: number,
source = 'auto',
signal?: AbortSignal,
): Promise<EnrichedResult[]> {
const results = await apiFetch<EnrichedResult[]>(
`/api/v1/results?session_key=${sessionKey}&source=${source}`,
{ signal, dedupeKey: `results:${source}:${sessionKey}` },
)
return Array.isArray(results) ? results : []
}
export async function fetchStartingGrid(
sessionKey: number,
source = 'auto',
signal?: AbortSignal,
): Promise<EnrichedGrid[]> {
const grid = await apiFetch<EnrichedGrid[]>(
`/api/v1/grid?session_key=${sessionKey}&source=${source}`,
{ signal, dedupeKey: `grid:${source}:${sessionKey}` },
)
return Array.isArray(grid) ? grid : []
}
export async function fetchTrackOutline(
circuitKey: number,
year: number,
signal?: AbortSignal,
): Promise<TrackOutline | null> {
try {
const data = await apiFetch<TrackOutline & { error?: string }>(
`/api/v1/track-outline?circuit_key=${circuitKey}&year=${year}`,
{ signal, dedupeKey: `track-outline:${circuitKey}:${year}` },
)
if (data?.error || !Array.isArray(data?.points) || data.points.length < 2) return null
return data as TrackOutline
} catch {
return null
}
}
export async function fetchReplayFrames(
sessionKey: number,
intervalMs = 5000,
signal?: AbortSignal,
): Promise<ReplayFramesResponse> {
const params = new URLSearchParams({
session_key: String(sessionKey),
interval_ms: String(intervalMs),
})
return apiFetch<ReplayFramesResponse>(`/api/v1/replay/frames?${params}`, {
signal,
dedupeKey: `replay-frames:${sessionKey}:${intervalMs}`,
})
}
export async function fetchSessions(
meetingKey: number,
source = 'openf1',
signal?: AbortSignal,
): Promise<Session[]> {
const sessions = await apiFetch<Session[]>(
`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`,
{ signal, dedupeKey: `sessions:${source}:${meetingKey}` },
)
return Array.isArray(sessions) ? sessions : []
}
export async function fetchWeekend(meetingKey: number, signal?: AbortSignal): Promise<Weekend> {
return apiFetch<Weekend>(`/api/v1/weekend?meeting_key=${meetingKey}`, {
signal,
dedupeKey: `weekend:${meetingKey}`,
})
}
// fetchWeekendContext consumes the canonical /api/v1/weekend-context endpoint
// (backend story #72). The response is the authoritative WeekendContext shape and
// is used verbatim as the Weekend home's source of truth. Any HTTP error throws
// so the hook can surface an explicit error state; there is no client-side
// re-derivation of the contract. Race Hub bare-default landing also reads this
// for `default_analysis_session` (#75).
export async function fetchWeekendContext(signal?: AbortSignal): Promise<WeekendContext> {
return apiFetch<WeekendContext>('/api/v1/weekend-context', {
signal,
dedupeKey: 'weekend-context',
})
}
export async function fetchChampionshipHub(
year?: number,
signal?: AbortSignal,
): Promise<ChampionshipHub> {
const params = new URLSearchParams({ source: 'auto' })
if (year) params.set('year', year.toString())
return apiFetch<ChampionshipHub>(`/api/v1/championship/hub?${params.toString()}`, {
signal,
dedupeKey: `championship-hub:${year ?? 'latest'}`,
})
}
export async function fetchDriverSummary(
driverNumber: number,
year?: number,
signal?: AbortSignal,
): Promise<DriverSummary> {
const params = new URLSearchParams({ driver_number: String(driverNumber), source: 'auto' })
if (year) params.set('year', String(year))
return apiFetch<DriverSummary>(`/api/v1/driver/summary?${params.toString()}`, {
signal,
dedupeKey: `driver-summary:${driverNumber}:${year ?? 'latest'}`,
})
}
export async function fetchLiveState(signal?: AbortSignal): Promise<LiveStateResponse> {
return apiFetch<LiveStateResponse>('/api/v1/live/state', {
signal,
dedupeKey: 'live-state',
})
}
export async function fetchLiveTrackOutline(
session: LiveSessionMeta,
year = new Date().getFullYear(),
signal?: AbortSignal,
): Promise<TrackOutline> {
const params = new URLSearchParams({ year: year.toString() })
if (session.MeetingName) params.set('meeting_name', session.MeetingName)
if (session.CircuitName) params.set('circuit_name', session.CircuitName)
return apiFetch<TrackOutline>(`/api/v1/track-outline?${params.toString()}`, {
signal,
dedupeKey: `live-track-outline:${year}:${session.MeetingName ?? ''}:${session.CircuitName ?? ''}`,
})
}
export async function fetchNews(
limit?: number,
source?: string,
signal?: AbortSignal,
): Promise<NewsItem[]> {
const params = new URLSearchParams()
if (limit) params.set('limit', limit.toString())
if (source) params.set('source', source)
const query = params.toString()
const url = query ? `/api/v1/news?${query}` : '/api/v1/news'
return apiFetch<NewsItem[]>(url, {
signal,
dedupeKey: `news:${limit ?? 'all'}:${source ?? 'all'}`,
})
}
export async function fetchNewsArticle(
articleUrl: string,
signal?: AbortSignal,
): Promise<ArticleContent> {
return apiFetch<ArticleContent>(
`/api/v1/news/article?url=${encodeURIComponent(articleUrl)}`,
{ signal },
)
}
export async function markNewsRead(articleUrl: string): Promise<void> {
await fetch('/api/v1/news/read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: articleUrl }),
})
}
export async function fetchTelemetry(
sessionKey: number,
driverNumber: number,
signal?: AbortSignal,
): Promise<CarDataSample[]> {
const data = await apiFetch<CarDataSample[]>(
`/api/v1/telemetry?session_key=${sessionKey}&driver_number=${driverNumber}`,
{ signal, dedupeKey: `telemetry:${sessionKey}:${driverNumber}` },
)
return Array.isArray(data) ? data : []
}
export async function fetchLapsComparison(
sessionKey: number,
drivers?: number[],
signal?: AbortSignal,
): Promise<LapsComparisonResponse> {
const params = new URLSearchParams({ session_key: String(sessionKey) })
if (drivers?.length) {
params.set('drivers', drivers.join(','))
}
return apiFetch<LapsComparisonResponse>(`/api/v1/laps/comparison?${params}`, {
signal,
dedupeKey: `laps-comparison:${sessionKey}:${drivers?.join(',') ?? 'all'}`,
})
}

View File

@@ -0,0 +1,233 @@
import { useEffect, useMemo, useState } from 'react'
import type { ChampionshipHub } from '../types'
import { teamColor } from '../utils'
import {
assignPosition,
defaultRound,
defaultScenario,
normalizeScenario,
pointsForPosition,
projectStandings,
} from '../lib/simulator'
import type { Scenario } from '../lib/simulator'
const STORAGE_PREFIX = 'box-box.champ.sim'
function storageKey(season: number): string {
return `${STORAGE_PREFIX}.${season}`
}
function loadScenario(hub: ChampionshipHub): Scenario {
try {
const raw = window.localStorage.getItem(storageKey(hub.season))
if (!raw) return defaultScenario(hub.drivers, hub.rounds_left)
return normalizeScenario(JSON.parse(raw), hub.drivers, hub.rounds_left)
} catch {
return defaultScenario(hub.drivers, hub.rounds_left)
}
}
function saveScenario(season: number, scenario: Scenario) {
try {
window.localStorage.setItem(storageKey(season), JSON.stringify(scenario))
} catch {
// storage unavailable (private mode, quota) — simulator still works in memory
}
}
/** Label for the i-th remaining round (0-based), e.g. "R7" or "Round 7". */
function roundLabel(hub: ChampionshipHub, index: number): string {
return hub.round_labels[hub.round + index] ?? `Round ${hub.round + index + 1}`
}
function fmtPts(n: number): string {
return Number.isInteger(n) ? String(n) : n.toFixed(1)
}
export function ChampionshipSimulator({ hub }: { hub: ChampionshipHub }) {
const [scenario, setScenario] = useState<Scenario>(() => loadScenario(hub))
const [selected, setSelected] = useState(0)
// Reload when the season changes (new hub, new storage key).
useEffect(() => {
setScenario(loadScenario(hub))
setSelected(0)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hub.season, hub.rounds_left])
useEffect(() => {
if (scenario.length > 0) saveScenario(hub.season, scenario)
}, [hub.season, scenario])
const projected = useMemo(
() => projectStandings(hub.drivers, scenario, hub.rounds_left),
[hub.drivers, scenario, hub.rounds_left],
)
if (hub.rounds_left <= 0 || hub.drivers.length === 0 || scenario.length === 0) {
return (
<div className="champ-chart-empty" data-testid="champ-view-simulator">
Season complete nothing left to simulate.
</div>
)
}
const roundIdx = Math.min(selected, scenario.length - 1)
const round = scenario[roundIdx]
const setRound = (nextRound: (number | null)[]) => {
setScenario((prev) => prev.map((r, i) => (i === roundIdx ? nextRound : r)))
}
const driverByNumber = new Map(hub.drivers.map((d) => [d.driver_number, d]))
return (
<div className="champ-sim" data-testid="champ-view-simulator">
<div className="champ-sim-head">
<span className="champ-chart-title mono">What-if simulator remaining rounds</span>
<button
type="button"
className="champ-sim-btn"
onClick={() => setScenario(defaultScenario(hub.drivers, hub.rounds_left))}
data-testid="sim-reset-all"
>
Reset all
</button>
</div>
<div className="champ-sim-rounds" role="tablist" aria-label="Remaining rounds">
{scenario.map((_, i) => (
<button
key={i}
type="button"
className={`champ-sim-round${i === roundIdx ? ' is-active' : ''}`}
onClick={() => setSelected(i)}
data-testid={`sim-round-${i}`}
>
{roundLabel(hub, i)}
</button>
))}
</div>
<div className="champ-sim-grid">
<div className="champ-sim-editor" data-testid="sim-editor">
<div className="champ-sim-editor-head">
<span className="champ-sim-editor-title mono">{roundLabel(hub, roundIdx)} finishing order</span>
<button
type="button"
className="champ-sim-btn"
onClick={() => setRound(defaultRound(hub.drivers))}
data-testid="sim-reset-round"
>
Reset round
</button>
</div>
{round.map((driverNumber, p) => {
const driver = driverNumber != null ? driverByNumber.get(driverNumber) : undefined
return (
<div className="champ-sim-slot" key={p}>
<span className="champ-sim-slot-pos mono">P{p + 1}</span>
<span
className="champ-sim-slot-bar"
style={{ background: driver ? teamColor(driver.team_colour) : 'var(--border)' }}
/>
<select
className="champ-sim-select mono"
value={driverNumber ?? ''}
aria-label={`P${p + 1} driver`}
data-testid={`sim-pos-${p + 1}`}
onChange={(e) => {
const value = e.target.value
setRound(assignPosition(round, p, value === '' ? null : Number(value)))
}}
>
<option value=""></option>
{hub.drivers.map((d) => (
<option key={d.driver_number} value={d.driver_number}>
{d.name_acronym} · {d.full_name}
</option>
))}
</select>
<span className="champ-sim-slot-pts mono">+{pointsForPosition(p + 1)}</span>
</div>
)
})}
</div>
<div className="champ-sim-table-wrap">
<div className="champ-sim-editor-head">
<span className="champ-sim-editor-title mono">Projected standings</span>
</div>
<div className="champ-scroll">
<table className="champ-table champ-sim-table" data-testid="sim-projected">
<thead>
<tr>
<th className="l">Pos</th>
<th className="c">Δ</th>
<th className="l">Driver</th>
<th className="r">Now</th>
<th className="r">+Sim</th>
<th className="r">Proj</th>
<th className="r">Title</th>
</tr>
</thead>
<tbody>
{projected.map((row) => {
const d = row.driver
const moved = row.delta !== 0
return (
<tr
key={d.driver_number}
className={moved ? 'champ-sim-row-moved' : undefined}
data-testid={`sim-row-${d.driver_number}`}
>
<td className="mono" style={{ fontWeight: 700 }}>
P{row.projectedPosition}
</td>
<td className="c mono">
{row.delta > 0 && (
<span className="champ-sim-delta up">{row.delta}</span>
)}
{row.delta < 0 && (
<span className="champ-sim-delta down">{-row.delta}</span>
)}
{row.delta === 0 && <span className="champ-td-dim"></span>}
</td>
<td>
<div className="champ-drv">
<span
className="champ-drv-bar"
style={{ background: teamColor(d.team_colour) }}
/>
<span className="champ-drv-code mono">{d.name_acronym}</span>
<span className="champ-drv-name">{d.full_name}</span>
</div>
</td>
<td className="r mono champ-td-muted">{fmtPts(row.currentPoints)}</td>
<td className="r mono champ-td-dim">+{fmtPts(row.simPoints)}</td>
<td className="r mono champ-td-pts">{fmtPts(row.projectedPoints)}</td>
<td className="r">
<span
className="champ-alive mono"
style={{ color: row.titleAlive ? 'var(--green)' : 'var(--text-3)' }}
>
{row.titleAlive ? 'ALIVE' : 'OUT'}
</span>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>
<p className="champ-sim-caption">
Simplified model: every remaining round is scored as a standard Grand Prix
(25-18-15-12-10-8-6-4-2-1, no fastest-lap point). Sprint weekends are ignored. Title status
uses the max-points-remaining bound against the leader&apos;s projected total.
</p>
</div>
)
}

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,67 @@
import { useState } from 'react'
import { Copy, Check } from 'lucide-react'
interface Command {
comment?: string
cmd: string
}
interface Props {
commands: Command[]
}
export function CliCommands({ commands }: Props) {
return (
<div className="cli-block" data-testid="cli-commands">
{commands.map(({ comment, cmd }, i) => (
<div key={cmd} className="cli-entry">
{comment && <div className="cli-comment">{comment}</div>}
<CliCommandLine cmd={cmd} />
{i < commands.length - 1 && <div className="cli-spacer" />}
</div>
))}
</div>
)
}
function CliCommandLine({ cmd }: { cmd: string }) {
const [copied, setCopied] = useState(false)
async function handleCopy() {
try {
await navigator.clipboard.writeText(cmd)
setCopied(true)
window.setTimeout(() => setCopied(false), 1500)
} catch {
// clipboard may be unavailable in tests
}
}
return (
<div className="cli-cmd-row">
<code className="cli-cmd">{cmd}</code>
<button type="button" className="cli-copy-btn interactive" onClick={handleCopy} aria-label={`Copy ${cmd}`} style={{ display: 'flex', alignItems: 'center', gap: '4px', padding: '4px 8px', background: copied ? 'var(--green)' : 'rgba(255,255,255,0.05)', color: copied ? '#000' : 'var(--text)', border: 'none', borderRadius: '4px', cursor: 'pointer', transition: 'all 0.2s' }}>
{copied ? <Check size={14} /> : <Copy size={14} />}
<span>{copied ? 'Copied' : 'Copy'}</span>
</button>
</div>
)
}
export function ingestYearCommands(year: number): Command[] {
return [
{ comment: '# Discover season meetings and sessions', cmd: `box-box --ingest-year ${year}` },
{ comment: '# Preview season discovery only', cmd: `box-box --ingest-year ${year} --dry-run` },
]
}
export function ingestMeetingCommands(meetingKey: number): Command[] {
return [
{ comment: '# Full weekend ingest (all sessions)', cmd: `box-box --ingest-meeting ${meetingKey}` },
{ comment: '# Preview without downloading', cmd: `box-box --ingest-meeting ${meetingKey} --dry-run` },
]
}
export function ingestSessionCommands(sessionKey: number): Command[] {
return [{ comment: '# Race Hub datasets for one session', cmd: `box-box --ingest-session ${sessionKey}` }]
}

View File

@@ -0,0 +1,415 @@
import { Link } from '@tanstack/react-router'
import { Activity, ArrowRight, Play } from 'lucide-react'
import { sessionTypeAbbrev } from '../lib/coverage'
import { countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
import { classifySessionStatus, type HeroStateKind } from '../lib/hero'
import { sortLiveTimingRows, trackStatusInfo } from '../lib/live'
import {
formatCountdown,
formatSessionScheduleTime,
meetingStartTime,
sessionStartTime,
type FocusMeetingKind,
} from '../lib/schedule'
import type { EnrichedResult, LiveStreamData, Meeting, Session, WeekendSession } from '../types'
import '../styles/hero.css'
export interface CommandCenterHeroProps {
state: HeroStateKind
now: Date
accent: string
liveActive: boolean
liveData: LiveStreamData | null | undefined
focusMeeting: Meeting
focusKind: FocusMeetingKind
sessions: WeekendSession[]
currentSession: Session | null
nextSession: Session | null
analysisSessionKey?: number
analysisSessionName?: string
lastRaceName: string
lastRacePodium: EnrichedResult[]
lastRaceSessionKey?: number
nextMeeting: Meeting | null
}
export function CommandCenterHero({
state,
now,
accent,
liveActive,
liveData,
focusMeeting,
focusKind,
sessions,
currentSession,
nextSession,
analysisSessionKey,
analysisSessionName,
lastRaceName,
lastRacePodium,
lastRaceSessionKey,
nextMeeting,
}: CommandCenterHeroProps) {
const accentStyle = {
'--gp-accent': accent,
'--hero-accent': accent,
} as React.CSSProperties
return (
<div className="hero-panel cc-hero" data-testid="cc-focus">
<section className="hero-card ui-card glass-panel" style={accentStyle}>
<div
className={`hero-accent${state === 'live' ? ' hero-accent--live' : ''}`}
aria-hidden="true"
/>
<div className="hero-body">
<div className="hero-inner">
{state === 'live' && (
<LiveHero
liveActive={liveActive}
liveData={liveData}
currentSession={currentSession}
focusMeeting={focusMeeting}
/>
)}
{state === 'upcoming' && (
<UpcomingHero
now={now}
focusMeeting={focusMeeting}
focusKind={focusKind}
sessions={sessions}
currentSession={currentSession}
nextSession={nextSession}
liveActive={liveActive}
analysisSessionKey={analysisSessionKey}
analysisSessionName={analysisSessionName}
/>
)}
{state === 'between' && (
<BetweenHero
now={now}
lastRaceName={lastRaceName}
lastRacePodium={lastRacePodium}
lastRaceSessionKey={lastRaceSessionKey}
nextMeeting={nextMeeting}
analysisSessionKey={analysisSessionKey}
analysisSessionName={analysisSessionName}
/>
)}
</div>
</div>
</section>
</div>
)
}
function LiveHero({
liveActive,
liveData,
currentSession,
focusMeeting,
}: {
liveActive: boolean
liveData: LiveStreamData | null | undefined
currentSession: Session | null
focusMeeting: Meeting
}) {
const sessionName =
liveData?.Session?.SessionName ?? currentSession?.session_name ?? 'Live session'
const trackStatus = trackStatusInfo(liveData?.TrackStatus)
const topThree = sortLiveTimingRows(liveData).filter((r) => r.Position > 0).slice(0, 3)
const decal = countryDecal(focusMeeting)
return (
<>
<div className="hero-row">
<span className="hero-decal mono">{decal}</span>
<div className="hero-identity">
<div className="hero-eyebrow hero-eyebrow--live mono"> Live now</div>
<h1 className="hero-title">{sessionName}</h1>
<div className="hero-sub mono">
{[focusMeeting.meeting_name, focusMeeting.circuit_short_name].filter(Boolean).join(' · ')}
</div>
<div className={`hero-track-status hero-track-status--${trackStatus.key}`}>
{trackStatus.label}
</div>
</div>
{topThree.length > 0 && (
<div className="hero-timing" data-testid="hero-live-timing">
{topThree.map((row) => (
<div key={row.RacingNumber} className="hero-timing-row">
<span className={`hero-timing-pos hero-timing-pos--p${row.Position}`}>
P{row.Position}
</span>
<span className="hero-timing-driver">
<span
className="hero-timing-bar"
style={{ background: `#${row.Info?.TeamColour ?? '9aa0a6'}` }}
/>
{row.Info?.Tla ?? row.RacingNumber}
</span>
<span className="hero-timing-gap">
{row.Position === 1 ? 'LEAD' : row.Driver.Interval || row.Driver.GapToLeader || '—'}
</span>
</div>
))}
</div>
)}
<div className="hero-countdown">
<div className="hero-cd-label mono">{liveActive ? 'SignalR' : 'On track'}</div>
<div className="hero-cd-value hero-cd-value--live mono">LIVE</div>
<div className="hero-cd-sub mono">{sessionName}</div>
</div>
</div>
<div className="hero-actions">
<Link to="/live" className="hero-cta hero-cta--primary" data-testid="hero-live-link">
Open Live Timing <ArrowRight size={16} />
</Link>
</div>
</>
)
}
function UpcomingHero({
now,
focusMeeting,
focusKind,
sessions,
currentSession,
nextSession,
liveActive,
analysisSessionKey,
analysisSessionName,
}: {
now: Date
focusMeeting: Meeting
focusKind: FocusMeetingKind
sessions: WeekendSession[]
currentSession: Session | null
nextSession: Session | null
liveActive: boolean
analysisSessionKey?: number
analysisSessionName?: string
}) {
const decal = countryDecal(focusMeeting)
const countdownTarget =
nextSession && sessionStartTime(nextSession)
? sessionStartTime(nextSession)!
: meetingStartTime(focusMeeting)
const kindLabel =
focusKind === 'current'
? 'Current weekend'
: focusKind === 'next'
? 'Next weekend'
: 'Weekend'
const sortedSessions = [...sessions].sort((a, b) => {
const left = sessionStartTime(a.session)?.getTime() ?? 0
const right = sessionStartTime(b.session)?.getTime() ?? 0
return left - right
})
return (
<>
<div className="hero-row">
<span className="hero-decal mono">{decal}</span>
<div className="hero-identity">
<div className="hero-eyebrow mono">{kindLabel}</div>
<h1 className="hero-title">{focusMeeting.meeting_name}</h1>
<div className="hero-sub mono">
{[focusMeeting.location, focusMeeting.circuit_short_name].filter(Boolean).join(' · ')}
</div>
<div className="hero-sub mono">{formatGpDateRange(focusMeeting)}</div>
</div>
<div className="hero-countdown" data-testid="hero-countdown">
{nextSession ? (
<>
<div className="hero-cd-label mono">Next · {nextSession.session_name}</div>
{countdownTarget && (
<div className="hero-cd-value mono">{formatCountdown(countdownTarget, now)}</div>
)}
<div className="hero-cd-sub mono">{formatSessionScheduleTime(nextSession.date_start)}</div>
</>
) : (
<>
<div className="hero-cd-label mono">Status</div>
<div className="hero-cd-value mono">Complete</div>
<div className="hero-cd-sub mono">Weekend finished</div>
</>
)}
</div>
</div>
{sortedSessions.length > 0 && (
<div className="hero-schedule-strip" data-testid="hero-schedule-strip" role="list">
{sortedSessions.map(({ session }) => {
const status = classifySessionStatus(session, now)
const isNext = nextSession?.session_key === session.session_key
const isCurrent = currentSession?.session_key === session.session_key
const isLive = isCurrent && liveActive
return (
<Link
key={session.session_key}
to="/race-hub"
search={{ session_key: session.session_key }}
className={`hero-schedule-card${isNext ? ' is-next' : ''}${
status === 'done' ? ' is-done' : ''
}${isLive ? ' is-live' : ''}`}
role="listitem"
>
<div className="hero-schedule-abbrev mono">
{sessionTypeAbbrev(session.session_type, session.session_name)}
</div>
<div className="hero-schedule-name">{session.session_name}</div>
<div className="hero-schedule-time mono">
{formatSessionScheduleTime(session.date_start)}
</div>
<div
className={`hero-schedule-marker mono${
isLive ? ' hero-schedule-marker--live' : isNext ? ' hero-schedule-marker--next' : ''
}`}
>
{isCurrent ? 'On track' : status === 'done' ? 'Done' : isNext ? 'Next' : 'Upcoming'}
</div>
</Link>
)
})}
</div>
)}
<div className="hero-actions">
<Link
to="/live"
className="hero-secondary-link"
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}
>
<Play size={14} /> Watch Live
</Link>
{analysisSessionKey != null && (
<Link
to="/race-hub"
search={{ session_key: analysisSessionKey }}
className="hero-secondary-link"
data-testid="hero-analysis-link"
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}
>
<Activity size={14} /> Open Analysis
{analysisSessionName ? ` · ${analysisSessionName}` : ''}
</Link>
)}
</div>
</>
)
}
function BetweenHero({
now,
lastRaceName,
lastRacePodium,
lastRaceSessionKey,
nextMeeting,
analysisSessionKey,
analysisSessionName,
}: {
now: Date
lastRaceName: string
lastRacePodium: EnrichedResult[]
lastRaceSessionKey?: number
nextMeeting: Meeting | null
analysisSessionKey?: number
analysisSessionName?: string
}) {
const nextStart = nextMeeting ? meetingStartTime(nextMeeting) : null
const podium = lastRacePodium
.filter((r) => r.position >= 1 && r.position <= 3)
.sort((a, b) => a.position - b.position)
return (
<>
<div className="hero-row">
<div className="hero-identity">
<div className="hero-eyebrow mono">Between race weekends</div>
{lastRaceName && (
<h1 className="hero-title" data-testid="hero-last-race">
After {lastRaceName}
</h1>
)}
{!lastRaceName && <h1 className="hero-title">Season pause</h1>}
</div>
{podium.length > 0 && (
<div className="hero-podium" data-testid="hero-podium">
{podium.map((r) => (
<div key={r.driver_number} className="hero-podium-row">
<span className={`hero-podium-pos hero-podium-pos--p${r.position} mono`}>
P{r.position}
</span>
<span className="hero-podium-driver">
<span
className="hero-timing-bar"
style={{ background: `#${r.team_colour}` }}
/>
{r.name_acronym}
</span>
</div>
))}
</div>
)}
{nextMeeting && (
<div className="hero-countdown" data-testid="hero-next-gp-countdown">
<div className="hero-between-next">
{countryFlag(nextMeeting) && (
<span className="hero-flag" aria-hidden="true">
{countryFlag(nextMeeting)}
</span>
)}
<div>
<div className="hero-cd-label mono">Next GP</div>
<div className="hero-title" style={{ fontSize: '20px' }}>
{nextMeeting.meeting_name}
</div>
</div>
</div>
{nextStart && (
<>
<div className="hero-cd-value mono" style={{ marginTop: 'var(--s3)' }}>
{formatCountdown(nextStart, now)}
</div>
<div className="hero-cd-sub mono">{formatGpDateRange(nextMeeting)}</div>
</>
)}
</div>
)}
</div>
<div className="hero-actions">
{lastRaceSessionKey != null && (
<Link
to="/race-hub"
search={{ session_key: lastRaceSessionKey }}
className="hero-secondary-link"
data-testid="hero-last-race-link"
>
View {lastRaceName || 'last race'} results
</Link>
)}
{analysisSessionKey != null && analysisSessionKey !== lastRaceSessionKey && (
<Link
to="/race-hub"
search={{ session_key: analysisSessionKey }}
className="hero-secondary-link"
data-testid="hero-analysis-link"
>
Open Analysis{analysisSessionName ? ` · ${analysisSessionName}` : ''}
</Link>
)}
<Link to="/live" className="hero-secondary-link">
Live Timing
</Link>
</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,88 @@
import { Link } from '@tanstack/react-router'
import { RACE_HUB_DATASETS } from '../lib/coverage'
import type { DatasetInfo } from '../types'
interface Props {
datasets: Record<string, DatasetInfo>
}
const DATASET_LABELS: Record<string, string> = {
meeting: 'Meeting',
session: 'Session',
drivers: 'Drivers',
results: 'Results',
starting_grid: 'Starting Grid',
stints: 'Stints',
pit_stops: 'Pit Stops',
positions: 'Positions',
race_control: 'Race Control',
weather: 'Weather',
laps: 'Laps',
}
export function DatasetStatusView({ datasets }: Props) {
const entries = RACE_HUB_DATASETS.map((key) => ({
key,
label: DATASET_LABELS[key] ?? key,
info: datasets[key] as DatasetInfo | undefined,
}))
const available = entries.filter((e) => e.info?.status === 'available').length
const total = entries.length
const missing = total - available
return (
<div data-testid="rh-data-status">
<div className="rh-coverage-meter" aria-hidden="true">
<div
className="rh-coverage-fill"
style={{ width: `${(available / total) * 100}%` }}
/>
</div>
<div className="ds-legend">
<span className="mono" style={{ color: 'var(--text-2)' }}>
{available}/{total} datasets local
</span>
{missing > 0 && (
<span style={{ color: 'var(--text-3)' }}>
{missing} dataset{missing === 1 ? '' : 's'} still missing {' '}
<Link to="/admin" className="rh-inline-link">
manage ingestion
</Link>
.
</span>
)}
</div>
<table className="data-table" style={{ maxWidth: 480 }}>
<thead>
<tr>
<th>Dataset</th>
<th>Status</th>
<th className="r">Records</th>
</tr>
</thead>
<tbody>
{entries.map(({ key, label, info }) => (
<tr key={key}>
<td className="mono" style={{ color: 'var(--text-2)' }}>
{label}
</td>
<td>
{info?.status === 'available' ? (
<span className="badge badge-local">Local</span>
) : info?.status === 'skipped' ? (
<span className="badge badge-none">N/A</span>
) : (
<span className="badge badge-none">Missing</span>
)}
</td>
<td className="r mono" style={{ color: 'var(--text-3)' }}>
{info?.count != null ? info.count : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,35 @@
import type { DatasetInfo } from '../types'
const DATASET_LABELS: Record<string, string> = {
meeting: 'meeting',
session: 'session',
drivers: 'drivers',
results: 'results',
starting_grid: 'grid',
}
interface Props {
datasets: Record<string, DatasetInfo>
}
export function DatasetStrip({ datasets }: Props) {
const keys = Object.keys(DATASET_LABELS)
return (
<div className="dataset-strip">
{keys.map((key) => {
const info = datasets[key]
const available = info?.status === 'available' || info?.status === 'skipped'
return (
<div key={key} className="ds-item" title={info ? `${info.status === 'skipped' ? 'N/A' : info.source} · ${info.count ?? 0} rows` : 'missing'}>
<div className={`ds-dot ${available ? 'ds-dot-local' : 'ds-dot-missing'}`} />
<span>{DATASET_LABELS[key]}</span>
{available && info.count != null && info.count > 0 && (
<span style={{ opacity: 0.5 }}>·{info.count}</span>
)}
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,17 @@
import { teamColor } from '../utils'
interface Props {
acronym: string
number: number
colour: string
}
export function DriverCell({ acronym, number, colour }: Props) {
return (
<div className="drv-cell">
<div className="drv-bar" style={{ background: teamColor(colour) }} />
<span className="drv-code">{acronym}</span>
<span className="drv-num">{number}</span>
</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,119 @@
import type { Driver, Lap } from '../types'
import { formatGap, formatLapTime, teamColor } from '../utils'
interface Props {
laps: Lap[]
drivers?: Driver[]
}
interface DriverLapSummary {
driver_number: number
total: number
best: Lap | null
lastLap: number
pitOuts: number
}
export function LapsView({ laps, drivers = [] }: Props) {
if (laps.length === 0) {
return (
<div className="missing-notice">
Laps not ingested. Run <code>box-box --ingest-session &lt;key&gt;</code> to
load this dataset.
</div>
)
}
const byDriver = new Map<number, DriverLapSummary>()
for (const lap of laps) {
const summary =
byDriver.get(lap.driver_number) ??
{
driver_number: lap.driver_number,
total: 0,
best: null,
lastLap: 0,
pitOuts: 0,
}
summary.total += 1
summary.lastLap = Math.max(summary.lastLap, lap.lap_number)
if (lap.is_pit_out_lap) summary.pitOuts += 1
if (
lap.lap_duration != null &&
lap.lap_duration > 0 &&
(!summary.best ||
summary.best.lap_duration == null ||
lap.lap_duration < summary.best.lap_duration)
) {
summary.best = lap
}
byDriver.set(lap.driver_number, summary)
}
const rows = [...byDriver.values()].sort((a, b) => {
const aBest = a.best?.lap_duration ?? Number.POSITIVE_INFINITY
const bBest = b.best?.lap_duration ?? Number.POSITIVE_INFINITY
if (aBest !== bBest) return aBest - bBest
return a.driver_number - b.driver_number
})
const driversByNumber = new Map(drivers.map((driver) => [driver.driver_number, driver]))
const fastest = rows.find((row) => row.best?.lap_duration != null)?.best
const fastestTime = fastest?.lap_duration ?? null
return (
<div className="scroll-x" data-testid="laps-view">
<table className="data-table" style={{ minWidth: 540, maxWidth: 700 }}>
<thead>
<tr>
<th>Driver</th>
<th className="c">Best Lap</th>
<th className="r">Best Time</th>
<th className="r">Gap</th>
<th className="r hide-mobile">Laps</th>
<th className="r hide-mobile">Pit Outs</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const driver = driversByNumber.get(row.driver_number)
const driverName =
driver?.full_name || driver?.broadcast_name || driver?.name_acronym || `#${row.driver_number}`
const colour = teamColor(driver?.team_colour)
const isFastest =
fastest &&
row.best?.driver_number === fastest.driver_number &&
row.best?.lap_number === fastest.lap_number
const gap =
row.best?.lap_duration != null && fastestTime != null
? row.best.lap_duration - fastestTime
: null
return (
<tr key={row.driver_number} className={isFastest ? 'lap-fastest-row' : undefined}>
<td style={{ fontWeight: 700 }}>
<span className="drv-cell">
<span className="drv-bar" style={{ background: colour }} />
<span>{driverName}</span>
<span className="drv-num">{row.driver_number}</span>
</span>
</td>
<td className="c mono">
{row.best ? row.best.lap_number : '—'}
</td>
<td className="r">{formatLapTime(row.best?.lap_duration)}</td>
<td className="r">{isFastest ? '—' : formatGap(gap)}</td>
<td className="r hide-mobile">{row.lastLap || row.total}</td>
<td className="r hide-mobile" style={{ color: 'var(--text-3)' }}>
{row.pitOuts || '—'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,291 @@
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
import { formatCoverageHint } from '../lib/coverage'
import { SourceBadge } from './SourceBadge'
import { SessionCoverageDots } from './SessionCoverageDots'
import { RouteState } from './RouteState'
import type { Meeting, WeekendSession } from '../types'
function formatMeetingDates(meeting: Meeting): string {
const start = meeting.date_start?.slice(0, 10)
const end = meeting.date_end?.slice(0, 10)
if (start && end && start !== end) return `${start} ${end}`
return start || end || '—'
}
function sessionSourceBadge(source: WeekendSession['source']) {
return <SourceBadge source={source} />
}
interface Props {
onSelectSession?: (sessionKey: number) => void
}
export function LocalDataNavigator({ onSelectSession }: Props) {
const navigate = useNavigate()
const [selectedYear, setSelectedYear] = useState<number | null>(null)
const [selectedMeetingKey, setSelectedMeetingKey] = useState<number | null>(null)
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: ({ signal }) => fetchSeasons(signal),
})
const meetingsQuery = useQuery({
queryKey: ['meetings', selectedYear],
queryFn: ({ signal }) => fetchLocalMeetings(selectedYear!, signal),
enabled: selectedYear != null,
})
const weekendQuery = useQuery({
queryKey: ['weekend', selectedMeetingKey],
queryFn: ({ signal }) => fetchWeekend(selectedMeetingKey!, signal),
enabled: selectedMeetingKey != null,
})
useEffect(() => {
if (seasonsQuery.data?.length && selectedYear == null) {
setSelectedYear(seasonsQuery.data[0])
}
}, [seasonsQuery.data, selectedYear])
function handleSelectSession(sessionKey: number) {
if (onSelectSession) {
onSelectSession(sessionKey)
return
}
navigate({ to: '/race-hub', search: { session_key: sessionKey } })
}
function handleSelectYear(year: number) {
setSelectedYear(year)
setSelectedMeetingKey(null)
}
function handleSelectMeeting(meetingKey: number) {
setSelectedMeetingKey((prev) => (prev === meetingKey ? null : meetingKey))
}
if (seasonsQuery.isLoading) {
return <RouteState kind="loading" title="loading local seasons…" className="nav-panel" />
}
if (seasonsQuery.isError) {
return (
<RouteState
kind="error"
className="nav-panel"
title="Local seasons unavailable"
error={seasonsQuery.error}
onRetry={() => {
if (!seasonsQuery.isFetching) void seasonsQuery.refetch()
}}
retrying={seasonsQuery.isFetching}
testId="weekend-error"
/>
)
}
const seasons = seasonsQuery.data ?? []
if (seasons.length === 0) {
return (
<div className="nav-panel" data-testid="local-nav-empty">
<div className="nav-panel-title">Local Data</div>
<div className="empty-state" style={{ padding: 'var(--s5) 0' }}>
<div className="empty-state-title">No ingested seasons yet</div>
<div className="empty-state-desc">
Ingest a session with <code>box-box --ingest-session &lt;key&gt;</code>, then browse
here or enter a session key below.
</div>
</div>
</div>
)
}
const meetings = meetingsQuery.data ?? []
const weekend = weekendQuery.data
return (
<div className="nav-panel" data-testid="local-nav">
<div className="nav-panel-head">
<span className="nav-panel-title">Local Data</span>
<div className="year-list" role="listbox" aria-label="Season">
{seasons.map((year) => (
<button
key={year}
type="button"
role="option"
aria-selected={year === selectedYear}
className={`year-btn ${year === selectedYear ? 'active' : ''}`}
onClick={() => handleSelectYear(year)}
>
{year}
</button>
))}
</div>
</div>
{meetingsQuery.isLoading && (
<div className="nav-section-meta">loading meetings</div>
)}
{meetingsQuery.isError && (
<RouteState
kind="error"
title="Meetings unavailable"
error={meetingsQuery.error}
onRetry={() => {
if (!meetingsQuery.isFetching) void meetingsQuery.refetch()
}}
retrying={meetingsQuery.isFetching}
testId="weekend-meetings-error"
/>
)}
{!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && (
<div className="nav-section-meta">No meetings ingested for {selectedYear}.</div>
)}
{meetings.length > 0 && (
<div className="nav-section">
<div className="sec-header">
<span className="sec-title">Meetings</span>
<span className="sec-meta">{meetings.length}</span>
</div>
<div className="scroll-x">
<table className="data-table nav-table">
<thead>
<tr>
<th>Grand Prix</th>
<th className="hide-mobile">Country</th>
<th className="hide-mobile">Dates</th>
<th className="r">Open</th>
</tr>
</thead>
<tbody>
{meetings.map((meeting) => {
const selected = meeting.meeting_key === selectedMeetingKey
return (
<tr
key={meeting.meeting_key}
className={selected ? 'nav-row-selected' : ''}
data-testid={`meeting-row-${meeting.meeting_key}`}
>
<td>
<span style={{ fontWeight: 600 }}>{meeting.meeting_name}</span>
{meeting.circuit_short_name && meeting.circuit_short_name !== meeting.meeting_name && (
<span className="nav-sub">{meeting.circuit_short_name}</span>
)}
</td>
<td className="hide-mobile mono" style={{ color: 'var(--text-2)' }}>
{meeting.country_code || meeting.country_name}
</td>
<td className="hide-mobile mono" style={{ color: 'var(--text-3)' }}>
{formatMeetingDates(meeting)}
</td>
<td className="r">
<button
type="button"
className={`nav-action-btn ${selected ? 'active' : ''}`}
aria-expanded={selected}
onClick={() => handleSelectMeeting(meeting.meeting_key)}
>
{selected ? 'Hide' : 'Sessions'}
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
{selectedMeetingKey != null && weekendQuery.isLoading && (
<div className="nav-section-meta">loading sessions</div>
)}
{selectedMeetingKey != null && weekendQuery.isError && (
<RouteState
kind="error"
title="Weekend unavailable"
error={weekendQuery.error}
onRetry={() => {
if (!weekendQuery.isFetching) void weekendQuery.refetch()
}}
retrying={weekendQuery.isFetching}
testId="weekend-error"
/>
)}
{weekend && (
<div className="nav-section" data-testid="weekend-sessions">
<div className="sec-header">
<span className="sec-title">{weekend.meeting.meeting_name} Sessions</span>
<span className="sec-meta">{weekend.sessions.length}</span>
</div>
{weekend.sessions.length === 0 ? (
<div className="nav-section-meta">No sessions stored for this meeting.</div>
) : (
<div className="scroll-x">
<table className="data-table nav-table">
<thead>
<tr>
<th>Session</th>
<th className="hide-mobile">Type</th>
<th>Coverage</th>
<th>Source</th>
<th className="r">Open</th>
</tr>
</thead>
<tbody>
{weekend.sessions.map(({ session, source, datasets }) => {
const coverage = formatCoverageHint(datasets)
const isDefault = session.session_key === weekend.default_session_key
return (
<tr key={session.session_key} data-testid={`session-row-${session.session_key}`}>
<td>
<span style={{ fontWeight: 600 }}>{session.session_name}</span>
{isDefault && <span className="nav-sub">default</span>}
<span className="nav-sub mono">{session.session_key}</span>
</td>
<td className="hide-mobile mono" style={{ color: 'var(--text-3)' }}>
{session.session_type}
</td>
<td>
<span className="mono" style={{ color: 'var(--text-2)' }}>
{coverage}
</span>
<SessionCoverageDots datasets={datasets} />
</td>
<td>{sessionSourceBadge(source)}</td>
<td className="r">
<button
type="button"
className="nav-action-btn nav-action-primary"
data-testid={`open-session-${session.session_key}`}
onClick={() => handleSelectSession(session.session_key)}
>
Race Hub
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
)
}
// Re-export helpers used by tests
export { countRaceHubDatasets, formatCoverageHint } from '../lib/coverage'

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

@@ -0,0 +1,108 @@
import { RACE_HUB_DATASETS, formatCoverageHint } from '../lib/coverage'
import { SourceBadge } from './SourceBadge'
import { SessionCoverageDots } from './SessionCoverageDots'
import {
CliCommands,
ingestMeetingCommands,
ingestSessionCommands,
} from './CliCommands'
import type { Weekend, WeekendSession } from '../types'
interface Props {
weekend: Weekend
}
export function MeetingDetailPanel({ weekend }: Props) {
const { meeting, sessions, source } = weekend
return (
<div className="dl-detail" data-testid="meeting-detail">
<div className="detail-header">
<div className="detail-header-row">
<span className="detail-title">{meeting.meeting_name}</span>
<SourceBadge source={source} label={source === 'local' ? 'Full' : undefined} />
</div>
<div className="detail-meta">
{meeting.country_name} · meeting_key {meeting.meeting_key}
</div>
<div className="detail-meta">
{sessions.length} session{sessions.length === 1 ? '' : 's'} stored locally
</div>
</div>
{sessions.length === 0 ? (
<div className="missing-notice">
No sessions ingested for this meeting. Run{' '}
<code>box-box --ingest-meeting {meeting.meeting_key}</code>
</div>
) : (
sessions.map((entry) => (
<SessionDetailBlock key={entry.session.session_key} entry={entry} />
))
)}
<div className="dl-cli-section">
<div className="sec-header">
<span className="sec-title">Ingest Commands</span>
</div>
<CliCommands commands={ingestMeetingCommands(meeting.meeting_key)} />
</div>
</div>
)
}
function SessionDetailBlock({ entry }: { entry: WeekendSession }) {
const { session, source, datasets } = entry
const coverage = formatCoverageHint(datasets)
return (
<div className="session-detail-row" data-testid={`session-detail-${session.session_key}`}>
<div className="session-detail-head">
<SourceBadge source={source} />
<span>{session.session_name}</span>
<span className="session-detail-key mono">{session.session_key}</span>
<span className="session-detail-coverage mono">{coverage}</span>
<SessionCoverageDots datasets={datasets} />
</div>
<table className="data-table ds-detail-table">
<thead>
<tr>
<th>Dataset</th>
<th>Status</th>
<th className="r">Records</th>
</tr>
</thead>
<tbody>
{RACE_HUB_DATASETS.map((key) => {
const info = datasets[key]
const available = info?.status === 'available'
return (
<tr key={key}>
<td className="mono" style={{ color: 'var(--text-2)' }}>
{key}
</td>
<td>
{info?.status === 'available' ? (
<span className="badge badge-local">Local</span>
) : info?.status === 'skipped' ? (
<span className="badge badge-none">N/A</span>
) : (
<span className="badge badge-none">Missing</span>
)}
</td>
<td className="r mono" style={{ color: 'var(--text-3)' }}>
{info?.count != null ? info.count : '—'}
</td>
</tr>
)
})}
</tbody>
</table>
<div className="session-cli">
<CliCommands commands={ingestSessionCommands(session.session_key)} />
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,264 @@
import type { RaceHub } from '../types'
import { compareFinishPosition, formatDuration, formatGap, formatLapTime } from '../utils'
import { Thermometer, Map, Droplets, Wind, CloudRain } from 'lucide-react'
interface Props {
data: RaceHub
}
export function OverviewView({ data }: Props) {
const sortedResults = [...data.results].sort((a, b) =>
compareFinishPosition(a.position, b.position),
)
const winner = sortedResults[0]
const podium = sortedResults.filter((r) => r.position > 0).slice(0, 3)
const pole = data.starting_grid.find((g) => g.position === 1)
const fastest = pickFastestLap(data)
const latestWeather = data.weather.length > 0 ? data.weather[data.weather.length - 1] : null
const rcHighlights = data.race_control.slice(-3).reverse()
const sessionType = (data.session?.session_type ?? '').toLowerCase()
const isRace = sessionType.includes('race')
const sessionLabel = isRace ? 'Race' : data.session?.session_type ?? 'Session'
return (
<div className="rh-overview" data-testid="rh-overview">
<div className="rh-stat-grid">
{winner && winner.position > 0 ? (
<StatCard
label={isRace ? 'Winner' : `${sessionLabel} P1`}
primary={winner.name_acronym || `#${winner.driver_number}`}
primaryColor={winner.team_colour ? `#${winner.team_colour}` : undefined}
secondary={winner.full_name}
tertiary={winner.team_name}
highlight={
isRace
? formatDuration(winner.duration)
: winner.duration
? formatDuration(winner.duration)
: ''
}
/>
) : (
<StatCard label={isRace ? 'Winner' : `${sessionLabel} P1`} placeholder />
)}
<PodiumCard podium={podium} />
{pole ? (
<StatCard
label={isRace ? 'Pole' : 'P1'}
primary={pole.name_acronym || `#${pole.driver_number}`}
primaryColor={pole.team_colour ? `#${pole.team_colour}` : undefined}
secondary={pole.full_name}
tertiary={pole.team_name}
highlight={pole.lap_duration ? formatLapTime(pole.lap_duration) : ''}
/>
) : (
<StatCard label={isRace ? 'Pole' : 'Grid'} placeholder />
)}
{fastest ? (
<StatCard
label="Fastest Lap"
primary={fastest.acronym}
primaryColor={fastest.colour ? `#${fastest.colour}` : undefined}
secondary={fastest.fullName}
tertiary={`Lap ${fastest.lap}`}
highlight={formatLapTime(fastest.time)}
/>
) : (
<StatCard label="Fastest Lap" placeholder />
)}
</div>
<div className="rh-overview-row">
<section className="rh-panel ui-card">
<div className="sec-header">
<span className="sec-title">Conditions</span>
{latestWeather && (
<span className="sec-meta mono">{shortTime(latestWeather.date)}</span>
)}
</div>
{latestWeather ? (
<div className="rh-condition-strip" data-testid="rh-conditions">
<ConditionChip icon={Thermometer} label="Air" value={`${latestWeather.air_temperature.toFixed(1)}°C`} />
<ConditionChip
icon={Map}
label="Track"
value={`${latestWeather.track_temperature.toFixed(1)}°C`}
/>
<ConditionChip icon={Droplets} label="Humidity" value={`${latestWeather.humidity.toFixed(0)}%`} />
<ConditionChip
icon={Wind}
label="Wind"
value={`${latestWeather.wind_speed.toFixed(1)} m/s`}
/>
<ConditionChip
icon={CloudRain}
label="Rain"
value={latestWeather.rainfall > 0 ? 'Yes' : 'No'}
accent={latestWeather.rainfall > 0 ? 'wet' : undefined}
/>
</div>
) : (
<div className="rh-empty-line">No weather samples ingested.</div>
)}
</section>
<section className="rh-panel ui-card">
<div className="sec-header">
<span className="sec-title">Race Control · Latest</span>
<span className="sec-meta mono">{data.race_control.length}</span>
</div>
{rcHighlights.length === 0 ? (
<div className="rh-empty-line">No race-control messages.</div>
) : (
<ul className="rh-rc-list">
{rcHighlights.map((m, i) => (
<li key={i} className="rh-rc-row">
<span className="rh-rc-time mono">{shortTime(m.date)}</span>
<span className={`rh-rc-flag rh-rc-flag-${(m.flag || 'none').toLowerCase()}`}>
{m.flag || m.category || '—'}
</span>
<span className="rh-rc-msg">{m.message}</span>
</li>
))}
</ul>
)}
</section>
</div>
</div>
)
}
interface StatCardProps {
label: string
primary?: string
primaryColor?: string
secondary?: string
tertiary?: string
highlight?: string
placeholder?: boolean
}
function StatCard({
label,
primary,
primaryColor,
secondary,
tertiary,
highlight,
placeholder,
}: StatCardProps) {
if (placeholder) {
return (
<div className="rh-stat-card ui-card rh-stat-empty">
<div className="rh-stat-label mono">{label}</div>
<div className="rh-stat-primary"></div>
<div className="rh-stat-secondary">No data ingested</div>
</div>
)
}
return (
<div className="rh-stat-card ui-card interactive">
<div className="rh-stat-label mono">{label}</div>
<div className="rh-stat-primary" style={primaryColor ? { color: primaryColor } : undefined}>
{primary}
</div>
{secondary && <div className="rh-stat-secondary">{secondary}</div>}
{tertiary && <div className="rh-stat-tertiary">{tertiary}</div>}
{highlight && <div className="rh-stat-highlight mono">{highlight}</div>}
</div>
)
}
function PodiumCard({ podium }: { podium: Array<{ name_acronym: string; team_colour: string; position: number; full_name: string; gap_to_leader: number | string | number[] | null; duration: number | number[] | null; driver_number: number }> }) {
if (podium.length === 0) {
return (
<div className="rh-stat-card ui-card rh-stat-empty">
<div className="rh-stat-label mono">Podium</div>
<div className="rh-stat-primary"></div>
<div className="rh-stat-secondary">No classified finishers</div>
</div>
)
}
return (
<div className="rh-stat-card ui-card interactive">
<div className="rh-stat-label mono">Podium</div>
<ol className="rh-podium-list">
{podium.map((r) => (
<li key={r.driver_number} className={`rh-podium-row rh-podium-p${r.position}`}>
<span className="rh-podium-pos mono">P{r.position}</span>
<span
className="rh-podium-driver"
style={r.team_colour ? { color: `#${r.team_colour}` } : undefined}
>
{r.name_acronym || `#${r.driver_number}`}
</span>
<span className="rh-podium-gap mono">
{r.position === 1
? formatDuration(r.duration)
: formatGap(r.gap_to_leader)}
</span>
</li>
))}
</ol>
</div>
)
}
function ConditionChip({
label,
value,
accent,
icon: Icon,
}: {
label: string
value: string
accent?: 'wet'
icon?: React.ElementType
}) {
return (
<div className={`rh-condition-chip${accent === 'wet' ? ' rh-condition-wet' : ''}`} style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
{Icon && <Icon size={14} style={{ opacity: 0.7 }} />}
<span className="rh-condition-label mono">{label}</span>
<span className="rh-condition-value">{value}</span>
</div>
)
}
function shortTime(iso: string): string {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso.slice(11, 16)
return d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', hour12: false })
}
function pickFastestLap(
data: RaceHub,
): { lap: number; time: number; acronym: string; fullName: string; colour: string } | null {
const candidates = data.laps.filter(
(l) => l.lap_duration != null && l.lap_duration > 0 && !l.is_pit_out_lap,
)
if (candidates.length === 0) return null
let best = candidates[0]
for (const lap of candidates) {
if ((lap.lap_duration ?? 0) < (best.lap_duration ?? Infinity)) {
best = lap
}
}
const driverInfo = data.results.find((r) => r.driver_number === best.driver_number)
?? data.drivers.find((d) => d.driver_number === best.driver_number)
return {
lap: best.lap_number,
time: best.lap_duration ?? 0,
acronym:
('name_acronym' in (driverInfo ?? {}) ? (driverInfo as { name_acronym: string }).name_acronym : '')
|| `#${best.driver_number}`,
fullName:
('full_name' in (driverInfo ?? {}) ? (driverInfo as { full_name: string }).full_name : '') || '',
colour:
('team_colour' in (driverInfo ?? {}) ? (driverInfo as { team_colour: string }).team_colour : '') || '',
}
}

View File

@@ -0,0 +1,149 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
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',
'bbc-f1': 'BBC Sport',
'autosport-f1': 'Autosport',
'racefans-f1': 'RaceFans',
'guardian-f1': 'Guardian',
'racer-f1': 'RACER',
'f1-youtube': 'F1 YouTube',
}
export function PaddockBriefing() {
const now = useMemo(() => new Date(), [])
const { data: news, isLoading, isError } = useQuery({
queryKey: ['news'],
queryFn: ({ signal }) => fetchNews(100, undefined, signal),
staleTime: 60_000,
})
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: ({ signal }) => fetchSeasons(signal),
})
const latestSeason = seasonsQuery.data?.[0] ?? null
const meetingsQuery = useQuery({
queryKey: ['season-meetings', latestSeason],
queryFn: ({ signal }) => fetchSeasonMeetings(latestSeason!, signal),
enabled: latestSeason != null,
})
const hubQuery = useQuery({
queryKey: ['championship-hub', latestSeason],
queryFn: ({ signal }) => fetchChampionshipHub(latestSeason!, signal),
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 = sinceItems.length > 0 ? sinceItems.slice(0, 5) : (tagged.slice(0, 5))
return (
<section className="cc-briefing" data-testid="paddock-briefing">
<div className="sec-header">
<span className="sec-title">
Paddock Briefing
{unreadCount > 0 && (
<span className="cc-brief-unread">{unreadCount}</span>
)}
</span>
<Link to="/briefing" className="sec-action mono">
View all
</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>}
{!isLoading && !isError && preview.length === 0 && (
<div className="briefing-state">
No items. Run <code>box-box --ingest-news</code> to populate.
</div>
)}
{preview.length > 0 && (
<div className="cc-brief-strip" role="list">
{preview.map((item) => (
<Link
key={item.url}
to="/briefing"
className={`cc-brief-item${item.read_at ? ' is-read' : ''}`}
role="listitem"
>
<div className="cc-brief-item-meta mono">
<span className="cc-brief-source">
{SOURCE_DISPLAY[item.source] ?? item.source}
</span>
<span className="cc-brief-age">
{timeAgo(item.published_at ?? item.fetched_at)}
</span>
</div>
<span className="cc-brief-title">{item.title}</span>
</Link>
))}
</div>
)}
</section>
)
}

View File

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

View File

@@ -0,0 +1,91 @@
import type { RaceControlMessage } from '../types'
import { rcFlagClass } from '../lib/live'
interface Props {
messages: RaceControlMessage[]
}
function formatEventTime(date: string): string {
if (!date) return '—'
const parsed = new Date(date)
if (Number.isNaN(parsed.getTime())) return date
return parsed.toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
function eventLabel(message: RaceControlMessage): string {
return message.flag || message.category || 'Message'
}
function eventClass(message: RaceControlMessage): string {
const flagClass = rcFlagClass(message.flag ?? '')
if (flagClass) return flagClass
const category = (message.category ?? '').toLowerCase()
const text = `${message.message ?? ''} ${message.category ?? ''}`.toLowerCase()
if (category.includes('safety') || text.includes('safety car')) return 'rc-flag-sc'
if (category === 'drs' || text.includes('drs')) return 'rc-flag-drs'
if (text.includes('virtual safety car')) return 'rc-flag-vsc'
if (text.includes('red flag')) return 'rc-flag-red'
if (text.includes('yellow')) return 'rc-flag-yellow'
if (text.includes('green light') || text.includes('green flag')) return 'rc-flag-green'
if (text.includes('chequered') || text.includes('checkered')) return 'rc-flag-chequered'
return 'rc-flag-other'
}
export function RaceControlView({ messages }: Props) {
if (messages.length === 0) {
return (
<div className="missing-notice">
Race control messages not ingested. Run{' '}
<code>box-box --ingest-session &lt;key&gt;</code> to load this dataset.
</div>
)
}
const rows = [...messages].sort((a, b) => a.date.localeCompare(b.date))
return (
<div className="scroll-x" data-testid="race-control-view">
<table className="data-table" style={{ minWidth: 620 }}>
<thead>
<tr>
<th>Time</th>
<th className="c">Lap</th>
<th>Event</th>
<th className="c hide-mobile">Driver</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{rows.map((message, index) => {
const visualClass = eventClass(message)
return (
<tr className={`race-control-row ${visualClass}`} key={`${message.date}-${index}`}>
<td className="mono rc-time-cell">
{formatEventTime(message.date)}
</td>
<td className="c mono">{message.lap_number ?? '—'}</td>
<td>
<span className={`rc-event-pill rc-flag ${visualClass}`}>{eventLabel(message)}</span>
{message.scope && (
<span className="rc-scope">
{message.scope.toLowerCase()}
</span>
)}
</td>
<td className="c mono hide-mobile">{message.driver_number ?? '—'}</td>
<td className="rc-message-cell">{message.message || '—'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,39 @@
import type { Meeting, Session, RaceHub } from '../types'
import { formatDate } from '../utils'
interface Props {
meeting?: Meeting
session?: Session
source: RaceHub['source']
}
function SourceBadge({ source }: { source: RaceHub['source'] }) {
if (source === 'local') return <span className="badge badge-local">Local</span>
if (source === 'partial') return <span className="badge badge-partial">Partial</span>
if (source === 'cancelled') return <span className="badge badge-cancelled">Cancelled</span>
return <span className="badge badge-none">No data</span>
}
export function RaceHubHeader({ meeting, session, source }: Props) {
const meetingName = meeting?.meeting_name ?? 'Unknown Meeting'
const sessionName = session?.session_name ?? 'Unknown Session'
const dateStr = formatDate(session?.date_start ?? meeting?.date_start)
const location = meeting ? `${meeting.location} · ${meeting.country_name}` : null
return (
<div className="rh-header">
<div className="rh-title-group">
<div className="rh-meeting">{meetingName}</div>
<div className="rh-session">{sessionName}</div>
<div className="rh-meta">
{dateStr && <span className="rh-meta-item">{dateStr}</span>}
{location && <span className="rh-meta-item" style={{ opacity: 0.6 }}>·</span>}
{location && <span className="rh-meta-item">{location}</span>}
</div>
</div>
<div style={{ flexShrink: 0, paddingTop: 2 }}>
<SourceBadge source={source} />
</div>
</div>
)
}

View File

@@ -0,0 +1,777 @@
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: {
results: EnrichedResult[]
starting_grid: EnrichedGrid[]
positions: PositionSample[]
laps: Lap[]
datasets: Record<string, any>
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 = [],
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 })
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 && 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: normaliseTime(time),
pos: p.position,
})
}
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)) {
samples.push({ t: 1, pos: samples[samples.length - 1].pos })
}
}
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 (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 null
}
if (scrubTime !== null) {
const currentPos = new Map<number, number>()
for (const [dNum, samples] of byDriver.entries()) {
const pos = getInterpPos(samples, scrubTime)
if (pos !== null) {
currentPos.set(dNum, pos)
}
}
displayResults = [...results].sort((a, b) => {
const posA = currentPos.get(a.driver_number) ?? 999
const posB = currentPos.get(b.driver_number) ?? 999
return posA - posB
})
}
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 = 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 lapInterval = winnerLaps.length < 30 ? 5 : 10
for (let i = 0; i < winnerLaps.length; i++) {
const lap = winnerLaps[i]
if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) {
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) })
}
}
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') {
if (!activeSC) activeSC = { start: t, type: 'SC' }
} else if (m.includes('TRACK CLEAR') || m.includes('CLEAR')) {
if (activeSC) {
scPeriods.push({ start: activeSC.start, end: t, type: activeSC.type })
activeSC = null
}
}
}
if (activeSC) {
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()
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${showMapPanel ? '' : ' rs-chart-container--full'}`}
data-testid="position-chart"
>
<svg
ref={svgRef}
className="rs-position-chart-svg"
viewBox={`0 0 ${W} ${H}`}
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
const x1 = toX(Math.max(0, startT))
const x2 = toX(Math.min(1, endT))
if (x2 <= PL || x1 >= W - PR) return null
return (
<rect
key={`sc-${i}`}
x={x1}
y={PT}
width={Math.max(0, x2 - x1)}
height={plotH}
fill={sc.type === 'SC' ? 'rgba(255, 153, 0, 0.15)' : 'rgba(255, 204, 0, 0.1)'}
/>
)
})}
{positionLabels.map((pos) => (
<g key={pos}>
<line
x1={PL}
x2={W - PR}
y1={toY(pos)}
y2={toY(pos)}
className="rs-chart-grid-line"
/>
<text
x={PL - 4}
y={toY(pos) + 4}
textAnchor="end"
className="rs-chart-axis-label"
>
P{pos}
</text>
</g>
))}
{lapTicks.map((tick) => (
<g key={`lap-${tick.lap}`}>
<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>
))}
{Array.from(byDriver.entries()).map(([dNum, samples]) => {
const colour = colorByDriver.get(dNum)
const color = colour ? `#${colour}` : '#888'
const pts = samples.map((s) => `${toX(s.t)},${toY(s.pos)}`).join(' ')
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)
return (
<g
key={dNum}
style={{ opacity: isFaded ? 0.2 : 1, transition: 'opacity 0.2s' }}
onMouseEnter={() => setHoverDriver(dNum)}
onMouseLeave={() => setHoverDriver(null)}
>
<polyline
points={pts}
fill="none"
stroke={color}
strokeWidth={isHovered ? 3 : 2}
strokeLinejoin="round"
strokeLinecap="round"
className="rs-driver-line"
pathLength={1}
/>
{driverPits.map((p, i) => {
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}
strokeWidth={2}
className="rs-pit-dot"
/>
)
})}
{last && (
<text
x={toX(last.t) + 6}
y={labelY + 4}
fill={color}
fontSize={isHovered ? 11 : 9}
fontFamily="var(--f-mono)"
fontWeight={700}
style={{ cursor: 'default' }}
>
{acronymByDriver.get(dNum) ?? dNum}
</text>
)}
</g>
)
})}
{scrubTime !== null && (
<line
x1={toX(scrubTime)}
x2={toX(scrubTime)}
y1={PT}
y2={H - PB}
stroke="var(--text)"
strokeWidth={1}
strokeDasharray="4 2"
className="rs-playhead"
style={{ pointerEvents: 'none' }}
/>
)}
<rect
x={PL}
y={PT}
width={plotW}
height={plotH}
fill="transparent"
data-testid="position-chart-interaction"
onPointerMove={handlePointerMove}
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 && 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"
role="region"
aria-label="Final running order"
data-testid="race-story-outcome"
>
{displayResults.map((r, i) => {
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' : ''
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}
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-driver-cell">
<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>
<span className="rs-driver-team">{r.team_name}</span>
</div>
</div>
</div>
<div className="rs-driver-right">
<div className="rs-metric">
<span>
<span className={gridDeltaClass(currentPos, gridPos)}>
{gridDelta(currentPos, gridPos)}
</span>
</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}
</span>
<span className="rs-metric-label">Pts</span>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
)
}

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

@@ -0,0 +1,163 @@
import type { ReactNode } from 'react'
import { isTimeoutError, userFacingError } from '../lib/fetch'
import { noticeMessage, type DataAvailability } from '../lib/availability'
export type { DataAvailability }
export function availabilityLabel(kind: DataAvailability): string {
switch (kind) {
case 'local':
return 'Local'
case 'partial':
return 'Partial'
case 'stale':
return 'Stale'
case 'archive':
return 'Archive'
case 'limited':
return 'Limited'
case 'missing':
return 'Missing'
}
}
export function AvailabilityBadge({ kind, label }: { kind: DataAvailability; label?: string }) {
return (
<span className={`badge badge-${kind === 'archive' ? 'none' : kind}`} data-testid={`availability-${kind}`}>
{label ?? availabilityLabel(kind)}
</span>
)
}
export type RouteStateKind = 'loading' | 'empty' | 'error' | 'timeout'
interface RouteStateProps {
kind: RouteStateKind
title?: string
message?: ReactNode
error?: unknown
onRetry?: () => void
retrying?: boolean
testId?: string
/** Optional override for the Retry button's data-testid (defaults to none). */
retryTestId?: string
className?: string
/** Optional availability strip (stale/limited/partial) above the state body. */
availability?: DataAvailability
children?: ReactNode
}
const DEFAULT_TITLES: Record<RouteStateKind, string> = {
loading: 'Loading…',
empty: 'Nothing here yet',
error: 'Could not load this view',
timeout: 'Request timed out',
}
const DEFAULT_MESSAGES: Record<RouteStateKind, string> = {
loading: 'Fetching the latest local data.',
empty: 'No data is available for this view yet.',
error: 'Something went wrong. Retry to try again.',
timeout: 'This request took too long. Check your connection, then retry.',
}
/**
* Shared primary-route state surface: loading, empty, timeout/error + retry.
* Retry is a real <button> (keyboard accessible) and callers should gate
* concurrent refetches via React Query / deduped apiFetch.
*/
export function RouteState({
kind,
title,
message,
error,
onRetry,
retrying = false,
testId,
retryTestId,
className = '',
availability,
children,
}: RouteStateProps) {
const resolvedKind: RouteStateKind =
kind === 'error' && isTimeoutError(error) ? 'timeout' : kind
const resolvedMessage =
message ??
(error != null && (resolvedKind === 'error' || resolvedKind === 'timeout')
? userFacingError(error)
: DEFAULT_MESSAGES[resolvedKind])
const showRetry =
(resolvedKind === 'error' || resolvedKind === 'timeout') && typeof onRetry === 'function'
return (
<div
className={`route-state route-state-${resolvedKind} ${className}`.trim()}
data-testid={testId ?? `route-state-${resolvedKind}`}
role={resolvedKind === 'error' || resolvedKind === 'timeout' ? 'alert' : undefined}
>
{availability && (
<div className="route-state-availability">
<AvailabilityBadge kind={availability} />
</div>
)}
{resolvedKind === 'loading' ? (
<div className="loading-state">{title ?? 'loading…'}</div>
) : (
<>
<div className="route-state-title">{title ?? DEFAULT_TITLES[resolvedKind]}</div>
<div className="route-state-message">{resolvedMessage}</div>
{children}
{showRetry && (
<button
type="button"
className="route-state-retry"
onClick={onRetry}
disabled={retrying}
aria-busy={retrying || undefined}
data-testid={retryTestId}
>
{retrying ? 'Retrying…' : 'Retry'}
</button>
)}
</>
)}
</div>
)
}
interface StaleNoticeProps {
availability?: DataAvailability
message?: string
onRetry?: () => void
retrying?: boolean
testId?: string
}
/** Inline notice when a successful payload is limited/stale/partial. */
export function DataNotice({
availability = 'stale',
message,
onRetry,
retrying = false,
testId = 'data-notice',
}: StaleNoticeProps) {
return (
<div className="data-notice" data-testid={testId} role="status">
<AvailabilityBadge kind={availability} />
<span className="data-notice-text">{message ?? noticeMessage(availability)}</span>
{onRetry && (
<button
type="button"
className="route-state-retry data-notice-retry"
onClick={onRetry}
disabled={retrying}
aria-busy={retrying || undefined}
>
{retrying ? 'Retrying…' : 'Retry'}
</button>
)}
</div>
)
}

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