Compare commits

..

51 Commits

Author SHA1 Message Date
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
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
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
109 changed files with 11123 additions and 517 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -1,6 +1,11 @@
import type {
ArticleContent,
CarDataSample,
ChampionshipHub,
DriverSummary,
EnrichedGrid,
EnrichedResult,
LapsComparisonResponse,
LiveStateResponse,
LiveSessionMeta,
Meeting,
@@ -38,7 +43,11 @@ export async function fetchLocalMeetings(year: number): Promise<Meeting[]> {
}
export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
const res = await fetch(`/api/v1/meetings?year=${year}&source=openf1`)
return fetchMeetings(year, 'openf1')
}
export async function fetchMeetings(year: number, source = 'auto'): Promise<Meeting[]> {
const res = await fetch(`/api/v1/meetings?year=${year}&source=${source}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
@@ -46,6 +55,32 @@ export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
return Array.isArray(meetings) ? meetings : []
}
export async function fetchResults(sessionKey: number, source = 'auto'): Promise<EnrichedResult[]> {
const res = await fetch(`/api/v1/results?session_key=${sessionKey}&source=${source}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
const results = await res.json()
return Array.isArray(results) ? results : []
}
export async function fetchStartingGrid(sessionKey: number, source = 'auto'): Promise<EnrichedGrid[]> {
const res = await fetch(`/api/v1/grid?session_key=${sessionKey}&source=${source}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
const grid = await res.json()
return Array.isArray(grid) ? grid : []
}
export async function fetchTrackOutline(circuitKey: number, year: number): Promise<TrackOutline | null> {
const res = await fetch(`/api/v1/track-outline?circuit_key=${circuitKey}&year=${year}`)
if (!res.ok) return null
const data = await res.json()
if (data?.error || !Array.isArray(data?.points) || data.points.length < 2) return null
return data as TrackOutline
}
export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise<Session[]> {
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
if (!res.ok) {
@@ -74,6 +109,19 @@ export async function fetchChampionshipHub(year?: number): Promise<ChampionshipH
return res.json()
}
export async function fetchDriverSummary(
driverNumber: number,
year?: number,
): Promise<DriverSummary> {
const params = new URLSearchParams({ driver_number: String(driverNumber) })
if (year) params.set('year', String(year))
const res = await fetch(`/api/v1/driver/summary?${params.toString()}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}
export async function fetchLiveState(): Promise<LiveStateResponse> {
const res = await fetch('/api/v1/live/state')
if (!res.ok) {
@@ -126,3 +174,32 @@ export async function markNewsRead(articleUrl: string): Promise<void> {
body: JSON.stringify({ url: articleUrl }),
})
}
export async function fetchTelemetry(
sessionKey: number,
driverNumber: number,
): Promise<CarDataSample[]> {
const res = await fetch(
`/api/v1/telemetry?session_key=${sessionKey}&driver_number=${driverNumber}`,
)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
const data = await res.json()
return Array.isArray(data) ? data : []
}
export async function fetchLapsComparison(
sessionKey: number,
drivers?: number[],
): Promise<LapsComparisonResponse> {
const params = new URLSearchParams({ session_key: String(sessionKey) })
if (drivers?.length) {
params.set('drivers', drivers.join(','))
}
const res = await fetch(`/api/v1/laps/comparison?${params}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}

View File

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

@@ -16,6 +16,9 @@ export function Nav() {
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
Race Hub
</Link>
<Link to="/preview" activeProps={{ className: 'active' }}>
Preview
</Link>
<Link to="/championship" activeProps={{ className: 'active' }}>
Championship
</Link>

View File

@@ -1,28 +1,9 @@
import type { EnrichedResult, Stint, PitStop } from '../types'
import { compareFinishPosition } from '../utils'
const COMPOUND_COLORS: Record<string, string> = {
SOFT: '#e8002d',
MEDIUM: '#ffd600',
HARD: '#e8e8e4',
INTERMEDIATE: '#39b54a',
WET: '#0067ff',
}
function compoundColor(c: string): string {
return COMPOUND_COLORS[c.toUpperCase()] ?? '#666'
}
function compoundInitial(c: string): string {
const abbr: Record<string, string> = {
SOFT: 'S',
MEDIUM: 'M',
HARD: 'H',
INTERMEDIATE: 'I',
WET: 'W',
}
return abbr[c.toUpperCase()] ?? c[0] ?? '?'
}
import {
TyreStintTimeline,
type StintTimelineRow,
} from './charts/TyreStintTimeline'
interface Props {
results: EnrichedResult[]
@@ -89,147 +70,33 @@ export function StrategyView({ results, stints, pit_stops, hasStints }: Props) {
const cmp = compareFinishPosition(a.position, b.position)
return cmp !== 0 ? cmp : a.driver_number - b.driver_number
})
const totalLaps = Math.max(
...stints.map((s) => s.lap_end),
...results.map((r) => r.number_of_laps),
1
1,
)
const SVG_W = 640
const LEFT = 48
const RIGHT = 12
const ROW_H = 28
const BAR_H = 14
const BAR_Y = 7
const BAR_W = SVG_W - LEFT - RIGHT
const SVG_H = sortedDrivers.length * ROW_H + 8
const lapX = (lap: number) => LEFT + ((lap - 1) / totalLaps) * BAR_W
const stintW = (s: Stint) =>
Math.max(2, ((s.lap_end - s.lap_start + 1) / totalLaps) * BAR_W)
const usedCompounds = [...new Set(stints.map((s) => s.compound.toUpperCase()))].filter(
(c) => c in COMPOUND_COLORS
)
const timelineRows: StintTimelineRow[] = sortedDrivers.map((driver) => ({
label: driver.name_acronym || String(driver.driver_number),
color: driver.team_colour ? `#${driver.team_colour}` : '#888',
stints: stints
.filter((s) => s.driver_number === driver.driver_number)
.map((s) => ({
compound: s.compound,
lapStart: s.lap_start,
lapEnd: s.lap_end,
isNew: s.tyre_age_at_start === 0,
})),
pitStops: pit_stops
.filter((p) => p.driver_number === driver.driver_number)
.map((p) => p.lap_number)
.sort((a, b) => a - b),
}))
return (
<div data-testid="strategy-chart">
<div className="scroll-x">
<svg
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
style={{ width: '100%', minWidth: 280, maxWidth: SVG_W, display: 'block' }}
role="img"
aria-label="Race strategy stint chart"
>
{sortedDrivers.map((driver, i) => {
const rowY = i * ROW_H
const color = driver.team_colour ? `#${driver.team_colour}` : '#888'
const dStints = stints.filter((s) => s.driver_number === driver.driver_number)
const dPits = pit_stops.filter((p) => p.driver_number === driver.driver_number)
return (
<g key={driver.driver_number} transform={`translate(0,${rowY})`}>
<text
x={LEFT - 5}
y={BAR_Y + BAR_H / 2 + 4}
textAnchor="end"
fill={color}
fontFamily="var(--f-mono)"
fontWeight={700}
fontSize={10}
>
{driver.name_acronym}
</text>
{dStints.map((stint, si) => {
const x = lapX(stint.lap_start)
const w = stintW(stint)
const fill = compoundColor(stint.compound)
return (
<g key={si}>
<rect x={x} y={BAR_Y} width={w} height={BAR_H} fill={fill} rx={1.5} />
{w > 18 && (
<text
x={x + w / 2}
y={BAR_Y + BAR_H / 2 + 4}
textAnchor="middle"
fill="#111"
fontFamily="var(--f-mono)"
fontWeight={700}
fontSize={8}
>
{compoundInitial(stint.compound)}
</text>
)}
</g>
)
})}
{dPits.map((pit, pi) => {
const x = lapX(pit.lap_number)
return (
<line
key={pi}
x1={x}
x2={x}
y1={BAR_Y - 3}
y2={BAR_Y + BAR_H + 3}
stroke="var(--text)"
strokeWidth={1.5}
opacity={0.7}
/>
)
})}
</g>
)
})}
</svg>
</div>
<div
style={{
display: 'flex',
gap: 'var(--s4)',
flexWrap: 'wrap',
marginTop: 'var(--s4)',
fontSize: 11,
color: 'var(--text-3)',
alignItems: 'center',
}}
>
{usedCompounds.map((c) => (
<span key={c} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span
style={{
width: 10,
height: 10,
background: COMPOUND_COLORS[c],
borderRadius: 2,
display: 'inline-block',
border: c === 'HARD' ? '1px solid #555' : undefined,
}}
/>
{c.charAt(0) + c.slice(1).toLowerCase()}
</span>
))}
{pit_stops.length > 0 && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, marginLeft: 4 }}>
<span
style={{
width: 1,
height: 12,
background: 'var(--text)',
display: 'inline-block',
opacity: 0.7,
}}
/>
Pit stop
</span>
)}
<span style={{ marginLeft: 'auto', fontFamily: 'var(--f-mono)', fontSize: 10 }}>
{totalLaps} laps
</span>
</div>
<TyreStintTimeline rows={timelineRows} totalLaps={totalLaps} />
</div>
)
}

View File

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

View File

@@ -0,0 +1,194 @@
import { useMemo, useRef, useState } from 'react'
import '../../styles/telemetry-trace.css'
export interface TelemetryTraceSample {
speed: number
throttle: number
brake: number
}
export interface TelemetryTraceSeries {
label: string
color: string
samples: TelemetryTraceSample[]
}
export type TelemetryTraceChannel = 'speed' | 'throttle' | 'brake'
export interface TelemetryTraceChartProps {
series: TelemetryTraceSeries[]
channels?: TelemetryTraceChannel[]
/** Height of each channel panel in SVG units (viewBox space). */
height?: number
}
const VIEW_WIDTH = 800
const PANEL_GAP = 18
const PAD_TOP = 6
const PAD_BOTTOM = 6
const CHANNEL_LABELS: Record<TelemetryTraceChannel, string> = {
speed: 'Speed',
throttle: 'Throttle',
brake: 'Brake',
}
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v))
function channelValue(sample: TelemetryTraceSample, channel: TelemetryTraceChannel): number {
const raw = sample[channel]
// Throttle/brake are percentages; clamp so out-of-range API values can't
// draw outside the panel. Speed is clamped to >= 0.
if (channel === 'speed') return Math.max(0, raw)
return clamp(raw, 0, 100)
}
function formatValue(value: number, channel: TelemetryTraceChannel): string {
if (channel === 'speed') return `${Math.round(value)} km/h`
return `${Math.round(value)}%`
}
export function TelemetryTraceChart({
series,
channels = ['speed', 'throttle', 'brake'],
height = 110,
}: TelemetryTraceChartProps) {
const svgRef = useRef<SVGSVGElement | null>(null)
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
// Series are index-aligned; mismatched lengths are clamped to the shortest
// series so every drawn x has a value for every driver.
const sampleCount = useMemo(
() => (series.length === 0 ? 0 : Math.min(...series.map((s) => s.samples.length))),
[series],
)
const speedMax = useMemo(() => {
let max = 0
for (const s of series) {
for (let i = 0; i < sampleCount; i++) {
max = Math.max(max, channelValue(s.samples[i], 'speed'))
}
}
return max > 0 ? max : 1
}, [series, sampleCount])
if (series.length === 0 || sampleCount === 0 || channels.length === 0) {
return <div className="telemetry-trace-empty">No telemetry data</div>
}
// Clamp a stale hover index in case the series prop shrank between renders.
const hover = hoverIndex === null ? null : Math.min(hoverIndex, sampleCount - 1)
const panelHeight = height
const totalHeight = channels.length * panelHeight + (channels.length - 1) * PANEL_GAP
const xAt = (i: number) => (i / Math.max(1, sampleCount - 1)) * VIEW_WIDTH
const channelMax = (channel: TelemetryTraceChannel) => (channel === 'speed' ? speedMax : 100)
const yAt = (value: number, channel: TelemetryTraceChannel, panelTop: number) => {
const usable = panelHeight - PAD_TOP - PAD_BOTTOM
const frac = channelValue({ speed: value, throttle: value, brake: value }, channel) / channelMax(channel)
return panelTop + PAD_TOP + (1 - frac) * usable
}
const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
const rect = svgRef.current?.getBoundingClientRect()
if (!rect || rect.width === 0) {
setHoverIndex(0)
return
}
const frac = clamp((e.clientX - rect.left) / rect.width, 0, 1)
setHoverIndex(clamp(Math.round(frac * (sampleCount - 1)), 0, sampleCount - 1))
}
return (
<div className="telemetry-trace" data-testid="telemetry-trace">
<div className="telemetry-trace-legend">
{series.map((s) => (
<span key={s.label} className="telemetry-trace-legend-item">
<span className="telemetry-trace-swatch" style={{ background: s.color }} aria-hidden="true" />
{s.label}
</span>
))}
</div>
<svg
ref={svgRef}
className="telemetry-trace-svg"
viewBox={`0 0 ${VIEW_WIDTH} ${totalHeight}`}
role="img"
aria-label="Telemetry trace chart"
onMouseMove={handleMouseMove}
onMouseLeave={() => setHoverIndex(null)}
>
{channels.map((channel, panelIdx) => {
const panelTop = panelIdx * (panelHeight + PANEL_GAP)
return (
<g key={channel} className="telemetry-trace-panel" data-channel={channel}>
<rect
className="telemetry-trace-panel-bg"
x={0}
y={panelTop}
width={VIEW_WIDTH}
height={panelHeight}
/>
<text className="telemetry-trace-panel-title" x={6} y={panelTop + 13}>
{CHANNEL_LABELS[channel]}
</text>
<text className="telemetry-trace-axis-max" x={VIEW_WIDTH - 6} y={panelTop + 13} textAnchor="end">
{channel === 'speed' ? `${Math.round(speedMax)} km/h` : '100%'}
</text>
{series.map((s) => {
const points = Array.from({ length: sampleCount }, (_, i) => {
const x = xAt(i)
const y = yAt(s.samples[i][channel], channel, panelTop)
return `${x.toFixed(2)},${y.toFixed(2)}`
}).join(' ')
return (
<polyline
key={s.label}
className="telemetry-trace-line"
data-channel={channel}
data-series={s.label}
points={points}
fill="none"
stroke={s.color}
strokeWidth={1.6}
strokeLinejoin="round"
strokeLinecap="round"
/>
)
})}
</g>
)
})}
{hover !== null && (
<line
className="telemetry-trace-crosshair"
data-testid="telemetry-trace-crosshair"
x1={xAt(hover)}
y1={0}
x2={xAt(hover)}
y2={totalHeight}
/>
)}
</svg>
{hover !== null && (
<div className="telemetry-trace-readout" data-testid="telemetry-trace-readout">
<span className="telemetry-trace-readout-index">Sample {hover}</span>
{series.map((s) => (
<span key={s.label} className="telemetry-trace-readout-driver">
<span className="telemetry-trace-swatch" style={{ background: s.color }} aria-hidden="true" />
<span className="telemetry-trace-readout-label">{s.label}</span>
{channels.map((channel) => (
<span key={channel} className="telemetry-trace-readout-value">
{formatValue(channelValue(s.samples[hover], channel), channel)}
</span>
))}
</span>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,207 @@
import { compoundClass } from '../../lib/live'
import '../../styles/stint-timeline.css'
export interface StintTimelineStint {
compound: string
lapStart: number
lapEnd: number
isNew?: boolean
}
export interface StintTimelineRow {
label: string
color: string
stints: StintTimelineStint[]
/** Lap numbers where the driver pitted; optional — rows without stops render normally. */
pitStops?: number[]
}
interface TyreStintTimelineProps {
rows: StintTimelineRow[]
totalLaps: number
}
const SVG_W = 640
const LEFT = 48
const RIGHT = 12
const ROW_H = 28
const BAR_H = 14
const BAR_Y = 7
const AXIS_H = 20
const BAR_W = SVG_W - LEFT - RIGHT
const COMPOUND_ORDER = ['SOFT', 'MEDIUM', 'HARD', 'INTERMEDIATE', 'WET'] as const
function compoundLabel(compound: string): string {
const upper = compound.toUpperCase()
if (upper === 'INTERMEDIATE') return 'Intermediate'
return upper.charAt(0) + upper.slice(1).toLowerCase()
}
function stintLength(stint: StintTimelineStint): number {
return stint.lapEnd - stint.lapStart + 1
}
function stintTitle(stint: StintTimelineStint): string {
const length = stintLength(stint)
return `${compoundLabel(stint.compound)} · L${stint.lapStart}${stint.lapEnd} · ${length} lap${length === 1 ? '' : 's'}`
}
function lapX(lap: number, totalLaps: number): number {
return LEFT + (lap / totalLaps) * BAR_W
}
function stintBarX(stint: StintTimelineStint, totalLaps: number): number {
return LEFT + ((stint.lapStart - 1) / totalLaps) * BAR_W
}
function stintBarW(stint: StintTimelineStint, totalLaps: number): number {
return Math.max(2, (stintLength(stint) / totalLaps) * BAR_W)
}
function pitMarkerX(lapNumber: number, totalLaps: number): number {
return LEFT + ((lapNumber - 1) / totalLaps) * BAR_W
}
function pitMarkerTitle(driverLabel: string, lapNumber: number): string {
return `${driverLabel} pit stop · L${lapNumber}`
}
function axisTicks(totalLaps: number): number[] {
const ticks: number[] = []
for (let lap = 0; lap <= totalLaps; lap += 10) {
ticks.push(lap)
}
return ticks
}
function collectUsedCompounds(rows: StintTimelineRow[]): string[] {
const seen = new Set<string>()
for (const row of rows) {
for (const stint of row.stints) {
seen.add(stint.compound.toUpperCase())
}
}
const ordered = COMPOUND_ORDER.filter((c) => seen.has(c))
const extras = [...seen]
.filter((c) => !COMPOUND_ORDER.includes(c as (typeof COMPOUND_ORDER)[number]))
.sort()
return [...ordered, ...extras]
}
export function TyreStintTimeline({ rows, totalLaps }: TyreStintTimelineProps) {
const safeTotal = Math.max(totalLaps, 1)
const usedCompounds = collectUsedCompounds(rows)
const ticks = axisTicks(safeTotal)
const chartH = rows.length * ROW_H
const svgH = chartH + AXIS_H + 4
if (rows.length === 0) {
return (
<div className="stint-timeline" data-testid="stint-timeline-empty">
<div className="stint-timeline__empty">No stint data to display.</div>
</div>
)
}
return (
<div className="stint-timeline" data-testid="stint-timeline">
<div className="stint-timeline__scroll">
<svg
viewBox={`0 0 ${SVG_W} ${svgH}`}
className="stint-timeline__svg"
role="img"
aria-label="Tyre stint timeline"
>
{rows.map((row, i) => {
const rowY = i * ROW_H
return (
<g key={`${row.label}-${i}`} transform={`translate(0,${rowY})`}>
<text
x={LEFT - 5}
y={BAR_Y + BAR_H / 2 + 4}
textAnchor="end"
fill={row.color}
className="stint-timeline__label"
>
{row.label}
</text>
{row.stints.map((stint, si) => (
<rect
key={si}
x={stintBarX(stint, safeTotal)}
y={BAR_Y}
width={stintBarW(stint, safeTotal)}
height={BAR_H}
rx={3}
className={`stint-timeline__bar ${compoundClass(stint.compound)}${stint.isNew ? ' stint-timeline__bar--new' : ''}`}
>
<title>{stintTitle(stint)}</title>
</rect>
))}
{(row.pitStops ?? []).map((lapNumber, pi) => (
<line
key={`pit-${pi}`}
x1={pitMarkerX(lapNumber, safeTotal)}
x2={pitMarkerX(lapNumber, safeTotal)}
y1={BAR_Y - 3}
y2={BAR_Y + BAR_H + 3}
className="stint-timeline__pit-marker"
data-testid="pit-marker"
data-lap={lapNumber}
>
<title>{pitMarkerTitle(row.label, lapNumber)}</title>
</line>
))}
</g>
)
})}
<g transform={`translate(0,${chartH})`}>
<line
x1={LEFT}
x2={LEFT + BAR_W}
y1={0}
y2={0}
className="stint-timeline__axis-line"
/>
{ticks.map((lap) => (
<g key={lap}>
<line
x1={lapX(lap, safeTotal)}
x2={lapX(lap, safeTotal)}
y1={0}
y2={4}
className="stint-timeline__axis-line"
/>
<text
x={lapX(lap, safeTotal)}
y={14}
textAnchor="middle"
className="stint-timeline__axis-tick"
>
{lap}
</text>
</g>
))}
</g>
</svg>
</div>
<div className="stint-timeline__legend">
{usedCompounds.map((compound) => (
<span key={compound} className="stint-timeline__legend-item">
<span
className={`stint-timeline__legend-swatch ${compoundClass(compound)}`}
data-testid={`legend-${compound.toLowerCase()}`}
/>
{compoundLabel(compound)}
</span>
))}
<span className="stint-timeline__meta">{safeTotal} laps</span>
</div>
</div>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,108 @@
import { useMemo, useRef, useState } from 'react'
import { Pause, Play } from 'lucide-react'
import type { LiveDriverInfo, LiveRadioCapture, LiveSessionMeta } from '../../types'
import { radioCaptureKey, radioClipUrl } from '../../lib/radio'
import { teamColor } from '../../utils'
import '../../styles/team-radio.css'
interface Props {
captures?: LiveRadioCapture[]
driverInfo?: Record<string, LiveDriverInfo>
session?: LiveSessionMeta
}
function relativeTime(utc: string): string {
const timestamp = new Date(utc).getTime()
if (!utc || Number.isNaN(timestamp)) return '--'
const diffSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000))
if (diffSeconds < 60) return `${diffSeconds}s ago`
const minutes = Math.floor(diffSeconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
return new Date(utc).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
function driverLabel(info: LiveDriverInfo | undefined, racingNumber: string): string {
return info?.Tla || racingNumber
}
export function TeamRadioTicker({ captures = [], driverInfo = {}, session }: Props) {
const audioRef = useRef<HTMLAudioElement | null>(null)
const [playingKey, setPlayingKey] = useState<string | null>(null)
const newestFirst = useMemo(() => [...captures].reverse(), [captures])
const toggleCapture = (capture: LiveRadioCapture) => {
const key = radioCaptureKey(capture)
const audio = audioRef.current
const url = radioClipUrl(session, capture)
if (!audio || !url) return
if (playingKey === key) {
audio.pause()
setPlayingKey(null)
return
}
audio.pause()
audio.src = url
setPlayingKey(key)
const play = audio.play()
if (play) {
play.catch(() => setPlayingKey(null))
}
}
return (
<section className="team-radio panel-glass" data-testid="team-radio-ticker">
<div className="sec-header sticky-header">
<span className="sec-title">Team Radio</span>
{captures.length > 0 && <span className="sec-meta">{captures.length} clips</span>}
</div>
<audio
ref={audioRef}
className="team-radio-audio"
onEnded={() => setPlayingKey(null)}
/>
{newestFirst.length === 0 ? (
<div className="missing-notice">No team radio clips in the current live snapshot.</div>
) : (
<div className="team-radio-list">
{newestFirst.map((capture) => {
const key = radioCaptureKey(capture)
const info = driverInfo[capture.RacingNumber]
const label = driverLabel(info, capture.RacingNumber)
const isPlaying = playingKey === key
const url = radioClipUrl(session, capture)
return (
<div className="team-radio-row" key={key}>
<button
className="team-radio-play"
type="button"
aria-label={`${isPlaying ? 'Pause' : 'Play'} ${label} radio`}
disabled={!url}
onClick={() => toggleCapture(capture)}
>
{isPlaying ? <Pause size={14} /> : <Play size={14} />}
</button>
<span
className="team-radio-driver"
style={{ borderColor: teamColor(info?.TeamColour) }}
title={info?.TeamName || undefined}
>
{label}
</span>
<span className="team-radio-time">{relativeTime(capture.Utc)}</span>
</div>
)
})}
</div>
)}
</section>
)
}

View File

@@ -12,6 +12,9 @@ import {
tyreLabel,
} from '../../lib/live'
import type { GapHistoryMap } from '../../lib/gapHistory'
import { parseIntervalSeconds } from '../../lib/gapHistory'
import { intervalMeaning } from '../../lib/meaning'
import { Meaning } from '../Meaning'
import { GapSparkline } from './GapSparkline'
import { StintHistory } from './StintHistory'
import { Pin } from 'lucide-react'
@@ -100,6 +103,10 @@ export function TimingTower({
const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition
const gapText = gapMode === 'interval' && isRace ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader
const intervalAnnotation =
gapMode === 'interval' && isRace && row.Position > 1
? intervalMeaning(parseIntervalSeconds(gapText))
: null
const renderSector = (idx: number) => {
const sec = driver.Sectors?.[idx]
@@ -149,7 +156,14 @@ export function TimingTower({
<td className={driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono'}>
{driver.LastLapTime || '-'}
</td>
<td className="mono">{gapText || '-'}</td>
<td className="mono">
<Meaning
value={gapText || '-'}
meaning={intervalAnnotation?.caption}
title={intervalAnnotation?.title}
tone={intervalAnnotation?.tone}
/>
</td>
{isRace && (
<td className="spark-cell">

View File

@@ -0,0 +1,140 @@
import { useEffect, useMemo, useState } from 'react'
import { teamColor } from '../../utils'
import type { LiveTimingRow } from '../../lib/live'
import { compoundClass, driverCode, tyreLabel } from '../../lib/live'
import { sparklinePoints } from '../../lib/gapHistory'
import { isRaceSession } from '../../lib/battles'
import type { StintHistoryMap } from '../../lib/tyredeg'
import {
PIT_LOSS_SECONDS,
degradationModel,
estimatePitRejoin,
formatSlope,
recordStintSamples,
stintInputFromRow,
} from '../../lib/tyredeg'
import { tyreAgeMeaning } from '../../lib/meaning'
import { Meaning } from '../Meaning'
import '../../styles/tyredeg.css'
const TOP_DRIVER_COUNT = 10
const SPARK_WIDTH = 64
const SPARK_HEIGHT = 16
interface Props {
rows: LiveTimingRow[]
sessionType: string | undefined
pinned: string[]
}
function StintSparkline({ seconds }: { seconds: number[] }) {
if (seconds.length < 2) return <span className="tyredeg-spark-empty">·</span>
const points = sparklinePoints(seconds, SPARK_WIDTH, SPARK_HEIGHT)
return (
<svg
className="tyredeg-spark"
width={SPARK_WIDTH}
height={SPARK_HEIGHT}
viewBox={`0 0 ${SPARK_WIDTH} ${SPARK_HEIGHT}`}
aria-hidden="true"
focusable="false"
>
<polyline
points={points}
fill="none"
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
)
}
export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
const [collapsed, setCollapsed] = useState(false)
const [stints, setStints] = useState<StintHistoryMap>({})
// One lap-history update per received snapshot (rows is rebuilt per snapshot).
useEffect(() => {
if (rows.length === 0) return
setStints((prev) => recordStintSamples(prev, rows.map(stintInputFromRow)))
}, [rows])
const isRace = isRaceSession(sessionType)
const visible = useMemo(
() =>
rows.filter(
(row) =>
row.Position > 0 &&
!row.Driver.Retired &&
(row.Position <= TOP_DRIVER_COUNT || pinned.includes(row.RacingNumber)),
),
[rows, pinned],
)
if (visible.length === 0) return null
return (
<section className="live-tyredeg-panel" data-testid="tyredeg-panel">
<button
type="button"
className="sec-header tyredeg-toggle"
onClick={() => setCollapsed((prev) => !prev)}
aria-expanded={!collapsed}
>
<span className="sec-title">Tyre Deg &amp; Pit Window</span>
{isRace && <span className="sec-meta">rejoin assumes ~{PIT_LOSS_SECONDS}s pit loss</span>}
<span className="tyredeg-chevron" aria-hidden="true">{collapsed ? '▸' : '▾'}</span>
</button>
{!collapsed && (
<div className="tyredeg-rows">
{visible.map((row) => {
const model = degradationModel(stints[row.RacingNumber]?.samples ?? [])
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age)
return (
<div className="tyredeg-row" key={row.RacingNumber} data-testid="tyredeg-row">
<span className="tyredeg-pos mono">P{row.Position}</span>
<span className="drv-bar" style={{ background: teamColor(row.Info?.TeamColour) }} />
<span className="drv-code">{driverCode(row)}</span>
<span className={`tyre-badge ${compoundClass(row.Tyre?.Compound)}`}>
<Meaning
value={tyreLabel(row.Tyre)}
meaning={ageAnnotation?.caption}
title={ageAnnotation?.title}
tone={ageAnnotation?.tone}
/>
</span>
{model ? (
<>
<span className={`tyredeg-trend tyredeg-trend-${model.trend}`}>
<StintSparkline seconds={model.samples.map((sample) => sample.seconds)} />
</span>
<span className={`tyredeg-slope mono tyredeg-trend-${model.trend}`}>
{formatSlope(model.slope)}
</span>
</>
) : (
<span className="tyredeg-warmup">warming up</span>
)}
{rejoin && (
<span className="tyredeg-rejoin mono" title={`Pits now: rejoins ~P${rejoin.rejoinPosition}`}>
~P{rejoin.rejoinPosition}
{rejoin.aheadCode && rejoin.behindCode && (
<span className="tyredeg-rejoin-between">
{rejoin.aheadCode} · {rejoin.behindCode}
</span>
)}
</span>
)}
</div>
)
})}
</div>
)}
</section>
)
}

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

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

View File

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

View File

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

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

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

View File

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

27
frontend/src/lib/hero.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { Session } from '../types'
import { sessionEndTime, sessionStartTime, type FocusMeetingKind } from './schedule'
export type HeroStateKind = 'live' | 'upcoming' | 'between'
export interface HeroStateInputs {
now: Date
liveActive: boolean
currentSession: Session | null
focusKind: FocusMeetingKind | null
}
export function classifySessionStatus(session: Session, now: Date): 'live' | 'done' | 'upcoming' {
const start = sessionStartTime(session)
const end = sessionEndTime(session)
if (start && end && now >= start && now < end) return 'live'
if (start && now >= start) return 'done'
return 'upcoming'
}
export function heroState(inputs: HeroStateInputs): HeroStateKind {
const { liveActive, currentSession, focusKind } = inputs
if (liveActive || currentSession) return 'live'
if (focusKind === 'current') return 'upcoming'
return 'between'
}

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

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

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

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

22
frontend/src/lib/radio.ts Normal file
View File

@@ -0,0 +1,22 @@
import type { LiveRadioCapture, LiveSessionMeta } from '../types'
export const LIVE_TIMING_STATIC_BASE = 'https://livetiming.formula1.com/static/'
export function radioClipUrl(
session: Pick<LiveSessionMeta, 'Path'> | null | undefined,
capture: Pick<LiveRadioCapture, 'Path'> | null | undefined,
): string {
const sessionPath = session?.Path?.trim()
const capturePath = capture?.Path?.trim()
if (!sessionPath || !capturePath) return ''
if (/^https?:\/\//i.test(capturePath)) return capturePath
const base = LIVE_TIMING_STATIC_BASE.replace(/\/+$/, '')
const normalizedSession = sessionPath.replace(/^\/+|\/+$/g, '')
const normalizedCapture = capturePath.replace(/^\/+/g, '')
return `${base}/${normalizedSession}/${normalizedCapture}`
}
export function radioCaptureKey(capture: LiveRadioCapture): string {
return `${capture.Utc}-${capture.RacingNumber}-${capture.Path}`
}

271
frontend/src/lib/tyredeg.ts Normal file
View File

@@ -0,0 +1,271 @@
// Tyre degradation + pit-window estimation for the live timing page.
// Pure functions only — no React, no side effects — so everything is unit-testable.
//
// Lap-time samples are accumulated client-side from successive SSE snapshots
// (mirroring the gapHistory/battles precedent); nothing here talks to the server.
import type { LiveTimingRow } from './live'
import { driverCode } from './live'
import { parseIntervalSeconds } from './gapHistory'
/**
* Rough typical pit-lane time loss (entry + stop + exit vs a flying lap), in
* seconds. Per-track values are deliberately out of scope for v1.
*/
export const PIT_LOSS_SECONDS = 22
/** Minimum clean laps in the current stint before a slope is trustworthy. */
export const MIN_CLEAN_LAPS = 4
/**
* Laps more than this many seconds off the stint median are treated as
* outliers (traffic, spins, safety car) and excluded from the fit.
*/
export const OUTLIER_DELTA_SECONDS = 5
/**
* Slope classification thresholds (seconds per lap):
* slope <= -DEG_SLOPE_THRESHOLD -> 'improving' (track evolution, fuel burn dominating)
* slope >= +DEG_SLOPE_THRESHOLD -> 'degrading' (tyre wear dominating)
* otherwise -> 'stable'
*/
export const DEG_SLOPE_THRESHOLD = 0.05
/** Safety cap so a very long stint cannot grow the buffer unbounded. */
export const MAX_STINT_SAMPLES = 80
export type DegTrend = 'improving' | 'stable' | 'degrading'
export interface StintSample {
lap: number
seconds: number
}
export interface DriverStintHistory {
compound: string
tyreAge: number
lastLapNumber: number
lastLapTime: string
/** The next completed lap is an out-lap (fresh stint) and must be discarded. */
skipNextLap: boolean
samples: StintSample[]
}
/** Per-driver current-stint lap history, keyed by racing number. */
export type StintHistoryMap = Record<string, DriverStintHistory>
export interface StintLapInput {
racingNumber: string
lapNumber: number
lastLapTime: string
compound: string
tyreAge: number
inPit: boolean
pitOut: boolean
}
/**
* Parse an F1 live-timing lap time string ("1:23.456", "83.456") into seconds.
* Same conventions as parseIntervalSeconds, but a lap time is never signed and
* never a lapped/leader marker; zero or negative values are rejected.
*/
export function parseLapTimeSeconds(raw: string | null | undefined): number | null {
const value = parseIntervalSeconds(raw)
if (value === null || value <= 0) return null
return value
}
export function stintInputFromRow(row: LiveTimingRow): StintLapInput {
return {
racingNumber: row.RacingNumber,
lapNumber: row.Driver.NumberOfLaps || 0,
lastLapTime: row.Driver.LastLapTime || '',
compound: row.Tyre?.Compound || '',
tyreAge: row.Tyre?.Age ?? 0,
inPit: Boolean(row.Driver.InPit),
pitOut: Boolean(row.Driver.PitOut),
}
}
/**
* Record one snapshot's worth of lap samples for each driver's current stint.
* Returns a new map (input is not mutated); drivers missing from `inputs` are
* pruned.
*
* A lap counts as completed when LastLapTime changes to a new non-empty value
* (NumberOfLaps can tick before the lap time arrives, so the time string is
* the trigger; a sample for the same lap number is replaced, not duplicated).
* The stint buffer resets when the compound changes or the tyre age drops
* (new set fitted), and laps completed in the pit lane (in-lap), on pit exit
* (out-lap), or immediately after a reset are excluded.
*/
export function recordStintSamples(
history: StintHistoryMap,
inputs: ReadonlyArray<StintLapInput>,
): StintHistoryMap {
const next: StintHistoryMap = {}
for (const input of inputs) {
if (!input.racingNumber) continue
const prior = history[input.racingNumber]
if (!prior) {
next[input.racingNumber] = {
compound: input.compound,
tyreAge: input.tyreAge,
lastLapNumber: input.lapNumber,
lastLapTime: input.lastLapTime,
skipNextLap: false,
samples: [],
}
continue
}
let samples = prior.samples
let skipNextLap = prior.skipNextLap
const compoundChanged = Boolean(input.compound) && Boolean(prior.compound) && input.compound !== prior.compound
const freshSet = input.tyreAge < prior.tyreAge
if (compoundChanged || freshSet || input.inPit) {
// Stint over (or a new one starting): drop the old laps and flag the
// upcoming out-lap for exclusion.
samples = []
skipNextLap = true
}
const lapCompleted = Boolean(input.lastLapTime) && input.lastLapTime !== prior.lastLapTime
if (lapCompleted) {
const seconds = parseLapTimeSeconds(input.lastLapTime)
const dirty = input.inPit || input.pitOut || skipNextLap
if (seconds !== null && !dirty) {
const last = samples[samples.length - 1]
if (last && last.lap === input.lapNumber) {
samples = [...samples.slice(0, -1), { lap: input.lapNumber, seconds }]
} else {
samples = [...samples, { lap: input.lapNumber, seconds }]
if (samples.length > MAX_STINT_SAMPLES) {
samples = samples.slice(samples.length - MAX_STINT_SAMPLES)
}
}
}
if (!input.inPit) skipNextLap = false
}
next[input.racingNumber] = {
compound: input.compound || prior.compound,
tyreAge: input.tyreAge,
lastLapNumber: input.lapNumber,
lastLapTime: input.lastLapTime || prior.lastLapTime,
skipNextLap,
samples,
}
}
return next
}
export interface DegModel {
/** Least-squares slope of lap time vs lap number, in seconds per lap. */
slope: number
trend: DegTrend
/** Clean (outlier-filtered) samples the fit ran over, in lap order. */
samples: StintSample[]
}
/** Drop laps more than OUTLIER_DELTA_SECONDS off the stint median. */
export function cleanStintSamples(
samples: ReadonlyArray<StintSample>,
maxDelta = OUTLIER_DELTA_SECONDS,
): StintSample[] {
if (samples.length === 0) return []
const sorted = samples.map((sample) => sample.seconds).sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
const median = sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
return samples.filter((sample) => Math.abs(sample.seconds - median) <= maxDelta)
}
/**
* Fit a linear degradation model over the stint's clean laps.
* Returns null with fewer than MIN_CLEAN_LAPS clean samples — the caller
* should show a "warming up" placeholder rather than a junk slope.
*/
export function degradationModel(samples: ReadonlyArray<StintSample>): DegModel | null {
const clean = cleanStintSamples(samples)
if (clean.length < MIN_CLEAN_LAPS) return null
const n = clean.length
const meanLap = clean.reduce((sum, s) => sum + s.lap, 0) / n
const meanSec = clean.reduce((sum, s) => sum + s.seconds, 0) / n
let numerator = 0
let denominator = 0
for (const sample of clean) {
numerator += (sample.lap - meanLap) * (sample.seconds - meanSec)
denominator += (sample.lap - meanLap) ** 2
}
if (denominator === 0) return null
const slope = numerator / denominator
const trend: DegTrend =
slope <= -DEG_SLOPE_THRESHOLD ? 'improving' : slope >= DEG_SLOPE_THRESHOLD ? 'degrading' : 'stable'
return { slope, trend, samples: clean }
}
/** "+0.08s/lap" / "-0.12s/lap" display form. */
export function formatSlope(slope: number): string {
const sign = slope >= 0 ? '+' : ''
return `${sign}${Math.abs(slope).toFixed(2)}s/lap`
}
export interface PitRejoinEstimate {
rejoinPosition: number
/** Cars behind that would get past during the stop. */
positionsLost: number
/** Driver code directly ahead after rejoining, if any. */
aheadCode: string | null
/** Driver code directly behind after rejoining, if any. */
behindCode: string | null
}
/**
* Estimate where a driver rejoins after a pit stop costing `pitLoss` seconds.
*
* Walks the cars behind the driver, accumulating their Interval (gap to car
* ahead) values: every car whose cumulative gap to the driver is under the
* pit loss gets past. An unparsable interval (lapped marker like "1L") ends
* the walk — those cars are at least a lap down and stay behind. Cars in the
* pits or retired are excluded from the ladder.
*/
export function estimatePitRejoin(
rows: ReadonlyArray<LiveTimingRow>,
racingNumber: string,
pitLoss = PIT_LOSS_SECONDS,
): PitRejoinEstimate | null {
const ladder = rows
.filter((row) => row.Position > 0 && !row.Driver.Retired && !row.Driver.InPit)
.sort((a, b) => a.Position - b.Position)
const index = ladder.findIndex((row) => row.RacingNumber === racingNumber)
if (index === -1) return null
let cumulative = 0
let positionsLost = 0
for (let i = index + 1; i < ladder.length; i++) {
const gap = parseIntervalSeconds(ladder[i].Driver.Interval)
if (gap === null) break
cumulative += Math.max(0, gap)
if (cumulative >= pitLoss) break
positionsLost += 1
}
const ahead = positionsLost > 0 ? ladder[index + positionsLost] : ladder[index - 1]
const behind = ladder[index + positionsLost + 1]
return {
rejoinPosition: ladder[index].Position + positionsLost,
positionsLost,
aheadCode: ahead ? driverCode(ahead) : null,
behindCode: behind ? driverCode(behind) : null,
}
}

View File

@@ -1,9 +1,12 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { fetchChampionshipHub, fetchSeasons } from '../api'
import { teamColor } from '../utils'
import type { ChampHubDriver, ChampionshipHub } from '../types'
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
import { Meaning } from '../components/Meaning'
import { pointsGapMeaning } from '../lib/meaning'
type View = 'drivers' | 'constructors' | 'progression' | 'simulator'
@@ -138,6 +141,8 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
color: teamColor(d.team_colour),
gapLeader: i === 0 ? 'LEADER' : `+${fmtPts(gapLeaderNum)}`,
gapAhead: gapAheadNum == null ? '—' : `+${fmtPts(gapAheadNum)}`,
gapAheadNum,
driverAhead: i === 0 ? null : drivers[i - 1].name_acronym,
spark: sparkPoints(d.form),
h2h: `${d.teammate_wins}${d.teammate_losses}`,
h2hWin: d.teammate_wins >= d.teammate_losses,
@@ -233,7 +238,13 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
</div>
{view === 'drivers' && (
<DriversView enriched={enriched} leaderPoints={leader.points} titleMath={titleMath} />
<DriversView
enriched={enriched}
leaderPoints={leader.points}
titleMath={titleMath}
roundsLeft={hub.rounds_left}
season={hub.season}
/>
)}
{view === 'constructors' && <ConstructorsView hub={hub} />}
{view === 'progression' && <ProgressionView hub={hub} />}
@@ -248,6 +259,8 @@ interface EnrichedDriver {
color: string
gapLeader: string
gapAhead: string
gapAheadNum: number | null
driverAhead: string | null
spark: string
h2h: string
h2hWin: boolean
@@ -259,10 +272,14 @@ function DriversView({
enriched,
leaderPoints,
titleMath,
roundsLeft,
season,
}: {
enriched: EnrichedDriver[]
leaderPoints: number
titleMath: string
roundsLeft: number
season: number
}) {
const podium = enriched.slice(0, 3)
return (
@@ -347,17 +364,36 @@ function DriversView({
P{e.pos}
</td>
<td>
<div className="champ-drv">
<Link
to="/drivers/$driverNumber"
params={{ driverNumber: String(e.d.driver_number) }}
search={{ year: season }}
className="champ-drv"
style={{ color: 'inherit', textDecoration: 'none' }}
data-testid={`champ-driver-link-${e.d.driver_number}`}
>
<span className="champ-drv-bar" style={{ background: e.color }} />
<span className="champ-drv-code mono">{e.d.name_acronym}</span>
<span className="champ-drv-name">{e.d.full_name}</span>
<span className="champ-drv-num mono">#{e.d.driver_number}</span>
</div>
</Link>
</td>
<td className="champ-td-team">{e.d.team_name}</td>
<td className="r mono champ-td-pts">{fmtPts(e.d.points)}</td>
<td className="r mono champ-td-muted">{e.gapLeader}</td>
<td className="r mono champ-td-dim">{e.gapAhead}</td>
<td className="r mono champ-td-dim">
{(() => {
const gapAnnotation = pointsGapMeaning(e.gapAheadNum, roundsLeft, e.driverAhead)
return (
<Meaning
value={e.gapAhead}
meaning={gapAnnotation?.caption}
title={gapAnnotation?.title}
tone={gapAnnotation?.tone}
/>
)
})()}
</td>
<td className="c mono" style={{ color: e.d.wins > 0 ? 'var(--text)' : 'var(--text-3)' }}>
{e.d.wins}
</td>

View File

@@ -1,38 +1,38 @@
import { useEffect, useMemo, useState } from 'react'
import { useQueries, useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { fetchLiveState, fetchLocalMeetings, fetchSeasonMeetings, fetchSeasons, fetchSessions, fetchWeekend, fetchChampionshipHub } from '../api'
import {
fetchChampionshipHub,
fetchLiveState,
fetchLocalMeetings,
fetchRaceHub,
fetchSeasonMeetings,
fetchSeasons,
fetchSessions,
fetchWeekend,
} from '../api'
import { CommandCenterHero } from '../components/CommandCenterHero'
import { PaddockBriefing } from '../components/PaddockBriefing'
import { RACE_HUB_DATASETS, countWeekendStats, formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
import { countryAccent, countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
import { classifySessionStatus, heroState } from '../lib/hero'
import {
currentAndNextSession,
focusMeetingKind,
formatCountdown,
formatSessionScheduleTime,
meetingHasStarted,
mostRecentPastMeeting,
nextUpcomingMeeting,
pickFocusMeeting,
sessionEndTime,
sessionStartTime,
sortSessionsByStart,
} from '../lib/schedule'
import { countryAccent, countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
import type { Meeting, Session, Weekend, WeekendSession } from '../types'
import { PaddockBriefing } from '../components/PaddockBriefing'
import { Play, Activity, Calendar, Trophy } from 'lucide-react'
type WeekendStatusKind = 'live' | 'current' | 'next' | 'recent' | 'fallback'
import { Trophy } from 'lucide-react'
const missingDatasets = Object.fromEntries(
RACE_HUB_DATASETS.map((dataset) => [dataset, { status: 'missing', source: 'none', count: 0 }]),
) as WeekendSession['datasets']
function classifySessionStatus(session: Session, now: Date): 'live' | 'done' | 'upcoming' {
const start = sessionStartTime(session)
const end = sessionEndTime(session)
if (start && end && now >= start && now < end) return 'live'
if (start && now >= start) return 'done'
return 'upcoming'
}
function meetingStatus(meeting: Meeting, focusKey: number | undefined, now: Date) {
if (meeting.meeting_key === focusKey) return 'focus'
if (meetingHasStarted(meeting, now)) return 'past'
@@ -148,7 +148,36 @@ export function CommandCenterPage() {
focusWeekendSessions[0]?.session
const analysisSessionKey = actionSession?.session_key
const weekendsLoading = weekendQueries.some((q) => q.isLoading)
const liveActive = liveQuery.data?.is_live === true
const heroStateKind = heroState({
now: nowDate,
liveActive,
currentSession,
focusKind,
})
const lastPastMeeting = useMemo(
() => mostRecentPastMeeting(focusMeetings, nowDate),
[focusMeetings, nowDate],
)
const lastPastWeekend = lastPastMeeting ? weekendsByKey.get(lastPastMeeting.meeting_key) : undefined
const lastRaceAnalysis = pickAnalysisSession(lastPastWeekend)
const lastRaceSessionKey = lastRaceAnalysis?.session.session_key
const lastRaceHubQuery = useQuery({
queryKey: ['race-hub', lastRaceSessionKey, 'hero-podium'],
queryFn: () => fetchRaceHub(lastRaceSessionKey!),
enabled: lastRaceSessionKey != null && heroStateKind === 'between',
staleTime: 60_000,
})
const nextMeetingForHero = useMemo(() => {
if (heroStateKind !== 'between') return null
return nextUpcomingMeeting(focusMeetings, nowDate)
}, [heroStateKind, focusMeetings, nowDate])
const lastRacePodium = lastRaceHubQuery.data?.results ?? []
const lastRaceName = champHub?.last_race ?? lastPastMeeting?.meeting_name ?? ''
if (seasonsQuery.isLoading) {
return <div className="page loading-state">loading command center</div>
@@ -189,19 +218,7 @@ export function CommandCenterPage() {
)
}
const liveActive = liveQuery.data?.is_live === true
const statusKind: WeekendStatusKind = liveActive
? 'live'
: focusKind === 'current'
? 'current'
: focusKind === 'next'
? 'next'
: focusKind === 'recent'
? 'recent'
: 'fallback'
const accent = countryAccent(focusMeeting ?? null)
const decal = countryDecal(focusMeeting ?? null)
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
return (
@@ -224,76 +241,25 @@ export function CommandCenterPage() {
</div>
)}
{focusMeeting && (
<div className="cc-hero">
<section className="cc-weekend-band ui-card glass-panel" data-testid="cc-focus">
<div className="cc-band-accent" aria-hidden="true" />
<div className="cc-band-body">
<div className="cc-band-row">
<span className="cc-band-decal mono">{decal}</span>
<div className="cc-band-titles">
<div className="cc-band-eyebrow mono">
<WeekendKindLabel kind={statusKind} />
</div>
<h1 className="cc-band-name">{focusMeeting.meeting_name}</h1>
<div className="cc-band-sub mono">
{[focusMeeting.location, focusMeeting.circuit_short_name]
.filter(Boolean)
.join(' · ')}
</div>
<div className="cc-band-sub mono cc-band-dates">{formatGpDateRange(focusMeeting)}</div>
</div>
<CountdownBlock
liveActive={liveActive}
currentSession={currentSession}
nextSession={nextSession}
meeting={focusMeeting}
now={nowDate}
/>
</div>
</div>
</section>
<div className="cc-actions-row" data-testid="cc-actions">
<Link
to="/live"
className={`cc-pri-action ui-card interactive ${liveActive ? 'is-live ui-cta-primary' : ''}`}
data-testid="cc-action-live"
>
<span className="cc-pri-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}><Play size={16} /> Watch Live</span>
<span className="cc-pri-meta mono">{liveActive ? 'Feed active' : 'Standby'}</span>
</Link>
<Link
to="/race-hub"
search={analysisSessionKey ? { session_key: analysisSessionKey } : {}}
className="cc-pri-action ui-card interactive"
data-testid="cc-action-race-hub"
>
<span className="cc-pri-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}><Activity size={16} /> Open Analysis</span>
<span className="cc-pri-meta mono">
{actionSession
? `${actionSession.session_name} · session ${actionSession.session_key}`
: 'Pick a session'}
</span>
</Link>
{nextSession && sessionStartTime(nextSession) && (
<a
href="#cc-schedule"
className="cc-pri-action ui-card interactive"
data-testid="cc-action-schedule"
onClick={(e) => {
e.preventDefault()
document.getElementById('cc-schedule')?.scrollIntoView({ behavior: 'smooth' })
}}
>
<span className="cc-pri-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}><Calendar size={16} /> Schedule</span>
<span className="cc-pri-meta mono">
next: {nextSession.session_name}
</span>
</a>
)}
</div>
</div>
{focusMeeting && focusKind && (
<CommandCenterHero
state={heroStateKind}
now={nowDate}
accent={accent}
liveActive={liveActive}
liveData={liveQuery.data?.data}
focusMeeting={focusMeeting}
focusKind={focusKind}
sessions={focusWeekendSessions}
currentSession={currentSession}
nextSession={nextSession}
analysisSessionKey={analysisSessionKey}
analysisSessionName={actionSession?.session_name}
lastRaceName={lastRaceName}
lastRacePodium={lastRacePodium}
lastRaceSessionKey={lastRaceSessionKey}
nextMeeting={nextMeetingForHero}
/>
)}
<div className="cc-dashboard-grid">
@@ -483,65 +449,3 @@ function FormSparkline({ form, color }: { form: number[], color: string }) {
)
}
function WeekendKindLabel({ kind }: { kind: WeekendStatusKind }) {
switch (kind) {
case 'live':
return <span className="cc-kind cc-kind-live"> Live now</span>
case 'current':
return <span className="cc-kind cc-kind-current">Current weekend</span>
case 'next':
return <span className="cc-kind cc-kind-next">Next weekend</span>
case 'recent':
return <span className="cc-kind cc-kind-recent">Recent weekend</span>
default:
return <span className="cc-kind">Weekend</span>
}
}
interface CountdownBlockProps {
liveActive: boolean
currentSession: Session | null
nextSession: Session | null
meeting: Meeting
now: Date
}
function CountdownBlock({ liveActive, currentSession, nextSession, meeting, now }: CountdownBlockProps) {
if (liveActive) {
return (
<div className="cc-countdown-block">
<div className="cc-cd-label mono">SignalR</div>
<div className="cc-cd-value cc-cd-live">LIVE</div>
<div className="cc-cd-sub mono">{currentSession?.session_name ?? 'Feed connected'}</div>
</div>
)
}
if (currentSession) {
return (
<div className="cc-countdown-block">
<div className="cc-cd-label mono">On Track</div>
<div className="cc-cd-value cc-cd-current">{currentSession.session_name}</div>
<div className="cc-cd-sub mono">In session</div>
</div>
)
}
if (nextSession && sessionStartTime(nextSession)) {
return (
<div className="cc-countdown-block">
<div className="cc-cd-label mono">Next · {nextSession.session_name}</div>
<div className="cc-cd-value mono">{formatCountdown(sessionStartTime(nextSession)!, now)}</div>
<div className="cc-cd-sub mono">{formatSessionScheduleTime(nextSession.date_start)}</div>
</div>
)
}
if (meetingHasStarted(meeting, now)) {
return (
<div className="cc-countdown-block">
<div className="cc-cd-label mono">Status</div>
<div className="cc-cd-value cc-cd-done">Complete</div>
<div className="cc-cd-sub mono">Weekend finished</div>
</div>
)
}
return null
}

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ import { TabBar, type Tab } from '../components/TabBar'
import { DatasetStatusView } from '../components/DatasetStatusView'
import { StrategyView } from '../components/StrategyView'
import { LapsView } from '../components/LapsView'
import { CompareView } from '../components/CompareView'
import { RaceControlView } from '../components/RaceControlView'
import { WeatherView } from '../components/WeatherView'
import { OverviewView } from '../components/OverviewView'
@@ -307,6 +308,19 @@ export function RaceHubPage({ sessionKey }: Props) {
</div>
)}
{activeTab === 'compare' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Driver Compare</span>
</div>
<CompareView
sessionKey={sessionKey}
results={data.results}
drivers={data.drivers}
/>
</div>
)}
{activeTab === 'lap_data' && (
<div className="data-section">
<div className="sec-header">

View File

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

View File

@@ -6,11 +6,17 @@ import { DataLibraryPage } from './pages/DataLibraryPage'
import { LiveTimingPage } from './pages/LiveTimingPage'
import { BriefingPage } from './pages/BriefingPage'
import { ChampionshipPage } from './pages/ChampionshipPage'
import { DriverProfilePage } from './pages/DriverProfilePage'
import { RacePreviewPage } from './pages/RacePreviewPage'
type RaceHubSearch = {
session_key?: number
}
type DriverProfileSearch = {
year?: number
}
const rootRoute = createRootRoute({
component: () => (
<>
@@ -64,12 +70,32 @@ export const championshipRoute = createRoute({
component: ChampionshipPage,
})
export const driverProfileRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/drivers/$driverNumber',
validateSearch: (search: Record<string, unknown>): DriverProfileSearch => {
const year = Number(search.year)
return Number.isFinite(year) && year > 0 ? { year } : {}
},
component: function DriverProfileRoute() {
const { driverNumber } = driverProfileRoute.useParams()
const { year } = driverProfileRoute.useSearch()
return <DriverProfilePage driverNumber={Number(driverNumber) || 0} year={year} />
},
})
export const briefingRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/briefing',
component: BriefingPage,
})
export const previewRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/preview',
component: RacePreviewPage,
})
const routeTree = rootRoute.addChildren([
commandCenterRoute,
raceHubRoute,
@@ -77,7 +103,9 @@ const routeTree = rootRoute.addChildren([
dataLibraryRoute,
liveTimingRoute,
championshipRoute,
driverProfileRoute,
briefingRoute,
previewRoute,
])
export const router = createRouter({ routeTree })

View File

@@ -808,6 +808,12 @@ a { color: inherit; text-decoration: none; }
color: var(--yellow);
}
.live-status-archive {
background: rgba(70, 140, 255, 0.08);
border: 1px solid rgba(70, 140, 255, 0.24);
color: #8bb7ff;
}
/* ── Live empty state ── */
.live-empty-status {
display: flex;
@@ -815,6 +821,29 @@ a { color: inherit; text-decoration: none; }
margin-bottom: var(--s4);
}
.live-archive-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--s2);
margin-top: var(--s5);
min-height: 36px;
padding: 0 var(--s4);
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
color: var(--text);
font-family: var(--f-mono);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.live-archive-btn:hover {
border-color: rgba(255, 255, 255, 0.28);
background: rgba(255, 255, 255, 0.1);
}
.mono { font-family: var(--f-mono); }
.live-conn,

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,373 @@
.hero-panel {
display: flex;
flex-direction: column;
gap: var(--s4);
}
.hero-card {
display: flex;
background: var(--surface);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
overflow: hidden;
min-height: 124px;
}
.hero-accent {
width: 4px;
flex-shrink: 0;
box-shadow: 0 0 12px var(--hero-accent, var(--gp-accent));
background: var(--hero-accent, var(--gp-accent));
}
.hero-accent--live {
animation: hero-pulse 1.4s ease-in-out infinite;
}
@keyframes hero-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
.hero-body {
flex: 1;
padding: var(--s5);
min-width: 0;
position: relative;
overflow: hidden;
background:
radial-gradient(ellipse at top right, color-mix(in srgb, var(--gp-accent) 15%, transparent), transparent 60%),
linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
var(--surface);
background-size: 100% 100%, 20px 20px, 20px 20px, 100% 100%;
}
.hero-body::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent 0%, rgba(0, 0, 0, 0.3) 100%);
pointer-events: none;
}
.hero-inner {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: var(--s5);
}
.hero-row {
display: flex;
align-items: stretch;
gap: var(--s6);
min-width: 0;
flex-wrap: wrap;
}
.hero-identity {
flex: 1;
min-width: 0;
}
.hero-decal {
font-size: 56px;
font-weight: 800;
line-height: 0.85;
letter-spacing: -0.04em;
color: color-mix(in srgb, var(--gp-accent) 35%, var(--text-3));
opacity: 0.9;
flex-shrink: 0;
}
.hero-eyebrow {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-3);
margin-bottom: var(--s2);
}
.hero-eyebrow--live {
color: var(--red);
}
.hero-title {
font-size: 28px;
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.1;
margin: 0;
}
.hero-sub {
font-size: 12px;
color: var(--text-2);
margin-top: var(--s2);
}
.hero-countdown {
text-align: right;
flex-shrink: 0;
}
.hero-cd-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-3);
margin-bottom: var(--s2);
}
.hero-cd-value {
font-size: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--text);
}
.hero-cd-value--live {
color: var(--red);
font-size: 28px;
letter-spacing: 0.08em;
}
.hero-cd-sub {
font-size: 11px;
color: var(--text-3);
margin-top: var(--s2);
}
.hero-track-status {
display: inline-flex;
align-items: center;
gap: var(--s3);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
padding: var(--s2) var(--s3);
border-radius: 4px;
margin-top: var(--s3);
}
.hero-track-status--green { color: #3dd68c; background: rgba(61, 214, 140, 0.1); }
.hero-track-status--yellow { color: #f5c842; background: rgba(245, 200, 66, 0.1); }
.hero-track-status--sc,
.hero-track-status--vsc { color: #f5a623; background: rgba(245, 166, 35, 0.12); }
.hero-track-status--red { color: var(--red); background: rgba(225, 6, 0, 0.12); }
.hero-track-status--unknown { color: var(--text-3); background: rgba(255, 255, 255, 0.05); }
.hero-timing {
display: flex;
flex-direction: column;
gap: var(--s2);
min-width: 200px;
}
.hero-timing-row {
display: grid;
grid-template-columns: 28px 1fr auto;
align-items: center;
gap: var(--s3);
padding: var(--s2) var(--s3);
background: rgba(255, 255, 255, 0.03);
border-radius: 6px;
font-size: 13px;
}
.hero-timing-pos {
font-weight: 700;
color: var(--text-3);
font-variant-numeric: tabular-nums;
}
.hero-timing-pos--p1 { color: #ffd700; }
.hero-timing-pos--p2 { color: #c0c0c0; }
.hero-timing-pos--p3 { color: #cd7f32; }
.hero-timing-bar {
width: 3px;
height: 14px;
border-radius: 2px;
flex-shrink: 0;
}
.hero-timing-driver {
display: flex;
align-items: center;
gap: var(--s3);
font-weight: 600;
font-family: var(--f-mono);
}
.hero-timing-gap {
font-size: 11px;
color: var(--text-3);
font-family: var(--f-mono);
font-variant-numeric: tabular-nums;
}
.hero-cta {
display: inline-flex;
align-items: center;
gap: var(--s3);
font-size: 14px;
font-weight: 700;
color: var(--red);
text-decoration: none;
transition: opacity 0.1s;
}
.hero-cta:hover {
opacity: 0.85;
}
.hero-cta--primary {
padding: var(--s3) var(--s5);
background: rgba(225, 6, 0, 0.12);
border: 1px solid rgba(225, 6, 0, 0.35);
border-radius: 6px;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: var(--s3);
align-items: center;
}
.hero-secondary-link {
font-size: 12px;
font-weight: 600;
color: var(--text-2);
text-decoration: none;
}
.hero-secondary-link:hover {
color: var(--text);
}
.hero-schedule-strip {
display: flex;
gap: var(--s3);
overflow-x: auto;
padding-bottom: var(--s2);
}
.hero-schedule-card {
flex: 0 0 auto;
min-width: 120px;
padding: var(--s3) var(--s4);
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.02);
text-decoration: none;
color: inherit;
transition: background 0.1s, border-color 0.1s;
}
.hero-schedule-card:hover {
background: rgba(255, 255, 255, 0.05);
}
.hero-schedule-card.is-next {
border-color: color-mix(in srgb, var(--gp-accent) 50%, transparent);
background: color-mix(in srgb, var(--gp-accent) 8%, transparent);
}
.hero-schedule-card.is-done {
opacity: 0.55;
}
.hero-schedule-card.is-live {
border-color: rgba(225, 6, 0, 0.4);
background: rgba(225, 6, 0, 0.08);
}
.hero-schedule-abbrev {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
color: var(--text-3);
}
.hero-schedule-name {
font-size: 12px;
font-weight: 600;
margin: var(--s2) 0;
}
.hero-schedule-time {
font-size: 10px;
color: var(--text-3);
}
.hero-schedule-marker {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
margin-top: var(--s2);
color: var(--text-3);
}
.hero-schedule-marker--next { color: var(--gp-accent); }
.hero-schedule-marker--live { color: var(--red); }
.hero-podium {
display: flex;
flex-direction: column;
gap: var(--s2);
min-width: 200px;
}
.hero-podium-row {
display: grid;
grid-template-columns: 32px 1fr;
align-items: center;
gap: var(--s3);
padding: var(--s2) var(--s3);
background: rgba(255, 255, 255, 0.03);
border-radius: 6px;
font-size: 13px;
}
.hero-podium-pos {
font-weight: 700;
font-family: var(--f-mono);
color: var(--text-3);
}
.hero-podium-pos--p1 { color: #ffd700; }
.hero-podium-pos--p2 { color: #c0c0c0; }
.hero-podium-pos--p3 { color: #cd7f32; }
.hero-podium-driver {
display: flex;
align-items: center;
gap: var(--s3);
font-weight: 600;
font-family: var(--f-mono);
}
.hero-between-next {
display: flex;
align-items: center;
gap: var(--s4);
}
.hero-flag {
font-size: 32px;
line-height: 1;
}
@media (min-width: 700px) {
.hero-row {
flex-wrap: nowrap;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,101 @@
.stint-timeline {
--stint-left: 48px;
--stint-right: 12px;
--stint-row-h: 28px;
--stint-bar-h: 14px;
--stint-axis-h: 20px;
}
.stint-timeline__scroll {
overflow-x: auto;
}
.stint-timeline__svg {
width: 100%;
min-width: 280px;
max-width: 640px;
display: block;
}
.stint-timeline__label {
font-family: var(--f-mono);
font-weight: 700;
font-size: 10px;
}
.stint-timeline__bar {
stroke: none;
}
.stint-timeline__bar.tyre-soft { fill: var(--tyre-soft); }
.stint-timeline__bar.tyre-medium { fill: var(--tyre-medium); }
.stint-timeline__bar.tyre-hard { fill: var(--tyre-hard); stroke: #555; stroke-width: 0.5; }
.stint-timeline__bar.tyre-inter { fill: var(--tyre-inter); }
.stint-timeline__bar.tyre-wet { fill: var(--tyre-wet); }
.stint-timeline__bar.tyre-unknown { fill: var(--surface-2); stroke: var(--border-2); stroke-width: 0.5; }
.stint-timeline__bar--new {
stroke: var(--text);
stroke-width: 1;
stroke-dasharray: 2 1;
}
.stint-timeline__pit-marker {
stroke: var(--text);
stroke-width: 1.5;
opacity: 0.7;
pointer-events: stroke;
}
.stint-timeline__axis-tick {
font-family: var(--f-mono);
font-size: 9px;
fill: var(--text-3);
}
.stint-timeline__axis-line {
stroke: var(--border-2);
stroke-width: 1;
}
.stint-timeline__legend {
display: flex;
gap: var(--s4);
flex-wrap: wrap;
margin-top: var(--s4);
font-size: 11px;
color: var(--text-3);
align-items: center;
}
.stint-timeline__legend-swatch {
width: 10px;
height: 10px;
border-radius: 2px;
display: inline-block;
}
.stint-timeline__legend-swatch.tyre-hard {
border: 1px solid #555;
}
.stint-timeline__legend-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.stint-timeline__meta {
margin-left: auto;
font-family: var(--f-mono);
font-size: 10px;
}
.stint-timeline__empty {
padding: var(--s5);
color: var(--text-3);
font-size: 13px;
text-align: center;
border: 1px dashed var(--border-2);
border-radius: var(--r2);
}

View File

@@ -0,0 +1,82 @@
.team-radio {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.team-radio-audio {
display: none;
}
.team-radio-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 260px;
overflow-y: auto;
padding-right: 2px;
}
.team-radio-row {
display: grid;
grid-template-columns: 28px minmax(44px, 56px) minmax(68px, 1fr);
align-items: center;
gap: 8px;
min-height: 34px;
padding: 6px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.team-radio-row:last-child {
border-bottom: 0;
}
.team-radio-play {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 50%;
color: var(--text-1);
background: rgba(255, 255, 255, 0.06);
cursor: pointer;
}
.team-radio-play:hover:not(:disabled),
.team-radio-play:focus-visible:not(:disabled) {
border-color: rgba(255, 255, 255, 0.36);
background: rgba(255, 255, 255, 0.12);
}
.team-radio-play:disabled {
color: var(--text-3);
cursor: not-allowed;
opacity: 0.55;
}
.team-radio-driver {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
height: 24px;
border-left: 3px solid var(--text-3);
color: var(--text-1);
font-family: var(--font-mono);
font-size: 12px;
font-weight: 700;
letter-spacing: 0;
}
.team-radio-time {
min-width: 0;
color: var(--text-2);
font-family: var(--font-mono);
font-size: 12px;
text-align: right;
white-space: nowrap;
}

View File

@@ -0,0 +1,101 @@
.telemetry-trace {
display: flex;
flex-direction: column;
gap: 8px;
}
.telemetry-trace-empty {
padding: 24px;
text-align: center;
color: var(--text-3);
font-size: 13px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
}
.telemetry-trace-legend {
display: flex;
flex-wrap: wrap;
gap: 14px;
font-size: 12px;
color: var(--text-2);
}
.telemetry-trace-legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.telemetry-trace-swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
flex: none;
}
.telemetry-trace-svg {
display: block;
width: 100%;
height: auto;
}
.telemetry-trace-panel-bg {
fill: var(--surface);
stroke: var(--border);
stroke-width: 1;
rx: 4;
}
.telemetry-trace-panel-title {
fill: var(--text-2);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.telemetry-trace-axis-max {
fill: var(--text-3);
font-size: 10px;
}
.telemetry-trace-crosshair {
stroke: var(--text-2);
stroke-width: 1;
stroke-dasharray: 3 3;
pointer-events: none;
}
.telemetry-trace-readout {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 6px 10px;
font-size: 12px;
color: var(--text-2);
background: var(--surface-h);
border: 1px solid var(--border);
border-radius: 6px;
}
.telemetry-trace-readout-index {
color: var(--text-3);
}
.telemetry-trace-readout-driver {
display: inline-flex;
align-items: center;
gap: 6px;
}
.telemetry-trace-readout-label {
color: var(--text);
font-weight: 600;
}
.telemetry-trace-readout-value {
font-variant-numeric: tabular-nums;
}

View File

@@ -0,0 +1,84 @@
/* Live tyre-degradation & pit-window panel (see components/live/TyreDegPanel.tsx) */
.live-tyredeg-panel {
margin-bottom: var(--s5);
}
.tyredeg-toggle {
width: 100%;
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
}
.tyredeg-chevron {
margin-left: auto;
font-size: 10px;
color: var(--text-3);
}
.tyredeg-rows {
display: flex;
flex-direction: column;
gap: 3px;
}
.tyredeg-row {
display: flex;
align-items: center;
gap: var(--s4);
padding: var(--s2) var(--s4);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 3px;
font-size: 12px;
}
.tyredeg-row .drv-bar { height: 16px; }
.tyredeg-pos {
min-width: 28px;
color: var(--text-2);
font-size: 11px;
}
.tyredeg-spark { display: block; }
.tyredeg-spark-empty { color: var(--text-3); }
.tyredeg-trend { display: inline-flex; align-items: center; }
.tyredeg-trend-improving { color: var(--green); }
.tyredeg-trend-stable { color: var(--text-3); }
.tyredeg-trend-degrading { color: #ff6b6b; }
.tyredeg-slope {
min-width: 78px;
font-size: 11px;
}
.tyredeg-warmup {
color: var(--text-3);
font-size: 11px;
font-style: italic;
}
.tyredeg-rejoin {
margin-left: auto;
display: inline-flex;
align-items: baseline;
gap: var(--s3);
color: var(--text-2);
font-size: 11px;
white-space: nowrap;
}
.tyredeg-rejoin-between {
color: var(--text-3);
font-size: 10px;
}
@media (max-width: 640px) {
.tyredeg-rejoin-between { display: none; }
.tyredeg-slope { min-width: 0; }
}

View File

@@ -112,6 +112,7 @@ describe('ChampionshipPage', () => {
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
expect(screen.getByText('Monaco GP', { exact: false })).toBeInTheDocument()
expect(screen.getByTestId('champ-titlemath')).toHaveTextContent('mathematically win the title')
expect(screen.getAllByText('~10 pts/round').length).toBeGreaterThan(0)
})
it('switches to constructors and progression views', async () => {

View File

@@ -12,9 +12,20 @@ vi.mock('../api', () => ({
fetchSessions: vi.fn(),
fetchWeekend: vi.fn(),
fetchLiveState: vi.fn(),
fetchChampionshipHub: vi.fn(),
fetchRaceHub: vi.fn(),
}))
import { fetchSeasons, fetchLocalMeetings, fetchSeasonMeetings, fetchSessions, fetchWeekend, fetchLiveState } from '../api'
import {
fetchSeasons,
fetchLocalMeetings,
fetchSeasonMeetings,
fetchSessions,
fetchWeekend,
fetchLiveState,
fetchChampionshipHub,
fetchRaceHub,
} from '../api'
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
@@ -22,6 +33,8 @@ const mockFetchSeasonMeetings = vi.mocked(fetchSeasonMeetings)
const mockFetchSessions = vi.mocked(fetchSessions)
const mockFetchWeekend = vi.mocked(fetchWeekend)
const mockFetchLiveState = vi.mocked(fetchLiveState)
const mockFetchChampionshipHub = vi.mocked(fetchChampionshipHub)
const mockFetchRaceHub = vi.mocked(fetchRaceHub)
const meeting: Meeting = {
meeting_key: 1229,
@@ -102,6 +115,49 @@ describe('CommandCenterPage', () => {
vi.clearAllMocks()
mockFetchLiveState.mockResolvedValue({ is_live: false, data: null })
mockFetchSessions.mockResolvedValue([])
mockFetchChampionshipHub.mockResolvedValue({
season: 2025,
round: 1,
total_rounds: 1,
rounds_left: 0,
last_race: 'Monaco',
round_labels: ['R1'],
drivers: [],
teams: [],
})
mockFetchRaceHub.mockResolvedValue({
source: 'local',
session_key: 9472,
datasets: fullDatasets,
results: [
{
driver_number: 1,
position: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull Racing',
team_colour: '3671c6',
dnf: false,
dns: false,
dsq: false,
duration: 7200,
gap_to_leader: null,
number_of_laps: 78,
points: 25,
session_key: 9472,
meeting_key: 1229,
},
],
starting_grid: [],
drivers: [],
stints: [],
pit_stops: [],
positions: [],
race_control: [],
weather: [],
laps: [],
chapters: [],
})
})
it('shows empty state when no seasons are ingested', async () => {
@@ -131,11 +187,10 @@ describe('CommandCenterPage', () => {
expect(screen.getByTestId('cc-session-9472')).toBeInTheDocument()
})
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
expect(screen.getByTestId('cc-focus')).toHaveTextContent('MON')
expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar')
expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01')
expect(screen.getByText('No live session')).toBeInTheDocument()
expect(screen.getByTestId('cc-action-race-hub')).toHaveTextContent('Race')
expect(screen.getByTestId('hero-last-race-link')).toHaveTextContent('Monaco')
})
it('uses OpenF1 calendar metadata to focus the current weekend when local ingest is behind', async () => {
@@ -181,9 +236,9 @@ describe('CommandCenterPage', () => {
await waitFor(() => {
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
})
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Current weekend')
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Live now')
expect(screen.getByTestId('cc-session-9602')).toHaveTextContent('On track')
expect(screen.getByTestId('cc-action-race-hub')).toHaveTextContent('Qualifying')
expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live')
vi.useRealTimers()
})

View File

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

View File

@@ -189,6 +189,26 @@ describe('TimingTower', () => {
expect(screen.getByText(/no driver timing rows/i)).toBeInTheDocument()
})
it('annotates DRS-range intervals in race mode', () => {
const raceRows = [
makeRow('1', 1, 'VER'),
makeRow('4', 2, 'NOR', { Interval: '+0.4', GapToLeader: '+0.4' }),
]
render(
<TimingTower
rows={raceRows}
session={{
MeetingName: 'Monaco Grand Prix',
CircuitName: 'Monaco',
SessionType: 'Race',
SessionName: 'Race',
Path: '',
}}
/>,
)
expect(screen.getByText('DRS range')).toBeInTheDocument()
})
it('renders the SQ1 cutoff after P17 and marks rows below as at risk', () => {
const sprintRows = Array.from({ length: 22 }, (_, index) =>
makeRow(String(index + 1), index + 1, `D${index + 1}`),
@@ -201,6 +221,7 @@ describe('TimingTower', () => {
CircuitName: 'Silverstone',
SessionType: 'Sprint Qualifying',
SessionName: 'Sprint Qualifying',
Path: '',
}}
/>,
)

View File

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

View File

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

View File

@@ -155,6 +155,7 @@ const raceHub: RaceHub = {
is_pit_out_lap: false,
},
],
chapters: [],
}
const weekend: Weekend = {

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,87 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
import { radioClipUrl } from '../lib/radio'
import type { LiveDriverInfo, LiveRadioCapture, LiveSessionMeta } from '../types'
const session: LiveSessionMeta = {
MeetingName: 'British Grand Prix',
CircuitName: 'Silverstone',
SessionType: 'Race',
SessionName: 'Race',
Path: '/2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/',
}
const driverInfo: Record<string, LiveDriverInfo> = {
'4': {
RacingNumber: '4',
BroadcastName: 'L NORRIS',
Tla: 'NOR',
TeamName: 'McLaren',
TeamColour: 'ff8000',
FirstName: 'Lando',
LastName: 'Norris',
},
'16': {
RacingNumber: '16',
BroadcastName: 'C LECLERC',
Tla: 'LEC',
TeamName: 'Ferrari',
TeamColour: 'e8002d',
FirstName: 'Charles',
LastName: 'Leclerc',
},
}
const captures: LiveRadioCapture[] = [
{ Utc: '2026-07-05T14:05:00Z', RacingNumber: '16', Path: '/TeamRadio/LEC-1.mp3' },
{ Utc: '2026-07-05T14:07:00Z', RacingNumber: '4', Path: 'TeamRadio/NOR-1.mp3' },
]
afterEach(() => {
vi.restoreAllMocks()
})
describe('radioClipUrl', () => {
it('builds static CDN URLs from the session path and capture path', () => {
expect(radioClipUrl(session, captures[0])).toBe(
'https://livetiming.formula1.com/static/2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/TeamRadio/LEC-1.mp3',
)
})
it('returns an empty URL without path data', () => {
expect(radioClipUrl({ ...session, Path: '' }, captures[0])).toBe('')
expect(radioClipUrl(session, { ...captures[0], Path: '' })).toBe('')
})
})
describe('TeamRadioTicker', () => {
it('renders captures newest first with driver labels', () => {
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-05T14:10:00Z'))
render(<TeamRadioTicker captures={captures} driverInfo={driverInfo} session={session} />)
const rows = screen.getAllByRole('button')
expect(rows).toHaveLength(2)
expect(screen.getByText('NOR')).toBeInTheDocument()
expect(screen.getByText('LEC')).toBeInTheDocument()
expect(screen.getAllByText(/m ago/).map((node) => node.textContent)).toEqual(['3m ago', '5m ago'])
})
it('uses one audio element and toggles play/pause per clip', () => {
const play = vi.spyOn(window.HTMLMediaElement.prototype, 'play').mockResolvedValue()
const pause = vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => {})
render(<TeamRadioTicker captures={captures} driverInfo={driverInfo} session={session} />)
fireEvent.click(screen.getByLabelText('Play NOR radio'))
expect(play).toHaveBeenCalledTimes(1)
expect(document.querySelectorAll('audio')).toHaveLength(1)
expect(document.querySelector('audio')?.getAttribute('src')).toBe(
'https://livetiming.formula1.com/static/2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/TeamRadio/NOR-1.mp3',
)
fireEvent.click(screen.getByLabelText('Pause NOR radio'))
expect(pause).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { TyreDegPanel } from '../components/live/TyreDegPanel'
import type { LiveTimingRow } from '../lib/live'
import type { LiveDriverData, LiveTyreData } from '../types'
function makeRow(
number: string,
position: number,
tla: string,
driver: Partial<LiveDriverData> = {},
tyre: Partial<LiveTyreData> = {},
): LiveTimingRow {
return {
RacingNumber: number,
Position: position,
Driver: {
RacingNumber: number,
Position: position,
Interval: position === 1 ? '' : '+5.0',
GapToLeader: '',
LastLapTime: '1:30.000',
NumberOfLaps: 10,
InPit: false,
PitOut: false,
Retired: false,
...driver,
} as LiveDriverData,
Info: {
RacingNumber: number,
BroadcastName: '',
Tla: tla,
TeamName: '',
TeamColour: '3671c6',
FirstName: '',
LastName: '',
},
Tyre: { Compound: 'MEDIUM', New: false, Age: 5, ...tyre } as LiveTyreData,
}
}
function snapshotRows(lap: number, lastLapTime: string): LiveTimingRow[] {
return [
makeRow('1', 1, 'VER', { NumberOfLaps: lap, LastLapTime: lastLapTime }),
makeRow('4', 2, 'NOR', { NumberOfLaps: lap, LastLapTime: lastLapTime }),
]
}
describe('TyreDegPanel', () => {
it('shows a warming-up placeholder until enough clean laps accumulate', () => {
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
const panel = screen.getByTestId('tyredeg-panel')
expect(panel).toHaveTextContent('VER')
expect(panel).toHaveTextContent('M +5')
expect(panel).toHaveTextContent('fresh')
expect(panel).toHaveTextContent('warming up')
})
it('annotates tyre age meaning on stint rows', () => {
const rows = [
makeRow('1', 1, 'VER', { NumberOfLaps: 10, LastLapTime: '1:30.000' }, { Compound: 'MEDIUM', Age: 12 }),
]
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={[]} />)
expect(screen.getByText('mid-life')).toBeInTheDocument()
})
it('renders slope and rejoin estimate once laps accumulate across snapshots', () => {
const { rerender } = render(
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
)
// Five completed laps at +0.1s/lap after the first observed snapshot.
for (let lap = 2; lap <= 6; lap++) {
const time = `1:30.${String((lap - 1) * 100).padStart(3, '0')}`
rerender(<TyreDegPanel rows={snapshotRows(lap, time)} sessionType="Race" pinned={[]} />)
}
const panel = screen.getByTestId('tyredeg-panel')
expect(panel).toHaveTextContent('+0.10s/lap')
expect(panel).not.toHaveTextContent('warming up')
// VER pits from P1 with NOR +5.0 behind: NOR gets past -> rejoin ~P2.
expect(panel).toHaveTextContent('→ ~P2')
})
it('hides the rejoin estimate outside race sessions and collapses on toggle', () => {
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Qualifying" pinned={[]} />)
const panel = screen.getByTestId('tyredeg-panel')
expect(panel).not.toHaveTextContent('~P')
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
})
it('limits rows to the top ten plus pinned drivers', () => {
const rows = Array.from({ length: 15 }, (_, index) =>
makeRow(String(index + 1), index + 1, `D${index + 1}`),
)
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={['14']} />)
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(11)
expect(screen.getByText('D14')).toBeInTheDocument()
expect(screen.queryByText('D12')).not.toBeInTheDocument()
})
it('renders nothing without rows', () => {
render(<TyreDegPanel rows={[]} sessionType="Race" pinned={[]} />)
expect(screen.queryByTestId('tyredeg-panel')).not.toBeInTheDocument()
})
})

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,268 @@
import { describe, expect, it } from 'vitest'
import {
MAX_EVENTS,
appendEvents,
diffSnapshots,
humanizeRaceControl,
sessionSignature,
} from '../lib/events'
import type { LiveEvent } from '../lib/events'
import type {
LiveDriverData,
LiveDriverInfo,
LiveRCMessage,
LiveStreamData,
} from '../types'
function driver(number: string, position: number, overrides: Partial<LiveDriverData> = {}): LiveDriverData {
return {
RacingNumber: number,
Position: position,
PrevPosition: position,
GapToLeader: '',
Interval: '',
LastLapTime: '',
LastLapPB: false,
LastLapOB: false,
BestLapTime: '',
BestLapPB: false,
BestLapOB: false,
BestLapNum: 0,
InPit: false,
PitOut: false,
Retired: false,
KnockedOut: false,
Cutoff: false,
OnFlyingLap: false,
NumberOfLaps: 10,
SpeedTrap: '',
Sectors: [],
...overrides,
}
}
function info(number: string, tla: string): LiveDriverInfo {
return {
RacingNumber: number,
BroadcastName: tla,
Tla: tla,
TeamName: '',
TeamColour: '3671C6',
FirstName: '',
LastName: '',
}
}
function snapshot(
drivers: Record<string, LiveDriverData>,
overrides: Partial<LiveStreamData> = {},
): LiveStreamData {
return {
Drivers: drivers,
DriverInfo: {
'1': info('1', 'VER'),
'4': info('4', 'NOR'),
'44': info('44', 'HAM'),
},
Tyres: {},
RCMessages: [],
TeamRadio: [],
Weather: {} as LiveStreamData['Weather'],
Session: {
MeetingName: 'Test Grand Prix',
CircuitName: 'Test Circuit',
SessionType: 'Race',
SessionName: 'Race',
Path: '',
},
TrackStatus: '1',
CurrentLap: 12,
TotalLaps: 57,
Clock: '01:23:45',
ClockRefTime: '',
ClockExtrapolating: false,
Stints: {},
...overrides,
}
}
function rc(message: string, category: string, overrides: Partial<LiveRCMessage> = {}): LiveRCMessage {
return { Time: '2026-07-03T14:00:00Z', Category: category, Flag: '', Message: message, Lap: 12, ...overrides }
}
describe('diffSnapshots', () => {
it('returns no events for identical snapshots', () => {
const prev = snapshot({ '1': driver('1', 1), '44': driver('44', 2) })
const next = snapshot({ '1': driver('1', 1), '44': driver('44', 2) })
expect(diffSnapshots(prev, next)).toEqual([])
})
it('reports an overtake when two drivers swap positions', () => {
const prev = snapshot({ '1': driver('1', 3), '44': driver('44', 2) })
const next = snapshot({ '1': driver('1', 2), '44': driver('44', 3) })
const events = diffSnapshots(prev, next)
expect(events).toHaveLength(1)
expect(events[0].kind).toBe('overtake')
expect(events[0].headline).toBe('VER overtakes HAM for P2')
expect(events[0].racingNumbers).toEqual(['1', '44'])
expect(events[0].lap).toBe(12)
})
it('reports a generic gain when the displaced driver pitted instead', () => {
const prev = snapshot({ '1': driver('1', 3), '44': driver('44', 2) })
const next = snapshot({ '1': driver('1', 2), '44': driver('44', 5, { InPit: true }) })
const events = diffSnapshots(prev, next)
const gain = events.find((event) => event.kind === 'position-gain')
expect(gain?.headline).toBe('VER moves up to P2 (from P3)')
// HAM's drop is covered by the pit-in event, not a position-loss one.
expect(events.some((event) => event.kind === 'position-loss')).toBe(false)
expect(events.some((event) => event.kind === 'pit-in')).toBe(true)
})
it('reports a position loss not explained by an overtake or pit stop', () => {
const prev = snapshot({ '44': driver('44', 3) })
const next = snapshot({ '44': driver('44', 6) })
const events = diffSnapshots(prev, next)
expect(events).toHaveLength(1)
expect(events[0].kind).toBe('position-loss')
expect(events[0].headline).toBe('HAM drops to P6 (from P3)')
})
it('reports pit entry with the position pitted from', () => {
const prev = snapshot({ '4': driver('4', 3) })
const next = snapshot({ '4': driver('4', 3, { InPit: true }) })
const events = diffSnapshots(prev, next)
expect(events).toHaveLength(1)
expect(events[0].kind).toBe('pit-in')
expect(events[0].headline).toBe('NOR pits from P3')
expect(events[0].racingNumbers).toEqual(['4'])
})
it('reports pit exit with rejoin position, compound, and stop count', () => {
const prev = snapshot({ '4': driver('4', 3, { InPit: true }) })
const next = snapshot(
{ '4': driver('4', 6, { PitOut: true }) },
{
Tyres: { '4': { Compound: 'MEDIUM', New: true, Age: 0 } },
Stints: {
'4': [
{ Compound: 'SOFT', New: true, Laps: 12 },
{ Compound: 'HARD', New: true, Laps: 20 },
{ Compound: 'MEDIUM', New: true, Laps: 0 },
],
},
},
)
const events = diffSnapshots(prev, next)
expect(events).toHaveLength(1)
expect(events[0].kind).toBe('pit-out')
expect(events[0].headline).toBe('NOR rejoins P6 (MEDIUM, 2nd stop)')
})
it('reports personal and overall best laps once per lap time', () => {
const prev = snapshot({
'1': driver('1', 1, { LastLapTime: '1:24.500' }),
'4': driver('4', 2, { LastLapTime: '1:25.100' }),
})
const next = snapshot({
'1': driver('1', 1, { LastLapTime: '1:23.456', LastLapPB: true, LastLapOB: true }),
'4': driver('4', 2, { LastLapTime: '1:24.900', LastLapPB: true }),
})
const events = diffSnapshots(prev, next)
const fastest = events.find((event) => event.kind === 'fastest-lap')
const personal = events.find((event) => event.kind === 'personal-best')
expect(fastest?.headline).toBe('VER sets the fastest lap — 1:23.456')
expect(personal?.headline).toBe('NOR sets a personal best — 1:24.900')
// A repeat diff of the same state produces nothing new.
expect(diffSnapshots(next, snapshot(next.Drivers))).toEqual([])
})
it('reports a retirement with the last running position', () => {
const prev = snapshot({ '44': driver('44', 5) })
const next = snapshot({ '44': driver('44', 5, { Retired: true }) })
const events = diffSnapshots(prev, next)
expect(events).toHaveLength(1)
expect(events[0].kind).toBe('retirement')
expect(events[0].headline).toBe('HAM retires')
expect(events[0].detail).toBe('Was running P5')
})
it('reports track status transitions as human sentences', () => {
const prev = snapshot({ '1': driver('1', 1) })
const next = snapshot({ '1': driver('1', 1) }, { TrackStatus: '4' })
const events = diffSnapshots(prev, next)
expect(events).toHaveLength(1)
expect(events[0].kind).toBe('track-status')
expect(events[0].headline).toBe('Safety Car deployed')
})
it('reports new Flag/SafetyCar/Drs race-control messages and skips other categories', () => {
const existing = rc('YELLOW IN TRACK SECTOR 7', 'Flag', { Time: '2026-07-03T13:00:00Z' })
const prev = snapshot({ '1': driver('1', 1) }, { RCMessages: [existing] })
const next = snapshot({ '1': driver('1', 1) }, {
RCMessages: [
existing,
rc('DRS ENABLED', 'Drs'),
rc('FIA STEWARDS: CAR 4 (NOR) UNDER INVESTIGATION', 'Other'),
],
})
const events = diffSnapshots(prev, next)
expect(events).toHaveLength(1)
expect(events[0].kind).toBe('race-control')
expect(events[0].headline).toBe('DRS enabled')
})
it('produces stable ids across repeated diffs of the same snapshots', () => {
const prev = snapshot({ '1': driver('1', 3), '44': driver('44', 2) })
const next = snapshot({ '1': driver('1', 2), '44': driver('44', 3) })
const first = diffSnapshots(prev, next)
const second = diffSnapshots(prev, next)
expect(first.map((event) => event.id)).toEqual(second.map((event) => event.id))
})
})
describe('humanizeRaceControl', () => {
it('sentence-cases while preserving acronyms', () => {
expect(humanizeRaceControl('SAFETY CAR DEPLOYED')).toBe('Safety car deployed')
expect(humanizeRaceControl('DRS ENABLED')).toBe('DRS enabled')
expect(humanizeRaceControl('VSC DEPLOYED')).toBe('VSC deployed')
})
})
describe('appendEvents', () => {
const event = (id: string): LiveEvent => ({
id,
kind: 'race-control',
lap: 1,
timestamp: '',
headline: id,
racingNumbers: [],
})
it('prepends new events newest-first and dedups by id', () => {
const buffer = appendEvents([event('a')], [event('a'), event('b'), event('c')])
expect(buffer.map((entry) => entry.id)).toEqual(['c', 'b', 'a'])
// Re-appending the same diff output changes nothing.
const again = appendEvents(buffer, [event('b'), event('c')])
expect(again.map((entry) => entry.id)).toEqual(['c', 'b', 'a'])
})
it('evicts the oldest events beyond the cap', () => {
let buffer: LiveEvent[] = []
for (let index = 0; index < MAX_EVENTS + 5; index += 1) {
buffer = appendEvents(buffer, [event(`event-${index}`)])
}
expect(buffer).toHaveLength(MAX_EVENTS)
expect(buffer[0].id).toBe(`event-${MAX_EVENTS + 4}`)
expect(buffer[buffer.length - 1].id).toBe('event-5')
})
})
describe('sessionSignature', () => {
it('changes when the session changes, enabling buffer resets', () => {
const race = snapshot({}).Session
expect(sessionSignature(race)).toBe('Test Grand Prix|Race|Race')
expect(sessionSignature({ ...race, SessionName: 'Qualifying' })).not.toBe(sessionSignature(race))
expect(sessionSignature(null)).toBe('')
})
})

View File

@@ -0,0 +1,123 @@
import { describe, it, expect } from 'vitest'
import { heroState, classifySessionStatus } from '../lib/hero'
import { currentAndNextSession } from '../lib/schedule'
import type { Session } from '../types'
const session = (overrides: Partial<Session> = {}): Session => ({
session_key: 9472,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1,
date_start: '2025-05-25T13:00:00+00:00',
date_end: '2025-05-25T15:00:00+00:00',
gmt_offset: '02:00:00',
...overrides,
})
describe('heroState', () => {
it('returns live when the live snapshot is active', () => {
const now = new Date('2025-05-24T12:00:00Z')
expect(
heroState({
now,
liveActive: true,
currentSession: null,
focusKind: 'current',
}),
).toBe('live')
})
it('returns live when a session is in progress', () => {
const now = new Date('2025-05-25T14:00:00Z')
const sessions = [session()]
const { current } = currentAndNextSession(sessions, now)
expect(
heroState({
now,
liveActive: false,
currentSession: current,
focusKind: 'current',
}),
).toBe('live')
})
it('returns upcoming during a focus weekend gap before the next session', () => {
const now = new Date('2025-05-24T12:00:00Z')
const sessions = [
session({
session_key: 1,
session_name: 'FP1',
date_start: '2025-05-23T10:00:00+00:00',
date_end: '2025-05-23T11:00:00+00:00',
}),
session({
session_key: 2,
session_name: 'Race',
date_start: '2025-05-25T13:00:00+00:00',
date_end: '2025-05-25T15:00:00+00:00',
}),
]
const { current, next } = currentAndNextSession(sessions, now)
expect(current).toBeNull()
expect(next?.session_key).toBe(2)
expect(
heroState({
now,
liveActive: false,
currentSession: current,
focusKind: 'current',
}),
).toBe('upcoming')
})
it('returns upcoming when the weekend just ended but is still the focus weekend', () => {
const now = new Date('2025-05-25T16:00:00Z')
const sessions = [session()]
const { current } = currentAndNextSession(sessions, now)
expect(current).toBeNull()
expect(
heroState({
now,
liveActive: false,
currentSession: current,
focusKind: 'current',
}),
).toBe('upcoming')
})
it('returns between before the next grand prix weekend', () => {
const now = new Date('2025-06-01T12:00:00Z')
expect(
heroState({
now,
liveActive: false,
currentSession: null,
focusKind: 'next',
}),
).toBe('between')
})
it('returns between after the season when only a recent meeting remains', () => {
const now = new Date('2026-01-01T00:00:00Z')
expect(
heroState({
now,
liveActive: false,
currentSession: null,
focusKind: 'recent',
}),
).toBe('between')
})
})
describe('classifySessionStatus', () => {
it('marks a session as done immediately after it ends', () => {
const now = new Date('2025-05-25T15:00:00Z')
expect(classifySessionStatus(session(), now)).toBe('done')
})
it('marks a session as live during its window', () => {
const now = new Date('2025-05-25T14:00:00Z')
expect(classifySessionStatus(session(), now)).toBe('live')
})
})

View File

@@ -0,0 +1,257 @@
import { describe, it, expect, vi } from 'vitest'
import type { ComponentProps, ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import type { EnrichedResult, LiveStreamData, Meeting, WeekendSession } from '../types'
vi.mock('@tanstack/react-router', () => ({
Link: ({
to,
children,
...rest
}: {
to: string
children: ReactNode
className?: string
'data-testid'?: string
}) => (
<a href={to} {...rest}>
{children}
</a>
),
}))
import { CommandCenterHero } from '../components/CommandCenterHero'
const focusMeeting: Meeting = {
meeting_key: 1229,
meeting_name: 'Monaco',
meeting_official_name: 'FORMULA 1 GRAND PRIX DE MONACO 2025',
location: 'Monaco',
country_name: 'Monaco',
country_code: 'MON',
country_flag: '',
circuit_short_name: 'Monaco',
date_start: '2025-05-23T00:00:00+00:00',
date_end: '2025-05-25T00:00:00+00:00',
year: 2025,
}
const weekendSessions: WeekendSession[] = [
{
session: {
session_key: 9472,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1229,
date_start: '2025-05-25T13:00:00+00:00',
date_end: '2025-05-25T15:00:00+00:00',
gmt_offset: '02:00:00',
},
source: 'local',
datasets: {} as WeekendSession['datasets'],
},
]
const liveData: LiveStreamData = {
Drivers: {
'1': {
RacingNumber: '1',
Position: 1,
PrevPosition: 1,
GapToLeader: '',
Interval: '',
LastLapTime: '1:14.000',
LastLapPB: false,
LastLapOB: false,
BestLapTime: '1:13.500',
BestLapPB: false,
BestLapOB: false,
BestLapNum: 10,
InPit: false,
PitOut: false,
Retired: false,
KnockedOut: false,
Cutoff: false,
OnFlyingLap: false,
NumberOfLaps: 10,
SpeedTrap: '',
Sectors: [],
},
'44': {
RacingNumber: '44',
Position: 2,
PrevPosition: 2,
GapToLeader: '+1.2',
Interval: '+1.2',
LastLapTime: '1:14.200',
LastLapPB: false,
LastLapOB: false,
BestLapTime: '1:13.700',
BestLapPB: false,
BestLapOB: false,
BestLapNum: 10,
InPit: false,
PitOut: false,
Retired: false,
KnockedOut: false,
Cutoff: false,
OnFlyingLap: false,
NumberOfLaps: 10,
SpeedTrap: '',
Sectors: [],
},
},
DriverInfo: {
'1': {
RacingNumber: '1',
BroadcastName: 'M VERSTAPPEN',
Tla: 'VER',
TeamName: 'Red Bull Racing',
TeamColour: '3671c6',
FirstName: 'Max',
LastName: 'Verstappen',
},
'44': {
RacingNumber: '44',
BroadcastName: 'L HAMILTON',
Tla: 'HAM',
TeamName: 'Ferrari',
TeamColour: 'e8002d',
FirstName: 'Lewis',
LastName: 'Hamilton',
},
},
Tyres: {},
RCMessages: [],
Weather: {
AirTemp: 20,
TrackTemp: 30,
Humidity: 50,
WindSpeed: 1,
WindDir: 0,
Rainfall: false,
},
Session: {
MeetingName: 'Monaco',
CircuitName: 'Monaco',
SessionType: 'Race',
SessionName: 'Race',
},
TrackStatus: '1',
CurrentLap: 10,
TotalLaps: 78,
Clock: '',
ClockRefTime: '',
ClockExtrapolating: false,
Stints: {},
}
const podium: EnrichedResult[] = [
{
driver_number: 1,
position: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull Racing',
team_colour: '3671c6',
dnf: false,
dns: false,
dsq: false,
duration: 7200,
gap_to_leader: null,
number_of_laps: 78,
points: 25,
session_key: 9472,
meeting_key: 1229,
},
{
driver_number: 44,
position: 2,
name_acronym: 'HAM',
full_name: 'Lewis Hamilton',
team_name: 'Ferrari',
team_colour: 'e8002d',
dnf: false,
dns: false,
dsq: false,
duration: 7205,
gap_to_leader: 3.2,
number_of_laps: 78,
points: 18,
session_key: 9472,
meeting_key: 1229,
},
]
function renderHero(overrides: Partial<ComponentProps<typeof CommandCenterHero>> = {}) {
const props: ComponentProps<typeof CommandCenterHero> = {
state: 'upcoming',
now: new Date('2025-05-24T12:00:00Z'),
accent: '#d61a3e',
liveActive: false,
liveData: null,
focusMeeting,
focusKind: 'current',
sessions: weekendSessions,
currentSession: null,
nextSession: weekendSessions[0].session,
lastRaceName: '',
lastRacePodium: [],
nextMeeting: null,
...overrides,
}
return render(<CommandCenterHero {...props} />)
}
describe('CommandCenterHero', () => {
it('renders live timing link and top three in live state', () => {
renderHero({
state: 'live',
liveActive: true,
liveData,
currentSession: weekendSessions[0].session,
})
expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live')
expect(screen.getByTestId('hero-live-timing')).toHaveTextContent('VER')
expect(screen.getByTestId('hero-live-timing')).toHaveTextContent('HAM')
expect(screen.getByText('TRACK CLEAR')).toBeInTheDocument()
})
it('renders countdown and schedule strip in upcoming state', () => {
renderHero({
state: 'upcoming',
nextSession: weekendSessions[0].session,
})
expect(screen.getByTestId('hero-countdown')).toHaveTextContent('1d 01h 00m 00s')
expect(screen.getByTestId('hero-schedule-strip')).toHaveTextContent('Race')
expect(screen.getByTestId('hero-schedule-strip')).toHaveTextContent('Next')
})
it('renders last race podium and next GP countdown in between state', () => {
const nextMeeting: Meeting = {
...focusMeeting,
meeting_key: 1301,
meeting_name: 'Canada',
country_code: 'CAN',
date_start: '2025-06-06T00:00:00+00:00',
date_end: '2025-06-08T00:00:00+00:00',
}
renderHero({
state: 'between',
lastRaceName: 'Monaco',
lastRacePodium: podium,
nextMeeting,
now: new Date('2025-06-01T12:00:00Z'),
})
expect(screen.getByTestId('hero-last-race')).toHaveTextContent('After Monaco')
expect(screen.getByTestId('hero-podium')).toHaveTextContent('VER')
expect(screen.getByTestId('hero-podium')).toHaveTextContent('HAM')
expect(screen.getByTestId('hero-next-gp-countdown')).toHaveTextContent('Canada')
expect(screen.getByTestId('hero-next-gp-countdown')).toHaveTextContent('4d')
})
})

View File

@@ -100,7 +100,8 @@ const snapshot: LiveStreamData = {
{ Time: '14:08', Category: 'Drs', Flag: '', Message: 'DRS ENABLED', Lap: 3 },
],
Weather: { AirTemp: 20, TrackTemp: 31, Humidity: 55, WindSpeed: 2, WindDir: 180, Rainfall: false },
Session: { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race' },
Session: { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race', Path: '2026/Monaco/Race' },
TeamRadio: [],
TrackStatus: '1',
CurrentLap: 21,
TotalLaps: 78,
@@ -162,6 +163,20 @@ describe('live transforms', () => {
expect(parsed?.data?.Drivers['16'].RacingNumber).toBe('16')
})
it('parses archived EventSource snapshots separately from active data', () => {
const parsed = parseLiveStateEvent(JSON.stringify({
is_live: false,
data: null,
last_snapshot: snapshot,
last_positions: { '16': { x: 1, y: 2, z: 3, status: 'OnTrack' } },
last_snapshot_at: '2026-07-04T14:00:00Z',
}))
expect(parsed?.is_live).toBe(false)
expect(parsed?.data).toBeNull()
expect(parsed?.last_snapshot?.Drivers['16'].RacingNumber).toBe('16')
expect(parsed?.last_positions?.['16'].status).toBe('OnTrack')
})
it('sorts timing rows by live position and includes drivers with metadata only', () => {
const rows = sortLiveTimingRows(snapshot)
expect(rows.map((row) => row.RacingNumber)).toEqual(['16', '1', '44'])
@@ -220,7 +235,7 @@ describe('track status mapping', () => {
describe('live qualifying display', () => {
it('puts the SQ1 cutoff after P17 for a 22-car sprint qualifying session', () => {
const display = liveSessionDisplay(
{ MeetingName: 'British Grand Prix', CircuitName: 'Silverstone', SessionType: 'Sprint Qualifying', SessionName: 'Sprint Qualifying' },
{ MeetingName: 'British Grand Prix', CircuitName: 'Silverstone', SessionType: 'Sprint Qualifying', SessionName: 'Sprint Qualifying', Path: '' },
rows(22),
)
expect(display.phaseLabel).toBe('SQ1')
@@ -232,7 +247,7 @@ describe('live qualifying display', () => {
it('keeps the normal Q1 cutoff after P15 for a 20-car qualifying session', () => {
const display = liveSessionDisplay(
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying' },
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying', Path: '' },
rows(20),
)
expect(display.phaseLabel).toBe('Q1')
@@ -241,7 +256,7 @@ describe('live qualifying display', () => {
it('moves phase 2 cutoff after P10 once five cars are knocked out', () => {
const display = liveSessionDisplay(
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying' },
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying', Path: '' },
rows(20, 5),
)
expect(display.phaseLabel).toBe('Q2')
@@ -251,13 +266,13 @@ describe('live qualifying display', () => {
it('shows no cutoff for race sessions or Q3', () => {
expect(
liveSessionDisplay(
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race' },
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race', Path: '' },
rows(20),
).cutoffPosition,
).toBeNull()
expect(
liveSessionDisplay(
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Q3' },
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Q3', Path: '' },
rows(10),
).cutoffPosition,
).toBeNull()

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import {
INTERVAL_DRS_MAX_SECONDS,
INTERVAL_UNDERCUT_MAX_SECONDS,
INTERVAL_UNDERCUT_MIN_SECONDS,
MAX_POINTS_PER_ROUND,
TYRE_CLIFF_LAPS,
intervalMeaning,
pointsGapMeaning,
tyreAgeMeaning,
} from '../lib/meaning'
describe('intervalMeaning', () => {
it('returns DRS range below the threshold', () => {
expect(intervalMeaning(0.4)?.caption).toBe('DRS range')
expect(intervalMeaning(INTERVAL_DRS_MAX_SECONDS - 0.01)?.caption).toBe('DRS range')
})
it('returns undercut window in the middle band', () => {
expect(intervalMeaning(INTERVAL_UNDERCUT_MIN_SECONDS)?.caption).toBe('undercut window')
expect(intervalMeaning(2.0)?.caption).toBe('undercut window')
expect(intervalMeaning(INTERVAL_UNDERCUT_MAX_SECONDS)?.caption).toBe('undercut window')
})
it('returns null outside known bands', () => {
expect(intervalMeaning(INTERVAL_UNDERCUT_MAX_SECONDS + 0.5)).toBeNull()
expect(intervalMeaning(10)).toBeNull()
expect(intervalMeaning(null)).toBeNull()
expect(intervalMeaning(undefined)).toBeNull()
expect(intervalMeaning(-1)).toBeNull()
})
})
describe('tyreAgeMeaning', () => {
it('labels fresh, mid-life, and laps-to-cliff for SOFT', () => {
const cliff = TYRE_CLIFF_LAPS.SOFT
expect(tyreAgeMeaning('SOFT', 2)?.caption).toBe('fresh')
expect(tyreAgeMeaning('SOFT', Math.ceil(cliff * 0.5))?.caption).toBe('mid-life')
expect(tyreAgeMeaning('SOFT', cliff - 2)?.caption).toBe('~2 laps to cliff')
expect(tyreAgeMeaning('SOFT', cliff + 5)?.caption).toBe('past cliff')
})
it('handles unknown compounds with defaults', () => {
expect(tyreAgeMeaning('UNKNOWN', 3)?.caption).toBe('fresh')
expect(tyreAgeMeaning(undefined, 3)?.caption).toBe('fresh')
})
it('returns null for invalid age', () => {
expect(tyreAgeMeaning('MEDIUM', null)).toBeNull()
expect(tyreAgeMeaning('MEDIUM', -1)).toBeNull()
})
})
describe('pointsGapMeaning', () => {
it('computes catchable pts/round', () => {
const result = pointsGapMeaning(40, 4, 'VER')
expect(result?.caption).toBe('~10 pts/round')
expect(result?.title).toContain('VER')
})
it('marks uncatchable gaps', () => {
const max = 3 * MAX_POINTS_PER_ROUND
expect(pointsGapMeaning(max + 1, 3, 'VER')?.caption).toBe('out of reach')
})
it('returns null for leader or invalid input', () => {
expect(pointsGapMeaning(0, 4)).toBeNull()
expect(pointsGapMeaning(10, 0)).toBeNull()
expect(pointsGapMeaning(null, 4)).toBeNull()
})
})

View File

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

View File

@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import {
TelemetryTraceChart,
type TelemetryTraceSeries,
} from '../components/charts/TelemetryTraceChart'
function makeSeries(label: string, color: string, speeds: number[]): TelemetryTraceSeries {
return {
label,
color,
samples: speeds.map((speed, i) => ({
speed,
throttle: (i * 25) % 101,
brake: i % 2 === 0 ? 100 : 0,
})),
}
}
const VER = makeSeries('VER', '#2563eb', [280, 310, 190, 90, 240])
const LEC = makeSeries('LEC', '#dc2626', [275, 305, 200, 95, 235])
describe('TelemetryTraceChart', () => {
it('renders one polyline per series per channel', () => {
const { container } = render(<TelemetryTraceChart series={[VER, LEC]} />)
const lines = container.querySelectorAll('polyline.telemetry-trace-line')
expect(lines).toHaveLength(2 * 3)
for (const channel of ['speed', 'throttle', 'brake']) {
expect(
container.querySelectorAll(`polyline.telemetry-trace-line[data-channel="${channel}"]`),
).toHaveLength(2)
}
expect(
container.querySelectorAll('polyline.telemetry-trace-line[data-series="VER"]'),
).toHaveLength(3)
})
it('respects the channels prop', () => {
const { container } = render(<TelemetryTraceChart series={[VER]} channels={['speed']} />)
expect(container.querySelectorAll('polyline.telemetry-trace-line')).toHaveLength(1)
expect(container.querySelectorAll('.telemetry-trace-panel')).toHaveLength(1)
})
it('renders an empty state for no series', () => {
render(<TelemetryTraceChart series={[]} />)
expect(screen.getByText('No telemetry data')).toBeInTheDocument()
})
it('renders an empty state when a series has no samples', () => {
render(<TelemetryTraceChart series={[makeSeries('VER', '#2563eb', [])]} />)
expect(screen.getByText('No telemetry data')).toBeInTheDocument()
})
it('survives a single sample and mismatched series lengths', () => {
const short = makeSeries('VER', '#2563eb', [300])
const long = makeSeries('LEC', '#dc2626', [280, 290, 300])
const { container } = render(<TelemetryTraceChart series={[short, long]} />)
// Clamped to the shortest series: every polyline has exactly one point.
const lines = container.querySelectorAll('polyline.telemetry-trace-line')
expect(lines).toHaveLength(6)
for (const line of lines) {
expect(line.getAttribute('points')!.trim().split(' ')).toHaveLength(1)
}
})
it('clamps throttle values to 0-100 within the panel', () => {
const wild: TelemetryTraceSeries = {
label: 'VER',
color: '#2563eb',
samples: [
{ speed: 100, throttle: 150, brake: 0 },
{ speed: 100, throttle: -20, brake: 0 },
{ speed: 100, throttle: 50, brake: 0 },
],
}
const { container } = render(
<TelemetryTraceChart series={[wild]} channels={['throttle']} height={110} />,
)
const line = container.querySelector('polyline.telemetry-trace-line[data-channel="throttle"]')!
const ys = line
.getAttribute('points')!
.split(' ')
.map((p) => Number(p.split(',')[1]))
// Panel occupies y 0..110 with 6px padding; clamped values pin to the edges.
expect(ys[0]).toBeCloseTo(6, 1) // 150% -> 100% -> panel top
expect(ys[1]).toBeCloseTo(104, 1) // -20% -> 0% -> panel bottom
expect(ys[2]).toBeCloseTo(55, 1) // 50% -> middle
})
it('shows the speed axis max in km/h', () => {
render(<TelemetryTraceChart series={[VER]} channels={['speed']} />)
expect(screen.getByText('310 km/h')).toBeInTheDocument()
})
it('lists every series in the legend with a color swatch', () => {
const { container } = render(<TelemetryTraceChart series={[VER, LEC]} />)
const legend = container.querySelector('.telemetry-trace-legend')!
expect(legend.textContent).toContain('VER')
expect(legend.textContent).toContain('LEC')
expect(legend.querySelectorAll('.telemetry-trace-swatch')).toHaveLength(2)
})
it('shows a crosshair and per-driver readout on hover, hides on leave', () => {
const { container } = render(<TelemetryTraceChart series={[VER, LEC]} />)
const svg = container.querySelector('svg.telemetry-trace-svg')!
expect(screen.queryByTestId('telemetry-trace-crosshair')).not.toBeInTheDocument()
fireEvent.mouseMove(svg, { clientX: 0, clientY: 10 })
expect(screen.getByTestId('telemetry-trace-crosshair')).toBeInTheDocument()
const readout = screen.getByTestId('telemetry-trace-readout')
expect(readout.textContent).toContain('VER')
expect(readout.textContent).toContain('LEC')
expect(readout.textContent).toContain('km/h')
fireEvent.mouseLeave(svg)
expect(screen.queryByTestId('telemetry-trace-crosshair')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,204 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { TyreStintTimeline, type StintTimelineRow } from '../components/charts/TyreStintTimeline'
import { StrategyView } from '../components/StrategyView'
import type { EnrichedResult, Stint, PitStop } from '../types'
const sampleRows: StintTimelineRow[] = [
{
label: 'VER',
color: '#3671C6',
stints: [
{ compound: 'MEDIUM', lapStart: 1, lapEnd: 30 },
{ compound: 'SOFT', lapStart: 31, lapEnd: 78 },
],
},
{
label: 'HAM',
color: '#E8002D',
stints: [{ compound: 'SOFT', lapStart: 1, lapEnd: 18 }],
},
]
describe('TyreStintTimeline', () => {
it('renders one rect per stint', () => {
const { container } = render(
<TyreStintTimeline rows={sampleRows} totalLaps={78} />,
)
expect(container.querySelectorAll('.stint-timeline__bar')).toHaveLength(3)
})
it('maps compounds to color classes', () => {
const { container } = render(
<TyreStintTimeline rows={sampleRows} totalLaps={78} />,
)
const bars = container.querySelectorAll('.stint-timeline__bar')
expect(bars[0]).toHaveClass('tyre-medium')
expect(bars[1]).toHaveClass('tyre-soft')
expect(bars[2]).toHaveClass('tyre-soft')
})
it('shows native title with compound, lap range, and stint length', () => {
const { container } = render(
<TyreStintTimeline rows={sampleRows} totalLaps={78} />,
)
const titles = [...container.querySelectorAll('title')].map((t) => t.textContent)
expect(titles).toContain('Medium · L130 · 30 laps')
expect(titles).toContain('Soft · L3178 · 48 laps')
expect(titles).toContain('Soft · L118 · 18 laps')
})
it('renders lap-axis ticks every 10 laps', () => {
render(<TyreStintTimeline rows={sampleRows} totalLaps={78} />)
expect(screen.getByText('0')).toBeInTheDocument()
expect(screen.getByText('10')).toBeInTheDocument()
expect(screen.getByText('20')).toBeInTheDocument()
expect(screen.getByText('70')).toBeInTheDocument()
})
it('shows legend only for used compounds', () => {
render(<TyreStintTimeline rows={sampleRows} totalLaps={78} />)
expect(screen.getByTestId('legend-soft')).toBeInTheDocument()
expect(screen.getByTestId('legend-medium')).toBeInTheDocument()
expect(screen.queryByTestId('legend-hard')).not.toBeInTheDocument()
expect(screen.queryByTestId('legend-wet')).not.toBeInTheDocument()
expect(screen.getByText('Soft')).toBeInTheDocument()
expect(screen.getByText('Medium')).toBeInTheDocument()
})
it('renders empty state when rows are empty', () => {
render(<TyreStintTimeline rows={[]} totalLaps={50} />)
expect(screen.getByTestId('stint-timeline-empty')).toBeInTheDocument()
expect(screen.getByText(/No stint data/i)).toBeInTheDocument()
})
it('renders one pit marker per stop at the correct lap position', () => {
const rowsWithPits: StintTimelineRow[] = [
{
label: 'HAM',
color: '#E8002D',
stints: [{ compound: 'SOFT', lapStart: 1, lapEnd: 18 }],
pitStops: [19],
},
{
label: 'VER',
color: '#3671C6',
stints: [
{ compound: 'MEDIUM', lapStart: 1, lapEnd: 30 },
{ compound: 'SOFT', lapStart: 31, lapEnd: 78 },
],
pitStops: [31, 52],
},
]
const { container } = render(
<TyreStintTimeline rows={rowsWithPits} totalLaps={78} />,
)
const markers = container.querySelectorAll('[data-testid="pit-marker"]')
expect(markers).toHaveLength(3)
expect(markers[0]).toHaveAttribute('data-lap', '19')
expect(markers[1]).toHaveAttribute('data-lap', '31')
expect(markers[2]).toHaveAttribute('data-lap', '52')
expect(container.querySelectorAll('.stint-timeline__bar')).toHaveLength(3)
})
it('positions pit markers using lap_number and includes driver in tooltip', () => {
const rows: StintTimelineRow[] = [
{
label: 'HAM',
color: '#E8002D',
stints: [{ compound: 'SOFT', lapStart: 1, lapEnd: 18 }],
pitStops: [19],
},
]
const { container } = render(<TyreStintTimeline rows={rows} totalLaps={78} />)
const marker = container.querySelector('[data-testid="pit-marker"]') as SVGLineElement
expect(marker).toBeTruthy()
// lap 19 → x = 48 + (18/78) * 580 ≈ 181.85
expect(Number(marker.getAttribute('x1'))).toBeCloseTo(181.85, 1)
const titles = [...container.querySelectorAll('title')].map((t) => t.textContent)
expect(titles).toContain('HAM pit stop · L19')
})
it('leaves rows without pit data unchanged', () => {
const { container } = render(
<TyreStintTimeline rows={sampleRows} totalLaps={78} />,
)
expect(container.querySelectorAll('[data-testid="pit-marker"]')).toHaveLength(0)
expect(container.querySelectorAll('.stint-timeline__bar')).toHaveLength(3)
})
})
const results: EnrichedResult[] = [
{
driver_number: 1,
position: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull Racing',
team_colour: '3671C6',
dnf: false,
dns: false,
dsq: false,
duration: null,
gap_to_leader: null,
number_of_laps: 78,
points: 25,
session_key: 9472,
meeting_key: 1229,
},
{
driver_number: 44,
position: 2,
name_acronym: 'HAM',
full_name: 'Lewis Hamilton',
team_name: 'Ferrari',
team_colour: 'E8002D',
dnf: false,
dns: false,
dsq: false,
duration: null,
gap_to_leader: 5.1,
number_of_laps: 78,
points: 18,
session_key: 9472,
meeting_key: 1229,
},
]
const stints: Stint[] = [
{
session_key: 9472,
driver_number: 1,
meeting_key: 1229,
stint_number: 1,
compound: 'MEDIUM',
lap_start: 1,
lap_end: 30,
tyre_age_at_start: 0,
},
{
session_key: 9472,
driver_number: 44,
meeting_key: 1229,
stint_number: 1,
compound: 'SOFT',
lap_start: 1,
lap_end: 18,
tyre_age_at_start: 0,
},
]
const pitStops: PitStop[] = []
describe('StrategyView integration', () => {
it('renders timeline when stints exist', () => {
const { container } = render(
<StrategyView results={results} stints={stints} pit_stops={pitStops} hasStints={true} />,
)
expect(container.querySelector('[data-testid="strategy-chart"]')).toBeInTheDocument()
expect(screen.getByTestId('stint-timeline')).toBeInTheDocument()
expect(screen.getByText('VER')).toBeInTheDocument()
expect(screen.getByText('HAM')).toBeInTheDocument()
expect(container.querySelectorAll('.stint-timeline__bar')).toHaveLength(2)
})
})

View File

@@ -0,0 +1,281 @@
import { describe, expect, it } from 'vitest'
import {
DEG_SLOPE_THRESHOLD,
MIN_CLEAN_LAPS,
PIT_LOSS_SECONDS,
cleanStintSamples,
degradationModel,
estimatePitRejoin,
formatSlope,
parseLapTimeSeconds,
recordStintSamples,
stintInputFromRow,
} from '../lib/tyredeg'
import type { StintHistoryMap, StintLapInput, StintSample } from '../lib/tyredeg'
import type { LiveTimingRow } from '../lib/live'
import type { LiveDriverData } from '../types'
function makeInput(overrides: Partial<StintLapInput> = {}): StintLapInput {
return {
racingNumber: '1',
lapNumber: 1,
lastLapTime: '1:30.000',
compound: 'MEDIUM',
tyreAge: 1,
inPit: false,
pitOut: false,
...overrides,
}
}
/** Feed a sequence of snapshots for one driver through the accumulator. */
function accumulate(snapshots: Array<Partial<StintLapInput>>): StintHistoryMap {
let history: StintHistoryMap = {}
for (const snapshot of snapshots) {
history = recordStintSamples(history, [makeInput(snapshot)])
}
return history
}
function makeRow(
number: string,
position: number,
tla: string,
driver: Partial<LiveDriverData> = {},
): LiveTimingRow {
return {
RacingNumber: number,
Position: position,
Driver: {
RacingNumber: number,
Position: position,
Interval: '',
GapToLeader: '',
LastLapTime: '1:30.000',
NumberOfLaps: 10,
InPit: false,
PitOut: false,
Retired: false,
...driver,
} as LiveDriverData,
Info: {
RacingNumber: number,
BroadcastName: '',
Tla: tla,
TeamName: '',
TeamColour: '3671c6',
FirstName: '',
LastName: '',
},
}
}
describe('parseLapTimeSeconds', () => {
it('parses minute-form and plain-second lap times', () => {
expect(parseLapTimeSeconds('1:23.456')).toBeCloseTo(83.456)
expect(parseLapTimeSeconds('83.456')).toBeCloseTo(83.456)
})
it('rejects empty, garbage, and non-positive values', () => {
expect(parseLapTimeSeconds('')).toBeNull()
expect(parseLapTimeSeconds(undefined)).toBeNull()
expect(parseLapTimeSeconds(null)).toBeNull()
expect(parseLapTimeSeconds('LAP 12')).toBeNull()
expect(parseLapTimeSeconds('-1:30.000')).toBeNull()
})
})
describe('recordStintSamples', () => {
it('accumulates one sample per completed lap across snapshots', () => {
const history = accumulate([
{ lapNumber: 1, lastLapTime: '1:30.000' },
{ lapNumber: 2, lastLapTime: '1:30.100' },
{ lapNumber: 2, lastLapTime: '1:30.100' }, // repeated snapshot, same lap
{ lapNumber: 3, lastLapTime: '1:30.200' },
])
expect(history['1'].samples).toEqual([
{ lap: 2, seconds: 90.1 },
{ lap: 3, seconds: 90.2 },
])
})
it('does not record a lap from the very first snapshot seen', () => {
const history = accumulate([{ lapNumber: 5, lastLapTime: '1:29.000' }])
expect(history['1'].samples).toEqual([])
})
it('replaces (not duplicates) a sample when the lap time is corrected', () => {
const history = accumulate([
{ lapNumber: 1, lastLapTime: '1:30.000' },
{ lapNumber: 2, lastLapTime: '1:30.500' },
{ lapNumber: 2, lastLapTime: '1:30.400' }, // late correction for lap 2
])
expect(history['1'].samples).toEqual([{ lap: 2, seconds: 90.4 }])
})
it('resets the stint on compound change and skips the next lap', () => {
const history = accumulate([
{ lapNumber: 1, lastLapTime: '1:30.000', compound: 'SOFT', tyreAge: 1 },
{ lapNumber: 2, lastLapTime: '1:30.100', compound: 'SOFT', tyreAge: 2 },
{ lapNumber: 3, lastLapTime: '1:30.200', compound: 'SOFT', tyreAge: 3 },
// Boxed for hards: old samples dropped, out-lap (lap 4) excluded.
{ lapNumber: 4, lastLapTime: '1:52.000', compound: 'HARD', tyreAge: 0 },
{ lapNumber: 5, lastLapTime: '1:31.000', compound: 'HARD', tyreAge: 1 },
{ lapNumber: 6, lastLapTime: '1:31.100', compound: 'HARD', tyreAge: 2 },
])
expect(history['1'].samples).toEqual([
{ lap: 5, seconds: 91.0 },
{ lap: 6, seconds: 91.1 },
])
})
it('resets on a fresh set of the same compound (tyre age drops)', () => {
const history = accumulate([
{ lapNumber: 1, lastLapTime: '1:30.000', tyreAge: 10 },
{ lapNumber: 2, lastLapTime: '1:30.100', tyreAge: 11 },
{ lapNumber: 3, lastLapTime: '1:50.000', tyreAge: 0 }, // new mediums
{ lapNumber: 4, lastLapTime: '1:30.500', tyreAge: 1 },
])
expect(history['1'].samples).toEqual([{ lap: 4, seconds: 90.5 }])
})
it('excludes laps completed in the pit lane or on pit exit', () => {
const history = accumulate([
{ lapNumber: 1, lastLapTime: '1:30.000' },
{ lapNumber: 2, lastLapTime: '1:30.100' },
{ lapNumber: 3, lastLapTime: '1:48.000', inPit: true }, // in-lap
{ lapNumber: 4, lastLapTime: '1:45.000', pitOut: true, tyreAge: 0 }, // out-lap
{ lapNumber: 5, lastLapTime: '1:30.300', tyreAge: 1 },
])
expect(history['1'].samples).toEqual([{ lap: 5, seconds: 90.3 }])
})
it('prunes drivers missing from the snapshot and does not mutate input', () => {
const initial = accumulate([
{ lapNumber: 1, lastLapTime: '1:30.000' },
{ lapNumber: 2, lastLapTime: '1:30.100' },
])
const next = recordStintSamples(initial, [makeInput({ racingNumber: '44' })])
expect(next['1']).toBeUndefined()
expect(next['44']).toBeDefined()
expect(initial['1'].samples).toHaveLength(1)
})
})
describe('degradationModel', () => {
const series = (times: number[], startLap = 1): StintSample[] =>
times.map((seconds, index) => ({ lap: startLap + index, seconds }))
it('fits the slope of a known linear series', () => {
const model = degradationModel(series([90.0, 90.1, 90.2, 90.3, 90.4]))
expect(model).not.toBeNull()
expect(model!.slope).toBeCloseTo(0.1, 5)
expect(model!.trend).toBe('degrading')
})
it('classifies improving and stable stints', () => {
expect(degradationModel(series([91.0, 90.8, 90.6, 90.4]))!.trend).toBe('improving')
expect(degradationModel(series([90.0, 90.01, 90.0, 90.02, 90.01]))!.trend).toBe('stable')
// A slope clearly under the threshold stays stable.
expect(DEG_SLOPE_THRESHOLD).toBeGreaterThan(0.03)
expect(degradationModel(series([90.0, 90.03, 90.06, 90.09, 90.12]))!.trend).toBe('stable')
})
it('excludes outliers more than ~5s off the stint median', () => {
// Clean laps follow +0.1s/lap; the 97.5 (traffic/spin) must not skew the fit.
const samples = series([90.0, 90.1, 97.5, 90.3, 90.4, 90.5])
expect(cleanStintSamples(samples)).toHaveLength(5)
const model = degradationModel(samples)
expect(model!.samples).toHaveLength(5)
expect(model!.slope).toBeCloseTo(0.1, 5)
})
it('returns null with fewer than MIN_CLEAN_LAPS clean laps', () => {
expect(degradationModel(series([90.0, 90.1, 90.2]))).toBeNull()
// 4 raw laps but one outlier -> only 3 clean -> still warming up
expect(degradationModel(series([90.0, 90.1, 90.2, 99.0]))).toBeNull()
expect(MIN_CLEAN_LAPS).toBe(4)
})
})
describe('formatSlope', () => {
it('renders signed seconds-per-lap', () => {
expect(formatSlope(0.083)).toBe('+0.08s/lap')
expect(formatSlope(-0.125)).toBe('0.13s/lap')
})
})
describe('estimatePitRejoin', () => {
const ladder = [
makeRow('1', 1, 'VER'),
makeRow('4', 2, 'NOR', { Interval: '+5.0' }),
makeRow('44', 3, 'HAM', { Interval: '+10.0' }),
makeRow('14', 4, 'ALO', { Interval: '+15.0' }),
makeRow('16', 5, 'LEC', { Interval: '+20.0' }),
]
it('projects the rejoin slot between the right cars', () => {
// VER pits from P1: NOR (5s) and HAM (15s cumulative) get past;
// ALO would be 30s back -> stays behind. Rejoin ~P3.
const estimate = estimatePitRejoin(ladder, '1')
expect(estimate).toEqual({
rejoinPosition: 3,
positionsLost: 2,
aheadCode: 'HAM',
behindCode: 'ALO',
})
})
it('keeps position when the cars behind are further than the pit loss', () => {
const estimate = estimatePitRejoin(ladder, '14')
// LEC is 20s behind ALO — inside the 22s window, so he gets past.
expect(estimate!.rejoinPosition).toBe(5)
expect(estimate!.aheadCode).toBe('LEC')
expect(estimate!.behindCode).toBeNull()
const wide = estimatePitRejoin(
[makeRow('1', 1, 'VER'), makeRow('4', 2, 'NOR', { Interval: `+${PIT_LOSS_SECONDS + 3}.0` })],
'1',
)
expect(wide!.rejoinPosition).toBe(1)
expect(wide!.positionsLost).toBe(0)
expect(wide!.behindCode).toBe('NOR')
})
it('stops the ladder walk at lapped cars and skips pitted/retired ones', () => {
const estimate = estimatePitRejoin(
[
makeRow('1', 1, 'VER'),
makeRow('4', 2, 'NOR', { Interval: '+5.0', InPit: true }), // mid-stop, excluded
makeRow('44', 3, 'HAM', { Interval: '+10.0' }),
makeRow('16', 4, 'LEC', { Interval: '1L' }), // lapped — walk ends here
makeRow('14', 5, 'ALO', { Interval: '+2.0' }),
],
'1',
)
expect(estimate!.positionsLost).toBe(1) // only HAM gets past
expect(estimate!.rejoinPosition).toBe(2)
expect(estimate!.aheadCode).toBe('HAM')
expect(estimate!.behindCode).toBe('LEC')
})
it('returns null for unknown drivers', () => {
expect(estimatePitRejoin(ladder, '99')).toBeNull()
})
})
describe('stintInputFromRow', () => {
it('maps tower rows into accumulator inputs', () => {
const row = makeRow('4', 2, 'NOR', { NumberOfLaps: 12, LastLapTime: '1:29.900' })
row.Tyre = { Compound: 'HARD', New: false, Age: 7 }
expect(stintInputFromRow(row)).toEqual({
racingNumber: '4',
lapNumber: 12,
lastLapTime: '1:29.900',
compound: 'HARD',
tyreAge: 7,
inPit: false,
pitOut: false,
})
})
})

View File

@@ -12,6 +12,7 @@ export interface Meeting {
country_name: string
country_code: string
country_flag: string
circuit_key?: number
circuit_short_name: string
date_start: string
date_end: string
@@ -23,6 +24,7 @@ export interface Session {
session_key: number
session_name: string
session_type: string
circuit_key?: number
meeting_key: number
date_start: string
date_end: string
@@ -89,6 +91,17 @@ export interface RaceHub {
race_control: RaceControlMessage[]
weather: WeatherSample[]
laps: Lap[]
chapters: Chapter[]
}
export interface Chapter {
kind: 'start' | 'safety_car' | 'virtual_safety_car' | 'red_flag' | 'pit_phase' | 'decisive_swing' | 'finish' | string
title: string
start_lap: number
end_lap: number
start_time?: string
end_time?: string
driver_numbers: number[]
}
export interface Stint {
@@ -158,6 +171,37 @@ export interface Lap {
is_pit_out_lap: boolean
}
export interface CarDataSample {
brake: number
date: string
driver_number: number
drs: number
meeting_key: number
n_gear: number
rpm: number
session_key: number
speed: number
throttle: number
}
export interface ComparisonLap extends Lap {
compound?: string
}
export interface ComparisonDriver {
driver_number: number
name_acronym: string
team_colour: string
laps: ComparisonLap[]
}
export interface LapsComparisonResponse {
session_key: number
sc_periods: unknown[]
pit_laps: Record<string, number[]>
drivers: ComparisonDriver[]
}
export interface WeekendSession {
session: Session
source: 'local' | 'partial' | 'none' | 'cancelled'
@@ -175,6 +219,9 @@ export interface Weekend {
export interface LiveStateResponse {
is_live: boolean
data: LiveStreamData | null
last_snapshot?: LiveStreamData | null
last_positions?: Record<string, LivePosition> | null
last_snapshot_at?: string
}
export interface LivePosition {
@@ -261,6 +308,7 @@ export interface LiveSessionMeta {
CircuitName: string
SessionType: string
SessionName: string
Path: string
}
export interface LiveStintData {
@@ -269,6 +317,12 @@ export interface LiveStintData {
Laps: number
}
export interface LiveRadioCapture {
Utc: string
RacingNumber: string
Path: string
}
export interface LiveStreamData {
Drivers: Record<string, LiveDriverData>
DriverInfo: Record<string, LiveDriverInfo>
@@ -277,6 +331,8 @@ export interface LiveStreamData {
RCMessages: LiveRCMessage[]
Weather: LiveWeatherData
Session: LiveSessionMeta
TeamRadio: LiveRadioCapture[]
SessionStatus?: string
TrackStatus: string
CurrentLap: number
TotalLaps: number
@@ -340,6 +396,39 @@ export interface ChampionshipHub {
teams: ChampHubTeam[]
}
export interface DriverSummaryRound {
meeting_key: number
meeting_name: string
country_code: string
country_name: string
race_position: number
grid_position: number
quali_position?: number
points: number
dnf: boolean
dns: boolean
dsq: boolean
}
export interface DriverSummary {
season: number
driver_number: number
name_acronym: string
full_name: string
team_name: string
team_colour: string
headshot_url: string
points: number
position: number
wins: number
podiums: number
poles: number
form: number[]
cumulative: number[]
round_labels: string[]
rounds: DriverSummaryRound[]
}
export interface NewsItem {
source: string
title: string

View File

@@ -94,6 +94,12 @@ func cacheDBPath() string {
return filepath.Join(".cache", "box-box", "cache.db")
}
// DefaultCacheDBPath returns the HTTP cache database path used by the OpenF1
// client in both TUI and web modes.
func DefaultCacheDBPath() string {
return cacheDBPath()
}
// ttlForURL determines the appropriate TTL based on the URL pattern.
// Returns 0 (CacheTTLForever) for historical data that will never change.
func ttlForURL(url string) time.Duration {

View File

@@ -623,6 +623,19 @@ func (c *OpenF1Client) GetTeamRadio(sessionKey, driverNumber int) ([]models.Team
// to maximise the chance of finding data quickly.
var candidateDrivers = []int{1, 11, 44, 16, 55, 4, 14, 63, 81, 24}
// TrackOutlinePrefetchResult summarizes a season track-outline cache warming
// run. Counts are scoped to the unique non-zero circuit keys in the provided
// meeting list.
type TrackOutlinePrefetchResult struct {
Year int
UniqueCircuits int
CachedBefore int
CachedAfter int
Skipped int
Fetched int
Failed int
}
// PrefetchTrackOutlines fetches GPS location data for every circuit in the
// provided meeting list and stores it in the cache so the track map tab can
// render during live sessions when the free-tier API is locked.
@@ -632,28 +645,58 @@ var candidateDrivers = []int{1, 11, 44, 16, 55, 4, 14, 63, 81, 24}
// Errors per-circuit are silently ignored — this is a best-effort operation
// and must never block or crash the main UI.
func (c *OpenF1Client) PrefetchTrackOutlines(meetings []models.Meeting) {
year := time.Now().Year()
for _, m := range meetings {
if m.Year != 0 {
year = m.Year
break
}
}
_ = c.PrefetchTrackOutlinesForYear(year, meetings)
}
// PrefetchTrackOutlinesForYear fetches and caches track outlines for unique
// circuits in the provided meeting list, storing them under the explicit season
// year. Unlike PrefetchTrackOutlines, it returns accounting suitable for CLI
// cache-warming workflows.
func (c *OpenF1Client) PrefetchTrackOutlinesForYear(year int, meetings []models.Meeting) TrackOutlinePrefetchResult {
const maxWorkers = 3
year := time.Now().Year()
// Filter to meetings that need fetching.
var pending []models.Meeting
result := TrackOutlinePrefetchResult{Year: year}
uniqueByCircuit := make(map[int]models.Meeting)
var unique []models.Meeting
for _, m := range meetings {
if m.CircuitKey == 0 {
continue
}
if _, exists := uniqueByCircuit[m.CircuitKey]; exists {
continue
}
uniqueByCircuit[m.CircuitKey] = m
unique = append(unique, m)
}
result.UniqueCircuits = len(unique)
// Filter to meetings that need fetching.
var pending []models.Meeting
for _, m := range unique {
if _, ok := c.cache.GetTrackOutline(m.CircuitKey, year); ok {
result.CachedBefore++
result.Skipped++
continue // already cached for this season
}
pending = append(pending, m)
}
if len(pending) == 0 {
return
result.CachedAfter = result.CachedBefore
return result
}
sem := make(chan struct{}, maxWorkers)
var wg sync.WaitGroup
var mu sync.Mutex
for _, mtg := range pending {
mtg := mtg // capture
@@ -662,19 +705,34 @@ func (c *OpenF1Client) PrefetchTrackOutlines(meetings []models.Meeting) {
go func() {
defer wg.Done()
defer func() { <-sem }()
c.prefetchCircuit(mtg, year)
ok := c.prefetchCircuit(mtg, year)
mu.Lock()
if ok {
result.Fetched++
} else {
result.Failed++
}
mu.Unlock()
}()
}
wg.Wait()
for _, m := range unique {
if _, ok := c.cache.GetTrackOutline(m.CircuitKey, year); ok {
result.CachedAfter++
}
}
return result
}
// prefetchCircuit fetches the track outline for a single meeting and stores it.
// It prefers completed sessions (past date_end) so the data is full and stable.
func (c *OpenF1Client) prefetchCircuit(mtg models.Meeting, year int) {
func (c *OpenF1Client) prefetchCircuit(mtg models.Meeting, year int) bool {
sessions, err := c.GetSessionsForMeeting(int(mtg.MeetingKey))
if err != nil || len(sessions) == 0 {
return
return false
}
// Pick the best session: prefer a completed race, then any session with
@@ -696,7 +754,7 @@ func (c *OpenF1Client) prefetchCircuit(mtg models.Meeting, year int) {
}
}
if bestSession == nil {
return
return false
}
// Try candidate drivers in order until we find one with enough points.
@@ -706,7 +764,7 @@ func (c *OpenF1Client) prefetchCircuit(mtg models.Meeting, year int) {
continue
}
// Store under the circuit key for this year and stop.
_ = c.cache.SetTrackOutline(mtg.CircuitKey, year, locs)
return
return c.cache.SetTrackOutline(mtg.CircuitKey, year, locs) == nil
}
return false
}

View File

@@ -0,0 +1,109 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/AmanTahiliani/box-box/internal/models"
)
func newTrackOutlineTestClient(t *testing.T, srvURL string) *OpenF1Client {
t.Helper()
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
c := NewOpenF1Client(srvURL, 5*time.Second)
c.pacer = &requestPacer{}
t.Cleanup(func() { _ = c.Close() })
return c
}
func TestPrefetchTrackOutlinesForYearSkipsCachedAndWritesLocations(t *testing.T) {
var sessionsByMeeting = map[string][]models.Session{
"202": {
{
SessionKey: 9002,
SessionName: "Race",
MeetingKey: 202,
CircuitKey: 2,
DateEnd: "2026-01-01T12:00:00+00:00",
},
},
}
var sessionsRequested []string
var locationsRequested []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/sessions":
meetingKey := r.URL.Query().Get("meeting_key")
sessionsRequested = append(sessionsRequested, meetingKey)
_ = json.NewEncoder(w).Encode(sessionsByMeeting[meetingKey])
case "/v1/location":
sessionKey := r.URL.Query().Get("session_key")
driverNumber := r.URL.Query().Get("driver_number")
locationsRequested = append(locationsRequested, sessionKey+"/"+driverNumber)
_ = json.NewEncoder(w).Encode(testLocations(9002, 1, 51))
default:
t.Fatalf("unexpected request path %s", r.URL.Path)
}
}))
defer srv.Close()
client := newTrackOutlineTestClient(t, srv.URL)
if err := client.Cache().SetTrackOutline(1, 2026, testLocations(9001, 1, 51)); err != nil {
t.Fatalf("SetTrackOutline() error = %v", err)
}
result := client.PrefetchTrackOutlinesForYear(2026, []models.Meeting{
{MeetingKey: 101, Year: 2026, Circuit: models.Circuit{CircuitKey: 1}},
{MeetingKey: 202, Year: 2026, Circuit: models.Circuit{CircuitKey: 2}},
{MeetingKey: 303, Year: 2026, Circuit: models.Circuit{CircuitKey: 2}},
})
if result.UniqueCircuits != 2 {
t.Fatalf("UniqueCircuits = %d, want 2", result.UniqueCircuits)
}
if result.CachedBefore != 1 || result.Skipped != 1 || result.Fetched != 1 || result.Failed != 0 || result.CachedAfter != 2 {
t.Fatalf("unexpected result: %+v", result)
}
if got, want := len(sessionsRequested), 1; got != want {
t.Fatalf("sessions requested %d time(s), want %d: %v", got, want, sessionsRequested)
}
if sessionsRequested[0] != "202" {
t.Fatalf("requested meeting %s, want 202", sessionsRequested[0])
}
if got, want := len(locationsRequested), 1; got != want {
t.Fatalf("locations requested %d time(s), want %d: %v", got, want, locationsRequested)
}
if locationsRequested[0] != "9002/1" {
t.Fatalf("requested location %s, want 9002/1", locationsRequested[0])
}
locs, ok := client.Cache().GetTrackOutline(2, 2026)
if !ok {
t.Fatal("expected circuit 2 outline to be cached")
}
if len(locs) != 51 {
t.Fatalf("cached %d locations, want 51", len(locs))
}
}
func testLocations(sessionKey, driverNumber, count int) []models.Location {
locs := make([]models.Location, count)
for i := range locs {
locs[i] = models.Location{
Date: "2026-01-01T12:00:" + strconv.Itoa(i%60) + "+00:00",
DriverNumber: driverNumber,
MeetingKey: 202,
SessionKey: sessionKey,
X: float64(i),
Y: float64(i * 2),
}
}
return locs
}

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import (
"compress/flate"
"encoding/base64"
"encoding/json"
"fmt"
"testing"
"time"
@@ -211,12 +212,16 @@ func TestProcessTopicSessionInfoCircuitName(t *testing.T) {
state.ProcessTopic("SessionInfo", json.RawMessage(`{
"Meeting": {"Name": "British Grand Prix", "Circuit": {"ShortName": "Silverstone"}},
"Name": "Race",
"Type": "Race"
"Type": "Race",
"Path": "2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/"
}`))
s := state.Snapshot().Session
if s.MeetingName != "British Grand Prix" || s.CircuitName != "Silverstone" {
t.Fatalf("session = %+v", s)
}
if s.Path != "2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/" {
t.Fatalf("session path = %q", s.Path)
}
}
func TestProcessTopicDriverList(t *testing.T) {
@@ -268,6 +273,40 @@ func TestProcessTopicTrackStatus(t *testing.T) {
}
}
func TestProcessTopicSessionStatus(t *testing.T) {
state := live.NewState()
if !state.ProcessTopic("SessionStatus", json.RawMessage(`{"Status": "Finished"}`)) {
t.Fatal("SessionStatus should update state")
}
snap := state.Snapshot()
if snap.SessionStatus != "Finished" {
t.Fatalf("session status = %q, want Finished", snap.SessionStatus)
}
if !snap.SnapshotUpdated {
t.Fatal("SessionStatus should mark snapshot updated")
}
}
func TestSessionStatusIsActive(t *testing.T) {
tests := []struct {
status string
want bool
}{
{"Started", true},
{"Resumed", true},
{"Finished", false},
{"Finalised", false},
{"Ends", false},
{"Aborted", false},
{"", false},
}
for _, tt := range tests {
if got := live.SessionStatusIsActive(tt.status); got != tt.want {
t.Errorf("SessionStatusIsActive(%q) = %v, want %v", tt.status, got, tt.want)
}
}
}
func TestProcessTopicRaceControlMessages(t *testing.T) {
state := live.NewState()
data := json.RawMessage(`{
@@ -285,6 +324,68 @@ func TestProcessTopicRaceControlMessages(t *testing.T) {
}
}
func TestProcessTopicTeamRadioSnapshotAndPatch(t *testing.T) {
state := live.NewState()
snapshot := json.RawMessage(`{
"Captures": [
{"Utc": "2026-07-05T14:05:30Z", "RacingNumber": "4", "Path": "TeamRadio/NOR-1.mp3"},
{"Utc": "2026-07-05T14:04:10Z", "RacingNumber": "16", "Path": "TeamRadio/LEC-1.mp3"}
]
}`)
if !state.ProcessTopic("TeamRadio", snapshot) {
t.Fatal("TeamRadio snapshot should update state")
}
patch := json.RawMessage(`{
"Captures": {
"2": {"Utc": "2026-07-05T14:06:00Z", "RacingNumber": "44", "Path": "TeamRadio/HAM-1.mp3"}
}
}`)
if !state.ProcessTopic("TeamRadio", patch) {
t.Fatal("TeamRadio keyed patch should update state")
}
radio := state.Snapshot().TeamRadio
if len(radio) != 3 {
t.Fatalf("radio captures = %d, want 3", len(radio))
}
if radio[0].RacingNumber != "16" || radio[1].RacingNumber != "4" || radio[2].RacingNumber != "44" {
t.Fatalf("radio order = %+v", radio)
}
}
func TestProcessTopicTeamRadioMalformed(t *testing.T) {
state := live.NewState()
if state.ProcessTopic("TeamRadio", json.RawMessage(`{"Captures": {"1": {"Utc": "2026-07-05T14:06:00Z", "Path": "missing-driver.mp3"}}}`)) {
t.Fatal("incomplete TeamRadio capture should not update state")
}
if state.ProcessTopic("TeamRadio", json.RawMessage(`{"Captures": "not-a-list"}`)) {
t.Fatal("unexpected TeamRadio captures shape should not update state")
}
if len(state.Snapshot().TeamRadio) != 0 {
t.Fatalf("malformed captures mutated state: %+v", state.Snapshot().TeamRadio)
}
}
func TestProcessTopicTeamRadioCapsAtTwenty(t *testing.T) {
state := live.NewState()
for i := 0; i < 25; i++ {
payload := json.RawMessage([]byte(fmt.Sprintf(`{
"Captures": [{"Utc": "2026-07-05T14:%02d:00Z", "RacingNumber": "%d", "Path": "TeamRadio/%02d.mp3"}]
}`, i, i, i)))
state.ProcessTopic("TeamRadio", payload)
}
radio := state.Snapshot().TeamRadio
if len(radio) != 20 {
t.Fatalf("radio captures = %d, want 20", len(radio))
}
if radio[0].Utc != "2026-07-05T14:05:00Z" || radio[19].Utc != "2026-07-05T14:24:00Z" {
t.Fatalf("radio cap kept wrong captures: first=%+v last=%+v", radio[0], radio[19])
}
}
func TestProcessTopicWeatherData(t *testing.T) {
state := live.NewState()
state.ProcessTopic("WeatherData", json.RawMessage(`{

View File

@@ -105,6 +105,7 @@ func connectToF1SignalRCore(dataChan chan LiveStreamData) error {
"RaceControlMessages",
"WeatherData",
"SessionInfo",
"TeamRadio",
"CurrentTyres",
"TimingAppData",
"TimingStats",
@@ -196,7 +197,7 @@ func connectToF1LegacySignalR(dataChan chan LiveStreamData) error {
return err
}
subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","Position.z","CarData.z","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","CurrentTyres","TimingAppData","TimingStats"]],"I":1}`)
subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","Position.z","CarData.z","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","TeamRadio","CurrentTyres","TimingAppData","TimingStats","SessionStatus"]],"I":1}`)
err = c.WriteMessage(websocket.TextMessage, subscribeMsg)
if err != nil {
return err

View File

@@ -8,6 +8,8 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"sort"
"strings"
"time"
)
@@ -23,6 +25,8 @@ type State struct {
RCMessages []LiveRCMessage
Weather LiveWeatherData
Session LiveSessionMeta
TeamRadio []LiveRadioCapture
SessionStatus string
TrackStatus string
CurrentLap int
TotalLaps int
@@ -34,6 +38,7 @@ type State struct {
}
const signalRRecordSeparator = byte(0x1e)
const maxTeamRadioCaptures = 20
// NewState returns an empty live timing accumulator.
func NewState() *State {
@@ -71,6 +76,8 @@ func (s *State) Snapshot() LiveStreamData {
}
cpyRC := make([]LiveRCMessage, len(s.RCMessages))
copy(cpyRC, s.RCMessages)
cpyRadio := make([]LiveRadioCapture, len(s.TeamRadio))
copy(cpyRadio, s.TeamRadio)
cpyStints := make(map[string][]LiveStintData, len(s.Stints))
for k, v := range s.Stints {
st := make([]LiveStintData, len(v))
@@ -86,6 +93,8 @@ func (s *State) Snapshot() LiveStreamData {
RCMessages: cpyRC,
Weather: s.Weather,
Session: s.Session,
TeamRadio: cpyRadio,
SessionStatus: s.SessionStatus,
TrackStatus: s.TrackStatus,
CurrentLap: s.CurrentLap,
TotalLaps: s.TotalLaps,
@@ -266,6 +275,14 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
s.TrackStatus = ts.Status
updated = true
}
case "SessionStatus":
var ss struct {
Status string `json:"Status"`
}
if json.Unmarshal(data, &ss) == nil && ss.Status != "" {
s.SessionStatus = ss.Status
updated = true
}
case "RaceControlMessages":
var rcm struct {
Messages json.RawMessage `json:"Messages"`
@@ -335,6 +352,7 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
} `json:"Meeting"`
Name string `json:"Name"`
Type string `json:"Type"`
Path string `json:"Path"`
}
if json.Unmarshal(data, &si) == nil {
if si.Meeting.Name != "" {
@@ -349,8 +367,13 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
if si.Type != "" {
s.Session.SessionType = si.Type
}
if si.Path != "" {
s.Session.Path = si.Path
}
updated = true
}
case "TeamRadio":
updated = s.updateTeamRadio(data)
case "CurrentTyres":
var ct map[string]json.RawMessage
if json.Unmarshal(data, &ct) == nil {
@@ -483,6 +506,62 @@ func readCompressed(r io.ReadCloser, err error) ([]byte, bool) {
return out, err == nil
}
func (s *State) updateTeamRadio(data json.RawMessage) bool {
var payload struct {
Captures json.RawMessage `json:"Captures"`
}
if err := json.Unmarshal(data, &payload); err != nil {
log.Printf("live: skipping malformed TeamRadio payload: %v", err)
return false
}
if len(payload.Captures) == 0 || string(payload.Captures) == "null" {
log.Printf("live: skipping TeamRadio payload without Captures")
return false
}
captures := indexedRawValues(payload.Captures)
if len(captures) == 0 {
log.Printf("live: skipping TeamRadio payload with unexpected Captures shape")
return false
}
updated := false
for _, captureRaw := range captures {
var capture LiveRadioCapture
if err := json.Unmarshal(captureRaw.Raw, &capture); err != nil {
log.Printf("live: skipping malformed TeamRadio capture: %v", err)
continue
}
if capture.Utc == "" || capture.RacingNumber == "" || capture.Path == "" {
log.Printf("live: skipping incomplete TeamRadio capture: utc=%q racing_number=%q path=%q", capture.Utc, capture.RacingNumber, capture.Path)
continue
}
if s.hasTeamRadioCapture(capture) {
continue
}
s.TeamRadio = append(s.TeamRadio, capture)
updated = true
}
if updated {
sort.SliceStable(s.TeamRadio, func(i, j int) bool {
return s.TeamRadio[i].Utc < s.TeamRadio[j].Utc
})
if len(s.TeamRadio) > maxTeamRadioCaptures {
s.TeamRadio = append([]LiveRadioCapture(nil), s.TeamRadio[len(s.TeamRadio)-maxTeamRadioCaptures:]...)
}
}
return updated
}
func (s *State) hasTeamRadioCapture(capture LiveRadioCapture) bool {
for _, existing := range s.TeamRadio {
if existing.Utc == capture.Utc && existing.RacingNumber == capture.RacingNumber && existing.Path == capture.Path {
return true
}
}
return false
}
func (s *State) updatePositions(data json.RawMessage) bool {
var payload struct {
Position json.RawMessage `json:"Position"`
@@ -799,6 +878,9 @@ func indexedRawValues(raw json.RawMessage) []indexedRaw {
fmt.Sscanf(k, "%d", &i)
values = append(values, indexedRaw{Index: i, Raw: v})
}
sort.Slice(values, func(i, j int) bool {
return values[i].Index < values[j].Index
})
return values
}

View File

@@ -85,6 +85,7 @@ type LiveSessionMeta struct {
CircuitName string
SessionType string
SessionName string
Path string
}
// LivePositionData is the latest raw F1 GPS position for one driver.
@@ -144,6 +145,13 @@ type LiveStintData struct {
Laps int
}
// LiveRadioCapture is one team radio audio clip from the live timing feed.
type LiveRadioCapture struct {
Utc string
RacingNumber string
Path string
}
// LiveStreamData is an immutable snapshot of all live timing state.
type LiveStreamData struct {
Drivers map[string]LiveDriverData
@@ -153,6 +161,8 @@ type LiveStreamData struct {
RCMessages []LiveRCMessage
Weather LiveWeatherData
Session LiveSessionMeta
TeamRadio []LiveRadioCapture
SessionStatus string
TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC
CurrentLap int
TotalLaps int
@@ -164,3 +174,27 @@ type LiveStreamData struct {
PositionUpdated bool `json:"-"`
SnapshotUpdated bool `json:"-"`
}
// SessionStatusIsActive reports whether a raw F1 live timing SessionStatus
// value represents an actively running session.
func SessionStatusIsActive(status string) bool {
switch normalizeSessionStatus(status) {
case "started", "resumed":
return true
default:
return false
}
}
func normalizeSessionStatus(status string) string {
out := make([]rune, 0, len(status))
for _, r := range status {
switch {
case r >= 'A' && r <= 'Z':
out = append(out, r+'a'-'A')
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
out = append(out, r)
}
}
return string(out)
}

View File

@@ -26,6 +26,15 @@ type ChampionshipInputs struct {
}
// GetChampionshipInputs derives championship hub inputs from ingested season data.
//
// 2026 note: Bahrain (meeting 1282 / session 11261) and Saudi Arabia (1283 / 11269)
// are cancelled due to Force Majeure (regional conflict) — OpenF1 returns no results
// for those sessions. This is intentional, which is why 2026 shows Round 9/24 after
// Silverstone instead of Round 11.
//
// Points: F1 Sprint points (SessionName == "Sprint") must be included alongside Race
// points. Both appear as SessionType "Race" in OpenF1 /api/v1/sessions, so we match
// on SessionName. Fix for #57.
func (s *Service) GetChampionshipInputs(year int) (ChampionshipInputs, error) {
meetings, err := s.ListMeetingsByYear(year)
if err != nil {
@@ -51,60 +60,101 @@ func (s *Service) GetChampionshipInputs(year int) (ChampionshipInputs, error) {
}
raceKey := 0
sprintKey := 0
for _, sess := range sessions {
if strings.EqualFold(sess.SessionName, "Race") {
raceKey = sess.SessionKey
break
} else if strings.EqualFold(sess.SessionName, "Sprint") {
sprintKey = sess.SessionKey
}
}
if raceKey == 0 {
if raceKey == 0 && sprintKey == 0 {
continue
}
results, err := s.store.ListSessionResults(raceKey)
if err != nil {
return ChampionshipInputs{}, err
}
grid, err := s.store.ListStartingGrid(raceKey)
if err != nil {
return ChampionshipInputs{}, err
}
// Always build Races slice from the Race session only (for UI),
// but include sprint points for standings.
var raceResults []models.SessionResult
var raceGrid []models.StartingGrid
race := ChampionshipRace{
Meeting: meeting,
RaceSessionKey: raceKey,
Results: make([]models.SessionResult, 0, len(results)),
Grid: make([]models.StartingGrid, 0, len(grid)),
}
for _, result := range results {
race.Results = append(race.Results, resultToModel(result))
}
for _, entry := range grid {
race.Grid = append(race.Grid, gridToModel(entry))
}
inputs.Races = append(inputs.Races, race)
if raceKey != 0 {
results, err := s.store.ListSessionResults(raceKey)
if err != nil {
return ChampionshipInputs{}, err
}
grid, err := s.store.ListStartingGrid(raceKey)
if err != nil {
return ChampionshipInputs{}, err
}
race := ChampionshipRace{
Meeting: meeting,
RaceSessionKey: raceKey,
Results: make([]models.SessionResult, 0, len(results)),
Grid: make([]models.StartingGrid, 0, len(grid)),
}
for _, result := range results {
race.Results = append(race.Results, resultToModel(result))
}
for _, entry := range grid {
race.Grid = append(race.Grid, gridToModel(entry))
}
inputs.Races = append(inputs.Races, race)
raceResults = race.Results
if len(results) == 0 {
continue
}
latestSessionKey = raceKey
latestMeetingKey = int(meeting.MeetingKey)
drivers, err := s.driversForSession(raceKey)
if err != nil {
return ChampionshipInputs{}, err
}
for _, d := range drivers {
inputs.DriverMap[d.DriverNumber] = d
}
for _, result := range results {
pointsByDriver[result.DriverNumber] += result.Points
team := inputs.DriverMap[result.DriverNumber].TeamName
if team != "" {
pointsByTeam[team] += result.Points
if len(results) > 0 {
latestSessionKey = raceKey
latestMeetingKey = int(meeting.MeetingKey)
drivers, err := s.driversForSession(raceKey)
if err != nil {
return ChampionshipInputs{}, err
}
for _, d := range drivers {
inputs.DriverMap[d.DriverNumber] = d
}
for _, result := range results {
pointsByDriver[result.DriverNumber] += result.Points
team := inputs.DriverMap[result.DriverNumber].TeamName
if team != "" {
pointsByTeam[team] += result.Points
}
}
}
}
// Include sprint points if present.
// Note: 2026 has 4 sprints up to British GP (Chinese GP 11240, Miami 11275,
// Canadian 11286, British 11321) totaling e.g. ANT 21 = 4+3+6+8, RUS 26.
if sprintKey != 0 {
sprintResults, err := s.store.ListSessionResults(sprintKey)
if err != nil {
return ChampionshipInputs{}, err
}
if len(sprintResults) > 0 {
// Ensure driver map includes sprint-only drivers if any.
sprintDrivers, err := s.driversForSession(sprintKey)
if err == nil {
for _, d := range sprintDrivers {
if _, ok := inputs.DriverMap[d.DriverNumber]; !ok {
inputs.DriverMap[d.DriverNumber] = d
}
}
}
for _, result := range sprintResults {
pointsByDriver[result.DriverNumber] += result.Points
team := inputs.DriverMap[result.DriverNumber].TeamName
if team != "" {
pointsByTeam[team] += result.Points
}
}
// If this meeting had no race results (edge), still track latest.
if len(raceResults) == 0 {
latestSessionKey = sprintKey
latestMeetingKey = int(meeting.MeetingKey)
}
}
}
_ = raceResults
_ = raceGrid
}
inputs.Champ = derivedDriverStandings(pointsByDriver, latestMeetingKey, latestSessionKey)

View File

@@ -375,3 +375,56 @@ func TestGetWeekendWithSessions(t *testing.T) {
t.Fatalf("first session drivers = %+v, want available", weekend.Sessions[0].Datasets["drivers"])
}
}
func TestGetChampionshipInputsIncludesSprintPoints(t *testing.T) {
// Regression for #57: Race-only aggregation dropped Sprint points.
// Setup: same meeting 1229 has Race (9472) 25pts + Sprint (9473) 8pts => total 33.
svc := openTestService(t)
seedRaceHubData(t, svc.store)
// Add sprint session for same meeting
if err := svc.store.UpsertSession(store.Session{
SessionKey: 9473,
MeetingKey: 1229,
SessionName: "Sprint",
SessionType: "Race", // OpenF1 uses SessionType Race even for Sprint
DateStart: "2025-05-24T13:00:00+00:00",
}); err != nil {
t.Fatalf("UpsertSession sprint error = %v", err)
}
if err := svc.store.UpsertSessionDriver(store.SessionDriver{
SessionKey: 9473, DriverNumber: 1, MeetingKey: 1229, TeamName: "Red Bull Racing",
}); err != nil {
t.Fatalf("UpsertSessionDriver sprint error = %v", err)
}
// Race points 25
if err := svc.store.UpsertSessionResult(store.SessionResult{
SessionKey: 9472, DriverNumber: 1, MeetingKey: 1229, Position: 1, Points: 25,
}); err != nil {
t.Fatalf("UpsertSessionResult race error = %v", err)
}
// Sprint points 8
if err := svc.store.UpsertSessionResult(store.SessionResult{
SessionKey: 9473, DriverNumber: 1, MeetingKey: 1229, Position: 1, Points: 8,
}); err != nil {
t.Fatalf("UpsertSessionResult sprint error = %v", err)
}
inputs, err := svc.GetChampionshipInputs(2025)
if err != nil {
t.Fatalf("GetChampionshipInputs() error = %v", err)
}
if len(inputs.Races) != 1 {
t.Fatalf("Races len = %d, want 1 race entry", len(inputs.Races))
}
if len(inputs.Champ) != 1 {
t.Fatalf("Champ len = %d, want 1", len(inputs.Champ))
}
if inputs.Champ[0].PointsCurrent != 33 {
t.Fatalf("PointsCurrent = %v, want 33 (25 race + 8 sprint)", inputs.Champ[0].PointsCurrent)
}
if len(inputs.Teams) != 1 || inputs.Teams[0].PointsCurrent != 33 {
t.Fatalf("Teams = %+v, want Red Bull 33", inputs.Teams)
}
}

View File

@@ -4,6 +4,7 @@ import (
"database/sql"
"errors"
"github.com/AmanTahiliani/box-box/internal/chapters"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/store"
)
@@ -52,6 +53,7 @@ type RaceHub struct {
RaceControl []models.RaceControl `json:"race_control"`
Weather []models.Weather `json:"weather"`
Laps []models.Lap `json:"laps"`
Chapters []chapters.Chapter `json:"chapters"`
}
// GetRaceHub loads ingested Race Hub datasets for a session from the local store.
@@ -80,6 +82,7 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
RaceControl: []models.RaceControl{},
Weather: []models.Weather{},
Laps: []models.Lap{},
Chapters: []chapters.Chapter{},
}
sess, err := s.store.GetSession(sessionKey)
@@ -248,6 +251,22 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
hub.Datasets["laps"] = availableLocal(len(hub.Laps))
}
hub.Chapters = chapters.Detect(hub.RaceControl, hub.Positions, hub.Laps, totalLaps(hub.Results, hub.Laps))
hub.Source = responseSource(hub.Datasets)
return hub, nil
}
func totalLaps(results []EnrichedResult, laps []models.Lap) int {
total := 0
for _, result := range results {
if result.NumberOfLaps > total {
total = result.NumberOfLaps
}
}
for _, lap := range laps {
if lap.LapNumber > total {
total = lap.LapNumber
}
}
return total
}

View File

@@ -797,11 +797,6 @@ func (s *Server) localChampionshipHub(year int) (champHubResponse, bool, error)
}
func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
meetings, err := s.client.GetMeetingsForYear(year)
if err != nil {
return champHubResponse{}, err
}
champ, err := s.client.GetDriverChampionshipForYear(year)
if err != nil {
return champHubResponse{}, err
@@ -816,16 +811,39 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
driverInfo = buildDriverMapFirst(ds)
}
races, incomplete, err := s.fetchSeasonRaces(year)
if err != nil {
return champHubResponse{}, err
}
resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo)
ttl := champHubTTL(year, time.Now())
if incomplete {
// Any per-meeting fetch failure (network, rate limit) yields an incomplete
// aggregate: serve it so the page still renders, but cache it only briefly
// so a partial view of the season doesn't stick around for the full TTL.
ttl = champHubIncompleteTTL
}
s.hubCache.put(year, resp, time.Now(), ttl)
return resp, nil
}
// fetchSeasonRaces returns a season's GP meetings in date order, each bundled
// with its race results and starting grid fetched from OpenF1. incomplete
// reports whether any per-meeting fetch failed, so callers can avoid caching a
// partial view of the season for long.
func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete bool, err error) {
meetings, err := s.client.GetMeetingsForYear(year)
if err != nil {
return nil, false, err
}
sort.Slice(meetings, func(i, j int) bool { return meetings[i].DateStart < meetings[j].DateStart })
// Any per-meeting fetch failure (network, rate limit) yields an incomplete
// aggregate: serve it so the page still renders, but cache it only briefly
// so a partial view of the season doesn't stick around for the full TTL.
var incomplete atomic.Bool
races := fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) {
var failed atomic.Bool
races = fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) {
sessions, serr := s.client.GetSessionsForMeeting(int(m.MeetingKey))
if serr != nil {
incomplete.Store(true)
failed.Store(true)
return meetingRace{}, false
}
raceKey := 0
@@ -841,18 +859,11 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
results, rerr := s.client.GetSessionResult(raceKey)
grid, gerr := s.client.GetStartingGrid(raceKey)
if rerr != nil || gerr != nil {
incomplete.Store(true)
failed.Store(true)
}
return meetingRace{Meeting: m, RaceSessionKey: raceKey, Results: results, Grid: grid}, true
})
resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo)
ttl := champHubTTL(year, time.Now())
if incomplete.Load() {
ttl = champHubIncompleteTTL
}
s.hubCache.put(year, resp, time.Now(), ttl)
return resp, nil
return races, failed.Load(), nil
}
// aggregateChampionshipHub is the pure aggregation core (no network) so it can be

View File

@@ -0,0 +1,177 @@
package web
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/AmanTahiliani/box-box/internal/models"
)
// --- /api/v1/driver/summary ---
// Per-driver season summary: championship standing plus per-round race results,
// aggregated server-side from the same sources as the championship hub. Caching
// relies on the OpenF1 client's HTTP cache TTLs — no extra layer here.
type driverSummaryRound struct {
MeetingKey int `json:"meeting_key"`
MeetingName string `json:"meeting_name"`
CountryCode string `json:"country_code"`
CountryName string `json:"country_name"`
// RacePosition and GridPosition are 0 when the driver has no entry for the
// round (did not enter, or data missing).
RacePosition int `json:"race_position"`
GridPosition int `json:"grid_position"`
// QualiPosition is derived from the starting grid in v1 (full qualifying
// session results are deferred); omitted when unknown.
QualiPosition int `json:"quali_position,omitempty"`
Points float64 `json:"points"`
DNF bool `json:"dnf"`
DNS bool `json:"dns"`
DSQ bool `json:"dsq"`
}
type driverSummaryResponse struct {
Season int `json:"season"`
DriverNumber int `json:"driver_number"`
NameAcronym string `json:"name_acronym"`
FullName string `json:"full_name"`
TeamName string `json:"team_name"`
TeamColour string `json:"team_colour"`
HeadshotURL string `json:"headshot_url"`
Points float64 `json:"points"`
Position int `json:"position"`
Wins int `json:"wins"`
Podiums int `json:"podiums"`
Poles int `json:"poles"`
Form []float64 `json:"form"`
Cumulative []float64 `json:"cumulative"`
RoundLabels []string `json:"round_labels"`
Rounds []driverSummaryRound `json:"rounds"`
}
func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
driverNumber, err := strconv.Atoi(r.URL.Query().Get("driver_number"))
if err != nil || driverNumber <= 0 {
http.Error(w, "driver_number required", http.StatusBadRequest)
return
}
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
if year == 0 {
year = time.Now().Year()
}
champ, err := s.client.GetDriverChampionshipForYear(year)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
return
}
var entry *models.ChampionshipDriver
for i := range champ {
if champ[i].DriverNumber == driverNumber {
entry = &champ[i]
break
}
}
if entry == nil {
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
return
}
driverInfo := map[int]models.Driver{}
if ds, derr := s.client.GetDriversForSession(champ[0].SessionKey); derr == nil {
driverInfo = buildDriverMapFirst(ds)
}
if d, ok := s.championshipDriverInfo(entry.SessionKey, driverNumber, driverInfo); ok {
driverInfo[driverNumber] = d
}
races, _, err := s.fetchSeasonRaces(year)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
return
}
resp, ok := aggregateDriverSummary(year, driverNumber, races, champ, driverInfo)
if !ok {
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
return
}
writeJSON(w, resp)
}
// aggregateDriverSummary is the pure aggregation core (no network) so it can be
// unit-tested with synthetic data. It reuses the championship-hub aggregation
// for the derived season stats (wins, podiums, poles, form, cumulative) and
// adds the per-round result rows. races must be ordered ascending by date and
// contain only GP meetings. Returns ok=false when the driver is not in champ.
func aggregateDriverSummary(
year, driverNumber int,
races []meetingRace,
champ []models.ChampionshipDriver,
driverInfo map[int]models.Driver,
) (driverSummaryResponse, bool) {
hub := aggregateChampionshipHub(year, races, champ, nil, driverInfo)
var hd *champHubDriver
for i := range hub.Drivers {
if hub.Drivers[i].DriverNumber == driverNumber {
hd = &hub.Drivers[i]
break
}
}
if hd == nil {
return driverSummaryResponse{}, false
}
rounds := make([]driverSummaryRound, 0, len(races))
for _, mr := range races {
if len(mr.Results) == 0 {
continue // round not completed yet
}
round := driverSummaryRound{
MeetingKey: int(mr.Meeting.MeetingKey),
MeetingName: mr.Meeting.MeetingName,
CountryCode: mr.Meeting.CountryCode,
CountryName: mr.Meeting.CountryName,
}
for _, res := range mr.Results {
if res.DriverNumber == driverNumber {
round.RacePosition = res.Position
round.Points = res.Points
round.DNF = res.DNF
round.DNS = res.DNS
round.DSQ = res.DSQ
break
}
}
for _, g := range mr.Grid {
if g.DriverNumber == driverNumber {
round.GridPosition = g.Position
round.QualiPosition = g.Position
break
}
}
rounds = append(rounds, round)
}
return driverSummaryResponse{
Season: year,
DriverNumber: hd.DriverNumber,
NameAcronym: hd.NameAcronym,
FullName: hd.FullName,
TeamName: hd.TeamName,
TeamColour: hd.TeamColour,
HeadshotURL: driverInfo[driverNumber].HeadshotURL,
Points: hd.Points,
Position: hd.Position,
Wins: hd.Wins,
Podiums: hd.Podiums,
Poles: hd.Poles,
Form: hd.Form,
Cumulative: hd.Cumulative,
RoundLabels: hub.RoundLabels,
Rounds: rounds,
}, true
}

View File

@@ -0,0 +1,136 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/AmanTahiliani/box-box/internal/models"
)
func driverSummaryFixtures() ([]meetingRace, []models.ChampionshipDriver, map[int]models.Driver) {
driverInfo := map[int]models.Driver{
1: {DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull", TeamColour: "3671c6", HeadshotURL: "https://example.com/ver.png"},
3: {DriverNumber: 3, NameAcronym: "HAM", FullName: "Lewis Hamilton", TeamName: "Mercedes", TeamColour: "27f4d2"},
}
champ := []models.ChampionshipDriver{
{DriverNumber: 1, PointsCurrent: 50, PositionCurrent: 1, SessionKey: 99},
{DriverNumber: 3, PointsCurrent: 33, PositionCurrent: 2, SessionKey: 99},
}
// Round 1: VER P1 from pole, HAM P2 from P3.
// Round 2: VER P1 from P2, HAM P3 from pole.
// Round 3: not yet run — must not appear in rounds.
races := []meetingRace{
{
Meeting: models.Meeting{MeetingKey: 1201, MeetingName: "Bahrain GP", CountryCode: "BHR", CountryName: "Bahrain"},
Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(3, 2, 18)},
Grid: []models.StartingGrid{{DriverNumber: 1, Position: 1}, {DriverNumber: 3, Position: 3}},
},
{
Meeting: models.Meeting{MeetingKey: 1202, MeetingName: "Saudi GP", CountryCode: "SAU", CountryName: "Saudi Arabia"},
Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(3, 3, 15)},
Grid: []models.StartingGrid{{DriverNumber: 3, Position: 1}, {DriverNumber: 1, Position: 2}},
},
{Meeting: models.Meeting{MeetingKey: 1203, MeetingName: "Australia GP"}},
}
return races, champ, driverInfo
}
func TestAggregateDriverSummary(t *testing.T) {
races, champ, driverInfo := driverSummaryFixtures()
resp, ok := aggregateDriverSummary(2025, 1, races, champ, driverInfo)
if !ok {
t.Fatal("aggregateDriverSummary ok = false, want true")
}
if resp.Season != 2025 || resp.DriverNumber != 1 {
t.Errorf("season/driver = %d/%d, want 2025/1", resp.Season, resp.DriverNumber)
}
if resp.NameAcronym != "VER" || resp.FullName != "Max Verstappen" || resp.TeamName != "Red Bull" || resp.TeamColour != "3671c6" {
t.Errorf("driver identity = %s %s %s %s", resp.NameAcronym, resp.FullName, resp.TeamName, resp.TeamColour)
}
if resp.HeadshotURL != "https://example.com/ver.png" {
t.Errorf("headshot = %q", resp.HeadshotURL)
}
if resp.Points != 50 || resp.Position != 1 {
t.Errorf("points/position = %v/%d, want 50/1", resp.Points, resp.Position)
}
if resp.Wins != 2 || resp.Podiums != 2 || resp.Poles != 1 {
t.Errorf("wins/podiums/poles = %d/%d/%d, want 2/2/1", resp.Wins, resp.Podiums, resp.Poles)
}
if len(resp.Form) != 2 || resp.Form[0] != 25 || resp.Form[1] != 25 {
t.Errorf("form = %v, want [25 25]", resp.Form)
}
// Cumulative reconciles the last value to the official total.
if len(resp.Cumulative) != 2 || resp.Cumulative[0] != 25 || resp.Cumulative[1] != 50 {
t.Errorf("cumulative = %v, want [25 50]", resp.Cumulative)
}
if len(resp.RoundLabels) != 2 || resp.RoundLabels[0] != "R1" {
t.Errorf("round labels = %v, want [R1 R2]", resp.RoundLabels)
}
// Only completed rounds appear.
if len(resp.Rounds) != 2 {
t.Fatalf("rounds = %d, want 2", len(resp.Rounds))
}
r1 := resp.Rounds[0]
if r1.MeetingKey != 1201 || r1.MeetingName != "Bahrain GP" || r1.CountryCode != "BHR" || r1.CountryName != "Bahrain" {
t.Errorf("round 1 meeting = %+v", r1)
}
if r1.RacePosition != 1 || r1.GridPosition != 1 || r1.QualiPosition != 1 || r1.Points != 25 {
t.Errorf("round 1 result = %+v, want P1 from P1 with 25 pts", r1)
}
r2 := resp.Rounds[1]
if r2.RacePosition != 1 || r2.GridPosition != 2 || r2.Points != 25 {
t.Errorf("round 2 result = %+v, want P1 from P2 with 25 pts", r2)
}
}
func TestAggregateDriverSummaryDriverMissingFromRound(t *testing.T) {
races, champ, driverInfo := driverSummaryFixtures()
// Drop HAM from round 2's results and grid: the round still appears with
// zero-valued positions so round indexing stays aligned with the season.
races[1].Results = []models.SessionResult{raceResult(1, 1, 25)}
races[1].Grid = []models.StartingGrid{{DriverNumber: 1, Position: 2}}
resp, ok := aggregateDriverSummary(2025, 3, races, champ, driverInfo)
if !ok {
t.Fatal("aggregateDriverSummary ok = false, want true")
}
if len(resp.Rounds) != 2 {
t.Fatalf("rounds = %d, want 2", len(resp.Rounds))
}
r2 := resp.Rounds[1]
if r2.RacePosition != 0 || r2.GridPosition != 0 || r2.QualiPosition != 0 || r2.Points != 0 {
t.Errorf("round 2 for absent driver = %+v, want zero values", r2)
}
}
func TestAggregateDriverSummaryUnknownDriver(t *testing.T) {
races, champ, driverInfo := driverSummaryFixtures()
if _, ok := aggregateDriverSummary(2025, 44, races, champ, driverInfo); ok {
t.Error("unknown driver should return ok = false")
}
}
func TestHandleDriverSummaryBadRequest(t *testing.T) {
srv := testServer(t, nil)
// Missing, non-numeric, zero, and negative driver_number are all rejected.
for _, url := range []string{
"/api/v1/driver/summary?year=2025",
"/api/v1/driver/summary?year=2025&driver_number=abc",
"/api/v1/driver/summary?year=2025&driver_number=0",
"/api/v1/driver/summary?year=2025&driver_number=-4",
} {
req := httptest.NewRequest(http.MethodGet, url, nil)
rec := httptest.NewRecorder()
srv.handleDriverSummary(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("%s: status = %d, want 400", url, rec.Code)
}
}
}

View File

@@ -29,10 +29,21 @@ type SSEHub struct {
deregister chan *sseClient
broadcast chan sseEvent
mu sync.RWMutex
lastSnapshot *live.LiveStreamData
lastPositions map[string]live.LivePositionData
isLive bool
mu sync.RWMutex
activeSnapshot *live.LiveStreamData
activePositions map[string]live.LivePositionData
lastSnapshot *live.LiveStreamData
lastPositions map[string]live.LivePositionData
lastSnapshotAt time.Time
isLive bool
}
type liveStatePayload struct {
IsLive bool `json:"is_live"`
Data *live.LiveStreamData `json:"data"`
LastSnapshot *live.LiveStreamData `json:"last_snapshot,omitempty"`
LastPositions map[string]live.LivePositionData `json:"last_positions,omitempty"`
LastSnapshotAt *time.Time `json:"last_snapshot_at,omitempty"`
}
func newSSEHub() *SSEHub {
@@ -51,19 +62,16 @@ func (h *SSEHub) run() {
case c := <-h.register:
clients[c] = true
// Send catch-up snapshot so new clients see current state immediately.
h.mu.RLock()
snap := h.lastSnapshot
positions := cloneLivePositions(h.lastPositions)
live := h.isLive
h.mu.RUnlock()
if snap != nil {
if data, err := json.Marshal(map[string]any{"data": snap, "is_live": live}); err == nil {
state := h.State()
if state.Data != nil || state.LastSnapshot != nil {
if data, err := json.Marshal(state); err == nil {
select {
case c.ch <- formatSSEFrame("snapshot", data):
default:
}
}
}
positions := h.ActivePositions()
if len(positions) > 0 {
if data, err := json.Marshal(positions); err == nil {
select {
@@ -96,11 +104,125 @@ func formatSSEFrame(event string, data []byte) []byte {
return []byte(fmt.Sprintf("event: %s\ndata: %s\n\n", event, data))
}
// Snapshot returns the latest live data snapshot and whether a session is active.
func (h *SSEHub) Snapshot() (*live.LiveStreamData, bool) {
// State returns the active live snapshot and the retained in-memory archive.
func (h *SSEHub) State() liveStatePayload {
h.mu.RLock()
defer h.mu.RUnlock()
return h.lastSnapshot, h.isLive
payload := liveStatePayload{
IsLive: h.isLive,
}
if h.isLive {
payload.Data = h.activeSnapshot
} else if h.lastSnapshot != nil {
payload.LastSnapshot = h.lastSnapshot
payload.LastPositions = cloneLivePositions(h.lastPositions)
if !h.lastSnapshotAt.IsZero() {
at := h.lastSnapshotAt
payload.LastSnapshotAt = &at
}
}
return payload
}
func (h *SSEHub) ActivePositions() map[string]live.LivePositionData {
h.mu.RLock()
defer h.mu.RUnlock()
return cloneLivePositions(h.activePositions)
}
func (h *SSEHub) applySnapshot(data live.LiveStreamData, now time.Time) liveStatePayload {
h.mu.Lock()
defer h.mu.Unlock()
if live.SessionStatusIsActive(data.SessionStatus) {
h.isLive = true
h.activeSnapshot = &data
if data.PositionUpdated && len(data.Positions) > 0 {
h.activePositions = cloneLivePositions(data.Positions)
}
return liveStatePayload{IsLive: true, Data: h.activeSnapshot}
}
archivePositions := cloneLivePositions(h.activePositions)
if data.PositionUpdated && len(data.Positions) > 0 {
archivePositions = cloneLivePositions(data.Positions)
}
h.isLive = false
h.activeSnapshot = nil
h.activePositions = nil
if hasLiveSnapshotData(data) {
h.lastSnapshot = &data
h.lastSnapshotAt = now
h.lastPositions = archivePositions
}
return h.stateLocked()
}
func (h *SSEHub) applyPositions(data live.LiveStreamData) map[string]live.LivePositionData {
h.mu.Lock()
defer h.mu.Unlock()
if len(data.Positions) == 0 {
return nil
}
positions := cloneLivePositions(data.Positions)
if h.isLive {
h.activePositions = positions
return positions
}
if h.lastSnapshot != nil {
h.lastPositions = positions
}
return nil
}
func (h *SSEHub) deactivate(now time.Time) liveStatePayload {
h.mu.Lock()
defer h.mu.Unlock()
if h.activeSnapshot != nil {
h.lastSnapshot = h.activeSnapshot
h.lastPositions = cloneLivePositions(h.activePositions)
h.lastSnapshotAt = now
}
h.isLive = false
h.activeSnapshot = nil
h.activePositions = nil
return h.stateLocked()
}
func (h *SSEHub) stateLocked() liveStatePayload {
payload := liveStatePayload{IsLive: h.isLive}
if h.isLive {
payload.Data = h.activeSnapshot
return payload
}
if h.lastSnapshot != nil {
payload.LastSnapshot = h.lastSnapshot
payload.LastPositions = cloneLivePositions(h.lastPositions)
if !h.lastSnapshotAt.IsZero() {
at := h.lastSnapshotAt
payload.LastSnapshotAt = &at
}
}
return payload
}
func hasLiveSnapshotData(data live.LiveStreamData) bool {
return len(data.Drivers) > 0 ||
len(data.DriverInfo) > 0 ||
len(data.Tyres) > 0 ||
len(data.Telemetry) > 0 ||
len(data.RCMessages) > 0 ||
len(data.TeamRadio) > 0 ||
len(data.Stints) > 0 ||
data.Session.MeetingName != "" ||
data.Session.SessionName != "" ||
data.Session.SessionType != "" ||
data.TrackStatus != "" ||
data.CurrentLap != 0 ||
data.TotalLaps != 0 ||
data.Clock != ""
}
// runLiveFeeds launches background goroutines for the F1 SignalR feed and keepalive.
@@ -129,13 +251,8 @@ func (s *Server) signalRLoop() {
log.Printf("web: live feed ended: %v", err)
}
s.hub.mu.Lock()
s.hub.isLive = false
s.hub.lastSnapshot = nil
s.hub.lastPositions = nil
s.hub.mu.Unlock()
if payload, err := json.Marshal(map[string]any{"data": nil, "is_live": false}); err == nil {
state := s.hub.deactivate(time.Now())
if payload, err := json.Marshal(state); err == nil {
s.hub.broadcast <- sseEvent{name: "snapshot", data: payload}
}
@@ -167,24 +284,20 @@ func (s *Server) connectAndDrain() error {
select {
case data := <-dataChan:
now := time.Now()
s.hub.mu.Lock()
if data.SnapshotUpdated {
s.hub.lastSnapshot = &data
}
if data.PositionUpdated && len(data.Positions) > 0 {
s.hub.lastPositions = cloneLivePositions(data.Positions)
}
s.hub.isLive = true
s.hub.mu.Unlock()
if data.SnapshotUpdated {
if payload, err := json.Marshal(map[string]any{"data": data, "is_live": true}); err == nil {
state := s.hub.applySnapshot(data, now)
if payload, err := json.Marshal(state); err == nil {
s.hub.broadcast <- sseEvent{name: "snapshot", data: payload}
}
}
if data.PositionUpdated && len(data.Positions) > 0 && now.Sub(lastPositionBroadcast) >= 250*time.Millisecond {
if payload, err := json.Marshal(data.Positions); err == nil {
positions := s.hub.applyPositions(data)
if len(positions) > 0 {
payload, err := json.Marshal(positions)
if err != nil {
continue
}
s.hub.broadcast <- sseEvent{name: "positions", data: payload}
lastPositionBroadcast = now
}
@@ -217,11 +330,7 @@ func cloneLivePositions(in map[string]live.LivePositionData) map[string]live.Liv
// handleLiveState returns the current live data snapshot as JSON.
func (s *Server) handleLiveState(w http.ResponseWriter, r *http.Request) {
snap, isLive := s.hub.Snapshot()
writeJSON(w, map[string]any{
"is_live": isLive,
"data": snap,
})
writeJSON(w, s.hub.State())
}
// handleSSEStream is the persistent SSE endpoint for live data.

View File

@@ -0,0 +1,90 @@
package web
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/AmanTahiliani/box-box/internal/live"
)
func TestSSEHubArchivesTerminalSessionSnapshot(t *testing.T) {
hub := newSSEHub()
now := time.Date(2026, 7, 4, 14, 0, 0, 0, time.UTC)
active := live.LiveStreamData{
SessionStatus: "Started",
Drivers: map[string]live.LiveDriverData{
"1": {RacingNumber: "1", Position: 1},
},
Positions: map[string]live.LivePositionData{
"1": {X: 100, Y: -50, Z: 2, Status: "OnTrack"},
},
PositionUpdated: true,
SnapshotUpdated: true,
}
if state := hub.applySnapshot(active, now); !state.IsLive || state.Data == nil {
t.Fatalf("active state = %+v, want live data", state)
}
terminal := active
terminal.SessionStatus = "Finished"
terminal.Positions = nil
terminal.PositionUpdated = false
state := hub.applySnapshot(terminal, now.Add(time.Minute))
if state.IsLive {
t.Fatal("terminal SessionStatus should not be live")
}
if state.Data != nil {
t.Fatalf("inactive state data = %+v, want nil", state.Data)
}
if state.LastSnapshot == nil || state.LastSnapshot.SessionStatus != "Finished" {
t.Fatalf("last snapshot = %+v, want terminal snapshot", state.LastSnapshot)
}
if got := state.LastPositions["1"]; got.X != 100 || got.Status != "OnTrack" {
t.Fatalf("last positions = %+v, want carried active positions", state.LastPositions)
}
if state.LastSnapshotAt == nil || !state.LastSnapshotAt.Equal(now.Add(time.Minute)) {
t.Fatalf("last snapshot time = %v, want %v", state.LastSnapshotAt, now.Add(time.Minute))
}
}
func TestHandleLiveStateKeepsArchiveOutOfActiveData(t *testing.T) {
hub := newSSEHub()
now := time.Date(2026, 7, 4, 14, 0, 0, 0, time.UTC)
hub.applySnapshot(live.LiveStreamData{
SessionStatus: "Finished",
Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", SessionName: "Race"},
Drivers: map[string]live.LiveDriverData{
"44": {RacingNumber: "44", Position: 1},
},
SnapshotUpdated: true,
}, now)
srv := &Server{hub: hub}
req := httptest.NewRequest(http.MethodGet, "/api/v1/live/state", nil)
rec := httptest.NewRecorder()
srv.handleLiveState(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var resp liveStatePayload
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.IsLive {
t.Fatal("archived snapshot should report is_live=false")
}
if resp.Data != nil {
t.Fatalf("archived snapshot leaked into data: %+v", resp.Data)
}
if resp.LastSnapshot == nil || resp.LastSnapshot.Session.MeetingName != "British Grand Prix" {
t.Fatalf("last snapshot = %+v, want archived race", resp.LastSnapshot)
}
if resp.LastSnapshotAt == nil {
t.Fatal("last_snapshot_at should be present for archived snapshots")
}
}

View File

@@ -4,6 +4,7 @@ import (
"net/http"
"strconv"
"github.com/AmanTahiliani/box-box/internal/chapters"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/query"
)
@@ -42,5 +43,6 @@ func emptyRaceHub(sessionKey int) query.RaceHub {
Drivers: []models.Driver{},
Results: []query.EnrichedResult{},
StartingGrid: []query.EnrichedGrid{},
Chapters: []chapters.Chapter{},
}
}

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