mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
Compare commits
4 Commits
5408a45bbd
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c72c12821 | ||
|
|
75ca5f4deb | ||
|
|
094620fa08 | ||
|
|
7263949260 |
49
.agents/README.md
Normal file
49
.agents/README.md
Normal 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
45
.agents/bin/dev
Executable 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
60
.agents/harnesses.sh
Normal 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
121
.agents/lib/dispatch.sh
Normal 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
68
.agents/lib/gh.sh
Normal 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"
|
||||
}
|
||||
35
.agents/personas/architect.md
Normal file
35
.agents/personas/architect.md
Normal 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
51
.agents/personas/grill.md
Normal 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 3–6 bullets, confirm it with
|
||||
the human, then hand off to `write-spec`.
|
||||
21
.agents/prompts/implement.md
Normal file
21
.agents/prompts/implement.md
Normal 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.
|
||||
27
.agents/prompts/ready-spec.md
Normal file
27
.agents/prompts/ready-spec.md
Normal 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
20
.agents/prompts/review.md
Normal 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.
|
||||
42
.agents/skills/groom/SKILL.md
Normal file
42
.agents/skills/groom/SKILL.md
Normal 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 3–6 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.
|
||||
32
.agents/skills/implement/SKILL.md
Normal file
32
.agents/skills/implement/SKILL.md
Normal 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>`.
|
||||
22
.agents/skills/lens-architect/SKILL.md
Normal file
22
.agents/skills/lens-architect/SKILL.md
Normal 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.
|
||||
53
.agents/skills/review/SKILL.md
Normal file
53
.agents/skills/review/SKILL.md
Normal 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.
|
||||
30
.agents/skills/write-spec/SKILL.md
Normal file
30
.agents/skills/write-spec/SKILL.md
Normal 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 (P0–P2).
|
||||
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
1
.claude/skills
Symbolic link
@@ -0,0 +1 @@
|
||||
../.agents/skills
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -22,7 +22,8 @@ frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# Local Claude workspace settings
|
||||
.claude/
|
||||
.claude/*
|
||||
!.claude/skills
|
||||
|
||||
# Playwright
|
||||
node_modules/
|
||||
@@ -31,3 +32,9 @@ node_modules/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
|
||||
# agentic dev: isolated implementation worktrees
|
||||
.worktrees/
|
||||
|
||||
# agentic dev: local review packets
|
||||
.review/
|
||||
|
||||
198
docs/PRODUCT_ROADMAP.md
Normal file
198
docs/PRODUCT_ROADMAP.md
Normal 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` (P0–P2), `Effort` (S/M/L), `Phase` (1–3).
|
||||
|
||||
**Working model — active vs. bank:**
|
||||
- Only **1–2 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.
|
||||
@@ -2,10 +2,12 @@ import type {
|
||||
ArticleContent,
|
||||
ChampionshipHub,
|
||||
LiveStateResponse,
|
||||
LiveSessionMeta,
|
||||
Meeting,
|
||||
NewsItem,
|
||||
RaceHub,
|
||||
Session,
|
||||
TrackOutline,
|
||||
Weekend,
|
||||
} from './types'
|
||||
|
||||
@@ -80,6 +82,20 @@ export async function fetchLiveState(): Promise<LiveStateResponse> {
|
||||
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[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (limit) params.set('limit', limit.toString())
|
||||
|
||||
108
frontend/src/components/live/TeamRadioTicker.tsx
Normal file
108
frontend/src/components/live/TeamRadioTicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
145
frontend/src/components/live/TrackMap.tsx
Normal file
145
frontend/src/components/live/TrackMap.tsx
Normal 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}`
|
||||
}
|
||||
22
frontend/src/lib/radio.ts
Normal file
22
frontend/src/lib/radio.ts
Normal 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}`
|
||||
}
|
||||
66
frontend/src/lib/trackmap.ts
Normal file
66
frontend/src/lib/trackmap.ts
Normal 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)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchLiveState } from '../api'
|
||||
import type { LiveStreamData } from '../types'
|
||||
import { fetchLiveState, fetchLiveTrackOutline } from '../api'
|
||||
import type { LivePosition, LiveStreamData } from '../types'
|
||||
import {
|
||||
loadPinnedDrivers,
|
||||
mergeVisibleSectors,
|
||||
@@ -21,6 +21,8 @@ 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 { TeamRadioTicker } from '../components/live/TeamRadioTicker'
|
||||
import { TrackMap } from '../components/live/TrackMap'
|
||||
import { Radio } from 'lucide-react'
|
||||
|
||||
type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
|
||||
@@ -33,6 +35,7 @@ export function LiveTimingPage() {
|
||||
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({
|
||||
queryKey: ['live-state'],
|
||||
@@ -40,6 +43,18 @@ export function LiveTimingPage() {
|
||||
staleTime: 5_000,
|
||||
})
|
||||
|
||||
const trackOutlineQuery = useQuery({
|
||||
queryKey: [
|
||||
'live-track-outline',
|
||||
snapshot?.Session?.MeetingName ?? '',
|
||||
snapshot?.Session?.CircuitName ?? '',
|
||||
],
|
||||
queryFn: () => fetchLiveTrackOutline(snapshot!.Session),
|
||||
enabled: Boolean(snapshot?.Session?.MeetingName || snapshot?.Session?.CircuitName),
|
||||
staleTime: Infinity,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
setIsLive(data.is_live)
|
||||
@@ -77,6 +92,17 @@ export function LiveTimingPage() {
|
||||
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 = () => {
|
||||
if (!cancelled) setStreamStatus('disconnected')
|
||||
}
|
||||
@@ -160,6 +186,14 @@ export function LiveTimingPage() {
|
||||
<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-tower-col">
|
||||
<div className="sec-header">
|
||||
@@ -178,6 +212,11 @@ export function LiveTimingPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="live-rc-col">
|
||||
<TeamRadioTicker
|
||||
captures={snapshot.TeamRadio ?? []}
|
||||
driverInfo={snapshot.DriverInfo}
|
||||
session={snapshot.Session}
|
||||
/>
|
||||
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -649,6 +649,141 @@ a { color: inherit; text-decoration: none; }
|
||||
gap: var(--s5);
|
||||
}
|
||||
|
||||
.live-track-panel {
|
||||
margin-bottom: var(--s5);
|
||||
}
|
||||
|
||||
.track-map-stage {
|
||||
position: relative;
|
||||
min-height: 360px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.track-map-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
.track-map-outline-shadow,
|
||||
.track-map-outline {
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.track-map-outline-shadow {
|
||||
stroke: rgba(255, 255, 255, 0.08);
|
||||
stroke-width: 5;
|
||||
}
|
||||
|
||||
.track-map-outline {
|
||||
stroke: var(--surface-3);
|
||||
stroke-width: 2.4;
|
||||
}
|
||||
|
||||
.track-car {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.track-car circle {
|
||||
stroke: #050505;
|
||||
stroke-width: 0.7;
|
||||
transition: r 0.12s, opacity 0.12s, stroke 0.12s;
|
||||
}
|
||||
|
||||
.track-car text {
|
||||
fill: #fff;
|
||||
font-family: var(--f-mono);
|
||||
font-size: 2.1px;
|
||||
font-weight: 800;
|
||||
pointer-events: none;
|
||||
text-anchor: middle;
|
||||
paint-order: stroke;
|
||||
stroke: rgba(0, 0, 0, 0.85);
|
||||
stroke-width: 0.7;
|
||||
}
|
||||
|
||||
.track-car:hover circle,
|
||||
.track-car:focus-visible circle,
|
||||
.track-car-selected circle {
|
||||
r: 3.7;
|
||||
stroke: #fff;
|
||||
}
|
||||
|
||||
.track-car-inactive {
|
||||
opacity: 0.35;
|
||||
filter: grayscale(0.8);
|
||||
}
|
||||
|
||||
.track-map-empty {
|
||||
display: grid;
|
||||
min-height: 160px;
|
||||
place-items: center;
|
||||
color: var(--text-3);
|
||||
font-family: var(--f-mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.track-map-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.track-telemetry {
|
||||
position: absolute;
|
||||
right: var(--s5);
|
||||
bottom: var(--s5);
|
||||
width: min(260px, calc(100% - 32px));
|
||||
padding: var(--s4);
|
||||
border: 1px solid var(--border-2);
|
||||
background: rgba(10, 10, 10, 0.88);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.track-telemetry-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s4);
|
||||
margin-bottom: var(--s3);
|
||||
color: var(--text-3);
|
||||
font-family: var(--f-mono);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.track-driver-code {
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.track-telemetry dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.track-telemetry dt {
|
||||
color: var(--text-3);
|
||||
font-family: var(--f-mono);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.track-telemetry dd {
|
||||
color: var(--text);
|
||||
font-family: var(--f-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.live-columns {
|
||||
display: grid;
|
||||
|
||||
82
frontend/src/styles/team-radio.css
Normal file
82
frontend/src/styles/team-radio.css
Normal 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;
|
||||
}
|
||||
@@ -7,9 +7,10 @@ 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 } from '../types'
|
||||
import type { LiveDriverData, LiveWeatherData, TrackOutline } from '../types'
|
||||
|
||||
function makeRow(
|
||||
number: string,
|
||||
@@ -200,6 +201,7 @@ describe('TimingTower', () => {
|
||||
CircuitName: 'Silverstone',
|
||||
SessionType: 'Sprint Qualifying',
|
||||
SessionName: 'Sprint Qualifying',
|
||||
Path: '',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
@@ -241,3 +243,66 @@ describe('PinnedDrivers', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
87
frontend/src/test/TeamRadioTicker.test.tsx
Normal file
87
frontend/src/test/TeamRadioTicker.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -100,7 +100,8 @@ const snapshot: LiveStreamData = {
|
||||
{ Time: '14:08', Category: 'Drs', Flag: '', Message: 'DRS ENABLED', Lap: 3 },
|
||||
],
|
||||
Weather: { AirTemp: 20, TrackTemp: 31, Humidity: 55, WindSpeed: 2, WindDir: 180, Rainfall: false },
|
||||
Session: { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race' },
|
||||
Session: { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race', Path: '2026/Monaco/Race' },
|
||||
TeamRadio: [],
|
||||
TrackStatus: '1',
|
||||
CurrentLap: 21,
|
||||
TotalLaps: 78,
|
||||
@@ -220,7 +221,7 @@ describe('track status mapping', () => {
|
||||
describe('live qualifying display', () => {
|
||||
it('puts the SQ1 cutoff after P17 for a 22-car sprint qualifying session', () => {
|
||||
const display = liveSessionDisplay(
|
||||
{ MeetingName: 'British Grand Prix', CircuitName: 'Silverstone', SessionType: 'Sprint Qualifying', SessionName: 'Sprint Qualifying' },
|
||||
{ MeetingName: 'British Grand Prix', CircuitName: 'Silverstone', SessionType: 'Sprint Qualifying', SessionName: 'Sprint Qualifying', Path: '' },
|
||||
rows(22),
|
||||
)
|
||||
expect(display.phaseLabel).toBe('SQ1')
|
||||
@@ -232,7 +233,7 @@ describe('live qualifying display', () => {
|
||||
|
||||
it('keeps the normal Q1 cutoff after P15 for a 20-car qualifying session', () => {
|
||||
const display = liveSessionDisplay(
|
||||
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying' },
|
||||
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying', Path: '' },
|
||||
rows(20),
|
||||
)
|
||||
expect(display.phaseLabel).toBe('Q1')
|
||||
@@ -241,7 +242,7 @@ describe('live qualifying display', () => {
|
||||
|
||||
it('moves phase 2 cutoff after P10 once five cars are knocked out', () => {
|
||||
const display = liveSessionDisplay(
|
||||
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying' },
|
||||
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying', Path: '' },
|
||||
rows(20, 5),
|
||||
)
|
||||
expect(display.phaseLabel).toBe('Q2')
|
||||
@@ -251,13 +252,13 @@ describe('live qualifying display', () => {
|
||||
it('shows no cutoff for race sessions or Q3', () => {
|
||||
expect(
|
||||
liveSessionDisplay(
|
||||
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race' },
|
||||
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race', Path: '' },
|
||||
rows(20),
|
||||
).cutoffPosition,
|
||||
).toBeNull()
|
||||
expect(
|
||||
liveSessionDisplay(
|
||||
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Q3' },
|
||||
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Q3', Path: '' },
|
||||
rows(10),
|
||||
).cutoffPosition,
|
||||
).toBeNull()
|
||||
|
||||
39
frontend/src/test/trackmap.test.ts
Normal file
39
frontend/src/test/trackmap.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -177,6 +177,22 @@ export interface LiveStateResponse {
|
||||
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 {
|
||||
Value: string
|
||||
PersonalFastest: boolean
|
||||
@@ -245,6 +261,7 @@ export interface LiveSessionMeta {
|
||||
CircuitName: string
|
||||
SessionType: string
|
||||
SessionName: string
|
||||
Path: string
|
||||
}
|
||||
|
||||
export interface LiveStintData {
|
||||
@@ -253,13 +270,21 @@ export interface LiveStintData {
|
||||
Laps: number
|
||||
}
|
||||
|
||||
export interface LiveRadioCapture {
|
||||
Utc: string
|
||||
RacingNumber: string
|
||||
Path: string
|
||||
}
|
||||
|
||||
export interface LiveStreamData {
|
||||
Drivers: Record<string, LiveDriverData>
|
||||
DriverInfo: Record<string, LiveDriverInfo>
|
||||
Tyres: Record<string, LiveTyreData>
|
||||
Telemetry?: Record<string, LiveTelemetry>
|
||||
RCMessages: LiveRCMessage[]
|
||||
Weather: LiveWeatherData
|
||||
Session: LiveSessionMeta
|
||||
TeamRadio: LiveRadioCapture[]
|
||||
TrackStatus: string
|
||||
CurrentLap: number
|
||||
TotalLaps: number
|
||||
@@ -269,6 +294,24 @@ export interface LiveStreamData {
|
||||
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
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package live_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -158,6 +162,68 @@ func TestProcessTopicTimingData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessTopicCompressedPositionAndCarData(t *testing.T) {
|
||||
state := live.NewState()
|
||||
positionPayload := `{
|
||||
"Position": [
|
||||
{"Timestamp": "2026-07-03T14:00:00Z", "Entries": {
|
||||
"1": {"Status": "OnTrack", "X": 1000, "Y": -200, "Z": 3},
|
||||
"44": {"Status": "OffTrack", "X": 1200, "Y": -250, "Z": 2}
|
||||
}}
|
||||
]
|
||||
}`
|
||||
if !state.ProcessTopic("Position.z", encodedDeflatePayload(t, positionPayload)) {
|
||||
t.Fatal("Position.z should update state")
|
||||
}
|
||||
|
||||
snap := state.Snapshot()
|
||||
if !snap.PositionUpdated || snap.SnapshotUpdated {
|
||||
t.Fatalf("position flags = position:%v snapshot:%v", snap.PositionUpdated, snap.SnapshotUpdated)
|
||||
}
|
||||
if got := snap.Positions["1"]; got.X != 1000 || got.Y != -200 || got.Z != 3 || got.Status != "OnTrack" {
|
||||
t.Fatalf("position 1 = %+v", got)
|
||||
}
|
||||
if got := snap.Positions["44"]; got.Status != "OffTrack" {
|
||||
t.Fatalf("position 44 = %+v", got)
|
||||
}
|
||||
|
||||
carPayload := `{
|
||||
"Entries": [
|
||||
{"Utc": "2026-07-03T14:00:00Z", "Cars": {
|
||||
"1": {"Channels": {"0": 11234, "2": 318, "3": 8, "4": 92, "5": 0, "45": 10}}
|
||||
}}
|
||||
]
|
||||
}`
|
||||
if !state.ProcessTopic("CarData.z", encodedDeflatePayload(t, carPayload)) {
|
||||
t.Fatal("CarData.z should update state")
|
||||
}
|
||||
snap = state.Snapshot()
|
||||
if !snap.SnapshotUpdated {
|
||||
t.Fatal("CarData should mark snapshot updated")
|
||||
}
|
||||
tel := snap.Telemetry["1"]
|
||||
if tel.RPM != 11234 || tel.Speed != 318 || tel.NGear != 8 || tel.Throttle != 92 || tel.Brake != 0 || tel.DRS != 10 {
|
||||
t.Fatalf("telemetry = %+v", tel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessTopicSessionInfoCircuitName(t *testing.T) {
|
||||
state := live.NewState()
|
||||
state.ProcessTopic("SessionInfo", json.RawMessage(`{
|
||||
"Meeting": {"Name": "British Grand Prix", "Circuit": {"ShortName": "Silverstone"}},
|
||||
"Name": "Race",
|
||||
"Type": "Race",
|
||||
"Path": "2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/"
|
||||
}`))
|
||||
s := state.Snapshot().Session
|
||||
if s.MeetingName != "British Grand Prix" || s.CircuitName != "Silverstone" {
|
||||
t.Fatalf("session = %+v", s)
|
||||
}
|
||||
if s.Path != "2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/" {
|
||||
t.Fatalf("session path = %q", s.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessTopicDriverList(t *testing.T) {
|
||||
state := live.NewState()
|
||||
data := json.RawMessage(`{"63": {"RacingNumber": "63", "Tla": "RUS", "TeamName": "Mercedes", "TeamColour": "27F4D2"}}`)
|
||||
@@ -224,6 +290,68 @@ func TestProcessTopicRaceControlMessages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessTopicTeamRadioSnapshotAndPatch(t *testing.T) {
|
||||
state := live.NewState()
|
||||
snapshot := json.RawMessage(`{
|
||||
"Captures": [
|
||||
{"Utc": "2026-07-05T14:05:30Z", "RacingNumber": "4", "Path": "TeamRadio/NOR-1.mp3"},
|
||||
{"Utc": "2026-07-05T14:04:10Z", "RacingNumber": "16", "Path": "TeamRadio/LEC-1.mp3"}
|
||||
]
|
||||
}`)
|
||||
|
||||
if !state.ProcessTopic("TeamRadio", snapshot) {
|
||||
t.Fatal("TeamRadio snapshot should update state")
|
||||
}
|
||||
|
||||
patch := json.RawMessage(`{
|
||||
"Captures": {
|
||||
"2": {"Utc": "2026-07-05T14:06:00Z", "RacingNumber": "44", "Path": "TeamRadio/HAM-1.mp3"}
|
||||
}
|
||||
}`)
|
||||
if !state.ProcessTopic("TeamRadio", patch) {
|
||||
t.Fatal("TeamRadio keyed patch should update state")
|
||||
}
|
||||
|
||||
radio := state.Snapshot().TeamRadio
|
||||
if len(radio) != 3 {
|
||||
t.Fatalf("radio captures = %d, want 3", len(radio))
|
||||
}
|
||||
if radio[0].RacingNumber != "16" || radio[1].RacingNumber != "4" || radio[2].RacingNumber != "44" {
|
||||
t.Fatalf("radio order = %+v", radio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessTopicTeamRadioMalformed(t *testing.T) {
|
||||
state := live.NewState()
|
||||
if state.ProcessTopic("TeamRadio", json.RawMessage(`{"Captures": {"1": {"Utc": "2026-07-05T14:06:00Z", "Path": "missing-driver.mp3"}}}`)) {
|
||||
t.Fatal("incomplete TeamRadio capture should not update state")
|
||||
}
|
||||
if state.ProcessTopic("TeamRadio", json.RawMessage(`{"Captures": "not-a-list"}`)) {
|
||||
t.Fatal("unexpected TeamRadio captures shape should not update state")
|
||||
}
|
||||
if len(state.Snapshot().TeamRadio) != 0 {
|
||||
t.Fatalf("malformed captures mutated state: %+v", state.Snapshot().TeamRadio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessTopicTeamRadioCapsAtTwenty(t *testing.T) {
|
||||
state := live.NewState()
|
||||
for i := 0; i < 25; i++ {
|
||||
payload := json.RawMessage([]byte(fmt.Sprintf(`{
|
||||
"Captures": [{"Utc": "2026-07-05T14:%02d:00Z", "RacingNumber": "%d", "Path": "TeamRadio/%02d.mp3"}]
|
||||
}`, i, i, i)))
|
||||
state.ProcessTopic("TeamRadio", payload)
|
||||
}
|
||||
|
||||
radio := state.Snapshot().TeamRadio
|
||||
if len(radio) != 20 {
|
||||
t.Fatalf("radio captures = %d, want 20", len(radio))
|
||||
}
|
||||
if radio[0].Utc != "2026-07-05T14:05:00Z" || radio[19].Utc != "2026-07-05T14:24:00Z" {
|
||||
t.Fatalf("radio cap kept wrong captures: first=%+v last=%+v", radio[0], radio[19])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessTopicWeatherData(t *testing.T) {
|
||||
state := live.NewState()
|
||||
state.ProcessTopic("WeatherData", json.RawMessage(`{
|
||||
@@ -356,3 +484,23 @@ func TestProcessMessageEmptyPayload(t *testing.T) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -96,6 +96,8 @@ func connectToF1SignalRCore(dataChan chan LiveStreamData) error {
|
||||
topics := []string{
|
||||
"Heartbeat",
|
||||
"TimingData",
|
||||
"Position.z",
|
||||
"CarData.z",
|
||||
"DriverList",
|
||||
"LapCount",
|
||||
"ExtrapolatedClock",
|
||||
@@ -103,6 +105,7 @@ func connectToF1SignalRCore(dataChan chan LiveStreamData) error {
|
||||
"RaceControlMessages",
|
||||
"WeatherData",
|
||||
"SessionInfo",
|
||||
"TeamRadio",
|
||||
"CurrentTyres",
|
||||
"TimingAppData",
|
||||
"TimingStats",
|
||||
@@ -194,7 +197,7 @@ func connectToF1LegacySignalR(dataChan chan LiveStreamData) error {
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"compress/zlib"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -11,19 +19,25 @@ type State struct {
|
||||
Drivers map[string]LiveDriverData
|
||||
DriverInfo map[string]F1DriverListEntry
|
||||
Tyres map[string]LiveTyreData
|
||||
Telemetry map[string]LiveTelemetryData
|
||||
Positions map[string]LivePositionData
|
||||
Stints map[string][]LiveStintData
|
||||
RCMessages []LiveRCMessage
|
||||
Weather LiveWeatherData
|
||||
Session LiveSessionMeta
|
||||
TeamRadio []LiveRadioCapture
|
||||
TrackStatus string
|
||||
CurrentLap int
|
||||
TotalLaps int
|
||||
Clock string
|
||||
ClockRefTime time.Time
|
||||
ClockExtrapolating bool
|
||||
positionUpdated bool
|
||||
snapshotUpdated bool
|
||||
}
|
||||
|
||||
const signalRRecordSeparator = byte(0x1e)
|
||||
const maxTeamRadioCaptures = 20
|
||||
|
||||
// NewState returns an empty live timing accumulator.
|
||||
func NewState() *State {
|
||||
@@ -31,6 +45,8 @@ func NewState() *State {
|
||||
Drivers: make(map[string]LiveDriverData),
|
||||
DriverInfo: make(map[string]F1DriverListEntry),
|
||||
Tyres: make(map[string]LiveTyreData),
|
||||
Telemetry: make(map[string]LiveTelemetryData),
|
||||
Positions: make(map[string]LivePositionData),
|
||||
Stints: make(map[string][]LiveStintData),
|
||||
}
|
||||
}
|
||||
@@ -49,8 +65,18 @@ func (s *State) Snapshot() LiveStreamData {
|
||||
for k, v := range s.Tyres {
|
||||
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))
|
||||
copy(cpyRC, s.RCMessages)
|
||||
cpyRadio := make([]LiveRadioCapture, len(s.TeamRadio))
|
||||
copy(cpyRadio, s.TeamRadio)
|
||||
cpyStints := make(map[string][]LiveStintData, len(s.Stints))
|
||||
for k, v := range s.Stints {
|
||||
st := make([]LiveStintData, len(v))
|
||||
@@ -62,9 +88,11 @@ func (s *State) Snapshot() LiveStreamData {
|
||||
Drivers: cpyDrivers,
|
||||
DriverInfo: cpyInfo,
|
||||
Tyres: cpyTyres,
|
||||
Telemetry: cpyTelemetry,
|
||||
RCMessages: cpyRC,
|
||||
Weather: s.Weather,
|
||||
Session: s.Session,
|
||||
TeamRadio: cpyRadio,
|
||||
TrackStatus: s.TrackStatus,
|
||||
CurrentLap: s.CurrentLap,
|
||||
TotalLaps: s.TotalLaps,
|
||||
@@ -72,11 +100,16 @@ func (s *State) Snapshot() LiveStreamData {
|
||||
ClockRefTime: s.ClockRefTime,
|
||||
ClockExtrapolating: s.ClockExtrapolating,
|
||||
Stints: cpyStints,
|
||||
Positions: cpyPositions,
|
||||
PositionUpdated: s.positionUpdated,
|
||||
SnapshotUpdated: s.snapshotUpdated,
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessMessage parses a raw SignalR WebSocket frame and applies any updates.
|
||||
func (s *State) ProcessMessage(message []byte) bool {
|
||||
s.clearTransientFlags()
|
||||
|
||||
var parsed F1SignalRMessage
|
||||
if err := json.Unmarshal(message, &parsed); err != nil {
|
||||
return false
|
||||
@@ -111,6 +144,8 @@ func (s *State) ProcessMessage(message []byte) bool {
|
||||
// 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 {
|
||||
@@ -156,7 +191,15 @@ func (s *State) ProcessCoreMessage(message []byte) bool {
|
||||
// ProcessTopic applies a single topic payload to the accumulator.
|
||||
func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
|
||||
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":
|
||||
var td struct {
|
||||
Lines map[string]json.RawMessage `json:"Lines"`
|
||||
@@ -170,6 +213,10 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
case "Position":
|
||||
updated = s.updatePositions(data)
|
||||
case "CarData":
|
||||
updated = s.updateTelemetry(data)
|
||||
case "DriverList":
|
||||
var dlMap map[string]json.RawMessage
|
||||
if json.Unmarshal(data, &dlMap) == nil {
|
||||
@@ -289,22 +336,34 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
|
||||
var si struct {
|
||||
Meeting struct {
|
||||
Name string `json:"Name"`
|
||||
Circuit struct {
|
||||
ShortName string `json:"ShortName"`
|
||||
} `json:"Circuit"`
|
||||
} `json:"Meeting"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
Path string `json:"Path"`
|
||||
}
|
||||
if json.Unmarshal(data, &si) == nil {
|
||||
if si.Meeting.Name != "" {
|
||||
s.Session.MeetingName = si.Meeting.Name
|
||||
}
|
||||
if si.Meeting.Circuit.ShortName != "" {
|
||||
s.Session.CircuitName = si.Meeting.Circuit.ShortName
|
||||
}
|
||||
if si.Name != "" {
|
||||
s.Session.SessionName = si.Name
|
||||
}
|
||||
if si.Type != "" {
|
||||
s.Session.SessionType = si.Type
|
||||
}
|
||||
if si.Path != "" {
|
||||
s.Session.Path = si.Path
|
||||
}
|
||||
updated = true
|
||||
}
|
||||
case "TeamRadio":
|
||||
updated = s.updateTeamRadio(data)
|
||||
case "CurrentTyres":
|
||||
var ct map[string]json.RawMessage
|
||||
if json.Unmarshal(data, &ct) == nil {
|
||||
@@ -386,9 +445,233 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
if updated {
|
||||
if baseTopic == "Position" {
|
||||
s.positionUpdated = true
|
||||
} else {
|
||||
s.snapshotUpdated = true
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
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) {
|
||||
d, exists := drivers[num]
|
||||
if !exists {
|
||||
@@ -585,6 +868,9 @@ func indexedRawValues(raw json.RawMessage) []indexedRaw {
|
||||
fmt.Sscanf(k, "%d", &i)
|
||||
values = append(values, indexedRaw{Index: i, Raw: v})
|
||||
}
|
||||
sort.Slice(values, func(i, j int) bool {
|
||||
return values[i].Index < values[j].Index
|
||||
})
|
||||
return values
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,25 @@ type LiveSessionMeta struct {
|
||||
CircuitName string
|
||||
SessionType 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.
|
||||
@@ -126,14 +145,23 @@ type LiveStintData struct {
|
||||
Laps int
|
||||
}
|
||||
|
||||
// LiveRadioCapture is one team radio audio clip from the live timing feed.
|
||||
type LiveRadioCapture struct {
|
||||
Utc string
|
||||
RacingNumber string
|
||||
Path string
|
||||
}
|
||||
|
||||
// LiveStreamData is an immutable snapshot of all live timing state.
|
||||
type LiveStreamData struct {
|
||||
Drivers map[string]LiveDriverData
|
||||
DriverInfo map[string]F1DriverListEntry
|
||||
Tyres map[string]LiveTyreData
|
||||
Telemetry map[string]LiveTelemetryData
|
||||
RCMessages []LiveRCMessage
|
||||
Weather LiveWeatherData
|
||||
Session LiveSessionMeta
|
||||
TeamRadio []LiveRadioCapture
|
||||
TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC
|
||||
CurrentLap int
|
||||
TotalLaps int
|
||||
@@ -141,4 +169,7 @@ type LiveStreamData struct {
|
||||
ClockRefTime time.Time // UTC when Clock was accurate
|
||||
ClockExtrapolating bool // true = actively counting down
|
||||
Stints map[string][]LiveStintData
|
||||
Positions map[string]LivePositionData `json:"-"`
|
||||
PositionUpdated bool `json:"-"`
|
||||
SnapshotUpdated bool `json:"-"`
|
||||
}
|
||||
|
||||
@@ -1046,21 +1046,35 @@ type trackPoint struct {
|
||||
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 {
|
||||
CircuitKey int `json:"circuit_key"`
|
||||
Points []trackPoint `json:"points"`
|
||||
Bounds trackBounds `json:"bounds"`
|
||||
}
|
||||
|
||||
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"))
|
||||
if year == 0 {
|
||||
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)
|
||||
if !ok || len(locs) == 0 {
|
||||
@@ -1110,7 +1124,85 @@ func (s *Server) handleTrackOutline(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, trackOutlineResponse{CircuitKey: circuitKey, Points: points})
|
||||
writeJSON(w, trackOutlineResponse{
|
||||
CircuitKey: circuitKey,
|
||||
Points: points,
|
||||
Bounds: trackBounds{MinX: minX, MaxX: maxX, MinY: minY, MaxY: maxY},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) resolveCircuitKey(year int, meetingName, circuitName string) int {
|
||||
if !s.hasLocalQuery() {
|
||||
return 0
|
||||
}
|
||||
meetings, err := s.query.ListMeetingsByYear(year)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
wantMeeting := normalizeTrackIdentity(meetingName)
|
||||
wantCircuit := normalizeTrackIdentity(circuitName)
|
||||
bestScore := 0
|
||||
bestCircuitKey := 0
|
||||
for _, m := range meetings {
|
||||
if m.CircuitKey == 0 {
|
||||
continue
|
||||
}
|
||||
score := identityScore(wantMeeting, m.MeetingName, m.MeetingOfficialName, m.Location)
|
||||
score += identityScore(wantCircuit, m.CircuitShortName, m.Location, m.MeetingName)
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestCircuitKey = m.CircuitKey
|
||||
}
|
||||
}
|
||||
if bestScore == 0 {
|
||||
return 0
|
||||
}
|
||||
return bestCircuitKey
|
||||
}
|
||||
|
||||
func identityScore(want string, candidates ...string) int {
|
||||
if want == "" {
|
||||
return 0
|
||||
}
|
||||
best := 0
|
||||
for _, candidate := range candidates {
|
||||
got := normalizeTrackIdentity(candidate)
|
||||
if got == "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case got == want:
|
||||
if best < 4 {
|
||||
best = 4
|
||||
}
|
||||
case strings.Contains(got, want) || strings.Contains(want, got):
|
||||
if best < 2 {
|
||||
best = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func normalizeTrackIdentity(s string) string {
|
||||
s = strings.ToLower(s)
|
||||
replacer := strings.NewReplacer(
|
||||
"grand prix", "",
|
||||
" gp", "",
|
||||
"circuit", "",
|
||||
"autodromo", "",
|
||||
"autódromo", "",
|
||||
"international", "",
|
||||
"street", "",
|
||||
" ", "",
|
||||
"-", "",
|
||||
"_", "",
|
||||
".", "",
|
||||
",", "",
|
||||
"'", "",
|
||||
"’", "",
|
||||
)
|
||||
return strings.TrimSpace(replacer.Replace(s))
|
||||
}
|
||||
|
||||
// --- /api/v1/strategy ---
|
||||
|
||||
@@ -31,6 +31,7 @@ type SSEHub struct {
|
||||
|
||||
mu sync.RWMutex
|
||||
lastSnapshot *live.LiveStreamData
|
||||
lastPositions map[string]live.LivePositionData
|
||||
isLive bool
|
||||
}
|
||||
|
||||
@@ -52,6 +53,7 @@ func (h *SSEHub) run() {
|
||||
// Send catch-up snapshot so new clients see current state immediately.
|
||||
h.mu.RLock()
|
||||
snap := h.lastSnapshot
|
||||
positions := cloneLivePositions(h.lastPositions)
|
||||
live := h.isLive
|
||||
h.mu.RUnlock()
|
||||
if snap != nil {
|
||||
@@ -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:
|
||||
if clients[c] {
|
||||
@@ -122,6 +132,7 @@ func (s *Server) signalRLoop() {
|
||||
s.hub.mu.Lock()
|
||||
s.hub.isLive = false
|
||||
s.hub.lastSnapshot = nil
|
||||
s.hub.lastPositions = nil
|
||||
s.hub.mu.Unlock()
|
||||
|
||||
if payload, err := json.Marshal(map[string]any{"data": nil, "is_live": false}); err == nil {
|
||||
@@ -150,18 +161,34 @@ func (s *Server) connectAndDrain() error {
|
||||
idleTimeout := 60 * time.Second
|
||||
timer := time.NewTimer(idleTimeout)
|
||||
defer timer.Stop()
|
||||
lastPositionBroadcast := time.Time{}
|
||||
|
||||
for {
|
||||
select {
|
||||
case data := <-dataChan:
|
||||
now := time.Now()
|
||||
s.hub.mu.Lock()
|
||||
if data.SnapshotUpdated {
|
||||
s.hub.lastSnapshot = &data
|
||||
}
|
||||
if data.PositionUpdated && len(data.Positions) > 0 {
|
||||
s.hub.lastPositions = cloneLivePositions(data.Positions)
|
||||
}
|
||||
s.hub.isLive = true
|
||||
s.hub.mu.Unlock()
|
||||
|
||||
if data.SnapshotUpdated {
|
||||
if payload, err := json.Marshal(map[string]any{"data": data, "is_live": true}); err == nil {
|
||||
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() {
|
||||
select {
|
||||
@@ -177,6 +204,17 @@ func (s *Server) connectAndDrain() error {
|
||||
}
|
||||
}
|
||||
|
||||
func cloneLivePositions(in map[string]live.LivePositionData) map[string]live.LivePositionData {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]live.LivePositionData, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleLiveState returns the current live data snapshot as JSON.
|
||||
func (s *Server) handleLiveState(w http.ResponseWriter, r *http.Request) {
|
||||
snap, isLive := s.hub.Snapshot()
|
||||
|
||||
76
internal/web/live_trackmap_test.go
Normal file
76
internal/web/live_trackmap_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/live"
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
"github.com/AmanTahiliani/box-box/internal/store"
|
||||
)
|
||||
|
||||
func TestHandleTrackOutlineReturnsBoundsAndResolvesLiveIdentity(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.UpsertMeeting(store.Meeting{
|
||||
MeetingKey: 1234,
|
||||
MeetingName: "British Grand Prix",
|
||||
Location: "Silverstone",
|
||||
CircuitKey: 9,
|
||||
CircuitShortName: "Silverstone",
|
||||
Year: 2026,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertMeeting() error = %v", err)
|
||||
}
|
||||
srv := testServer(t, st)
|
||||
locs := []models.Location{
|
||||
{X: -100, Y: 50, Z: 0},
|
||||
{X: 0, Y: 100, Z: 0},
|
||||
{X: 100, Y: 50, Z: 0},
|
||||
}
|
||||
if err := srv.client.Cache().SetTrackOutline(9, 2026, locs); err != nil {
|
||||
t.Fatalf("SetTrackOutline() error = %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/track-outline?meeting_name=British+Grand+Prix&circuit_name=Silverstone&year=2026", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleTrackOutline(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp trackOutlineResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.CircuitKey != 9 {
|
||||
t.Fatalf("circuit_key = %d, want 9", resp.CircuitKey)
|
||||
}
|
||||
if resp.Bounds.MinX != -100 || resp.Bounds.MaxX != 100 || resp.Bounds.MinY != 50 || resp.Bounds.MaxY != 100 {
|
||||
t.Fatalf("bounds = %+v", resp.Bounds)
|
||||
}
|
||||
if len(resp.Points) != 3 {
|
||||
t.Fatalf("points len = %d, want 3", len(resp.Points))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPositionsSSEFrameShape(t *testing.T) {
|
||||
payload, err := json.Marshal(map[string]live.LivePositionData{
|
||||
"1": {X: 100, Y: -50, Z: 2, Status: "OnTrack"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal positions: %v", err)
|
||||
}
|
||||
frame := string(formatSSEFrame("positions", payload))
|
||||
if !strings.HasPrefix(frame, "event: positions\ndata: ") {
|
||||
t.Fatalf("frame prefix = %q", frame)
|
||||
}
|
||||
if !strings.Contains(frame, `"1":{"x":100,"y":-50,"z":2,"status":"OnTrack"}`) {
|
||||
t.Fatalf("frame data = %q", frame)
|
||||
}
|
||||
if !strings.HasSuffix(frame, "\n\n") {
|
||||
t.Fatalf("frame should end with blank line: %q", frame)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user