Compare commits

...

6 Commits

Author SHA1 Message Date
Aman Tahiliani
094620fa08 chore: add agentic development toolkit (#33) 2026-07-03 23:18:31 -04:00
Aman Tahiliani
7263949260 feat(live): add web track map (#32)
Subscribe to SignalR Position.z and CarData.z, decode DEFLATE payloads, stream throttled positions over SSE, and render live car dots against cached track outline bounds with tap telemetry.

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

Playwright remains out of scope for live rendering because BOXBOX_DISABLE_LIVE is used there; coverage is via parser, web handler/SSE, pure transform, and seeded component tests.
2026-07-03 19:34:09 -04:00
AmanTahiliani
5408a45bbd Refresh live sprint qualifying timing 2026-07-03 12:43:45 -04:00
AmanTahiliani
b7696c11de docs: streamline README and split guides 2026-07-03 03:11:16 -04:00
AmanTahiliani
8e3d4cea3f docs: refresh project homepage 2026-07-03 03:07:34 -04:00
AmanTahiliani
c973c03689 Show cancelled schedule status 2026-07-03 02:57:18 -04:00
63 changed files with 2971 additions and 297 deletions

49
.agents/README.md Normal file
View File

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

45
.agents/bin/dev Executable file
View File

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

52
.agents/harnesses.sh Normal file
View File

@@ -0,0 +1,52 @@
#!/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")" )
}
# ---- 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
}
harness_cursor() { # Cursor CLI agent — CONFIRM flags
local dir="$1" prompt="$2"
( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --force ) # placeholder — verify
}
# ---- build/typecheck gate (fast, local) ----
# Returns non-zero on failure. This is a smoke gate — CI runs the full suite. Tune freely.
run_gate() {
local dir="$1"
( cd "$dir" && go build ./... ) || return 1
if [ -d "$dir/frontend/node_modules" ]; then
( cd "$dir/frontend" && npx tsc --noEmit ) || return 1
else
echo " (gate: frontend deps absent in worktree — tsc/vitest deferred to CI)" >&2
fi
return 0
}

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

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

1
.claude/skills Symbolic link
View File

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

9
.gitignore vendored
View File

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

230
README.md
View File

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

198
docs/PRODUCT_ROADMAP.md Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View File

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

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

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

59
docs/testing.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,17 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { fetchLiveState } from '../api' import { fetchLiveState, fetchLiveTrackOutline } from '../api'
import type { LiveStreamData } from '../types' import type { LivePosition, LiveStreamData } from '../types'
import { import {
loadPinnedDrivers, loadPinnedDrivers,
mergeVisibleSectors,
parseLiveStateEvent, parseLiveStateEvent,
rowsWithVisibleSectors,
savePinnedDrivers, savePinnedDrivers,
sortLiveTimingRows, sortLiveTimingRows,
togglePin, togglePin,
} from '../lib/live' } from '../lib/live'
import type { VisibleSectorState } from '../lib/live'
import type { GapHistoryMap } from '../lib/gapHistory' import type { GapHistoryMap } from '../lib/gapHistory'
import { recordGapSamples } from '../lib/gapHistory' import { recordGapSamples } from '../lib/gapHistory'
import { battleNumbers, detectBattles } from '../lib/battles' import { battleNumbers, detectBattles } from '../lib/battles'
@@ -18,6 +21,7 @@ import { TimingTower } from '../components/live/TimingTower'
import { BattleChips } from '../components/live/BattleChips' import { BattleChips } from '../components/live/BattleChips'
import { PinnedDrivers } from '../components/live/PinnedDrivers' import { PinnedDrivers } from '../components/live/PinnedDrivers'
import { RaceControlFeed } from '../components/live/RaceControlFeed' import { RaceControlFeed } from '../components/live/RaceControlFeed'
import { TrackMap } from '../components/live/TrackMap'
import { Radio } from 'lucide-react' import { Radio } from 'lucide-react'
type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error' type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
@@ -29,6 +33,8 @@ export function LiveTimingPage() {
const [now, setNow] = useState(Date.now()) const [now, setNow] = useState(Date.now())
const [gapHistory, setGapHistory] = useState<GapHistoryMap>({}) const [gapHistory, setGapHistory] = useState<GapHistoryMap>({})
const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers()) const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers())
const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({})
const [positions, setPositions] = useState<Record<string, LivePosition>>({})
const { data, isLoading, isError, error } = useQuery({ const { data, isLoading, isError, error } = useQuery({
queryKey: ['live-state'], queryKey: ['live-state'],
@@ -36,6 +42,18 @@ export function LiveTimingPage() {
staleTime: 5_000, staleTime: 5_000,
}) })
const trackOutlineQuery = useQuery({
queryKey: [
'live-track-outline',
snapshot?.Session?.MeetingName ?? '',
snapshot?.Session?.CircuitName ?? '',
],
queryFn: () => fetchLiveTrackOutline(snapshot!.Session),
enabled: Boolean(snapshot?.Session?.MeetingName || snapshot?.Session?.CircuitName),
staleTime: Infinity,
retry: false,
})
useEffect(() => { useEffect(() => {
if (!data) return if (!data) return
setIsLive(data.is_live) setIsLive(data.is_live)
@@ -73,6 +91,17 @@ export function LiveTimingPage() {
if (!cancelled) setStreamStatus('connected') if (!cancelled) setStreamStatus('connected')
}) })
events.addEventListener('positions', (event) => {
if (cancelled) return
try {
const parsed = JSON.parse(event.data) as Record<string, LivePosition>
setPositions(parsed && typeof parsed === 'object' ? parsed : {})
setStreamStatus('connected')
} catch {
// Ignore malformed transient frames; the next 4Hz update will replace it.
}
})
events.onerror = () => { events.onerror = () => {
if (!cancelled) setStreamStatus('disconnected') if (!cancelled) setStreamStatus('disconnected')
} }
@@ -83,7 +112,17 @@ export function LiveTimingPage() {
} }
}, []) }, [])
const rows = useMemo(() => sortLiveTimingRows(snapshot), [snapshot]) const rawRows = useMemo(() => sortLiveTimingRows(snapshot), [snapshot])
useEffect(() => {
if (rawRows.length === 0) {
setVisibleSectors({})
return
}
setVisibleSectors((prev) => mergeVisibleSectors(prev, rawRows))
}, [rawRows])
const rows = useMemo(() => rowsWithVisibleSectors(rawRows, visibleSectors), [rawRows, visibleSectors])
// One interval sample per received snapshot, ring-buffered per driver. // One interval sample per received snapshot, ring-buffered per driver.
useEffect(() => { useEffect(() => {
@@ -143,9 +182,17 @@ export function LiveTimingPage() {
{snapshot && ( {snapshot && (
<> <>
<SessionBanner isLive={isLive} snapshot={snapshot} connection={streamStatus} now={now} /> <SessionBanner isLive={isLive} snapshot={snapshot} rows={rows} connection={streamStatus} now={now} />
<TrackStatusBanner status={snapshot.TrackStatus} /> <TrackStatusBanner status={snapshot.TrackStatus} />
<PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} /> <PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} />
<TrackMap
outline={trackOutlineQuery.data}
positions={positions}
telemetry={snapshot.Telemetry}
drivers={snapshot.Drivers}
driverInfo={snapshot.DriverInfo}
loading={trackOutlineQuery.isLoading}
/>
<div className="live-columns"> <div className="live-columns">
<div className="live-tower-col"> <div className="live-tower-col">
<div className="sec-header"> <div className="sec-header">
@@ -160,7 +207,7 @@ export function LiveTimingPage() {
battleNumbers={inBattle} battleNumbers={inBattle}
pinned={pinned} pinned={pinned}
onTogglePin={handleTogglePin} onTogglePin={handleTogglePin}
sessionType={snapshot.Session?.SessionType} session={snapshot.Session}
/> />
</div> </div>
<div className="live-rc-col"> <div className="live-rc-col">

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,10 +4,13 @@ import {
compoundLetter, compoundLetter,
extrapolateClock, extrapolateClock,
latestRaceControl, latestRaceControl,
liveSessionDisplay,
loadPinnedDrivers, loadPinnedDrivers,
mergeVisibleSectors,
positionDeltaClass, positionDeltaClass,
parseLiveStateEvent, parseLiveStateEvent,
rcFlagClass, rcFlagClass,
rowsWithVisibleSectors,
savePinnedDrivers, savePinnedDrivers,
sortLiveTimingRows, sortLiveTimingRows,
togglePin, togglePin,
@@ -17,7 +20,8 @@ import {
tyreLabel, tyreLabel,
windDirectionLabel, windDirectionLabel,
} from '../lib/live' } from '../lib/live'
import type { LiveStreamData } from '../types' import type { LiveDriverData, LiveSectorData, LiveStreamData } from '../types'
import type { LiveTimingRow } from '../lib/live'
const snapshot: LiveStreamData = { const snapshot: LiveStreamData = {
Drivers: { Drivers: {
@@ -106,6 +110,51 @@ const snapshot: LiveStreamData = {
Stints: {}, Stints: {},
} }
function sector(value: string, over: Partial<LiveSectorData> = {}): LiveSectorData {
return { Value: value, PersonalFastest: false, OverallFastest: false, ...over }
}
function timingRow(
number: string,
position: number,
driver: Partial<LiveDriverData> = {},
): LiveTimingRow {
return {
RacingNumber: number,
Position: position,
Driver: {
RacingNumber: number,
Position: position,
PrevPosition: position,
GapToLeader: '',
Interval: '',
LastLapTime: '',
LastLapPB: false,
LastLapOB: false,
BestLapTime: '',
BestLapPB: false,
BestLapOB: false,
BestLapNum: 0,
InPit: false,
PitOut: false,
Retired: false,
KnockedOut: false,
Cutoff: false,
OnFlyingLap: false,
NumberOfLaps: 0,
SpeedTrap: '',
Sectors: [],
...driver,
},
}
}
function rows(count: number, knockedOut = 0): LiveTimingRow[] {
return Array.from({ length: count }, (_, index) =>
timingRow(String(index + 1), index + 1, { KnockedOut: index >= count - knockedOut }),
)
}
describe('live transforms', () => { describe('live transforms', () => {
it('parses live EventSource snapshots without changing PascalCase data', () => { it('parses live EventSource snapshots without changing PascalCase data', () => {
const parsed = parseLiveStateEvent(JSON.stringify({ is_live: true, data: snapshot })) const parsed = parseLiveStateEvent(JSON.stringify({ is_live: true, data: snapshot }))
@@ -168,6 +217,91 @@ describe('track status mapping', () => {
}) })
}) })
describe('live qualifying display', () => {
it('puts the SQ1 cutoff after P17 for a 22-car sprint qualifying session', () => {
const display = liveSessionDisplay(
{ MeetingName: 'British Grand Prix', CircuitName: 'Silverstone', SessionType: 'Sprint Qualifying', SessionName: 'Sprint Qualifying' },
rows(22),
)
expect(display.phaseLabel).toBe('SQ1')
expect(display.cutoffPosition).toBe(17)
expect(display.advanceCount).toBe(17)
expect(display.atRiskStart).toBe(18)
expect(display.atRiskEnd).toBe(22)
})
it('keeps the normal Q1 cutoff after P15 for a 20-car qualifying session', () => {
const display = liveSessionDisplay(
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying' },
rows(20),
)
expect(display.phaseLabel).toBe('Q1')
expect(display.cutoffPosition).toBe(15)
})
it('moves phase 2 cutoff after P10 once five cars are knocked out', () => {
const display = liveSessionDisplay(
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying' },
rows(20, 5),
)
expect(display.phaseLabel).toBe('Q2')
expect(display.cutoffPosition).toBe(10)
})
it('shows no cutoff for race sessions or Q3', () => {
expect(
liveSessionDisplay(
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race' },
rows(20),
).cutoffPosition,
).toBeNull()
expect(
liveSessionDisplay(
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Q3' },
rows(10),
).cutoffPosition,
).toBeNull()
})
})
describe('visible sector display', () => {
it('holds S1 and S2 through temporary blanks while a flying lap is active', () => {
const first = [timingRow('4', 1, {
NumberOfLaps: 3,
OnFlyingLap: true,
Sectors: [sector('29.111'), sector('41.222'), sector('')],
})]
const held = mergeVisibleSectors({}, first)
const blank = [timingRow('4', 1, {
NumberOfLaps: 3,
OnFlyingLap: false,
Sectors: [sector(''), sector(''), sector('')],
})]
const next = mergeVisibleSectors(held, blank)
const visibleRows = rowsWithVisibleSectors(blank, next)
expect(visibleRows[0].Driver.Sectors[0].Value).toBe('29.111')
expect(visibleRows[0].Driver.Sectors[1].Value).toBe('41.222')
})
it('clears held sectors after the lap completes and the feed goes blank', () => {
const first = mergeVisibleSectors({}, [timingRow('4', 1, {
NumberOfLaps: 3,
LastLapTime: '1:30.000',
OnFlyingLap: true,
Sectors: [sector('29.111'), sector('41.222'), sector('20.333')],
})])
const next = mergeVisibleSectors(first, [timingRow('4', 1, {
NumberOfLaps: 4,
LastLapTime: '1:30.000',
OnFlyingLap: false,
Sectors: [sector(''), sector(''), sector('')],
})])
expect(next['4']).toBeUndefined()
})
})
describe('weather and stint helpers', () => { describe('weather and stint helpers', () => {
it('maps wind direction degrees to compass points', () => { it('maps wind direction degrees to compass points', () => {
expect(windDirectionLabel(0)).toBe('N') expect(windDirectionLabel(0)).toBe('N')

View File

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

View File

@@ -16,6 +16,7 @@ export interface Meeting {
date_start: string date_start: string
date_end: string date_end: string
year: number year: number
is_cancelled?: boolean
} }
export interface Session { export interface Session {
@@ -26,6 +27,7 @@ export interface Session {
date_start: string date_start: string
date_end: string date_end: string
gmt_offset: string gmt_offset: string
is_cancelled?: boolean
} }
export interface Driver { export interface Driver {
@@ -73,7 +75,7 @@ export interface EnrichedGrid {
} }
export interface RaceHub { export interface RaceHub {
source: 'local' | 'partial' | 'none' source: 'local' | 'partial' | 'none' | 'cancelled'
session_key: number session_key: number
datasets: Record<string, DatasetInfo> datasets: Record<string, DatasetInfo>
meeting?: Meeting meeting?: Meeting
@@ -158,12 +160,12 @@ export interface Lap {
export interface WeekendSession { export interface WeekendSession {
session: Session session: Session
source: 'local' | 'partial' | 'none' source: 'local' | 'partial' | 'none' | 'cancelled'
datasets: Record<string, DatasetInfo> datasets: Record<string, DatasetInfo>
} }
export interface Weekend { export interface Weekend {
source: 'local' | 'partial' | 'none' source: 'local' | 'partial' | 'none' | 'cancelled'
meeting_key: number meeting_key: number
meeting: Meeting meeting: Meeting
sessions: WeekendSession[] sessions: WeekendSession[]
@@ -175,6 +177,22 @@ export interface LiveStateResponse {
data: LiveStreamData | null data: LiveStreamData | null
} }
export interface LivePosition {
x: number
y: number
z: number
status: string
}
export interface LiveTelemetry {
Speed: number
Throttle: number
Brake: number
DRS: number
NGear: number
RPM: number
}
export interface LiveSectorData { export interface LiveSectorData {
Value: string Value: string
PersonalFastest: boolean PersonalFastest: boolean
@@ -255,6 +273,7 @@ export interface LiveStreamData {
Drivers: Record<string, LiveDriverData> Drivers: Record<string, LiveDriverData>
DriverInfo: Record<string, LiveDriverInfo> DriverInfo: Record<string, LiveDriverInfo>
Tyres: Record<string, LiveTyreData> Tyres: Record<string, LiveTyreData>
Telemetry?: Record<string, LiveTelemetry>
RCMessages: LiveRCMessage[] RCMessages: LiveRCMessage[]
Weather: LiveWeatherData Weather: LiveWeatherData
Session: LiveSessionMeta Session: LiveSessionMeta
@@ -267,6 +286,24 @@ export interface LiveStreamData {
Stints: Record<string, LiveStintData[]> Stints: Record<string, LiveStintData[]>
} }
export interface TrackPoint {
x: number
y: number
}
export interface TrackBounds {
minX: number
maxX: number
minY: number
maxY: number
}
export interface TrackOutline {
circuit_key: number
points: TrackPoint[]
bounds: TrackBounds
}
export interface ChampHubDriver { export interface ChampHubDriver {
driver_number: number driver_number: number
name_acronym: string name_acronym: string

View File

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

View File

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

View File

@@ -1,6 +1,9 @@
package live_test package live_test
import ( import (
"bytes"
"compress/flate"
"encoding/base64"
"encoding/json" "encoding/json"
"testing" "testing"
"time" "time"
@@ -158,6 +161,64 @@ 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"
}`))
s := state.Snapshot().Session
if s.MeetingName != "British Grand Prix" || s.CircuitName != "Silverstone" {
t.Fatalf("session = %+v", s)
}
}
func TestProcessTopicDriverList(t *testing.T) { func TestProcessTopicDriverList(t *testing.T) {
state := live.NewState() state := live.NewState()
data := json.RawMessage(`{"63": {"RacingNumber": "63", "Tla": "RUS", "TeamName": "Mercedes", "TeamColour": "27F4D2"}}`) data := json.RawMessage(`{"63": {"RacingNumber": "63", "Tla": "RUS", "TeamName": "Mercedes", "TeamColour": "27F4D2"}}`)
@@ -356,3 +417,23 @@ func TestProcessMessageEmptyPayload(t *testing.T) {
t.Error("empty envelope should not update state") t.Error("empty envelope should not update state")
} }
} }
func encodedDeflatePayload(t *testing.T, payload string) json.RawMessage {
t.Helper()
var buf bytes.Buffer
w, err := flate.NewWriter(&buf, flate.DefaultCompression)
if err != nil {
t.Fatalf("flate.NewWriter() error = %v", err)
}
if _, err := w.Write([]byte(payload)); err != nil {
t.Fatalf("flate write error = %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("flate close error = %v", err)
}
raw, err := json.Marshal(base64.StdEncoding.EncodeToString(buf.Bytes()))
if err != nil {
t.Fatalf("marshal payload error = %v", err)
}
return raw
}

View File

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

View File

@@ -1,8 +1,14 @@
package live package live
import ( import (
"bytes"
"compress/flate"
"compress/zlib"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"strings"
"time" "time"
) )
@@ -11,6 +17,8 @@ type State struct {
Drivers map[string]LiveDriverData Drivers map[string]LiveDriverData
DriverInfo map[string]F1DriverListEntry DriverInfo map[string]F1DriverListEntry
Tyres map[string]LiveTyreData Tyres map[string]LiveTyreData
Telemetry map[string]LiveTelemetryData
Positions map[string]LivePositionData
Stints map[string][]LiveStintData Stints map[string][]LiveStintData
RCMessages []LiveRCMessage RCMessages []LiveRCMessage
Weather LiveWeatherData Weather LiveWeatherData
@@ -21,6 +29,8 @@ type State struct {
Clock string Clock string
ClockRefTime time.Time ClockRefTime time.Time
ClockExtrapolating bool ClockExtrapolating bool
positionUpdated bool
snapshotUpdated bool
} }
const signalRRecordSeparator = byte(0x1e) const signalRRecordSeparator = byte(0x1e)
@@ -31,6 +41,8 @@ func NewState() *State {
Drivers: make(map[string]LiveDriverData), Drivers: make(map[string]LiveDriverData),
DriverInfo: make(map[string]F1DriverListEntry), DriverInfo: make(map[string]F1DriverListEntry),
Tyres: make(map[string]LiveTyreData), Tyres: make(map[string]LiveTyreData),
Telemetry: make(map[string]LiveTelemetryData),
Positions: make(map[string]LivePositionData),
Stints: make(map[string][]LiveStintData), Stints: make(map[string][]LiveStintData),
} }
} }
@@ -49,6 +61,14 @@ func (s *State) Snapshot() LiveStreamData {
for k, v := range s.Tyres { for k, v := range s.Tyres {
cpyTyres[k] = v cpyTyres[k] = v
} }
cpyTelemetry := make(map[string]LiveTelemetryData, len(s.Telemetry))
for k, v := range s.Telemetry {
cpyTelemetry[k] = v
}
cpyPositions := make(map[string]LivePositionData, len(s.Positions))
for k, v := range s.Positions {
cpyPositions[k] = v
}
cpyRC := make([]LiveRCMessage, len(s.RCMessages)) cpyRC := make([]LiveRCMessage, len(s.RCMessages))
copy(cpyRC, s.RCMessages) copy(cpyRC, s.RCMessages)
cpyStints := make(map[string][]LiveStintData, len(s.Stints)) cpyStints := make(map[string][]LiveStintData, len(s.Stints))
@@ -62,6 +82,7 @@ func (s *State) Snapshot() LiveStreamData {
Drivers: cpyDrivers, Drivers: cpyDrivers,
DriverInfo: cpyInfo, DriverInfo: cpyInfo,
Tyres: cpyTyres, Tyres: cpyTyres,
Telemetry: cpyTelemetry,
RCMessages: cpyRC, RCMessages: cpyRC,
Weather: s.Weather, Weather: s.Weather,
Session: s.Session, Session: s.Session,
@@ -72,11 +93,16 @@ func (s *State) Snapshot() LiveStreamData {
ClockRefTime: s.ClockRefTime, ClockRefTime: s.ClockRefTime,
ClockExtrapolating: s.ClockExtrapolating, ClockExtrapolating: s.ClockExtrapolating,
Stints: cpyStints, Stints: cpyStints,
Positions: cpyPositions,
PositionUpdated: s.positionUpdated,
SnapshotUpdated: s.snapshotUpdated,
} }
} }
// ProcessMessage parses a raw SignalR WebSocket frame and applies any updates. // ProcessMessage parses a raw SignalR WebSocket frame and applies any updates.
func (s *State) ProcessMessage(message []byte) bool { func (s *State) ProcessMessage(message []byte) bool {
s.clearTransientFlags()
var parsed F1SignalRMessage var parsed F1SignalRMessage
if err := json.Unmarshal(message, &parsed); err != nil { if err := json.Unmarshal(message, &parsed); err != nil {
return false return false
@@ -111,6 +137,8 @@ func (s *State) ProcessMessage(message []byte) bool {
// ProcessCoreMessage parses one or more SignalR Core JSON frames and applies // ProcessCoreMessage parses one or more SignalR Core JSON frames and applies
// completion snapshots and feed deltas from the current official F1 live timing hub. // completion snapshots and feed deltas from the current official F1 live timing hub.
func (s *State) ProcessCoreMessage(message []byte) bool { func (s *State) ProcessCoreMessage(message []byte) bool {
s.clearTransientFlags()
updated := false updated := false
for _, frame := range splitSignalRFrames(message) { for _, frame := range splitSignalRFrames(message) {
var envelope struct { var envelope struct {
@@ -156,7 +184,15 @@ func (s *State) ProcessCoreMessage(message []byte) bool {
// ProcessTopic applies a single topic payload to the accumulator. // ProcessTopic applies a single topic payload to the accumulator.
func (s *State) ProcessTopic(topic string, data json.RawMessage) bool { func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
updated := false updated := false
switch topic { baseTopic := strings.TrimSuffix(topic, ".z")
if topic != baseTopic {
var ok bool
data, ok = inflateTopicPayload(data)
if !ok {
return false
}
}
switch baseTopic {
case "TimingData": case "TimingData":
var td struct { var td struct {
Lines map[string]json.RawMessage `json:"Lines"` Lines map[string]json.RawMessage `json:"Lines"`
@@ -170,6 +206,10 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
} }
} }
} }
case "Position":
updated = s.updatePositions(data)
case "CarData":
updated = s.updateTelemetry(data)
case "DriverList": case "DriverList":
var dlMap map[string]json.RawMessage var dlMap map[string]json.RawMessage
if json.Unmarshal(data, &dlMap) == nil { if json.Unmarshal(data, &dlMap) == nil {
@@ -288,7 +328,10 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
case "SessionInfo": case "SessionInfo":
var si struct { var si struct {
Meeting struct { Meeting struct {
Name string `json:"Name"` Name string `json:"Name"`
Circuit struct {
ShortName string `json:"ShortName"`
} `json:"Circuit"`
} `json:"Meeting"` } `json:"Meeting"`
Name string `json:"Name"` Name string `json:"Name"`
Type string `json:"Type"` Type string `json:"Type"`
@@ -297,6 +340,9 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
if si.Meeting.Name != "" { if si.Meeting.Name != "" {
s.Session.MeetingName = si.Meeting.Name s.Session.MeetingName = si.Meeting.Name
} }
if si.Meeting.Circuit.ShortName != "" {
s.Session.CircuitName = si.Meeting.Circuit.ShortName
}
if si.Name != "" { if si.Name != "" {
s.Session.SessionName = si.Name s.Session.SessionName = si.Name
} }
@@ -386,9 +432,177 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
} }
} }
} }
if updated {
if baseTopic == "Position" {
s.positionUpdated = true
} else {
s.snapshotUpdated = true
}
}
return updated return updated
} }
func (s *State) clearTransientFlags() {
s.positionUpdated = false
s.snapshotUpdated = false
}
func inflateTopicPayload(data json.RawMessage) (json.RawMessage, bool) {
var encoded string
if err := json.Unmarshal(data, &encoded); err != nil {
var wrapper struct {
Z string `json:"z"`
}
if json.Unmarshal(data, &wrapper) != nil || wrapper.Z == "" {
return nil, false
}
encoded = wrapper.Z
}
compressed, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, false
}
if inflated, ok := readCompressed(zlib.NewReader(bytes.NewReader(compressed))); ok {
return json.RawMessage(inflated), true
}
if inflated, ok := readCompressed(func() (io.ReadCloser, error) {
return flate.NewReader(bytes.NewReader(compressed)), nil
}()); ok {
return json.RawMessage(inflated), true
}
return nil, false
}
func readCompressed(r io.ReadCloser, err error) ([]byte, bool) {
if err != nil {
return nil, false
}
defer r.Close()
out, err := io.ReadAll(r)
return out, err == nil
}
func (s *State) updatePositions(data json.RawMessage) bool {
var payload struct {
Position json.RawMessage `json:"Position"`
}
if json.Unmarshal(data, &payload) != nil || len(payload.Position) == 0 {
return false
}
updated := false
for _, sampleRaw := range indexedRawValues(payload.Position) {
var sample struct {
Entries map[string]struct {
Status string `json:"Status"`
X json.Number `json:"X"`
Y json.Number `json:"Y"`
Z json.Number `json:"Z"`
} `json:"Entries"`
}
if json.Unmarshal(sampleRaw.Raw, &sample) != nil {
continue
}
for num, entry := range sample.Entries {
x, okX := numberToFloat(entry.X)
y, okY := numberToFloat(entry.Y)
z, okZ := numberToFloat(entry.Z)
if !okX || !okY {
continue
}
if !okZ {
z = 0
}
s.Positions[num] = LivePositionData{
X: x,
Y: y,
Z: z,
Status: entry.Status,
}
updated = true
}
}
return updated
}
func (s *State) updateTelemetry(data json.RawMessage) bool {
var payload struct {
Entries json.RawMessage `json:"Entries"`
}
if json.Unmarshal(data, &payload) != nil || len(payload.Entries) == 0 {
return false
}
updated := false
for _, entryRaw := range indexedRawValues(payload.Entries) {
var entry struct {
Cars map[string]struct {
Channels map[string]json.RawMessage `json:"Channels"`
} `json:"Cars"`
}
if json.Unmarshal(entryRaw.Raw, &entry) != nil {
continue
}
for num, car := range entry.Cars {
t := s.Telemetry[num]
if v, ok := channelInt(car.Channels, "0"); ok {
t.RPM = v
}
if v, ok := channelInt(car.Channels, "2"); ok {
t.Speed = v
}
if v, ok := channelInt(car.Channels, "3"); ok {
t.NGear = v
}
if v, ok := channelInt(car.Channels, "4"); ok {
t.Throttle = v
}
if v, ok := channelInt(car.Channels, "5"); ok {
t.Brake = v
}
if v, ok := channelInt(car.Channels, "45"); ok {
t.DRS = v
}
s.Telemetry[num] = t
updated = true
}
}
return updated
}
func channelInt(channels map[string]json.RawMessage, key string) (int, bool) {
raw, ok := channels[key]
if !ok {
return 0, false
}
var n json.Number
if json.Unmarshal(raw, &n) == nil {
if i, err := n.Int64(); err == nil {
return int(i), true
}
if f, err := n.Float64(); err == nil {
return int(f), true
}
}
var s string
if json.Unmarshal(raw, &s) == nil {
var i int
if _, err := fmt.Sscanf(s, "%d", &i); err == nil {
return i, true
}
}
return 0, false
}
func numberToFloat(n json.Number) (float64, bool) {
if n == "" {
return 0, false
}
v, err := n.Float64()
return v, err == nil
}
func updateDriver(drivers map[string]LiveDriverData, num string, line F1TimingLine) { func updateDriver(drivers map[string]LiveDriverData, num string, line F1TimingLine) {
d, exists := drivers[num] d, exists := drivers[num]
if !exists { if !exists {

View File

@@ -87,6 +87,24 @@ type LiveSessionMeta struct {
SessionName string SessionName string
} }
// LivePositionData is the latest raw F1 GPS position for one driver.
type LivePositionData struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
Status string `json:"status"`
}
// LiveTelemetryData is the latest car telemetry for one driver.
type LiveTelemetryData struct {
Speed int `json:"Speed"`
Throttle int `json:"Throttle"`
Brake int `json:"Brake"`
DRS int `json:"DRS"`
NGear int `json:"NGear"`
RPM int `json:"RPM"`
}
// LiveSectorData holds a single sector time and flags. // LiveSectorData holds a single sector time and flags.
type LiveSectorData struct { type LiveSectorData struct {
Value string Value string
@@ -131,6 +149,7 @@ type LiveStreamData struct {
Drivers map[string]LiveDriverData Drivers map[string]LiveDriverData
DriverInfo map[string]F1DriverListEntry DriverInfo map[string]F1DriverListEntry
Tyres map[string]LiveTyreData Tyres map[string]LiveTyreData
Telemetry map[string]LiveTelemetryData
RCMessages []LiveRCMessage RCMessages []LiveRCMessage
Weather LiveWeatherData Weather LiveWeatherData
Session LiveSessionMeta Session LiveSessionMeta
@@ -141,4 +160,7 @@ type LiveStreamData struct {
ClockRefTime time.Time // UTC when Clock was accurate ClockRefTime time.Time // UTC when Clock was accurate
ClockExtrapolating bool // true = actively counting down ClockExtrapolating bool // true = actively counting down
Stints map[string][]LiveStintData Stints map[string][]LiveStintData
Positions map[string]LivePositionData `json:"-"`
PositionUpdated bool `json:"-"`
SnapshotUpdated bool `json:"-"`
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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