Compare commits

..

21 Commits

Author SHA1 Message Date
AmanTahiliani
7d0b25b66e fix(test): align events fixture with LiveSessionMeta.Path / TeamRadio from phase-1 2026-07-04 00:09:51 -04:00
AmanTahiliani
bc18a20829 Merge phase-1 (radio ticker, tyre-deg panel, hero) into event-rail branch 2026-07-04 00:08:24 -04:00
Aman Tahiliani
2795ccf11b Merge pull request #39 from AmanTahiliani/feat/issue-13-live-tyre-degradation-pit-window-panel
Live tyre-degradation & pit-window panel (#13)
2026-07-04 00:07:11 -04:00
AmanTahiliani
4813b958a0 Merge remote-tracking branch 'origin/phase-1' into feat/issue-13-live-tyre-degradation-pit-window-panel 2026-07-04 00:06:42 -04:00
Aman Tahiliani
a97e3caa4f Merge pull request #38 from AmanTahiliani/feat/issue-14-session-aware-command-center-hero
Session-aware Command Center hero (#14)
2026-07-04 00:06:12 -04:00
Aman Tahiliani
655df9845b Merge pull request #37 from AmanTahiliani/feat/issue-15-telemetry-trace-chart-component
Telemetry trace chart component (#15)
2026-07-04 00:03:46 -04:00
AmanTahiliani
2d49a8bd00 feat(#11): "What just happened" synthesized event rail
Implemented by claude via .agents/dev dispatch.
2026-07-04 00:00:34 -04:00
AmanTahiliani
4e62e93ea2 feat(#13): Live tyre-degradation & pit-window panel
Implemented by claude via .agents/dev dispatch.
2026-07-04 00:00:19 -04:00
AmanTahiliani
eacd5f26e9 feat(#15): Telemetry trace chart component
Implemented by claude via .agents/dev dispatch.
2026-07-03 23:59:39 -04:00
AmanTahiliani
ba7f653e3c feat(frontend): add session-aware Command Center hero (#14)
Replace the static weekend band with a three-state hero (live, upcoming,
between weekends) driven by heroState() and existing page queries. Between-weekend
podium uses the existing race-hub endpoint for the last ingested race session
since championship hub does not expose finish positions.

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

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

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

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

Playwright remains out of scope for live rendering because BOXBOX_DISABLE_LIVE is used there; coverage is via parser, web handler/SSE, pure transform, and seeded component tests.
2026-07-03 19:34:09 -04:00
AmanTahiliani
5408a45bbd Refresh live sprint qualifying timing 2026-07-03 12:43:45 -04:00
72 changed files with 7400 additions and 401 deletions

49
.agents/README.md Normal file
View File

@@ -0,0 +1,49 @@
# `.agents/` — harness-agnostic agentic dev toolkit
Portable skills, prompts, and scripts that drive the box-box development lifecycle.
Any harness (Claude, Codex, opencode, …) can read these — the canonical workflows
live here, not in a tool-specific folder. The shared project context every harness
reads is `AGENTS.md` (→ `CLAUDE.md`).
## Layout
```
skills/ Codex/open agent skills: groom, write-spec, implement, review, lenses
personas/ grill.md (base) + lens overlays (architect, …) — the interrogation voices
prompts/ ready-spec.md (groomed spec), implement.md/review.md dispatch prompts
lib/gh.sh GitHub issue + Project (#2) state helpers: issue_*, set_stage/effort/priority
lib/dispatch.sh dispatch(): Ready issue → worktree → harness → gate → PR
harnesses.sh headless adapters (one fn per harness) + run_gate — the ONLY tool-specific code
bin/dev CLI: `dev implement <issue#> --harness <name> [--dry-run]`
```
## The lifecycle
`Icebox → Research → Ready → In Progress → In Review → Done` (the Project `Stage` field).
- **Groom** (interactive, Claude): `/groom <issue#>` runs a seeded grill-me → writes a
Ready spec into the issue body → sets Effort/Priority → leaves Stage at `Research`.
You review and flip to `Ready`.
- **Implement** (any harness): `.agents/bin/dev implement <issue#> --harness <name>`
(or `/implement …` in Claude to supervise) → isolated worktree → runs the harness
headless on the spec → build gate → opens a PR → sets Stage `In Review`.
- **Review + merge**: use the `review` skill from a harness different from the
implementer to create a local review packet and PR comment, then you merge.
## Skills and harnesses
`.agents/skills` is the canonical home for reusable workflows. Codex discovers
repo skills from that path directly, and Claude can use the same files through
`.claude/skills -> ../.agents/skills`. Other harnesses can read the same
`SKILL.md` files explicitly or enter the workflow through `.agents/bin/dev`.
Do not put canonical workflow instructions under `.claude/`; that directory is
local adapter state.
## Adding / fixing a harness
Edit one function in `harnesses.sh`: `harness_<name> <workdir> <promptfile>`, running the
tool non-interactively in `<workdir>` on the prompt. `claude`/`codex`/`opencode` are
wired; `pi`/`cursor` are stubs — confirm their headless flags before trusting.
Always `--dry-run` a new harness first: it renders the exact prompt and plan, touching
nothing (no worktree, PR, or state change).

45
.agents/bin/dev Executable file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# .agents/bin/dev — box-box agentic dev CLI. Works from any harness's shell.
#
# dev implement <issue#> --harness <claude|codex|opencode|pi|cursor> [--dry-run] [--base <branch>]
#
# Grooming is driven interactively via the Claude Code /groom skill; this CLI covers
# the implement lane (dispatch a Ready issue to a harness → worktree → gate → PR).
set -o pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../.." && pwd)"
usage() {
cat >&2 <<EOF
box-box dev CLI
dev implement <issue#> --harness <name> [--dry-run] [--base <branch>]
harnesses: claude, codex, opencode (supported) · pi, cursor (verify flags in .agents/harnesses.sh)
--dry-run render the prompt + plan, touch nothing (no worktree/PR/state change)
--base base branch for the worktree/PR (default: main)
EOF
exit 2
}
cmd="${1:-}"; shift 2>/dev/null || true
case "$cmd" in
implement)
issue="${1:-}"; shift 2>/dev/null || true
harness=""; passthru=()
while [ $# -gt 0 ]; do
case "$1" in
--harness) harness="${2:-}"; shift 2 ;;
--dry-run) passthru+=(--dry-run); shift ;;
--base) passthru+=(--base "${2:-}"); shift 2 ;;
*) echo "unknown arg: $1" >&2; usage ;;
esac
done
[ -n "$issue" ] && [ -n "$harness" ] || usage
# shellcheck source=/dev/null
source "$ROOT/.agents/lib/dispatch.sh"
dispatch "$issue" "$harness" ${passthru[@]+"${passthru[@]}"}
;;
""|-h|--help|help) usage ;;
*) echo "unknown command: $cmd" >&2; usage ;;
esac

62
.agents/harnesses.sh Normal file
View File

@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# .agents/harnesses.sh — headless harness adapters.
#
# ONE function per harness: harness_<name> <workdir> <promptfile>
# Each runs the harness NON-INTERACTIVELY inside <workdir>, feeding it the rendered
# prompt, expected to edit files (and ideally commit). This is the ONLY harness-specific
# code in the toolkit — tune the exact flags here per tool/version.
#
# Autonomy note: these run unattended, so each uses the tool's "just do it" mode
# (accept edits / full-auto). Only run harnesses you trust on code you can review via PR.
# ---- MUST-HAVE ----
harness_claude() { # Claude Code — print mode, auto-accept edits
local dir="$1" prompt="$2"
( cd "$dir" && claude -p "$(cat "$prompt")" --permission-mode acceptEdits )
}
harness_codex() { # OpenAI Codex CLI — non-interactive exec, full auto
local dir="$1" prompt="$2"
( cd "$dir" && codex exec --full-auto "$(cat "$prompt")" )
}
harness_opencode() { # opencode — non-interactive run
local dir="$1" prompt="$2"
( cd "$dir" && opencode run "$(cat "$prompt")" )
}
harness_cursor() { # Cursor CLI agent — composer-2.5, headless full-auto
local dir="$1" prompt="$2"
( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --model composer-2.5 --force --trust )
}
harness_agy() { # Antigravity CLI — UNRELIABLE headless (2026-07): with
# --new-project it ignores the prompt and asks to scaffold a project; without it, it
# resumes the previous conversation (silently keeping its old model — --model only
# applies to new conversations) and can hang past the print timeout. Do not trust for
# dispatch until fixed upstream; verify with a trivial prompt first.
local dir="$1" prompt="$2"
( cd "$dir" && agy --print --print-timeout 60m \
--model="Gemini 3.1 Pro (High)" --dangerously-skip-permissions "$(cat "$prompt")" )
}
# ---- NICE-TO-HAVE (verify the exact invocation for your version before trusting) ----
harness_pi() { # pi — CONFIRM headless CLI + flags
local dir="$1" prompt="$2"
( cd "$dir" && pi run "$(cat "$prompt")" ) # placeholder — verify
}
# ---- build/typecheck gate (fast, local) ----
# Returns non-zero on failure. This is a smoke gate — CI runs the full suite. Tune freely.
run_gate() {
local dir="$1"
( cd "$dir" && go build ./... ) || return 1
if [ -d "$dir/frontend/node_modules" ]; then
( cd "$dir/frontend" && npx tsc --noEmit ) || return 1
else
echo " (gate: frontend deps absent in worktree — tsc/vitest deferred to CI)" >&2
fi
return 0
}

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

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
---
name: lens-architect
description: Grill/analyze a box-box issue or epic from a software-architecture perspective and post the findings as a comment. Use standalone as "/lens-architect <issue-number>" for an on-the-fly architecture review, or let /groom compose it via "--lens architect". Reads the base grill + architect persona and focuses on reuse, data flow, seams, testability, and risk.
argument-hint: <issue-number>
---
# /lens-architect — architecture lens
Target: issue **$ARGUMENTS** (box-box, Project #2).
1. `source .agents/lib/gh.sh`; load context (`issue_json <n>`, `CLAUDE.md`, and the
relevant code paths).
2. Read `.agents/personas/grill.md` + `.agents/personas/architect.md` and run a focused
grill from the architecture lens: hybrid asking (AskUserQuestion for discrete
decisions, recommendation first; prose otherwise), recommend every answer, and
explore the code to self-answer before asking.
3. When aligned, write an **"## Architecture review"** summary (decisions taken,
files/seams affected, risks, the test seam) to a temp file and `add_comment <n> <file>`.
4. Print the issue URL.
If invoked from **within /groom**, skip the comment — instead return the architecture
decisions inline so groom can fold them into the spec.

View File

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

View File

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

1
.claude/skills Symbolic link
View File

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

9
.gitignore vendored
View File

@@ -22,7 +22,8 @@ frontend/node_modules/
frontend/dist/ frontend/dist/
# Local Claude workspace settings # Local Claude workspace settings
.claude/ .claude/*
!.claude/skills
# Playwright # Playwright
node_modules/ node_modules/
@@ -31,3 +32,9 @@ node_modules/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
/playwright/.auth/ /playwright/.auth/
# agentic dev: isolated implementation worktrees
.worktrees/
# agentic dev: local review packets
.review/

198
docs/PRODUCT_ROADMAP.md Normal file
View File

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

View File

@@ -2,10 +2,12 @@ import type {
ArticleContent, ArticleContent,
ChampionshipHub, ChampionshipHub,
LiveStateResponse, LiveStateResponse,
LiveSessionMeta,
Meeting, Meeting,
NewsItem, NewsItem,
RaceHub, RaceHub,
Session, Session,
TrackOutline,
Weekend, Weekend,
} from './types' } from './types'
@@ -80,6 +82,20 @@ export async function fetchLiveState(): Promise<LiveStateResponse> {
return res.json() return res.json()
} }
export async function fetchLiveTrackOutline(
session: LiveSessionMeta,
year = new Date().getFullYear(),
): Promise<TrackOutline> {
const params = new URLSearchParams({ year: year.toString() })
if (session.MeetingName) params.set('meeting_name', session.MeetingName)
if (session.CircuitName) params.set('circuit_name', session.CircuitName)
const res = await fetch(`/api/v1/track-outline?${params.toString()}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}
export async function fetchNews(limit?: number, source?: string): Promise<NewsItem[]> { export async function fetchNews(limit?: number, source?: string): Promise<NewsItem[]> {
const params = new URLSearchParams() const params = new URLSearchParams()
if (limit) params.set('limit', limit.toString()) if (limit) params.set('limit', limit.toString())

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

@@ -1,28 +1,9 @@
import type { EnrichedResult, Stint, PitStop } from '../types' import type { EnrichedResult, Stint, PitStop } from '../types'
import { compareFinishPosition } from '../utils' import { compareFinishPosition } from '../utils'
import {
const COMPOUND_COLORS: Record<string, string> = { TyreStintTimeline,
SOFT: '#e8002d', type StintTimelineRow,
MEDIUM: '#ffd600', } from './charts/TyreStintTimeline'
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] ?? '?'
}
interface Props { interface Props {
results: EnrichedResult[] results: EnrichedResult[]
@@ -31,7 +12,7 @@ interface Props {
hasStints: boolean hasStints: boolean
} }
export function StrategyView({ results, stints, pit_stops, hasStints }: Props) { export function StrategyView({ results, stints, pit_stops: _pitStops, hasStints }: Props) {
if (!hasStints) { if (!hasStints) {
return ( return (
<div> <div>
@@ -89,147 +70,29 @@ export function StrategyView({ results, stints, pit_stops, hasStints }: Props) {
const cmp = compareFinishPosition(a.position, b.position) const cmp = compareFinishPosition(a.position, b.position)
return cmp !== 0 ? cmp : a.driver_number - b.driver_number return cmp !== 0 ? cmp : a.driver_number - b.driver_number
}) })
const totalLaps = Math.max( const totalLaps = Math.max(
...stints.map((s) => s.lap_end), ...stints.map((s) => s.lap_end),
...results.map((r) => r.number_of_laps), ...results.map((r) => r.number_of_laps),
1 1,
) )
const SVG_W = 640 const timelineRows: StintTimelineRow[] = sortedDrivers.map((driver) => ({
const LEFT = 48 label: driver.name_acronym || String(driver.driver_number),
const RIGHT = 12 color: driver.team_colour ? `#${driver.team_colour}` : '#888',
const ROW_H = 28 stints: stints
const BAR_H = 14 .filter((s) => s.driver_number === driver.driver_number)
const BAR_Y = 7 .map((s) => ({
const BAR_W = SVG_W - LEFT - RIGHT compound: s.compound,
const SVG_H = sortedDrivers.length * ROW_H + 8 lapStart: s.lap_start,
lapEnd: s.lap_end,
const lapX = (lap: number) => LEFT + ((lap - 1) / totalLaps) * BAR_W isNew: s.tyre_age_at_start === 0,
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
)
return ( return (
<div data-testid="strategy-chart"> <div data-testid="strategy-chart">
<div className="scroll-x"> <TyreStintTimeline rows={timelineRows} totalLaps={totalLaps} />
<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>
</div> </div>
) )
} }

View File

@@ -0,0 +1,282 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import {
computeCumulativeDeltas,
deltaPolylineSegments,
formatDeltaSeconds,
type DeltaSeries,
type DriverDeltaResult,
} from '../../lib/delta'
import '../../styles/delta-graph.css'
export type { DeltaSeries }
export interface DeltaTimeGraphProps {
series: DeltaSeries[]
referenceLabel?: string
}
const W = 640
const H = 220
const PL = 44
const PR = 16
const PT = 12
const PB = 28
function lapCount(series: ReadonlyArray<DeltaSeries>): number {
return series.reduce((max, s) => Math.max(max, s.lapTimes.length), 0)
}
function yExtent(drivers: ReadonlyArray<DriverDeltaResult>): { min: number; max: number } {
let min = 0
let max = 0
for (const driver of drivers) {
for (const delta of driver.deltas) {
if (delta === null) continue
min = Math.min(min, delta)
max = Math.max(max, delta)
}
}
if (min === max) {
const pad = 1
return { min: min - pad, max: max + pad }
}
const span = max - min
const pad = span * 0.08
return { min: min - pad, max: max + pad }
}
function niceYTicks(min: number, max: number): number[] {
const span = max - min
if (span <= 0) return [0]
const rough = span / 4
const magnitude = Math.pow(10, Math.floor(Math.log10(rough)))
const step = Math.ceil(rough / magnitude) * magnitude
const ticks: number[] = []
const start = Math.ceil(min / step) * step
for (let v = start; v <= max + step * 0.01; v += step) {
ticks.push(Number(v.toFixed(6)))
}
if (!ticks.some((t) => Math.abs(t) < step * 0.01)) {
ticks.push(0)
ticks.sort((a, b) => a - b)
}
return ticks
}
export function DeltaTimeGraph({ series, referenceLabel }: DeltaTimeGraphProps) {
const svgRef = useRef<SVGSVGElement>(null)
const [hoverLap, setHoverLap] = useState<number | null>(null)
const drivers = useMemo(
() => computeCumulativeDeltas(series, referenceLabel),
[series, referenceLabel],
)
const laps = useMemo(() => lapCount(series), [series])
const plotW = W - PL - PR
const plotH = H - PT - PB
const { min: yMin, max: yMax } = useMemo(() => yExtent(drivers), [drivers])
const yTicks = useMemo(() => niceYTicks(yMin, yMax), [yMin, yMax])
const toX = useCallback(
(lapIndex: number) => {
if (laps <= 1) return PL + plotW / 2
return PL + (lapIndex / (laps - 1)) * plotW
},
[laps, plotW],
)
const toY = useCallback(
(delta: number) => {
const span = yMax - yMin || 1
return PT + ((delta - yMin) / span) * plotH
},
[yMin, yMax, plotH],
)
const lapTickNumbers = useMemo(() => {
const ticks: number[] = []
for (let lap = 5; lap <= laps; lap += 5) {
ticks.push(lap)
}
return ticks
}, [laps])
const handlePointerMove = useCallback(
(e: React.PointerEvent<SVGRectElement> | React.MouseEvent<SVGRectElement>) => {
if (!svgRef.current || laps === 0) return
const rect = svgRef.current.getBoundingClientRect()
if (rect.width <= 0) return
const x = ((e.clientX - rect.left) / rect.width) * W
const frac = Math.max(0, Math.min(1, (x - PL) / plotW))
const lapIndex = laps <= 1 ? 0 : Math.round(frac * (laps - 1))
if (!Number.isFinite(lapIndex)) return
setHoverLap(Math.max(0, Math.min(laps - 1, lapIndex)))
},
[laps, plotW],
)
const handlePointerLeave = useCallback(() => setHoverLap(null), [])
if (series.length === 0 || laps === 0) {
return (
<div className="delta-graph" data-testid="delta-time-graph-empty">
<p className="delta-graph-empty">No lap data to compare.</p>
</div>
)
}
if (drivers.length === 0) {
return (
<div className="delta-graph" data-testid="delta-time-graph-empty">
<p className="delta-graph-empty">Select at least two drivers to compare.</p>
</div>
)
}
const hoverX = hoverLap !== null ? toX(hoverLap) : null
const tooltipRows = hoverLap !== null
? drivers
.map((d) => {
const delta = d.deltas[hoverLap]
if (delta == null) return null
return { label: d.label, color: d.color, delta }
})
.filter((row): row is { label: string; color: string; delta: number } => row !== null)
: []
const tooltipH = 18 + tooltipRows.length * 14
const tooltipW = 120
const tooltipX = hoverX !== null ? Math.min(Math.max(hoverX + 8, PL), W - PR - tooltipW) : 0
const tooltipY = PT
return (
<div className="delta-graph" data-testid="delta-time-graph">
<svg
ref={svgRef}
className="delta-graph-svg"
viewBox={`0 0 ${W} ${H}`}
role="img"
aria-label="Cumulative delta time chart"
>
{yTicks.map((tick) => (
<g key={`y-${tick}`}>
<line
x1={PL}
x2={W - PR}
y1={toY(tick)}
y2={toY(tick)}
className={Math.abs(tick) < 1e-9 ? 'delta-graph-zero-line' : 'delta-graph-grid-line'}
data-testid={Math.abs(tick) < 1e-9 ? 'delta-zero-line' : undefined}
/>
<text
x={PL - 6}
y={toY(tick) + 3}
textAnchor="end"
className="delta-graph-axis-label"
>
{formatDeltaSeconds(tick)}
</text>
</g>
))}
{lapTickNumbers.map((lap) => {
const x = toX(lap - 1)
return (
<g key={`lap-${lap}`}>
<line
x1={x}
x2={x}
y1={H - PB}
y2={H - PB + 4}
className="delta-graph-grid-line"
/>
<text
x={x}
y={H - PB + 16}
textAnchor="middle"
className="delta-graph-axis-label"
>
{lap}
</text>
</g>
)
})}
{drivers.map((driver) => {
const segments = deltaPolylineSegments(driver.deltas, (lapIndex, delta) =>
`${toX(lapIndex).toFixed(1)},${toY(delta).toFixed(1)}`,
)
return (
<g key={driver.label} data-testid={`delta-line-${driver.label}`}>
{segments.map((points, i) => (
<polyline
key={`${driver.label}-${i}`}
points={points}
className="delta-graph-driver-line"
stroke={driver.color}
/>
))}
</g>
)
})}
{hoverX !== null && (
<>
<line
x1={hoverX}
x2={hoverX}
y1={PT}
y2={H - PB}
className="delta-graph-crosshair"
data-testid="delta-crosshair"
/>
{tooltipRows.length > 0 && (
<g className="delta-graph-tooltip" data-testid="delta-tooltip">
<rect
x={tooltipX}
y={tooltipY}
width={tooltipW}
height={tooltipH}
rx={4}
className="delta-graph-tooltip-bg"
/>
<text
x={tooltipX + 8}
y={tooltipY + 14}
className="delta-graph-tooltip-title"
>
Lap {hoverLap! + 1}
</text>
{tooltipRows.map((row, i) => (
<text
key={row.label}
x={tooltipX + 8}
y={tooltipY + 28 + i * 14}
className="delta-graph-tooltip-row"
fill={row.color}
>
{row.label} {formatDeltaSeconds(row.delta)}
</text>
))}
</g>
)}
</>
)}
<rect
x={PL}
y={PT}
width={plotW}
height={plotH}
fill="transparent"
className="delta-graph-hover-layer"
onPointerMove={handlePointerMove}
onMouseMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
onMouseLeave={handlePointerLeave}
/>
</svg>
</div>
)
}

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,182 @@
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[]
}
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 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>
))}
</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

@@ -1,17 +1,22 @@
import type { LiveStreamData } from '../../types' import type { LiveStreamData } from '../../types'
import { extrapolateClock } from '../../lib/live' import type { LiveTimingRow } from '../../lib/live'
import { extrapolateClock, liveSessionDisplay } from '../../lib/live'
import { WeatherStrip } from './WeatherStrip' import { WeatherStrip } from './WeatherStrip'
interface Props { interface Props {
isLive: boolean isLive: boolean
snapshot: LiveStreamData snapshot: LiveStreamData
rows: LiveTimingRow[]
connection: 'connected' | 'connecting' | 'disconnected' | 'error' connection: 'connected' | 'connecting' | 'disconnected' | 'error'
now: number now: number
} }
export function SessionBanner({ isLive, snapshot, connection, now }: Props) { export function SessionBanner({ isLive, snapshot, rows, connection, now }: Props) {
const session = snapshot.Session const session = snapshot.Session
const clock = extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now) 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` : ''
return ( return (
<section className="live-banner"> <section className="live-banner">
@@ -25,14 +30,19 @@ export function SessionBanner({ isLive, snapshot, connection, now }: Props) {
</p> </p>
</div> </div>
</div> </div>
<div className="live-session-board">
{display.phaseLabel && <span className="live-phase-pill mono">{display.phaseLabel}</span>}
<div className="live-clock mono" data-testid="live-clock">{clock || '--:--:--'}</div>
<div className="live-banner-meta"> <div className="live-banner-meta">
<span className="mono"> {display.advanceCount && <span>{display.advanceCount} advance</span>}
{atRiskLabel && <span>{atRiskLabel}</span>}
<span>
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong> L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
</span> </span>
{clock && <span className="mono">{clock}</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'}>{isLive ? 'live' : 'stale'}</span>
</div> </div>
</div> </div>
</div>
<WeatherStrip weather={snapshot.Weather} /> <WeatherStrip weather={snapshot.Weather} />
</section> </section>
) )

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

@@ -1,10 +1,11 @@
import { useState, Fragment } from 'react' import { useState, Fragment } from 'react'
import { teamColor } from '../../utils' import { teamColor } from '../../utils'
import { useAutoAnimate } from '@formkit/auto-animate/react' import { useAutoAnimate } from '@formkit/auto-animate/react'
import type { LiveStintData } from '../../types' import type { LiveSessionMeta, LiveStintData } from '../../types'
import type { LiveTimingRow } from '../../lib/live' import type { LiveTimingRow } from '../../lib/live'
import { import {
driverCode, driverCode,
liveSessionDisplay,
positionDelta, positionDelta,
positionDeltaClass, positionDeltaClass,
tyreClass, tyreClass,
@@ -22,7 +23,7 @@ interface Props {
battleNumbers?: Set<string> battleNumbers?: Set<string>
pinned?: string[] pinned?: string[]
onTogglePin?: (racingNumber: string) => void onTogglePin?: (racingNumber: string) => void
sessionType?: string session?: LiveSessionMeta
} }
function posClass(pos: number): string { function posClass(pos: number): string {
@@ -39,7 +40,7 @@ export function TimingTower({
battleNumbers, battleNumbers,
pinned, pinned,
onTogglePin, onTogglePin,
sessionType = '', session,
}: Props) { }: Props) {
const [expandedRow, setExpandedRow] = useState<string | null>(null) const [expandedRow, setExpandedRow] = useState<string | null>(null)
const [gapMode, setGapMode] = useState<'interval' | 'leader'>('interval') const [gapMode, setGapMode] = useState<'interval' | 'leader'>('interval')
@@ -53,16 +54,14 @@ export function TimingTower({
) )
} }
const sType = sessionType.toLowerCase() const sessionDisplay = liveSessionDisplay(session, rows)
const isRace = sType.includes('race') || sType.includes('sprint') const isRace = sessionDisplay.isRace
const isQuali = sType.includes('qualifying') || sType.includes('practice') || !isRace const isQuali = sessionDisplay.isQualifying || !isRace
const columnCount = 7 + (isRace ? 3 : 0) + (isQuali ? 3 : 0)
const isQ1 = sType === 'qualifying 1' || sType.includes('q1')
const isQ2 = sType === 'qualifying 2' || sType.includes('q2')
return ( return (
<div className="scroll-x"> <div className="scroll-x">
<table className="data-table live-tower" style={{ minWidth: 620 }}> <table className="data-table live-tower" style={{ minWidth: 760 }}>
<thead> <thead>
<tr> <tr>
<th>Pos</th> <th>Pos</th>
@@ -95,27 +94,20 @@ export function TimingTower({
const isPinned = pinned?.includes(row.RacingNumber) ?? false const isPinned = pinned?.includes(row.RacingNumber) ?? false
const inBattle = battleNumbers?.has(row.RacingNumber) ?? false const inBattle = battleNumbers?.has(row.RacingNumber) ?? false
const isExpanded = expandedRow === row.RacingNumber const isExpanded = expandedRow === row.RacingNumber
const isAtRisk =
// Knockout zone border Boolean(sessionDisplay.cutoffPosition && row.Position > sessionDisplay.cutoffPosition) ||
let koClass = '' driver.Cutoff
if (isQuali && (isQ1 || isQ2)) { const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition
if (isQ1 && row.Position === 15) koClass = 'ko-line-p15'
if (isQ2 && row.Position === 10) koClass = 'ko-line-p10'
} else if (isQuali) {
if (row.Position === 15) koClass = 'ko-line-p15'
if (row.Position === 10) koClass = 'ko-line-p10'
}
const gapText = gapMode === 'interval' && isRace ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader const gapText = gapMode === 'interval' && isRace ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader
// Render micro sectors
const renderSector = (idx: number) => { const renderSector = (idx: number) => {
const sec = driver.Sectors?.[idx] const sec = driver.Sectors?.[idx]
if (!sec) return '-' if (!sec) return '-'
let sClass = 'mono' let sClass = 'sector-time mono'
if (sec.OverallFastest) sClass = 'mono txt-purple' if (sec.OverallFastest) sClass += ' sector-overall txt-purple'
else if (sec.PersonalFastest) sClass = 'mono txt-green' else if (sec.PersonalFastest) sClass += ' sector-personal txt-green'
else if (sec.Value) sClass = 'mono txt-yellow' else if (sec.Value) sClass += ' sector-active txt-yellow'
return <span className={sClass}>{sec.Value || '-'}</span> return <span className={sClass}>{sec.Value || '-'}</span>
} }
@@ -128,7 +120,8 @@ export function TimingTower({
driver.Retired ? 'retired' : '', driver.Retired ? 'retired' : '',
inBattle ? 'battle-row' : '', inBattle ? 'battle-row' : '',
isPinned ? 'pinned-row' : '', isPinned ? 'pinned-row' : '',
koClass, isAtRisk && !driver.KnockedOut ? 'danger-row' : '',
driver.OnFlyingLap ? 'flying-row' : '',
'interactive-row' 'interactive-row'
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
onClick={() => setExpandedRow(isExpanded ? null : row.RacingNumber)} onClick={() => setExpandedRow(isExpanded ? null : row.RacingNumber)}
@@ -146,6 +139,7 @@ export function TimingTower({
{driver.Retired && <span className="badge badge-out">RET</span>} {driver.Retired && <span className="badge badge-out">RET</span>}
{driver.KnockedOut && <span className="badge badge-knocked">KO</span>} {driver.KnockedOut && <span className="badge badge-knocked">KO</span>}
{driver.Cutoff && !driver.KnockedOut && <span className="badge badge-cutoff">CUT</span>} {driver.Cutoff && !driver.KnockedOut && <span className="badge badge-cutoff">CUT</span>}
{!driver.Cutoff && isAtRisk && !driver.KnockedOut && <span className="badge badge-risk">RISK</span>}
{driver.OnFlyingLap && <span className="badge badge-flying">FL</span>} {driver.OnFlyingLap && <span className="badge badge-flying">FL</span>}
</div> </div>
</td> </td>
@@ -188,7 +182,7 @@ export function TimingTower({
</tr> </tr>
{isExpanded && ( {isExpanded && (
<tr className="expanded-row"> <tr className="expanded-row">
<td colSpan={12} style={{ padding: '16px', background: 'rgba(255,255,255,0.02)', borderBottom: '1px solid var(--border)' }}> <td colSpan={columnCount} style={{ padding: '16px', background: 'rgba(255,255,255,0.02)', borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'flex', gap: '24px', alignItems: 'center' }}> <div style={{ display: 'flex', gap: '24px', alignItems: 'center' }}>
<div> <div>
<div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>STINTS</div> <div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>STINTS</div>
@@ -210,6 +204,17 @@ export function TimingTower({
</td> </td>
</tr> </tr>
)} )}
{showCutoffAfter && (
<tr className="cutoff-separator" data-testid="qualifying-cutoff">
<td colSpan={columnCount}>
<span>{sessionDisplay.phaseLabel || 'Q'} cutoff</span>
<strong>P{sessionDisplay.cutoffPosition} advance</strong>
{sessionDisplay.atRiskStart && sessionDisplay.atRiskEnd && (
<em>P{sessionDisplay.atRiskStart}-P{sessionDisplay.atRiskEnd} at risk</em>
)}
</td>
</tr>
)}
</Fragment> </Fragment>
) )
})} })}

View File

@@ -0,0 +1,145 @@
import { useMemo, useState } from 'react'
import type {
LiveDriverData,
LiveDriverInfo,
LivePosition,
LiveTelemetry,
TrackOutline,
} from '../../types'
import {
buildOutlinePath,
canvasToSvg,
isOnTrack,
normalizeRawPoint,
} from '../../lib/trackmap'
interface Props {
outline?: TrackOutline | null
positions: Record<string, LivePosition>
telemetry?: Record<string, LiveTelemetry>
drivers?: Record<string, LiveDriverData>
driverInfo?: Record<string, LiveDriverInfo>
loading?: boolean
}
export function TrackMap({
outline,
positions,
telemetry = {},
drivers = {},
driverInfo = {},
loading = false,
}: Props) {
const [selected, setSelected] = useState<string | null>(null)
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
const cars = useMemo(() => {
if (!outline?.bounds) return []
return Object.entries(positions)
.map(([number, position]) => {
const canvas = normalizeRawPoint(position, outline.bounds)
return {
number,
position,
svg: canvasToSvg(canvas),
info: driverInfo[number],
driver: drivers[number],
telemetry: telemetry[number],
active: isOnTrack(position.status) && !drivers[number]?.Retired,
}
})
.sort((a, b) => Number(a.number) - Number(b.number))
}, [driverInfo, drivers, outline?.bounds, positions, telemetry])
const selectedCar = selected ? cars.find((car) => car.number === selected) : null
if (loading) {
return (
<section className="live-track-panel" data-testid="track-map">
<div className="sec-header">
<span className="sec-title">Track Map</span>
</div>
<div className="track-map-empty">loading cached circuit outline...</div>
</section>
)
}
if (!outline || !outlinePath) {
return (
<section className="live-track-panel" data-testid="track-map">
<div className="sec-header">
<span className="sec-title">Track Map</span>
</div>
<div className="track-map-empty">track outline unavailable for this live session</div>
</section>
)
}
return (
<section className="live-track-panel" data-testid="track-map">
<div className="sec-header">
<span className="sec-title">Track Map</span>
<span className="sec-meta">{cars.length ? `${cars.length} cars` : 'waiting for GPS'}</span>
</div>
<div className="track-map-stage">
<svg className="track-map-svg" viewBox="0 0 100 100" role="img" aria-label="Live track map">
<path className="track-map-outline-shadow" d={outlinePath} />
<path className="track-map-outline" d={outlinePath} />
{cars.map((car) => {
const label = car.info?.Tla || car.number
return (
<g
key={car.number}
role="button"
tabIndex={0}
aria-label={`${label} telemetry`}
className={`track-car ${car.active ? 'track-car-active' : 'track-car-inactive'} ${selected === car.number ? 'track-car-selected' : ''}`}
transform={`translate(${car.svg.x.toFixed(2)} ${car.svg.y.toFixed(2)})`}
onClick={() => setSelected(car.number)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setSelected(car.number)
}
}}
>
<circle r="2.8" fill={teamColor(car.info)} />
<text y="0.85">{label}</text>
</g>
)
})}
</svg>
{cars.length === 0 && <div className="track-map-empty track-map-overlay">waiting for live GPS</div>}
{selectedCar && (
<div className="track-telemetry" data-testid="track-telemetry">
<div className="track-telemetry-head">
<span className="track-driver-code">{selectedCar.info?.Tla || selectedCar.number}</span>
<span>{selectedCar.position.status || 'OnTrack'}</span>
</div>
<dl>
<Metric label="SPD" value={selectedCar.telemetry?.Speed} suffix="km/h" />
<Metric label="THR" value={selectedCar.telemetry?.Throttle} suffix="%" />
<Metric label="BRK" value={selectedCar.telemetry?.Brake} suffix="%" />
<Metric label="DRS" value={selectedCar.telemetry?.DRS} />
<Metric label="GEAR" value={selectedCar.telemetry?.NGear} />
</dl>
</div>
)}
</div>
</section>
)
}
function Metric({ label, value, suffix = '' }: { label: string; value: number | undefined; suffix?: string }) {
return (
<>
<dt>{label}</dt>
<dd>{value === undefined ? '-' : `${value}${suffix}`}</dd>
</>
)
}
function teamColor(info: LiveDriverInfo | undefined): string {
const raw = info?.TeamColour?.trim()
if (!raw) return '#777777'
return raw.startsWith('#') ? raw : `#${raw}`
}

View File

@@ -0,0 +1,130 @@
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 '../../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
return (
<div className="tyredeg-row" key={row.RacingNumber} data-testid="tyredeg-row">
<span className="tyredeg-pos mono">P{row.Position}</span>
<span className="drv-bar" style={{ background: teamColor(row.Info?.TeamColour) }} />
<span className="drv-code">{driverCode(row)}</span>
<span className={`tyre-badge ${compoundClass(row.Tyre?.Compound)}`}>{tyreLabel(row.Tyre)}</span>
{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>
)
}

View File

@@ -25,7 +25,8 @@ export interface Battle {
export function isRaceSession(sessionType: string | null | undefined): boolean { export function isRaceSession(sessionType: string | null | undefined): boolean {
if (!sessionType) return false if (!sessionType) return false
const type = sessionType.toLowerCase() const type = sessionType.toLowerCase()
return type.includes('race') || type.includes('sprint') const isQualifying = type.includes('qualifying') || type.includes('shootout') || /\bsq\s*[123]\b/.test(type)
return !isQualifying && (type.includes('race') || /\bsprint\b/.test(type))
} }
function isEligible(row: LiveTimingRow): boolean { function isEligible(row: LiveTimingRow): boolean {

112
frontend/src/lib/delta.ts Normal file
View File

@@ -0,0 +1,112 @@
// Cumulative lap-time delta math for driver comparison charts.
// Pure functions only — no React — so everything is unit-testable.
export interface DeltaSeries {
label: string
color: string
/** Lap duration in seconds; null = no time (pit/out lap). */
lapTimes: (number | null)[]
}
export interface DriverDeltaResult {
label: string
color: string
/** Cumulative delta vs reference (seconds); null when that lap has no time. */
deltas: (number | null)[]
}
/** Format a delta value for axis labels and tooltips (e.g. "+2.5s", "-1.2s"). */
export function formatDeltaSeconds(delta: number): string {
const sign = delta >= 0 ? '+' : ''
return `${sign}${delta.toFixed(1)}s`
}
function buildCumulative(lapTimes: ReadonlyArray<number | null>): number[] {
const cumulative: number[] = []
let running = 0
for (const lap of lapTimes) {
if (lap !== null) {
running += lap
}
cumulative.push(running)
}
return cumulative
}
function resolveReference(
series: ReadonlyArray<DeltaSeries>,
referenceLabel?: string,
): DeltaSeries | null {
if (series.length === 0) return null
if (referenceLabel) {
return series.find((s) => s.label === referenceLabel) ?? series[0]
}
return series[0]
}
/**
* 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).
*/
export function computeCumulativeDeltas(
series: ReadonlyArray<DeltaSeries>,
referenceLabel?: string,
): DriverDeltaResult[] {
const reference = resolveReference(series, referenceLabel)
if (!reference) return []
const refCumulative = buildCumulative(reference.lapTimes)
return series
.filter((s) => s.label !== reference.label)
.map((driver) => {
const driverCumulative = buildCumulative(driver.lapTimes)
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) {
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)
}
return {
label: driver.label,
color: driver.color,
deltas,
}
})
}
/** Split delta samples into contiguous SVG polyline point strings (gaps at null laps). */
export function deltaPolylineSegments(
deltas: ReadonlyArray<number | null>,
toPoint: (lapIndex: number, delta: number) => string,
): string[] {
const segments: string[] = []
let current: string[] = []
for (let i = 0; i < deltas.length; i++) {
const value = deltas[i]
if (value === null) {
if (current.length > 0) {
segments.push(current.join(' '))
current = []
}
continue
}
current.push(toPoint(i, value))
}
if (current.length > 0) {
segments.push(current.join(' '))
}
return segments
}

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`
}

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

View File

@@ -2,6 +2,8 @@ import type {
LiveDriverData, LiveDriverData,
LiveDriverInfo, LiveDriverInfo,
LiveRCMessage, LiveRCMessage,
LiveSectorData,
LiveSessionMeta,
LiveStateResponse, LiveStateResponse,
LiveStreamData, LiveStreamData,
LiveTyreData, LiveTyreData,
@@ -15,6 +17,27 @@ export interface LiveTimingRow {
Tyre?: LiveTyreData Tyre?: LiveTyreData
} }
export interface LiveSessionDisplay {
isRace: boolean
isQualifying: boolean
isSprintQualifying: boolean
phase: 1 | 2 | 3 | null
phaseLabel: string
cutoffPosition: number | null
advanceCount: number | null
atRiskStart: number | null
atRiskEnd: number | null
}
export interface VisibleSectorEntry {
lap: number
lastLapTime: string
completed: boolean
sectors: LiveSectorData[]
}
export type VisibleSectorState = Record<string, VisibleSectorEntry>
const TRACK_STATUS_LABELS: Record<string, string> = { const TRACK_STATUS_LABELS: Record<string, string> = {
'1': 'GREEN', '1': 'GREEN',
'2': 'YELLOW', '2': 'YELLOW',
@@ -111,6 +134,136 @@ export function sortLiveTimingRows(snapshot: LiveStreamData | null | undefined):
})) }))
} }
export function liveSessionDisplay(
session: LiveSessionMeta | null | undefined,
rows: ReadonlyArray<LiveTimingRow>,
): LiveSessionDisplay {
const text = [session?.SessionName, session?.SessionType].filter(Boolean).join(' ').toLowerCase()
const isQualifying =
/\bs?q\s*[123]\b/i.test(text) ||
text.includes('qualifying') ||
text.includes('shootout')
const isSprintQualifying =
/\bsq\s*[123]\b/i.test(text) ||
text.includes('sprint qualifying') ||
text.includes('sprint shootout')
const isRace = !isQualifying && (text.includes('race') || /\bsprint\b/i.test(text))
const phase = isQualifying ? explicitQualifyingPhase(text) ?? inferredQualifyingPhase(rows) : null
const prefix = isSprintQualifying ? 'SQ' : 'Q'
const cutoffPosition = qualifyingCutoffPosition(rows.length, phase)
const atRiskStart = cutoffPosition === null ? null : cutoffPosition + 1
return {
isRace,
isQualifying,
isSprintQualifying,
phase,
phaseLabel: phase ? `${prefix}${phase}` : isQualifying ? prefix : '',
cutoffPosition,
advanceCount: cutoffPosition,
atRiskStart,
atRiskEnd: cutoffPosition === null ? null : rows.length,
}
}
function explicitQualifyingPhase(text: string): 1 | 2 | 3 | null {
const match = text.match(/\b(?:s?q|qualifying)\s*([123])\b/i)
if (!match) return null
const phase = Number(match[1])
return phase === 1 || phase === 2 || phase === 3 ? phase : null
}
function inferredQualifyingPhase(rows: ReadonlyArray<LiveTimingRow>): 1 | 2 | 3 {
if (rows.length === 0) return 1
const knockedOut = rows.filter((row) => row.Driver.KnockedOut).length
if (knockedOut >= Math.max(0, rows.length - 10)) return 3
if (knockedOut >= 5) return 2
if (rows.length <= 10) return 3
if (rows.length <= 18) return 2
return 1
}
export function qualifyingCutoffPosition(totalRows: number, phase: 1 | 2 | 3 | null): number | null {
if (phase === 1 && totalRows > 5) return totalRows - 5
if (phase === 2 && totalRows > 10) return 10
return null
}
function emptySector(): LiveSectorData {
return { Value: '', PersonalFastest: false, OverallFastest: false }
}
function sectorHasValue(sector: LiveSectorData | undefined): boolean {
return Boolean(sector?.Value)
}
function normalizeSectors(sectors: ReadonlyArray<LiveSectorData> | undefined): LiveSectorData[] {
return [0, 1, 2].map((index) => sectors?.[index] ?? emptySector())
}
export function mergeVisibleSectors(
previous: VisibleSectorState,
rows: ReadonlyArray<LiveTimingRow>,
): VisibleSectorState {
const next: VisibleSectorState = {}
for (const row of rows) {
const driver = row.Driver
const prior = previous[row.RacingNumber]
const incoming = normalizeSectors(driver.Sectors)
const hasIncoming = incoming.some(sectorHasValue)
const lap = driver.NumberOfLaps || prior?.lap || 0
const lapAdvanced = Boolean(prior && driver.NumberOfLaps > prior.lap)
const lastLapChanged = Boolean(
prior &&
driver.LastLapTime &&
driver.LastLapTime !== prior.lastLapTime,
)
if ((lapAdvanced || lastLapChanged || prior?.completed) && !hasIncoming) {
continue
}
if (!prior && !hasIncoming) continue
const merged = lapAdvanced || lastLapChanged ? normalizeSectors(undefined) : normalizeSectors(prior?.sectors)
for (let index = 0; index < 3; index += 1) {
if (sectorHasValue(incoming[index])) {
merged[index] = incoming[index]
}
}
const hasMerged = merged.some(sectorHasValue)
if (!hasMerged) continue
next[row.RacingNumber] = {
lap,
lastLapTime: driver.LastLapTime || prior?.lastLapTime || '',
completed: sectorHasValue(merged[2]) || (!driver.OnFlyingLap && lastLapChanged),
sectors: merged,
}
}
return next
}
export function rowsWithVisibleSectors(
rows: ReadonlyArray<LiveTimingRow>,
visibleSectors: VisibleSectorState,
): LiveTimingRow[] {
return rows.map((row) => {
const sectors = visibleSectors[row.RacingNumber]?.sectors
if (!sectors) return row
return {
...row,
Driver: {
...row.Driver,
Sectors: sectors,
},
}
})
}
export function driverCode(row: LiveTimingRow): string { export function driverCode(row: LiveTimingRow): string {
return row.Info?.Tla || row.RacingNumber return row.Info?.Tla || row.RacingNumber
} }

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}`
}

View File

@@ -0,0 +1,66 @@
import type { LivePosition, TrackBounds, TrackPoint } from '../types'
export interface CanvasPoint {
x: number
y: number
}
const VIEWBOX_SIZE = 100
export function normalizeRawPoint(
point: Pick<LivePosition, 'x' | 'y'>,
bounds: TrackBounds,
): CanvasPoint {
return {
x: normalizeAxis(point.x, bounds.minX, bounds.maxX),
y: 1 - normalizeAxis(point.y, bounds.minY, bounds.maxY),
}
}
export function outlinePointToCanvas(point: TrackPoint): CanvasPoint {
return {
x: clamp01(point.x),
y: 1 - clamp01(point.y),
}
}
export function buildOutlinePath(points: ReadonlyArray<TrackPoint>): string {
if (points.length < 2) return ''
return points
.map((point, index) => {
const canvas = outlinePointToCanvas(point)
const command = index === 0 ? 'M' : 'L'
return `${command} ${formatSvgCoord(canvas.x)} ${formatSvgCoord(canvas.y)}`
})
.join(' ')
}
export function canvasToSvg(point: CanvasPoint): CanvasPoint {
return {
x: clamp01(point.x) * VIEWBOX_SIZE,
y: clamp01(point.y) * VIEWBOX_SIZE,
}
}
export function isOnTrack(status: string | null | undefined): boolean {
if (!status) return true
const normalized = status.toLowerCase()
return normalized === 'ontrack' || normalized === 'on-track' || normalized === 'on_track'
}
function normalizeAxis(value: number, min: number, max: number): number {
const range = max - min
if (!Number.isFinite(value) || !Number.isFinite(min) || !Number.isFinite(max)) return 0.5
if (range === 0) return 0.5
return clamp01((value - min) / range)
}
function clamp01(value: number): number {
if (!Number.isFinite(value)) return 0.5
return Math.min(1, Math.max(0, value))
}
function formatSvgCoord(value: number): string {
return (value * VIEWBOX_SIZE).toFixed(2)
}

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,38 +1,38 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useQueries, useQuery } from '@tanstack/react-query' import { useQueries, useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router' 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 { RACE_HUB_DATASETS, countWeekendStats, formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
import { countryAccent, countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
import { classifySessionStatus, heroState } from '../lib/hero'
import { import {
currentAndNextSession, currentAndNextSession,
focusMeetingKind, focusMeetingKind,
formatCountdown,
formatSessionScheduleTime, formatSessionScheduleTime,
meetingHasStarted, meetingHasStarted,
mostRecentPastMeeting,
nextUpcomingMeeting,
pickFocusMeeting, pickFocusMeeting,
sessionEndTime,
sessionStartTime,
sortSessionsByStart, sortSessionsByStart,
} from '../lib/schedule' } from '../lib/schedule'
import { countryAccent, countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
import type { Meeting, Session, Weekend, WeekendSession } from '../types' import type { Meeting, Session, Weekend, WeekendSession } from '../types'
import { PaddockBriefing } from '../components/PaddockBriefing' import { Trophy } from 'lucide-react'
import { Play, Activity, Calendar, Trophy } from 'lucide-react'
type WeekendStatusKind = 'live' | 'current' | 'next' | 'recent' | 'fallback'
const missingDatasets = Object.fromEntries( const missingDatasets = Object.fromEntries(
RACE_HUB_DATASETS.map((dataset) => [dataset, { status: 'missing', source: 'none', count: 0 }]), RACE_HUB_DATASETS.map((dataset) => [dataset, { status: 'missing', source: 'none', count: 0 }]),
) as WeekendSession['datasets'] ) 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) { function meetingStatus(meeting: Meeting, focusKey: number | undefined, now: Date) {
if (meeting.meeting_key === focusKey) return 'focus' if (meeting.meeting_key === focusKey) return 'focus'
if (meetingHasStarted(meeting, now)) return 'past' if (meetingHasStarted(meeting, now)) return 'past'
@@ -148,7 +148,36 @@ export function CommandCenterPage() {
focusWeekendSessions[0]?.session focusWeekendSessions[0]?.session
const analysisSessionKey = actionSession?.session_key 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) { if (seasonsQuery.isLoading) {
return <div className="page loading-state">loading command center</div> 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 accent = countryAccent(focusMeeting ?? null)
const decal = countryDecal(focusMeeting ?? null)
const accentStyle = { '--gp-accent': accent } as React.CSSProperties const accentStyle = { '--gp-accent': accent } as React.CSSProperties
return ( return (
@@ -224,76 +241,25 @@ export function CommandCenterPage() {
</div> </div>
)} )}
{focusMeeting && ( {focusMeeting && focusKind && (
<div className="cc-hero"> <CommandCenterHero
<section className="cc-weekend-band ui-card glass-panel" data-testid="cc-focus"> state={heroStateKind}
<div className="cc-band-accent" aria-hidden="true" /> now={nowDate}
<div className="cc-band-body"> accent={accent}
<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} liveActive={liveActive}
liveData={liveQuery.data?.data}
focusMeeting={focusMeeting}
focusKind={focusKind}
sessions={focusWeekendSessions}
currentSession={currentSession} currentSession={currentSession}
nextSession={nextSession} nextSession={nextSession}
meeting={focusMeeting} analysisSessionKey={analysisSessionKey}
now={nowDate} analysisSessionName={actionSession?.session_name}
lastRaceName={lastRaceName}
lastRacePodium={lastRacePodium}
lastRaceSessionKey={lastRaceSessionKey}
nextMeeting={nextMeetingForHero}
/> />
</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>
)} )}
<div className="cc-dashboard-grid"> <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

@@ -1,23 +1,32 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { fetchLiveState } from '../api' import { fetchLiveState, fetchLiveTrackOutline } from '../api'
import type { LiveStreamData } from '../types' import type { LivePosition, LiveStreamData } from '../types'
import { import {
loadPinnedDrivers, loadPinnedDrivers,
mergeVisibleSectors,
parseLiveStateEvent, parseLiveStateEvent,
rowsWithVisibleSectors,
savePinnedDrivers, savePinnedDrivers,
sortLiveTimingRows, sortLiveTimingRows,
togglePin, togglePin,
} from '../lib/live' } from '../lib/live'
import type { VisibleSectorState } from '../lib/live'
import type { GapHistoryMap } from '../lib/gapHistory' import type { GapHistoryMap } from '../lib/gapHistory'
import { recordGapSamples } from '../lib/gapHistory' import { recordGapSamples } from '../lib/gapHistory'
import { battleNumbers, detectBattles } from '../lib/battles' 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 { SessionBanner } from '../components/live/SessionBanner'
import { TrackStatusBanner } from '../components/live/TrackStatusBanner' import { TrackStatusBanner } from '../components/live/TrackStatusBanner'
import { TimingTower } from '../components/live/TimingTower' import { TimingTower } from '../components/live/TimingTower'
import { BattleChips } from '../components/live/BattleChips' import { BattleChips } from '../components/live/BattleChips'
import { PinnedDrivers } from '../components/live/PinnedDrivers' import { PinnedDrivers } from '../components/live/PinnedDrivers'
import { RaceControlFeed } from '../components/live/RaceControlFeed' import { RaceControlFeed } from '../components/live/RaceControlFeed'
import { EventRail } from '../components/live/EventRail'
import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
import { TrackMap } from '../components/live/TrackMap'
import { TyreDegPanel } from '../components/live/TyreDegPanel'
import { Radio } from 'lucide-react' import { Radio } from 'lucide-react'
type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error' type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
@@ -29,6 +38,11 @@ export function LiveTimingPage() {
const [now, setNow] = useState(Date.now()) const [now, setNow] = useState(Date.now())
const [gapHistory, setGapHistory] = useState<GapHistoryMap>({}) const [gapHistory, setGapHistory] = useState<GapHistoryMap>({})
const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers()) 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 { data, isLoading, isError, error } = useQuery({ const { data, isLoading, isError, error } = useQuery({
queryKey: ['live-state'], queryKey: ['live-state'],
@@ -36,6 +50,18 @@ export function LiveTimingPage() {
staleTime: 5_000, 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(() => { useEffect(() => {
if (!data) return if (!data) return
setIsLive(data.is_live) setIsLive(data.is_live)
@@ -73,6 +99,17 @@ export function LiveTimingPage() {
if (!cancelled) setStreamStatus('connected') if (!cancelled) setStreamStatus('connected')
}) })
events.addEventListener('positions', (event) => {
if (cancelled) return
try {
const parsed = JSON.parse(event.data) as Record<string, LivePosition>
setPositions(parsed && typeof parsed === 'object' ? parsed : {})
setStreamStatus('connected')
} catch {
// Ignore malformed transient frames; the next 4Hz update will replace it.
}
})
events.onerror = () => { events.onerror = () => {
if (!cancelled) setStreamStatus('disconnected') if (!cancelled) setStreamStatus('disconnected')
} }
@@ -83,7 +120,34 @@ export function LiveTimingPage() {
} }
}, []) }, [])
const rows = useMemo(() => sortLiveTimingRows(snapshot), [snapshot]) // 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(() => {
if (rawRows.length === 0) {
setVisibleSectors({})
return
}
setVisibleSectors((prev) => mergeVisibleSectors(prev, rawRows))
}, [rawRows])
const rows = useMemo(() => rowsWithVisibleSectors(rawRows, visibleSectors), [rawRows, visibleSectors])
// One interval sample per received snapshot, ring-buffered per driver. // One interval sample per received snapshot, ring-buffered per driver.
useEffect(() => { useEffect(() => {
@@ -143,9 +207,18 @@ export function LiveTimingPage() {
{snapshot && ( {snapshot && (
<> <>
<SessionBanner isLive={isLive} snapshot={snapshot} connection={streamStatus} now={now} /> <SessionBanner isLive={isLive} snapshot={snapshot} rows={rows} connection={streamStatus} now={now} />
<TrackStatusBanner status={snapshot.TrackStatus} /> <TrackStatusBanner status={snapshot.TrackStatus} />
<PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} /> <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-columns">
<div className="live-tower-col"> <div className="live-tower-col">
<div className="sec-header"> <div className="sec-header">
@@ -160,11 +233,17 @@ export function LiveTimingPage() {
battleNumbers={inBattle} battleNumbers={inBattle}
pinned={pinned} pinned={pinned}
onTogglePin={handleTogglePin} onTogglePin={handleTogglePin}
sessionType={snapshot.Session?.SessionType} session={snapshot.Session}
/> />
</div> </div>
<div className="live-rc-col"> <div className="live-rc-col">
<TeamRadioTicker
captures={snapshot.TeamRadio ?? []}
driverInfo={snapshot.DriverInfo}
session={snapshot.Session}
/>
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} /> <RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
</div> </div>
</div> </div>
</> </>

View File

@@ -539,12 +539,17 @@ a { color: inherit; text-decoration: none; }
} }
/* ── Live timing ── */ /* ── Live timing ── */
.live-page { max-width: 1120px; } .live-page { max-width: 1320px; }
.live-banner { .live-banner {
padding-bottom: var(--s4); padding: var(--s4) var(--s5);
border-bottom: 1px solid var(--border); border: 1px solid rgba(255, 255, 255, 0.08);
border-left: 3px solid var(--red);
border-radius: 8px;
margin-bottom: var(--s5); margin-bottom: var(--s5);
background:
linear-gradient(90deg, rgba(225, 6, 0, 0.12), transparent 28%),
rgba(255, 255, 255, 0.025);
} }
.live-banner-row { .live-banner-row {
@@ -562,7 +567,7 @@ a { color: inherit; text-decoration: none; }
} }
.live-banner h1 { .live-banner h1 {
font-size: 18px; font-size: 24px;
line-height: 1.2; line-height: 1.2;
} }
@@ -571,12 +576,51 @@ a { color: inherit; text-decoration: none; }
font-size: 12px; font-size: 12px;
} }
.live-session-board {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--s4);
flex-wrap: wrap;
text-align: right;
}
.live-clock {
min-width: 148px;
color: var(--text);
font-size: 32px;
font-weight: 800;
line-height: 1;
letter-spacing: 0;
text-align: right;
}
.live-phase-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 54px;
height: 44px;
padding: 0 var(--s3);
border: 1px solid rgba(255, 214, 0, 0.36);
border-radius: 4px;
background: rgba(255, 214, 0, 0.14);
color: var(--yellow);
font-size: 18px;
font-weight: 900;
}
.live-banner-meta { .live-banner-meta {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: var(--s3); gap: var(--s2);
flex-wrap: wrap; flex-wrap: wrap;
max-width: 280px;
color: var(--text-2);
font-size: 11px;
font-family: var(--f-mono);
text-transform: uppercase;
} }
.live-weather-strip { .live-weather-strip {
@@ -605,10 +649,145 @@ a { color: inherit; text-decoration: none; }
gap: var(--s5); gap: var(--s5);
} }
.live-track-panel {
margin-bottom: var(--s5);
}
.track-map-stage {
position: relative;
min-height: 360px;
border: 1px solid var(--border);
background: var(--surface);
overflow: hidden;
}
.track-map-svg {
display: block;
width: 100%;
height: 360px;
}
.track-map-outline-shadow,
.track-map-outline {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.track-map-outline-shadow {
stroke: rgba(255, 255, 255, 0.08);
stroke-width: 5;
}
.track-map-outline {
stroke: var(--surface-3);
stroke-width: 2.4;
}
.track-car {
cursor: pointer;
outline: none;
}
.track-car circle {
stroke: #050505;
stroke-width: 0.7;
transition: r 0.12s, opacity 0.12s, stroke 0.12s;
}
.track-car text {
fill: #fff;
font-family: var(--f-mono);
font-size: 2.1px;
font-weight: 800;
pointer-events: none;
text-anchor: middle;
paint-order: stroke;
stroke: rgba(0, 0, 0, 0.85);
stroke-width: 0.7;
}
.track-car:hover circle,
.track-car:focus-visible circle,
.track-car-selected circle {
r: 3.7;
stroke: #fff;
}
.track-car-inactive {
opacity: 0.35;
filter: grayscale(0.8);
}
.track-map-empty {
display: grid;
min-height: 160px;
place-items: center;
color: var(--text-3);
font-family: var(--f-mono);
font-size: 11px;
text-transform: uppercase;
}
.track-map-overlay {
position: absolute;
inset: 0;
pointer-events: none;
}
.track-telemetry {
position: absolute;
right: var(--s5);
bottom: var(--s5);
width: min(260px, calc(100% - 32px));
padding: var(--s4);
border: 1px solid var(--border-2);
background: rgba(10, 10, 10, 0.88);
backdrop-filter: blur(10px);
}
.track-telemetry-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s4);
margin-bottom: var(--s3);
color: var(--text-3);
font-family: var(--f-mono);
font-size: 10px;
text-transform: uppercase;
}
.track-driver-code {
color: var(--text);
font-size: 13px;
font-weight: 800;
}
.track-telemetry dl {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--s3);
}
.track-telemetry dt {
color: var(--text-3);
font-family: var(--f-mono);
font-size: 9px;
}
.track-telemetry dd {
color: var(--text);
font-family: var(--f-mono);
font-size: 12px;
font-weight: 700;
min-width: 0;
}
@media (min-width: 900px) { @media (min-width: 900px) {
.live-columns { .live-columns {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 340px; grid-template-columns: minmax(0, 1fr) 318px;
gap: var(--s5); gap: var(--s5);
align-items: start; align-items: start;
} }
@@ -669,7 +848,7 @@ a { color: inherit; text-decoration: none; }
.live-tower { .live-tower {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
border-collapse: separate; border-collapse: separate;
border-spacing: 0 4px; border-spacing: 0 3px;
} }
.live-tower th { .live-tower th {
border-bottom: none !important; border-bottom: none !important;
@@ -699,12 +878,55 @@ a { color: inherit; text-decoration: none; }
.live-tower .in-pit td { background: rgba(0, 80, 160, 0.2) !important; } .live-tower .in-pit td { background: rgba(0, 80, 160, 0.2) !important; }
.live-tower .pit-out td { background: rgba(57, 199, 58, 0.15) !important; } .live-tower .pit-out td { background: rgba(57, 199, 58, 0.15) !important; }
.live-tower .retired td { opacity: 0.5; filter: grayscale(80%); } .live-tower .retired td { opacity: 0.5; filter: grayscale(80%); }
.live-tower .danger-row td {
background: rgba(225, 6, 0, 0.095);
}
.live-tower .danger-row td:first-child {
box-shadow: inset 3px 0 0 rgba(225, 6, 0, 0.78);
}
.live-tower .flying-row td {
background: rgba(194, 120, 255, 0.055);
}
.live-tower .flying-row td:first-child {
box-shadow: inset 3px 0 0 rgba(194, 120, 255, 0.68);
}
.live-tower .danger-row.flying-row td:first-child {
box-shadow: inset 3px 0 0 rgba(225, 6, 0, 0.78), inset 6px 0 0 rgba(194, 120, 255, 0.68);
}
.cutoff-separator td {
padding: 6px var(--s3) !important;
background: transparent !important;
border-radius: 0 !important;
border-top: 1px dashed rgba(225, 6, 0, 0.78) !important;
border-bottom: none !important;
color: #ff8a8a;
font-size: 10px;
font-family: var(--f-mono);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.cutoff-separator span,
.cutoff-separator strong,
.cutoff-separator em {
margin-right: var(--s4);
font-style: normal;
}
.cutoff-separator strong {
color: var(--text);
}
.cutoff-separator em {
color: #ffb0b0;
}
/* Race Control Panel & Animations */ /* Race Control Panel & Animations */
.panel-glass { .panel-glass {
background: rgba(20, 20, 20, 0.6); background: rgba(20, 20, 20, 0.6);
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px; border-radius: 8px;
padding: 16px; padding: 16px;
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
max-height: calc(100vh - 120px); max-height: calc(100vh - 120px);
@@ -789,6 +1011,7 @@ a { color: inherit; text-decoration: none; }
.badge-flying { background: rgba(194,120,255,.14); color: var(--purple); border: 1px solid rgba(194,120,255,.26); } .badge-flying { background: rgba(194,120,255,.14); color: var(--purple); border: 1px solid rgba(194,120,255,.26); }
.badge-knocked { background: rgba(225,6,0,.12); color: #ff6b6b; border: 1px solid rgba(225,6,0,.24); } .badge-knocked { background: rgba(225,6,0,.12); color: #ff6b6b; border: 1px solid rgba(225,6,0,.24); }
.badge-cutoff { background: rgba(255,214,0,.12); color: var(--yellow); border: 1px solid rgba(255,214,0,.25); } .badge-cutoff { background: rgba(255,214,0,.12); color: var(--yellow); border: 1px solid rgba(255,214,0,.25); }
.badge-risk { background: rgba(225,6,0,.14); color: #ff8a8a; border: 1px solid rgba(225,6,0,.28); }
.tyre-soft { background: var(--tyre-soft); color: #fff; } .tyre-soft { background: var(--tyre-soft); color: #fff; }
.tyre-medium { background: var(--tyre-medium); color: #111; } .tyre-medium { background: var(--tyre-medium); color: #111; }
@@ -867,6 +1090,24 @@ a { color: inherit; text-decoration: none; }
.spark-cell { line-height: 0; } .spark-cell { line-height: 0; }
.sector-time {
display: inline-flex;
align-items: center;
justify-content: flex-end;
min-width: 58px;
padding: 2px 5px;
border-radius: 3px;
}
.sector-active {
background: rgba(255, 214, 0, 0.08);
}
.sector-personal {
background: rgba(57, 199, 58, 0.1);
}
.sector-overall {
background: rgba(194, 120, 255, 0.12);
}
/* ── Battle highlighting ── */ /* ── Battle highlighting ── */
.live-tower tr.battle-row td { background: rgba(255,214,0,.045); } .live-tower tr.battle-row td { background: rgba(255,214,0,.045); }
.live-tower tr.battle-row td:first-child { box-shadow: inset 2px 0 0 rgba(255,214,0,.55); } .live-tower tr.battle-row td:first-child { box-shadow: inset 2px 0 0 rgba(255,214,0,.55); }
@@ -2971,11 +3212,27 @@ a { color: inherit; text-decoration: none; }
align-items: flex-start; align-items: flex-start;
gap: var(--s3); gap: var(--s3);
} }
.live-banner-meta { .live-session-board {
justify-content: flex-start; justify-content: flex-start;
text-align: left;
width: 100%; width: 100%;
} }
.live-banner h1 { font-size: 16px; } .live-clock {
min-width: 0;
font-size: 26px;
text-align: left;
}
.live-phase-pill {
min-width: 48px;
height: 38px;
font-size: 16px;
}
.live-banner-meta {
justify-content: flex-start;
max-width: none;
width: 100%;
}
.live-banner h1 { font-size: 19px; }
.live-weather-strip { gap: var(--s3); } .live-weather-strip { gap: var(--s3); }
.live-rc-scroll { max-height: 220px; } .live-rc-scroll { max-height: 220px; }

View File

@@ -0,0 +1,76 @@
.delta-graph {
width: 100%;
min-width: 280px;
max-width: 640px;
}
.delta-graph-svg {
display: block;
width: 100%;
}
.delta-graph-empty {
padding: 1.5rem 1rem;
text-align: center;
color: var(--text-3);
font-size: 0.875rem;
}
.delta-graph-zero-line {
stroke: var(--text-3);
stroke-width: 1;
stroke-dasharray: 4 3;
}
.delta-graph-grid-line {
stroke: var(--border);
stroke-width: 0.5;
}
.delta-graph-axis-label {
fill: var(--text-3);
font-family: var(--f-mono);
font-size: 9px;
}
.delta-graph-driver-line {
fill: none;
stroke-width: 2;
stroke-linejoin: round;
stroke-linecap: round;
}
.delta-graph-crosshair {
stroke: var(--text-2);
stroke-width: 1;
stroke-dasharray: 3 3;
pointer-events: none;
}
.delta-graph-hover-layer {
cursor: crosshair;
touch-action: none;
}
.delta-graph-tooltip {
pointer-events: none;
}
.delta-graph-tooltip-bg {
fill: var(--bg-elevated, var(--bg));
stroke: var(--border);
stroke-width: 1;
}
.delta-graph-tooltip-title {
fill: var(--text-1);
font-family: var(--f-mono);
font-size: 10px;
font-weight: 600;
}
.delta-graph-tooltip-row {
fill: var(--text-2);
font-family: var(--f-mono);
font-size: 9px;
}

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,94 @@
.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__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

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

View File

@@ -7,9 +7,10 @@ import { BattleChips } from '../components/live/BattleChips'
import { GapSparkline } from '../components/live/GapSparkline' import { GapSparkline } from '../components/live/GapSparkline'
import { PinnedDrivers } from '../components/live/PinnedDrivers' import { PinnedDrivers } from '../components/live/PinnedDrivers'
import { TimingTower } from '../components/live/TimingTower' import { TimingTower } from '../components/live/TimingTower'
import { TrackMap } from '../components/live/TrackMap'
import { detectBattles } from '../lib/battles' import { detectBattles } from '../lib/battles'
import type { LiveTimingRow } from '../lib/live' import type { LiveTimingRow } from '../lib/live'
import type { LiveDriverData, LiveWeatherData } from '../types' import type { LiveDriverData, LiveWeatherData, TrackOutline } from '../types'
function makeRow( function makeRow(
number: string, number: string,
@@ -187,6 +188,29 @@ describe('TimingTower', () => {
render(<TimingTower rows={[]} />) render(<TimingTower rows={[]} />)
expect(screen.getByText(/no driver timing rows/i)).toBeInTheDocument() expect(screen.getByText(/no driver timing rows/i)).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}`),
)
render(
<TimingTower
rows={sprintRows}
session={{
MeetingName: 'British Grand Prix',
CircuitName: 'Silverstone',
SessionType: 'Sprint Qualifying',
SessionName: 'Sprint Qualifying',
Path: '',
}}
/>,
)
expect(screen.getByTestId('qualifying-cutoff')).toHaveTextContent('SQ1 cutoff')
expect(screen.getByTestId('qualifying-cutoff')).toHaveTextContent('P17 advance')
expect(screen.getByText('D18').closest('tr')).toHaveClass('danger-row')
expect(screen.getByText('D17').closest('tr')).not.toHaveClass('danger-row')
})
}) })
describe('PinnedDrivers', () => { describe('PinnedDrivers', () => {
@@ -219,3 +243,66 @@ describe('PinnedDrivers', () => {
expect(screen.queryByTestId('pinned-strip')).not.toBeInTheDocument() expect(screen.queryByTestId('pinned-strip')).not.toBeInTheDocument()
}) })
}) })
describe('TrackMap', () => {
const outline: TrackOutline = {
circuit_key: 9,
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
points: [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: 1 },
{ x: 0, y: 1 },
],
}
it('renders car dots and opens mini telemetry on tap', () => {
render(
<TrackMap
outline={outline}
positions={{
'4': { x: 50, y: 25, z: 0, status: 'OnTrack' },
'81': { x: 25, y: 75, z: 0, status: 'OffTrack' },
}}
telemetry={{
'4': { Speed: 302, Throttle: 88, Brake: 0, DRS: 10, NGear: 8, RPM: 11111 },
}}
driverInfo={{
'4': {
RacingNumber: '4',
BroadcastName: 'L NORRIS',
Tla: 'NOR',
TeamName: 'McLaren',
TeamColour: 'ff8000',
FirstName: 'Lando',
LastName: 'Norris',
},
'81': {
RacingNumber: '81',
BroadcastName: 'O PIASTRI',
Tla: 'PIA',
TeamName: 'McLaren',
TeamColour: 'ff8000',
FirstName: 'Oscar',
LastName: 'Piastri',
},
}}
/>,
)
expect(screen.getByTestId('track-map')).toHaveTextContent('2 cars')
fireEvent.click(screen.getByRole('button', { name: /NOR telemetry/i }))
const readout = screen.getByTestId('track-telemetry')
expect(readout).toHaveTextContent('NOR')
expect(readout).toHaveTextContent('302km/h')
expect(readout).toHaveTextContent('88%')
expect(readout).toHaveTextContent('DRS')
expect(readout).toHaveTextContent('10')
expect(screen.getByRole('button', { name: /PIA telemetry/i })).toHaveClass('track-car-inactive')
})
it('renders an empty state without outline data', () => {
render(<TrackMap outline={null} positions={{}} />)
expect(screen.getByTestId('track-map')).toHaveTextContent(/track outline unavailable/i)
})
})

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,99 @@
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('warming up')
})
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

@@ -39,6 +39,7 @@ describe('isRaceSession', () => {
it('only treats race and sprint sessions as races', () => { it('only treats race and sprint sessions as races', () => {
expect(isRaceSession('Race')).toBe(true) expect(isRaceSession('Race')).toBe(true)
expect(isRaceSession('Sprint')).toBe(true) expect(isRaceSession('Sprint')).toBe(true)
expect(isRaceSession('Sprint Qualifying')).toBe(false)
expect(isRaceSession('Qualifying')).toBe(false) expect(isRaceSession('Qualifying')).toBe(false)
expect(isRaceSession('Practice')).toBe(false) expect(isRaceSession('Practice')).toBe(false)
expect(isRaceSession('')).toBe(false) expect(isRaceSession('')).toBe(false)

View File

@@ -0,0 +1,121 @@
import { describe, expect, it, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { DeltaTimeGraph } from '../components/charts/DeltaTimeGraph'
import {
computeCumulativeDeltas,
deltaPolylineSegments,
formatDeltaSeconds,
type DeltaSeries,
} from '../lib/delta'
const reference: DeltaSeries = {
label: 'VER',
color: '#3671C6',
lapTimes: [90, 91, 92],
}
const challenger: DeltaSeries = {
label: 'HAM',
color: '#E8002D',
lapTimes: [89, 92, 90],
}
describe('formatDeltaSeconds', () => {
it('formats signed deltas with one decimal and s suffix', () => {
expect(formatDeltaSeconds(2.5)).toBe('+2.5s')
expect(formatDeltaSeconds(-1.2)).toBe('-1.2s')
expect(formatDeltaSeconds(0)).toBe('+0.0s')
})
})
describe('computeCumulativeDeltas', () => {
it('computes cumulative delta vs the first series by default', () => {
const result = computeCumulativeDeltas([reference, challenger])
expect(result).toHaveLength(1)
expect(result[0].label).toBe('HAM')
// Lap 1: 89-90=-1, Lap 2: (89+92)-(90+91)=0, Lap 3: (89+92+90)-(90+91+92)=-2
expect(result[0].deltas[0]).toBeCloseTo(-1)
expect(result[0].deltas[1]).toBeCloseTo(0)
expect(result[0].deltas[2]).toBeCloseTo(-2)
})
it('respects an explicit reference label', () => {
const result = computeCumulativeDeltas([reference, challenger], 'HAM')
expect(result).toHaveLength(1)
expect(result[0].label).toBe('VER')
expect(result[0].deltas[0]).toBeCloseTo(1)
expect(result[0].deltas[2]).toBeCloseTo(2)
})
it('emits null for missing lap times while carrying cumulative forward', () => {
const withNull: DeltaSeries = {
label: 'NOR',
color: '#FF8000',
lapTimes: [88, null, 93],
}
const result = computeCumulativeDeltas([reference, withNull])
expect(result[0].deltas[0]).toBeCloseTo(-2)
expect(result[0].deltas[1]).toBeNull()
// After null: NOR cum=181, VER cum=273 → delta -92
expect(result[0].deltas[2]).toBeCloseTo(-92)
})
it('returns an empty array when only one series is provided', () => {
expect(computeCumulativeDeltas([reference])).toEqual([])
})
})
describe('deltaPolylineSegments', () => {
it('splits polylines at null laps', () => {
const segments = deltaPolylineSegments(
[1, null, 2],
(lap, delta) => `${lap},${delta}`,
)
expect(segments).toEqual(['0,1', '2,2'])
})
})
describe('DeltaTimeGraph', () => {
it('renders an empty state with no series', () => {
render(<DeltaTimeGraph series={[]} />)
expect(screen.getByTestId('delta-time-graph-empty')).toBeInTheDocument()
expect(screen.getByText(/No lap data/i)).toBeInTheDocument()
})
it('renders an empty state with only the reference driver', () => {
render(<DeltaTimeGraph series={[reference]} />)
expect(screen.getByTestId('delta-time-graph-empty')).toBeInTheDocument()
expect(screen.getByText(/at least two drivers/i)).toBeInTheDocument()
})
it('renders a zero line and one polyline per non-reference driver', () => {
const { container } = render(<DeltaTimeGraph series={[reference, challenger]} />)
expect(screen.getByTestId('delta-time-graph')).toBeInTheDocument()
expect(container.querySelector('[data-testid="delta-zero-line"]')).toBeInTheDocument()
expect(screen.getByTestId('delta-line-HAM')).toBeInTheDocument()
expect(container.querySelectorAll('.delta-graph-driver-line')).toHaveLength(1)
expect(screen.queryByTestId('delta-line-VER')).not.toBeInTheDocument()
})
it('shows a crosshair tooltip on hover', () => {
vi.spyOn(SVGSVGElement.prototype, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
width: 640,
height: 220,
right: 640,
bottom: 220,
toJSON: () => ({}),
})
const { container } = render(<DeltaTimeGraph series={[reference, challenger]} />)
const hoverLayer = container.querySelector('.delta-graph-hover-layer')
expect(hoverLayer).toBeTruthy()
fireEvent.mouseMove(hoverLayer!, { clientX: 44, clientY: 100 })
expect(screen.getByTestId('delta-crosshair')).toBeInTheDocument()
expect(screen.getByTestId('delta-tooltip')).toBeInTheDocument()
expect(screen.getByText(/Lap 1/)).toBeInTheDocument()
vi.restoreAllMocks()
})
})

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

@@ -4,10 +4,13 @@ import {
compoundLetter, compoundLetter,
extrapolateClock, extrapolateClock,
latestRaceControl, latestRaceControl,
liveSessionDisplay,
loadPinnedDrivers, loadPinnedDrivers,
mergeVisibleSectors,
positionDeltaClass, positionDeltaClass,
parseLiveStateEvent, parseLiveStateEvent,
rcFlagClass, rcFlagClass,
rowsWithVisibleSectors,
savePinnedDrivers, savePinnedDrivers,
sortLiveTimingRows, sortLiveTimingRows,
togglePin, togglePin,
@@ -17,7 +20,8 @@ import {
tyreLabel, tyreLabel,
windDirectionLabel, windDirectionLabel,
} from '../lib/live' } from '../lib/live'
import type { LiveStreamData } from '../types' import type { LiveDriverData, LiveSectorData, LiveStreamData } from '../types'
import type { LiveTimingRow } from '../lib/live'
const snapshot: LiveStreamData = { const snapshot: LiveStreamData = {
Drivers: { Drivers: {
@@ -96,7 +100,8 @@ const snapshot: LiveStreamData = {
{ Time: '14:08', Category: 'Drs', Flag: '', Message: 'DRS ENABLED', Lap: 3 }, { Time: '14:08', Category: 'Drs', Flag: '', Message: 'DRS ENABLED', Lap: 3 },
], ],
Weather: { AirTemp: 20, TrackTemp: 31, Humidity: 55, WindSpeed: 2, WindDir: 180, Rainfall: false }, 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', TrackStatus: '1',
CurrentLap: 21, CurrentLap: 21,
TotalLaps: 78, TotalLaps: 78,
@@ -106,6 +111,51 @@ const snapshot: LiveStreamData = {
Stints: {}, Stints: {},
} }
function sector(value: string, over: Partial<LiveSectorData> = {}): LiveSectorData {
return { Value: value, PersonalFastest: false, OverallFastest: false, ...over }
}
function timingRow(
number: string,
position: number,
driver: Partial<LiveDriverData> = {},
): LiveTimingRow {
return {
RacingNumber: number,
Position: position,
Driver: {
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: 0,
SpeedTrap: '',
Sectors: [],
...driver,
},
}
}
function rows(count: number, knockedOut = 0): LiveTimingRow[] {
return Array.from({ length: count }, (_, index) =>
timingRow(String(index + 1), index + 1, { KnockedOut: index >= count - knockedOut }),
)
}
describe('live transforms', () => { describe('live transforms', () => {
it('parses live EventSource snapshots without changing PascalCase data', () => { it('parses live EventSource snapshots without changing PascalCase data', () => {
const parsed = parseLiveStateEvent(JSON.stringify({ is_live: true, data: snapshot })) const parsed = parseLiveStateEvent(JSON.stringify({ is_live: true, data: snapshot }))
@@ -168,6 +218,91 @@ 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', Path: '' },
rows(22),
)
expect(display.phaseLabel).toBe('SQ1')
expect(display.cutoffPosition).toBe(17)
expect(display.advanceCount).toBe(17)
expect(display.atRiskStart).toBe(18)
expect(display.atRiskEnd).toBe(22)
})
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', Path: '' },
rows(20),
)
expect(display.phaseLabel).toBe('Q1')
expect(display.cutoffPosition).toBe(15)
})
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', Path: '' },
rows(20, 5),
)
expect(display.phaseLabel).toBe('Q2')
expect(display.cutoffPosition).toBe(10)
})
it('shows no cutoff for race sessions or Q3', () => {
expect(
liveSessionDisplay(
{ 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', Path: '' },
rows(10),
).cutoffPosition,
).toBeNull()
})
})
describe('visible sector display', () => {
it('holds S1 and S2 through temporary blanks while a flying lap is active', () => {
const first = [timingRow('4', 1, {
NumberOfLaps: 3,
OnFlyingLap: true,
Sectors: [sector('29.111'), sector('41.222'), sector('')],
})]
const held = mergeVisibleSectors({}, first)
const blank = [timingRow('4', 1, {
NumberOfLaps: 3,
OnFlyingLap: false,
Sectors: [sector(''), sector(''), sector('')],
})]
const next = mergeVisibleSectors(held, blank)
const visibleRows = rowsWithVisibleSectors(blank, next)
expect(visibleRows[0].Driver.Sectors[0].Value).toBe('29.111')
expect(visibleRows[0].Driver.Sectors[1].Value).toBe('41.222')
})
it('clears held sectors after the lap completes and the feed goes blank', () => {
const first = mergeVisibleSectors({}, [timingRow('4', 1, {
NumberOfLaps: 3,
LastLapTime: '1:30.000',
OnFlyingLap: true,
Sectors: [sector('29.111'), sector('41.222'), sector('20.333')],
})])
const next = mergeVisibleSectors(first, [timingRow('4', 1, {
NumberOfLaps: 4,
LastLapTime: '1:30.000',
OnFlyingLap: false,
Sectors: [sector(''), sector(''), sector('')],
})])
expect(next['4']).toBeUndefined()
})
})
describe('weather and stint helpers', () => { describe('weather and stint helpers', () => {
it('maps wind direction degrees to compass points', () => { it('maps wind direction degrees to compass points', () => {
expect(windDirectionLabel(0)).toBe('N') expect(windDirectionLabel(0)).toBe('N')

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,39 @@
import { describe, expect, it } from 'vitest'
import {
buildOutlinePath,
canvasToSvg,
isOnTrack,
normalizeRawPoint,
outlinePointToCanvas,
} from '../lib/trackmap'
describe('track map transforms', () => {
it('normalizes raw F1 coordinates into canvas space', () => {
const point = normalizeRawPoint(
{ x: 50, y: 75 },
{ minX: 0, maxX: 100, minY: 50, maxY: 100 },
)
expect(point).toEqual({ x: 0.5, y: 0.5 })
expect(canvasToSvg(point)).toEqual({ x: 50, y: 50 })
})
it('centers zero-range bounds and clamps out-of-range points', () => {
expect(normalizeRawPoint({ x: 10, y: 20 }, { minX: 10, maxX: 10, minY: 20, maxY: 20 }))
.toEqual({ x: 0.5, y: 0.5 })
expect(normalizeRawPoint({ x: 20, y: 5 }, { minX: 10, maxX: 15, minY: 10, maxY: 15 }))
.toEqual({ x: 1, y: 1 })
})
it('builds an SVG path from normalized outline points', () => {
expect(outlinePointToCanvas({ x: 0.25, y: 0.75 })).toEqual({ x: 0.25, y: 0.25 })
expect(buildOutlinePath([{ x: 0, y: 0 }, { x: 1, y: 1 }]))
.toBe('M 0.00 100.00 L 100.00 0.00')
expect(buildOutlinePath([{ x: 0, y: 0 }])).toBe('')
})
it('classifies on-track status defensively', () => {
expect(isOnTrack('OnTrack')).toBe(true)
expect(isOnTrack('OffTrack')).toBe(false)
expect(isOnTrack('')).toBe(true)
})
})

View File

@@ -0,0 +1,149 @@
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()
})
})
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

@@ -177,6 +177,22 @@ export interface LiveStateResponse {
data: LiveStreamData | null data: LiveStreamData | null
} }
export interface LivePosition {
x: number
y: number
z: number
status: string
}
export interface LiveTelemetry {
Speed: number
Throttle: number
Brake: number
DRS: number
NGear: number
RPM: number
}
export interface LiveSectorData { export interface LiveSectorData {
Value: string Value: string
PersonalFastest: boolean PersonalFastest: boolean
@@ -245,6 +261,7 @@ export interface LiveSessionMeta {
CircuitName: string CircuitName: string
SessionType: string SessionType: string
SessionName: string SessionName: string
Path: string
} }
export interface LiveStintData { export interface LiveStintData {
@@ -253,13 +270,21 @@ export interface LiveStintData {
Laps: number Laps: number
} }
export interface LiveRadioCapture {
Utc: string
RacingNumber: string
Path: string
}
export interface LiveStreamData { export interface LiveStreamData {
Drivers: Record<string, LiveDriverData> Drivers: Record<string, LiveDriverData>
DriverInfo: Record<string, LiveDriverInfo> DriverInfo: Record<string, LiveDriverInfo>
Tyres: Record<string, LiveTyreData> Tyres: Record<string, LiveTyreData>
Telemetry?: Record<string, LiveTelemetry>
RCMessages: LiveRCMessage[] RCMessages: LiveRCMessage[]
Weather: LiveWeatherData Weather: LiveWeatherData
Session: LiveSessionMeta Session: LiveSessionMeta
TeamRadio: LiveRadioCapture[]
TrackStatus: string TrackStatus: string
CurrentLap: number CurrentLap: number
TotalLaps: number TotalLaps: number
@@ -269,6 +294,24 @@ export interface LiveStreamData {
Stints: Record<string, LiveStintData[]> Stints: Record<string, LiveStintData[]>
} }
export interface TrackPoint {
x: number
y: number
}
export interface TrackBounds {
minX: number
maxX: number
minY: number
maxY: number
}
export interface TrackOutline {
circuit_key: number
points: TrackPoint[]
bounds: TrackBounds
}
export interface ChampHubDriver { export interface ChampHubDriver {
driver_number: number driver_number: number
name_acronym: string name_acronym: string

View File

@@ -1,7 +1,11 @@
package live_test package live_test
import ( import (
"bytes"
"compress/flate"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt"
"testing" "testing"
"time" "time"
@@ -158,6 +162,68 @@ func TestProcessTopicTimingData(t *testing.T) {
} }
} }
func TestProcessTopicCompressedPositionAndCarData(t *testing.T) {
state := live.NewState()
positionPayload := `{
"Position": [
{"Timestamp": "2026-07-03T14:00:00Z", "Entries": {
"1": {"Status": "OnTrack", "X": 1000, "Y": -200, "Z": 3},
"44": {"Status": "OffTrack", "X": 1200, "Y": -250, "Z": 2}
}}
]
}`
if !state.ProcessTopic("Position.z", encodedDeflatePayload(t, positionPayload)) {
t.Fatal("Position.z should update state")
}
snap := state.Snapshot()
if !snap.PositionUpdated || snap.SnapshotUpdated {
t.Fatalf("position flags = position:%v snapshot:%v", snap.PositionUpdated, snap.SnapshotUpdated)
}
if got := snap.Positions["1"]; got.X != 1000 || got.Y != -200 || got.Z != 3 || got.Status != "OnTrack" {
t.Fatalf("position 1 = %+v", got)
}
if got := snap.Positions["44"]; got.Status != "OffTrack" {
t.Fatalf("position 44 = %+v", got)
}
carPayload := `{
"Entries": [
{"Utc": "2026-07-03T14:00:00Z", "Cars": {
"1": {"Channels": {"0": 11234, "2": 318, "3": 8, "4": 92, "5": 0, "45": 10}}
}}
]
}`
if !state.ProcessTopic("CarData.z", encodedDeflatePayload(t, carPayload)) {
t.Fatal("CarData.z should update state")
}
snap = state.Snapshot()
if !snap.SnapshotUpdated {
t.Fatal("CarData should mark snapshot updated")
}
tel := snap.Telemetry["1"]
if tel.RPM != 11234 || tel.Speed != 318 || tel.NGear != 8 || tel.Throttle != 92 || tel.Brake != 0 || tel.DRS != 10 {
t.Fatalf("telemetry = %+v", tel)
}
}
func TestProcessTopicSessionInfoCircuitName(t *testing.T) {
state := live.NewState()
state.ProcessTopic("SessionInfo", json.RawMessage(`{
"Meeting": {"Name": "British Grand Prix", "Circuit": {"ShortName": "Silverstone"}},
"Name": "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) { func TestProcessTopicDriverList(t *testing.T) {
state := live.NewState() state := live.NewState()
data := json.RawMessage(`{"63": {"RacingNumber": "63", "Tla": "RUS", "TeamName": "Mercedes", "TeamColour": "27F4D2"}}`) data := json.RawMessage(`{"63": {"RacingNumber": "63", "Tla": "RUS", "TeamName": "Mercedes", "TeamColour": "27F4D2"}}`)
@@ -224,6 +290,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) { func TestProcessTopicWeatherData(t *testing.T) {
state := live.NewState() state := live.NewState()
state.ProcessTopic("WeatherData", json.RawMessage(`{ state.ProcessTopic("WeatherData", json.RawMessage(`{
@@ -356,3 +484,23 @@ func TestProcessMessageEmptyPayload(t *testing.T) {
t.Error("empty envelope should not update state") t.Error("empty envelope should not update state")
} }
} }
func encodedDeflatePayload(t *testing.T, payload string) json.RawMessage {
t.Helper()
var buf bytes.Buffer
w, err := flate.NewWriter(&buf, flate.DefaultCompression)
if err != nil {
t.Fatalf("flate.NewWriter() error = %v", err)
}
if _, err := w.Write([]byte(payload)); err != nil {
t.Fatalf("flate write error = %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("flate close error = %v", err)
}
raw, err := json.Marshal(base64.StdEncoding.EncodeToString(buf.Bytes()))
if err != nil {
t.Fatalf("marshal payload error = %v", err)
}
return raw
}

View File

@@ -96,6 +96,8 @@ func connectToF1SignalRCore(dataChan chan LiveStreamData) error {
topics := []string{ topics := []string{
"Heartbeat", "Heartbeat",
"TimingData", "TimingData",
"Position.z",
"CarData.z",
"DriverList", "DriverList",
"LapCount", "LapCount",
"ExtrapolatedClock", "ExtrapolatedClock",
@@ -103,6 +105,7 @@ func connectToF1SignalRCore(dataChan chan LiveStreamData) error {
"RaceControlMessages", "RaceControlMessages",
"WeatherData", "WeatherData",
"SessionInfo", "SessionInfo",
"TeamRadio",
"CurrentTyres", "CurrentTyres",
"TimingAppData", "TimingAppData",
"TimingStats", "TimingStats",
@@ -194,7 +197,7 @@ func connectToF1LegacySignalR(dataChan chan LiveStreamData) error {
return err return err
} }
subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","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"]],"I":1}`)
err = c.WriteMessage(websocket.TextMessage, subscribeMsg) err = c.WriteMessage(websocket.TextMessage, subscribeMsg)
if err != nil { if err != nil {
return err return err

View File

@@ -1,8 +1,16 @@
package live package live
import ( import (
"bytes"
"compress/flate"
"compress/zlib"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log"
"sort"
"strings"
"time" "time"
) )
@@ -11,19 +19,25 @@ type State struct {
Drivers map[string]LiveDriverData Drivers map[string]LiveDriverData
DriverInfo map[string]F1DriverListEntry DriverInfo map[string]F1DriverListEntry
Tyres map[string]LiveTyreData Tyres map[string]LiveTyreData
Telemetry map[string]LiveTelemetryData
Positions map[string]LivePositionData
Stints map[string][]LiveStintData Stints map[string][]LiveStintData
RCMessages []LiveRCMessage RCMessages []LiveRCMessage
Weather LiveWeatherData Weather LiveWeatherData
Session LiveSessionMeta Session LiveSessionMeta
TeamRadio []LiveRadioCapture
TrackStatus string TrackStatus string
CurrentLap int CurrentLap int
TotalLaps int TotalLaps int
Clock string Clock string
ClockRefTime time.Time ClockRefTime time.Time
ClockExtrapolating bool ClockExtrapolating bool
positionUpdated bool
snapshotUpdated bool
} }
const signalRRecordSeparator = byte(0x1e) const signalRRecordSeparator = byte(0x1e)
const maxTeamRadioCaptures = 20
// NewState returns an empty live timing accumulator. // NewState returns an empty live timing accumulator.
func NewState() *State { func NewState() *State {
@@ -31,6 +45,8 @@ func NewState() *State {
Drivers: make(map[string]LiveDriverData), Drivers: make(map[string]LiveDriverData),
DriverInfo: make(map[string]F1DriverListEntry), DriverInfo: make(map[string]F1DriverListEntry),
Tyres: make(map[string]LiveTyreData), Tyres: make(map[string]LiveTyreData),
Telemetry: make(map[string]LiveTelemetryData),
Positions: make(map[string]LivePositionData),
Stints: make(map[string][]LiveStintData), Stints: make(map[string][]LiveStintData),
} }
} }
@@ -49,8 +65,18 @@ func (s *State) Snapshot() LiveStreamData {
for k, v := range s.Tyres { for k, v := range s.Tyres {
cpyTyres[k] = v cpyTyres[k] = v
} }
cpyTelemetry := make(map[string]LiveTelemetryData, len(s.Telemetry))
for k, v := range s.Telemetry {
cpyTelemetry[k] = v
}
cpyPositions := make(map[string]LivePositionData, len(s.Positions))
for k, v := range s.Positions {
cpyPositions[k] = v
}
cpyRC := make([]LiveRCMessage, len(s.RCMessages)) cpyRC := make([]LiveRCMessage, len(s.RCMessages))
copy(cpyRC, s.RCMessages) copy(cpyRC, s.RCMessages)
cpyRadio := make([]LiveRadioCapture, len(s.TeamRadio))
copy(cpyRadio, s.TeamRadio)
cpyStints := make(map[string][]LiveStintData, len(s.Stints)) cpyStints := make(map[string][]LiveStintData, len(s.Stints))
for k, v := range s.Stints { for k, v := range s.Stints {
st := make([]LiveStintData, len(v)) st := make([]LiveStintData, len(v))
@@ -62,9 +88,11 @@ func (s *State) Snapshot() LiveStreamData {
Drivers: cpyDrivers, Drivers: cpyDrivers,
DriverInfo: cpyInfo, DriverInfo: cpyInfo,
Tyres: cpyTyres, Tyres: cpyTyres,
Telemetry: cpyTelemetry,
RCMessages: cpyRC, RCMessages: cpyRC,
Weather: s.Weather, Weather: s.Weather,
Session: s.Session, Session: s.Session,
TeamRadio: cpyRadio,
TrackStatus: s.TrackStatus, TrackStatus: s.TrackStatus,
CurrentLap: s.CurrentLap, CurrentLap: s.CurrentLap,
TotalLaps: s.TotalLaps, TotalLaps: s.TotalLaps,
@@ -72,11 +100,16 @@ func (s *State) Snapshot() LiveStreamData {
ClockRefTime: s.ClockRefTime, ClockRefTime: s.ClockRefTime,
ClockExtrapolating: s.ClockExtrapolating, ClockExtrapolating: s.ClockExtrapolating,
Stints: cpyStints, Stints: cpyStints,
Positions: cpyPositions,
PositionUpdated: s.positionUpdated,
SnapshotUpdated: s.snapshotUpdated,
} }
} }
// ProcessMessage parses a raw SignalR WebSocket frame and applies any updates. // ProcessMessage parses a raw SignalR WebSocket frame and applies any updates.
func (s *State) ProcessMessage(message []byte) bool { func (s *State) ProcessMessage(message []byte) bool {
s.clearTransientFlags()
var parsed F1SignalRMessage var parsed F1SignalRMessage
if err := json.Unmarshal(message, &parsed); err != nil { if err := json.Unmarshal(message, &parsed); err != nil {
return false return false
@@ -111,6 +144,8 @@ func (s *State) ProcessMessage(message []byte) bool {
// ProcessCoreMessage parses one or more SignalR Core JSON frames and applies // ProcessCoreMessage parses one or more SignalR Core JSON frames and applies
// completion snapshots and feed deltas from the current official F1 live timing hub. // completion snapshots and feed deltas from the current official F1 live timing hub.
func (s *State) ProcessCoreMessage(message []byte) bool { func (s *State) ProcessCoreMessage(message []byte) bool {
s.clearTransientFlags()
updated := false updated := false
for _, frame := range splitSignalRFrames(message) { for _, frame := range splitSignalRFrames(message) {
var envelope struct { var envelope struct {
@@ -156,7 +191,15 @@ func (s *State) ProcessCoreMessage(message []byte) bool {
// ProcessTopic applies a single topic payload to the accumulator. // ProcessTopic applies a single topic payload to the accumulator.
func (s *State) ProcessTopic(topic string, data json.RawMessage) bool { func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
updated := false updated := false
switch topic { baseTopic := strings.TrimSuffix(topic, ".z")
if topic != baseTopic {
var ok bool
data, ok = inflateTopicPayload(data)
if !ok {
return false
}
}
switch baseTopic {
case "TimingData": case "TimingData":
var td struct { var td struct {
Lines map[string]json.RawMessage `json:"Lines"` Lines map[string]json.RawMessage `json:"Lines"`
@@ -170,6 +213,10 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
} }
} }
} }
case "Position":
updated = s.updatePositions(data)
case "CarData":
updated = s.updateTelemetry(data)
case "DriverList": case "DriverList":
var dlMap map[string]json.RawMessage var dlMap map[string]json.RawMessage
if json.Unmarshal(data, &dlMap) == nil { if json.Unmarshal(data, &dlMap) == nil {
@@ -289,22 +336,34 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
var si struct { var si struct {
Meeting struct { Meeting struct {
Name string `json:"Name"` Name string `json:"Name"`
Circuit struct {
ShortName string `json:"ShortName"`
} `json:"Circuit"`
} `json:"Meeting"` } `json:"Meeting"`
Name string `json:"Name"` Name string `json:"Name"`
Type string `json:"Type"` Type string `json:"Type"`
Path string `json:"Path"`
} }
if json.Unmarshal(data, &si) == nil { if json.Unmarshal(data, &si) == nil {
if si.Meeting.Name != "" { if si.Meeting.Name != "" {
s.Session.MeetingName = si.Meeting.Name s.Session.MeetingName = si.Meeting.Name
} }
if si.Meeting.Circuit.ShortName != "" {
s.Session.CircuitName = si.Meeting.Circuit.ShortName
}
if si.Name != "" { if si.Name != "" {
s.Session.SessionName = si.Name s.Session.SessionName = si.Name
} }
if si.Type != "" { if si.Type != "" {
s.Session.SessionType = si.Type s.Session.SessionType = si.Type
} }
if si.Path != "" {
s.Session.Path = si.Path
}
updated = true updated = true
} }
case "TeamRadio":
updated = s.updateTeamRadio(data)
case "CurrentTyres": case "CurrentTyres":
var ct map[string]json.RawMessage var ct map[string]json.RawMessage
if json.Unmarshal(data, &ct) == nil { if json.Unmarshal(data, &ct) == nil {
@@ -386,9 +445,233 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
} }
} }
} }
if updated {
if baseTopic == "Position" {
s.positionUpdated = true
} else {
s.snapshotUpdated = true
}
}
return updated return updated
} }
func (s *State) clearTransientFlags() {
s.positionUpdated = false
s.snapshotUpdated = false
}
func inflateTopicPayload(data json.RawMessage) (json.RawMessage, bool) {
var encoded string
if err := json.Unmarshal(data, &encoded); err != nil {
var wrapper struct {
Z string `json:"z"`
}
if json.Unmarshal(data, &wrapper) != nil || wrapper.Z == "" {
return nil, false
}
encoded = wrapper.Z
}
compressed, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, false
}
if inflated, ok := readCompressed(zlib.NewReader(bytes.NewReader(compressed))); ok {
return json.RawMessage(inflated), true
}
if inflated, ok := readCompressed(func() (io.ReadCloser, error) {
return flate.NewReader(bytes.NewReader(compressed)), nil
}()); ok {
return json.RawMessage(inflated), true
}
return nil, false
}
func readCompressed(r io.ReadCloser, err error) ([]byte, bool) {
if err != nil {
return nil, false
}
defer r.Close()
out, err := io.ReadAll(r)
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"`
}
if json.Unmarshal(data, &payload) != nil || len(payload.Position) == 0 {
return false
}
updated := false
for _, sampleRaw := range indexedRawValues(payload.Position) {
var sample struct {
Entries map[string]struct {
Status string `json:"Status"`
X json.Number `json:"X"`
Y json.Number `json:"Y"`
Z json.Number `json:"Z"`
} `json:"Entries"`
}
if json.Unmarshal(sampleRaw.Raw, &sample) != nil {
continue
}
for num, entry := range sample.Entries {
x, okX := numberToFloat(entry.X)
y, okY := numberToFloat(entry.Y)
z, okZ := numberToFloat(entry.Z)
if !okX || !okY {
continue
}
if !okZ {
z = 0
}
s.Positions[num] = LivePositionData{
X: x,
Y: y,
Z: z,
Status: entry.Status,
}
updated = true
}
}
return updated
}
func (s *State) updateTelemetry(data json.RawMessage) bool {
var payload struct {
Entries json.RawMessage `json:"Entries"`
}
if json.Unmarshal(data, &payload) != nil || len(payload.Entries) == 0 {
return false
}
updated := false
for _, entryRaw := range indexedRawValues(payload.Entries) {
var entry struct {
Cars map[string]struct {
Channels map[string]json.RawMessage `json:"Channels"`
} `json:"Cars"`
}
if json.Unmarshal(entryRaw.Raw, &entry) != nil {
continue
}
for num, car := range entry.Cars {
t := s.Telemetry[num]
if v, ok := channelInt(car.Channels, "0"); ok {
t.RPM = v
}
if v, ok := channelInt(car.Channels, "2"); ok {
t.Speed = v
}
if v, ok := channelInt(car.Channels, "3"); ok {
t.NGear = v
}
if v, ok := channelInt(car.Channels, "4"); ok {
t.Throttle = v
}
if v, ok := channelInt(car.Channels, "5"); ok {
t.Brake = v
}
if v, ok := channelInt(car.Channels, "45"); ok {
t.DRS = v
}
s.Telemetry[num] = t
updated = true
}
}
return updated
}
func channelInt(channels map[string]json.RawMessage, key string) (int, bool) {
raw, ok := channels[key]
if !ok {
return 0, false
}
var n json.Number
if json.Unmarshal(raw, &n) == nil {
if i, err := n.Int64(); err == nil {
return int(i), true
}
if f, err := n.Float64(); err == nil {
return int(f), true
}
}
var s string
if json.Unmarshal(raw, &s) == nil {
var i int
if _, err := fmt.Sscanf(s, "%d", &i); err == nil {
return i, true
}
}
return 0, false
}
func numberToFloat(n json.Number) (float64, bool) {
if n == "" {
return 0, false
}
v, err := n.Float64()
return v, err == nil
}
func updateDriver(drivers map[string]LiveDriverData, num string, line F1TimingLine) { func updateDriver(drivers map[string]LiveDriverData, num string, line F1TimingLine) {
d, exists := drivers[num] d, exists := drivers[num]
if !exists { if !exists {
@@ -585,6 +868,9 @@ func indexedRawValues(raw json.RawMessage) []indexedRaw {
fmt.Sscanf(k, "%d", &i) fmt.Sscanf(k, "%d", &i)
values = append(values, indexedRaw{Index: i, Raw: v}) 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 return values
} }

View File

@@ -85,6 +85,25 @@ type LiveSessionMeta struct {
CircuitName string CircuitName string
SessionType string SessionType string
SessionName string SessionName string
Path string
}
// LivePositionData is the latest raw F1 GPS position for one driver.
type LivePositionData struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
Status string `json:"status"`
}
// LiveTelemetryData is the latest car telemetry for one driver.
type LiveTelemetryData struct {
Speed int `json:"Speed"`
Throttle int `json:"Throttle"`
Brake int `json:"Brake"`
DRS int `json:"DRS"`
NGear int `json:"NGear"`
RPM int `json:"RPM"`
} }
// LiveSectorData holds a single sector time and flags. // LiveSectorData holds a single sector time and flags.
@@ -126,14 +145,23 @@ type LiveStintData struct {
Laps int 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. // LiveStreamData is an immutable snapshot of all live timing state.
type LiveStreamData struct { type LiveStreamData struct {
Drivers map[string]LiveDriverData Drivers map[string]LiveDriverData
DriverInfo map[string]F1DriverListEntry DriverInfo map[string]F1DriverListEntry
Tyres map[string]LiveTyreData Tyres map[string]LiveTyreData
Telemetry map[string]LiveTelemetryData
RCMessages []LiveRCMessage RCMessages []LiveRCMessage
Weather LiveWeatherData Weather LiveWeatherData
Session LiveSessionMeta Session LiveSessionMeta
TeamRadio []LiveRadioCapture
TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC
CurrentLap int CurrentLap int
TotalLaps int TotalLaps int
@@ -141,4 +169,7 @@ type LiveStreamData struct {
ClockRefTime time.Time // UTC when Clock was accurate ClockRefTime time.Time // UTC when Clock was accurate
ClockExtrapolating bool // true = actively counting down ClockExtrapolating bool // true = actively counting down
Stints map[string][]LiveStintData Stints map[string][]LiveStintData
Positions map[string]LivePositionData `json:"-"`
PositionUpdated bool `json:"-"`
SnapshotUpdated bool `json:"-"`
} }

View File

@@ -1046,21 +1046,35 @@ type trackPoint struct {
Y float64 `json:"y"` Y float64 `json:"y"`
} }
type trackBounds struct {
MinX float64 `json:"minX"`
MaxX float64 `json:"maxX"`
MinY float64 `json:"minY"`
MaxY float64 `json:"maxY"`
}
type trackOutlineResponse struct { type trackOutlineResponse struct {
CircuitKey int `json:"circuit_key"` CircuitKey int `json:"circuit_key"`
Points []trackPoint `json:"points"` Points []trackPoint `json:"points"`
Bounds trackBounds `json:"bounds"`
} }
func (s *Server) handleTrackOutline(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTrackOutline(w http.ResponseWriter, r *http.Request) {
circuitKey, err := strconv.Atoi(r.URL.Query().Get("circuit_key"))
if err != nil || circuitKey == 0 {
http.Error(w, "circuit_key required", http.StatusBadRequest)
return
}
year, _ := strconv.Atoi(r.URL.Query().Get("year")) year, _ := strconv.Atoi(r.URL.Query().Get("year"))
if year == 0 { if year == 0 {
year = time.Now().Year() year = time.Now().Year()
} }
circuitKey, err := strconv.Atoi(r.URL.Query().Get("circuit_key"))
if err != nil {
circuitKey = 0
}
if circuitKey == 0 {
circuitKey = s.resolveCircuitKey(year, r.URL.Query().Get("meeting_name"), r.URL.Query().Get("circuit_name"))
}
if circuitKey == 0 {
http.Error(w, "circuit_key or live meeting identity required", http.StatusBadRequest)
return
}
locs, ok := s.client.Cache().GetTrackOutline(circuitKey, year) locs, ok := s.client.Cache().GetTrackOutline(circuitKey, year)
if !ok || len(locs) == 0 { if !ok || len(locs) == 0 {
@@ -1110,7 +1124,85 @@ func (s *Server) handleTrackOutline(w http.ResponseWriter, r *http.Request) {
} }
} }
writeJSON(w, trackOutlineResponse{CircuitKey: circuitKey, Points: points}) writeJSON(w, trackOutlineResponse{
CircuitKey: circuitKey,
Points: points,
Bounds: trackBounds{MinX: minX, MaxX: maxX, MinY: minY, MaxY: maxY},
})
}
func (s *Server) resolveCircuitKey(year int, meetingName, circuitName string) int {
if !s.hasLocalQuery() {
return 0
}
meetings, err := s.query.ListMeetingsByYear(year)
if err != nil {
return 0
}
wantMeeting := normalizeTrackIdentity(meetingName)
wantCircuit := normalizeTrackIdentity(circuitName)
bestScore := 0
bestCircuitKey := 0
for _, m := range meetings {
if m.CircuitKey == 0 {
continue
}
score := identityScore(wantMeeting, m.MeetingName, m.MeetingOfficialName, m.Location)
score += identityScore(wantCircuit, m.CircuitShortName, m.Location, m.MeetingName)
if score > bestScore {
bestScore = score
bestCircuitKey = m.CircuitKey
}
}
if bestScore == 0 {
return 0
}
return bestCircuitKey
}
func identityScore(want string, candidates ...string) int {
if want == "" {
return 0
}
best := 0
for _, candidate := range candidates {
got := normalizeTrackIdentity(candidate)
if got == "" {
continue
}
switch {
case got == want:
if best < 4 {
best = 4
}
case strings.Contains(got, want) || strings.Contains(want, got):
if best < 2 {
best = 2
}
}
}
return best
}
func normalizeTrackIdentity(s string) string {
s = strings.ToLower(s)
replacer := strings.NewReplacer(
"grand prix", "",
" gp", "",
"circuit", "",
"autodromo", "",
"autódromo", "",
"international", "",
"street", "",
" ", "",
"-", "",
"_", "",
".", "",
",", "",
"'", "",
"", "",
)
return strings.TrimSpace(replacer.Replace(s))
} }
// --- /api/v1/strategy --- // --- /api/v1/strategy ---

View File

@@ -31,6 +31,7 @@ type SSEHub struct {
mu sync.RWMutex mu sync.RWMutex
lastSnapshot *live.LiveStreamData lastSnapshot *live.LiveStreamData
lastPositions map[string]live.LivePositionData
isLive bool isLive bool
} }
@@ -52,6 +53,7 @@ func (h *SSEHub) run() {
// Send catch-up snapshot so new clients see current state immediately. // Send catch-up snapshot so new clients see current state immediately.
h.mu.RLock() h.mu.RLock()
snap := h.lastSnapshot snap := h.lastSnapshot
positions := cloneLivePositions(h.lastPositions)
live := h.isLive live := h.isLive
h.mu.RUnlock() h.mu.RUnlock()
if snap != nil { if snap != nil {
@@ -62,6 +64,14 @@ func (h *SSEHub) run() {
} }
} }
} }
if len(positions) > 0 {
if data, err := json.Marshal(positions); err == nil {
select {
case c.ch <- formatSSEFrame("positions", data):
default:
}
}
}
case c := <-h.deregister: case c := <-h.deregister:
if clients[c] { if clients[c] {
@@ -122,6 +132,7 @@ func (s *Server) signalRLoop() {
s.hub.mu.Lock() s.hub.mu.Lock()
s.hub.isLive = false s.hub.isLive = false
s.hub.lastSnapshot = nil s.hub.lastSnapshot = nil
s.hub.lastPositions = nil
s.hub.mu.Unlock() s.hub.mu.Unlock()
if payload, err := json.Marshal(map[string]any{"data": nil, "is_live": false}); err == nil { if payload, err := json.Marshal(map[string]any{"data": nil, "is_live": false}); err == nil {
@@ -150,18 +161,34 @@ func (s *Server) connectAndDrain() error {
idleTimeout := 60 * time.Second idleTimeout := 60 * time.Second
timer := time.NewTimer(idleTimeout) timer := time.NewTimer(idleTimeout)
defer timer.Stop() defer timer.Stop()
lastPositionBroadcast := time.Time{}
for { for {
select { select {
case data := <-dataChan: case data := <-dataChan:
now := time.Now()
s.hub.mu.Lock() s.hub.mu.Lock()
if data.SnapshotUpdated {
s.hub.lastSnapshot = &data s.hub.lastSnapshot = &data
}
if data.PositionUpdated && len(data.Positions) > 0 {
s.hub.lastPositions = cloneLivePositions(data.Positions)
}
s.hub.isLive = true s.hub.isLive = true
s.hub.mu.Unlock() s.hub.mu.Unlock()
if data.SnapshotUpdated {
if payload, err := json.Marshal(map[string]any{"data": data, "is_live": true}); err == nil { if payload, err := json.Marshal(map[string]any{"data": data, "is_live": true}); err == nil {
s.hub.broadcast <- sseEvent{name: "snapshot", data: payload} 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 {
s.hub.broadcast <- sseEvent{name: "positions", data: payload}
lastPositionBroadcast = now
}
}
if !timer.Stop() { if !timer.Stop() {
select { select {
@@ -177,6 +204,17 @@ func (s *Server) connectAndDrain() error {
} }
} }
func cloneLivePositions(in map[string]live.LivePositionData) map[string]live.LivePositionData {
if len(in) == 0 {
return nil
}
out := make(map[string]live.LivePositionData, len(in))
for k, v := range in {
out[k] = v
}
return out
}
// handleLiveState returns the current live data snapshot as JSON. // handleLiveState returns the current live data snapshot as JSON.
func (s *Server) handleLiveState(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLiveState(w http.ResponseWriter, r *http.Request) {
snap, isLive := s.hub.Snapshot() snap, isLive := s.hub.Snapshot()

View File

@@ -0,0 +1,76 @@
package web
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/AmanTahiliani/box-box/internal/live"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/store"
)
func TestHandleTrackOutlineReturnsBoundsAndResolvesLiveIdentity(t *testing.T) {
st := openTestStore(t)
if err := st.UpsertMeeting(store.Meeting{
MeetingKey: 1234,
MeetingName: "British Grand Prix",
Location: "Silverstone",
CircuitKey: 9,
CircuitShortName: "Silverstone",
Year: 2026,
}); err != nil {
t.Fatalf("UpsertMeeting() error = %v", err)
}
srv := testServer(t, st)
locs := []models.Location{
{X: -100, Y: 50, Z: 0},
{X: 0, Y: 100, Z: 0},
{X: 100, Y: 50, Z: 0},
}
if err := srv.client.Cache().SetTrackOutline(9, 2026, locs); err != nil {
t.Fatalf("SetTrackOutline() error = %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/track-outline?meeting_name=British+Grand+Prix&circuit_name=Silverstone&year=2026", nil)
rec := httptest.NewRecorder()
srv.handleTrackOutline(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String())
}
var resp trackOutlineResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.CircuitKey != 9 {
t.Fatalf("circuit_key = %d, want 9", resp.CircuitKey)
}
if resp.Bounds.MinX != -100 || resp.Bounds.MaxX != 100 || resp.Bounds.MinY != 50 || resp.Bounds.MaxY != 100 {
t.Fatalf("bounds = %+v", resp.Bounds)
}
if len(resp.Points) != 3 {
t.Fatalf("points len = %d, want 3", len(resp.Points))
}
}
func TestPositionsSSEFrameShape(t *testing.T) {
payload, err := json.Marshal(map[string]live.LivePositionData{
"1": {X: 100, Y: -50, Z: 2, Status: "OnTrack"},
})
if err != nil {
t.Fatalf("marshal positions: %v", err)
}
frame := string(formatSSEFrame("positions", payload))
if !strings.HasPrefix(frame, "event: positions\ndata: ") {
t.Fatalf("frame prefix = %q", frame)
}
if !strings.Contains(frame, `"1":{"x":100,"y":-50,"z":2,"status":"OnTrack"}`) {
t.Fatalf("frame data = %q", frame)
}
if !strings.HasSuffix(frame, "\n\n") {
t.Fatalf("frame should end with blank line: %q", frame)
}
}

View File

@@ -9,7 +9,7 @@ test.describe('Command Center', () => {
await expect(page.getByTestId('command-center')).toBeVisible() await expect(page.getByTestId('command-center')).toBeVisible()
await expect(page.getByTestId('cc-focus')).toBeVisible() await expect(page.getByTestId('cc-focus')).toBeVisible()
await expect(page.getByTestId('cc-session-9472')).toBeVisible() await expect(page.getByTestId('cc-session-9472')).toBeVisible()
await expect(page.getByTestId('cc-actions')).toBeVisible() await expect(page.getByTestId('hero-last-race-link')).toBeVisible()
}) })
test('nav link reaches command center from race hub', async ({ page }) => { test('nav link reaches command center from race hub', async ({ page }) => {
@@ -21,8 +21,8 @@ test.describe('Command Center', () => {
test('open analysis action opens race hub for the focus session', async ({ page }) => { test('open analysis action opens race hub for the focus session', async ({ page }) => {
await page.goto('/') await page.goto('/')
await expect(page.getByTestId('cc-action-race-hub')).toContainText(String(FULL_SESSION)) await expect(page.getByTestId('hero-last-race-link')).toBeVisible()
await page.getByTestId('cc-action-race-hub').click() await page.getByTestId('hero-last-race-link').click()
await expect(page).toHaveURL(new RegExp(`/race-hub\\?session_key=${FULL_SESSION}`)) await expect(page).toHaveURL(new RegExp(`/race-hub\\?session_key=${FULL_SESSION}`))
await expect(page.getByTestId('rh-identity')).toBeVisible() await expect(page.getByTestId('rh-identity')).toBeVisible()
await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible() await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible()

View File

@@ -102,6 +102,51 @@ const raceSnapshot = {
}, },
} }
const sprintQualifyingSnapshot = {
is_live: true,
data: {
...raceSnapshot.data,
Drivers: Object.fromEntries(
Array.from({ length: 22 }, (_, index) => {
const num = String(index + 1)
return [
num,
driver(num, index + 1, index === 0 ? '' : `+${(index * 0.123).toFixed(3)}`, index === 0 ? '' : `+${(index * 0.123).toFixed(3)}`, {
LastLapTime: index < 2 ? '1:29.273' : '',
BestLapTime: `1:${String(29 + Math.floor(index / 10)).padStart(2, '0')}.${String(273 + index).padStart(3, '0')}`,
NumberOfLaps: 4,
Sectors: index === 7
? [{ Value: '28.573', PersonalFastest: false, OverallFastest: false }, { Value: '', PersonalFastest: false, OverallFastest: false }, { Value: '', PersonalFastest: false, OverallFastest: false }]
: [],
OnFlyingLap: index === 7,
}),
]
}),
),
DriverInfo: Object.fromEntries(
Array.from({ length: 22 }, (_, index) => {
const num = String(index + 1)
return [num, info(num, `D${index + 1}`, 'Driver', String(index + 1), 'Test Team', index % 2 ? 'FF8000' : '27F4D2')]
}),
),
Tyres: Object.fromEntries(
Array.from({ length: 22 }, (_, index) => [String(index + 1), { Compound: 'MEDIUM', New: false, Age: index % 4 }]),
),
Session: {
MeetingName: 'British Grand Prix',
CircuitName: 'Silverstone',
SessionType: 'Sprint Qualifying',
SessionName: 'Sprint Qualifying',
},
TrackStatus: '1',
CurrentLap: 0,
TotalLaps: 0,
Clock: '00:02:11',
ClockRefTime: '2026-07-03T15:39:49Z',
ClockExtrapolating: false,
},
}
test.describe('Live Timing (mocked snapshot)', () => { test.describe('Live Timing (mocked snapshot)', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.route('**/api/v1/live/state', (route) => await page.route('**/api/v1/live/state', (route) =>
@@ -148,21 +193,46 @@ test.describe('Live Timing (mocked snapshot)', () => {
}) })
test('renders stint history for drivers that have stints', async ({ page }) => { test('renders stint history for drivers that have stints', async ({ page }) => {
await page.locator('.live-tower tbody tr', { hasText: 'VER' }).click()
await expect(page.getByTestId('stint-seq').first()).toBeVisible() await expect(page.getByTestId('stint-seq').first()).toBeVisible()
}) })
test('clicking a row pins the driver to the focus strip', async ({ page }) => { test('clicking a pin button pins the driver to the focus strip', async ({ page }) => {
await page.locator('.live-tower tbody tr', { hasText: 'VER' }).click() await page.locator('.live-tower tbody tr', { hasText: 'VER' }).locator('.pin-btn').click()
const pinned = page.getByTestId('pinned-strip') const pinned = page.getByTestId('pinned-strip')
await expect(pinned).toBeVisible() await expect(pinned).toBeVisible()
await expect(pinned).toContainText('VER') await expect(pinned).toContainText('VER')
// Unpin restores the empty strip. // Unpin restores the empty strip.
await page.locator('.live-tower tbody tr', { hasText: 'VER' }).click() await page.locator('.live-tower tbody tr', { hasText: 'VER' }).locator('.pin-btn').click()
await expect(page.getByTestId('pinned-strip')).toHaveCount(0) await expect(page.getByTestId('pinned-strip')).toHaveCount(0)
}) })
}) })
test.describe('Live Timing (mocked Sprint Qualifying)', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/v1/live/state', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(sprintQualifyingSnapshot) }),
)
await page.route('**/api/v1/live/stream', (route) =>
route.fulfill({
contentType: 'text/event-stream',
body: 'event: heartbeat\ndata: {}\n\n',
}),
)
await page.goto('/live')
})
test('shows SQ1 phase, large clock, and 22-car cutoff after P17', async ({ page }) => {
await expect(page.getByText('SQ1', { exact: true })).toBeVisible()
await expect(page.getByTestId('live-clock')).toContainText('00:02:11')
await expect(page.getByTestId('qualifying-cutoff')).toContainText('P17 advance')
await expect(page.getByTestId('qualifying-cutoff')).toContainText('P18-P22 at risk')
await expect(page.locator('.live-tower tbody tr', { hasText: 'D18' })).toHaveClass(/danger-row/)
await expect(page.locator('.live-tower tbody tr', { hasText: 'D8' })).toHaveClass(/flying-row/)
})
})
test.describe('Live Timing (no session)', () => { test.describe('Live Timing (no session)', () => {
test('shows the empty state when the feed has no snapshot', async ({ page }) => { test('shows the empty state when the feed has no snapshot', async ({ page }) => {
await page.goto('/live') await page.goto('/live')