Compare commits

..

23 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 00:09:47 -04:00
AmanTahiliani
91e2d13ab7 Implement SignalR Core support and add session fetching for live timing 2026-06-21 19:53:42 -07:00
117 changed files with 9981 additions and 673 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

60
.agents/harnesses.sh Normal file
View File

@@ -0,0 +1,60 @@
#!/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 — Gemini 3.1 Pro, headless full-auto
# --new-project is required for --model to take effect (otherwise agy resumes the
# previous conversation and silently keeps its old model).
local dir="$1" prompt="$2"
( cd "$dir" && agy --print --new-project --print-timeout 60m \
--model="Gemini 3.1 Pro (High)" --dangerously-skip-permissions "$(cat "$prompt")" )
}
# ---- 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/

View File

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

230
README.md
View File

@@ -1,228 +1,66 @@
# box-box # box-box
> "Box, box. Box, box." Every F1 race engineer, ever. > "Box, box. Box, box." Every F1 race engineer, ever.
**box-box** is a Formula 1 dashboard in Go with two surfaces: **box-box** is an unofficial F1 race-weekend command center: live timing, Race Hub analytics, championship context, paddock briefing feeds, and local historical data in one Go + React app, with a preserved Bubble Tea TUI.
- **Web UI (primary)** — React + TypeScript SPA for Race Hub analytics, local data coverage, and live timing. Live demo: [box-box.amantahiliani.com](https://box-box.amantahiliani.com/)
- **TUI (preserved)** — Bubble Tea terminal app with standings, calendar, driver profiles, official live timing, track map, battles, pit window, and replay.
Historical Web data is **local-first** in a SQLite domain database (ingested from OpenF1). **Live timing** uses the official F1 SignalR feed via the Go server; the React app reads `/api/v1/live/*`, not OpenF1 directly. ![box-box Command Center](docs/assets/command-center.jpg)
For architecture, phase history, and design rationale, see [documentations/refactor/README.md](documentations/refactor/README.md). ## What It Does
## How it fits together - **Command Center**: current race-weekend home with GP identity, live status, schedule, championship leaders, and direct analysis links.
- **Race Hub**: session workspace for overview, race story, strategy, laps, weather, race control, and dataset coverage.
- **Live Timing**: official F1 SignalR feed bridged through the Go server to the browser via SSE.
- **Championship View**: standings, form, teammate context, cumulative points, and a simulator.
- **Paddock Briefing**: RSS/Atom news ingestion for a local race-weekend briefing surface.
- **Local-first history**: OpenF1 data ingested into a SQLite domain database for fast historical browsing.
- **Terminal Mode**: Bubble Tea TUI with standings, calendar, driver profiles, live timing, track map, battles, pit window, and replay.
| Layer | Role | ## Quickstart
| --- | --- |
| **OpenF1 REST** | Backfill and ingestion source; optional paid tier via `OPENF1_API_KEY`. Also powers the TUIs on-demand reads and HTTP cache. |
| **Domain SQLite** (`boxbox.db`) | Local store for meetings, sessions, Race Hub datasets, and navigation APIs used by the Web UI. |
| **HTTP cache SQLite** (`cache.db`) | TTL cache for OpenF1 responses (TUI and legacy paths). Separate from the domain DB. |
| **Official F1 SignalR** | Live timing bridge in `internal/live`, exposed to Web (SSE) and TUI. |
| **Go server** | `cmd/main.go` — TUI, `--web` API + static SPA, or CLI ingestion. |
| **React frontend** | `frontend/` — production build served from `frontend/dist` when present. |
The Web UI should call **local-first Go APIs** (`/api/v1/...`). Do not add direct OpenF1 reads in the frontend.
## Prerequisites
- [Go](https://go.dev/doc/install) (see `go.mod` for the module version)
- [Node.js](https://nodejs.org/) 18+ and npm (Web UI dev, unit tests, Playwright)
- Internet for ingestion and TUI OpenF1 calls
- For E2E / visual tests: `npx playwright install` (Chromium) after `npm install` at the repo root
## Install
```bash ```bash
git clone https://github.com/AmanTahiliani/box-box.git git clone https://github.com/AmanTahiliani/box-box.git
cd box-box cd box-box
npm install # Playwright and repo-level test scripts npm install
npm install --prefix frontend # Vite + React app npm install --prefix frontend
```
## Build
```bash
go build -o box-box ./cmd/main.go
npm run build --prefix frontend # writes frontend/dist (gitignored)
```
## Run — TUI (default)
```bash
go run ./cmd/main.go
# or: ./box-box
```
Logs go to `box-box.log` in the project directory so the terminal stays clean.
### TUI keybindings
| Key | Action |
| --- | --- |
| `1``7` | Home, Standings, Calendar, Race Detail, Drivers, Live, Track Map |
| `tab` / `shift+tab` | Next / previous tab |
| `j`/`k`, `enter`, `b`/`esc` | Navigate, select, back |
| `s`, `b`, `p` | Live: sectors, battles, pit window |
| `r` | Race replay (Race Detail, race sessions) |
| `y` | Cycle season year |
| `q` / `ctrl+c` | Quit |
## Run — Web (Go serves API + built React)
Build the frontend first, then start web mode. Go walks up from the cwd to find `frontend/dist/index.html`; if missing, it serves embedded legacy assets.
```bash
npm run build --prefix frontend npm run build --prefix frontend
go run ./cmd/main.go --web go run ./cmd/main.go --web
# http://localhost:8080 # http://localhost:8080
``` ```
Use a specific domain database or port: For a local frontend development loop with seeded data, see [docs/getting-started.md](docs/getting-started.md).
```bash ## Project Shape
go run ./cmd/main.go --web --db ~/.local/share/box-box/boxbox.db --port 8080
```
## Run — Web dev (Vite + Go API) | Area | What lives there |
Vite proxies `/api` to the Go server. Set `BOXBOX_API_PORT` to match the Go `--port`.
**Terminal 1 — API (seeded DB is enough for UI work without ingesting):**
```bash
go run ./scripts/seed-e2e-db/main.go --db /tmp/boxbox-dev.db
BOXBOX_DISABLE_LIVE=1 go run ./cmd/main.go --web --db /tmp/boxbox-dev.db --port 18080
```
**Terminal 2 — frontend:**
```bash
BOXBOX_API_PORT=18080 npm run dev --prefix frontend
# default Vite port 5173 → http://localhost:5173
```
`BOXBOX_DISABLE_LIVE=1` skips starting the SignalR bridge (used in CI and local UI work).
## Web routes
| Route | Purpose |
| --- | --- | | --- | --- |
| `/` | **Command Center** — fan-facing race-weekend home with GP identity, live status, session schedule, and analysis links | | `cmd/main.go` | Entry point for TUI, web server, and ingestion CLI |
| `/race-hub?session_key=<key>` | **Race Hub** — weekend workspace with session rail and Overview / Race Story / Strategy / Lap Data / Conditions / Race Control / Data Status tabs. Bare `/race-hub` auto-resolves to the focus session. | | `internal/web/` | Go REST API, SSE live bridge, SPA serving |
| `/admin` | **Admin / Data Health** — ingestion coverage, local data status, and suggested CLI commands | | `internal/live/` | Official F1 SignalR client shared by Web and TUI |
| `/data-library` | Legacy alias for Admin / Data Health | | `internal/store/`, `internal/ingest/`, `internal/query/` | Local SQLite domain database, ingestion, and read models |
| `/live` | **Live Timing** — timing tower and race control via SSE when a session is live | | `internal/ui/` | Bubble Tea TUI |
| `frontend/` | React + Vite + TypeScript web app |
| `tests/` | Playwright e2e and visual coverage |
Example after seeding: `http://localhost:5173/race-hub?session_key=9472` The Web UI is local-first and should call the Go APIs under `/api/v1/...`; it should not read OpenF1 directly.
## Ingest historical data and briefing feeds ## Documentation
Ingestion is a **CLI mode** on the same binary. Only one of `--ingest-year`, `--ingest-meeting`, `--ingest-session`, or `--ingest-news` may be set per run. - [Getting Started](docs/getting-started.md): install, build, run modes, TUI keybindings, and web routes.
- [Data and Operations](docs/data-and-operations.md): ingestion, environment variables, local files, and live timing notes.
- [Testing](docs/testing.md): Go, frontend, e2e, and visual regression commands.
- [Architecture Notes](documentations/refactor/README.md): deeper design rationale, data-source decisions, and phase history.
```bash ## Status
# Season: discover and store meeting metadata (2023+)
go run ./cmd/main.go --ingest-year 2025
# Full race weekend: all sessions + Race Hub datasets Pre-beta and actively developed. The Web UI is the primary surface; the TUI is preserved and still useful for terminal workflows. Live timing depends on F1 broadcasting timing data, so it is only fully active during live sessions.
go run ./cmd/main.go --ingest-meeting 1229
# Single session only
go run ./cmd/main.go --ingest-session 9472
# Preview without writing
go run ./cmd/main.go --dry-run --ingest-meeting 1229
# Refresh Paddock Briefing RSS/Atom feeds
go run ./cmd/main.go --ingest-news
# Custom DB path (default: ~/.local/share/box-box/boxbox.db)
go run ./cmd/main.go --ingest-meeting 1229 --db /tmp/boxbox.db
```
Use **`--ingest-meeting`** for a complete weekend. **`--ingest-year`** lists meetings for the season; ingest meetings individually or by weekend as needed. Optional analytics fetches may partially fail without aborting the whole run.
## `OPENF1_API_KEY`
```bash
export OPENF1_API_KEY=your_key_here
go run ./cmd/main.go --web
```
Without a key, the free OpenF1 tier is used. A key may be required for paid-tier behavior (e.g. live session access during API lockouts). Ingestion and TUI calls use the same client.
## Local files
| Path | Purpose |
| --- | --- |
| `~/.local/share/box-box/boxbox.db` | Domain database (default `--db`) |
| `~/.cache/box-box/cache.db` | OpenF1 HTTP response cache (TUI / client) |
| `box-box.log` | TUI application log (project root) |
| `frontend/dist/` | Production React build (**gitignored** — build locally, do not commit) |
| `.playwright/*.db` | Seeded DBs for automated tests |
Web mode logs to **stderr**.
## Tests and QA
### Go (targeted packages)
```bash
go test ./internal/live ./internal/models ./internal/store ./internal/ingest ./internal/query ./internal/web
```
All packages: `go test ./...`
OpenF1 integration tests (network, rate-limit aware): `go test -v ./internal/api`
### Frontend unit tests and build
```bash
npm --prefix frontend test -- --run
npm --prefix frontend run build
```
### E2E (Vite dev proxy + seeded API)
Starts seeded Go on port `18080` and Vite on `15173` (see `playwright.config.ts`).
```bash
npx playwright install # first time only
npm run test:e2e
```
### E2E production (Go serves `frontend/dist`)
```bash
npm run test:e2e:prod
```
### Visual regression (screenshots)
```bash
npm run test:visual # dev proxy stack
npm run test:visual:prod # production serving (canonical baselines)
# after intentional UI changes
npm run test:visual:update
npm run test:visual:prod:update
```
Snapshots live under `tests/visual/__snapshots__/`.
## Known limitations
- **Live timing** only works when F1 is broadcasting timing data; there is no guaranteed live session for local dev.
- **E2E / visual tests** use `BOXBOX_DISABLE_LIVE=1` and seeded SQLite — they do not exercise full SignalR live behavior.
- **`frontend/dist`** is generated output; build before production web mode or `test:e2e:prod`.
- **TUI historical views** still use OpenF1 on demand with the HTTP cache; the Web UIs local-first model does not fully replace the TUI yet.
- **`--ingest-year`** stores season meetings, not full session datasets — use `--ingest-meeting` or `--ingest-session` for Race Hub data.
## License ## License
MIT © [Aman Tahiliani](https://github.com/AmanTahiliani) MIT © [Aman Tahiliani](https://github.com/AmanTahiliani)
---
*Unofficial project; not associated with Formula 1 or the FIA.* *Unofficial project; not associated with Formula 1 or the FIA.*

View File

@@ -41,11 +41,19 @@ func main() {
return return
} }
// BOXBOX_OPENF1_BASE_URL overrides the upstream OpenF1 API root. E2E runs
// point it at an unreachable address so tests stay hermetic and
// deterministic regardless of wall-clock date or network state.
baseURL := os.Getenv("BOXBOX_OPENF1_BASE_URL")
if baseURL == "" {
baseURL = "https://api.openf1.org"
}
var client *api.OpenF1Client var client *api.OpenF1Client
if apiKey := os.Getenv("OPENF1_API_KEY"); apiKey != "" { if apiKey := os.Getenv("OPENF1_API_KEY"); apiKey != "" {
client = api.NewOpenF1ClientWithKey("https://api.openf1.org", 15*time.Second, apiKey) client = api.NewOpenF1ClientWithKey(baseURL, 15*time.Second, apiKey)
} else { } else {
client = api.NewOpenF1Client("https://api.openf1.org", 15*time.Second) client = api.NewOpenF1Client(baseURL, 15*time.Second)
} }
defer client.Close() defer client.Close()

198
docs/PRODUCT_ROADMAP.md Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View File

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

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

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

59
docs/testing.md Normal file
View File

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

View File

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

View File

@@ -8,8 +8,10 @@
"name": "box-box-web", "name": "box-box-web",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@formkit/auto-animate": "^0.9.0",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.81.0", "@tanstack/react-router": "^1.81.0",
"lucide-react": "^1.23.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
@@ -902,6 +904,12 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@formkit/auto-animate": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.9.0.tgz",
"integrity": "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==",
"license": "MIT"
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -2556,6 +2564,15 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-react": {
"version": "1.23.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz",
"integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": { "node_modules/lz-string": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",

View File

@@ -11,8 +11,10 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@formkit/auto-animate": "^0.9.0",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.81.0", "@tanstack/react-router": "^1.81.0",
"lucide-react": "^1.23.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },

BIN
frontend/public/favicon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Copy, Check } from 'lucide-react'
interface Command { interface Command {
comment?: string comment?: string
@@ -39,8 +40,9 @@ function CliCommandLine({ cmd }: { cmd: string }) {
return ( return (
<div className="cli-cmd-row"> <div className="cli-cmd-row">
<code className="cli-cmd">{cmd}</code> <code className="cli-cmd">{cmd}</code>
<button type="button" className="cli-copy-btn" onClick={handleCopy} aria-label={`Copy ${cmd}`}> <button type="button" className="cli-copy-btn interactive" onClick={handleCopy} aria-label={`Copy ${cmd}`} style={{ display: 'flex', alignItems: 'center', gap: '4px', padding: '4px 8px', background: copied ? 'var(--green)' : 'rgba(255,255,255,0.05)', color: copied ? '#000' : 'var(--text)', border: 'none', borderRadius: '4px', cursor: 'pointer', transition: 'all 0.2s' }}>
{copied ? 'Copied' : 'Copy'} {copied ? <Check size={14} /> : <Copy size={14} />}
<span>{copied ? 'Copied' : 'Copy'}</span>
</button> </button>
</div> </div>
) )

View File

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

View File

@@ -1,6 +1,7 @@
import type { RaceHub } from '../types' import type { RaceHub } from '../types'
import { compareFinishPosition, formatDuration, formatGap, formatLapTime } from '../utils' import { compareFinishPosition, formatDuration, formatGap, formatLapTime } from '../utils'
import { countRaceHubDatasets } from '../lib/coverage' import { countRaceHubDatasets } from '../lib/coverage'
import { Thermometer, Map, Droplets, Wind, CloudRain } from 'lucide-react'
interface Props { interface Props {
data: RaceHub data: RaceHub
@@ -74,7 +75,7 @@ export function OverviewView({ data }: Props) {
</div> </div>
<div className="rh-overview-row"> <div className="rh-overview-row">
<section className="rh-panel"> <section className="rh-panel ui-card">
<div className="sec-header"> <div className="sec-header">
<span className="sec-title">Conditions</span> <span className="sec-title">Conditions</span>
{latestWeather && ( {latestWeather && (
@@ -83,17 +84,20 @@ export function OverviewView({ data }: Props) {
</div> </div>
{latestWeather ? ( {latestWeather ? (
<div className="rh-condition-strip" data-testid="rh-conditions"> <div className="rh-condition-strip" data-testid="rh-conditions">
<ConditionChip label="Air" value={`${latestWeather.air_temperature.toFixed(1)}°C`} /> <ConditionChip icon={Thermometer} label="Air" value={`${latestWeather.air_temperature.toFixed(1)}°C`} />
<ConditionChip <ConditionChip
icon={Map}
label="Track" label="Track"
value={`${latestWeather.track_temperature.toFixed(1)}°C`} value={`${latestWeather.track_temperature.toFixed(1)}°C`}
/> />
<ConditionChip label="Humidity" value={`${latestWeather.humidity.toFixed(0)}%`} /> <ConditionChip icon={Droplets} label="Humidity" value={`${latestWeather.humidity.toFixed(0)}%`} />
<ConditionChip <ConditionChip
icon={Wind}
label="Wind" label="Wind"
value={`${latestWeather.wind_speed.toFixed(1)} m/s`} value={`${latestWeather.wind_speed.toFixed(1)} m/s`}
/> />
<ConditionChip <ConditionChip
icon={CloudRain}
label="Rain" label="Rain"
value={latestWeather.rainfall > 0 ? 'Yes' : 'No'} value={latestWeather.rainfall > 0 ? 'Yes' : 'No'}
accent={latestWeather.rainfall > 0 ? 'wet' : undefined} accent={latestWeather.rainfall > 0 ? 'wet' : undefined}
@@ -104,7 +108,7 @@ export function OverviewView({ data }: Props) {
)} )}
</section> </section>
<section className="rh-panel"> <section className="rh-panel ui-card">
<div className="sec-header"> <div className="sec-header">
<span className="sec-title">Race Control · Latest</span> <span className="sec-title">Race Control · Latest</span>
<span className="sec-meta mono">{data.race_control.length}</span> <span className="sec-meta mono">{data.race_control.length}</span>
@@ -126,7 +130,7 @@ export function OverviewView({ data }: Props) {
)} )}
</section> </section>
<section className="rh-panel"> <section className="rh-panel ui-card">
<div className="sec-header"> <div className="sec-header">
<span className="sec-title">Local Coverage</span> <span className="sec-title">Local Coverage</span>
<span className="sec-meta mono"> <span className="sec-meta mono">
@@ -173,7 +177,7 @@ function StatCard({
}: StatCardProps) { }: StatCardProps) {
if (placeholder) { if (placeholder) {
return ( return (
<div className="rh-stat-card rh-stat-empty"> <div className="rh-stat-card ui-card rh-stat-empty">
<div className="rh-stat-label mono">{label}</div> <div className="rh-stat-label mono">{label}</div>
<div className="rh-stat-primary"></div> <div className="rh-stat-primary"></div>
<div className="rh-stat-secondary">No data ingested</div> <div className="rh-stat-secondary">No data ingested</div>
@@ -181,7 +185,7 @@ function StatCard({
) )
} }
return ( return (
<div className="rh-stat-card"> <div className="rh-stat-card ui-card interactive">
<div className="rh-stat-label mono">{label}</div> <div className="rh-stat-label mono">{label}</div>
<div className="rh-stat-primary" style={primaryColor ? { color: primaryColor } : undefined}> <div className="rh-stat-primary" style={primaryColor ? { color: primaryColor } : undefined}>
{primary} {primary}
@@ -196,7 +200,7 @@ function StatCard({
function PodiumCard({ podium }: { podium: Array<{ name_acronym: string; team_colour: string; position: number; full_name: string; gap_to_leader: number | string | number[] | null; duration: number | number[] | null; driver_number: number }> }) { function PodiumCard({ podium }: { podium: Array<{ name_acronym: string; team_colour: string; position: number; full_name: string; gap_to_leader: number | string | number[] | null; duration: number | number[] | null; driver_number: number }> }) {
if (podium.length === 0) { if (podium.length === 0) {
return ( return (
<div className="rh-stat-card rh-stat-empty"> <div className="rh-stat-card ui-card rh-stat-empty">
<div className="rh-stat-label mono">Podium</div> <div className="rh-stat-label mono">Podium</div>
<div className="rh-stat-primary"></div> <div className="rh-stat-primary"></div>
<div className="rh-stat-secondary">No classified finishers</div> <div className="rh-stat-secondary">No classified finishers</div>
@@ -204,7 +208,7 @@ function PodiumCard({ podium }: { podium: Array<{ name_acronym: string; team_col
) )
} }
return ( return (
<div className="rh-stat-card"> <div className="rh-stat-card ui-card interactive">
<div className="rh-stat-label mono">Podium</div> <div className="rh-stat-label mono">Podium</div>
<ol className="rh-podium-list"> <ol className="rh-podium-list">
{podium.map((r) => ( {podium.map((r) => (
@@ -232,13 +236,16 @@ function ConditionChip({
label, label,
value, value,
accent, accent,
icon: Icon,
}: { }: {
label: string label: string
value: string value: string
accent?: 'wet' accent?: 'wet'
icon?: React.ElementType
}) { }) {
return ( return (
<div className={`rh-condition-chip${accent === 'wet' ? ' rh-condition-wet' : ''}`}> <div className={`rh-condition-chip${accent === 'wet' ? ' rh-condition-wet' : ''}`} style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
{Icon && <Icon size={14} style={{ opacity: 0.7 }} />}
<span className="rh-condition-label mono">{label}</span> <span className="rh-condition-label mono">{label}</span>
<span className="rh-condition-value">{value}</span> <span className="rh-condition-value">{value}</span>
</div> </div>

View File

@@ -10,6 +10,7 @@ interface Props {
function SourceBadge({ source }: { source: RaceHub['source'] }) { function SourceBadge({ source }: { source: RaceHub['source'] }) {
if (source === 'local') return <span className="badge badge-local">Local</span> if (source === 'local') return <span className="badge badge-local">Local</span>
if (source === 'partial') return <span className="badge badge-partial">Partial</span> if (source === 'partial') return <span className="badge badge-partial">Partial</span>
if (source === 'cancelled') return <span className="badge badge-cancelled">Cancelled</span>
return <span className="badge badge-none">No data</span> return <span className="badge badge-none">No data</span>
} }

View File

@@ -1,4 +1,4 @@
type Source = 'local' | 'partial' | 'none' type Source = 'local' | 'partial' | 'none' | 'cancelled'
interface Props { interface Props {
source: Source source: Source
@@ -11,6 +11,8 @@ export function SourceBadge({ source, label }: Props) {
return <span className="badge badge-local">{label ?? 'Local'}</span> return <span className="badge badge-local">{label ?? 'Local'}</span>
case 'partial': case 'partial':
return <span className="badge badge-partial">{label ?? 'Partial'}</span> return <span className="badge badge-partial">{label ?? 'Partial'}</span>
case 'cancelled':
return <span className="badge badge-cancelled">{label ?? 'Cancelled'}</span>
default: default:
return <span className="badge badge-none">{label ?? 'None'}</span> return <span className="badge badge-none">{label ?? 'None'}</span>
} }
@@ -22,6 +24,8 @@ export function weekendStatusLabel(source: Source): string {
return 'Full' return 'Full'
case 'partial': case 'partial':
return 'Partial' return 'Partial'
case 'cancelled':
return 'Cancelled'
default: default:
return 'Missing' return 'Missing'
} }

View File

@@ -0,0 +1,21 @@
import type { Battle } from '../../lib/battles'
import { battleLabel } from '../../lib/battles'
interface Props {
battles: Battle[]
}
export function BattleChips({ battles }: Props) {
if (battles.length === 0) return null
return (
<div className="battle-chips" data-testid="battle-chips">
<span className="battle-chips-label">Battles</span>
{battles.map((battle) => (
<span className="battle-chip mono" key={battle.drivers.map((d) => d.racingNumber).join('-')}>
{battleLabel(battle)}
</span>
))}
</div>
)
}

View File

@@ -0,0 +1,44 @@
import { gapTrend, sparklinePoints } from '../../lib/gapHistory'
interface Props {
samples: number[] | undefined
width?: number
height?: number
}
export function GapSparkline({ samples, width = 56, height = 14 }: Props) {
if (!samples || samples.length < 2) {
return <span className="gap-spark gap-spark-empty">·</span>
}
const trend = gapTrend(samples)
const points = sparklinePoints(samples, width, height)
return (
<span className={`gap-spark trend-${trend ?? 'steady'}`} data-testid="gap-spark">
<svg
className="gap-spark-svg"
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
aria-hidden="true"
focusable="false"
>
<polyline
points={points}
fill="none"
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
{trend === 'closing' && (
<span className="trend-arrow trend-arrow-closing" title="Gap closing"></span>
)}
{trend === 'opening' && (
<span className="trend-arrow trend-arrow-opening" title="Gap opening"></span>
)}
</span>
)
}

View File

@@ -0,0 +1,63 @@
import { teamColor } from '../../utils'
import type { LiveTimingRow } from '../../lib/live'
import { driverCode, tyreClass, tyreLabel } from '../../lib/live'
import type { GapHistoryMap } from '../../lib/gapHistory'
import { GapSparkline } from './GapSparkline'
interface Props {
rows: LiveTimingRow[]
history: GapHistoryMap
pinned: string[]
onToggle: (racingNumber: string) => void
}
export function PinnedDrivers({ rows, history, pinned, onToggle }: Props) {
if (pinned.length === 0) return null
const rowsByNumber = new Map(rows.map((row) => [row.RacingNumber, row]))
return (
<div className="pinned-strip" data-testid="pinned-strip">
{pinned.map((number) => {
const row = rowsByNumber.get(number)
if (!row) {
return (
<button
type="button"
className="pinned-card pinned-card-missing"
key={number}
onClick={() => onToggle(number)}
title="Unpin driver"
>
<span className="drv-code">#{number}</span>
<span className="pinned-nodata">no data</span>
<span className="pinned-unpin" aria-hidden="true">×</span>
</button>
)
}
const driver = row.Driver
const gap = driver.Interval || driver.GapToLeader || '-'
return (
<button
type="button"
className="pinned-card"
key={number}
onClick={() => onToggle(number)}
title="Unpin driver"
>
<span className="pinned-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 ${tyreClass(row.Tyre)}`}>{tyreLabel(row.Tyre)}</span>
<span className="pinned-gap mono">{gap}</span>
<GapSparkline samples={history[number]} />
{driver.InPit && <span className="badge badge-pit">PIT</span>}
{driver.Retired && <span className="badge badge-out">RET</span>}
<span className="pinned-unpin" aria-hidden="true">×</span>
</button>
)
})}
</div>
)
}

View File

@@ -1,36 +1,119 @@
import type { LiveRCMessage } from '../../types' import { useEffect, useRef, useState } from 'react'
import type { LiveRCMessage, LiveDriverInfo } from '../../types'
import { latestRaceControl, rcFlagClass } from '../../lib/live' import { latestRaceControl, rcFlagClass } from '../../lib/live'
import { teamColor } from '../../utils'
import { useAutoAnimate } from '@formkit/auto-animate/react'
interface Props { interface Props {
messages: LiveRCMessage[] messages: LiveRCMessage[]
driverInfo?: Record<string, LiveDriverInfo>
} }
export function RaceControlFeed({ messages }: Props) { function FormattedMessage({ text, driverInfo }: { text: string, driverInfo?: Record<string, LiveDriverInfo> }) {
if (!text) return null;
const regex = /(CAR \d+(?: \([A-Z]{3}\))?|DELETED|TRACK LIMITS|INVESTIGATING|NO FURTHER INVESTIGATION|TIME PENALTY|DRIVE THROUGH PENALTY|STOP AND GO PENALTY|BLACK AND WHITE FLAG|DISQUALIFIED)/g;
const parts = text.split(regex);
return (
<span className="rc-message">
{parts.map((part, i) => {
if (!part) return null;
if (part.startsWith('CAR ')) {
let style = {}
const match = part.match(/CAR (\d+)/)
if (match && driverInfo && driverInfo[match[1]]) {
const hex = teamColor(driverInfo[match[1]].TeamColour)
style = {
backgroundColor: hex,
color: '#fff',
textShadow: '0 1px 2px rgba(0,0,0,0.5)',
border: 'none'
}
}
return <span key={i} className="rc-hlt rc-hlt-car" style={style}>{part}</span>
}
if (part === 'DELETED' || part.includes('PENALTY') || part === 'DISQUALIFIED') return <span key={i} className="rc-hlt rc-hlt-bad">{part}</span>;
if (part === 'TRACK LIMITS' || part === 'INVESTIGATING' || part === 'BLACK AND WHITE FLAG') return <span key={i} className="rc-hlt rc-hlt-warn">{part}</span>;
if (part === 'NO FURTHER INVESTIGATION') return <span key={i} className="rc-hlt rc-hlt-ok">{part}</span>;
return <span key={i}>{part}</span>;
})}
</span>
)
}
export function RaceControlFeed({ messages, driverInfo }: Props) {
const latest = latestRaceControl(messages) const latest = latestRaceControl(messages)
const [listRef] = useAutoAnimate<HTMLDivElement>()
const prevMessagesRef = useRef<LiveRCMessage[]>([])
const [newKeys, setNewKeys] = useState<Set<string>>(new Set())
useEffect(() => {
const prev = prevMessagesRef.current
if (prev.length > 0 && messages.length > prev.length) {
// Find new messages
const newlyAdded = messages.filter(
(m) => !prev.some((p) => p.Time === m.Time && p.Message === m.Message)
)
if (newlyAdded.length > 0) {
const keys = newlyAdded.map((m) => `${m.Time}-${m.Message}`)
setNewKeys((current) => {
const updated = new Set(current)
keys.forEach(k => updated.add(k))
return updated
})
// Clear highlight after 3 seconds
setTimeout(() => {
setNewKeys((current) => {
const updated = new Set(current)
keys.forEach(k => updated.delete(k))
return updated
})
}, 3000)
}
}
prevMessagesRef.current = messages
}, [messages])
return ( return (
<section className="live-rc"> <section className="live-rc panel-glass">
<div className="sec-header"> <div className="sec-header sticky-header">
<span className="sec-title">Race Control</span> <span className="sec-title">Race Control</span>
{messages.length > 0 && <span className="sec-meta">{messages.length} messages</span>} {messages.length > 0 && <span className="sec-meta">{messages.length} messages</span>}
</div> </div>
{latest.length === 0 ? ( {latest.length === 0 ? (
<div className="missing-notice">No race control messages in the current live snapshot.</div> <div className="missing-notice">No race control messages in the current live snapshot.</div>
) : ( ) : (
<div className="live-rc-list live-rc-scroll"> <div className="live-rc-list live-rc-scroll" ref={listRef}>
{latest.map((message, index) => ( {latest.map((message, index) => {
<div className="live-rc-row" key={`${message.Time}-${message.Message}-${index}`}> const key = `${message.Time}-${message.Message}`
<span className="rc-time">{message.Time || '--:--'}</span> const isNew = newKeys.has(key)
{message.Lap > 0 && <span className="rc-lap">L{message.Lap}</span>} let flashClass = ''
{message.Flag if (isNew) {
? <span className={`rc-flag ${rcFlagClass(message.Flag)}`}>{message.Flag}</span> if (message.Flag === 'YELLOW' || message.Flag === 'DOUBLE YELLOW') {
: message.Category && message.Category !== 'Other' flashClass = ' rc-item-yellow'
? <span className="rc-category">{message.Category}</span> } else if (message.Flag === 'RED') {
: null flashClass = ' rc-item-red'
} else {
flashClass = ' rc-item-new'
} }
<span className="rc-message">{message.Message}</span> }
</div>
))} return (
<div className={`live-rc-row${flashClass}`} key={key}>
<span className="rc-time">{message.Time || '--:--'}</span>
{message.Lap > 0 && <span className="rc-lap">L{message.Lap}</span>}
{message.Flag
? <span className={`rc-flag ${rcFlagClass(message.Flag)}`}>{message.Flag}</span>
: message.Category && message.Category !== 'Other'
? <span className="rc-category">{message.Category}</span>
: null
}
<FormattedMessage text={message.Message} driverInfo={driverInfo} />
</div>
)
})}
</div> </div>
)} )}
</section> </section>

View File

@@ -1,19 +1,22 @@
import type { LiveStreamData } from '../../types' import type { LiveStreamData } from '../../types'
import { extrapolateClock, trackStatusClass, trackStatusLabel } from '../../lib/live' import type { LiveTimingRow } from '../../lib/live'
import { extrapolateClock, liveSessionDisplay } from '../../lib/live'
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 status = snapshot.TrackStatus ? trackStatusLabel(snapshot.TrackStatus) : '' const display = liveSessionDisplay(session, rows)
const weather = snapshot.Weather const atRiskLabel =
const hasWeather = weather && (weather.AirTemp > 0 || weather.TrackTemp > 0) display.atRiskStart && display.atRiskEnd ? `P${display.atRiskStart}-P${display.atRiskEnd} at risk` : ''
return ( return (
<section className="live-banner"> <section className="live-banner">
@@ -27,24 +30,20 @@ export function SessionBanner({ isLive, snapshot, connection, now }: Props) {
</p> </p>
</div> </div>
</div> </div>
<div className="live-banner-meta"> <div className="live-session-board">
{status && <span className={`track-status ${trackStatusClass(snapshot.TrackStatus)}`}>{status}</span>} {display.phaseLabel && <span className="live-phase-pill mono">{display.phaseLabel}</span>}
<span className="mono"> <div className="live-clock mono" data-testid="live-clock">{clock || '--:--:--'}</div>
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong> <div className="live-banner-meta">
</span> {display.advanceCount && <span>{display.advanceCount} advance</span>}
{clock && <span className="mono">{clock}</span>} {atRiskLabel && <span>{atRiskLabel}</span>}
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{isLive ? 'live' : 'stale'}</span> <span>
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
</span>
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{isLive ? 'live' : 'stale'}</span>
</div>
</div> </div>
</div> </div>
{hasWeather && ( <WeatherStrip weather={snapshot.Weather} />
<div className="live-weather-strip">
<span>{weather.AirTemp.toFixed(0)}° air</span>
<span>{weather.TrackTemp.toFixed(0)}° track</span>
{weather.Humidity > 0 && <span>{weather.Humidity.toFixed(0)}% humidity</span>}
{weather.WindSpeed > 0 && <span>{weather.WindSpeed.toFixed(1)} m/s</span>}
{weather.Rainfall && <span className="badge badge-wet">WET</span>}
</div>
)}
</section> </section>
) )
} }

View File

@@ -0,0 +1,29 @@
import type { LiveStintData } from '../../types'
import { compoundClass, compoundLetter } from '../../lib/live'
interface Props {
stints: LiveStintData[] | undefined
}
export function StintHistory({ stints }: Props) {
if (!stints || stints.length === 0) {
return <span className="stint-empty">-</span>
}
return (
<span className="stint-seq" data-testid="stint-seq">
{stints.map((stint, index) => (
<span className="stint-item" key={index}>
{index > 0 && <span className="stint-arrow"></span>}
<span
className={`stint-dot ${compoundClass(stint.Compound)}`}
title={`${stint.Compound || 'Unknown'}${stint.New ? ' (new)' : ''} · ${stint.Laps} laps`}
>
{compoundLetter(stint.Compound)}
</span>
{stint.Laps > 0 && <span className="stint-laps">{stint.Laps}</span>}
</span>
))}
</span>
)
}

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,16 +1,29 @@
import { useState, Fragment } from 'react'
import { teamColor } from '../../utils' import { teamColor } from '../../utils'
import type { LiveStreamData } from '../../types' import { useAutoAnimate } from '@formkit/auto-animate/react'
import type { LiveSessionMeta, LiveStintData } from '../../types'
import type { LiveTimingRow } from '../../lib/live'
import { import {
driverCode, driverCode,
liveSessionDisplay,
positionDelta, positionDelta,
positionDeltaClass, positionDeltaClass,
sortLiveTimingRows,
tyreClass, tyreClass,
tyreLabel, tyreLabel,
} from '../../lib/live' } from '../../lib/live'
import type { GapHistoryMap } from '../../lib/gapHistory'
import { GapSparkline } from './GapSparkline'
import { StintHistory } from './StintHistory'
import { Pin } from 'lucide-react'
interface Props { interface Props {
snapshot: LiveStreamData rows: LiveTimingRow[]
stints?: Record<string, LiveStintData[]>
history?: GapHistoryMap
battleNumbers?: Set<string>
pinned?: string[]
onTogglePin?: (racingNumber: string) => void
session?: LiveSessionMeta
} }
function posClass(pos: number): string { function posClass(pos: number): string {
@@ -20,8 +33,18 @@ function posClass(pos: number): string {
return 'pos-n' return 'pos-n'
} }
export function TimingTower({ snapshot }: Props) { export function TimingTower({
const rows = sortLiveTimingRows(snapshot) rows,
stints,
history,
battleNumbers,
pinned,
onTogglePin,
session,
}: Props) {
const [expandedRow, setExpandedRow] = useState<string | null>(null)
const [gapMode, setGapMode] = useState<'interval' | 'leader'>('interval')
const [listRef] = useAutoAnimate<HTMLTableSectionElement>({ duration: 300, easing: 'ease-out' })
if (rows.length === 0) { if (rows.length === 0) {
return ( return (
@@ -31,62 +54,168 @@ export function TimingTower({ snapshot }: Props) {
) )
} }
const sessionDisplay = liveSessionDisplay(session, rows)
const isRace = sessionDisplay.isRace
const isQuali = sessionDisplay.isQualifying || !isRace
const columnCount = 7 + (isRace ? 3 : 0) + (isQuali ? 3 : 0)
return ( return (
<div className="scroll-x"> <div className="scroll-x">
<table className="data-table live-tower" style={{ minWidth: 480 }}> <table className="data-table live-tower" style={{ minWidth: 760 }}>
<thead> <thead>
<tr> <tr>
<th>Pos</th> <th>Pos</th>
<th>Δ</th> {isRace && <th>Δ</th>}
<th>Driver</th> <th>Driver</th>
<th>Tyre</th> <th>Tyre</th>
<th>Last Lap</th> <th>Last Lap</th>
<th>Gap</th> <th
className="interactive"
onClick={() => setGapMode(g => g === 'interval' ? 'leader' : 'interval')}
title="Click to toggle Gap vs Interval"
style={{ cursor: 'pointer', textDecoration: 'underline' }}
>
{gapMode === 'interval' && isRace ? 'Interval' : 'Gap to P1'}
</th>
{isRace && <th>Trend</th>}
{isQuali && <th>S1</th>}
{isQuali && <th>S2</th>}
{isQuali && <th>S3</th>}
<th className="hide-mobile">Best</th> <th className="hide-mobile">Best</th>
<th className="hide-mobile r">Laps</th> {isRace && <th className="hide-mobile r">Laps</th>}
<th className="r"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody ref={listRef}>
{rows.map((row) => { {rows.map((row) => {
const driver = row.Driver const driver = row.Driver
const delta = positionDelta(driver) const delta = positionDelta(driver)
const deltaClass = positionDeltaClass(driver) const deltaClass = positionDeltaClass(driver)
const isPinned = pinned?.includes(row.RacingNumber) ?? false
const inBattle = battleNumbers?.has(row.RacingNumber) ?? false
const isExpanded = expandedRow === row.RacingNumber
const isAtRisk =
Boolean(sessionDisplay.cutoffPosition && row.Position > sessionDisplay.cutoffPosition) ||
driver.Cutoff
const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition
const gapText = gapMode === 'interval' && isRace ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader
const renderSector = (idx: number) => {
const sec = driver.Sectors?.[idx]
if (!sec) return '-'
let sClass = 'sector-time mono'
if (sec.OverallFastest) sClass += ' sector-overall txt-purple'
else if (sec.PersonalFastest) sClass += ' sector-personal txt-green'
else if (sec.Value) sClass += ' sector-active txt-yellow'
return <span className={sClass}>{sec.Value || '-'}</span>
}
return ( return (
<tr <Fragment key={row.RacingNumber}>
key={row.RacingNumber} <tr
className={[ className={[
driver.InPit ? 'in-pit' : '', driver.InPit ? 'in-pit' : '',
driver.PitOut ? 'pit-out' : '', driver.PitOut ? 'pit-out' : '',
driver.Retired ? 'retired' : '', driver.Retired ? 'retired' : '',
].filter(Boolean).join(' ')} inBattle ? 'battle-row' : '',
> isPinned ? 'pinned-row' : '',
<td className={`mono ${posClass(row.Position)}`}>{row.Position}</td> isAtRisk && !driver.KnockedOut ? 'danger-row' : '',
<td className={`pos-delta${deltaClass ? ` ${deltaClass}` : ''}`}>{delta}</td> driver.OnFlyingLap ? 'flying-row' : '',
<td> 'interactive-row'
<div className="drv-cell"> ].filter(Boolean).join(' ')}
<div className="drv-bar" style={{ background: teamColor(row.Info?.TeamColour) }} /> onClick={() => setExpandedRow(isExpanded ? null : row.RacingNumber)}
<span className="drv-code">{driverCode(row)}</span> style={{ cursor: 'pointer' }}
<span className="drv-num">{row.RacingNumber}</span> >
{driver.InPit && <span className="badge badge-pit">PIT</span>} <td className={`mono ${posClass(row.Position)}`}>{row.Position}</td>
{driver.PitOut && !driver.InPit && <span className="badge badge-pit">OUT</span>} {isRace && <td className={`pos-delta${deltaClass ? ` ${deltaClass}` : ''}`}>{delta}</td>}
{driver.Retired && <span className="badge badge-out">RET</span>} <td>
{driver.KnockedOut && <span className="badge badge-knocked">KO</span>} <div className="drv-cell">
{driver.Cutoff && !driver.KnockedOut && <span className="badge badge-cutoff">CUT</span>} <div className="drv-bar" style={{ background: teamColor(row.Info?.TeamColour) }} />
{driver.OnFlyingLap && <span className="badge badge-flying">FL</span>} <span className="drv-code">{driverCode(row)}</span>
</div> <span className="drv-num">{row.RacingNumber}</span>
</td> {driver.InPit && <span className="badge badge-pit">PIT</span>}
<td> {driver.PitOut && !driver.InPit && <span className="badge badge-pit">OUT</span>}
<span className={`tyre-badge ${tyreClass(row.Tyre)}`}>{tyreLabel(row.Tyre)}</span> {driver.Retired && <span className="badge badge-out">RET</span>}
</td> {driver.KnockedOut && <span className="badge badge-knocked">KO</span>}
<td className={driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono'}> {driver.Cutoff && !driver.KnockedOut && <span className="badge badge-cutoff">CUT</span>}
{driver.LastLapTime || '-'} {!driver.Cutoff && isAtRisk && !driver.KnockedOut && <span className="badge badge-risk">RISK</span>}
</td> {driver.OnFlyingLap && <span className="badge badge-flying">FL</span>}
<td className="mono">{driver.GapToLeader || driver.Interval || '-'}</td> </div>
<td className={`hide-mobile ${driver.BestLapOB ? 'mono lap-ob' : 'mono'}`}> </td>
{driver.BestLapTime || '-'} <td>
</td> <span className={`tyre-badge ${tyreClass(row.Tyre)}`}>{tyreLabel(row.Tyre)}</span>
<td className="hide-mobile mono r">{driver.NumberOfLaps || '-'}</td> </td>
</tr> <td className={driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono'}>
{driver.LastLapTime || '-'}
</td>
<td className="mono">{gapText || '-'}</td>
{isRace && (
<td className="spark-cell">
<GapSparkline samples={history?.[row.RacingNumber]} />
</td>
)}
{isQuali && <td>{renderSector(0)}</td>}
{isQuali && <td>{renderSector(1)}</td>}
{isQuali && <td>{renderSector(2)}</td>}
<td className={`hide-mobile ${driver.BestLapOB ? 'mono lap-ob' : 'mono'}`}>
{driver.BestLapTime || '-'}
</td>
{isRace && <td className="hide-mobile mono r">{driver.NumberOfLaps || '-'}</td>}
<td className="r">
{onTogglePin && (
<button
className="pin-btn interactive"
onClick={(e) => { e.stopPropagation(); onTogglePin(row.RacingNumber); }}
style={{ opacity: isPinned ? 1 : 0.3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text)' }}
title={isPinned ? 'Unpin driver' : 'Pin driver'}
>
<Pin size={14} fill={isPinned ? 'currentColor' : 'none'} />
</button>
)}
</td>
</tr>
{isExpanded && (
<tr className="expanded-row">
<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>
<div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>STINTS</div>
<StintHistory stints={stints?.[row.RacingNumber]} />
</div>
{!isRace && (
<div>
<div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>LAPS</div>
<div className="mono">{driver.NumberOfLaps || 0}</div>
</div>
)}
{driver.SpeedTrap && (
<div>
<div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>SPEED TRAP</div>
<div className="mono">{driver.SpeedTrap} km/h</div>
</div>
)}
</div>
</td>
</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>
) )
})} })}
</tbody> </tbody>

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,17 @@
import { trackStatusInfo } from '../../lib/live'
interface Props {
status: string | null | undefined
}
export function TrackStatusBanner({ status }: Props) {
if (!status) return null
const info = trackStatusInfo(status)
return (
<div className={`track-banner track-banner-${info.key}`} role="status" data-testid="track-banner">
<span className="track-banner-label">{info.label}</span>
{info.detail && <span className="track-banner-detail">{info.detail}</span>}
</div>
)
}

View File

@@ -0,0 +1,51 @@
import type { LiveWeatherData } from '../../types'
import { windDirectionLabel } from '../../lib/live'
interface Props {
weather: LiveWeatherData | null | undefined
}
export function WeatherStrip({ weather }: Props) {
if (!weather) return null
const hasData =
weather.AirTemp > 0 ||
weather.TrackTemp > 0 ||
weather.Humidity > 0 ||
weather.WindSpeed > 0 ||
weather.Rainfall
if (!hasData) return null
const windDir = windDirectionLabel(weather.WindDir)
return (
<div className="live-weather-strip" data-testid="weather-strip">
{weather.AirTemp > 0 && (
<span className="weather-item">
<span className="weather-k">air</span>
<span className="weather-v">{weather.AirTemp.toFixed(0)}°C</span>
</span>
)}
{weather.TrackTemp > 0 && (
<span className="weather-item">
<span className="weather-k">track</span>
<span className="weather-v">{weather.TrackTemp.toFixed(0)}°C</span>
</span>
)}
{weather.Humidity > 0 && (
<span className="weather-item">
<span className="weather-k">hum</span>
<span className="weather-v">{weather.Humidity.toFixed(0)}%</span>
</span>
)}
{weather.WindSpeed > 0 && (
<span className="weather-item">
<span className="weather-k">wind</span>
<span className="weather-v">
{weather.WindSpeed.toFixed(1)} m/s{windDir ? ` ${windDir}` : ''}
</span>
</span>
)}
{weather.Rainfall && <span className="badge badge-wet">RAIN</span>}
</div>
)
}

108
frontend/src/lib/battles.ts Normal file
View File

@@ -0,0 +1,108 @@
// Battle detection for the live timing tower.
// Pure functions only — no React, no side effects — so everything is unit-testable.
import type { LiveTimingRow } from './live'
import { driverCode } from './live'
import { parseIntervalSeconds } from './gapHistory'
export const BATTLE_THRESHOLD_SECONDS = 1.0
export interface BattleDriver {
racingNumber: string
code: string
position: number
/** Interval to the car ahead within the group; null for the group leader. */
gapToAhead: number | null
}
export interface Battle {
drivers: BattleDriver[]
/** Tightest car-to-car interval within the group. */
minGap: number
}
/** Battles are only meaningful in race-type sessions (GP race, sprint). */
export function isRaceSession(sessionType: string | null | undefined): boolean {
if (!sessionType) return false
const type = sessionType.toLowerCase()
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 {
const driver = row.Driver
return Boolean(driver) && !driver.InPit && !driver.Retired && row.Position > 0
}
/**
* Scan position-sorted tower rows and group consecutive cars racing within
* `threshold` seconds of the car ahead. Cars in the pits or retired break
* the chain, as do lapped/unparsable intervals.
*/
export function detectBattles(
rows: ReadonlyArray<LiveTimingRow>,
sessionType: string | null | undefined,
threshold = BATTLE_THRESHOLD_SECONDS,
): Battle[] {
if (!isRaceSession(sessionType) || rows.length < 2) return []
const battles: Battle[] = []
let current: BattleDriver[] | null = null
let minGap = Number.POSITIVE_INFINITY
const flush = () => {
if (current && current.length >= 2) {
battles.push({ drivers: current, minGap })
}
current = null
minGap = Number.POSITIVE_INFINITY
}
for (let i = 1; i < rows.length; i++) {
const ahead = rows[i - 1]
const row = rows[i]
const gap = parseIntervalSeconds(row.Driver?.Interval)
const inBattle =
gap !== null && gap >= 0 && gap <= threshold && isEligible(ahead) && isEligible(row)
if (!inBattle) {
flush()
continue
}
if (!current) {
current = [toBattleDriver(ahead, null)]
}
current.push(toBattleDriver(row, gap))
if (gap < minGap) minGap = gap
}
flush()
return battles
}
function toBattleDriver(row: LiveTimingRow, gapToAhead: number | null): BattleDriver {
return {
racingNumber: row.RacingNumber,
code: driverCode(row),
position: row.Position,
gapToAhead,
}
}
/** Chip label, e.g. "VER ⚔ NOR +0.4" or "VER ⚔ NOR ⚔ PIA +0.3". */
export function battleLabel(battle: Battle): string {
const codes = battle.drivers.map((driver) => driver.code).join(' ⚔ ')
const gap = Number.isFinite(battle.minGap) ? ` +${battle.minGap.toFixed(1)}` : ''
return `${codes}${gap}`
}
/** Racing numbers involved in any battle, for tower row highlighting. */
export function battleNumbers(battles: ReadonlyArray<Battle>): Set<string> {
const numbers = new Set<string>()
for (const battle of battles) {
for (const driver of battle.drivers) numbers.add(driver.racingNumber)
}
return numbers
}

View File

@@ -51,6 +51,7 @@ export function countWeekendStats(weekends: (Weekend | undefined)[]) {
let full = 0 let full = 0
let partial = 0 let partial = 0
let missing = 0 let missing = 0
let cancelled = 0
for (const weekend of weekends) { for (const weekend of weekends) {
if (!weekend || weekend.sessions.length === 0) { if (!weekend || weekend.sessions.length === 0) {
@@ -64,15 +65,19 @@ export function countWeekendStats(weekends: (Weekend | undefined)[]) {
case 'partial': case 'partial':
partial++ partial++
break break
case 'cancelled':
cancelled++
break
default: default:
missing++ missing++
} }
} }
return { full, partial, missing, total: weekends.length } return { full, partial, cancelled, missing, total: weekends.length }
} }
export function sessionIconClass(session: WeekendSession): string { export function sessionIconClass(session: WeekendSession): string {
if (session.source === 'cancelled') return 'si-cancelled'
if (session.source === 'none') return 'si-missing' if (session.source === 'none') return 'si-missing'
if (isSessionComplete(session.datasets)) return 'si-full' if (isSessionComplete(session.datasets)) return 'si-full'
return 'si-partial' return 'si-partial'

View File

@@ -0,0 +1,108 @@
// Client-side gap/interval history tracking for the live timing tower.
// Pure functions only — no React, no side effects — so everything is unit-testable.
export const MAX_GAP_SAMPLES = 40
/** Per-driver ring buffer of interval samples (seconds), keyed by racing number. */
export type GapHistoryMap = Record<string, number[]>
export type GapTrend = 'closing' | 'opening' | 'steady'
/**
* Parse an F1 live-timing interval/gap string into seconds.
* Handles "+1.234", "1.234", "+1:05.678" (minute form) and rejects
* lapped/leader markers: "", "LAP 12", "1L", "+1 LAP", "2 LAPS", etc.
*/
export function parseIntervalSeconds(raw: string | null | undefined): number | null {
if (!raw) return null
const text = raw.trim()
if (!text) return null
// Lapped / leader markers are never numeric gaps.
if (/lap/i.test(text) || /^\+?\d+\s*L$/i.test(text)) return null
const match = text.match(/^([+-])?(?:(\d+):)?(\d+(?:\.\d+)?)$/)
if (!match) return null
const sign = match[1] === '-' ? -1 : 1
const minutes = match[2] ? Number(match[2]) : 0
const seconds = Number(match[3])
if (!Number.isFinite(minutes) || !Number.isFinite(seconds)) return null
return sign * (minutes * 60 + seconds)
}
export interface GapSampleInput {
racingNumber: string
interval: string
}
/**
* Record one snapshot's worth of interval samples.
* Returns a new map (input is not mutated). Drivers missing from `rows`
* are pruned; unparsable intervals keep the existing buffer untouched.
*/
export function recordGapSamples(
history: GapHistoryMap,
rows: ReadonlyArray<GapSampleInput>,
maxSamples = MAX_GAP_SAMPLES,
): GapHistoryMap {
const next: GapHistoryMap = {}
for (const row of rows) {
if (!row.racingNumber) continue
const existing = history[row.racingNumber] ?? []
const value = parseIntervalSeconds(row.interval)
if (value === null) {
if (existing.length > 0) next[row.racingNumber] = existing
continue
}
const samples = [...existing, value]
next[row.racingNumber] = samples.length > maxSamples ? samples.slice(samples.length - maxSamples) : samples
}
return next
}
/**
* Classify the recent trend of a gap buffer: is the driver closing on the
* car ahead, dropping back, or holding steady? Compares the mean of the
* older half vs the newer half of the most recent samples.
*/
export function gapTrend(samples: ReadonlyArray<number>, window = 10, threshold = 0.1): GapTrend | null {
if (!samples || samples.length < 3) return null
const recent = samples.slice(-window)
const mid = Math.floor(recent.length / 2)
const older = recent.slice(0, mid)
const newer = recent.slice(mid)
if (older.length === 0 || newer.length === 0) return null
const mean = (xs: ReadonlyArray<number>) => xs.reduce((a, b) => a + b, 0) / xs.length
const delta = mean(newer) - mean(older)
if (delta <= -threshold) return 'closing'
if (delta >= threshold) return 'opening'
return 'steady'
}
/**
* Compute SVG polyline points for a sparkline of the samples, fitted to
* width x height with a small vertical inset. Flat data draws a mid line.
*/
export function sparklinePoints(
samples: ReadonlyArray<number>,
width: number,
height: number,
inset = 1.5,
): string {
if (!samples || samples.length < 2) return ''
const min = Math.min(...samples)
const max = Math.max(...samples)
const span = max - min
const usable = height - inset * 2
const step = width / (samples.length - 1)
return samples
.map((value, index) => {
const x = index * step
const y = span === 0 ? height / 2 : inset + (1 - (value - min) / span) * usable
return `${x.toFixed(1)},${y.toFixed(1)}`
})
.join(' ')
}

View File

@@ -2,6 +2,8 @@ import type {
LiveDriverData, LiveDriverData,
LiveDriverInfo, LiveDriverInfo,
LiveRCMessage, LiveRCMessage,
LiveSectorData,
LiveSessionMeta,
LiveStateResponse, LiveStateResponse,
LiveStreamData, LiveStreamData,
LiveTyreData, LiveTyreData,
@@ -15,12 +17,62 @@ 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',
'4': 'SC', '4': 'SC',
'5': 'RED', '5': 'RED',
'6': 'VSC', '6': 'VSC',
'7': 'VSC ENDING',
}
export interface TrackStatusInfo {
key: 'green' | 'yellow' | 'sc' | 'vsc' | 'red' | 'unknown'
label: string
detail: string
}
// Raw F1 SignalR TrackStatus.Status values (see internal/live/types.go):
// "1"=all clear, "2"=yellow, "4"=safety car, "5"=red, "6"=VSC, "7"=VSC ending.
// "3" has not been observed in the feed; unknown values fall through to a
// neutral display so a new encoding never breaks the banner.
const TRACK_STATUS_INFO: Record<string, TrackStatusInfo> = {
'1': { key: 'green', label: 'TRACK CLEAR', detail: 'Green flag — racing' },
'2': { key: 'yellow', label: 'YELLOW FLAG', detail: 'Caution on track' },
'4': { key: 'sc', label: 'SAFETY CAR', detail: 'Safety car deployed' },
'5': { key: 'red', label: 'RED FLAG', detail: 'Session stopped' },
'6': { key: 'vsc', label: 'VIRTUAL SAFETY CAR', detail: 'VSC deployed' },
'7': { key: 'vsc', label: 'VSC ENDING', detail: 'Virtual safety car ending' },
}
export function trackStatusInfo(status: string | null | undefined): TrackStatusInfo {
if (status && TRACK_STATUS_INFO[status]) return TRACK_STATUS_INFO[status]
return {
key: 'unknown',
label: status ? `TRACK STATUS ${status}` : 'TRACK STATUS UNKNOWN',
detail: '',
}
} }
export function parseLiveStateEvent(data: string): LiveStateResponse | null { export function parseLiveStateEvent(data: string): LiveStateResponse | null {
@@ -82,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
} }
@@ -90,10 +272,6 @@ export function trackStatusLabel(status: string): string {
return TRACK_STATUS_LABELS[status] || status || 'UNKNOWN' return TRACK_STATUS_LABELS[status] || status || 'UNKNOWN'
} }
export function trackStatusClass(status: string): string {
return `track-${trackStatusLabel(status).toLowerCase()}`
}
export function positionDelta(driver: LiveDriverData): string { export function positionDelta(driver: LiveDriverData): string {
if (!driver.PrevPosition || !driver.Position || driver.PrevPosition === driver.Position) return '' if (!driver.PrevPosition || !driver.Position || driver.PrevPosition === driver.Position) return ''
return driver.PrevPosition > driver.Position ? '▲' : '▼' return driver.PrevPosition > driver.Position ? '▲' : '▼'
@@ -132,10 +310,68 @@ export function tyreLabel(tyre: LiveTyreData | undefined): string {
return `${compound} +${tyre.Age || 0}` return `${compound} +${tyre.Age || 0}`
} }
export function compoundClass(compound: string | null | undefined): string {
if (!compound) return 'tyre-unknown'
const normalized = compound.toLowerCase()
return `tyre-${normalized === 'intermediate' ? 'inter' : normalized}`
}
export function compoundLetter(compound: string | null | undefined): string {
return compound?.charAt(0).toUpperCase() || '?'
}
export function tyreClass(tyre: LiveTyreData | undefined): string { export function tyreClass(tyre: LiveTyreData | undefined): string {
if (!tyre?.Compound) return 'tyre-unknown' return compoundClass(tyre?.Compound)
const compound = tyre.Compound.toLowerCase() }
return `tyre-${compound === 'intermediate' ? 'inter' : compound}`
export function windDirectionLabel(degrees: number | null | undefined): string {
if (degrees == null || !Number.isFinite(degrees)) return ''
const points = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
const index = Math.round((((degrees % 360) + 360) % 360) / 45) % 8
return points[index]
}
export const MAX_PINNED_DRIVERS = 3
/**
* Toggle a driver pin. Unpins if already pinned; otherwise appends,
* dropping the oldest pin when at capacity so clicking always works.
*/
export function togglePin(pins: ReadonlyArray<string>, racingNumber: string, max = MAX_PINNED_DRIVERS): string[] {
if (!racingNumber) return [...pins]
if (pins.includes(racingNumber)) return pins.filter((pin) => pin !== racingNumber)
const next = [...pins, racingNumber]
return next.length > max ? next.slice(next.length - max) : next
}
const PINS_STORAGE_KEY = 'box-box.live.pins'
export function loadPinnedDrivers(storage: Pick<Storage, 'getItem'> | null = safeStorage()): string[] {
try {
const raw = storage?.getItem(PINS_STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter((pin): pin is string => typeof pin === 'string').slice(0, MAX_PINNED_DRIVERS)
} catch {
return []
}
}
export function savePinnedDrivers(pins: ReadonlyArray<string>, storage: Pick<Storage, 'setItem'> | null = safeStorage()): void {
try {
storage?.setItem(PINS_STORAGE_KEY, JSON.stringify(pins))
} catch {
// storage unavailable (private mode, SSR) — pins just won't persist
}
}
function safeStorage(): Storage | null {
try {
return typeof window !== 'undefined' ? window.localStorage : null
} catch {
return null
}
} }
export function latestRaceControl(messages: LiveRCMessage[], limit = 10): LiveRCMessage[] { export function latestRaceControl(messages: LiveRCMessage[], limit = 10): LiveRCMessage[] {

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,183 @@
// Championship simulator: project the drivers' championship over the
// remaining rounds from per-round finishing assignments.
// Pure functions only — no React, no side effects — so everything is unit-testable.
import type { ChampHubDriver } from '../types'
/** Current F1 points system for P1P10. No fastest-lap point (dropped in 2025). */
export const POINTS_BY_POSITION = [25, 18, 15, 12, 10, 8, 6, 4, 2, 1] as const
/** Number of points-scoring positions per round. */
export const SCORING_POSITIONS = POINTS_BY_POSITION.length
/** Maximum points a single driver can take from one round. */
export const MAX_POINTS_PER_ROUND = POINTS_BY_POSITION[0]
/**
* One remaining round: driver numbers assigned to P1P10 (index 0 = P1).
* `null` means the position is unassigned (nobody scores those points).
*/
export type RoundAssignment = (number | null)[]
/** One assignment per remaining round, in chronological order. */
export type Scenario = RoundAssignment[]
/** Points for a 1-based finishing position; 0 outside the top ten. */
export function pointsForPosition(position: number): number {
if (!Number.isInteger(position) || position < 1 || position > SCORING_POSITIONS) return 0
return POINTS_BY_POSITION[position - 1]
}
/** An empty round: all ten scoring positions unassigned. */
export function emptyRound(): RoundAssignment {
return Array.from({ length: SCORING_POSITIONS }, () => null)
}
/** Drivers sorted by current championship order (points desc, position asc). */
function championshipOrder(drivers: ReadonlyArray<ChampHubDriver>): ChampHubDriver[] {
return [...drivers].sort((a, b) => b.points - a.points || a.position - b.position)
}
/**
* Default assignment for one round: the top ten drivers in current
* championship order finish P1P10.
*/
export function defaultRound(drivers: ReadonlyArray<ChampHubDriver>): RoundAssignment {
const ordered = championshipOrder(drivers)
const round = emptyRound()
for (let i = 0; i < SCORING_POSITIONS; i++) {
round[i] = ordered[i]?.driver_number ?? null
}
return round
}
/** Default scenario: every remaining round finishes in current championship order. */
export function defaultScenario(drivers: ReadonlyArray<ChampHubDriver>, roundsLeft: number): Scenario {
const rounds = Math.max(0, Math.floor(roundsLeft) || 0)
return Array.from({ length: rounds }, () => defaultRound(drivers))
}
/**
* Validate an untrusted (e.g. localStorage) scenario against the current hub.
* Rounds with the wrong shape, unknown driver numbers, or duplicate drivers
* fall back to the default round; the scenario is trimmed/padded to
* `roundsLeft`. Never throws.
*/
export function normalizeScenario(
raw: unknown,
drivers: ReadonlyArray<ChampHubDriver>,
roundsLeft: number,
): Scenario {
const fallback = defaultScenario(drivers, roundsLeft)
if (!Array.isArray(raw)) return fallback
const known = new Set(drivers.map((d) => d.driver_number))
return fallback.map((defRound, i) => {
const candidate = raw[i]
if (!Array.isArray(candidate) || candidate.length !== SCORING_POSITIONS) return defRound
const seen = new Set<number>()
const round = emptyRound()
for (let p = 0; p < SCORING_POSITIONS; p++) {
const v = candidate[p]
if (v === null) continue
if (typeof v !== 'number' || !known.has(v) || seen.has(v)) return defRound
seen.add(v)
round[p] = v
}
return round
})
}
/** Total simulated (extra) points per driver number across the scenario. */
export function simulatedPoints(scenario: Scenario): Map<number, number> {
const totals = new Map<number, number>()
for (const round of scenario) {
if (!Array.isArray(round)) continue
for (let p = 0; p < Math.min(round.length, SCORING_POSITIONS); p++) {
const driverNumber = round[p]
if (driverNumber == null) continue
totals.set(driverNumber, (totals.get(driverNumber) ?? 0) + POINTS_BY_POSITION[p])
}
}
return totals
}
export interface ProjectedDriver {
driver: ChampHubDriver
currentPoints: number
simPoints: number
projectedPoints: number
currentPosition: number
projectedPosition: number
/** Positive = moved up the standings, negative = dropped. */
delta: number
/** Mathematically alive for the title under the max-points-remaining bound. */
titleAlive: boolean
}
/**
* Project final standings from current standings plus a scenario.
* Ties on projected points keep the driver with the better current position ahead.
*
* Title math: a driver is mathematically alive if
* current points + 25 × rounds left ≥ current leader's points under the scenario.
* The current leader is always alive by this bound.
*/
export function projectStandings(
drivers: ReadonlyArray<ChampHubDriver>,
scenario: Scenario,
roundsLeft: number,
): ProjectedDriver[] {
if (drivers.length === 0) return []
const extras = simulatedPoints(scenario)
const ordered = championshipOrder(drivers)
const leader = ordered[0]
const leaderProjected = leader.points + (extras.get(leader.driver_number) ?? 0)
const maxRemaining = MAX_POINTS_PER_ROUND * Math.max(0, roundsLeft)
const rows = ordered.map((driver, i) => {
const simPoints = extras.get(driver.driver_number) ?? 0
return {
driver,
currentPoints: driver.points,
simPoints,
projectedPoints: driver.points + simPoints,
currentPosition: i + 1,
projectedPosition: 0,
delta: 0,
titleAlive: driver.points + maxRemaining >= leaderProjected,
}
})
rows.sort(
(a, b) => b.projectedPoints - a.projectedPoints || a.currentPosition - b.currentPosition,
)
rows.forEach((row, i) => {
row.projectedPosition = i + 1
row.delta = row.currentPosition - row.projectedPosition
})
return rows
}
/**
* Assign a driver to a position within one round, returning a new round.
* The driver is removed from any other position it held; assigning `null`
* clears the slot.
*/
export function assignPosition(
round: RoundAssignment,
positionIndex: number,
driverNumber: number | null,
): RoundAssignment {
const next = round.slice(0, SCORING_POSITIONS)
while (next.length < SCORING_POSITIONS) next.push(null)
if (positionIndex < 0 || positionIndex >= SCORING_POSITIONS) return next
if (driverNumber != null) {
for (let p = 0; p < next.length; p++) {
if (next[p] === driverNumber) next[p] = null
}
}
next[positionIndex] = driverNumber
return next
}

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

View File

@@ -0,0 +1,628 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchChampionshipHub, fetchSeasons } from '../api'
import { teamColor } from '../utils'
import type { ChampHubDriver, ChampionshipHub } from '../types'
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
type View = 'drivers' | 'constructors' | 'progression' | 'simulator'
const GOLD = '#ffd700'
const SILVER = '#c0c0c0'
const BRONZE = '#cd7f32'
function medalColor(pos: number): string {
if (pos === 1) return GOLD
if (pos === 2) return SILVER
if (pos === 3) return BRONZE
return 'var(--text-2)'
}
function ghostColor(pos: number): string {
if (pos === 1) return 'rgba(255,215,0,0.08)'
if (pos === 2) return 'rgba(192,192,192,0.07)'
if (pos === 3) return 'rgba(205,127,50,0.07)'
return 'rgba(255,255,255,0.03)'
}
function fmtPts(n: number): string {
return Number.isInteger(n) ? String(n) : n.toFixed(1)
}
/** Sparkline polyline points inside a 62×20 box. */
function sparkPoints(form: number[]): string {
if (!form.length) return ''
const fmax = Math.max(25, ...form)
const n = form.length
return form
.map((v, k) => {
const x = n === 1 ? 0 : (k * 62) / (n - 1)
const y = 18 - (fmax > 0 ? (v / fmax) * 16 : 0)
return `${x.toFixed(1)},${y.toFixed(1)}`
})
.join(' ')
}
interface TeamSplit {
driverA: string
ptsA: number
driverB: string
ptsB: number
splitA: number
splitB: number
shareLabel: string
}
function teamSplit(teamName: string, drivers: ChampHubDriver[]): TeamSplit {
const ds = drivers.filter((d) => d.team_name === teamName).sort((a, b) => b.points - a.points)
const a = ds[0]
const b = ds[1]
const ptsA = a?.points ?? 0
const ptsB = b?.points ?? 0
const total = ptsA + ptsB || 1
const splitA = Math.round((ptsA / total) * 100)
return {
driverA: a?.name_acronym ?? '—',
ptsA,
driverB: b?.name_acronym ?? '—',
ptsB,
splitA,
splitB: 100 - splitA,
shareLabel: `${splitA}/${100 - splitA}`,
}
}
export function ChampionshipPage() {
const [view, setView] = useState<View>('drivers')
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons })
const latestSeason = seasonsQuery.data?.[0] ?? null
const hubQuery = useQuery({
queryKey: ['championship-hub', latestSeason],
queryFn: () => fetchChampionshipHub(latestSeason ?? undefined),
enabled: latestSeason != null,
staleTime: 5 * 60_000,
})
if (seasonsQuery.isLoading || hubQuery.isLoading) {
return <div className="page loading-state">loading championship</div>
}
if (hubQuery.isError) {
return (
<div className="page error-box">
{hubQuery.error instanceof Error ? hubQuery.error.message : 'Failed to load championship'}
</div>
)
}
const hub = hubQuery.data
if (!hub || hub.drivers.length === 0) {
return (
<div className="champ-page champ-empty" data-testid="championship-empty">
<div className="champ-empty-band">
<span className="champ-empty-eyebrow mono">box-box · championship</span>
<h1 className="champ-empty-title">No championship data</h1>
<p className="champ-empty-sub">
Standings for {latestSeason ?? 'this season'} are not available yet. Once race results are
ingested they will appear here.
</p>
</div>
</div>
)
}
return <ChampionshipBody hub={hub} view={view} setView={setView} />
}
interface BodyProps {
hub: ChampionshipHub
view: View
setView: (v: View) => void
}
function ChampionshipBody({ hub, view, setView }: BodyProps) {
const { drivers, teams } = hub
const leader = drivers[0]
const remaining = hub.rounds_left * 25
const enriched = useMemo<EnrichedDriver[]>(
() =>
drivers.map((d, i) => {
const gapLeaderNum = leader.points - d.points
const gapAheadNum = i === 0 ? null : drivers[i - 1].points - d.points
const alive = i === 0 || gapLeaderNum <= remaining
return {
d,
pos: d.position,
color: teamColor(d.team_colour),
gapLeader: i === 0 ? 'LEADER' : `+${fmtPts(gapLeaderNum)}`,
gapAhead: gapAheadNum == null ? '—' : `+${fmtPts(gapAheadNum)}`,
spark: sparkPoints(d.form),
h2h: `${d.teammate_wins}${d.teammate_losses}`,
h2hWin: d.teammate_wins >= d.teammate_losses,
aliveLabel: i === 0 ? 'LEADS' : alive ? 'ALIVE' : 'OUT',
aliveColor: i === 0 ? GOLD : alive ? 'var(--green)' : 'var(--text-3)',
}
}),
[drivers, leader, remaining],
)
const aliveCount = enriched.filter((e) => e.aliveLabel === 'ALIVE' || e.aliveLabel === 'LEADS').length
const titleMath =
`${aliveCount} driver${aliveCount === 1 ? '' : 's'} can still mathematically win the title. ` +
`With ${hub.rounds_left} round${hub.rounds_left === 1 ? '' : 's'} left (max ${remaining} pts), ` +
`${leader.name_acronym} leads ` +
(drivers[1] ? `${drivers[1].name_acronym} by ${fmtPts(leader.points - drivers[1].points)}` : 'the field') +
(drivers[2] ? ` and ${drivers[2].name_acronym} by ${fmtPts(leader.points - drivers[2].points)}.` : '.')
const topTeam = teams[0]
const teamGap = teams[1] ? topTeam.points - teams[1].points : 0
const seasonPct = hub.total_rounds > 0 ? Math.round((hub.round / hub.total_rounds) * 100) : 0
const statRail = [
{ label: 'Drivers Leader', value: leader.name_acronym, sub: `${fmtPts(leader.points)} pts · ${leader.wins} wins`, color: '#fff' },
{
label: 'Constructors Leader',
value: topTeam?.team_name ?? '—',
sub: teams[1] ? `+${fmtPts(teamGap)} ahead` : 'Sole entry',
color: '#fff',
},
{ label: 'Title Fight', value: `${aliveCount} alive`, sub: `${hub.rounds_left} rounds remain`, color: 'var(--red)' },
{ label: 'Season Progress', value: `${seasonPct}%`, sub: `Round ${hub.round}/${hub.total_rounds}`, color: '#fff' },
]
return (
<div className="champ-page" data-testid="championship">
<div className="champ-header">
<div className="champ-title-row">
<span className="champ-accent" aria-hidden="true" />
<h1 className="champ-title">Championship</h1>
<span className="champ-season mono">{hub.season}</span>
</div>
<div className="champ-sub">
{hub.last_race ? `After ${hub.last_race} · ` : ''}Round {hub.round} of {hub.total_rounds}
</div>
<div className="champ-tabs" role="tablist">
<button
type="button"
className={`champ-tab${view === 'drivers' ? ' is-active' : ''}`}
onClick={() => setView('drivers')}
data-testid="champ-tab-drivers"
>
Drivers
</button>
<button
type="button"
className={`champ-tab${view === 'constructors' ? ' is-active' : ''}`}
onClick={() => setView('constructors')}
data-testid="champ-tab-constructors"
>
Constructors
</button>
<button
type="button"
className={`champ-tab${view === 'progression' ? ' is-active' : ''}`}
onClick={() => setView('progression')}
data-testid="champ-tab-progression"
>
Progression
</button>
<button
type="button"
className={`champ-tab${view === 'simulator' ? ' is-active' : ''}`}
onClick={() => setView('simulator')}
data-testid="champ-tab-simulator"
>
Simulator
</button>
</div>
</div>
<div className="champ-stat-rail">
{statRail.map((stat) => (
<div className="champ-stat" key={stat.label}>
<div className="champ-stat-label mono">{stat.label}</div>
<div className="champ-stat-value mono" style={{ color: stat.color }}>
{stat.value}
</div>
<div className="champ-stat-sub">{stat.sub}</div>
</div>
))}
</div>
{view === 'drivers' && (
<DriversView enriched={enriched} leaderPoints={leader.points} titleMath={titleMath} />
)}
{view === 'constructors' && <ConstructorsView hub={hub} />}
{view === 'progression' && <ProgressionView hub={hub} />}
{view === 'simulator' && <ChampionshipSimulator hub={hub} />}
</div>
)
}
interface EnrichedDriver {
d: ChampHubDriver
pos: number
color: string
gapLeader: string
gapAhead: string
spark: string
h2h: string
h2hWin: boolean
aliveLabel: string
aliveColor: string
}
function DriversView({
enriched,
leaderPoints,
titleMath,
}: {
enriched: EnrichedDriver[]
leaderPoints: number
titleMath: string
}) {
const podium = enriched.slice(0, 3)
return (
<div data-testid="champ-view-drivers">
<div className="champ-podium">
{podium.map((e) => (
<div
className="champ-podium-card"
key={e.d.driver_number}
style={{ borderTopColor: e.color }}
>
<span className="champ-podium-ghost mono" style={{ color: ghostColor(e.pos) }}>
P{e.pos}
</span>
<div className="champ-podium-inner">
<div className="champ-podium-top">
<span className="mono" style={{ color: medalColor(e.pos), fontWeight: 700 }}>
P{e.pos}
</span>
<span className="champ-podium-team mono">{e.d.team_name}</span>
</div>
<div className="champ-podium-id">
<span className="champ-podium-bar" style={{ background: e.color }} />
<span className="champ-podium-code mono">{e.d.name_acronym}</span>
</div>
<div className="champ-podium-name">{e.d.full_name}</div>
<div className="champ-podium-pts">
<span className="champ-podium-pts-num mono">{fmtPts(e.d.points)}</span>
<span className="champ-podium-pts-unit">PTS</span>
<span
className="champ-podium-gap mono"
style={{ color: e.pos === 1 ? GOLD : 'var(--text-2)' }}
>
{e.pos === 1 ? 'P1' : `+${fmtPts(leaderPoints - e.d.points)}`}
</span>
</div>
<div className="champ-podium-stats">
<div>
<div className="champ-podium-stat-num mono">{e.d.wins}</div>
<div className="champ-podium-stat-label">Wins</div>
</div>
<div>
<div className="champ-podium-stat-num mono">{e.d.podiums}</div>
<div className="champ-podium-stat-label">Podiums</div>
</div>
<div>
<div className="champ-podium-stat-num mono">{e.d.poles}</div>
<div className="champ-podium-stat-label">Poles</div>
</div>
</div>
</div>
</div>
))}
</div>
<div className="champ-titlemath" data-testid="champ-titlemath">
<span className="champ-titlemath-tag mono">Title Math</span>
<span className="champ-titlemath-text">{titleMath}</span>
</div>
<div className="champ-scroll">
<table className="champ-table champ-table-drivers">
<thead>
<tr>
<th className="l">Pos</th>
<th className="l">Driver</th>
<th className="l">Team</th>
<th className="r">Pts</th>
<th className="r">Gap</th>
<th className="r">Int</th>
<th className="c">Wins</th>
<th className="c">Pod</th>
<th className="l">Form</th>
<th className="c">vs Teammate</th>
<th className="r">Title</th>
</tr>
</thead>
<tbody>
{enriched.map((e) => (
<tr key={e.d.driver_number}>
<td className="mono" style={{ color: medalColor(e.pos), fontWeight: 700 }}>
P{e.pos}
</td>
<td>
<div className="champ-drv">
<span className="champ-drv-bar" style={{ background: e.color }} />
<span className="champ-drv-code mono">{e.d.name_acronym}</span>
<span className="champ-drv-name">{e.d.full_name}</span>
<span className="champ-drv-num mono">#{e.d.driver_number}</span>
</div>
</td>
<td className="champ-td-team">{e.d.team_name}</td>
<td className="r mono champ-td-pts">{fmtPts(e.d.points)}</td>
<td className="r mono champ-td-muted">{e.gapLeader}</td>
<td className="r mono champ-td-dim">{e.gapAhead}</td>
<td className="c mono" style={{ color: e.d.wins > 0 ? 'var(--text)' : 'var(--text-3)' }}>
{e.d.wins}
</td>
<td className="c mono champ-td-muted">{e.d.podiums}</td>
<td>
{e.spark ? (
<svg width="62" height="20" viewBox="0 0 62 20" className="champ-spark">
<polyline
points={e.spark}
fill="none"
stroke={e.color}
strokeWidth="1.5"
strokeLinejoin="round"
strokeLinecap="round"
opacity="0.9"
/>
</svg>
) : (
<span className="champ-td-dim mono"></span>
)}
</td>
<td className="c mono" style={{ color: e.h2hWin ? 'var(--green)' : 'var(--text-2)' }}>
{e.h2h}
</td>
<td className="r">
<span className="champ-alive mono" style={{ color: e.aliveColor }}>
{e.aliveLabel}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function ConstructorsView({ hub }: { hub: ChampionshipHub }) {
const { teams, drivers } = hub
const leaderPts = teams[0]?.points ?? 0
const podium = teams.slice(0, 3)
return (
<div data-testid="champ-view-constructors">
<div className="champ-podium">
{podium.map((t) => {
const color = teamColor(t.team_colour)
const split = teamSplit(t.team_name, drivers)
return (
<div className="champ-podium-card" key={t.team_name} style={{ borderTopColor: color }}>
<span className="champ-podium-ghost mono" style={{ color: ghostColor(t.position) }}>
P{t.position}
</span>
<div className="champ-podium-inner">
<div className="champ-podium-top">
<span className="mono" style={{ color: medalColor(t.position), fontWeight: 700 }}>
P{t.position}
</span>
</div>
<div className="champ-podium-id">
<span className="champ-podium-bar" style={{ background: color }} />
<span className="champ-podium-team-name">{t.team_name}</span>
</div>
<div className="champ-podium-pts">
<span className="champ-podium-pts-num mono">{fmtPts(t.points)}</span>
<span className="champ-podium-pts-unit">PTS</span>
<span
className="champ-podium-gap mono"
style={{ color: t.position === 1 ? GOLD : 'var(--text-2)' }}
>
{t.position === 1 ? 'P1' : `+${fmtPts(leaderPts - t.points)}`}
</span>
</div>
<div className="champ-contrib">
<div className="champ-contrib-bar">
<span style={{ width: `${split.splitA}%`, background: color }} />
<span style={{ width: `${split.splitB}%`, background: color, opacity: 0.4 }} />
</div>
<div className="champ-contrib-legend mono">
<span>
{split.driverA} <em>{fmtPts(split.ptsA)}</em>
</span>
<span>
{split.driverB} <em>{fmtPts(split.ptsB)}</em>
</span>
</div>
</div>
</div>
</div>
)
})}
</div>
<div className="champ-scroll">
<table className="champ-table champ-table-teams">
<thead>
<tr>
<th className="l">Pos</th>
<th className="l">Constructor</th>
<th className="r">Pts</th>
<th className="r">Gap</th>
<th className="c">Wins</th>
<th className="l">Driver Contribution</th>
<th className="r">Share</th>
</tr>
</thead>
<tbody>
{teams.map((t) => {
const color = teamColor(t.team_colour)
const split = teamSplit(t.team_name, drivers)
return (
<tr key={t.team_name}>
<td className="mono" style={{ color: medalColor(t.position), fontWeight: 700 }}>
P{t.position}
</td>
<td>
<div className="champ-drv">
<span className="champ-drv-bar" style={{ background: color }} />
<span className="champ-team-name">{t.team_name}</span>
</div>
</td>
<td className="r mono champ-td-pts">{fmtPts(t.points)}</td>
<td className="r mono champ-td-muted">
{t.position === 1 ? 'LEADER' : `+${fmtPts(leaderPts - t.points)}`}
</td>
<td className="c mono" style={{ color: t.wins > 0 ? 'var(--text)' : 'var(--text-3)' }}>
{t.wins}
</td>
<td>
<div className="champ-contrib-row">
<div className="champ-contrib-bar">
<span style={{ width: `${split.splitA}%`, background: color }} />
<span style={{ width: `${split.splitB}%`, background: color, opacity: 0.4 }} />
</div>
<span className="champ-contrib-names mono">
{split.driverA}
<em> · </em>
{split.driverB}
</span>
</div>
</td>
<td className="r mono champ-td-dim">{split.shareLabel}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}
const CHART_PAD_L = 48
const CHART_PAD_T = 16
const CHART_PLOT_W = 882
const CHART_PLOT_H = 316
function ProgressionView({ hub }: { hub: ChampionshipHub }) {
const top = hub.drivers.slice(0, 6).filter((d) => d.cumulative.length > 0)
if (top.length === 0) {
return (
<div className="champ-chart-empty" data-testid="champ-view-progression">
No completed rounds yet progression will appear after the first race.
</div>
)
}
const N = Math.max(...top.map((d) => d.cumulative.length))
const peak = Math.max(...top.flatMap((d) => d.cumulative))
const maxY = Math.max(100, Math.ceil(peak / 100) * 100)
const x = (i: number) => (N <= 1 ? CHART_PAD_L : CHART_PAD_L + (i * CHART_PLOT_W) / (N - 1))
const y = (v: number) => CHART_PAD_T + CHART_PLOT_H - (v / maxY) * CHART_PLOT_H
const yGrid = [0, 0.25, 0.5, 0.75, 1].map((f) => {
const value = Math.round(maxY * f)
const yy = y(value)
return { y: yy, label: value }
})
// x ticks: up to ~7 evenly spaced round labels.
const tickStep = Math.max(1, Math.ceil(N / 7))
const xGrid: { x: number; label: string }[] = []
for (let i = 0; i < N; i += tickStep) {
xGrid.push({ x: x(i), label: hub.round_labels[i] ?? `R${i + 1}` })
}
if (xGrid[xGrid.length - 1]?.label !== (hub.round_labels[N - 1] ?? `R${N}`)) {
xGrid.push({ x: x(N - 1), label: hub.round_labels[N - 1] ?? `R${N}` })
}
const seenTeams = new Set<string>()
const series = top.map((d, idx) => {
const dashed = seenTeams.has(d.team_name)
seenTeams.add(d.team_name)
const pts = d.cumulative.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ')
const endVal = d.cumulative[d.cumulative.length - 1]
const endX = x(d.cumulative.length - 1)
const endY = y(endVal)
return {
code: d.name_acronym,
name: d.full_name,
total: d.points,
color: teamColor(d.team_colour),
width: idx < 3 ? 2.4 : 1.8,
dash: dashed ? '5 4' : '0',
points: pts,
endX,
endY,
}
})
return (
<div className="champ-progression" data-testid="champ-view-progression">
<div className="champ-chart-head">
<span className="champ-chart-title mono">Cumulative points top {top.length} drivers</span>
<span className="champ-chart-meta">
Rounds 1{hub.round} · {hub.season}
</span>
</div>
<div className="champ-chart">
<svg viewBox="0 0 1000 380" className="champ-chart-svg" preserveAspectRatio="none">
{yGrid.map((g) => (
<g key={g.label}>
<line x1={CHART_PAD_L} y1={g.y} x2={930} y2={g.y} stroke="var(--border)" strokeWidth="1" />
<text x={40} y={g.y + 4} textAnchor="end" className="champ-chart-axis">
{g.label}
</text>
</g>
))}
{xGrid.map((g, i) => (
<text key={i} x={g.x} y={372} textAnchor="middle" className="champ-chart-axis">
{g.label}
</text>
))}
{series.map((s) => (
<g key={s.code}>
<polyline
points={s.points}
fill="none"
stroke={s.color}
strokeWidth={s.width}
strokeDasharray={s.dash}
strokeLinejoin="round"
strokeLinecap="round"
/>
<circle cx={s.endX} cy={s.endY} r="3" fill={s.color} />
<text x={s.endX + 8} y={s.endY + 4} fill={s.color} className="champ-chart-label">
{s.code}
</text>
</g>
))}
</svg>
</div>
<div className="champ-legend">
{series.map((s) => (
<div className="champ-legend-item" key={s.code}>
<span className="champ-legend-swatch" style={{ background: s.color }} />
<span className="champ-legend-code mono">{s.code}</span>
<span className="champ-legend-name">{s.name}</span>
<span className="champ-legend-total mono">{fmtPts(s.total)}</span>
</div>
))}
</div>
</div>
)
}

View File

@@ -1,8 +1,8 @@
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, fetchWeekend } from '../api' import { fetchLiveState, fetchLocalMeetings, fetchSeasonMeetings, fetchSeasons, fetchSessions, fetchWeekend, fetchChampionshipHub } from '../api'
import { countWeekendStats, formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage' import { RACE_HUB_DATASETS, countWeekendStats, formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
import { import {
currentAndNextSession, currentAndNextSession,
focusMeetingKind, focusMeetingKind,
@@ -17,9 +17,14 @@ import {
import { countryAccent, countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity' 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 { PaddockBriefing } from '../components/PaddockBriefing'
import { Play, Activity, Calendar, Trophy } from 'lucide-react'
type WeekendStatusKind = 'live' | 'current' | 'next' | 'recent' | 'fallback' type WeekendStatusKind = 'live' | 'current' | 'next' | 'recent' | 'fallback'
const missingDatasets = Object.fromEntries(
RACE_HUB_DATASETS.map((dataset) => [dataset, { status: 'missing', source: 'none', count: 0 }]),
) as WeekendSession['datasets']
function classifySessionStatus(session: Session, now: Date): 'live' | 'done' | 'upcoming' { function classifySessionStatus(session: Session, now: Date): 'live' | 'done' | 'upcoming' {
const start = sessionStartTime(session) const start = sessionStartTime(session)
const end = sessionEndTime(session) const end = sessionEndTime(session)
@@ -70,13 +75,13 @@ export function CommandCenterPage() {
const localMeetings = meetingsQuery.data ?? [] const localMeetings = meetingsQuery.data ?? []
const seasonMeetings = seasonMeetingsQuery.data?.length ? seasonMeetingsQuery.data : localMeetings const seasonMeetings = seasonMeetingsQuery.data?.length ? seasonMeetingsQuery.data : localMeetings
const meetings = localMeetings const focusMeetings = seasonMeetings.length > 0 ? seasonMeetings : localMeetings
const weekendQueries = useQueries({ const weekendQueries = useQueries({
queries: meetings.map((meeting) => ({ queries: localMeetings.map((meeting) => ({
queryKey: ['weekend', meeting.meeting_key], queryKey: ['weekend', meeting.meeting_key],
queryFn: () => fetchWeekend(meeting.meeting_key), queryFn: () => fetchWeekend(meeting.meeting_key),
enabled: meetings.length > 0, enabled: localMeetings.length > 0,
staleTime: 60_000, staleTime: 60_000,
})), })),
}) })
@@ -87,6 +92,13 @@ export function CommandCenterPage() {
staleTime: 5_000, staleTime: 5_000,
}) })
const championshipQuery = useQuery({
queryKey: ['championship-hub', latestSeason],
queryFn: () => fetchChampionshipHub(latestSeason!),
enabled: latestSeason != null,
})
const champHub = championshipQuery.data
useEffect(() => { useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000) const timer = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(timer) return () => window.clearInterval(timer)
@@ -96,28 +108,45 @@ export function CommandCenterPage() {
const weekendsByKey = useMemo(() => { const weekendsByKey = useMemo(() => {
const map = new Map<number, Weekend>() const map = new Map<number, Weekend>()
meetings.forEach((meeting, i) => { localMeetings.forEach((meeting, i) => {
const data = weekendQueries[i]?.data const data = weekendQueries[i]?.data
if (data) map.set(meeting.meeting_key, data) if (data) map.set(meeting.meeting_key, data)
}) })
return map return map
}, [meetings, weekendQueries]) }, [localMeetings, weekendQueries])
const weekendList = useMemo(() => weekendQueries.map((q) => q.data), [weekendQueries]) const weekendList = useMemo(() => weekendQueries.map((q) => q.data), [weekendQueries])
const meetingStats = countWeekendStats(weekendList) const meetingStats = countWeekendStats(weekendList)
const focusMeeting = pickFocusMeeting(meetings, nowDate) const focusMeeting = pickFocusMeeting(focusMeetings, nowDate)
const focusWeekend = focusMeeting ? weekendsByKey.get(focusMeeting.meeting_key) : undefined const focusWeekend = focusMeeting ? weekendsByKey.get(focusMeeting.meeting_key) : undefined
const openF1SessionsQuery = useQuery({
queryKey: ['sessions', focusMeeting?.meeting_key, 'openf1'],
queryFn: () => fetchSessions(focusMeeting!.meeting_key, 'openf1'),
enabled: focusMeeting != null && focusWeekend == null,
staleTime: 60_000,
})
const openF1WeekendSessions: WeekendSession[] = useMemo(
() =>
(openF1SessionsQuery.data ?? []).map((session) => ({
session,
source: 'none',
datasets: missingDatasets,
})),
[openF1SessionsQuery.data],
)
const focusWeekendSessions = focusWeekend?.sessions ?? openF1WeekendSessions
const focusKind = focusMeeting ? focusMeetingKind(focusMeeting, nowDate) : null const focusKind = focusMeeting ? focusMeetingKind(focusMeeting, nowDate) : null
const focusSessions: Session[] = focusWeekend const focusSessions: Session[] = sortSessionsByStart(focusWeekendSessions.map((s) => s.session))
? sortSessionsByStart(focusWeekend.sessions.map((s) => s.session))
: []
const { current: currentSession, next: nextSession } = currentAndNextSession(focusSessions, nowDate) const { current: currentSession, next: nextSession } = currentAndNextSession(focusSessions, nowDate)
const analysisSession = pickAnalysisSession(focusWeekend) const analysisSession = pickAnalysisSession(focusWeekend)
const analysisSessionKey = const actionSession =
analysisSession?.session.session_key ?? analysisSession?.session ??
focusWeekend?.default_session_key ?? currentSession ??
focusWeekend?.sessions[0]?.session.session_key nextSession ??
focusWeekendSessions.find((s) => s.session.session_key === focusWeekend?.default_session_key)?.session ??
focusWeekendSessions[0]?.session
const analysisSessionKey = actionSession?.session_key
const weekendsLoading = weekendQueries.some((q) => q.isLoading) const weekendsLoading = weekendQueries.some((q) => q.isLoading)
@@ -196,174 +225,264 @@ export function CommandCenterPage() {
)} )}
{focusMeeting && ( {focusMeeting && (
<section className="cc-weekend-band" data-testid="cc-focus"> <div className="cc-hero">
<div className="cc-band-accent" aria-hidden="true" /> <section className="cc-weekend-band ui-card glass-panel" data-testid="cc-focus">
<div className="cc-band-body"> <div className="cc-band-accent" aria-hidden="true" />
<div className="cc-band-row"> <div className="cc-band-body">
<span className="cc-band-decal mono">{decal}</span> <div className="cc-band-row">
<div className="cc-band-titles"> <span className="cc-band-decal mono">{decal}</span>
<div className="cc-band-eyebrow mono"> <div className="cc-band-titles">
<WeekendKindLabel kind={statusKind} /> <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> </div>
<h1 className="cc-band-name">{focusMeeting.meeting_name}</h1> <CountdownBlock
<div className="cc-band-sub mono"> liveActive={liveActive}
{[focusMeeting.location, focusMeeting.circuit_short_name] currentSession={currentSession}
.filter(Boolean) nextSession={nextSession}
.join(' · ')} meeting={focusMeeting}
</div> now={nowDate}
<div className="cc-band-sub mono cc-band-dates">{formatGpDateRange(focusMeeting)}</div> />
</div> </div>
<CountdownBlock
liveActive={liveActive}
currentSession={currentSession}
nextSession={nextSession}
meeting={focusMeeting}
now={nowDate}
/>
</div> </div>
</div> </section>
</section>
)}
{focusMeeting && ( <div className="cc-actions-row" data-testid="cc-actions">
<div className="cc-actions-row" data-testid="cc-actions"> <Link
<Link to="/live"
to="/live" className={`cc-pri-action ui-card interactive ${liveActive ? 'is-live ui-cta-primary' : ''}`}
className={`cc-pri-action ${liveActive ? 'is-live' : ''}`} data-testid="cc-action-live"
data-testid="cc-action-live"
>
<span className="cc-pri-label">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"
data-testid="cc-action-race-hub"
>
<span className="cc-pri-label">Open Analysis</span>
<span className="cc-pri-meta mono">
{analysisSession
? `${analysisSession.session.session_name} · session ${analysisSession.session.session_key}`
: 'Pick a session'}
</span>
</Link>
{nextSession && sessionStartTime(nextSession) && (
<a
href="#cc-schedule"
className="cc-pri-action"
data-testid="cc-action-schedule"
onClick={(e) => {
e.preventDefault()
document.getElementById('cc-schedule')?.scrollIntoView({ behavior: 'smooth' })
}}
> >
<span className="cc-pri-label">Schedule</span> <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"> <span className="cc-pri-meta mono">
next: {nextSession.session_name} {actionSession
? `${actionSession.session_name} · session ${actionSession.session_key}`
: 'Pick a session'}
</span> </span>
</a> </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>
)} )}
{focusWeekend && focusWeekend.sessions.length > 0 && ( <div className="cc-dashboard-grid">
<section className="cc-schedule" id="cc-schedule"> <div className="cc-dashboard-main">
<div className="sec-header"> <div
<span className="sec-title">Weekend Schedule</span> className="cc-ambient-aura"
<span className="sec-meta mono">{focusWeekend.sessions.length} sessions</span> aria-hidden="true"
style={focusMeeting ? { '--aura-accent': countryAccent(focusMeeting) } as React.CSSProperties : undefined}
/>
<div className="cc-dashboard-panel">
{seasonMeetings.length > 0 && (
<section className="cc-season-calendar" data-testid="cc-season-calendar">
<div className="sec-header">
<span className="sec-title">Season Calendar</span>
<span className="sec-meta mono">
{seasonMeetings.length} rounds · {meetingStats.full}/{meetingStats.total || 0} local
</span>
</div>
<div className="cc-calendar-grid" role="list">
{seasonMeetings.map((meeting, index) => {
const weekend = weekendsByKey.get(meeting.meeting_key)
const target = pickAnalysisSession(weekend)
const targetKey = target?.session.session_key ?? weekend?.default_session_key
const status = meetingStatus(meeting, focusMeeting?.meeting_key, nowDate)
const cardAccent = countryAccent(meeting)
return (
<Link
key={meeting.meeting_key}
to="/race-hub"
search={targetKey ? { session_key: targetKey } : {}}
className={`cc-calendar-card cc-calendar-${status}`}
data-testid={`cc-calendar-${meeting.meeting_key}`}
role="listitem"
style={{ '--gp-card-accent': cardAccent } as React.CSSProperties}
>
<div className="cc-calendar-accent" aria-hidden="true" />
<div className="cc-calendar-top mono">
<span className="cc-calendar-round">R{String(index + 1).padStart(2, '0')}</span>
<span className={`cc-cov-dot cc-cov-${weekend?.source ?? 'none'}`} aria-hidden="true" />
</div>
<div className="cc-calendar-id">
{countryFlag(meeting) && <span className="cc-calendar-flag">{countryFlag(meeting)}</span>}
</div>
<span className="cc-calendar-decal mono">{countryDecal(meeting)}</span>
<div className="cc-calendar-copy">
<div className="cc-calendar-name">{meeting.meeting_name}</div>
<div className="cc-calendar-circuit mono">{meeting.circuit_short_name || meeting.location}</div>
<div className="cc-calendar-date mono">{formatGpDateRange(meeting)}</div>
</div>
</Link>
)
})}
</div>
{seasonMeetingsQuery.isError && (
<div className="cc-side-empty">Using local meetings because the full calendar could not load.</div>
)}
</section>
)}
</div> </div>
<div className="cc-session-strip" role="list"> </div>
{focusWeekend.sessions.map(({ session, source, datasets }) => {
const status = classifySessionStatus(session, nowDate)
const isNext = nextSession?.session_key === session.session_key
const isCurrent = currentSession?.session_key === session.session_key
return (
<Link
key={session.session_key}
to="/race-hub"
search={{ session_key: session.session_key }}
className={`cc-session-card cc-status-${status}${isNext ? ' is-next' : ''}${
isCurrent ? ' is-current' : ''
}`}
data-testid={`cc-session-${session.session_key}`}
role="listitem"
>
<div className="cc-session-card-head">
<span className="cc-session-abbrev mono">
{sessionTypeAbbrev(session.session_type, session.session_name)}
</span>
<span className={`cc-session-status cc-status-pill-${status}`}>
{isCurrent ? 'On track' : status === 'done' ? 'Done' : isNext ? 'Next' : 'Upcoming'}
</span>
</div>
<div className="cc-session-name">{session.session_name}</div>
<div className="cc-session-time mono">{formatSessionScheduleTime(session.date_start)}</div>
<div className="cc-session-cov mono">
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
{formatCoverageHint(datasets)}
</div>
</Link>
)
})}
</div>
</section>
)}
{seasonMeetings.length > 0 && ( <div className="cc-dashboard-sidebar">
<section className="cc-season-calendar" data-testid="cc-season-calendar"> {champHub && (
<div className="sec-header"> <section className="cc-champ-snapshot glass-panel" data-testid="cc-champ-snapshot" style={{ padding: 'var(--s5)', borderRadius: '12px' }}>
<span className="sec-title">Season Calendar</span> <div className="cc-champ-header">
<span className="sec-meta mono"> <span>Championship Leaders</span>
{seasonMeetings.length} rounds · {meetingStats.full}/{meetingStats.total || 0} local <Link to="/championship" className="cc-champ-link">Full Standings </Link>
</span> </div>
</div> <div className="cc-champ-group-label">Drivers</div>
<div className="cc-calendar-grid" role="list"> <div className="cc-champ-list">
{seasonMeetings.map((meeting, index) => { {champHub.drivers.slice(0, 3).map((d) => (
const weekend = weekendsByKey.get(meeting.meeting_key) <div key={d.driver_number} className="cc-champ-row">
const target = pickAnalysisSession(weekend) <div className="cc-champ-left">
const targetKey = target?.session.session_key ?? weekend?.default_session_key <div className="cc-champ-color" style={{ color: `#${d.team_colour}`, background: 'currentColor' }} />
const status = meetingStatus(meeting, focusMeeting?.meeting_key, nowDate) <span className="cc-champ-name">{d.name_acronym}</span>
const cardAccent = countryAccent(meeting) </div>
return ( <div className="cc-champ-form" title="Last 5 races">
<Link <FormSparkline form={d.form} color={`#${d.team_colour}`} />
key={meeting.meeting_key} </div>
to="/race-hub" <div className="cc-champ-right">
search={targetKey ? { session_key: targetKey } : {}} <span className="cc-champ-gap">
className={`cc-calendar-card cc-calendar-${status}`} {d.position === 1 ? '—' : `-${champHub.drivers[0].points - d.points}`}
data-testid={`cc-calendar-${meeting.meeting_key}`} </span>
role="listitem" <span className="cc-champ-pts">{d.points}</span>
style={{ '--gp-card-accent': cardAccent } as React.CSSProperties} </div>
>
<div className="cc-calendar-accent" aria-hidden="true" />
<div className="cc-calendar-top mono">
<span className="cc-calendar-round">R{String(index + 1).padStart(2, '0')}</span>
<span className={`cc-cov-dot cc-cov-${weekend?.source ?? 'none'}`} aria-hidden="true" />
</div> </div>
<div className="cc-calendar-id"> ))}
<span className="cc-calendar-decal mono">{countryDecal(meeting)}</span> </div>
{countryFlag(meeting) && <span className="cc-calendar-flag">{countryFlag(meeting)}</span>} <div className="cc-champ-group-label">Constructors</div>
<div className="cc-champ-list">
{champHub.teams.slice(0, 3).map((t, i) => (
<div key={t.team_name} className="cc-champ-row">
<div className="cc-champ-left">
<div className="cc-champ-color" style={{ color: `#${t.team_colour}`, background: 'currentColor' }} />
<span className="cc-champ-name">{t.team_name}</span>
</div>
<div className="cc-champ-form" style={{ opacity: 0.4, fontSize: '11px', display: 'flex', gap: '4px', alignItems: 'center' }} title={`${t.wins} Wins`}>
<Trophy size={12} /> {t.wins}
</div>
<div className="cc-champ-right">
<span className="cc-champ-gap">
{i === 0 ? '—' : `-${champHub.teams[0].points - t.points}`}
</span>
<span className="cc-champ-pts">{t.points}</span>
</div>
</div> </div>
<div className="cc-calendar-copy"> ))}
<div className="cc-calendar-name">{meeting.meeting_name}</div> </div>
<div className="cc-calendar-circuit mono">{meeting.circuit_short_name || meeting.location}</div> </section>
<div className="cc-calendar-date mono">{formatGpDateRange(meeting)}</div>
</div>
</Link>
)
})}
</div>
{seasonMeetingsQuery.isError && (
<div className="cc-side-empty">Using local meetings because the full calendar could not load.</div>
)} )}
</section>
)}
<PaddockBriefing /> {focusWeekendSessions.length > 0 && (
<section className="cc-schedule" data-testid="cc-schedule">
<div className="sec-header">
<span className="sec-title">Weekend Schedule</span>
<span className="sec-meta mono">{focusWeekendSessions.length} sessions</span>
</div>
<div className="cc-session-strip" role="list">
{focusWeekendSessions.map(({ session, source, datasets }) => {
const status = classifySessionStatus(session, nowDate)
const isNext = nextSession?.session_key === session.session_key
const isCurrent = currentSession?.session_key === session.session_key
const isLive = isCurrent && liveActive
return (
<Link
key={session.session_key}
to="/race-hub"
search={{ session_key: session.session_key }}
className={`cc-session-card ui-card interactive cc-status-${status}${isNext ? ' is-next' : ''}${
isLive ? ' is-live glass-panel' : ''
}`}
data-testid={`cc-session-${session.session_key}`}
role="listitem"
>
<div className="cc-session-card-head">
<span className="cc-session-abbrev mono">
{sessionTypeAbbrev(session.session_type, session.session_name)}
</span>
<span className={`cc-session-status cc-status-pill-${status}`}>
{isCurrent ? 'On track' : status === 'done' ? 'Done' : isNext ? 'Next' : 'Upcoming'}
</span>
</div>
<div className="cc-session-name">{session.session_name}</div>
<div className="cc-session-time mono">{formatSessionScheduleTime(session.date_start)}</div>
<div className="cc-session-cov mono">
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
{formatCoverageHint(datasets)}
</div>
</Link>
)
})}
</div>
</section>
)}
<PaddockBriefing />
</div>
</div>
</div> </div>
) )
} }
function FormSparkline({ form, color }: { form: number[], color: string }) {
if (!form || form.length === 0) return null
const max = Math.max(...form, 26) // 26 is standard max for a race win
const width = 45
const height = 14
const step = width / Math.max(form.length - 1, 1)
const points = form.map((val, i) => {
const x = i * step
const y = height - (val / max) * height
return `${x},${y}`
}).join(' ')
return (
<svg width={width} height={height} style={{ overflow: 'visible' }}>
<polyline
fill="none"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
points={points}
/>
</svg>
)
}
function WeekendKindLabel({ kind }: { kind: WeekendStatusKind }) { function WeekendKindLabel({ kind }: { kind: WeekendStatusKind }) {
switch (kind) { switch (kind) {
case 'live': case 'live':

View File

@@ -137,6 +137,9 @@ export function DataLibraryPage() {
<span> <span>
<em className="dl-stat-partial">{stats.partial}</em> partial <em className="dl-stat-partial">{stats.partial}</em> partial
</span> </span>
<span>
<em className="dl-stat-cancelled">{stats.cancelled}</em> cancelled
</span>
<span> <span>
<em>{stats.missing}</em> missing <em>{stats.missing}</em> missing
</span> </span>
@@ -177,6 +180,10 @@ export function DataLibraryPage() {
<span className="dl-stat-label">Partial</span> <span className="dl-stat-label">Partial</span>
<span className="dl-stat-val dl-stat-partial">{stats.partial}</span> <span className="dl-stat-val dl-stat-partial">{stats.partial}</span>
</div> </div>
<div className="dl-stat">
<span className="dl-stat-label">Cancelled</span>
<span className="dl-stat-val dl-stat-cancelled">{stats.cancelled}</span>
</div>
<div className="dl-stat"> <div className="dl-stat">
<span className="dl-stat-label">Missing</span> <span className="dl-stat-label">Missing</span>
<span className="dl-stat-val">{stats.missing}</span> <span className="dl-stat-val">{stats.missing}</span>
@@ -294,7 +301,9 @@ export function DataLibraryPage() {
)} )}
</td> </td>
<td className="hide-mobile mono" style={{ color: 'var(--text-2)' }}> <td className="hide-mobile mono" style={{ color: 'var(--text-2)' }}>
{weekend && weekend.sessions.length > 0 {weekend?.source === 'cancelled'
? 'cancelled'
: weekend && weekend.sessions.length > 0
? `${weekend.sessions.filter((s) => s.source === 'local').length}/${weekend.sessions.length} full` ? `${weekend.sessions.filter((s) => s.source === 'local').length}/${weekend.sessions.length} full`
: '—'} : '—'}
</td> </td>

View File

@@ -1,11 +1,29 @@
import { useEffect, useState } from 'react' import { useEffect, useMemo, 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 { parseLiveStateEvent } from '../lib/live' import {
loadPinnedDrivers,
mergeVisibleSectors,
parseLiveStateEvent,
rowsWithVisibleSectors,
savePinnedDrivers,
sortLiveTimingRows,
togglePin,
} from '../lib/live'
import type { VisibleSectorState } from '../lib/live'
import type { GapHistoryMap } from '../lib/gapHistory'
import { recordGapSamples } from '../lib/gapHistory'
import { battleNumbers, detectBattles } from '../lib/battles'
import { SessionBanner } from '../components/live/SessionBanner' import { SessionBanner } from '../components/live/SessionBanner'
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 { PinnedDrivers } from '../components/live/PinnedDrivers'
import { RaceControlFeed } from '../components/live/RaceControlFeed' import { RaceControlFeed } from '../components/live/RaceControlFeed'
import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
import { TrackMap } from '../components/live/TrackMap'
import { Radio } from 'lucide-react'
type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error' type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
@@ -14,6 +32,10 @@ export function LiveTimingPage() {
const [isLive, setIsLive] = useState(false) const [isLive, setIsLive] = useState(false)
const [streamStatus, setStreamStatus] = useState<StreamStatus>('connecting') const [streamStatus, setStreamStatus] = useState<StreamStatus>('connecting')
const [now, setNow] = useState(Date.now()) const [now, setNow] = useState(Date.now())
const [gapHistory, setGapHistory] = useState<GapHistoryMap>({})
const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers())
const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({})
const [positions, setPositions] = useState<Record<string, LivePosition>>({})
const { data, isLoading, isError, error } = useQuery({ const { data, isLoading, isError, error } = useQuery({
queryKey: ['live-state'], queryKey: ['live-state'],
@@ -21,6 +43,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)
@@ -58,6 +92,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')
} }
@@ -68,6 +113,43 @@ export function LiveTimingPage() {
} }
}, []) }, [])
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.
useEffect(() => {
if (rows.length === 0) return
setGapHistory((prev) =>
recordGapSamples(
prev,
rows.map((row) => ({ racingNumber: row.RacingNumber, interval: row.Driver.Interval || '' })),
),
)
}, [rows])
useEffect(() => {
savePinnedDrivers(pinned)
}, [pinned])
const battles = useMemo(
() => detectBattles(rows, snapshot?.Session?.SessionType),
[rows, snapshot?.Session?.SessionType],
)
const inBattle = useMemo(() => battleNumbers(battles), [battles])
const handleTogglePin = (racingNumber: string) => {
setPinned((prev) => togglePin(prev, racingNumber))
}
return ( return (
<div className="page live-page" data-testid="live-page"> <div className="page live-page" data-testid="live-page">
{isError && ( {isError && (
@@ -87,29 +169,55 @@ export function LiveTimingPage() {
)} )}
{!isLoading && !snapshot && ( {!isLoading && !snapshot && (
<div className="empty-state" data-testid="live-empty"> <div className="empty-state ui-card glass-panel" style={{ padding: '40px', textAlign: 'center', marginTop: '20vh', maxWidth: '400px', marginLeft: 'auto', marginRight: 'auto' }} data-testid="live-empty">
<div className="live-empty-status"> <div className="live-empty-status" style={{ marginBottom: '16px' }}>
<span className={`live-conn live-conn-${streamStatus}`}>{streamStatus}</span> <span className={`live-conn live-conn-${streamStatus}`}>{streamStatus}</span>
</div> </div>
<div className="empty-state-title">No live session active</div> <Radio size={48} style={{ color: 'var(--text-3)', margin: '0 auto 16px auto', display: 'block' }} />
<div className="empty-state-desc"> <h2 className="empty-state-title" style={{ fontSize: '20px', marginBottom: '8px' }}>No live session active</h2>
No timing data in the current snapshot. The feed will update automatically when an F1 session goes live. <p className="empty-state-desc" style={{ color: 'var(--text-2)' }}>
</div> The telemetry feed is currently offline. <br /><br /> Check the <a href="/" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Command Center</a> for the weekend schedule or explore historical data in the <a href="/race-hub" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Race Hub</a>.
</p>
</div> </div>
)} )}
{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} />
<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}
/>
<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">
<span className="sec-title">Timing Tower</span> <span className="sec-title">Timing Tower</span>
{pinned.length > 0 && <span className="sec-meta">{pinned.length}/3 pinned</span>}
</div> </div>
<TimingTower snapshot={snapshot} /> <BattleChips battles={battles} />
<TimingTower
rows={rows}
stints={snapshot.Stints}
history={gapHistory}
battleNumbers={inBattle}
pinned={pinned}
onTogglePin={handleTogglePin}
session={snapshot.Session}
/>
</div> </div>
<div className="live-rc-col"> <div className="live-rc-col">
<RaceControlFeed messages={snapshot.RCMessages ?? []} /> <TeamRadioTicker
captures={snapshot.TeamRadio ?? []}
driverInfo={snapshot.DriverInfo}
session={snapshot.Session}
/>
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
</div> </div>
</div> </div>
</> </>

View File

@@ -5,6 +5,7 @@ import { RaceHubPage } from './pages/RaceHubPage'
import { DataLibraryPage } from './pages/DataLibraryPage' import { DataLibraryPage } from './pages/DataLibraryPage'
import { LiveTimingPage } from './pages/LiveTimingPage' import { LiveTimingPage } from './pages/LiveTimingPage'
import { BriefingPage } from './pages/BriefingPage' import { BriefingPage } from './pages/BriefingPage'
import { ChampionshipPage } from './pages/ChampionshipPage'
type RaceHubSearch = { type RaceHubSearch = {
session_key?: number session_key?: number
@@ -57,6 +58,12 @@ export const liveTimingRoute = createRoute({
component: LiveTimingPage, component: LiveTimingPage,
}) })
export const championshipRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/championship',
component: ChampionshipPage,
})
export const briefingRoute = createRoute({ export const briefingRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/briefing', path: '/briefing',
@@ -69,6 +76,7 @@ const routeTree = rootRoute.addChildren([
adminRoute, adminRoute,
dataLibraryRoute, dataLibraryRoute,
liveTimingRoute, liveTimingRoute,
championshipRoute,
briefingRoute, briefingRoute,
]) ])

File diff suppressed because it is too large Load Diff

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,152 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
import { ChampionshipPage } from '../pages/ChampionshipPage'
import type { ChampHubDriver, ChampHubTeam, ChampionshipHub } from '../types'
vi.mock('../api', () => ({
fetchSeasons: vi.fn(),
fetchChampionshipHub: vi.fn(),
}))
import { fetchSeasons, fetchChampionshipHub } from '../api'
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchHub = vi.mocked(fetchChampionshipHub)
function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
return {
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
points: 200,
position: 1,
wins: 5,
podiums: 8,
poles: 4,
form: [25, 18, 25, 15, 25],
cumulative: [25, 43, 68, 83, 108, 200],
teammate_wins: 9,
teammate_losses: 1,
...over,
}
}
const drivers: ChampHubDriver[] = [
driver({ driver_number: 1, name_acronym: 'VER', team_name: 'Red Bull', points: 200, position: 1 }),
driver({
driver_number: 4,
name_acronym: 'NOR',
full_name: 'Lando Norris',
team_name: 'McLaren',
team_colour: 'ff8000',
points: 160,
position: 2,
wins: 3,
cumulative: [18, 36, 54, 80, 120, 160],
}),
driver({
driver_number: 16,
name_acronym: 'LEC',
full_name: 'Charles Leclerc',
team_name: 'Ferrari',
team_colour: 'e8002d',
points: 120,
position: 3,
wins: 1,
cumulative: [15, 28, 40, 60, 90, 120],
}),
]
const teams: ChampHubTeam[] = [
{ team_name: 'Red Bull', team_colour: '3671c6', points: 260, position: 1, wins: 6 },
{ team_name: 'McLaren', team_colour: 'ff8000', points: 220, position: 2, wins: 3 },
{ team_name: 'Ferrari', team_colour: 'e8002d', points: 180, position: 3, wins: 1 },
]
const hub: ChampionshipHub = {
season: 2025,
round: 6,
total_rounds: 10,
rounds_left: 4,
last_race: 'Monaco GP',
round_labels: ['R1', 'R2', 'R3', 'R4', 'R5', 'R6'],
drivers,
teams,
}
function renderPage() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const rootRoute = createRootRoute({
component: () => (
<QueryClientProvider client={queryClient}>
<ChampionshipPage />
</QueryClientProvider>
),
})
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: ChampionshipPage })
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
return render(<RouterProvider router={router} />)
}
describe('ChampionshipPage', () => {
beforeEach(() => {
vi.clearAllMocks()
window.localStorage.clear()
mockFetchSeasons.mockResolvedValue([2025])
mockFetchHub.mockResolvedValue(hub)
})
it('renders the drivers view with leader and title math', async () => {
renderPage()
await waitFor(() => {
expect(screen.getByTestId('championship')).toBeInTheDocument()
})
expect(screen.getByTestId('champ-view-drivers')).toBeInTheDocument()
// Leader code shows in the stat rail and the table.
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
expect(screen.getByText('Monaco GP', { exact: false })).toBeInTheDocument()
expect(screen.getByTestId('champ-titlemath')).toHaveTextContent('mathematically win the title')
})
it('switches to constructors and progression views', async () => {
renderPage()
await waitFor(() => expect(screen.getByTestId('championship')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('champ-tab-constructors'))
expect(screen.getByTestId('champ-view-constructors')).toBeInTheDocument()
expect(screen.getAllByText('Red Bull', { exact: false }).length).toBeGreaterThan(0)
fireEvent.click(screen.getByTestId('champ-tab-progression'))
expect(screen.getByTestId('champ-view-progression')).toBeInTheDocument()
expect(screen.getByText('Cumulative points', { exact: false })).toBeInTheDocument()
})
it('switches to the simulator view and projects standings', async () => {
renderPage()
await waitFor(() => expect(screen.getByTestId('championship')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('champ-tab-simulator'))
expect(screen.getByTestId('champ-view-simulator')).toBeInTheDocument()
expect(screen.getByTestId('sim-projected')).toBeInTheDocument()
// 4 rounds left, default scenario: VER projects to 200 + 4×25 = 300.
expect(screen.getByText('300')).toBeInTheDocument()
})
it('shows the empty state when no drivers are returned', async () => {
mockFetchHub.mockResolvedValue({ ...hub, drivers: [], teams: [] })
renderPage()
await waitFor(() => {
expect(screen.getByTestId('championship-empty')).toBeInTheDocument()
})
expect(screen.getByText('No championship data')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,165 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen, fireEvent, within } from '@testing-library/react'
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
import type { ChampHubDriver, ChampionshipHub } from '../types'
function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
return {
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
points: 0,
position: 1,
wins: 0,
podiums: 0,
poles: 0,
form: [],
cumulative: [],
teammate_wins: 0,
teammate_losses: 0,
...over,
}
}
const drivers: ChampHubDriver[] = [
driver({ driver_number: 1, name_acronym: 'VER', points: 200, position: 1 }),
driver({
driver_number: 4,
name_acronym: 'NOR',
full_name: 'Lando Norris',
team_name: 'McLaren',
team_colour: 'ff8000',
points: 190,
position: 2,
}),
driver({
driver_number: 16,
name_acronym: 'LEC',
full_name: 'Charles Leclerc',
team_name: 'Ferrari',
team_colour: 'e8002d',
points: 120,
position: 3,
}),
]
const hub: ChampionshipHub = {
season: 2025,
round: 9,
total_rounds: 10,
rounds_left: 1,
last_race: 'Monaco GP',
round_labels: ['R1', 'R2', 'R3', 'R4', 'R5', 'R6', 'R7', 'R8', 'R9'],
drivers,
teams: [],
}
describe('ChampionshipSimulator', () => {
beforeEach(() => {
window.localStorage.clear()
})
it('seeds the round by championship order and projects the default scenario', () => {
render(<ChampionshipSimulator hub={hub} />)
expect(screen.getByTestId('champ-view-simulator')).toBeInTheDocument()
// Only 1 remaining round; label falls back to "Round 10" (no label yet).
expect(screen.getByTestId('sim-round-0')).toHaveTextContent('Round 10')
// P1 select seeded with the current leader.
expect(screen.getByTestId('sim-pos-1')).toHaveValue('1')
expect(screen.getByTestId('sim-pos-2')).toHaveValue('4')
// Default projection: VER 200+25=225 on top, delta arrows absent.
const verRow = screen.getByTestId('sim-row-1')
expect(within(verRow).getByText('225')).toBeInTheDocument()
expect(within(verRow).getByText('P1')).toBeInTheDocument()
})
it('updates the projected table when a win is reassigned', () => {
render(<ChampionshipSimulator hub={hub} />)
// Give NOR the win; VER (previous P1) is bumped out of the slot.
fireEvent.change(screen.getByTestId('sim-pos-1'), { target: { value: '4' } })
// NOR: 190 + 25 = 215 → P1 with an up arrow; VER stays on 200 → P2 down.
const norRow = screen.getByTestId('sim-row-4')
expect(within(norRow).getByText('215')).toBeInTheDocument()
expect(within(norRow).getByText('P1')).toBeInTheDocument()
expect(within(norRow).getByText('▲1')).toBeInTheDocument()
const verRow = screen.getByTestId('sim-row-1')
// "200" appears in both Now and Proj columns (no simulated points).
expect(within(verRow).getAllByText('200')).toHaveLength(2)
expect(within(verRow).getByText('+0')).toBeInTheDocument()
expect(within(verRow).getByText('P2')).toBeInTheDocument()
expect(within(verRow).getByText('▼1')).toBeInTheDocument()
// VER was removed from P1 and holds no slot now.
expect(screen.getByTestId('sim-pos-1')).toHaveValue('4')
})
it('shows title alive/eliminated states', () => {
render(<ChampionshipSimulator hub={hub} />)
// Default: VER projects to 225. NOR max = 190+25=215 < 225 → OUT.
expect(within(screen.getByTestId('sim-row-1')).getByText('ALIVE')).toBeInTheDocument()
expect(within(screen.getByTestId('sim-row-4')).getByText('OUT')).toBeInTheDocument()
// If VER scores nothing, NOR can still catch him: 190+25 ≥ 200 → ALIVE.
fireEvent.change(screen.getByTestId('sim-pos-1'), { target: { value: '4' } })
expect(within(screen.getByTestId('sim-row-4')).getByText('ALIVE')).toBeInTheDocument()
})
it('reset round and reset all restore the default order', () => {
render(<ChampionshipSimulator hub={hub} />)
fireEvent.change(screen.getByTestId('sim-pos-1'), { target: { value: '16' } })
expect(screen.getByTestId('sim-pos-1')).toHaveValue('16')
fireEvent.click(screen.getByTestId('sim-reset-round'))
expect(screen.getByTestId('sim-pos-1')).toHaveValue('1')
fireEvent.change(screen.getByTestId('sim-pos-1'), { target: { value: '16' } })
fireEvent.click(screen.getByTestId('sim-reset-all'))
expect(screen.getByTestId('sim-pos-1')).toHaveValue('1')
})
it('persists the scenario to localStorage keyed by season', () => {
const { unmount } = render(<ChampionshipSimulator hub={hub} />)
fireEvent.change(screen.getByTestId('sim-pos-1'), { target: { value: '4' } })
unmount()
const stored = window.localStorage.getItem('box-box.champ.sim.2025')
expect(stored).not.toBeNull()
render(<ChampionshipSimulator hub={hub} />)
expect(screen.getByTestId('sim-pos-1')).toHaveValue('4')
})
it('survives corrupt localStorage', () => {
window.localStorage.setItem('box-box.champ.sim.2025', '{not json')
render(<ChampionshipSimulator hub={hub} />)
expect(screen.getByTestId('sim-pos-1')).toHaveValue('1')
})
it('shows the season-complete empty state when no rounds remain', () => {
render(<ChampionshipSimulator hub={{ ...hub, round: 10, rounds_left: 0 }} />)
expect(screen.getByTestId('champ-view-simulator')).toHaveTextContent(
'Season complete — nothing left to simulate.',
)
})
it('uses round labels beyond the current round when available', () => {
const labelled: ChampionshipHub = {
...hub,
round: 8,
rounds_left: 2,
round_labels: [...hub.round_labels, 'ABU'],
}
render(<ChampionshipSimulator hub={labelled} />)
expect(screen.getByTestId('sim-round-0')).toHaveTextContent('R9')
expect(screen.getByTestId('sim-round-1')).toHaveTextContent('ABU')
})
})

View File

@@ -9,15 +9,17 @@ vi.mock('../api', () => ({
fetchSeasons: vi.fn(), fetchSeasons: vi.fn(),
fetchLocalMeetings: vi.fn(), fetchLocalMeetings: vi.fn(),
fetchSeasonMeetings: vi.fn(), fetchSeasonMeetings: vi.fn(),
fetchSessions: vi.fn(),
fetchWeekend: vi.fn(), fetchWeekend: vi.fn(),
fetchLiveState: vi.fn(), fetchLiveState: vi.fn(),
})) }))
import { fetchSeasons, fetchLocalMeetings, fetchSeasonMeetings, fetchWeekend, fetchLiveState } from '../api' import { fetchSeasons, fetchLocalMeetings, fetchSeasonMeetings, fetchSessions, fetchWeekend, fetchLiveState } from '../api'
const mockFetchSeasons = vi.mocked(fetchSeasons) const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings) const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
const mockFetchSeasonMeetings = vi.mocked(fetchSeasonMeetings) const mockFetchSeasonMeetings = vi.mocked(fetchSeasonMeetings)
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)
@@ -99,6 +101,7 @@ describe('CommandCenterPage', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockFetchLiveState.mockResolvedValue({ is_live: false, data: null }) mockFetchLiveState.mockResolvedValue({ is_live: false, data: null })
mockFetchSessions.mockResolvedValue([])
}) })
it('shows empty state when no seasons are ingested', async () => { it('shows empty state when no seasons are ingested', async () => {
@@ -134,4 +137,54 @@ describe('CommandCenterPage', () => {
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('cc-action-race-hub')).toHaveTextContent('Race')
}) })
it('uses OpenF1 calendar metadata to focus the current weekend when local ingest is behind', async () => {
const canada = {
...meeting,
meeting_key: 1301,
meeting_name: 'Canada',
country_name: 'Canada',
country_code: 'CAN',
circuit_short_name: 'Montreal',
date_start: '2026-05-22T00:00:00+00:00',
date_end: '2026-05-24T23:59:59+00:00',
year: 2026,
}
const monaco = {
...meeting,
meeting_key: 1302,
meeting_name: 'Monaco',
date_start: '2026-06-05T00:00:00+00:00',
date_end: '2026-06-07T23:59:59+00:00',
year: 2026,
}
vi.setSystemTime(new Date('2026-06-06T15:15:00Z'))
mockFetchSeasons.mockResolvedValue([2026])
mockFetchLocalMeetings.mockResolvedValue([canada])
mockFetchSeasonMeetings.mockResolvedValue([canada, monaco])
mockFetchWeekend.mockResolvedValue({ ...weekend, meeting: canada, meeting_key: canada.meeting_key })
mockFetchSessions.mockResolvedValue([
{
session_key: 9602,
session_name: 'Qualifying',
session_type: 'Qualifying',
meeting_key: monaco.meeting_key,
date_start: '2026-06-06T15:00:00+00:00',
date_end: '2026-06-06T16:00:00+00:00',
gmt_offset: '02:00:00',
},
])
renderPage()
await waitFor(() => {
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
})
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Current weekend')
expect(screen.getByTestId('cc-session-9602')).toHaveTextContent('On track')
expect(screen.getByTestId('cc-action-race-hub')).toHaveTextContent('Qualifying')
vi.useRealTimers()
})
}) })

View File

@@ -0,0 +1,308 @@
import { describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { TrackStatusBanner } from '../components/live/TrackStatusBanner'
import { WeatherStrip } from '../components/live/WeatherStrip'
import { StintHistory } from '../components/live/StintHistory'
import { BattleChips } from '../components/live/BattleChips'
import { GapSparkline } from '../components/live/GapSparkline'
import { PinnedDrivers } from '../components/live/PinnedDrivers'
import { TimingTower } from '../components/live/TimingTower'
import { TrackMap } from '../components/live/TrackMap'
import { detectBattles } from '../lib/battles'
import type { LiveTimingRow } from '../lib/live'
import type { LiveDriverData, LiveWeatherData, TrackOutline } from '../types'
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:14.000',
BestLapTime: '1:13.500',
NumberOfLaps: 10,
InPit: false,
PitOut: false,
Retired: false,
...driver,
} as LiveDriverData,
Info: {
RacingNumber: number,
BroadcastName: '',
Tla: tla,
TeamName: '',
TeamColour: '3671c6',
FirstName: '',
LastName: '',
},
}
}
describe('TrackStatusBanner', () => {
it('renders a safety car banner for status 4', () => {
render(<TrackStatusBanner status="4" />)
const banner = screen.getByTestId('track-banner')
expect(banner).toHaveClass('track-banner-sc')
expect(banner).toHaveTextContent('SAFETY CAR')
})
it('renders a neutral banner for unknown statuses', () => {
render(<TrackStatusBanner status="42" />)
const banner = screen.getByTestId('track-banner')
expect(banner).toHaveClass('track-banner-unknown')
expect(banner).toHaveTextContent('TRACK STATUS 42')
})
it('renders nothing without a status', () => {
render(<TrackStatusBanner status="" />)
expect(screen.queryByTestId('track-banner')).not.toBeInTheDocument()
})
})
describe('WeatherStrip', () => {
const weather: LiveWeatherData = {
AirTemp: 21.4,
TrackTemp: 34.8,
Humidity: 58,
WindSpeed: 2.3,
WindDir: 180,
Rainfall: true,
}
it('renders all populated weather fields', () => {
render(<WeatherStrip weather={weather} />)
const strip = screen.getByTestId('weather-strip')
expect(strip).toHaveTextContent('21°C')
expect(strip).toHaveTextContent('35°C')
expect(strip).toHaveTextContent('58%')
expect(strip).toHaveTextContent('2.3 m/s S')
expect(strip).toHaveTextContent('RAIN')
})
it('hides gracefully when the payload is empty', () => {
render(
<WeatherStrip
weather={{ AirTemp: 0, TrackTemp: 0, Humidity: 0, WindSpeed: 0, WindDir: 0, Rainfall: false }}
/>,
)
expect(screen.queryByTestId('weather-strip')).not.toBeInTheDocument()
})
it('hides when weather is missing entirely', () => {
render(<WeatherStrip weather={undefined} />)
expect(screen.queryByTestId('weather-strip')).not.toBeInTheDocument()
})
})
describe('StintHistory', () => {
it('renders the compound sequence with lap counts', () => {
render(
<StintHistory
stints={[
{ Compound: 'MEDIUM', New: true, Laps: 12 },
{ Compound: 'HARD', New: false, Laps: 20 },
]}
/>,
)
const seq = screen.getByTestId('stint-seq')
expect(seq).toHaveTextContent('M')
expect(seq).toHaveTextContent('12')
expect(seq).toHaveTextContent('H')
expect(seq).toHaveTextContent('20')
})
it('renders a dash without stint data', () => {
render(<StintHistory stints={undefined} />)
expect(screen.getByText('-')).toBeInTheDocument()
})
})
describe('GapSparkline', () => {
it('shows a placeholder with too few samples', () => {
render(<GapSparkline samples={[1.0]} />)
expect(screen.queryByTestId('gap-spark')).not.toBeInTheDocument()
})
it('renders a closing indicator when the gap shrinks', () => {
render(<GapSparkline samples={[2.0, 1.8, 1.5, 1.2, 0.9, 0.6]} />)
expect(screen.getByTestId('gap-spark')).toHaveClass('trend-closing')
expect(screen.getByTitle('Gap closing')).toBeInTheDocument()
})
})
describe('BattleChips', () => {
it('renders chip labels for detected battles', () => {
const battles = detectBattles(
[makeRow('1', 1, 'VER'), makeRow('4', 2, 'NOR', { Interval: '+0.4' })],
'Race',
)
render(<BattleChips battles={battles} />)
expect(screen.getByTestId('battle-chips')).toHaveTextContent('VER ⚔ NOR +0.4')
})
it('renders nothing when there are no battles', () => {
render(<BattleChips battles={[]} />)
expect(screen.queryByTestId('battle-chips')).not.toBeInTheDocument()
})
})
describe('TimingTower', () => {
const rows = [
makeRow('1', 1, 'VER'),
makeRow('4', 2, 'NOR', { Interval: '+0.4', GapToLeader: '+0.4' }),
makeRow('16', 3, 'LEC', { Interval: '+3.2', GapToLeader: '+3.6' }),
]
it('highlights battle rows and marks pinned drivers', () => {
render(
<TimingTower
rows={rows}
battleNumbers={new Set(['1', '4'])}
pinned={['16']}
onTogglePin={() => {}}
/>,
)
expect(screen.getByText('VER').closest('tr')).toHaveClass('battle-row')
expect(screen.getByText('NOR').closest('tr')).toHaveClass('battle-row')
const lecRow = screen.getByText('LEC').closest('tr')
expect(lecRow).not.toHaveClass('battle-row')
expect(lecRow).toHaveClass('pinned-row')
})
it('toggles a pin when the pin button is clicked', () => {
const onTogglePin = vi.fn()
render(<TimingTower rows={rows} onTogglePin={onTogglePin} />)
fireEvent.click(screen.getByText('NOR').closest('tr')!.querySelector('.pin-btn')!)
expect(onTogglePin).toHaveBeenCalledWith('4')
})
it('shows an empty notice without rows', () => {
render(<TimingTower rows={[]} />)
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', () => {
it('renders pinned cards with gap and unpins on click', () => {
const onToggle = vi.fn()
render(
<PinnedDrivers
rows={[makeRow('4', 2, 'NOR', { Interval: '+0.4' })]}
history={{ '4': [1.0, 0.8, 0.6, 0.4] }}
pinned={['4']}
onToggle={onToggle}
/>,
)
const strip = screen.getByTestId('pinned-strip')
expect(strip).toHaveTextContent('NOR')
expect(strip).toHaveTextContent('+0.4')
fireEvent.click(screen.getByText('NOR').closest('button')!)
expect(onToggle).toHaveBeenCalledWith('4')
})
it('renders a fallback card when the driver is missing from the feed', () => {
render(
<PinnedDrivers rows={[]} history={{}} pinned={['44']} onToggle={() => {}} />,
)
expect(screen.getByTestId('pinned-strip')).toHaveTextContent('no data')
})
it('renders nothing without pins', () => {
render(<PinnedDrivers rows={[]} history={{}} pinned={[]} onToggle={() => {}} />)
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,119 @@
import { describe, expect, it } from 'vitest'
import { battleLabel, battleNumbers, detectBattles, isRaceSession } from '../lib/battles'
import type { LiveTimingRow } from '../lib/live'
import type { LiveDriverData } from '../types'
function row(
number: string,
position: number,
interval: string,
tla: string,
overrides: Partial<LiveDriverData> = {},
): LiveTimingRow {
return {
RacingNumber: number,
Position: position,
Driver: {
RacingNumber: number,
Position: position,
Interval: interval,
GapToLeader: interval,
InPit: false,
PitOut: false,
Retired: false,
...overrides,
} as LiveDriverData,
Info: {
RacingNumber: number,
BroadcastName: '',
Tla: tla,
TeamName: '',
TeamColour: '',
FirstName: '',
LastName: '',
},
}
}
describe('isRaceSession', () => {
it('only treats race and sprint sessions as races', () => {
expect(isRaceSession('Race')).toBe(true)
expect(isRaceSession('Sprint')).toBe(true)
expect(isRaceSession('Sprint Qualifying')).toBe(false)
expect(isRaceSession('Qualifying')).toBe(false)
expect(isRaceSession('Practice')).toBe(false)
expect(isRaceSession('')).toBe(false)
expect(isRaceSession(undefined)).toBe(false)
})
})
describe('detectBattles', () => {
const rows = [
row('1', 1, '', 'VER'),
row('4', 2, '+0.4', 'NOR'),
row('81', 3, '+0.8', 'PIA'),
row('16', 4, '+5.0', 'LEC'),
row('44', 5, '+0.9', 'HAM'),
row('63', 6, '+12.2', 'RUS'),
]
it('groups consecutive cars within 1.0s in race sessions', () => {
const battles = detectBattles(rows, 'Race')
expect(battles).toHaveLength(2)
expect(battles[0].drivers.map((d) => d.code)).toEqual(['VER', 'NOR', 'PIA'])
expect(battles[0].minGap).toBeCloseTo(0.4)
expect(battles[1].drivers.map((d) => d.code)).toEqual(['LEC', 'HAM'])
expect(battles[1].minGap).toBeCloseTo(0.9)
})
it('returns nothing outside race sessions', () => {
expect(detectBattles(rows, 'Qualifying')).toEqual([])
expect(detectBattles(rows, undefined)).toEqual([])
})
it('excludes cars in the pits or retired', () => {
const pitted = [
row('1', 1, '', 'VER'),
row('4', 2, '+0.4', 'NOR', { InPit: true }),
row('81', 3, '+0.6', 'PIA'),
row('16', 4, '+8.0', 'LEC'),
row('44', 5, '+0.5', 'HAM', { Retired: true }),
]
const battles = detectBattles(pitted, 'Race')
expect(battles).toEqual([])
})
it('ignores lapped and missing intervals', () => {
const lapped = [
row('1', 1, '', 'VER'),
row('4', 2, '1L', 'NOR'),
row('81', 3, '', 'PIA'),
]
expect(detectBattles(lapped, 'Race')).toEqual([])
})
it('handles empty and single-row input', () => {
expect(detectBattles([], 'Race')).toEqual([])
expect(detectBattles([row('1', 1, '', 'VER')], 'Race')).toEqual([])
})
})
describe('battleLabel / battleNumbers', () => {
it('formats a two-car chip label', () => {
const battles = detectBattles([row('1', 1, '', 'VER'), row('4', 2, '+0.4', 'NOR')], 'Race')
expect(battleLabel(battles[0])).toBe('VER ⚔ NOR +0.4')
})
it('collects racing numbers across all battles', () => {
const battles = detectBattles(
[
row('1', 1, '', 'VER'),
row('4', 2, '+0.4', 'NOR'),
row('16', 3, '+9.0', 'LEC'),
row('44', 4, '+0.7', 'HAM'),
],
'Race',
)
expect(battleNumbers(battles)).toEqual(new Set(['1', '4', '16', '44']))
})
})

View File

@@ -59,11 +59,18 @@ describe('coverage helpers', () => {
meeting: {} as Weekend['meeting'], meeting: {} as Weekend['meeting'],
sessions: [{ session: {} as Weekend['sessions'][0]['session'], source: 'partial', datasets: {} }], sessions: [{ session: {} as Weekend['sessions'][0]['session'], source: 'partial', datasets: {} }],
} }
expect(countWeekendStats([local, partial, undefined])).toEqual({ const cancelled: Weekend = {
source: 'cancelled',
meeting_key: 3,
meeting: {} as Weekend['meeting'],
sessions: [{ session: {} as Weekend['sessions'][0]['session'], source: 'cancelled', datasets: {} }],
}
expect(countWeekendStats([local, partial, cancelled, undefined])).toEqual({
full: 1, full: 1,
partial: 1, partial: 1,
cancelled: 1,
missing: 1, missing: 1,
total: 3, total: 4,
}) })
}) })
}) })

View File

@@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest'
import {
MAX_GAP_SAMPLES,
gapTrend,
parseIntervalSeconds,
recordGapSamples,
sparklinePoints,
} from '../lib/gapHistory'
import type { GapHistoryMap } from '../lib/gapHistory'
describe('parseIntervalSeconds', () => {
it('parses signed and unsigned decimal gaps', () => {
expect(parseIntervalSeconds('+1.234')).toBe(1.234)
expect(parseIntervalSeconds('1.234')).toBe(1.234)
expect(parseIntervalSeconds('+0.4')).toBe(0.4)
expect(parseIntervalSeconds('12')).toBe(12)
expect(parseIntervalSeconds('-0.5')).toBe(-0.5)
})
it('parses minute-form gaps', () => {
expect(parseIntervalSeconds('+1:05.234')).toBeCloseTo(65.234)
})
it('rejects leader, lapped, and garbage markers', () => {
expect(parseIntervalSeconds('')).toBeNull()
expect(parseIntervalSeconds(' ')).toBeNull()
expect(parseIntervalSeconds(undefined)).toBeNull()
expect(parseIntervalSeconds(null)).toBeNull()
expect(parseIntervalSeconds('LAP 12')).toBeNull()
expect(parseIntervalSeconds('1L')).toBeNull()
expect(parseIntervalSeconds('+1 LAP')).toBeNull()
expect(parseIntervalSeconds('2 LAPS')).toBeNull()
expect(parseIntervalSeconds('abc')).toBeNull()
})
})
describe('recordGapSamples', () => {
it('appends one sample per snapshot and does not mutate the input', () => {
const initial = { '1': [1.0] }
const next = recordGapSamples(initial, [
{ racingNumber: '1', interval: '+0.9' },
{ racingNumber: '4', interval: '+1.5' },
])
expect(next['1']).toEqual([1.0, 0.9])
expect(next['4']).toEqual([1.5])
expect(initial['1']).toEqual([1.0])
})
it('keeps existing history when the interval is unparsable', () => {
const next = recordGapSamples({ '1': [1.2, 1.1] }, [{ racingNumber: '1', interval: '1L' }])
expect(next['1']).toEqual([1.2, 1.1])
})
it('prunes drivers missing from the snapshot', () => {
const next = recordGapSamples({ '99': [3.0] }, [{ racingNumber: '1', interval: '+0.5' }])
expect(next['99']).toBeUndefined()
})
it('caps the ring buffer', () => {
let history: GapHistoryMap = {}
for (let i = 0; i < 50; i++) {
history = recordGapSamples(history, [{ racingNumber: '1', interval: `+${i}.0` }])
}
expect(history['1']).toHaveLength(MAX_GAP_SAMPLES)
expect(history['1'][MAX_GAP_SAMPLES - 1]).toBe(49)
expect(history['1'][0]).toBe(50 - MAX_GAP_SAMPLES)
})
it('respects a custom cap', () => {
const next = recordGapSamples({ '1': [1, 2, 3] }, [{ racingNumber: '1', interval: '+4.0' }], 3)
expect(next['1']).toEqual([2, 3, 4])
})
})
describe('gapTrend', () => {
it('detects a closing gap', () => {
expect(gapTrend([2.0, 1.8, 1.6, 1.4, 1.2, 1.0])).toBe('closing')
})
it('detects an opening gap', () => {
expect(gapTrend([1.0, 1.2, 1.4, 1.6, 1.8, 2.0])).toBe('opening')
})
it('reports steady within the threshold', () => {
expect(gapTrend([1.0, 1.02, 0.98, 1.01, 1.0, 0.99])).toBe('steady')
})
it('needs at least three samples', () => {
expect(gapTrend([])).toBeNull()
expect(gapTrend([1.0])).toBeNull()
expect(gapTrend([1.0, 0.5])).toBeNull()
})
})
describe('sparklinePoints', () => {
it('returns empty for insufficient samples', () => {
expect(sparklinePoints([], 56, 14)).toBe('')
expect(sparklinePoints([1.0], 56, 14)).toBe('')
})
it('spans the full width and inverts the y axis (smaller gap = lower)', () => {
const points = sparklinePoints([0, 10], 56, 14).split(' ')
expect(points).toHaveLength(2)
const [x1, y1] = points[0].split(',').map(Number)
const [x2, y2] = points[1].split(',').map(Number)
expect(x1).toBe(0)
expect(x2).toBe(56)
expect(y1).toBeGreaterThan(y2) // larger gap plots higher (smaller y)
})
it('draws a mid line for flat data', () => {
const points = sparklinePoints([1, 1, 1], 56, 14).split(' ')
for (const point of points) {
expect(Number(point.split(',')[1])).toBe(7)
}
})
})

View File

@@ -1,16 +1,27 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
compoundClass,
compoundLetter,
extrapolateClock, extrapolateClock,
latestRaceControl, latestRaceControl,
liveSessionDisplay,
loadPinnedDrivers,
mergeVisibleSectors,
positionDeltaClass, positionDeltaClass,
parseLiveStateEvent, parseLiveStateEvent,
rcFlagClass, rcFlagClass,
rowsWithVisibleSectors,
savePinnedDrivers,
sortLiveTimingRows, sortLiveTimingRows,
togglePin,
trackStatusInfo,
trackStatusLabel, trackStatusLabel,
tyreClass, tyreClass,
tyreLabel, tyreLabel,
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: {
@@ -89,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,
@@ -99,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 }))
@@ -137,3 +194,155 @@ describe('live transforms', () => {
expect(extrapolateClock('01:20:00', '2026-05-25T12:00:00Z', true, Date.parse('2026-05-25T12:00:30Z'))).toBe('01:19:30') expect(extrapolateClock('01:20:00', '2026-05-25T12:00:00Z', true, Date.parse('2026-05-25T12:00:30Z'))).toBe('01:19:30')
}) })
}) })
describe('track status mapping', () => {
it('maps all known raw statuses to banner info', () => {
expect(trackStatusInfo('1')).toMatchObject({ key: 'green', label: 'TRACK CLEAR' })
expect(trackStatusInfo('2')).toMatchObject({ key: 'yellow', label: 'YELLOW FLAG' })
expect(trackStatusInfo('4')).toMatchObject({ key: 'sc', label: 'SAFETY CAR' })
expect(trackStatusInfo('5')).toMatchObject({ key: 'red', label: 'RED FLAG' })
expect(trackStatusInfo('6')).toMatchObject({ key: 'vsc', label: 'VIRTUAL SAFETY CAR' })
expect(trackStatusInfo('7')).toMatchObject({ key: 'vsc', label: 'VSC ENDING' })
})
it('falls back to a neutral display for unknown values', () => {
expect(trackStatusInfo('9')).toMatchObject({ key: 'unknown', label: 'TRACK STATUS 9' })
expect(trackStatusInfo('')).toMatchObject({ key: 'unknown', label: 'TRACK STATUS UNKNOWN' })
expect(trackStatusInfo(undefined)).toMatchObject({ key: 'unknown' })
})
it('keeps the compact label helper defensive', () => {
expect(trackStatusLabel('7')).toBe('VSC ENDING')
expect(trackStatusLabel('99')).toBe('99')
expect(trackStatusLabel('')).toBe('UNKNOWN')
})
})
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', () => {
it('maps wind direction degrees to compass points', () => {
expect(windDirectionLabel(0)).toBe('N')
expect(windDirectionLabel(90)).toBe('E')
expect(windDirectionLabel(180)).toBe('S')
expect(windDirectionLabel(315)).toBe('NW')
expect(windDirectionLabel(359)).toBe('N')
expect(windDirectionLabel(null)).toBe('')
})
it('maps stint compounds to classes and letters', () => {
expect(compoundClass('SOFT')).toBe('tyre-soft')
expect(compoundClass('INTERMEDIATE')).toBe('tyre-inter')
expect(compoundClass('')).toBe('tyre-unknown')
expect(compoundLetter('MEDIUM')).toBe('M')
expect(compoundLetter(undefined)).toBe('?')
})
})
describe('pinned drivers', () => {
it('toggles pins with a max of three, dropping the oldest', () => {
expect(togglePin([], '1')).toEqual(['1'])
expect(togglePin(['1'], '1')).toEqual([])
expect(togglePin(['1', '4'], '16')).toEqual(['1', '4', '16'])
expect(togglePin(['1', '4', '16'], '44')).toEqual(['4', '16', '44'])
expect(togglePin(['1', '4', '16'], '4')).toEqual(['1', '16'])
})
it('round-trips pins through storage and survives corrupt data', () => {
const store = new Map<string, string>()
const storage = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => void store.set(key, value),
}
savePinnedDrivers(['1', '44'], storage)
expect(loadPinnedDrivers(storage)).toEqual(['1', '44'])
store.set('box-box.live.pins', 'not json {{')
expect(loadPinnedDrivers(storage)).toEqual([])
store.set('box-box.live.pins', '{"nope":true}')
expect(loadPinnedDrivers(storage)).toEqual([])
})
})

View File

@@ -4,3 +4,26 @@ Object.defineProperty(window, 'scrollTo', {
value: () => {}, value: () => {},
writable: true, writable: true,
}) })
// Node 22+ ships an experimental `localStorage` global that shadows jsdom's
// implementation; without `--localstorage-file` it resolves to undefined in
// the test environment. Back-fill a minimal in-memory Storage so components
// that persist state (e.g. the championship simulator) are testable.
if (typeof window !== 'undefined' && !window.localStorage) {
const store = new Map<string, string>()
const localStorageMock: Storage = {
get length() {
return store.size
},
clear: () => store.clear(),
getItem: (key) => (store.has(key) ? store.get(key)! : null),
key: (index) => [...store.keys()][index] ?? null,
removeItem: (key) => {
store.delete(key)
},
setItem: (key, value) => {
store.set(String(key), String(value))
},
}
Object.defineProperty(window, 'localStorage', { value: localStorageMock, writable: true })
}

View File

@@ -0,0 +1,240 @@
import { describe, expect, it } from 'vitest'
import {
MAX_POINTS_PER_ROUND,
POINTS_BY_POSITION,
SCORING_POSITIONS,
assignPosition,
defaultRound,
defaultScenario,
emptyRound,
normalizeScenario,
pointsForPosition,
projectStandings,
simulatedPoints,
} from '../lib/simulator'
import type { Scenario } from '../lib/simulator'
import type { ChampHubDriver } from '../types'
function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
return {
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
points: 0,
position: 1,
wins: 0,
podiums: 0,
poles: 0,
form: [],
cumulative: [],
teammate_wins: 0,
teammate_losses: 0,
...over,
}
}
const standings: ChampHubDriver[] = [
driver({ driver_number: 1, name_acronym: 'VER', points: 200, position: 1 }),
driver({ driver_number: 4, name_acronym: 'NOR', points: 190, position: 2 }),
driver({ driver_number: 16, name_acronym: 'LEC', points: 120, position: 3 }),
driver({ driver_number: 44, name_acronym: 'HAM', points: 40, position: 4 }),
]
describe('pointsForPosition', () => {
it('matches the current F1 points table for P1P10', () => {
expect(POINTS_BY_POSITION).toEqual([25, 18, 15, 12, 10, 8, 6, 4, 2, 1])
expect(pointsForPosition(1)).toBe(25)
expect(pointsForPosition(2)).toBe(18)
expect(pointsForPosition(10)).toBe(1)
})
it('awards zero outside the top ten and for invalid positions', () => {
expect(pointsForPosition(0)).toBe(0)
expect(pointsForPosition(11)).toBe(0)
expect(pointsForPosition(-3)).toBe(0)
expect(pointsForPosition(2.5)).toBe(0)
})
})
describe('defaultRound / defaultScenario', () => {
it('seeds the round with the current championship order', () => {
const round = defaultRound(standings)
expect(round.length).toBe(SCORING_POSITIONS)
expect(round.slice(0, 4)).toEqual([1, 4, 16, 44])
expect(round.slice(4)).toEqual([null, null, null, null, null, null])
})
it('sorts by points even when position fields disagree', () => {
const shuffled = [
driver({ driver_number: 4, points: 190, position: 2 }),
driver({ driver_number: 1, points: 200, position: 1 }),
]
expect(defaultRound(shuffled).slice(0, 2)).toEqual([1, 4])
})
it('builds one round per remaining round, and none for finished seasons', () => {
expect(defaultScenario(standings, 3)).toHaveLength(3)
expect(defaultScenario(standings, 0)).toHaveLength(0)
expect(defaultScenario(standings, -2)).toHaveLength(0)
expect(defaultScenario([], 2)[0]).toEqual(emptyRound())
})
})
describe('simulatedPoints', () => {
it('sums points across rounds per driver', () => {
const scenario: Scenario = [defaultRound(standings), defaultRound(standings)]
const totals = simulatedPoints(scenario)
expect(totals.get(1)).toBe(50)
expect(totals.get(4)).toBe(36)
expect(totals.get(16)).toBe(30)
expect(totals.get(44)).toBe(24)
})
it('ignores unassigned slots and empty scenarios', () => {
expect(simulatedPoints([]).size).toBe(0)
expect(simulatedPoints([emptyRound()]).size).toBe(0)
})
})
describe('projectStandings', () => {
it('returns an empty array for no drivers', () => {
expect(projectStandings([], [emptyRound()], 3)).toEqual([])
})
it('keeps order and zero deltas under the default (status quo) scenario', () => {
const rows = projectStandings(standings, defaultScenario(standings, 2), 2)
expect(rows.map((r) => r.driver.driver_number)).toEqual([1, 4, 16, 44])
expect(rows.every((r) => r.delta === 0)).toBe(true)
expect(rows[0].projectedPoints).toBe(200 + 50)
})
it('computes projected points, positions, and deltas when the order flips', () => {
// NOR wins both remaining rounds, VER scores nothing.
const win: (number | null)[] = [4, ...Array(9).fill(null)]
const rows = projectStandings(standings, [win, win], 2)
const nor = rows.find((r) => r.driver.driver_number === 4)!
const ver = rows.find((r) => r.driver.driver_number === 1)!
expect(nor.projectedPoints).toBe(190 + 50)
expect(nor.projectedPosition).toBe(1)
expect(nor.delta).toBe(1) // moved up one place
expect(ver.projectedPosition).toBe(2)
expect(ver.delta).toBe(-1) // dropped one place
})
it('breaks projected-points ties by current position', () => {
const pair = [
driver({ driver_number: 1, name_acronym: 'VER', points: 100, position: 1 }),
driver({ driver_number: 4, name_acronym: 'NOR', points: 90, position: 2 }),
]
// NOR takes P5 (+10) → both project to 100. Better current position wins the tie.
const round: (number | null)[] = [null, null, null, null, 4, null, null, null, null, null]
const rows = projectStandings(pair, [round], 1)
expect(rows[0].driver.driver_number).toBe(1)
expect(rows[1].driver.driver_number).toBe(4)
expect(rows[0].projectedPoints).toBe(100)
expect(rows[1].projectedPoints).toBe(100)
expect(rows.every((r) => r.delta === 0)).toBe(true)
})
})
describe('title elimination math', () => {
it('marks drivers alive when max remaining points can match the leader projection', () => {
// Default scenario: leader VER projects to 200 + 2×25 = 250.
// HAM max = 40 + 2×25 = 90 < 250 → OUT. NOR max = 190 + 50 = 240 < 250 → OUT.
const rows = projectStandings(standings, defaultScenario(standings, 2), 2)
const byNum = new Map(rows.map((r) => [r.driver.driver_number, r]))
expect(byNum.get(1)!.titleAlive).toBe(true)
expect(byNum.get(4)!.titleAlive).toBe(false)
expect(byNum.get(44)!.titleAlive).toBe(false)
})
it('keeps close challengers alive when the leader scores nothing in the scenario', () => {
// Leader scores 0 in both remaining rounds → leader projected stays 200.
const scenario: Scenario = [emptyRound(), emptyRound()]
const rows = projectStandings(standings, scenario, 2)
const byNum = new Map(rows.map((r) => [r.driver.driver_number, r]))
expect(byNum.get(4)!.titleAlive).toBe(true) // 190 + 50 ≥ 200
expect(byNum.get(16)!.titleAlive).toBe(false) // 120 + 50 < 200
expect(byNum.get(44)!.titleAlive).toBe(false) // 40 + 50 < 200
})
it('always keeps the current leader alive', () => {
const scenario = defaultScenario(standings, 5)
const rows = projectStandings(standings, scenario, 5)
expect(rows.find((r) => r.driver.driver_number === 1)!.titleAlive).toBe(true)
})
it('handles zero rounds left: alive only means already matching the leader', () => {
const rows = projectStandings(standings, [], 0)
expect(rows[0].titleAlive).toBe(true)
expect(rows.slice(1).every((r) => !r.titleAlive)).toBe(true)
})
it('uses 25 as the max points per round', () => {
expect(MAX_POINTS_PER_ROUND).toBe(25)
})
})
describe('normalizeScenario', () => {
it('accepts a valid stored scenario', () => {
const stored: Scenario = [
[4, 1, null, null, null, null, null, null, null, null],
defaultRound(standings),
]
const result = normalizeScenario(JSON.parse(JSON.stringify(stored)), standings, 2)
expect(result).toEqual(stored)
})
it('falls back to defaults for garbage input', () => {
const def = defaultScenario(standings, 2)
expect(normalizeScenario(undefined, standings, 2)).toEqual(def)
expect(normalizeScenario('nope', standings, 2)).toEqual(def)
expect(normalizeScenario({ a: 1 }, standings, 2)).toEqual(def)
expect(normalizeScenario(42, standings, 2)).toEqual(def)
})
it('rejects rounds with unknown drivers, duplicates, or the wrong shape', () => {
const def = defaultRound(standings)
const bad: unknown = [
[999, null, null, null, null, null, null, null, null, null], // unknown driver
[1, 1, null, null, null, null, null, null, null, null], // duplicate
[1, 4], // wrong length
]
const result = normalizeScenario(bad, standings, 3)
expect(result).toEqual([def, def, def])
})
it('trims or pads to the current rounds_left', () => {
const one: Scenario = [[4, null, null, null, null, null, null, null, null, null]]
expect(normalizeScenario(one, standings, 3)).toHaveLength(3)
expect(normalizeScenario([...one, ...one, ...one], standings, 1)).toHaveLength(1)
})
})
describe('assignPosition', () => {
it('assigns a driver and removes it from its previous slot', () => {
const round = defaultRound(standings) // [1, 4, 16, 44, ...]
const next = assignPosition(round, 0, 4) // NOR to P1
expect(next[0]).toBe(4)
expect(next[1]).toBeNull() // NOR removed from P2
expect(next[2]).toBe(16)
expect(round[0]).toBe(1) // input not mutated
})
it('clears a slot when assigning null and ignores out-of-range positions', () => {
const round = defaultRound(standings)
expect(assignPosition(round, 0, null)[0]).toBeNull()
expect(assignPosition(round, 99, 4)).toEqual(round)
expect(assignPosition(round, -1, 4)).toEqual(round)
})
it('repairs short rounds to the full ten slots', () => {
const next = assignPosition([1], 3, 4)
expect(next).toHaveLength(SCORING_POSITIONS)
expect(next[0]).toBe(1)
expect(next[3]).toBe(4)
})
})

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

@@ -16,6 +16,7 @@ export interface Meeting {
date_start: string date_start: string
date_end: string date_end: string
year: number year: number
is_cancelled?: boolean
} }
export interface Session { export interface Session {
@@ -26,6 +27,7 @@ export interface Session {
date_start: string date_start: string
date_end: string date_end: string
gmt_offset: string gmt_offset: string
is_cancelled?: boolean
} }
export interface Driver { export interface Driver {
@@ -73,7 +75,7 @@ export interface EnrichedGrid {
} }
export interface RaceHub { export interface RaceHub {
source: 'local' | 'partial' | 'none' source: 'local' | 'partial' | 'none' | 'cancelled'
session_key: number session_key: number
datasets: Record<string, DatasetInfo> datasets: Record<string, DatasetInfo>
meeting?: Meeting meeting?: Meeting
@@ -158,12 +160,12 @@ export interface Lap {
export interface WeekendSession { export interface WeekendSession {
session: Session session: Session
source: 'local' | 'partial' | 'none' source: 'local' | 'partial' | 'none' | 'cancelled'
datasets: Record<string, DatasetInfo> datasets: Record<string, DatasetInfo>
} }
export interface Weekend { export interface Weekend {
source: 'local' | 'partial' | 'none' source: 'local' | 'partial' | 'none' | 'cancelled'
meeting_key: number meeting_key: number
meeting: Meeting meeting: Meeting
sessions: WeekendSession[] sessions: WeekendSession[]
@@ -175,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
@@ -243,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 {
@@ -251,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
@@ -267,6 +294,60 @@ 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 {
driver_number: number
name_acronym: string
full_name: string
team_name: string
team_colour: string
points: number
position: number
wins: number
podiums: number
poles: number
form: number[]
cumulative: number[]
teammate_wins: number
teammate_losses: number
}
export interface ChampHubTeam {
team_name: string
team_colour: string
points: number
position: number
wins: number
}
export interface ChampionshipHub {
season: number
round: number
total_rounds: number
rounds_left: number
last_race: string
round_labels: string[]
drivers: ChampHubDriver[]
teams: ChampHubTeam[]
}
export interface NewsItem { export interface NewsItem {
source: string source: string
title: string title: string

View File

@@ -2,15 +2,52 @@ package api
import ( import (
"net/http" "net/http"
"sync"
"sync/atomic" "sync/atomic"
"time" "time"
) )
// Minimum spacing between live OpenF1 requests. The free tier throttles
// bursts of more than ~3 requests/second, so anonymous clients are paced
// conservatively; authenticated (paid tier) clients get a higher rate.
const (
anonRequestInterval = 350 * time.Millisecond
authRequestInterval = 100 * time.Millisecond
)
// requestPacer spaces network requests evenly so concurrent callers
// (e.g. the championship hub fan-out) cannot burst past the API rate limit.
// Cache hits never touch the pacer.
type requestPacer struct {
mu sync.Mutex
interval time.Duration
next time.Time
}
// wait blocks until this caller's reserved slot arrives.
func (p *requestPacer) wait() {
if p == nil || p.interval <= 0 {
return
}
p.mu.Lock()
now := time.Now()
if p.next.Before(now) {
p.next = now
}
sleep := p.next.Sub(now)
p.next = p.next.Add(p.interval)
p.mu.Unlock()
if sleep > 0 {
time.Sleep(sleep)
}
}
type OpenF1Client struct { type OpenF1Client struct {
url string url string
apiKey string apiKey string
httpClient *http.Client httpClient *http.Client
cache *Cache cache *Cache
pacer *requestPacer
// staleFlag is set to 1 atomically whenever a request falls back to stale // staleFlag is set to 1 atomically whenever a request falls back to stale
// cached data (e.g. because the API is locked during a live session). // cached data (e.g. because the API is locked during a live session).
@@ -24,6 +61,7 @@ func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client {
url: url, url: url,
httpClient: &http.Client{Timeout: timeout}, httpClient: &http.Client{Timeout: timeout},
cache: NewCache(), cache: NewCache(),
pacer: &requestPacer{interval: anonRequestInterval},
} }
} }
@@ -35,6 +73,7 @@ func NewOpenF1ClientWithKey(url string, timeout time.Duration, apiKey string) *O
apiKey: apiKey, apiKey: apiKey,
httpClient: &http.Client{Timeout: timeout}, httpClient: &http.Client{Timeout: timeout},
cache: NewCache(), cache: NewCache(),
pacer: &requestPacer{interval: authRequestInterval},
} }
} }

View File

@@ -35,6 +35,45 @@ func IsLiveSessionError(err error) bool {
return errors.Is(err, ErrLiveSessionLocked) return errors.Is(err, ErrLiveSessionLocked)
} }
// max429Retries is how many times doPaced re-attempts a request that came
// back 429 before giving up and letting the caller fall back to stale data.
const max429Retries = 3
// retryAfter429 extracts the server-requested backoff from a 429 response,
// with a sane default and cap so a hostile header can't stall the app.
func retryAfter429(resp *http.Response) time.Duration {
delay := time.Second
if header := resp.Header.Get("Retry-After"); header != "" {
if seconds, err := strconv.Atoi(header); err == nil && seconds > 0 {
delay = time.Duration(seconds) * time.Second
}
}
if delay > 10*time.Second {
delay = 10 * time.Second
}
return delay
}
// doPaced executes req through the client's request pacer and transparently
// retries 429 responses (honouring Retry-After) up to max429Retries times.
// Without this, concurrent fan-outs (championship hub, track prefetch) burst
// past the free-tier limit and callers silently treat 429s as missing data.
func (c *OpenF1Client) doPaced(req *http.Request) (*http.Response, error) {
for attempt := 0; ; attempt++ {
c.pacer.wait()
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusTooManyRequests || attempt >= max429Retries {
return resp, nil
}
delay := retryAfter429(resp)
resp.Body.Close()
time.Sleep(delay)
}
}
// get performs a GET request and returns the response body, or an error if the // get performs a GET request and returns the response body, or an error if the
// status code is not 200 OK. It checks the SQLite cache before making a // status code is not 200 OK. It checks the SQLite cache before making a
// network request. // network request.
@@ -59,7 +98,7 @@ func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("Authorization", "Bearer "+c.apiKey)
} }
resp, err := c.httpClient.Do(req) resp, err := c.doPaced(req)
if err != nil { if err != nil {
return c.tryStale(url, err) return c.tryStale(url, err)
} }
@@ -114,20 +153,14 @@ func (c *OpenF1Client) FetchStrict(url string) ([]byte, error) {
req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("Authorization", "Bearer "+c.apiKey)
} }
resp, err := c.httpClient.Do(req) resp, err := c.doPaced(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests { if resp.StatusCode == http.StatusTooManyRequests {
retryAfterDur := 500 * time.Millisecond return nil, &RateLimitError{RetryAfter: retryAfter429(resp)}
if retryAfterHeader := resp.Header.Get("Retry-After"); retryAfterHeader != "" {
if seconds, err := strconv.Atoi(retryAfterHeader); err == nil {
retryAfterDur = time.Duration(seconds) * time.Second
}
}
return nil, &RateLimitError{RetryAfter: retryAfterDur}
} }
data, err := io.ReadAll(resp.Body) data, err := io.ReadAll(resp.Body)

107
internal/api/pacing_test.go Normal file
View File

@@ -0,0 +1,107 @@
package api
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
)
// newPacedTestClient builds a client against a test server with an isolated cache
// (HOME is pointed at a temp dir so the SQLite cache never touches the real one).
func newPacedTestClient(t *testing.T, srvURL string, interval time.Duration) *OpenF1Client {
t.Helper()
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
c := NewOpenF1Client(srvURL, 5*time.Second)
c.pacer = &requestPacer{interval: interval}
t.Cleanup(func() { _ = c.Close() })
return c
}
func TestRequestPacerSpacesConcurrentCallers(t *testing.T) {
p := &requestPacer{interval: 20 * time.Millisecond}
const callers = 5
start := time.Now()
var wg sync.WaitGroup
for i := 0; i < callers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
p.wait()
}()
}
wg.Wait()
// 5 callers at 20ms spacing: the last slot is 80ms after the first.
if elapsed := time.Since(start); elapsed < 4*20*time.Millisecond {
t.Fatalf("pacer did not space callers: %d finished in %v", callers, elapsed)
}
}
func TestRequestPacerNilSafe(t *testing.T) {
var p *requestPacer
p.wait() // must not panic
}
func TestGetRetriesOn429(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if calls.Add(1) <= 2 {
w.Header().Set("Retry-After", "0")
w.WriteHeader(http.StatusTooManyRequests)
return
}
_ = json.NewEncoder(w).Encode([]map[string]any{{"meeting_key": 1}})
}))
defer srv.Close()
c := newPacedTestClient(t, srv.URL, time.Millisecond)
body, err := c.get(srv.URL + "/v1/meetings")
if err != nil {
t.Fatalf("get after 429s should succeed, got %v", err)
}
data, _ := io.ReadAll(body)
body.Close()
if len(data) == 0 {
t.Fatal("expected response body after retries")
}
if got := calls.Load(); got != 3 {
t.Fatalf("expected 3 attempts (2×429 + 1×200), got %d", got)
}
}
func TestGetGivesUpAfterMaxRetries(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.Header().Set("Retry-After", "0")
w.WriteHeader(http.StatusTooManyRequests)
}))
defer srv.Close()
c := newPacedTestClient(t, srv.URL, time.Millisecond)
_, err := c.get(srv.URL + "/v1/meetings")
if err == nil {
t.Fatal("expected error when server keeps returning 429")
}
if got := calls.Load(); got != int32(max429Retries)+1 {
t.Fatalf("expected %d attempts, got %d", max429Retries+1, got)
}
}
func TestRetryAfter429Cap(t *testing.T) {
resp := &http.Response{Header: http.Header{"Retry-After": []string{"3600"}}}
if got := retryAfter429(resp); got != 10*time.Second {
t.Fatalf("expected 10s cap, got %v", got)
}
resp = &http.Response{Header: http.Header{}}
if got := retryAfter429(resp); got != time.Second {
t.Fatalf("expected 1s default, got %v", got)
}
}

View File

@@ -430,6 +430,27 @@ func (s *Service) ingestSessionDatasets(sess models.Session) (Summary, error) {
coverage = make(map[string]store.CoverageEntry) coverage = make(map[string]store.CoverageEntry)
} }
if sess.IsCancelled {
s.opts.Progress.Step("session %d (%s) is cancelled; skipping Race Hub datasets", sessionKey, sess.SessionName)
if !s.opts.DryRun {
for _, dataset := range []string{
"drivers",
"session_result",
"starting_grid",
"stints",
"pit_stops",
"positions",
"race_control",
"weather",
"laps",
} {
_ = s.store.UpsertCoverage(sessionKey, dataset, "skipped", 0, "session cancelled")
}
}
summary.Status = statusForCancelled(s.opts.DryRun)
return summary, nil
}
// 1. Ingest drivers // 1. Ingest drivers
if cov, ok := coverage["drivers"]; ok && cov.Status == "complete" && !s.opts.Force { if cov, ok := coverage["drivers"]; ok && cov.Status == "complete" && !s.opts.Force {
s.opts.Progress.Step("drivers already complete for session %d, skipping", sessionKey) s.opts.Progress.Step("drivers already complete for session %d, skipping", sessionKey)
@@ -919,6 +940,13 @@ func statusForErrors(dryRun bool, errs []string) string {
return "completed" return "completed"
} }
func statusForCancelled(dryRun bool) string {
if dryRun {
return "dry_run"
}
return "cancelled"
}
type fetchFunc[T any] func() (FetchResult, T, error) type fetchFunc[T any] func() (FetchResult, T, error)
func fetchWithRetry[T any](s *Service, fn fetchFunc[T]) (FetchResult, T, error) { func fetchWithRetry[T any](s *Service, fn fetchFunc[T]) (FetchResult, T, error) {

View File

@@ -325,6 +325,7 @@ func meetingToStore(m models.Meeting) store.Meeting {
DateStart: m.DateStart, DateStart: m.DateStart,
DateEnd: m.DateEnd, DateEnd: m.DateEnd,
Year: m.Year, Year: m.Year,
IsCancelled: m.IsCancelled,
} }
} }
@@ -338,6 +339,7 @@ func sessionToStore(s models.Session) store.Session {
DateStart: s.DateStart, DateStart: s.DateStart,
DateEnd: s.DateEnd, DateEnd: s.DateEnd,
GMTOffset: s.GMTOffset, GMTOffset: s.GMTOffset,
IsCancelled: s.IsCancelled,
} }
} }

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"
@@ -77,6 +81,41 @@ func TestProcessMessageIncremental(t *testing.T) {
} }
} }
func TestProcessCoreMessageCompletionAndFeed(t *testing.T) {
state := live.NewState()
msg := []byte(`{"type":3,"invocationId":"1","result":{
"ExtrapolatedClock":{"Remaining":"00:04:23","Utc":"2026-06-06T14:42:36.0491737Z","Extrapolating":true},
"TimingData":{"Lines":{"12":{"Position":"1","RacingNumber":"12","Sectors":[{"Value":"22.430"},{"Value":"40.119"},{"Value":""}],"Speeds":{"ST":{"Value":"254"}},"BestLapTime":{"Value":"1:12.704","Lap":15}}}},
"DriverList":{"12":{"RacingNumber":"12","Tla":"ANT","TeamName":"Mercedes","TeamColour":"00D7B6"}}
}}` + "\x1e")
if !state.ProcessCoreMessage(msg) {
t.Fatal("expected SignalR Core completion to produce updates")
}
snap := state.Snapshot()
if snap.Clock != "00:04:23" || !snap.ClockExtrapolating {
t.Errorf("clock = %q extrapolating=%v", snap.Clock, snap.ClockExtrapolating)
}
if snap.Drivers["12"].Position != 1 || snap.Drivers["12"].BestLapTime != "1:12.704" {
t.Errorf("driver = %+v", snap.Drivers["12"])
}
if snap.Drivers["12"].Sectors[1].Value != "40.119" || snap.Drivers["12"].SpeedTrap != "254" {
t.Errorf("driver sectors/speed = %+v", snap.Drivers["12"])
}
if snap.DriverInfo["12"].Tla != "ANT" {
t.Errorf("driver info = %+v", snap.DriverInfo["12"])
}
feed := []byte(`{"type":1,"target":"feed","arguments":["TimingData",{"Lines":{"12":{"LastLapTime":{"Value":"1:13.000","PersonalFastest":true}}}},"2026-06-06T14:42:37Z"]}` + "\x1e")
if !state.ProcessCoreMessage(feed) {
t.Fatal("expected SignalR Core feed frame to produce updates")
}
if state.Snapshot().Drivers["12"].LastLapTime != "1:13.000" {
t.Errorf("last lap = %q", state.Snapshot().Drivers["12"].LastLapTime)
}
}
func TestProcessTopicTimingData(t *testing.T) { func TestProcessTopicTimingData(t *testing.T) {
state := live.NewState() state := live.NewState()
data := json.RawMessage(`{ data := json.RawMessage(`{
@@ -123,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"}}`)
@@ -189,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(`{
@@ -321,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

@@ -1,11 +1,14 @@
package live package live
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
"os"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
@@ -14,6 +17,135 @@ import (
// to timing topics, and sends defensive snapshots on dataChan until the // to timing topics, and sends defensive snapshots on dataChan until the
// connection closes. // connection closes.
func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error { func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error {
if err := connectToF1SignalRCore(dataChan); err == nil {
return nil
} else {
log.Printf("f1 signalrcore feed unavailable, trying legacy signalr: %v", err)
}
return connectToF1LegacySignalR(dataChan)
}
func connectToF1SignalRCore(dataChan chan LiveStreamData) error {
req, err := http.NewRequest("POST", "https://livetiming.formula1.com/signalrcore/negotiate?negotiateVersion=1", nil)
if err != nil {
return err
}
req.Header.Set("Origin", "https://www.formula1.com")
req.Header.Set("User-Agent", "Mozilla/5.0")
req.Header.Set("Content-Length", "0")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("f1 signalrcore negotiate returned %s", resp.Status)
}
var neg struct {
ConnectionToken string `json:"connectionToken"`
}
if err := json.Unmarshal(body, &neg); err != nil {
return err
}
if neg.ConnectionToken == "" {
return fmt.Errorf("f1 signalrcore negotiate returned an empty connection token")
}
wsURL := "wss://livetiming.formula1.com/signalrcore?id=" + url.QueryEscape(neg.ConnectionToken)
header := http.Header{}
header.Set("Origin", "https://www.formula1.com")
header.Set("User-Agent", "Mozilla/5.0")
for _, cookie := range resp.Cookies() {
header.Add("Cookie", cookie.String())
}
c, _, err := websocket.DefaultDialer.Dial(wsURL, header)
if err != nil {
return err
}
writeCoreFrame := func(payload any) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
body = append(body, signalRRecordSeparator)
return c.WriteMessage(websocket.TextMessage, body)
}
if err := writeCoreFrame(map[string]any{"protocol": "json", "version": 1}); err != nil {
c.Close()
return err
}
_, handshake, err := c.ReadMessage()
if err != nil {
c.Close()
return err
}
if !bytes.Contains(handshake, []byte("{}")) {
c.Close()
return fmt.Errorf("f1 signalrcore handshake returned %q", string(handshake))
}
topics := []string{
"Heartbeat",
"TimingData",
"Position.z",
"CarData.z",
"DriverList",
"LapCount",
"ExtrapolatedClock",
"TrackStatus",
"RaceControlMessages",
"WeatherData",
"SessionInfo",
"TeamRadio",
"CurrentTyres",
"TimingAppData",
"TimingStats",
"SessionStatus",
"TopThree",
}
if err := writeCoreFrame(map[string]any{
"type": 1,
"target": "subscribe",
"arguments": []any{topics},
"invocationId": "1",
}); err != nil {
c.Close()
return err
}
go func() {
defer c.Close()
state := NewState()
for {
_, message, err := c.ReadMessage()
if err != nil {
log.Println("SignalR Core read error:", err)
return
}
if state.ProcessCoreMessage(message) {
select {
case dataChan <- state.Snapshot():
default:
}
}
}
}()
return nil
}
func connectToF1LegacySignalR(dataChan chan LiveStreamData) error {
hubName := `[{"name":"Streaming"}]` hubName := `[{"name":"Streaming"}]`
negotiateURL := fmt.Sprintf("https://livetiming.formula1.com/signalr/negotiate?clientProtocol=1.5&connectionData=%s", url.QueryEscape(hubName)) negotiateURL := fmt.Sprintf("https://livetiming.formula1.com/signalr/negotiate?clientProtocol=1.5&connectionData=%s", url.QueryEscape(hubName))
@@ -21,20 +153,30 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error {
if err != nil { if err != nil {
return err return err
} }
req.Header.Set("User-Agent", "BestHTTP")
if token := f1LiveBearerToken(); token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return err return err
} }
cookies := resp.Cookies()
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("f1 signalr negotiate returned %s (auth=%s)", resp.Status, authState())
}
cookies := resp.Cookies()
var neg struct { var neg struct {
ConnectionToken string `json:"ConnectionToken"` ConnectionToken string `json:"ConnectionToken"`
} }
if err := json.NewDecoder(resp.Body).Decode(&neg); err != nil { if err := json.NewDecoder(resp.Body).Decode(&neg); err != nil {
return err return err
} }
if neg.ConnectionToken == "" {
return fmt.Errorf("f1 signalr negotiate returned an empty connection token")
}
wsURL := fmt.Sprintf("wss://livetiming.formula1.com/signalr/connect?clientProtocol=1.5&transport=webSockets&connectionToken=%s&connectionData=%s", wsURL := fmt.Sprintf("wss://livetiming.formula1.com/signalr/connect?clientProtocol=1.5&transport=webSockets&connectionToken=%s&connectionData=%s",
url.QueryEscape(neg.ConnectionToken), url.QueryEscape(neg.ConnectionToken),
@@ -46,13 +188,16 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error {
header.Add("Cookie", cookie.String()) header.Add("Cookie", cookie.String())
} }
header.Add("User-Agent", "BestHTTP") header.Add("User-Agent", "BestHTTP")
if token := f1LiveBearerToken(); token != "" {
header.Add("Authorization", "Bearer "+token)
}
c, _, err := websocket.DefaultDialer.Dial(wsURL, header) c, _, err := websocket.DefaultDialer.Dial(wsURL, header)
if err != nil { if err != nil {
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
@@ -80,3 +225,17 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error {
return nil return nil
} }
func f1LiveBearerToken() string {
if token := os.Getenv("BOXBOX_F1_LIVE_BEARER_TOKEN"); token != "" {
return token
}
return os.Getenv("F1_LIVE_BEARER_TOKEN")
}
func authState() string {
if f1LiveBearerToken() != "" {
return "bearer-configured"
}
return "no-bearer-token"
}

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,24 +19,34 @@ 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 maxTeamRadioCaptures = 20
// NewState returns an empty live timing accumulator. // NewState returns an empty live timing accumulator.
func NewState() *State { func NewState() *State {
return &State{ return &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),
} }
} }
@@ -47,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))
@@ -60,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,
@@ -70,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
@@ -106,10 +141,65 @@ func (s *State) ProcessMessage(message []byte) bool {
return updated return updated
} }
// ProcessCoreMessage parses one or more SignalR Core JSON frames and applies
// completion snapshots and feed deltas from the current official F1 live timing hub.
func (s *State) ProcessCoreMessage(message []byte) bool {
s.clearTransientFlags()
updated := false
for _, frame := range splitSignalRFrames(message) {
var envelope struct {
Type int `json:"type"`
Target string `json:"target"`
Args []json.RawMessage `json:"arguments"`
Result json.RawMessage `json:"result"`
}
if err := json.Unmarshal(frame, &envelope); err != nil {
continue
}
switch envelope.Type {
case 1:
if envelope.Target != "feed" || len(envelope.Args) < 2 {
continue
}
var topic string
if err := json.Unmarshal(envelope.Args[0], &topic); err != nil {
continue
}
if s.ProcessTopic(topic, envelope.Args[1]) {
updated = true
}
case 3:
if len(envelope.Result) == 0 || string(envelope.Result) == "null" {
continue
}
var result map[string]json.RawMessage
if err := json.Unmarshal(envelope.Result, &result); err != nil {
continue
}
for topic, data := range result {
if s.ProcessTopic(topic, data) {
updated = true
}
}
}
}
return updated
}
// 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"`
@@ -123,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 {
@@ -181,10 +275,10 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
} }
case "RaceControlMessages": case "RaceControlMessages":
var rcm struct { var rcm struct {
Messages map[string]json.RawMessage `json:"Messages"` Messages json.RawMessage `json:"Messages"`
} }
if json.Unmarshal(data, &rcm) == nil { if json.Unmarshal(data, &rcm) == nil {
for _, msgRaw := range rcm.Messages { for _, msgRaw := range indexedRawValues(rcm.Messages) {
var msg struct { var msg struct {
Utc string `json:"Utc"` Utc string `json:"Utc"`
Category string `json:"Category"` Category string `json:"Category"`
@@ -192,7 +286,7 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
Message string `json:"Message"` Message string `json:"Message"`
Lap int `json:"Lap"` Lap int `json:"Lap"`
} }
if json.Unmarshal(msgRaw, &msg) == nil && msg.Message != "" { if json.Unmarshal(msgRaw.Raw, &msg) == nil && msg.Message != "" {
t := "" t := ""
if len(msg.Utc) >= 19 { if len(msg.Utc) >= 19 {
t = msg.Utc[11:16] t = msg.Utc[11:16]
@@ -241,23 +335,35 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
case "SessionInfo": case "SessionInfo":
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 {
@@ -285,17 +391,17 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
if json.Unmarshal(data, &tad) == nil { if json.Unmarshal(data, &tad) == nil {
for num, lineRaw := range tad.Lines { for num, lineRaw := range tad.Lines {
var line struct { var line struct {
Stints map[string]json.RawMessage `json:"Stints"` Stints json.RawMessage `json:"Stints"`
} }
if json.Unmarshal(lineRaw, &line) == nil && line.Stints != nil { if json.Unmarshal(lineRaw, &line) == nil && line.Stints != nil {
var driverStints []LiveStintData var driverStints []LiveStintData
for _, sRaw := range line.Stints { for _, sRaw := range indexedRawValues(line.Stints) {
var st struct { var st struct {
Compound string `json:"Compound"` Compound string `json:"Compound"`
New string `json:"New"` New string `json:"New"`
TotalLaps int `json:"TotalLaps"` TotalLaps int `json:"TotalLaps"`
} }
if json.Unmarshal(sRaw, &st) == nil && st.Compound != "" { if json.Unmarshal(sRaw.Raw, &st) == nil && st.Compound != "" {
driverStints = append(driverStints, LiveStintData{ driverStints = append(driverStints, LiveStintData{
Compound: st.Compound, Compound: st.Compound,
New: st.New == "true" || st.New == "True", New: st.New == "true" || st.New == "True",
@@ -339,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 {
@@ -417,16 +747,15 @@ func updateDriver(drivers map[string]LiveDriverData, num string, line F1TimingLi
} }
} }
for idx, sRaw := range line.Sectors { for _, sector := range indexedRawValues(line.Sectors) {
i := 0 i := sector.Index
fmt.Sscanf(idx, "%d", &i)
if i >= 0 && i < 3 { if i >= 0 && i < 3 {
var sec struct { var sec struct {
Value string `json:"Value"` Value string `json:"Value"`
PersonalFastest bool `json:"PersonalFastest"` PersonalFastest bool `json:"PersonalFastest"`
OverallFastest bool `json:"OverallFastest"` OverallFastest bool `json:"OverallFastest"`
} }
if json.Unmarshal(sRaw, &sec) == nil { if json.Unmarshal(sector.Raw, &sec) == nil {
if sec.Value == "" { if sec.Value == "" {
d.Sectors[i] = LiveSectorData{} d.Sectors[i] = LiveSectorData{}
} else { } else {
@@ -493,3 +822,57 @@ func toInt(v interface{}) (int, bool) {
} }
return 0, false return 0, false
} }
type indexedRaw struct {
Index int
Raw json.RawMessage
}
func splitSignalRFrames(message []byte) []json.RawMessage {
parts := []json.RawMessage{}
start := 0
for i, b := range message {
if b != signalRRecordSeparator {
continue
}
if i > start {
parts = append(parts, json.RawMessage(message[start:i]))
}
start = i + 1
}
if start < len(message) {
parts = append(parts, json.RawMessage(message[start:]))
}
return parts
}
func indexedRawValues(raw json.RawMessage) []indexedRaw {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var arr []json.RawMessage
if err := json.Unmarshal(raw, &arr); err == nil {
values := make([]indexedRaw, 0, len(arr))
for i, v := range arr {
values = append(values, indexedRaw{Index: i, Raw: v})
}
return values
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err == nil {
values := make([]indexedRaw, 0, len(obj))
for k, v := range obj {
i := 0
fmt.Sscanf(k, "%d", &i)
values = append(values, indexedRaw{Index: i, Raw: v})
}
sort.Slice(values, func(i, j int) bool {
return values[i].Index < values[j].Index
})
return values
}
return nil
}

View File

@@ -38,7 +38,7 @@ type F1TimingLine struct {
KnockedOut interface{} `json:"KnockedOut"` KnockedOut interface{} `json:"KnockedOut"`
Cutoff interface{} `json:"Cutoff"` Cutoff interface{} `json:"Cutoff"`
NumberOfLaps interface{} `json:"NumberOfLaps"` NumberOfLaps interface{} `json:"NumberOfLaps"`
Sectors map[string]json.RawMessage `json:"Sectors"` Sectors json.RawMessage `json:"Sectors"`
Speeds map[string]json.RawMessage `json:"Speeds"` Speeds map[string]json.RawMessage `json:"Speeds"`
} }
@@ -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

@@ -24,6 +24,8 @@ type Meeting struct {
DateStart string `json:"date_start"` DateStart string `json:"date_start"`
DateEnd string `json:"date_end"` DateEnd string `json:"date_end"`
Year int `json:"year"` Year int `json:"year"`
IsCancelled bool `json:"is_cancelled"`
} }
type Session struct { type Session struct {
@@ -38,6 +40,8 @@ type Session struct {
DateStart string `json:"date_start"` DateStart string `json:"date_start"`
DateEnd string `json:"date_end"` DateEnd string `json:"date_end"`
GMTOffset string `json:"gmt_offset"` GMTOffset string `json:"gmt_offset"`
IsCancelled bool `json:"is_cancelled"`
} }
// TyreCompound represents the type of tyre compound used. // TyreCompound represents the type of tyre compound used.
@@ -169,7 +173,7 @@ type Pit struct {
LaneDuration float64 `json:"lane_duration"` // pit lane time (entry to exit) LaneDuration float64 `json:"lane_duration"` // pit lane time (entry to exit)
LapNumber int `json:"lap_number"` LapNumber int `json:"lap_number"`
MeetingKey int `json:"meeting_key"` MeetingKey int `json:"meeting_key"`
PitDuration float64 `json:"pit_duration"` // deprecated, use StopDuration PitDuration float64 `json:"pit_duration"` // deprecated, use StopDuration
SessionKey int `json:"session_key"` SessionKey int `json:"session_key"`
StopDuration float64 `json:"stop_duration"` // stationary time only StopDuration float64 `json:"stop_duration"` // stationary time only
} }
@@ -186,7 +190,7 @@ type Interval struct {
Date string `json:"date"` Date string `json:"date"`
DriverNumber int `json:"driver_number"` DriverNumber int `json:"driver_number"`
GapToLeader *float64 `json:"gap_to_leader"` // null when leading GapToLeader *float64 `json:"gap_to_leader"` // null when leading
Interval *float64 `json:"interval"` // null when leading Interval *float64 `json:"interval"` // null when leading
MeetingKey int `json:"meeting_key"` MeetingKey int `json:"meeting_key"`
SessionKey int `json:"session_key"` SessionKey int `json:"session_key"`
} }
@@ -228,7 +232,7 @@ type Weather struct {
} }
type CarData struct { type CarData struct {
Brake int `json:"brake"` // 0-100 Brake int `json:"brake"` // 0-100
Date string `json:"date"` Date string `json:"date"`
DriverNumber int `json:"driver_number"` DriverNumber int `json:"driver_number"`
DRS int `json:"drs"` // 0=off, 8=eligible, 10=open DRS int `json:"drs"` // 0=off, 8=eligible, 10=open

View File

@@ -0,0 +1,177 @@
package query
import (
"database/sql"
"errors"
"sort"
"strings"
"github.com/AmanTahiliani/box-box/internal/models"
)
// ChampionshipRace is the local data needed by the web championship hub for one GP.
type ChampionshipRace struct {
Meeting models.Meeting
RaceSessionKey int
Results []models.SessionResult
Grid []models.StartingGrid
}
// ChampionshipInputs are local-first inputs for the championship hub aggregation.
type ChampionshipInputs struct {
Races []ChampionshipRace
Champ []models.ChampionshipDriver
Teams []models.ChampionshipTeam
DriverMap map[int]models.Driver
}
// GetChampionshipInputs derives championship hub inputs from ingested season data.
func (s *Service) GetChampionshipInputs(year int) (ChampionshipInputs, error) {
meetings, err := s.ListMeetingsByYear(year)
if err != nil {
return ChampionshipInputs{}, err
}
inputs := ChampionshipInputs{
Races: []ChampionshipRace{},
Champ: []models.ChampionshipDriver{},
Teams: []models.ChampionshipTeam{},
DriverMap: map[int]models.Driver{},
}
pointsByDriver := map[int]float64{}
pointsByTeam := map[string]float64{}
latestSessionKey := 0
latestMeetingKey := 0
for _, meeting := range meetings {
sessions, err := s.ListSessionsByMeeting(int(meeting.MeetingKey))
if err != nil {
return ChampionshipInputs{}, err
}
raceKey := 0
for _, sess := range sessions {
if strings.EqualFold(sess.SessionName, "Race") {
raceKey = sess.SessionKey
break
}
}
if raceKey == 0 {
continue
}
results, err := s.store.ListSessionResults(raceKey)
if err != nil {
return ChampionshipInputs{}, err
}
grid, err := s.store.ListStartingGrid(raceKey)
if err != nil {
return ChampionshipInputs{}, err
}
race := ChampionshipRace{
Meeting: meeting,
RaceSessionKey: raceKey,
Results: make([]models.SessionResult, 0, len(results)),
Grid: make([]models.StartingGrid, 0, len(grid)),
}
for _, result := range results {
race.Results = append(race.Results, resultToModel(result))
}
for _, entry := range grid {
race.Grid = append(race.Grid, gridToModel(entry))
}
inputs.Races = append(inputs.Races, race)
if len(results) == 0 {
continue
}
latestSessionKey = raceKey
latestMeetingKey = int(meeting.MeetingKey)
drivers, err := s.driversForSession(raceKey)
if err != nil {
return ChampionshipInputs{}, err
}
for _, d := range drivers {
inputs.DriverMap[d.DriverNumber] = d
}
for _, result := range results {
pointsByDriver[result.DriverNumber] += result.Points
team := inputs.DriverMap[result.DriverNumber].TeamName
if team != "" {
pointsByTeam[team] += result.Points
}
}
}
inputs.Champ = derivedDriverStandings(pointsByDriver, latestMeetingKey, latestSessionKey)
inputs.Teams = derivedTeamStandings(pointsByTeam, latestMeetingKey, latestSessionKey)
return inputs, nil
}
func (s *Service) driversForSession(sessionKey int) ([]models.Driver, error) {
sess, err := s.store.GetSession(sessionKey)
if err != nil {
return nil, err
}
links, err := s.store.ListSessionDrivers(sessionKey)
if err != nil {
return nil, err
}
drivers := make([]models.Driver, 0, len(links))
for _, link := range links {
d, err := s.store.GetDriver(link.DriverNumber)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
drivers = append(drivers, driverToModel(sessionKey, sess.MeetingKey, link, d))
}
return drivers, nil
}
func derivedDriverStandings(points map[int]float64, meetingKey, sessionKey int) []models.ChampionshipDriver {
drivers := make([]models.ChampionshipDriver, 0, len(points))
for num, pts := range points {
drivers = append(drivers, models.ChampionshipDriver{
DriverNumber: num,
MeetingKey: meetingKey,
PointsCurrent: pts,
SessionKey: sessionKey,
})
}
sort.Slice(drivers, func(i, j int) bool {
if drivers[i].PointsCurrent == drivers[j].PointsCurrent {
return drivers[i].DriverNumber < drivers[j].DriverNumber
}
return drivers[i].PointsCurrent > drivers[j].PointsCurrent
})
for i := range drivers {
drivers[i].PositionCurrent = i + 1
}
return drivers
}
func derivedTeamStandings(points map[string]float64, meetingKey, sessionKey int) []models.ChampionshipTeam {
teams := make([]models.ChampionshipTeam, 0, len(points))
for name, pts := range points {
teams = append(teams, models.ChampionshipTeam{
TeamName: name,
MeetingKey: meetingKey,
PointsCurrent: pts,
SessionKey: sessionKey,
})
}
sort.Slice(teams, func(i, j int) bool {
if teams[i].PointsCurrent == teams[j].PointsCurrent {
return teams[i].TeamName < teams[j].TeamName
}
return teams[i].PointsCurrent > teams[j].PointsCurrent
})
for i := range teams {
teams[i].PositionCurrent = i + 1
}
return teams
}

View File

@@ -23,6 +23,8 @@ func meetingToModel(m store.Meeting) models.Meeting {
DateStart: m.DateStart, DateStart: m.DateStart,
DateEnd: m.DateEnd, DateEnd: m.DateEnd,
Year: m.Year, Year: m.Year,
IsCancelled: m.IsCancelled,
} }
} }
@@ -36,6 +38,8 @@ func sessionToModel(s store.Session) models.Session {
DateStart: s.DateStart, DateStart: s.DateStart,
DateEnd: s.DateEnd, DateEnd: s.DateEnd,
GMTOffset: s.GMTOffset, GMTOffset: s.GMTOffset,
IsCancelled: s.IsCancelled,
} }
} }

View File

@@ -55,3 +55,23 @@ func datasetsFromCounts(meetingAvailable, sessionAvailable bool, counts store.Se
} }
return ds return ds
} }
func cancelledDatasets() map[string]DatasetInfo {
ds := emptyDatasetMap()
ds["meeting"] = availableLocal(1)
ds["session"] = availableLocal(1)
for _, key := range []string{
"drivers",
"results",
"starting_grid",
"stints",
"pit_stops",
"positions",
"race_control",
"weather",
"laps",
} {
ds[key] = skippedNA()
}
return ds
}

View File

@@ -10,9 +10,10 @@ const (
DataSourceNone = "none" DataSourceNone = "none"
DataSourceOpenF1 = "openf1" DataSourceOpenF1 = "openf1"
ResponseSourceLocal = "local" ResponseSourceLocal = "local"
ResponseSourceNone = "none" ResponseSourceNone = "none"
ResponseSourcePartial = "partial" ResponseSourcePartial = "partial"
ResponseSourceCancelled = "cancelled"
) )
// DatasetInfo describes availability of a single dataset. // DatasetInfo describes availability of a single dataset.

View File

@@ -5,6 +5,7 @@ import (
"errors" "errors"
"github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/store"
) )
// ErrMeetingNotFound is returned when a meeting is not in the local store. // ErrMeetingNotFound is returned when a meeting is not in the local store.
@@ -65,21 +66,35 @@ func (s *Service) GetWeekend(meetingKey int) (Weekend, error) {
} }
datasets := datasetsFromCounts(true, true, counts) datasets := datasetsFromCounts(true, true, counts)
sessionModel := sessionToModel(sess) sessionModel := sessionToModel(sess)
if sess.IsCancelled {
datasets = cancelledDatasets()
}
if datasets["starting_grid"].Status == DatasetStatusMissing && !isGridExpected(sessionModel.SessionType, sessionModel.SessionName) { if datasets["starting_grid"].Status == DatasetStatusMissing && !isGridExpected(sessionModel.SessionType, sessionModel.SessionName) {
datasets["starting_grid"] = skippedNA() datasets["starting_grid"] = skippedNA()
} }
out.Sessions = append(out.Sessions, WeekendSession{ out.Sessions = append(out.Sessions, WeekendSession{
Session: sessionModel, Session: sessionModel,
Source: responseSource(datasets), Source: sessionSource(sess, datasets),
Datasets: datasets, Datasets: datasets,
}) })
} }
out.Source = weekendSource(out.Sessions) if meeting.IsCancelled {
out.Source = ResponseSourceCancelled
} else {
out.Source = weekendSource(out.Sessions)
}
out.DefaultSessionKey = pickDefaultSession(out.Sessions) out.DefaultSessionKey = pickDefaultSession(out.Sessions)
return out, nil return out, nil
} }
func sessionSource(sess store.Session, datasets map[string]DatasetInfo) string {
if sess.IsCancelled {
return ResponseSourceCancelled
}
return responseSource(datasets)
}
func weekendSource(sessions []WeekendSession) string { func weekendSource(sessions []WeekendSession) string {
if len(sessions) == 0 { if len(sessions) == 0 {
return ResponseSourceNone return ResponseSourceNone

View File

@@ -166,6 +166,68 @@ func TestGetRaceHubCompleteData(t *testing.T) {
} }
} }
func TestGetChampionshipInputsDerivesLocalStandings(t *testing.T) {
svc := openTestService(t)
seedRaceHubData(t, svc.store)
if err := svc.store.UpsertDriver(store.Driver{
DriverNumber: 44,
FullName: "Lewis Hamilton",
NameAcronym: "HAM",
TeamName: "Ferrari",
TeamColour: "E8002D",
}); err != nil {
t.Fatalf("UpsertDriver() error = %v", err)
}
if err := svc.store.UpsertSessionDriver(store.SessionDriver{
SessionKey: 9472,
DriverNumber: 44,
MeetingKey: 1229,
TeamName: "Ferrari",
TeamColour: "E8002D",
}); err != nil {
t.Fatalf("UpsertSessionDriver() error = %v", err)
}
if err := svc.store.UpsertSessionResult(store.SessionResult{
SessionKey: 9472,
DriverNumber: 1,
MeetingKey: 1229,
Position: 1,
Points: 25,
}); err != nil {
t.Fatalf("UpsertSessionResult() error = %v", err)
}
if err := svc.store.UpsertSessionResult(store.SessionResult{
SessionKey: 9472,
DriverNumber: 44,
MeetingKey: 1229,
Position: 2,
Points: 18,
}); err != nil {
t.Fatalf("UpsertSessionResult() error = %v", err)
}
inputs, err := svc.GetChampionshipInputs(2025)
if err != nil {
t.Fatalf("GetChampionshipInputs() error = %v", err)
}
if len(inputs.Races) != 1 || inputs.Races[0].RaceSessionKey != 9472 {
t.Fatalf("Races = %+v, want Monaco race", inputs.Races)
}
if len(inputs.Champ) != 2 {
t.Fatalf("Champ len = %d, want 2", len(inputs.Champ))
}
if inputs.Champ[0].DriverNumber != 1 || inputs.Champ[0].PointsCurrent != 25 || inputs.Champ[0].PositionCurrent != 1 {
t.Fatalf("leader = %+v, want VER 25 P1", inputs.Champ[0])
}
if got := inputs.DriverMap[44]; got.NameAcronym != "HAM" || got.TeamName != "Ferrari" {
t.Fatalf("DriverMap[44] = %+v, want HAM Ferrari", got)
}
if len(inputs.Teams) != 2 || inputs.Teams[0].TeamName != "Red Bull Racing" || inputs.Teams[0].PointsCurrent != 25 {
t.Fatalf("Teams = %+v, want Red Bull Racing leading with 25", inputs.Teams)
}
}
func TestListMeetingsByYear(t *testing.T) { func TestListMeetingsByYear(t *testing.T) {
svc := openTestService(t) svc := openTestService(t)
seedRaceHubData(t, svc.store) seedRaceHubData(t, svc.store)

View File

@@ -106,6 +106,12 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
hub.Datasets["meeting"] = availableLocal(1) hub.Datasets["meeting"] = availableLocal(1)
} }
if sess.IsCancelled || (hub.Meeting != nil && hub.Meeting.IsCancelled) {
hub.Source = ResponseSourceCancelled
hub.Datasets = cancelledDatasets()
return hub, nil
}
driverLinks, err := s.store.ListSessionDrivers(sessionKey) driverLinks, err := s.store.ListSessionDrivers(sessionKey)
if err != nil { if err != nil {
return RaceHub{}, err return RaceHub{}, err

View File

@@ -12,8 +12,8 @@ func TestCoverageCRUD(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("SchemaVersion() error = %v", err) t.Fatalf("SchemaVersion() error = %v", err)
} }
if version != 6 { if version != 7 {
t.Fatalf("SchemaVersion() = %d, want 6", version) t.Fatalf("SchemaVersion() = %d, want 7", version)
} }
// Verify session_coverage table exists // Verify session_coverage table exists

View File

@@ -16,8 +16,8 @@ func (s *Store) UpsertMeeting(m Meeting) error {
INSERT INTO meetings ( INSERT INTO meetings (
meeting_key, meeting_name, meeting_official_name, location, meeting_key, meeting_name, meeting_official_name, location,
country_code, country_name, circuit_key, circuit_short_name, country_code, country_name, circuit_key, circuit_short_name,
gmt_offset, date_start, date_end, year, updated_at gmt_offset, date_start, date_end, year, is_cancelled, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(meeting_key) DO UPDATE SET ON CONFLICT(meeting_key) DO UPDATE SET
meeting_name = excluded.meeting_name, meeting_name = excluded.meeting_name,
meeting_official_name = excluded.meeting_official_name, meeting_official_name = excluded.meeting_official_name,
@@ -30,6 +30,7 @@ func (s *Store) UpsertMeeting(m Meeting) error {
date_start = excluded.date_start, date_start = excluded.date_start,
date_end = excluded.date_end, date_end = excluded.date_end,
year = excluded.year, year = excluded.year,
is_cancelled = excluded.is_cancelled,
updated_at = excluded.updated_at updated_at = excluded.updated_at
`, `,
m.MeetingKey, m.MeetingKey,
@@ -44,6 +45,7 @@ func (s *Store) UpsertMeeting(m Meeting) error {
nullString(m.DateStart), nullString(m.DateStart),
nullString(m.DateEnd), nullString(m.DateEnd),
m.Year, m.Year,
boolInt(m.IsCancelled),
m.UpdatedAt.Unix(), m.UpdatedAt.Unix(),
) )
if err != nil { if err != nil {
@@ -56,6 +58,7 @@ func (s *Store) UpsertMeeting(m Meeting) error {
func (s *Store) GetMeeting(meetingKey int) (Meeting, error) { func (s *Store) GetMeeting(meetingKey int) (Meeting, error) {
var m Meeting var m Meeting
var updatedAt int64 var updatedAt int64
var isCancelled int
var officialName, location, countryCode, countryName sql.NullString var officialName, location, countryCode, countryName sql.NullString
var circuitKey sql.NullInt64 var circuitKey sql.NullInt64
var circuitShortName, gmtOffset, dateStart, dateEnd sql.NullString var circuitShortName, gmtOffset, dateStart, dateEnd sql.NullString
@@ -63,7 +66,7 @@ func (s *Store) GetMeeting(meetingKey int) (Meeting, error) {
err := s.db.QueryRow(` err := s.db.QueryRow(`
SELECT meeting_key, meeting_name, meeting_official_name, location, SELECT meeting_key, meeting_name, meeting_official_name, location,
country_code, country_name, circuit_key, circuit_short_name, country_code, country_name, circuit_key, circuit_short_name,
gmt_offset, date_start, date_end, year, updated_at gmt_offset, date_start, date_end, year, is_cancelled, updated_at
FROM meetings FROM meetings
WHERE meeting_key = ? WHERE meeting_key = ?
`, meetingKey).Scan( `, meetingKey).Scan(
@@ -79,6 +82,7 @@ func (s *Store) GetMeeting(meetingKey int) (Meeting, error) {
&dateStart, &dateStart,
&dateEnd, &dateEnd,
&m.Year, &m.Year,
&isCancelled,
&updatedAt, &updatedAt,
) )
if err != nil { if err != nil {
@@ -96,6 +100,7 @@ func (s *Store) GetMeeting(meetingKey int) (Meeting, error) {
m.GMTOffset = gmtOffset.String m.GMTOffset = gmtOffset.String
m.DateStart = dateStart.String m.DateStart = dateStart.String
m.DateEnd = dateEnd.String m.DateEnd = dateEnd.String
m.IsCancelled = isCancelled != 0
m.UpdatedAt = time.Unix(updatedAt, 0) m.UpdatedAt = time.Unix(updatedAt, 0)
return m, nil return m, nil
} }
@@ -129,7 +134,7 @@ func (s *Store) ListMeetingsByYear(year int) ([]Meeting, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT meeting_key, meeting_name, meeting_official_name, location, SELECT meeting_key, meeting_name, meeting_official_name, location,
country_code, country_name, circuit_key, circuit_short_name, country_code, country_name, circuit_key, circuit_short_name,
gmt_offset, date_start, date_end, year, updated_at gmt_offset, date_start, date_end, year, is_cancelled, updated_at
FROM meetings FROM meetings
WHERE year = ? WHERE year = ?
ORDER BY date_start ASC, meeting_key ASC ORDER BY date_start ASC, meeting_key ASC
@@ -151,8 +156,8 @@ func (s *Store) UpsertSession(sess Session) error {
_, err := s.db.Exec(` _, err := s.db.Exec(`
INSERT INTO sessions ( INSERT INTO sessions (
session_key, meeting_key, session_name, session_type, session_key, meeting_key, session_name, session_type,
circuit_key, date_start, date_end, gmt_offset, updated_at circuit_key, date_start, date_end, gmt_offset, is_cancelled, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_key) DO UPDATE SET ON CONFLICT(session_key) DO UPDATE SET
meeting_key = excluded.meeting_key, meeting_key = excluded.meeting_key,
session_name = excluded.session_name, session_name = excluded.session_name,
@@ -161,6 +166,7 @@ func (s *Store) UpsertSession(sess Session) error {
date_start = excluded.date_start, date_start = excluded.date_start,
date_end = excluded.date_end, date_end = excluded.date_end,
gmt_offset = excluded.gmt_offset, gmt_offset = excluded.gmt_offset,
is_cancelled = excluded.is_cancelled,
updated_at = excluded.updated_at updated_at = excluded.updated_at
`, `,
sess.SessionKey, sess.SessionKey,
@@ -171,6 +177,7 @@ func (s *Store) UpsertSession(sess Session) error {
nullString(sess.DateStart), nullString(sess.DateStart),
nullString(sess.DateEnd), nullString(sess.DateEnd),
nullString(sess.GMTOffset), nullString(sess.GMTOffset),
boolInt(sess.IsCancelled),
sess.UpdatedAt.Unix(), sess.UpdatedAt.Unix(),
) )
if err != nil { if err != nil {
@@ -183,12 +190,13 @@ func (s *Store) UpsertSession(sess Session) error {
func (s *Store) GetSession(sessionKey int) (Session, error) { func (s *Store) GetSession(sessionKey int) (Session, error) {
var sess Session var sess Session
var updatedAt int64 var updatedAt int64
var isCancelled int
var circuitKey sql.NullInt64 var circuitKey sql.NullInt64
var dateStart, dateEnd, gmtOffset sql.NullString var dateStart, dateEnd, gmtOffset sql.NullString
err := s.db.QueryRow(` err := s.db.QueryRow(`
SELECT session_key, meeting_key, session_name, session_type, SELECT session_key, meeting_key, session_name, session_type,
circuit_key, date_start, date_end, gmt_offset, updated_at circuit_key, date_start, date_end, gmt_offset, is_cancelled, updated_at
FROM sessions FROM sessions
WHERE session_key = ? WHERE session_key = ?
`, sessionKey).Scan( `, sessionKey).Scan(
@@ -200,6 +208,7 @@ func (s *Store) GetSession(sessionKey int) (Session, error) {
&dateStart, &dateStart,
&dateEnd, &dateEnd,
&gmtOffset, &gmtOffset,
&isCancelled,
&updatedAt, &updatedAt,
) )
if err != nil { if err != nil {
@@ -212,6 +221,7 @@ func (s *Store) GetSession(sessionKey int) (Session, error) {
sess.DateStart = dateStart.String sess.DateStart = dateStart.String
sess.DateEnd = dateEnd.String sess.DateEnd = dateEnd.String
sess.GMTOffset = gmtOffset.String sess.GMTOffset = gmtOffset.String
sess.IsCancelled = isCancelled != 0
sess.UpdatedAt = time.Unix(updatedAt, 0) sess.UpdatedAt = time.Unix(updatedAt, 0)
return sess, nil return sess, nil
} }
@@ -220,7 +230,7 @@ func (s *Store) GetSession(sessionKey int) (Session, error) {
func (s *Store) ListSessionsByMeeting(meetingKey int) ([]Session, error) { func (s *Store) ListSessionsByMeeting(meetingKey int) ([]Session, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT session_key, meeting_key, session_name, session_type, SELECT session_key, meeting_key, session_name, session_type,
circuit_key, date_start, date_end, gmt_offset, updated_at circuit_key, date_start, date_end, gmt_offset, is_cancelled, updated_at
FROM sessions FROM sessions
WHERE meeting_key = ? WHERE meeting_key = ?
ORDER BY date_start ASC, session_key ASC ORDER BY date_start ASC, session_key ASC
@@ -238,6 +248,7 @@ func scanMeetings(rows *sql.Rows) ([]Meeting, error) {
for rows.Next() { for rows.Next() {
var m Meeting var m Meeting
var updatedAt int64 var updatedAt int64
var isCancelled int
var officialName, location, countryCode, countryName sql.NullString var officialName, location, countryCode, countryName sql.NullString
var circuitKey sql.NullInt64 var circuitKey sql.NullInt64
var circuitShortName, gmtOffset, dateStart, dateEnd sql.NullString var circuitShortName, gmtOffset, dateStart, dateEnd sql.NullString
@@ -255,6 +266,7 @@ func scanMeetings(rows *sql.Rows) ([]Meeting, error) {
&dateStart, &dateStart,
&dateEnd, &dateEnd,
&m.Year, &m.Year,
&isCancelled,
&updatedAt, &updatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
@@ -271,6 +283,7 @@ func scanMeetings(rows *sql.Rows) ([]Meeting, error) {
m.GMTOffset = gmtOffset.String m.GMTOffset = gmtOffset.String
m.DateStart = dateStart.String m.DateStart = dateStart.String
m.DateEnd = dateEnd.String m.DateEnd = dateEnd.String
m.IsCancelled = isCancelled != 0
m.UpdatedAt = time.Unix(updatedAt, 0) m.UpdatedAt = time.Unix(updatedAt, 0)
out = append(out, m) out = append(out, m)
} }
@@ -282,6 +295,7 @@ func scanSessions(rows *sql.Rows) ([]Session, error) {
for rows.Next() { for rows.Next() {
var sess Session var sess Session
var updatedAt int64 var updatedAt int64
var isCancelled int
var circuitKey sql.NullInt64 var circuitKey sql.NullInt64
var dateStart, dateEnd, gmtOffset sql.NullString var dateStart, dateEnd, gmtOffset sql.NullString
@@ -294,6 +308,7 @@ func scanSessions(rows *sql.Rows) ([]Session, error) {
&dateStart, &dateStart,
&dateEnd, &dateEnd,
&gmtOffset, &gmtOffset,
&isCancelled,
&updatedAt, &updatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
@@ -305,6 +320,7 @@ func scanSessions(rows *sql.Rows) ([]Session, error) {
sess.DateStart = dateStart.String sess.DateStart = dateStart.String
sess.DateEnd = dateEnd.String sess.DateEnd = dateEnd.String
sess.GMTOffset = gmtOffset.String sess.GMTOffset = gmtOffset.String
sess.IsCancelled = isCancelled != 0
sess.UpdatedAt = time.Unix(updatedAt, 0) sess.UpdatedAt = time.Unix(updatedAt, 0)
out = append(out, sess) out = append(out, sess)
} }

View File

@@ -0,0 +1,3 @@
ALTER TABLE meetings ADD COLUMN is_cancelled INTEGER NOT NULL DEFAULT 0;
ALTER TABLE sessions ADD COLUMN is_cancelled INTEGER NOT NULL DEFAULT 0;

View File

@@ -68,6 +68,7 @@ type Meeting struct {
DateStart string DateStart string
DateEnd string DateEnd string
Year int Year int
IsCancelled bool
UpdatedAt time.Time UpdatedAt time.Time
} }
@@ -81,6 +82,7 @@ type Session struct {
DateStart string DateStart string
DateEnd string DateEnd string
GMTOffset string GMTOffset string
IsCancelled bool
UpdatedAt time.Time UpdatedAt time.Time
} }

View File

@@ -28,8 +28,8 @@ func TestOpenAppliesMigrations(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("SchemaVersion() error = %v", err) t.Fatalf("SchemaVersion() error = %v", err)
} }
if version != 6 { if version != 7 {
t.Fatalf("SchemaVersion() = %d, want 6", version) t.Fatalf("SchemaVersion() = %d, want 7", version)
} }
tables := []string{ tables := []string{
@@ -105,6 +105,18 @@ func TestMigrationsAreIdempotent(t *testing.T) {
if count != 1 { if count != 1 {
t.Fatalf("schema_migrations v5 count = %d, want 1", count) t.Fatalf("schema_migrations v5 count = %d, want 1", count)
} }
if err := s.db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = 6`).Scan(&count); err != nil {
t.Fatalf("count schema_migrations v6: %v", err)
}
if count != 1 {
t.Fatalf("schema_migrations v6 count = %d, want 1", count)
}
if err := s.db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = 7`).Scan(&count); err != nil {
t.Fatalf("count schema_migrations v7: %v", err)
}
if count != 1 {
t.Fatalf("schema_migrations v7 count = %d, want 1", count)
}
} }
func TestRawPayloadInsertAndRead(t *testing.T) { func TestRawPayloadInsertAndRead(t *testing.T) {

View File

@@ -11,6 +11,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
readability "codeberg.org/readeck/go-readability/v2" readability "codeberg.org/readeck/go-readability/v2"
@@ -597,6 +598,446 @@ func (s *Server) handleChampionshipTeams(w http.ResponseWriter, r *http.Request)
writeJSON(w, teams) writeJSON(w, teams)
} }
// --- /api/v1/championship/hub ---
// Aggregated championship view: official points/positions enriched with derived
// stats (wins, podiums, poles, recent form, teammate head-to-head) and a
// per-round cumulative-points series, computed from season race results.
type champHubDriver struct {
DriverNumber int `json:"driver_number"`
NameAcronym string `json:"name_acronym"`
FullName string `json:"full_name"`
TeamName string `json:"team_name"`
TeamColour string `json:"team_colour"`
Points float64 `json:"points"`
Position int `json:"position"`
Wins int `json:"wins"`
Podiums int `json:"podiums"`
Poles int `json:"poles"`
Form []float64 `json:"form"` // last 5 races' points
Cumulative []float64 `json:"cumulative"` // running total per completed round
TeammateWins int `json:"teammate_wins"`
TeammateLosses int `json:"teammate_losses"`
}
type champHubTeam struct {
TeamName string `json:"team_name"`
TeamColour string `json:"team_colour"`
Points float64 `json:"points"`
Position int `json:"position"`
Wins int `json:"wins"`
}
type champHubResponse struct {
Season int `json:"season"`
Round int `json:"round"`
TotalRounds int `json:"total_rounds"`
RoundsLeft int `json:"rounds_left"`
LastRace string `json:"last_race"`
RoundLabels []string `json:"round_labels"`
Drivers []champHubDriver `json:"drivers"`
Teams []champHubTeam `json:"teams"`
}
// meetingRace bundles a GP meeting with its (already-fetched) race results and grid.
type meetingRace struct {
Meeting models.Meeting
RaceSessionKey int
Results []models.SessionResult
Grid []models.StartingGrid
}
// champHubWorkers bounds the concurrent per-meeting fetches for the hub.
const champHubWorkers = 5
// champHubCurrentTTL / champHubPastTTL control how long an aggregated hub
// response stays cached: short for the in-progress season, long for past
// seasons whose results are final.
const (
champHubCurrentTTL = 15 * time.Minute
champHubPastTTL = 24 * time.Hour
// champHubIncompleteTTL keeps a partially-fetched aggregate around just
// long enough to absorb page-load bursts while retrying soon after.
champHubIncompleteTTL = 2 * time.Minute
)
// champHubTTL returns the in-memory cache TTL for a season's hub response.
func champHubTTL(year int, now time.Time) time.Duration {
if year >= now.Year() {
return champHubCurrentTTL
}
return champHubPastTTL
}
type champHubEntry struct {
resp champHubResponse
expires time.Time
}
// champHubCache is an in-memory cache of aggregated hub responses keyed by
// year. The zero value is ready to use.
type champHubCache struct {
mu sync.Mutex
entries map[int]champHubEntry
}
func (c *champHubCache) get(year int, now time.Time) (champHubResponse, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries[year]
if !ok || now.After(e.expires) {
return champHubResponse{}, false
}
return e.resp, true
}
func (c *champHubCache) put(year int, resp champHubResponse, now time.Time, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
if c.entries == nil {
c.entries = map[int]champHubEntry{}
}
c.entries[year] = champHubEntry{resp: resp, expires: now.Add(ttl)}
}
// fetchMeetingRaces fans fetch out across meetings with bounded concurrency.
// The returned slice preserves the input meeting order regardless of
// completion order; meetings for which fetch reports ok=false are skipped.
func fetchMeetingRaces(meetings []models.Meeting, workers int, fetch func(models.Meeting) (meetingRace, bool)) []meetingRace {
if workers < 1 {
workers = 1
}
slots := make([]*meetingRace, len(meetings))
sem := make(chan struct{}, workers)
var wg sync.WaitGroup
for i, m := range meetings {
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
if mr, ok := fetch(m); ok {
slots[i] = &mr
}
}()
}
wg.Wait()
races := make([]meetingRace, 0, len(meetings))
for _, mr := range slots {
if mr != nil {
races = append(races, *mr)
}
}
return races
}
func (s *Server) handleChampionshipHub(w http.ResponseWriter, r *http.Request) {
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
if year == 0 {
year = time.Now().Year()
}
mode := parseSourceMode(r)
if mode != sourceLocal {
if resp, ok := s.hubCache.get(year, time.Now()); ok {
writeJSON(w, resp)
return
}
}
if mode == sourceLocal || mode == sourceAuto {
resp, ok, err := s.localChampionshipHub(year)
if err != nil {
writeError(w, err, http.StatusInternalServerError, false)
return
}
if ok {
s.hubCache.put(year, resp, time.Now(), champHubTTL(year, time.Now()))
writeJSON(w, resp)
return
}
if mode == sourceLocal {
writeJSON(w, resp)
return
}
}
resp, err := s.openF1ChampionshipHub(year)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
return
}
writeJSON(w, resp)
}
func (s *Server) localChampionshipHub(year int) (champHubResponse, bool, error) {
if !s.hasLocalQuery() {
return champHubResponse{Season: year, RoundLabels: []string{}, Drivers: []champHubDriver{}, Teams: []champHubTeam{}}, false, nil
}
inputs, err := s.query.GetChampionshipInputs(year)
if err != nil {
return champHubResponse{}, false, err
}
if len(inputs.Champ) == 0 {
return champHubResponse{Season: year, RoundLabels: []string{}, Drivers: []champHubDriver{}, Teams: []champHubTeam{}}, false, nil
}
races := make([]meetingRace, 0, len(inputs.Races))
for _, race := range inputs.Races {
races = append(races, meetingRace{
Meeting: race.Meeting,
RaceSessionKey: race.RaceSessionKey,
Results: race.Results,
Grid: race.Grid,
})
}
return aggregateChampionshipHub(year, races, inputs.Champ, inputs.Teams, inputs.DriverMap), true, nil
}
func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
meetings, err := s.client.GetMeetingsForYear(year)
if err != nil {
return champHubResponse{}, err
}
champ, err := s.client.GetDriverChampionshipForYear(year)
if err != nil {
return champHubResponse{}, err
}
if len(champ) == 0 {
return champHubResponse{Season: year, RoundLabels: []string{}, Drivers: []champHubDriver{}, Teams: []champHubTeam{}}, nil
}
teams, _ := s.client.GetTeamChampionshipForYear(year)
driverInfo := map[int]models.Driver{}
if ds, derr := s.client.GetDriversForSession(champ[0].SessionKey); derr == nil {
driverInfo = buildDriverMapFirst(ds)
}
sort.Slice(meetings, func(i, j int) bool { return meetings[i].DateStart < meetings[j].DateStart })
// Any per-meeting fetch failure (network, rate limit) yields an incomplete
// aggregate: serve it so the page still renders, but cache it only briefly
// so a partial view of the season doesn't stick around for the full TTL.
var incomplete atomic.Bool
races := fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) {
sessions, serr := s.client.GetSessionsForMeeting(int(m.MeetingKey))
if serr != nil {
incomplete.Store(true)
return meetingRace{}, false
}
raceKey := 0
for _, sess := range sessions {
if strings.EqualFold(sess.SessionName, "Race") {
raceKey = sess.SessionKey
break
}
}
if raceKey == 0 {
return meetingRace{}, false // not a GP meeting (e.g. pre-season testing)
}
results, rerr := s.client.GetSessionResult(raceKey)
grid, gerr := s.client.GetStartingGrid(raceKey)
if rerr != nil || gerr != nil {
incomplete.Store(true)
}
return meetingRace{Meeting: m, RaceSessionKey: raceKey, Results: results, Grid: grid}, true
})
resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo)
ttl := champHubTTL(year, time.Now())
if incomplete.Load() {
ttl = champHubIncompleteTTL
}
s.hubCache.put(year, resp, time.Now(), ttl)
return resp, nil
}
// aggregateChampionshipHub is the pure aggregation core (no network) so it can be
// unit-tested with synthetic data. races must be ordered ascending by date and
// contain only GP meetings (those with a Race session).
func aggregateChampionshipHub(
year int,
races []meetingRace,
champ []models.ChampionshipDriver,
teams []models.ChampionshipTeam,
driverInfo map[int]models.Driver,
) champHubResponse {
type acc struct {
wins, podiums, poles int
form []float64
finishByRound map[int]int
}
accs := map[int]*acc{}
getAcc := func(num int) *acc {
a := accs[num]
if a == nil {
a = &acc{finishByRound: map[int]int{}}
accs[num] = a
}
return a
}
completed := 0
lastRace := ""
roundPoints := []map[int]float64{} // per completed round: driver -> race points
for _, mr := range races {
if len(mr.Results) == 0 {
continue // round not completed yet
}
completed++
lastRace = mr.Meeting.MeetingName
for _, g := range mr.Grid {
if g.Position == 1 {
getAcc(g.DriverNumber).poles++
}
}
rp := map[int]float64{}
for _, res := range mr.Results {
a := getAcc(res.DriverNumber)
if res.Position == 1 {
a.wins++
}
if res.Position >= 1 && res.Position <= 3 {
a.podiums++
}
a.form = append(a.form, res.Points)
a.finishByRound[completed] = res.Position
rp[res.DriverNumber] += res.Points
}
roundPoints = append(roundPoints, rp)
}
roundLabels := make([]string, 0, completed)
for i := 1; i <= completed; i++ {
roundLabels = append(roundLabels, fmt.Sprintf("R%d", i))
}
// Official totals are authoritative; reconcile the cumulative endpoint to them.
champPts := map[int]float64{}
for _, c := range champ {
champPts[c.DriverNumber] = c.PointsCurrent
}
cumulative := map[int][]float64{}
for num := range accs {
running := 0.0
series := make([]float64, 0, completed)
for i := 0; i < completed; i++ {
running += roundPoints[i][num]
series = append(series, running)
}
if completed > 0 {
if off, ok := champPts[num]; ok {
series[completed-1] = off
}
}
cumulative[num] = series
}
// Teammate head-to-head: per round, the teammate finishing ahead wins.
teamOf := func(num int) string { return driverInfo[num].TeamName }
byTeam := map[string][]int{}
for num := range accs {
byTeam[teamOf(num)] = append(byTeam[teamOf(num)], num)
}
twins := map[int]int{}
tloss := map[int]int{}
for team, members := range byTeam {
if team == "" || len(members) < 2 {
continue
}
for round := 1; round <= completed; round++ {
for i := 0; i < len(members); i++ {
for j := i + 1; j < len(members); j++ {
p1, ok1 := accs[members[i]].finishByRound[round]
p2, ok2 := accs[members[j]].finishByRound[round]
if !ok1 || !ok2 {
continue
}
if p1 < p2 {
twins[members[i]]++
tloss[members[j]]++
} else if p2 < p1 {
twins[members[j]]++
tloss[members[i]]++
}
}
}
}
}
sortedChamp := make([]models.ChampionshipDriver, len(champ))
copy(sortedChamp, champ)
sort.Slice(sortedChamp, func(i, j int) bool { return sortedChamp[i].PositionCurrent < sortedChamp[j].PositionCurrent })
drivers := make([]champHubDriver, 0, len(sortedChamp))
for _, c := range sortedChamp {
a := accs[c.DriverNumber]
if a == nil {
a = &acc{}
}
form := a.form
if len(form) > 5 {
form = form[len(form)-5:]
}
info := driverInfo[c.DriverNumber]
drivers = append(drivers, champHubDriver{
DriverNumber: c.DriverNumber,
NameAcronym: info.NameAcronym,
FullName: info.FullName,
TeamName: info.TeamName,
TeamColour: info.TeamColour,
Points: c.PointsCurrent,
Position: c.PositionCurrent,
Wins: a.wins,
Podiums: a.podiums,
Poles: a.poles,
Form: form,
Cumulative: cumulative[c.DriverNumber],
TeammateWins: twins[c.DriverNumber],
TeammateLosses: tloss[c.DriverNumber],
})
}
teamWins := map[string]int{}
teamColour := map[string]string{}
for num, a := range accs {
teamWins[teamOf(num)] += a.wins
if col := driverInfo[num].TeamColour; col != "" {
teamColour[teamOf(num)] = col
}
}
sortedTeams := make([]models.ChampionshipTeam, len(teams))
copy(sortedTeams, teams)
sort.Slice(sortedTeams, func(i, j int) bool { return sortedTeams[i].PositionCurrent < sortedTeams[j].PositionCurrent })
teamsOut := make([]champHubTeam, 0, len(sortedTeams))
for _, t := range sortedTeams {
teamsOut = append(teamsOut, champHubTeam{
TeamName: t.TeamName,
TeamColour: teamColour[t.TeamName],
Points: t.PointsCurrent,
Position: t.PositionCurrent,
Wins: teamWins[t.TeamName],
})
}
totalRounds := len(races)
return champHubResponse{
Season: year,
Round: completed,
TotalRounds: totalRounds,
RoundsLeft: totalRounds - completed,
LastRace: lastRace,
RoundLabels: roundLabels,
Drivers: drivers,
Teams: teamsOut,
}
}
// --- /api/v1/track-outline --- // --- /api/v1/track-outline ---
// Accepts circuit_key and year (the frontend has both from meeting+session data). // Accepts circuit_key and year (the frontend has both from meeting+session data).
@@ -605,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 {
@@ -669,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

@@ -0,0 +1,266 @@
package web
import (
"sync/atomic"
"testing"
"time"
"github.com/AmanTahiliani/box-box/internal/models"
)
func raceResult(num, pos int, pts float64) models.SessionResult {
return models.SessionResult{DriverNumber: num, Position: pos, Points: pts}
}
func TestAggregateChampionshipHub(t *testing.T) {
driverInfo := map[int]models.Driver{
1: {DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull", TeamColour: "3671c6"},
2: {DriverNumber: 2, NameAcronym: "PER", FullName: "Sergio Perez", TeamName: "Red Bull", TeamColour: "3671c6"},
3: {DriverNumber: 3, NameAcronym: "HAM", FullName: "Lewis Hamilton", TeamName: "Mercedes", TeamColour: "27f4d2"},
}
champ := []models.ChampionshipDriver{
{DriverNumber: 1, PointsCurrent: 50, PositionCurrent: 1, SessionKey: 99},
{DriverNumber: 3, PointsCurrent: 33, PositionCurrent: 2, SessionKey: 99},
{DriverNumber: 2, PointsCurrent: 30, PositionCurrent: 3, SessionKey: 99},
}
teams := []models.ChampionshipTeam{
{TeamName: "Red Bull", PointsCurrent: 80, PositionCurrent: 1},
{TeamName: "Mercedes", PointsCurrent: 33, PositionCurrent: 2},
}
// Round 1: VER P1(25), HAM P2(18), PER P3(15). Pole: VER.
// Round 2: VER P1(25), PER P2(18), HAM P3(15). Pole: HAM.
races := []meetingRace{
{
Meeting: models.Meeting{MeetingName: "Bahrain GP"},
Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(3, 2, 18), raceResult(2, 3, 15)},
Grid: []models.StartingGrid{{DriverNumber: 1, Position: 1}},
},
{
Meeting: models.Meeting{MeetingName: "Saudi GP"},
Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(2, 2, 18), raceResult(3, 3, 15)},
Grid: []models.StartingGrid{{DriverNumber: 3, Position: 1}},
},
// Round 3: not yet run (no results) — should not count as completed.
{Meeting: models.Meeting{MeetingName: "Australia GP"}},
}
resp := aggregateChampionshipHub(2025, races, champ, teams, driverInfo)
if resp.Season != 2025 {
t.Errorf("season = %d, want 2025", resp.Season)
}
if resp.Round != 2 {
t.Errorf("completed rounds = %d, want 2", resp.Round)
}
if resp.TotalRounds != 3 {
t.Errorf("total rounds = %d, want 3", resp.TotalRounds)
}
if resp.RoundsLeft != 1 {
t.Errorf("rounds left = %d, want 1", resp.RoundsLeft)
}
if resp.LastRace != "Saudi GP" {
t.Errorf("last race = %q, want Saudi GP", resp.LastRace)
}
if len(resp.RoundLabels) != 2 || resp.RoundLabels[0] != "R1" || resp.RoundLabels[1] != "R2" {
t.Errorf("round labels = %v, want [R1 R2]", resp.RoundLabels)
}
// Drivers are sorted by official position: VER, HAM, PER.
if len(resp.Drivers) != 3 {
t.Fatalf("drivers = %d, want 3", len(resp.Drivers))
}
ver := resp.Drivers[0]
if ver.NameAcronym != "VER" || ver.Position != 1 {
t.Errorf("first driver = %s P%d, want VER P1", ver.NameAcronym, ver.Position)
}
if ver.Wins != 2 {
t.Errorf("VER wins = %d, want 2", ver.Wins)
}
if ver.Podiums != 2 {
t.Errorf("VER podiums = %d, want 2", ver.Podiums)
}
if ver.Poles != 1 {
t.Errorf("VER poles = %d, want 1", ver.Poles)
}
if len(ver.Form) != 2 || ver.Form[0] != 25 || ver.Form[1] != 25 {
t.Errorf("VER form = %v, want [25 25]", ver.Form)
}
// Cumulative reconciles final value to official total (50).
if len(ver.Cumulative) != 2 || ver.Cumulative[0] != 25 || ver.Cumulative[1] != 50 {
t.Errorf("VER cumulative = %v, want [25 50]", ver.Cumulative)
}
// VER beat teammate PER in both rounds.
if ver.TeammateWins != 2 || ver.TeammateLosses != 0 {
t.Errorf("VER h2h = %d-%d, want 2-0", ver.TeammateWins, ver.TeammateLosses)
}
// PER lost both intra-team battles to VER.
var per champHubDriver
for _, d := range resp.Drivers {
if d.NameAcronym == "PER" {
per = d
}
}
if per.TeammateWins != 0 || per.TeammateLosses != 2 {
t.Errorf("PER h2h = %d-%d, want 0-2", per.TeammateWins, per.TeammateLosses)
}
if per.Poles != 0 {
t.Errorf("PER poles = %d, want 0", per.Poles)
}
// HAM has no teammate in the data — no h2h recorded.
var ham champHubDriver
for _, d := range resp.Drivers {
if d.NameAcronym == "HAM" {
ham = d
}
}
if ham.TeammateWins != 0 || ham.TeammateLosses != 0 {
t.Errorf("HAM h2h = %d-%d, want 0-0 (no teammate)", ham.TeammateWins, ham.TeammateLosses)
}
if ham.Poles != 1 {
t.Errorf("HAM poles = %d, want 1", ham.Poles)
}
// Teams sorted by position; Red Bull wins = VER(2) + PER(0) = 2.
if len(resp.Teams) != 2 {
t.Fatalf("teams = %d, want 2", len(resp.Teams))
}
if resp.Teams[0].TeamName != "Red Bull" || resp.Teams[0].Wins != 2 {
t.Errorf("top team = %s wins %d, want Red Bull wins 2", resp.Teams[0].TeamName, resp.Teams[0].Wins)
}
if resp.Teams[0].TeamColour != "3671c6" {
t.Errorf("Red Bull colour = %q, want 3671c6", resp.Teams[0].TeamColour)
}
}
func TestAggregateChampionshipHubEmpty(t *testing.T) {
resp := aggregateChampionshipHub(2025, nil, nil, nil, map[int]models.Driver{})
if resp.Round != 0 || resp.TotalRounds != 0 || len(resp.Drivers) != 0 {
t.Errorf("empty aggregation should be zero-valued, got %+v", resp)
}
}
func TestFetchMeetingRacesPreservesOrderAndSkips(t *testing.T) {
const n = 12
meetings := make([]models.Meeting, n)
for i := range meetings {
meetings[i] = models.Meeting{MeetingKey: int32(i + 1)}
}
races := fetchMeetingRaces(meetings, 5, func(m models.Meeting) (meetingRace, bool) {
// Later meetings finish first to shuffle completion order.
time.Sleep(time.Duration(n-int(m.MeetingKey)) * time.Millisecond)
if m.MeetingKey%3 == 0 {
return meetingRace{}, false // simulate skip (fetch error / no Race session)
}
return meetingRace{Meeting: m, RaceSessionKey: int(m.MeetingKey) * 100}, true
})
want := 0
for i := 1; i <= n; i++ {
if i%3 != 0 {
want++
}
}
if len(races) != want {
t.Fatalf("races = %d, want %d", len(races), want)
}
prev := int32(0)
for _, r := range races {
if r.Meeting.MeetingKey <= prev {
t.Fatalf("races out of input order: key %d after %d", r.Meeting.MeetingKey, prev)
}
if r.Meeting.MeetingKey%3 == 0 {
t.Fatalf("skipped meeting %d present in output", r.Meeting.MeetingKey)
}
if r.RaceSessionKey != int(r.Meeting.MeetingKey)*100 {
t.Fatalf("meeting %d has mismatched race key %d", r.Meeting.MeetingKey, r.RaceSessionKey)
}
prev = r.Meeting.MeetingKey
}
}
func TestFetchMeetingRacesBoundsConcurrency(t *testing.T) {
const workers = 3
var inFlight, peak atomic.Int32
meetings := make([]models.Meeting, 20)
for i := range meetings {
meetings[i] = models.Meeting{MeetingKey: int32(i + 1)}
}
fetchMeetingRaces(meetings, workers, func(m models.Meeting) (meetingRace, bool) {
cur := inFlight.Add(1)
for {
p := peak.Load()
if cur <= p || peak.CompareAndSwap(p, cur) {
break
}
}
time.Sleep(5 * time.Millisecond)
inFlight.Add(-1)
return meetingRace{Meeting: m, RaceSessionKey: 1}, true
})
if p := peak.Load(); p > workers {
t.Errorf("peak concurrent fetches = %d, want <= %d", p, workers)
}
}
func TestChampHubTTL(t *testing.T) {
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
if got := champHubTTL(2026, now); got != champHubCurrentTTL {
t.Errorf("current year TTL = %v, want %v", got, champHubCurrentTTL)
}
if got := champHubTTL(2027, now); got != champHubCurrentTTL {
t.Errorf("future year TTL = %v, want %v", got, champHubCurrentTTL)
}
if got := champHubTTL(2024, now); got != champHubPastTTL {
t.Errorf("past year TTL = %v, want %v", got, champHubPastTTL)
}
}
func TestChampHubCache(t *testing.T) {
var c champHubCache
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
if _, ok := c.get(2026, now); ok {
t.Fatal("empty cache should miss")
}
c.put(2026, champHubResponse{Season: 2026, Round: 10}, now, champHubTTL(2026, now))
c.put(2024, champHubResponse{Season: 2024, Round: 24}, now, champHubTTL(2024, now))
// Current-year entry: hit within 15 min, miss after.
if resp, ok := c.get(2026, now.Add(14*time.Minute)); !ok || resp.Round != 10 {
t.Errorf("current-year get within TTL = (%+v, %v), want hit with Round 10", resp, ok)
}
if _, ok := c.get(2026, now.Add(16*time.Minute)); ok {
t.Error("current-year entry should expire after 15 minutes")
}
// Past-year entry: hit well beyond 15 min, miss after 24 h.
if resp, ok := c.get(2024, now.Add(12*time.Hour)); !ok || resp.Round != 24 {
t.Errorf("past-year get within TTL = (%+v, %v), want hit with Round 24", resp, ok)
}
if _, ok := c.get(2024, now.Add(25*time.Hour)); ok {
t.Error("past-year entry should expire after 24 hours")
}
// Re-put refreshes the entry.
c.put(2026, champHubResponse{Season: 2026, Round: 11}, now.Add(20*time.Minute), champHubTTL(2026, now))
if resp, ok := c.get(2026, now.Add(30*time.Minute)); !ok || resp.Round != 11 {
t.Errorf("refreshed entry = (%+v, %v), want hit with Round 11", resp, ok)
}
// Incomplete aggregates get the short TTL: hit right away, gone in 3 min.
c.put(2023, champHubResponse{Season: 2023, Round: 5}, now, champHubIncompleteTTL)
if _, ok := c.get(2023, now.Add(time.Minute)); !ok {
t.Error("incomplete entry should hit within its short TTL")
}
if _, ok := c.get(2023, now.Add(3*time.Minute)); ok {
t.Error("incomplete entry should expire after champHubIncompleteTTL")
}
}

View File

@@ -29,9 +29,10 @@ type SSEHub struct {
deregister chan *sseClient deregister chan *sseClient
broadcast chan sseEvent broadcast chan sseEvent
mu sync.RWMutex mu sync.RWMutex
lastSnapshot *live.LiveStreamData lastSnapshot *live.LiveStreamData
isLive bool lastPositions map[string]live.LivePositionData
isLive bool
} }
func newSSEHub() *SSEHub { func newSSEHub() *SSEHub {
@@ -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] {
@@ -121,8 +131,14 @@ 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.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 {
s.hub.broadcast <- sseEvent{name: "snapshot", data: payload}
}
log.Printf("web: live feed reconnecting in %v", backoff) log.Printf("web: live feed reconnecting in %v", backoff)
time.Sleep(backoff) time.Sleep(backoff)
if backoff < maxBackoff { if backoff < maxBackoff {
@@ -145,17 +161,33 @@ 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()
s.hub.lastSnapshot = &data if data.SnapshotUpdated {
s.hub.lastSnapshot = &data
}
if data.PositionUpdated && len(data.Positions) > 0 {
s.hub.lastPositions = cloneLivePositions(data.Positions)
}
s.hub.isLive = true s.hub.isLive = true
s.hub.mu.Unlock() s.hub.mu.Unlock()
if payload, err := json.Marshal(map[string]any{"data": data, "is_live": true}); err == nil { if data.SnapshotUpdated {
s.hub.broadcast <- sseEvent{name: "snapshot", data: payload} if payload, err := json.Marshal(map[string]any{"data": data, "is_live": true}); err == nil {
s.hub.broadcast <- sseEvent{name: "snapshot", data: payload}
}
}
if data.PositionUpdated && len(data.Positions) > 0 && now.Sub(lastPositionBroadcast) >= 250*time.Millisecond {
if payload, err := json.Marshal(data.Positions); err == nil {
s.hub.broadcast <- sseEvent{name: "positions", data: payload}
lastPositionBroadcast = now
}
} }
if !timer.Stop() { if !timer.Stop() {
@@ -172,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()

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