mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
Compare commits
84 Commits
161e871c53
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0fd252096 | ||
|
|
cc4337be88 | ||
|
|
addfd6d24d | ||
|
|
b5d070e116 | ||
|
|
233eefaf12 | ||
|
|
51b0238b09 | ||
|
|
24bcac8038 | ||
|
|
93ac0ccebb | ||
|
|
66bf649729 | ||
|
|
2ddc930efe | ||
|
|
7d0b25b66e | ||
|
|
bc18a20829 | ||
|
|
2795ccf11b | ||
|
|
4813b958a0 | ||
|
|
a97e3caa4f | ||
|
|
655df9845b | ||
|
|
2d49a8bd00 | ||
|
|
4e62e93ea2 | ||
|
|
eacd5f26e9 | ||
|
|
ba7f653e3c | ||
|
|
f2bf1fd0fc | ||
|
|
6c72c12821 | ||
|
|
98126a06f9 | ||
|
|
47f0cd6d12 | ||
|
|
26642e3ff6 | ||
|
|
0977861806 | ||
|
|
c5a7ad51eb | ||
|
|
75ca5f4deb | ||
|
|
094620fa08 | ||
|
|
7263949260 | ||
|
|
5408a45bbd | ||
|
|
b7696c11de | ||
|
|
8e3d4cea3f | ||
|
|
c973c03689 | ||
|
|
85a8b6ad44 | ||
|
|
cc5184c4b6 | ||
|
|
8d5ab4d7a7 | ||
|
|
c3ad7743e6 | ||
|
|
127628e4dd | ||
|
|
c989aace10 | ||
|
|
3c6115e815 | ||
|
|
7f0c338a57 | ||
|
|
5cfaed30ba | ||
|
|
e5cb4e4ed2 | ||
|
|
c708b6697d | ||
|
|
7a5e0a323d | ||
|
|
282b5a8c24 | ||
|
|
2220d5d30a | ||
|
|
91e2d13ab7 | ||
|
|
024accfb3d | ||
|
|
a2aa772838 | ||
|
|
3eb74a9083 | ||
|
|
e3453de788 | ||
|
|
e060bcba24 | ||
|
|
937674f808 | ||
|
|
bc1c551a82 | ||
|
|
c25822dee0 | ||
|
|
c172bb59b1 | ||
|
|
7ce2b1ace4 | ||
|
|
3bd169c55c | ||
|
|
ee88a07aa1 | ||
|
|
84a8827244 | ||
|
|
79b0b9f469 | ||
|
|
a0f135aac1 | ||
|
|
9532206522 | ||
|
|
e539abcc5f | ||
|
|
571edb9b5a | ||
|
|
10729a906c | ||
|
|
17c94d83ad | ||
|
|
404029ff24 | ||
|
|
e410a01db1 | ||
|
|
5eb0983ffb | ||
|
|
95060b07a5 | ||
|
|
0992fc03e8 | ||
|
|
f2339a00a9 | ||
|
|
2c9db0213c | ||
|
|
1661f8dec3 | ||
|
|
b5d87775a6 | ||
|
|
dfd0cf7c12 | ||
|
|
c7438f0ed8 | ||
|
|
5470b0df38 | ||
|
|
32d500f5af | ||
|
|
e08255db70 | ||
|
|
517c6b987b |
49
.agents/README.md
Normal file
49
.agents/README.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# `.agents/` — harness-agnostic agentic dev toolkit
|
||||||
|
|
||||||
|
Portable skills, prompts, and scripts that drive the box-box development lifecycle.
|
||||||
|
Any harness (Claude, Codex, opencode, …) can read these — the canonical workflows
|
||||||
|
live here, not in a tool-specific folder. The shared project context every harness
|
||||||
|
reads is `AGENTS.md` (→ `CLAUDE.md`).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
skills/ Codex/open agent skills: groom, write-spec, implement, review, lenses
|
||||||
|
personas/ grill.md (base) + lens overlays (architect, …) — the interrogation voices
|
||||||
|
prompts/ ready-spec.md (groomed spec), implement.md/review.md dispatch prompts
|
||||||
|
lib/gh.sh GitHub issue + Project (#2) state helpers: issue_*, set_stage/effort/priority
|
||||||
|
lib/dispatch.sh dispatch(): Ready issue → worktree → harness → gate → PR
|
||||||
|
harnesses.sh headless adapters (one fn per harness) + run_gate — the ONLY tool-specific code
|
||||||
|
bin/dev CLI: `dev implement <issue#> --harness <name> [--dry-run]`
|
||||||
|
```
|
||||||
|
|
||||||
|
## The lifecycle
|
||||||
|
|
||||||
|
`Icebox → Research → Ready → In Progress → In Review → Done` (the Project `Stage` field).
|
||||||
|
|
||||||
|
- **Groom** (interactive, Claude): `/groom <issue#>` runs a seeded grill-me → writes a
|
||||||
|
Ready spec into the issue body → sets Effort/Priority → leaves Stage at `Research`.
|
||||||
|
You review and flip to `Ready`.
|
||||||
|
- **Implement** (any harness): `.agents/bin/dev implement <issue#> --harness <name>`
|
||||||
|
(or `/implement …` in Claude to supervise) → isolated worktree → runs the harness
|
||||||
|
headless on the spec → build gate → opens a PR → sets Stage `In Review`.
|
||||||
|
- **Review + merge**: use the `review` skill from a harness different from the
|
||||||
|
implementer to create a local review packet and PR comment, then you merge.
|
||||||
|
|
||||||
|
## Skills and harnesses
|
||||||
|
|
||||||
|
`.agents/skills` is the canonical home for reusable workflows. Codex discovers
|
||||||
|
repo skills from that path directly, and Claude can use the same files through
|
||||||
|
`.claude/skills -> ../.agents/skills`. Other harnesses can read the same
|
||||||
|
`SKILL.md` files explicitly or enter the workflow through `.agents/bin/dev`.
|
||||||
|
Do not put canonical workflow instructions under `.claude/`; that directory is
|
||||||
|
local adapter state.
|
||||||
|
|
||||||
|
## Adding / fixing a harness
|
||||||
|
|
||||||
|
Edit one function in `harnesses.sh`: `harness_<name> <workdir> <promptfile>`, running the
|
||||||
|
tool non-interactively in `<workdir>` on the prompt. `claude`/`codex`/`opencode` are
|
||||||
|
wired; `pi`/`cursor` are stubs — confirm their headless flags before trusting.
|
||||||
|
|
||||||
|
Always `--dry-run` a new harness first: it renders the exact prompt and plan, touching
|
||||||
|
nothing (no worktree, PR, or state change).
|
||||||
45
.agents/bin/dev
Executable file
45
.agents/bin/dev
Executable file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# .agents/bin/dev — box-box agentic dev CLI. Works from any harness's shell.
|
||||||
|
#
|
||||||
|
# dev implement <issue#> --harness <claude|codex|opencode|pi|cursor> [--dry-run] [--base <branch>]
|
||||||
|
#
|
||||||
|
# Grooming is driven interactively via the Claude Code /groom skill; this CLI covers
|
||||||
|
# the implement lane (dispatch a Ready issue to a harness → worktree → gate → PR).
|
||||||
|
|
||||||
|
set -o pipefail
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../.." && pwd)"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat >&2 <<EOF
|
||||||
|
box-box dev CLI
|
||||||
|
|
||||||
|
dev implement <issue#> --harness <name> [--dry-run] [--base <branch>]
|
||||||
|
|
||||||
|
harnesses: claude, codex, opencode (supported) · pi, cursor (verify flags in .agents/harnesses.sh)
|
||||||
|
--dry-run render the prompt + plan, touch nothing (no worktree/PR/state change)
|
||||||
|
--base base branch for the worktree/PR (default: main)
|
||||||
|
EOF
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd="${1:-}"; shift 2>/dev/null || true
|
||||||
|
case "$cmd" in
|
||||||
|
implement)
|
||||||
|
issue="${1:-}"; shift 2>/dev/null || true
|
||||||
|
harness=""; passthru=()
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--harness) harness="${2:-}"; shift 2 ;;
|
||||||
|
--dry-run) passthru+=(--dry-run); shift ;;
|
||||||
|
--base) passthru+=(--base "${2:-}"); shift 2 ;;
|
||||||
|
*) echo "unknown arg: $1" >&2; usage ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ -n "$issue" ] && [ -n "$harness" ] || usage
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$ROOT/.agents/lib/dispatch.sh"
|
||||||
|
dispatch "$issue" "$harness" ${passthru[@]+"${passthru[@]}"}
|
||||||
|
;;
|
||||||
|
""|-h|--help|help) usage ;;
|
||||||
|
*) echo "unknown command: $cmd" >&2; usage ;;
|
||||||
|
esac
|
||||||
62
.agents/harnesses.sh
Normal file
62
.agents/harnesses.sh
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# .agents/harnesses.sh — headless harness adapters.
|
||||||
|
#
|
||||||
|
# ONE function per harness: harness_<name> <workdir> <promptfile>
|
||||||
|
# Each runs the harness NON-INTERACTIVELY inside <workdir>, feeding it the rendered
|
||||||
|
# prompt, expected to edit files (and ideally commit). This is the ONLY harness-specific
|
||||||
|
# code in the toolkit — tune the exact flags here per tool/version.
|
||||||
|
#
|
||||||
|
# Autonomy note: these run unattended, so each uses the tool's "just do it" mode
|
||||||
|
# (accept edits / full-auto). Only run harnesses you trust on code you can review via PR.
|
||||||
|
|
||||||
|
# ---- MUST-HAVE ----
|
||||||
|
|
||||||
|
harness_claude() { # Claude Code — print mode, auto-accept edits
|
||||||
|
local dir="$1" prompt="$2"
|
||||||
|
( cd "$dir" && claude -p "$(cat "$prompt")" --permission-mode acceptEdits )
|
||||||
|
}
|
||||||
|
|
||||||
|
harness_codex() { # OpenAI Codex CLI — non-interactive exec, full auto
|
||||||
|
local dir="$1" prompt="$2"
|
||||||
|
( cd "$dir" && codex exec --full-auto "$(cat "$prompt")" )
|
||||||
|
}
|
||||||
|
|
||||||
|
harness_opencode() { # opencode — non-interactive run
|
||||||
|
local dir="$1" prompt="$2"
|
||||||
|
( cd "$dir" && opencode run "$(cat "$prompt")" )
|
||||||
|
}
|
||||||
|
|
||||||
|
harness_cursor() { # Cursor CLI agent — composer-2.5, headless full-auto
|
||||||
|
local dir="$1" prompt="$2"
|
||||||
|
( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --model composer-2.5 --force --trust )
|
||||||
|
}
|
||||||
|
|
||||||
|
harness_agy() { # Antigravity CLI — UNRELIABLE headless (2026-07): with
|
||||||
|
# --new-project it ignores the prompt and asks to scaffold a project; without it, it
|
||||||
|
# resumes the previous conversation (silently keeping its old model — --model only
|
||||||
|
# applies to new conversations) and can hang past the print timeout. Do not trust for
|
||||||
|
# dispatch until fixed upstream; verify with a trivial prompt first.
|
||||||
|
local dir="$1" prompt="$2"
|
||||||
|
( cd "$dir" && agy --print --print-timeout 60m \
|
||||||
|
--model="Gemini 3.1 Pro (High)" --dangerously-skip-permissions "$(cat "$prompt")" )
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- NICE-TO-HAVE (verify the exact invocation for your version before trusting) ----
|
||||||
|
|
||||||
|
harness_pi() { # pi — CONFIRM headless CLI + flags
|
||||||
|
local dir="$1" prompt="$2"
|
||||||
|
( cd "$dir" && pi run "$(cat "$prompt")" ) # placeholder — verify
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- build/typecheck gate (fast, local) ----
|
||||||
|
# Returns non-zero on failure. This is a smoke gate — CI runs the full suite. Tune freely.
|
||||||
|
run_gate() {
|
||||||
|
local dir="$1"
|
||||||
|
( cd "$dir" && go build ./... ) || return 1
|
||||||
|
if [ -d "$dir/frontend/node_modules" ]; then
|
||||||
|
( cd "$dir/frontend" && npx tsc --noEmit ) || return 1
|
||||||
|
else
|
||||||
|
echo " (gate: frontend deps absent in worktree — tsc/vitest deferred to CI)" >&2
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
121
.agents/lib/dispatch.sh
Normal file
121
.agents/lib/dispatch.sh
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# .agents/lib/dispatch.sh — implement a Ready issue with a chosen harness in an
|
||||||
|
# isolated git worktree, run the build gate, and open a PR.
|
||||||
|
#
|
||||||
|
# Source it, then: dispatch <issue#> <harness> [--dry-run] [--base <branch>]
|
||||||
|
# (or use the CLI: .agents/bin/dev implement <issue#> --harness <name> [--dry-run])
|
||||||
|
|
||||||
|
_AGENTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$_AGENTS_DIR/lib/gh.sh"
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$_AGENTS_DIR/harnesses.sh"
|
||||||
|
|
||||||
|
_slug() { echo "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//' | cut -c1-40; }
|
||||||
|
|
||||||
|
_render_prompt() { # <issue#> <title> <body>
|
||||||
|
echo "# Implement: $2"
|
||||||
|
echo
|
||||||
|
cat "$_AGENTS_DIR/prompts/implement.md"
|
||||||
|
echo; echo "---"; echo
|
||||||
|
echo "## Spec — issue #$1"
|
||||||
|
echo
|
||||||
|
echo "$3"
|
||||||
|
}
|
||||||
|
|
||||||
|
_pr_body() { # <issue#> <harness> <gate>
|
||||||
|
cat <<EOF
|
||||||
|
Implements #$1.
|
||||||
|
|
||||||
|
- **Harness:** $2 (dispatched via \`.agents/bin/dev\`)
|
||||||
|
- **Local gate** (\`go build\` + \`tsc --noEmit\`): **$3**
|
||||||
|
- Full test suite + independent review run in CI / by a reviewer harness.
|
||||||
|
|
||||||
|
See #$1 for the groomed spec, Test Plan, and Definition of Done.
|
||||||
|
|
||||||
|
Closes #$1
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch() { # <issue#> <harness> [--dry-run] [--base <branch>]
|
||||||
|
local issue="$1" harness="$2"; shift 2 || { echo "usage: dispatch <issue#> <harness> [--dry-run] [--base <branch>]"; return 2; }
|
||||||
|
local dry=0 base="main"
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--dry-run) dry=1 ;;
|
||||||
|
--base) base="$2"; shift ;;
|
||||||
|
*) echo "dispatch: unknown flag '$1'" >&2; return 2 ;;
|
||||||
|
esac; shift
|
||||||
|
done
|
||||||
|
|
||||||
|
# adapter must exist
|
||||||
|
if ! declare -f "harness_$harness" >/dev/null 2>&1; then
|
||||||
|
echo "no adapter for harness '$harness' — add harness_$harness() to .agents/harnesses.sh" >&2; return 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
local repo_root title body slug branch wt prompt
|
||||||
|
repo_root="$(git rev-parse --show-toplevel)" || return 1
|
||||||
|
title="$(issue_title "$issue")" || { echo "issue #$issue not found on $REPO" >&2; return 1; }
|
||||||
|
body="$(issue_body "$issue")"
|
||||||
|
slug="$(_slug "$title")"
|
||||||
|
branch="feat/issue-${issue}-${slug}"
|
||||||
|
wt="$repo_root/.worktrees/issue-${issue}"
|
||||||
|
prompt="$(mktemp "${TMPDIR:-/tmp}/boxbox-prompt-${issue}.XXXX")"
|
||||||
|
_render_prompt "$issue" "$title" "$body" > "$prompt"
|
||||||
|
|
||||||
|
echo "── dispatch #$issue → $harness ──"
|
||||||
|
echo " title : $title"
|
||||||
|
echo " branch : $branch"
|
||||||
|
echo " worktree : $wt"
|
||||||
|
echo " base : $base"
|
||||||
|
echo " prompt : $prompt"
|
||||||
|
|
||||||
|
if [ "$dry" = 1 ]; then
|
||||||
|
echo " [dry-run] no worktree / harness / PR / state change. Prompt preview:"
|
||||||
|
sed 's/^/ | /' "$prompt"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# preflight: warn (don't block) if not Ready
|
||||||
|
local stage; stage="$(get_field "$issue" Stage)"
|
||||||
|
[ "$stage" = "Ready" ] || echo " ⚠ Stage is '$stage' (not Ready) — dispatching anyway"
|
||||||
|
|
||||||
|
# isolated worktree
|
||||||
|
if [ -d "$wt" ]; then
|
||||||
|
echo " worktree exists — reusing"
|
||||||
|
else
|
||||||
|
git -C "$repo_root" worktree add -b "$branch" "$wt" "$base" || return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
set_stage "$issue" "In Progress"
|
||||||
|
|
||||||
|
echo " running $harness (headless)…"
|
||||||
|
( harness_"$harness" "$wt" "$prompt" ); local hrc=$?
|
||||||
|
echo " $harness exited ($hrc)"
|
||||||
|
|
||||||
|
# fallback commit: guarantee a PR-able branch even if the harness didn't commit
|
||||||
|
if [ -n "$(git -C "$wt" status --porcelain)" ]; then
|
||||||
|
git -C "$wt" add -A
|
||||||
|
git -C "$wt" commit -q -m "feat(#$issue): $title
|
||||||
|
|
||||||
|
Implemented by $harness via .agents/dev dispatch." && echo " committed leftover changes"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# gate
|
||||||
|
local gate="passed"
|
||||||
|
run_gate "$wt" || gate="FAILED"
|
||||||
|
echo " gate: $gate"
|
||||||
|
|
||||||
|
# PR (only if there are commits ahead of base)
|
||||||
|
if [ -n "$(git -C "$wt" log "$base..$branch" --oneline 2>/dev/null)" ]; then
|
||||||
|
git -C "$wt" push -u origin "$branch" || { echo " push failed — inspect $wt" >&2; return 1; }
|
||||||
|
local draft=""; [ "$gate" = "FAILED" ] && draft="--draft"
|
||||||
|
local pr
|
||||||
|
pr="$(gh pr create -R "$REPO" --head "$branch" --base "$base" $draft \
|
||||||
|
--title "$title (#$issue)" --body "$(_pr_body "$issue" "$harness" "$gate")")" || { echo " gh pr create failed" >&2; return 1; }
|
||||||
|
echo " PR: $pr${draft:+ (draft — gate failed)}"
|
||||||
|
set_stage "$issue" "In Review"
|
||||||
|
else
|
||||||
|
echo " no commits on $branch — leaving Stage 'In Progress'. Inspect the worktree: $wt" >&2
|
||||||
|
fi
|
||||||
|
}
|
||||||
68
.agents/lib/gh.sh
Normal file
68
.agents/lib/gh.sh
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# .agents/lib/gh.sh — harness-agnostic helpers for the box-box agentic dev lifecycle.
|
||||||
|
#
|
||||||
|
# Source it, then call the functions:
|
||||||
|
# source .agents/lib/gh.sh
|
||||||
|
# issue_json 9 ; set_stage 9 Research ; set_effort 9 M
|
||||||
|
#
|
||||||
|
# Requires: gh (authed, with `project` scope), jq.
|
||||||
|
# Config is overridable via env vars.
|
||||||
|
|
||||||
|
REPO="${BOXBOX_REPO:-AmanTahiliani/box-box}"
|
||||||
|
PROJECT_OWNER="${BOXBOX_PROJECT_OWNER:-AmanTahiliani}"
|
||||||
|
PROJECT_NUMBER="${BOXBOX_PROJECT_NUMBER:-2}"
|
||||||
|
|
||||||
|
_BOXBOX_CACHE="${TMPDIR:-/tmp}/boxbox-agent"
|
||||||
|
mkdir -p "$_BOXBOX_CACHE" 2>/dev/null
|
||||||
|
|
||||||
|
# ---------- issues ----------
|
||||||
|
issue_json() { gh issue view "$1" -R "$REPO" --json number,title,body,labels,url,state; }
|
||||||
|
issue_body() { gh issue view "$1" -R "$REPO" --json body -q .body; }
|
||||||
|
issue_title() { gh issue view "$1" -R "$REPO" --json title -q .title; }
|
||||||
|
issue_url() { gh issue view "$1" -R "$REPO" --json url -q .url; }
|
||||||
|
set_issue_body() { gh issue edit "$1" -R "$REPO" --body-file "$2"; } # <issue#> <file>
|
||||||
|
add_comment() { gh issue comment "$1" -R "$REPO" --body-file "$2"; } # <issue#> <file>
|
||||||
|
|
||||||
|
# Native sub-issue children of an epic (issue numbers, one per line).
|
||||||
|
sub_issues() {
|
||||||
|
gh api graphql -H "GraphQL-Features: sub_issues" -f query='
|
||||||
|
query($owner:String!,$repo:String!,$num:Int!){
|
||||||
|
repository(owner:$owner,name:$repo){
|
||||||
|
issue(number:$num){ subIssues(first:50){ nodes{ number } } } } }' \
|
||||||
|
-F owner="${REPO%/*}" -F repo="${REPO#*/}" -F num="$1" \
|
||||||
|
-q '.data.repository.issue.subIssues.nodes[].number'
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------- project fields (cached per shell invocation) ----------
|
||||||
|
_fields_file="$_BOXBOX_CACHE/fields.json"
|
||||||
|
_items_file="$_BOXBOX_CACHE/items.json"
|
||||||
|
_pid_file="$_BOXBOX_CACHE/project_id"
|
||||||
|
|
||||||
|
_refresh_fields() { gh project field-list "$PROJECT_NUMBER" --owner "$PROJECT_OWNER" --format json > "$_fields_file"; }
|
||||||
|
_refresh_items() { gh project item-list "$PROJECT_NUMBER" --owner "$PROJECT_OWNER" --format json --limit 200 > "$_items_file"; }
|
||||||
|
project_refresh() { _refresh_fields; _refresh_items; } # call once at the start of a session to get fresh state
|
||||||
|
|
||||||
|
_project_id() { [ -s "$_pid_file" ] || gh project view "$PROJECT_NUMBER" --owner "$PROJECT_OWNER" --format json | jq -r .id > "$_pid_file"; cat "$_pid_file"; }
|
||||||
|
_field_id() { [ -s "$_fields_file" ] || _refresh_fields; jq -r --arg n "$1" '.fields[]|select(.name==$n)|.id' "$_fields_file"; }
|
||||||
|
_option_id() { [ -s "$_fields_file" ] || _refresh_fields; jq -r --arg f "$1" --arg o "$2" '.fields[]|select(.name==$f)|.options[]?|select(.name==$o)|.id' "$_fields_file"; }
|
||||||
|
_item_id() { [ -s "$_items_file" ] || _refresh_items; jq -r --arg n "$1" '.items[]|select(.content.number==($n|tonumber))|.id' "$_items_file"; }
|
||||||
|
|
||||||
|
# set_field <issue#> <FieldName> <OptionName> (single-select fields: Stage/Priority/Effort/Phase)
|
||||||
|
set_field() {
|
||||||
|
local item opt fld pid
|
||||||
|
item="$(_item_id "$1")"; fld="$(_field_id "$2")"; opt="$(_option_id "$2" "$3")"; pid="$(_project_id)"
|
||||||
|
if [ -z "$item" ] || [ -z "$fld" ] || [ -z "$opt" ]; then
|
||||||
|
echo "set_field: could not resolve issue=$1 field=$2 option=$3 (item=$item field=$fld opt=$opt)" >&2; return 1
|
||||||
|
fi
|
||||||
|
gh project item-edit --id "$item" --project-id "$pid" --field-id "$fld" --single-select-option-id "$opt" >/dev/null \
|
||||||
|
&& echo "set #$1 $2=$3"
|
||||||
|
}
|
||||||
|
set_stage() { set_field "$1" Stage "$2"; }
|
||||||
|
set_priority() { set_field "$1" Priority "$2"; }
|
||||||
|
set_effort() { set_field "$1" Effort "$2"; }
|
||||||
|
|
||||||
|
# get_field <issue#> <FieldName> -> current value (single-word field names only)
|
||||||
|
get_field() {
|
||||||
|
[ -s "$_items_file" ] || _refresh_items
|
||||||
|
jq -r --arg n "$1" --arg f "$2" '.items[]|select(.content.number==($n|tonumber))|.[($f|ascii_downcase)] // "-"' "$_items_file"
|
||||||
|
}
|
||||||
35
.agents/personas/architect.md
Normal file
35
.agents/personas/architect.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Lens overlay: Architect
|
||||||
|
|
||||||
|
Apply this **on top of** the base grill (`grill.md`). Bias every question toward
|
||||||
|
technical soundness and fit with the existing box-box architecture.
|
||||||
|
|
||||||
|
## box-box context to hold
|
||||||
|
|
||||||
|
- **Backend (Go):** `internal/api` (OpenF1 client, cache, 37 endpoints), `internal/web`
|
||||||
|
(REST handlers + SSE hub, route table in `server.go`), `internal/store` (domain
|
||||||
|
SQLite), `internal/query` (read models over the domain DB), `internal/ingest`,
|
||||||
|
`internal/live` (SignalR).
|
||||||
|
- **Frontend (React+Vite+TS):** TanStack Router/Query, `src/api.ts` (typed fetchers),
|
||||||
|
`src/types.ts` (payload mirrors), `src/lib` (client helpers), `src/pages`,
|
||||||
|
`src/components` (incl. `components/live`).
|
||||||
|
- **Patterns to respect** (see CLAUDE.md "How To Extend"): ServeMux longest-prefix
|
||||||
|
route ordering in `server.go`, cache TTL tiers, `?source=openf1|local|auto`
|
||||||
|
resolution, two-phase standings load, lazy tab loads, stale-data fallback banner.
|
||||||
|
|
||||||
|
## Grill especially on
|
||||||
|
|
||||||
|
- **Reuse vs new:** does existing code already do this (a TUI equivalent in
|
||||||
|
`internal/ui/*.go`, a query model, an `api.ts` fetcher)? Port vs rebuild.
|
||||||
|
- **Data flow & source:** OpenF1 live vs domain DB vs cache; payload size; rate
|
||||||
|
limits; how `?source` is handled.
|
||||||
|
- **Seams:** which files/modules change; new endpoint (mind registration order!) vs
|
||||||
|
extend an existing one; new component vs extend; where shared logic lives
|
||||||
|
(`frontend/src/lib/*`).
|
||||||
|
- **Testability:** how does this land in `go test` / `vitest` / hermetic Playwright?
|
||||||
|
What seam makes it testable without live OpenF1?
|
||||||
|
- **Risk:** domain-DB migrations, perf on large sessions, backward compat, and
|
||||||
|
failure / stale-data behavior.
|
||||||
|
|
||||||
|
Keep questions concrete and decision-shaped — e.g. *"port the GPS normalization from
|
||||||
|
`internal/ui/trackmap.go`, or recompute in a shared `frontend/src/lib/trackmap.ts` so
|
||||||
|
it's unit-testable?"* — each with your recommendation.
|
||||||
51
.agents/personas/grill.md
Normal file
51
.agents/personas/grill.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Persona: The Grill
|
||||||
|
|
||||||
|
You run a **grill-me** session. Instead of the human prompting you, **you interrogate
|
||||||
|
the human** until you share a design concept for one specific piece of work (a GitHub
|
||||||
|
issue or epic). The shared understanding — not the document — is the real output.
|
||||||
|
|
||||||
|
## Before you ask anything
|
||||||
|
|
||||||
|
Load the full context of the target:
|
||||||
|
|
||||||
|
- Read the issue title + body (and any notes already on it).
|
||||||
|
- Read `CLAUDE.md`.
|
||||||
|
- Explore the code paths the work implicates.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. **One question at a time.** Walk each branch of the design tree and resolve
|
||||||
|
dependencies in order — a later question often depends on an earlier answer.
|
||||||
|
2. **Recommend, consequence-first.** Every question carries your recommended answer
|
||||||
|
and a short "why". **Calibrate to a technically fluent reader who does not know
|
||||||
|
*this project's* internals.** Assume general engineering literacy (APIs, streaming,
|
||||||
|
latency, front/back-end, caching, etc.) and don't explain those. **Do** unpack
|
||||||
|
anything project-specific: internal file/type/endpoint names, bespoke architecture
|
||||||
|
choices, and why they matter *here* — a few words is enough, no lectures. Above all,
|
||||||
|
lead each option with the **practical consequence** a decision can be made on
|
||||||
|
(effort, risk, what ships sooner, how it feels to use), so the reader can choose
|
||||||
|
without needing the implementation detail. Recommendation first, with why it's the
|
||||||
|
better call **for them**.
|
||||||
|
- *Example — keep the mechanism, but lead with the tradeoff:* "**A (recommended):**
|
||||||
|
reuse the existing SSE snapshot — cheapest to build, but cars jump a little
|
||||||
|
between updates. **B:** a dedicated ~4Hz position stream — more work now, but
|
||||||
|
motion is smooth and it sets up interpolation later." (Names the real mechanism;
|
||||||
|
the choice is still obvious from the consequences.)
|
||||||
|
3. **Hybrid asking.**
|
||||||
|
- Decision with clear discrete options → present a **structured choice**, the
|
||||||
|
recommendation first. *(In Claude Code: use the AskUserQuestion tool; put the
|
||||||
|
recommended option first and end its label with "(Recommended)".)* Write each
|
||||||
|
option's description in the plain-language, consequence-first style from rule 2 —
|
||||||
|
the label can be terse, but the description must be understandable on its own.
|
||||||
|
- Genuinely open-ended → ask in **prose**.
|
||||||
|
4. **Explore before you ask.** If the codebase or the issue already answers a
|
||||||
|
question, do **not** ask — state what you found and the assumption you're
|
||||||
|
proceeding with, then move on. Only ask about real forks the human must decide.
|
||||||
|
5. **Stay in scope.** Grill the design of *this* work, not the whole app. Note
|
||||||
|
out-of-scope temptations instead of chasing them.
|
||||||
|
|
||||||
|
## Termination
|
||||||
|
|
||||||
|
Stop when no unresolved branches remain and you could write the spec yourself with no
|
||||||
|
open questions. Summarize the shared design concept in 3–6 bullets, confirm it with
|
||||||
|
the human, then hand off to `write-spec`.
|
||||||
21
.agents/prompts/implement.md
Normal file
21
.agents/prompts/implement.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
You are an autonomous coding agent working in an **isolated git worktree** on the
|
||||||
|
**box-box** repo. Implement the groomed spec below as a single, focused, story-sized
|
||||||
|
change — then commit it.
|
||||||
|
|
||||||
|
## Ground rules
|
||||||
|
- Read `AGENTS.md` / `CLAUDE.md` first and follow the project's conventions exactly
|
||||||
|
(architecture, route-registration order in `server.go`, cache TTL tiers,
|
||||||
|
`api.ts`/`types.ts` mirrors, test layout).
|
||||||
|
- Implement ONLY this story's scope. Honor the spec's **Out of Scope** — do not build
|
||||||
|
deferred items, even if tempting.
|
||||||
|
- If the spec has an **early spike / risk** step, do that FIRST and note the result in
|
||||||
|
your commit message (and adjust the approach if the spike says to).
|
||||||
|
- Add or extend tests per the **Test Plan**. Make the relevant suites pass:
|
||||||
|
`go build ./...`, `go test ./...`, and in `frontend/`: `npm run test`, `tsc --noEmit`.
|
||||||
|
- Keep the change reviewable and story-sized. **Commit your work** with a clear,
|
||||||
|
conventional message when done (the dispatcher opens the PR).
|
||||||
|
- Satisfy every item in the spec's **Definition of Done**.
|
||||||
|
|
||||||
|
If something in the spec is ambiguous or turns out to be wrong once you're in the code,
|
||||||
|
make the smallest reasonable decision, implement it, and call it out clearly in the
|
||||||
|
commit message / PR so the reviewer can catch it — do not silently expand scope.
|
||||||
27
.agents/prompts/ready-spec.md
Normal file
27
.agents/prompts/ready-spec.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
## Context
|
||||||
|
{one paragraph: the problem and why it matters, grounded in the grooming}
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- [ ] {observable behaviour 1}
|
||||||
|
- [ ] {observable behaviour 2}
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
- **Files to touch:** {real paths}
|
||||||
|
- **Data source:** {OpenF1 live | domain DB | cache | ...}
|
||||||
|
- **Endpoints/components:** {new or extended — note route-registration order if new}
|
||||||
|
- **Key decisions:** {the forks resolved during the grill}
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
- {cases mapped to the real suites: go test · vitest · hermetic Playwright}
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
- {explicitly deferred}
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
- [ ] Tests added and green (`go test` · `vitest` · `tsc --noEmit` · hermetic Playwright as applicable)
|
||||||
|
- [ ] Matches CLAUDE.md conventions (route order, cache TTLs, `api.ts`/`types.ts` mirrors)
|
||||||
|
- [ ] No console / preview errors (UI verified in preview)
|
||||||
|
- [ ] Story-sized PR, linked to this issue
|
||||||
|
|
||||||
|
---
|
||||||
|
_Groomed {date} · Effort {S|M|L} · Priority {P0|P1|P2} · via /groom_
|
||||||
20
.agents/prompts/review.md
Normal file
20
.agents/prompts/review.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
You are an independent reviewer for a box-box pull request. Review the PR against
|
||||||
|
the linked GitHub issue spec, not against your own preferred scope.
|
||||||
|
|
||||||
|
## Ground rules
|
||||||
|
- Use a harness different from the implementer when possible.
|
||||||
|
- Read `AGENTS.md` / `CLAUDE.md`, the PR body/diff, and the linked issue body.
|
||||||
|
- Run the relevant local gates and capture logs under `.review/issue-<n>-pr-<pr>/logs`.
|
||||||
|
- For UI changes, create a local visual packet under `.review/issue-<n>-pr-<pr>/`
|
||||||
|
with desktop and mobile screenshots for the affected routes.
|
||||||
|
- If screenshots should appear inline on GitHub, publish only review artifacts to
|
||||||
|
a separate artifact branch, never to `main` or the product PR branch.
|
||||||
|
- Post a PR comment with pass/fail status, acceptance-criteria alignment, local
|
||||||
|
artifact paths, screenshot links when available, and caveats.
|
||||||
|
- Do not merge. The human owns the merge gate.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
- A local packet with `summary.md`, optional `index.html`, screenshots, logs, and
|
||||||
|
any visual diffs.
|
||||||
|
- A GitHub PR comment that makes the review visually scannable.
|
||||||
|
- A clear recommendation: pass, pass with caveats, or needs changes.
|
||||||
42
.agents/skills/groom/SKILL.md
Normal file
42
.agents/skills/groom/SKILL.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
name: groom
|
||||||
|
description: Groom a box-box GitHub issue into a Ready spec via a seeded grill-me interrogation. Use when the user asks to groom an issue, for example "/groom <issue-number>" or "$groom <issue-number>" with optional "--lens architect". Single-issue path: asks targeted questions, writes a structured spec into the issue body, sets Effort/Priority, and leaves Stage at Research for approval.
|
||||||
|
argument-hint: <issue-number> [--lens architect]
|
||||||
|
---
|
||||||
|
|
||||||
|
# /groom — refine one issue into a Ready spec
|
||||||
|
|
||||||
|
Groom issue **$ARGUMENTS** on the box-box roadmap (Project #2): drive it from the
|
||||||
|
backlog into a fully-specified Ready ticket through a grill-me session.
|
||||||
|
|
||||||
|
## 0. Setup
|
||||||
|
- Parse the first token of the arguments as the **issue number**. An optional
|
||||||
|
`--lens <name>` pulls in a lens overlay (currently: `architect`).
|
||||||
|
- Run: `source .agents/lib/gh.sh && project_refresh` (fresh field/item state).
|
||||||
|
- Load context **before asking anything**: `issue_json <n>`, read `CLAUDE.md`, and
|
||||||
|
explore the code paths the issue implicates.
|
||||||
|
- Move it into grooming if it isn't already there: `set_stage <n> Research`.
|
||||||
|
|
||||||
|
## 1. Grill
|
||||||
|
- Read `.agents/personas/grill.md`. If `--lens <name>` was given, also read
|
||||||
|
`.agents/personas/<name>.md` and apply it on top.
|
||||||
|
- Run the grill exactly per those rules: **one question at a time**, recommendation
|
||||||
|
first, **hybrid** asking (AskUserQuestion for discrete decisions with the
|
||||||
|
recommended option first and labelled "(Recommended)"; prose for open-ended), and
|
||||||
|
**explore the code to self-answer** wherever possible — only ask about genuine forks.
|
||||||
|
- Track the resolved decisions as you go.
|
||||||
|
|
||||||
|
## 2. Synthesize
|
||||||
|
- When no open branches remain, summarize the shared design concept in 3–6 bullets and
|
||||||
|
confirm it with the user.
|
||||||
|
- Then invoke the **write-spec** skill for issue `<n>`, handing it the resolved
|
||||||
|
decisions, so it renders `.agents/prompts/ready-spec.md` into the issue body and
|
||||||
|
sets Effort + Priority.
|
||||||
|
|
||||||
|
## 3. Hand back (human gate)
|
||||||
|
- Do **not** auto-advance to Ready — that's the user's call. Report that the spec is
|
||||||
|
written, Stage is `Research`, and they should review the issue and flip Stage →
|
||||||
|
`Ready` when satisfied (`set_stage <n> Ready`).
|
||||||
|
- Print the issue URL (`issue_url <n>`).
|
||||||
|
|
||||||
|
Stay focused on THIS issue's design throughout. Note but don't chase out-of-scope ideas.
|
||||||
32
.agents/skills/implement/SKILL.md
Normal file
32
.agents/skills/implement/SKILL.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
name: implement
|
||||||
|
description: Dispatch a Ready box-box issue to a coding harness (claude/codex/opencode/pi/cursor) in an isolated git worktree, run the build gate, and open a PR. Use when supervising implementation via "/implement <issue-number> --harness <name> [--dry-run]" or from any harness terminal with .agents/bin/dev.
|
||||||
|
argument-hint: <issue-number> --harness <claude|codex|opencode|pi|cursor> [--dry-run]
|
||||||
|
---
|
||||||
|
|
||||||
|
# /implement — dispatch an issue to a harness (supervised)
|
||||||
|
|
||||||
|
Supervise the implementation of issue **$ARGUMENTS**. You are SUPERVISING, not coding —
|
||||||
|
a fresh harness does the work in its own isolated worktree with clean context. Do not
|
||||||
|
edit project files yourself.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Parse: `<issue-number> --harness <name> [--dry-run] [--base <branch>]`.
|
||||||
|
2. **Preflight (report, don't hard-block):** `source .agents/lib/gh.sh` and check
|
||||||
|
`get_field <n> Stage` is `Ready` and the issue body has an "## Acceptance Criteria"
|
||||||
|
section (a groomed spec). If it's not Ready or has no spec, say so and recommend
|
||||||
|
`/groom <n>` first — proceed only if the user confirms.
|
||||||
|
3. **Dispatch:** run `.agents/bin/dev implement <n> --harness <name> [flags]`. For a
|
||||||
|
first run against an unfamiliar harness, suggest `--dry-run` first so the user can
|
||||||
|
eyeball the prompt.
|
||||||
|
4. **Report the outcome:** branch, worktree path, gate result (pass/FAILED → draft PR),
|
||||||
|
and the PR URL. On success, Stage will be `In Review`.
|
||||||
|
5. If no PR was created (no changes, or push failed), surface exactly why and point at
|
||||||
|
the worktree (`.worktrees/issue-<n>`) so the user can inspect. Diagnose from the
|
||||||
|
dispatcher output; recommend a fix or re-run — don't silently take over the coding.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- The harness adapters and the gate live in `.agents/harnesses.sh` — the single place
|
||||||
|
to tune per-tool flags.
|
||||||
|
- From any harness shell, run the same thing directly:
|
||||||
|
`.agents/bin/dev implement <n> --harness <name>`.
|
||||||
22
.agents/skills/lens-architect/SKILL.md
Normal file
22
.agents/skills/lens-architect/SKILL.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: lens-architect
|
||||||
|
description: Grill/analyze a box-box issue or epic from a software-architecture perspective and post the findings as a comment. Use standalone as "/lens-architect <issue-number>" for an on-the-fly architecture review, or let /groom compose it via "--lens architect". Reads the base grill + architect persona and focuses on reuse, data flow, seams, testability, and risk.
|
||||||
|
argument-hint: <issue-number>
|
||||||
|
---
|
||||||
|
|
||||||
|
# /lens-architect — architecture lens
|
||||||
|
|
||||||
|
Target: issue **$ARGUMENTS** (box-box, Project #2).
|
||||||
|
|
||||||
|
1. `source .agents/lib/gh.sh`; load context (`issue_json <n>`, `CLAUDE.md`, and the
|
||||||
|
relevant code paths).
|
||||||
|
2. Read `.agents/personas/grill.md` + `.agents/personas/architect.md` and run a focused
|
||||||
|
grill from the architecture lens: hybrid asking (AskUserQuestion for discrete
|
||||||
|
decisions, recommendation first; prose otherwise), recommend every answer, and
|
||||||
|
explore the code to self-answer before asking.
|
||||||
|
3. When aligned, write an **"## Architecture review"** summary (decisions taken,
|
||||||
|
files/seams affected, risks, the test seam) to a temp file and `add_comment <n> <file>`.
|
||||||
|
4. Print the issue URL.
|
||||||
|
|
||||||
|
If invoked from **within /groom**, skip the comment — instead return the architecture
|
||||||
|
decisions inline so groom can fold them into the spec.
|
||||||
53
.agents/skills/review/SKILL.md
Normal file
53
.agents/skills/review/SKILL.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
name: review
|
||||||
|
description: Locally review a box-box PR against its linked GitHub issue spec, run tests, capture visual screenshots when applicable, create a .review packet, and post a GitHub PR comment. Use when a ticket implementation is ready for independent local review before the human merge gate.
|
||||||
|
argument-hint: <pr-number> [--harness <name>] [--publish-screenshots]
|
||||||
|
---
|
||||||
|
|
||||||
|
# /review — local independent PR review
|
||||||
|
|
||||||
|
Review PR **$ARGUMENTS** for box-box using the local-only lifecycle. You are
|
||||||
|
reviewing, not implementing. Do not merge the PR.
|
||||||
|
|
||||||
|
## 0. Setup
|
||||||
|
- Parse the PR number. Optional `--harness <name>` records which reviewer harness is
|
||||||
|
acting; optional `--publish-screenshots` allows pushing visual artifacts to a
|
||||||
|
dedicated artifact branch.
|
||||||
|
- Read `.agents/prompts/review.md`, `AGENTS.md`, the PR metadata/diff, and the linked
|
||||||
|
issue body.
|
||||||
|
- Identify the implementer harness from the PR body when present. If it matches the
|
||||||
|
reviewer harness, call that out as a reduced independence caveat.
|
||||||
|
- Create `.review/issue-<issue>-pr-<pr>/logs`, `screenshots`, and `artifacts`.
|
||||||
|
|
||||||
|
## 1. Verify
|
||||||
|
- Run the smallest meaningful gates first, then broaden based on risk:
|
||||||
|
`go test` for touched Go packages, `npm run test` and `npm run build` for frontend
|
||||||
|
changes, and hermetic Playwright when user-facing routes changed.
|
||||||
|
- Save all command output to `.review/issue-<issue>-pr-<pr>/logs`.
|
||||||
|
- Inspect the diff for spec conformance, scope leaks, missing tests, and known project
|
||||||
|
conventions from `AGENTS.md`.
|
||||||
|
|
||||||
|
## 2. Visual Packet
|
||||||
|
- If the PR changes UI, start a local seeded or mocked preview and capture desktop and
|
||||||
|
mobile screenshots for affected routes.
|
||||||
|
- Prefer hermetic mocks/seeded data over live external state.
|
||||||
|
- Save screenshots under `.review/issue-<issue>-pr-<pr>/screenshots`.
|
||||||
|
- Create a concise `summary.md`; create `index.html` when screenshots exist.
|
||||||
|
|
||||||
|
## 3. Publish
|
||||||
|
- If `--publish-screenshots` is present, publish only review artifacts to a dedicated
|
||||||
|
branch such as `review-artifacts/pr-<pr>/` and use raw GitHub URLs in the comment.
|
||||||
|
- Post a PR comment with:
|
||||||
|
- reviewer harness and implementer harness
|
||||||
|
- result: pass, pass with caveats, or needs changes
|
||||||
|
- local packet path
|
||||||
|
- gates run and results
|
||||||
|
- acceptance-criteria checklist
|
||||||
|
- screenshots or artifact links when available
|
||||||
|
- caveats that the human must inspect
|
||||||
|
- If the result is pass/pass-with-caveats, set the linked issue's custom Project
|
||||||
|
`Stage` to `In Review` using `.agents/lib/gh.sh`. Do not set `Done`.
|
||||||
|
|
||||||
|
## 4. Hand Back
|
||||||
|
- Tell the human exactly what to open locally and what decision remains theirs.
|
||||||
|
- Do not merge or delete worktrees.
|
||||||
30
.agents/skills/write-spec/SKILL.md
Normal file
30
.agents/skills/write-spec/SKILL.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
name: write-spec
|
||||||
|
description: Render a groomed design into the box-box Ready-spec template and write it into a GitHub issue body, then set Effort and Priority. Called by /groom after a grill session, or run standalone as "/write-spec <issue-number>" to (re)write an issue's spec from agreed decisions. Does not change Stage.
|
||||||
|
argument-hint: <issue-number>
|
||||||
|
---
|
||||||
|
|
||||||
|
# /write-spec — write the Ready spec into an issue
|
||||||
|
|
||||||
|
Target issue: **$ARGUMENTS** (box-box, Project #2).
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. `source .agents/lib/gh.sh`
|
||||||
|
2. Gather the agreed design decisions: from the current grooming conversation if one
|
||||||
|
is in progress; otherwise ask the user for the key points, or read the issue and
|
||||||
|
explore the code to draft them and confirm.
|
||||||
|
3. Read `.agents/prompts/ready-spec.md` and fill every placeholder:
|
||||||
|
- Concrete, **behavioural** acceptance criteria (checkboxes).
|
||||||
|
- Technical approach grounded in **real files/paths** and the chosen data source.
|
||||||
|
- Test plan mapped to the actual suites (`go test` · `vitest` · hermetic Playwright).
|
||||||
|
- Explicit out-of-scope.
|
||||||
|
- Keep the Definition of Done checklist verbatim.
|
||||||
|
- Stamp the footer: date (from the current-date context), Effort (S/M/L),
|
||||||
|
Priority (P0–P2).
|
||||||
|
4. Write it into the issue body: save the filled template to a temp file under the
|
||||||
|
scratchpad and `set_issue_body <n> <file>`. The spec is the single source of truth —
|
||||||
|
only preserve prior body text that captures decisions the spec doesn't.
|
||||||
|
5. Set fields: `set_effort <n> <S|M|L>` and `set_priority <n> <P0|P1|P2>`.
|
||||||
|
6. Print the issue URL and a one-line summary of what was written.
|
||||||
|
|
||||||
|
Do **not** change Stage — `/groom` owns state transitions.
|
||||||
1
.claude/skills
Symbolic link
1
.claude/skills
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../.agents/skills
|
||||||
23
.gitignore
vendored
23
.gitignore
vendored
@@ -7,6 +7,7 @@ build/
|
|||||||
|
|
||||||
# Log files
|
# Log files
|
||||||
*.log
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
# SQLite database files
|
# SQLite database files
|
||||||
*.db
|
*.db
|
||||||
@@ -15,3 +16,25 @@ build/
|
|||||||
|
|
||||||
# Old file cache
|
# Old file cache
|
||||||
.cache/
|
.cache/
|
||||||
|
|
||||||
|
# Frontend dependencies/build output
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# Local Claude workspace settings
|
||||||
|
.claude/*
|
||||||
|
!.claude/skills
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
node_modules/
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/blob-report/
|
||||||
|
/playwright/.cache/
|
||||||
|
/playwright/.auth/
|
||||||
|
|
||||||
|
# agentic dev: isolated implementation worktrees
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
|
# agentic dev: local review packets
|
||||||
|
.review/
|
||||||
|
|||||||
88
CLAUDE.md
88
CLAUDE.md
@@ -3,18 +3,31 @@
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build -o box-box ./cmd/main.go # Build binary
|
go build -o box-box ./cmd/main.go # Build binary
|
||||||
go run cmd/main.go # Run directly
|
go run cmd/main.go # Run TUI
|
||||||
go test ./... # All tests
|
go run cmd/main.go --web --port 8080 # Run web server (default port 8080)
|
||||||
|
go run cmd/main.go --ingest-year 2025 # Ingest a season into the domain DB (also: --ingest-meeting, --ingest-session, --ingest-news, --coverage)
|
||||||
|
go test ./... # All Go tests
|
||||||
go test -v ./internal/api # API integration tests (requires internet, rate-limit aware)
|
go test -v ./internal/api # API integration tests (requires internet, rate-limit aware)
|
||||||
OPENF1_API_KEY=key go run cmd/main.go # Run with paid tier (enables live session access)
|
OPENF1_API_KEY=key go run cmd/main.go # Run with paid tier (enables live session access)
|
||||||
|
|
||||||
|
# Frontend (run inside frontend/)
|
||||||
|
npm run dev # Vite dev server on :5173, proxies /api to :8080 (override with BOXBOX_API_PORT)
|
||||||
|
npm run build # tsc --noEmit && vite build -> frontend/dist
|
||||||
|
npm run test # Vitest unit tests (frontend/src/test/); npm run test:watch for watch mode
|
||||||
|
|
||||||
|
# E2E / visual (run at repo root; Playwright auto-starts a seeded Go server + Vite dev server)
|
||||||
|
npm run test:e2e # Playwright e2e (tests/*.spec.ts, playwright.config.ts)
|
||||||
|
npm run test:visual # Visual snapshots (tests/visual/, playwright.visual.config.ts)
|
||||||
|
npm run test:visual:update # Regenerate visual snapshots
|
||||||
|
# :prod variants (test:e2e:prod, test:visual:prod) run against the *.prod.config.ts configs
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
**box-box** is an F1 Terminal UI (TUI) dashboard built in Go with Bubble Tea. It shows live timing, standings, race calendar, driver telemetry, track maps, and race replay — all sourced from the OpenF1 API.
|
**box-box** is an F1 dashboard in Go with two frontends sharing the same data layer: a Bubble Tea TUI and a web app (`--web` flag) — a Go HTTP server in `internal/web/` serving a React SPA plus a REST/SSE API. It shows live timing, standings, race calendar, driver telemetry, track maps, and race replay — sourced from the OpenF1 API and a local domain SQLite DB filled by the ingestion CLI.
|
||||||
|
|
||||||
**Status**: Pre-beta, actively developed. All layers (API, models, UI) are fully implemented.
|
**Status**: Pre-beta, actively developed.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
@@ -23,12 +36,15 @@ OPENF1_API_KEY=key go run cmd/main.go # Run with paid tier (enables live sessio
|
|||||||
- **Bubbles** — TUI components (spinner, viewport, table)
|
- **Bubbles** — TUI components (spinner, viewport, table)
|
||||||
- **OpenF1 API** — F1 data at `https://api.openf1.org`
|
- **OpenF1 API** — F1 data at `https://api.openf1.org`
|
||||||
- **gorilla/websocket** — Official F1 SignalR live feed
|
- **gorilla/websocket** — Official F1 SignalR live feed
|
||||||
- **modernc.org/sqlite** — HTTP response caching with TTL
|
- **modernc.org/sqlite** — HTTP response cache + domain DB
|
||||||
|
- **React 18 + Vite + TypeScript** — Web frontend (`frontend/`), TanStack Router + TanStack Query
|
||||||
|
- **Vitest + Testing Library** — Frontend unit tests; **Playwright** — e2e and visual tests (repo root)
|
||||||
|
|
||||||
## File Map
|
## File Map
|
||||||
|
|
||||||
```
|
```
|
||||||
cmd/main.go Entry point (package main). Inits client, launches TUI.
|
cmd/main.go Entry point. Flags: --web/--port (web server), --ingest-* /
|
||||||
|
--backfill-season/--coverage (ingestion CLI), --db. Default: TUI.
|
||||||
|
|
||||||
internal/api/
|
internal/api/
|
||||||
client.go OpenF1Client: HTTP client, 15s timeout, optional Bearer auth
|
client.go OpenF1Client: HTTP client, 15s timeout, optional Bearer auth
|
||||||
@@ -56,6 +72,40 @@ internal/ui/
|
|||||||
battles.go Sub-view: Auto-detected on-track battles with gap sparkline
|
battles.go Sub-view: Auto-detected on-track battles with gap sparkline
|
||||||
pitwindow.go Sub-view: Pit stop rejoin position calculator
|
pitwindow.go Sub-view: Pit stop rejoin position calculator
|
||||||
replay.go Sub-view: Lap-by-lap race replay scrubber
|
replay.go Sub-view: Lap-by-lap race replay scrubber
|
||||||
|
|
||||||
|
internal/store/ Domain SQLite DB (~/.local/share/box-box/boxbox.db), season/session data
|
||||||
|
internal/ingest/ OpenF1 -> domain DB ingestion (driven by cmd/main.go --ingest-* flags)
|
||||||
|
internal/query/ Read models over the domain DB, used by web handlers
|
||||||
|
internal/news/ RSS/Atom paddock briefing feed ingestion (--ingest-news)
|
||||||
|
internal/live/ Shared F1 SignalR live feed client + LiveStreamData types
|
||||||
|
|
||||||
|
internal/web/
|
||||||
|
server.go HTTP server: route table, CORS/logging middleware, SPA file server.
|
||||||
|
Serves frontend/dist if found (walks up from cwd), else embedded assets/
|
||||||
|
api.go REST handlers under /api/v1/ (results, laps, telemetry, championship
|
||||||
|
hub aggregation, news + readability article extraction, ...)
|
||||||
|
live.go SSEHub + SignalR bridge: relays official F1 feed to SSE subscribers
|
||||||
|
racehub.go /api/v1/race-hub: per-session payload assembled from the domain DB
|
||||||
|
navigation.go Local-first navigation: /api/v1/seasons, meetings, sessions, weekend
|
||||||
|
source.go ?source=openf1|local|auto data-source resolution
|
||||||
|
assets/ Embedded fallback SPA (legacy vanilla JS; used when no frontend/dist)
|
||||||
|
|
||||||
|
frontend/ React + Vite + TypeScript SPA
|
||||||
|
src/main.tsx Entry: QueryClientProvider + RouterProvider
|
||||||
|
src/router.tsx TanStack Router: / (command center), /race-hub, /live, /championship,
|
||||||
|
/briefing, /data-library (also /admin alias)
|
||||||
|
src/api.ts Typed fetch wrappers for /api/v1/ endpoints
|
||||||
|
src/types.ts TypeScript mirrors of API payloads
|
||||||
|
src/pages/ CommandCenterPage, RaceHubPage, LiveTimingPage, ChampionshipPage,
|
||||||
|
BriefingPage, DataLibraryPage
|
||||||
|
src/components/ Shared components (Nav, TabBar, race hub views, live/ timing tower)
|
||||||
|
src/lib/ Client helpers: live SSE parsing, schedule, coverage, GP identity
|
||||||
|
src/test/ Vitest + Testing Library unit tests
|
||||||
|
|
||||||
|
tests/ Playwright e2e specs; tests/visual/ visual snapshot specs
|
||||||
|
playwright*.config.ts Dev/prod e2e + visual configs (webServer blocks seed a temp domain DB
|
||||||
|
and start Go API + Vite automatically)
|
||||||
|
scripts/seed-e2e-db/ Seeds the throwaway domain DB used by Playwright runs
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -66,6 +116,17 @@ Each tab is a sub-model with `Init()`, `Update(msg)`, `View()`. The root `AppMod
|
|||||||
|
|
||||||
Async work (API calls, WebSocket) returns `tea.Cmd` that emits typed messages back to Update. Use `tea.Batch()` for parallel fetches.
|
Async work (API calls, WebSocket) returns `tea.Cmd` that emits typed messages back to Update. Use `tea.Batch()` for parallel fetches.
|
||||||
|
|
||||||
|
### Web Layer
|
||||||
|
|
||||||
|
`box-box --web` starts an HTTP server (default `:8080`) with three surfaces:
|
||||||
|
|
||||||
|
- **REST API at `/api/v1/...`** — Handlers in `internal/web/api.go` wrap `OpenF1Client`; navigation/race-hub endpoints read the domain DB via `internal/query` (empty responses if the DB is missing). `?source=openf1|local|auto` picks the data source where supported. Register routes in `routes()` in `server.go` — Go ServeMux longest-prefix matching means more specific paths (e.g. `/api/v1/laps/comparison`) must be registered before their prefixes.
|
||||||
|
- **SSE live stream** — `internal/web/live.go` runs a background SignalR connection to the official F1 feed (exponential-backoff reconnect; disabled with `BOXBOX_DISABLE_LIVE=1`). An `SSEHub` broadcasts snapshots to browsers on `/api/v1/live/stream`, with `/api/v1/live/state` for the initial snapshot and a 20s heartbeat. `LiveTimingPage.tsx` consumes it; parsing helpers live in `frontend/src/lib/live.ts`.
|
||||||
|
- **Embedded SPA** — Static file server with SPA fallback to `index.html`. Prefers a `frontend/dist` directory found by walking up from cwd (so `npm run build` output is served without rebuilding Go); otherwise serves the legacy assets embedded via `//go:embed assets`.
|
||||||
|
- **Championship hub** — `/api/v1/championship/hub` aggregates official standings with derived stats (wins, podiums, poles, last-5 form, teammate head-to-head) and per-round cumulative points, computed from all season race results.
|
||||||
|
|
||||||
|
Frontend dev loop: run `go run cmd/main.go --web` and `npm run dev` in `frontend/` — Vite proxies `/api` to the Go server.
|
||||||
|
|
||||||
### Key Patterns
|
### Key Patterns
|
||||||
|
|
||||||
- **Two-phase standings load**: `GetLatestDriverChampionship()` -> extract SessionKey -> `GetDriversForSession(sessionKey)` -> join by DriverNumber for names/colors
|
- **Two-phase standings load**: `GetLatestDriverChampionship()` -> extract SessionKey -> `GetDriversForSession(sessionKey)` -> join by DriverNumber for names/colors
|
||||||
@@ -104,13 +165,20 @@ Replay: `h`/`l` or arrows scrub laps
|
|||||||
- **New message type**: Define in `messages.go`, handle in relevant model's `Update()`
|
- **New message type**: Define in `messages.go`, handle in relevant model's `Update()`
|
||||||
- **New keybinding**: Define in `keys.go`, handle in relevant model's `Update()`
|
- **New keybinding**: Define in `keys.go`, handle in relevant model's `Update()`
|
||||||
- **New styles**: Add to `styles.go`, reference F1 palette constants
|
- **New styles**: Add to `styles.go`, reference F1 palette constants
|
||||||
|
- **New web page/route**: Create page in `frontend/src/pages/`, register route in `frontend/src/router.tsx`, add nav link in `frontend/src/components/Nav.tsx`, add fetchers to `src/api.ts` and payload types to `src/types.ts`, add a test in `frontend/src/test/`
|
||||||
|
- **New web API endpoint**: Add handler in `internal/web/api.go` (or a new file in `internal/web/`), register it in `routes()` in `server.go` (mind prefix ordering), add a handler test alongside (see `championship_hub_test.go`)
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Tests in `openf1_test.go` hit the real OpenF1 API. They use `skipOnRateLimit(t, err)` to gracefully skip on HTTP 429. Require internet.
|
- Go: tests in `openf1_test.go` hit the real OpenF1 API and use `skipOnRateLimit(t, err)` to skip on HTTP 429 (require internet). `internal/web` handler tests run offline.
|
||||||
|
- Frontend: Vitest + Testing Library in `frontend/src/test/` (`npm run test` inside `frontend/`).
|
||||||
|
- E2E/visual: Playwright at repo root (`npm run test:e2e`, `npm run test:visual`). Configs seed a temp domain DB and start the Go server with `BOXBOX_DISABLE_LIVE=1` and `BOXBOX_OPENF1_BASE_URL` pointed at an unreachable address (hermetic: no live OpenF1 dependency, date-stable) plus a Vite dev server — no manual setup needed.
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
- `OPENF1_API_KEY` — Optional Bearer token for paid tier (live session WebSocket access)
|
- `OPENF1_API_KEY` — Optional Bearer token for paid tier (live session WebSocket access)
|
||||||
- Logs go to `box-box.log` in project root (prevents TUI pollution)
|
- `BOXBOX_DISABLE_LIVE=1` — Skip the background SignalR live feed in web mode (used by e2e)
|
||||||
- Cache at `~/.cache/box-box/cache.db` (SQLite WAL mode, auto-created)
|
- `BOXBOX_OPENF1_BASE_URL` — Override the OpenF1 API root (default `https://api.openf1.org`; e2e sets an unreachable address for hermetic runs)
|
||||||
|
- `BOXBOX_API_PORT` — Go API port that the Vite dev proxy targets (default 8080)
|
||||||
|
- Logs: TUI writes `box-box.log` in project root; web/ingest modes log to stderr
|
||||||
|
- HTTP cache at `~/.cache/box-box/cache.db`; domain DB at `~/.local/share/box-box/boxbox.db` (override with `--db`)
|
||||||
|
|||||||
108
README.md
108
README.md
@@ -1,92 +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 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.
|
||||||
|
|
||||||
**⚠️ Disclaimer: This project is currently in a pre-beta state.** Features may be incomplete, and you might encounter bugs as we fine-tune the engine.
|
Live demo: [box-box.amantahiliani.com](https://box-box.amantahiliani.com/)
|
||||||
|
|
||||||
**box-box** is a high-performance Formula 1 Terminal User Interface (TUI) built for fans who live in the command line. Get real-time standings, race calendars, and deep-dive driver stats without ever leaving your terminal.
|

|
||||||
|
|
||||||

|
## What It Does
|
||||||

|
|
||||||
|
|
||||||
## ✨ Features
|
- **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.
|
||||||
|
|
||||||
- 🏆 **Live Standings**: Keep track of the Driver and Constructor Championships.
|
## Quickstart
|
||||||
- 📅 **Race Calendar**: The full 2025 schedule at your fingertips.
|
|
||||||
- 🏎️ **Race Details**: Deep dive into session results, starting grids, and lap data.
|
|
||||||
- 👤 **Driver Profiles**: Detailed stats for every driver on the grid.
|
|
||||||
- 🔴 **Official Live Timing**: Real-time F1 timing tower via the official SignalR feed — gaps, intervals, tyre age, sector times, DRS, and track status.
|
|
||||||
- ⚔️ **Battle Tracker**: Auto-detects on-track duels within DRS range with gap sparklines and tyre strategy comparison.
|
|
||||||
- 🔧 **Pit Window Calculator**: Predicts rejoin position if a driver pits now, using per-circuit pit loss times.
|
|
||||||
- ⏪ **Race Replay**: Lap-by-lap scrubber for completed races — relive the whole field's evolution with pit annotations and race control messages.
|
|
||||||
- 🗺️ **ASCII Track Map**: Live car positions on a terminal-rendered track outline, team-coloured.
|
|
||||||
- 🔌 **Offline-ish**: Fast, lightweight, and powered by the wonderful [OpenF1 API](https://openf1.org).
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- [Go](https://go.dev/doc/install) 1.21 or higher.
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
|
||||||
git clone https://github.com/AmanTahiliani/box-box.git
|
git clone https://github.com/AmanTahiliani/box-box.git
|
||||||
cd box-box
|
cd box-box
|
||||||
|
|
||||||
# Build and run
|
npm install
|
||||||
go run cmd/main.go
|
npm install --prefix frontend
|
||||||
|
|
||||||
|
npm run build --prefix frontend
|
||||||
|
go run ./cmd/main.go --web
|
||||||
|
# http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🎮 Controls
|
For a local frontend development loop with seeded data, see [docs/getting-started.md](docs/getting-started.md).
|
||||||
|
|
||||||
| Key | Action |
|
## Project Shape
|
||||||
|
|
||||||
|
| Area | What lives there |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `1` | Switch to **Home** |
|
| `cmd/main.go` | Entry point for TUI, web server, and ingestion CLI |
|
||||||
| `2` | Switch to **Standings** |
|
| `internal/web/` | Go REST API, SSE live bridge, SPA serving |
|
||||||
| `3` | Switch to **Calendar** |
|
| `internal/live/` | Official F1 SignalR client shared by Web and TUI |
|
||||||
| `4` | Switch to **Race Details** |
|
| `internal/store/`, `internal/ingest/`, `internal/query/` | Local SQLite domain database, ingestion, and read models |
|
||||||
| `5` | Switch to **Drivers** |
|
| `internal/ui/` | Bubble Tea TUI |
|
||||||
| `6` | Switch to **Live Timing** |
|
| `frontend/` | React + Vite + TypeScript web app |
|
||||||
| `7` | Switch to **Track Map** |
|
| `tests/` | Playwright e2e and visual coverage |
|
||||||
| `tab` / `shift+tab` | Next / Previous tab |
|
|
||||||
| `j`/`↓` | Navigate down |
|
|
||||||
| `k`/`↑` | Navigate up |
|
|
||||||
| `enter` | Select/Inspect item |
|
|
||||||
| `b` / `esc` | Go back / collapse |
|
|
||||||
| `s` | Toggle sector times (Live tab) |
|
|
||||||
| `b` | Toggle Battle Tracker (Live tab) |
|
|
||||||
| `p` | Toggle Pit Window Calculator (Live tab) |
|
|
||||||
| `r` | Enter Race Replay (Race Detail tab, Race sessions) |
|
|
||||||
| `←`/`h` · `→`/`l` | Scrub laps in Replay |
|
|
||||||
| `y` | Cycle season year |
|
|
||||||
| `q` / `ctrl+c` | Exit |
|
|
||||||
|
|
||||||
## 🛠️ Tech Stack
|
The Web UI is local-first and should call the Go APIs under `/api/v1/...`; it should not read OpenF1 directly.
|
||||||
|
|
||||||
- **[Bubble Tea](https://github.com/charmbracelet/bubbletea)**: The TUI engine.
|
## Documentation
|
||||||
- **[Lipgloss](https://github.com/charmbracelet/lipgloss)**: For that sleek F1 styling.
|
|
||||||
- **[Bubbles](https://github.com/charmbracelet/bubbles)**: Common TUI components.
|
|
||||||
- **[OpenF1 API](https://api.openf1.org)**: The data source (Free, no API key needed).
|
|
||||||
|
|
||||||
## 🚥 Development
|
- [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.
|
||||||
|
|
||||||
Want to tinker under the hood?
|
## Status
|
||||||
|
|
||||||
```bash
|
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.
|
||||||
# Run tests
|
|
||||||
go test ./...
|
|
||||||
|
|
||||||
# View API integration tests (requires internet)
|
## License
|
||||||
go test -v ./internal/api
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📜 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.*
|
||||||
*Disclaimer: This project is unofficial and not associated with Formula 1 or the FIA in any way.*
|
|
||||||
|
|||||||
316
cmd/main.go
316
cmd/main.go
@@ -1,13 +1,19 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/api"
|
"github.com/AmanTahiliani/box-box/internal/api"
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/ingest"
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/news"
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/store"
|
||||||
"github.com/AmanTahiliani/box-box/internal/ui"
|
"github.com/AmanTahiliani/box-box/internal/ui"
|
||||||
"github.com/AmanTahiliani/box-box/internal/web"
|
"github.com/AmanTahiliani/box-box/internal/web"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
@@ -16,23 +22,113 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
webMode := flag.Bool("web", false, "Start web companion server instead of TUI")
|
webMode := flag.Bool("web", false, "Start web companion server instead of TUI")
|
||||||
port := flag.Int("port", 8080, "Port for web server (used with --web)")
|
port := flag.Int("port", 8080, "Port for web server (used with --web)")
|
||||||
|
ingestYear := flag.Int("ingest-year", 0, "Ingest OpenF1 meetings for a season year")
|
||||||
|
backfillSeason := flag.Int("backfill-season", 0, "Trigger full-season backfill/deep-ingestion for the given year")
|
||||||
|
ingestMeeting := flag.Int("ingest-meeting", 0, "Ingest meeting metadata and Race Hub datasets for all sessions")
|
||||||
|
ingestSession := flag.Int("ingest-session", 0, "Ingest Race Hub datasets for a session key")
|
||||||
|
ingestNews := flag.Bool("ingest-news", false, "Refresh RSS/Atom paddock briefing feeds")
|
||||||
|
prefetchTrackOutlines := flag.Int("prefetch-track-outlines", 0, "Warm the web track-outline cache for a season year (for web-only hosts, run before --web so /api/v1/track-outline can serve live maps)")
|
||||||
|
dryRun := flag.Bool("dry-run", false, "Preview ingestion without writing domain rows")
|
||||||
|
force := flag.Bool("force", false, "Re-ingest datasets even if already tracked in the session_coverage table as completed")
|
||||||
|
coverageYear := flag.Int("coverage", 0, "Show season coverage report for the given year")
|
||||||
|
dbPath := flag.String("db", "", "Domain database path (default: ~/.local/share/box-box/boxbox.db)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
if *coverageYear != 0 {
|
||||||
|
if err := runCoverageReport(*coverageYear, *dbPath); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "coverage report error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// BOXBOX_OPENF1_BASE_URL overrides the upstream OpenF1 API root. E2E runs
|
||||||
|
// point it at an unreachable address so tests stay hermetic and
|
||||||
|
// deterministic regardless of wall-clock date or network state.
|
||||||
|
baseURL := os.Getenv("BOXBOX_OPENF1_BASE_URL")
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://api.openf1.org"
|
||||||
|
}
|
||||||
|
|
||||||
var client *api.OpenF1Client
|
var client *api.OpenF1Client
|
||||||
if apiKey := os.Getenv("OPENF1_API_KEY"); apiKey != "" {
|
if apiKey := os.Getenv("OPENF1_API_KEY"); apiKey != "" {
|
||||||
client = api.NewOpenF1ClientWithKey("https://api.openf1.org", 15*time.Second, apiKey)
|
client = api.NewOpenF1ClientWithKey(baseURL, 15*time.Second, apiKey)
|
||||||
} else {
|
} else {
|
||||||
client = api.NewOpenF1Client("https://api.openf1.org", 15*time.Second)
|
client = api.NewOpenF1Client(baseURL, 15*time.Second)
|
||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
// Clean up old file-based cache (one-time migration).
|
// Clean up old file-based cache (one-time migration).
|
||||||
go api.CleanupOldFileCache()
|
go api.CleanupOldFileCache()
|
||||||
|
|
||||||
|
ingestFlags := 0
|
||||||
|
if *ingestYear != 0 {
|
||||||
|
ingestFlags++
|
||||||
|
}
|
||||||
|
if *backfillSeason != 0 {
|
||||||
|
ingestFlags++
|
||||||
|
}
|
||||||
|
if *ingestMeeting != 0 {
|
||||||
|
ingestFlags++
|
||||||
|
}
|
||||||
|
if *ingestSession != 0 {
|
||||||
|
ingestFlags++
|
||||||
|
}
|
||||||
|
if *ingestNews {
|
||||||
|
ingestFlags++
|
||||||
|
}
|
||||||
|
if *prefetchTrackOutlines != 0 {
|
||||||
|
ingestFlags++
|
||||||
|
}
|
||||||
|
if ingestFlags > 0 {
|
||||||
|
if ingestFlags > 1 {
|
||||||
|
fmt.Fprintln(os.Stderr, "box-box: only one of --ingest-year, --backfill-season, --ingest-meeting, --ingest-session, --ingest-news, or --prefetch-track-outlines may be set")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if *prefetchTrackOutlines != 0 {
|
||||||
|
if err := runTrackOutlinePrefetch(client, *prefetchTrackOutlines); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "box-box track outline prefetch error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if *ingestNews {
|
||||||
|
if err := runNewsIngestion(*dryRun, *dbPath); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "box-box ingest error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
yearVal := *ingestYear
|
||||||
|
if *backfillSeason != 0 {
|
||||||
|
yearVal = *backfillSeason
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runIngestion(client, yearVal, *ingestMeeting, *ingestSession, *force, *dryRun, *dbPath); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "box-box ingest error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if *webMode {
|
if *webMode {
|
||||||
log.SetOutput(os.Stderr) // web mode logs to stderr, not file
|
log.SetOutput(os.Stderr) // web mode logs to stderr, not file
|
||||||
fmt.Printf("box-box web → http://localhost:%d\n", *port)
|
fmt.Printf("box-box web → http://localhost:%d\n", *port)
|
||||||
srv := web.NewServer(client, *port)
|
|
||||||
|
var domainStore *store.Store
|
||||||
|
db := *dbPath
|
||||||
|
if db == "" {
|
||||||
|
db = store.DefaultDBPath()
|
||||||
|
}
|
||||||
|
if st, err := store.Open(db); err != nil {
|
||||||
|
log.Printf("web: domain database unavailable (%s): %v", db, err)
|
||||||
|
} else {
|
||||||
|
domainStore = st
|
||||||
|
defer domainStore.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := web.NewServer(client, *port, domainStore)
|
||||||
log.Fatal(srv.Start())
|
log.Fatal(srv.Start())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -56,3 +152,217 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runIngestion(client *api.OpenF1Client, year, meetingKey, sessionKey int, force, dryRun bool, dbPath string) error {
|
||||||
|
log.SetOutput(os.Stderr)
|
||||||
|
|
||||||
|
path := dbPath
|
||||||
|
if path == "" {
|
||||||
|
path = store.DefaultDBPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := store.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open domain database: %w", err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
opts := ingest.DefaultOptions()
|
||||||
|
opts.DryRun = dryRun
|
||||||
|
opts.Force = force
|
||||||
|
opts.Progress = ingest.NewProgress(os.Stderr)
|
||||||
|
|
||||||
|
svc := ingest.NewService(st, ingest.NewOpenF1Source(client), opts)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case year != 0:
|
||||||
|
_, err = svc.IngestYear(year)
|
||||||
|
case meetingKey != 0:
|
||||||
|
_, err = svc.IngestMeeting(meetingKey)
|
||||||
|
case sessionKey != 0:
|
||||||
|
_, err = svc.IngestSession(sessionKey)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTrackOutlinePrefetch(client *api.OpenF1Client, year int) error {
|
||||||
|
log.SetOutput(os.Stderr)
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "track outlines: warming HTTP cache %s for %d\n", api.DefaultCacheDBPath(), year)
|
||||||
|
|
||||||
|
meetings, err := client.GetMeetingsForYear(year)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch meetings for %d: %w", year, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := client.PrefetchTrackOutlinesForYear(year, meetings)
|
||||||
|
fmt.Printf(
|
||||||
|
"track outlines %d: cached %d/%d unique circuit(s) before, %d/%d after; %d skipped, %d fetched, %d failed\n",
|
||||||
|
result.Year,
|
||||||
|
result.CachedBefore,
|
||||||
|
result.UniqueCircuits,
|
||||||
|
result.CachedAfter,
|
||||||
|
result.UniqueCircuits,
|
||||||
|
result.Skipped,
|
||||||
|
result.Fetched,
|
||||||
|
result.Failed,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.CachedAfter == 0 {
|
||||||
|
return fmt.Errorf("cached zero track outlines for %d", year)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runNewsIngestion(dryRun bool, dbPath string) error {
|
||||||
|
log.SetOutput(os.Stderr)
|
||||||
|
|
||||||
|
path := dbPath
|
||||||
|
if path == "" {
|
||||||
|
path = store.DefaultDBPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
var st *store.Store
|
||||||
|
if !dryRun {
|
||||||
|
var err error
|
||||||
|
st, err = store.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open domain database: %w", err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
fmt.Fprintf(os.Stderr, "news: dry run, not writing to %s\n", path)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "news: refreshing feeds into %s\n", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
result, err := news.Refresh(ctx, st, news.RefreshOptions{
|
||||||
|
Client: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
DryRun: dryRun,
|
||||||
|
Progress: os.Stderr,
|
||||||
|
EnrichOG: !dryRun,
|
||||||
|
})
|
||||||
|
fmt.Fprintf(
|
||||||
|
os.Stderr,
|
||||||
|
"news: %d source(s) fetched, %d failed, %d item(s) fetched, %d upserted\n",
|
||||||
|
result.SourcesFetched,
|
||||||
|
result.SourcesFailed,
|
||||||
|
result.ItemsFetched,
|
||||||
|
result.ItemsUpserted,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCoverageReport(year int, dbPath string) error {
|
||||||
|
path := dbPath
|
||||||
|
if path == "" {
|
||||||
|
path = store.DefaultDBPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := store.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open domain database: %w", err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
rows, err := st.GetSeasonCoverage(year)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get season coverage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) == 0 {
|
||||||
|
fmt.Printf("No session coverage data found for year %d.\n", year)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type datasetStatus struct {
|
||||||
|
Status string
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionInfo struct {
|
||||||
|
MeetingName string
|
||||||
|
SessionName string
|
||||||
|
SessionKey int
|
||||||
|
Datasets map[string]datasetStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessions []sessionInfo
|
||||||
|
sessionMap := make(map[int]int)
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
idx, exists := sessionMap[row.SessionKey]
|
||||||
|
if !exists {
|
||||||
|
idx = len(sessions)
|
||||||
|
sessions = append(sessions, sessionInfo{
|
||||||
|
MeetingName: row.MeetingName,
|
||||||
|
SessionName: row.SessionName,
|
||||||
|
SessionKey: row.SessionKey,
|
||||||
|
Datasets: make(map[string]datasetStatus),
|
||||||
|
})
|
||||||
|
sessionMap[row.SessionKey] = idx
|
||||||
|
}
|
||||||
|
if row.Dataset != "" {
|
||||||
|
sessions[idx].Datasets[row.Dataset] = datasetStatus{
|
||||||
|
Status: row.Status,
|
||||||
|
Count: row.RowCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n--- Season %d Coverage Report ---\n\n", year)
|
||||||
|
fmt.Printf("%-35s | %-5s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s\n",
|
||||||
|
"Meeting / Session (Key)", "ID", "DR", "SR", "SG", "ST", "PS", "PO", "RC", "WE", "LA")
|
||||||
|
fmt.Println(strings.Repeat("-", 82))
|
||||||
|
|
||||||
|
for _, sess := range sessions {
|
||||||
|
statusChar := func(ds string) string {
|
||||||
|
dsStatus, ok := sess.Datasets[ds]
|
||||||
|
if !ok {
|
||||||
|
return "."
|
||||||
|
}
|
||||||
|
switch dsStatus.Status {
|
||||||
|
case "complete":
|
||||||
|
return "✓"
|
||||||
|
case "failed":
|
||||||
|
return "✗"
|
||||||
|
default:
|
||||||
|
return "."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nameCol := fmt.Sprintf("%s - %s (%d)", sess.MeetingName, sess.SessionName, sess.SessionKey)
|
||||||
|
if len(nameCol) > 35 {
|
||||||
|
nameCol = nameCol[:32] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%-35s | %-5d | %s | %s | %s | %s | %s | %s | %s | %s | %s\n",
|
||||||
|
nameCol,
|
||||||
|
sess.SessionKey,
|
||||||
|
statusChar("drivers"),
|
||||||
|
statusChar("session_result"),
|
||||||
|
statusChar("starting_grid"),
|
||||||
|
statusChar("stints"),
|
||||||
|
statusChar("pit_stops"),
|
||||||
|
statusChar("positions"),
|
||||||
|
statusChar("race_control"),
|
||||||
|
statusChar("weather"),
|
||||||
|
statusChar("laps"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(strings.Repeat("-", 82))
|
||||||
|
fmt.Println("\nLegend:")
|
||||||
|
fmt.Println(" [✓] Complete [✗] Failed [.] Pending/Unattempted")
|
||||||
|
fmt.Println("Datasets:")
|
||||||
|
fmt.Println(" DR: drivers SR: session_result SG: starting_grid")
|
||||||
|
fmt.Println(" ST: stints PS: pit_stops PO: positions")
|
||||||
|
fmt.Println(" RC: race_control WE: weather LA: laps")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
198
docs/PRODUCT_ROADMAP.md
Normal file
198
docs/PRODUCT_ROADMAP.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# box-box — Product & UX Roadmap
|
||||||
|
|
||||||
|
> An F1-geek + product/UX analysis of box-box as an F1 companion, and a
|
||||||
|
> prioritized plan to make it richer than "data in tables" — for both live race
|
||||||
|
> weekends and the quiet stretch between rounds.
|
||||||
|
|
||||||
|
## Where the app is today (honest read)
|
||||||
|
|
||||||
|
box-box has a strong data layer with two front doors (TUI + web) and six web
|
||||||
|
surfaces:
|
||||||
|
|
||||||
|
| Surface | What it does today | Where it's thin |
|
||||||
|
|---|---|---|
|
||||||
|
| **Command Center** (`/`) | Hero weekend + countdown, season calendar, championship snapshot, weekend schedule, news | Strong launcher, but tells you *when*, rarely *what to care about*. It's a menu, not a companion |
|
||||||
|
| **Race Hub** (`/race-hub`) | Per-session tabs: Overview, Race Story (position-evolution scrubber), Strategy (stints), Laps, Conditions, Race Control | `RaceStoryCanvas` is the best "beyond tables" work in the app. Everything else is still tables |
|
||||||
|
| **Live Timing** (`/live`) | SSE timing tower, battle chips, gap sparklines, pinned drivers, RC feed | Solid tower, but it's "a spreadsheet that updates" — no track map, no telemetry, no "what just happened" |
|
||||||
|
| **Championship** (`/championship`) | Standings + hub stats (wins/poles/form) + points simulator | Good; the simulator is a genuine differentiator |
|
||||||
|
| **Briefing** (`/briefing`) | 7 RSS feeds (FIA, BBC, Autosport, RaceFans, Guardian, RACER, F1 YouTube) + readability extraction | Passive reading list, not tied to the season narrative |
|
||||||
|
| **Data Library** (`/admin`) | Ingestion / coverage admin | Fine as-is |
|
||||||
|
|
||||||
|
**Biggest finding:** the app already *pulls* the richest data in F1 — GPS
|
||||||
|
`Location`, full car telemetry (throttle / brake / DRS / gear / rpm / speed),
|
||||||
|
team-radio audio, mini-sector `Segments`, speed traps, overtakes — and the web
|
||||||
|
app visualizes almost none of it. The **track map exists only in the TUI**
|
||||||
|
(`internal/ui/trackmap.go`); telemetry is fetched and shown to nobody. This is a
|
||||||
|
large latent asset.
|
||||||
|
|
||||||
|
## Core product thesis
|
||||||
|
|
||||||
|
An F1 companion serves two different jobs; the app currently treats them the same
|
||||||
|
(data, in tables, per session):
|
||||||
|
|
||||||
|
1. **On a race weekend — the "second screen."** The user is watching the
|
||||||
|
broadcast (or can't, and wants to *feel* it). They want: what's happening now,
|
||||||
|
why it matters, what to watch next. The broadcast supplies emotion; box-box
|
||||||
|
should supply **the data the broadcast doesn't show** — the delta the director
|
||||||
|
cut away from, the undercut developing, the tyre cliff approaching.
|
||||||
|
|
||||||
|
2. **Between races — "understand the season."** No live action. The user wants to
|
||||||
|
make sense of what happened and anticipate what's next: rewatch the story of
|
||||||
|
the last race, argue strategy, track the title fight, get smart before the next
|
||||||
|
round.
|
||||||
|
|
||||||
|
Everything below makes each job *feel* like a companion instead of a database.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Race-weekend experience ("second screen")
|
||||||
|
|
||||||
|
### 1. Live Track Map — highest-leverage missing feature
|
||||||
|
Port the TUI's GPS outline (`trackmap.go`, `GetLocation`) to a web SVG dot-map:
|
||||||
|
cars as team-colored dots on the circuit outline, DRS zones highlighted, sectors
|
||||||
|
tinted by status. Turn mini-sectors purple/green live using the `Segments` data
|
||||||
|
already fetched. Tap a car → mini-telemetry readout. This is what turns "updating
|
||||||
|
spreadsheet" into "I'm watching the race."
|
||||||
|
|
||||||
|
### 2. "What just happened" synthesized event rail — the companion voice
|
||||||
|
Combine Race Control + overtakes + pit stops + position deltas into plain-English
|
||||||
|
beats: *"LAP 34 — VER pits (2.4s), rejoins P4 behind NOR — undercut on RUS is
|
||||||
|
live."* The inputs all exist; this is a synthesis layer, not new data. It's the
|
||||||
|
difference between *data* and *commentary*.
|
||||||
|
|
||||||
|
### 3. Telemetry compare overlay
|
||||||
|
On the tower / a driver panel, pick two drivers → overlaid speed/throttle/brake
|
||||||
|
traces + delta-time graph for their last comparable lap. *The* tifosi feature —
|
||||||
|
"where did Leclerc lose the lap." `/api/v1/laps/comparison` + car data already
|
||||||
|
exist; this needs a chart, not a table.
|
||||||
|
|
||||||
|
### 4. Live strategy / tyre-degradation view
|
||||||
|
Stints + tyre age + pit-lane times → live pit window and tyre-cliff panel, with
|
||||||
|
**undercut/overcut threat** indicators from gap-to-car-behind vs. pit-loss time.
|
||||||
|
The pit-window calculator exists in the TUI (`pitwindow.go`); bring it to the web
|
||||||
|
and make it live.
|
||||||
|
|
||||||
|
### 5. Team radio, surfaced
|
||||||
|
`GetTeamRadio` returns audio clips. Add a "Radio" ticker on the live page — play
|
||||||
|
button + driver + timestamp. Peak emotional content, and no rival dashboard has it
|
||||||
|
inline.
|
||||||
|
|
||||||
|
### 6. Session-aware Command Center
|
||||||
|
When a session is live, the home hero should pull the top battles, leader gap, and
|
||||||
|
last RC flag onto the front page instead of only saying "LIVE." Make the front
|
||||||
|
page reactive to the moment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Between-races experience ("understand the season")
|
||||||
|
|
||||||
|
### 7. Race replay as a first-class story
|
||||||
|
Grow `RaceStoryCanvas` from a scrubber into a narrative replay: auto-generated
|
||||||
|
"chapters" (start, first pit phase, VSC, decisive overtake, finish) with a
|
||||||
|
headline each (derived from RC + position swings), and scrub the position graph
|
||||||
|
and track map together. The "watch the race in 90 seconds" mode that makes people
|
||||||
|
open the app on a Tuesday.
|
||||||
|
|
||||||
|
### 8. Driver pages + rivalry view (a real gap)
|
||||||
|
No driver profile exists in the web app. Add a **driver page** (season form,
|
||||||
|
teammate H2H — already computed in the champ hub — quali vs. race pace, tyre
|
||||||
|
management, track-by-track) and a **rivalry view** (two drivers → cumulative
|
||||||
|
points, H2H, gap-over-season). Feeds the argument every fan has.
|
||||||
|
|
||||||
|
### 9. Next-race preview / "get smart" page
|
||||||
|
Between races the app goes quiet. Fill it: circuit characteristics, last year's
|
||||||
|
result (2023/24 is cached), typical strategy (1 vs 2 stop), DRS zones, weather
|
||||||
|
outlook, and the storylines (title-fight math to watch). Turn dead air into
|
||||||
|
anticipation.
|
||||||
|
|
||||||
|
### 10. Championship scenario narratives
|
||||||
|
Extend the simulator from "drag points around" to narrative permutations:
|
||||||
|
*"VER clinches if he outscores NOR by 9 this weekend"* / *"first race McLaren can
|
||||||
|
seal constructors'."* The stuff fans actually search for.
|
||||||
|
|
||||||
|
### 11. Briefing → calendar-aware season digest
|
||||||
|
Reframe the news reader into a paddock digest tied to the calendar: group by GP,
|
||||||
|
tag by team/driver, surface a "since last race" summary. Optionally an
|
||||||
|
LLM-generated weekly briefing (a `claude-haiku` summarization pass over ingested
|
||||||
|
RSS — cheap and on-brand).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New / better data sources
|
||||||
|
|
||||||
|
- **Jolpica (Ergast successor, `api.jolpi.ca`)** — free historical results back to
|
||||||
|
1950: qualifying, pit stops, lap times, circuit metadata. Unlocks all-time
|
||||||
|
records, "best-ever at this track," and career stats OpenF1 (2023+) can't give.
|
||||||
|
High value for driver pages and the "get smart" preview.
|
||||||
|
- **OpenF1 weather timeseries** — already fetched; plot it as a session-long strip
|
||||||
|
(track temp / rain / wind) instead of a table. Weather narrates strategy.
|
||||||
|
- **Circuit metadata / DRS zones / corner names** — enriches the track map and
|
||||||
|
previews. Some is in OpenF1 circuit info; curate the rest once as static data.
|
||||||
|
- **Static per-race context** — a tiny curated JSON per round (tyre allocation,
|
||||||
|
notable stats) goes a long way for previews.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-cutting UX principles
|
||||||
|
|
||||||
|
1. **Replace tables with a shape wherever a shape carries the meaning.** Lap times
|
||||||
|
→ a trace with fastest lap marked. Stints → a horizontal tyre timeline. Gaps →
|
||||||
|
the sparkline already shipped. Keep tables only where data is genuinely tabular
|
||||||
|
(standings) — but annotate them.
|
||||||
|
2. **Always answer "so what?"** Every number sits next to its meaning (a gap next
|
||||||
|
to "undercut live," a tyre age next to "5 laps from the cliff").
|
||||||
|
3. **One primary "moment" per screen.** The Command Center should always have a
|
||||||
|
single obvious "here's what to watch/do now."
|
||||||
|
4. **Make between-races feel alive.** The app currently rewards you only on
|
||||||
|
Sundays. Previews, digests, replays, and rivalries give a reason to open it
|
||||||
|
midweek.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested sequencing (impact × effort)
|
||||||
|
|
||||||
|
**Phase 1 — turn live into a companion (highest impact; data already in hand)**
|
||||||
|
1. Web track map (SVG + GPS + mini-sectors)
|
||||||
|
2. Telemetry compare overlay (speed/throttle/brake + delta)
|
||||||
|
3. "What just happened" synthesized event rail
|
||||||
|
4. Team radio ticker
|
||||||
|
|
||||||
|
**Phase 2 — own the between-races window**
|
||||||
|
5. Narrative race replay (grow `RaceStoryCanvas`)
|
||||||
|
6. Driver pages + rivalry view
|
||||||
|
7. Next-race preview page
|
||||||
|
|
||||||
|
**Phase 3 — depth & reach**
|
||||||
|
8. Jolpica/Ergast historical integration + all-time records
|
||||||
|
9. Championship scenario narratives
|
||||||
|
10. Calendar-aware briefing digest (optional LLM summaries)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution tracking (GitHub)
|
||||||
|
|
||||||
|
This roadmap is tracked on GitHub:
|
||||||
|
|
||||||
|
- **Project board:** https://github.com/users/AmanTahiliani/projects/2 ("box-box Roadmap")
|
||||||
|
- **Epics:** issues #2–#8 (label `epic`), one per epic above, on Phase milestones
|
||||||
|
- **Stories:** issues #9–#31, wired as native **sub-issues** under their epic (progress rolls up automatically)
|
||||||
|
- **Labels:** `epic`, `enabler`, `research`, `area:{live,viz,between-races,championship,data,ux}`
|
||||||
|
- **Milestones:** `Phase 1 — Live Companion`, `Phase 2 — Between-Races`, `Phase 3 — Depth & Reach`
|
||||||
|
|
||||||
|
**Board fields:** `Stage` (Icebox → Research → Ready → In Progress → In Review → Done),
|
||||||
|
`Priority` (P0–P2), `Effort` (S/M/L), `Phase` (1–3).
|
||||||
|
|
||||||
|
**Working model — active vs. bank:**
|
||||||
|
- Only **1–2 epics active** at a time (currently **E1 Live Race Companion** + **E2 Viz
|
||||||
|
Primitives**); the other five epics are the theme-level idea bank.
|
||||||
|
- `Stage = Icebox` is the story-level bank. The 10 E1/E2 stories are seeded to
|
||||||
|
`Ready`; everything else is `Icebox`. Promote a handful to `Ready`/`In Progress`
|
||||||
|
per cycle; use `Research` to scope a vague idea before it's `Ready`.
|
||||||
|
**Board views** (built):
|
||||||
|
- **Backlog** — Table, all epics with expandable sub-issues: the full bank.
|
||||||
|
- **Board** — grouped by `Stage`, filtered `-stage:Icebox`: the active-WIP wall.
|
||||||
|
- **Roadmap** — Table grouped by `Phase` (Phase 1/2/3). Note: this is a
|
||||||
|
phase-grouped table, not a timeline. A true timeline roadmap needs a date or
|
||||||
|
iteration field (GitHub won't draw/persist a roadmap layout without one); add a
|
||||||
|
`Target date` field and populate it once phases have real target dates, then
|
||||||
|
switch this view to the Roadmap layout.
|
||||||
BIN
docs/assets/command-center.jpg
Normal file
BIN
docs/assets/command-center.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
75
docs/data-and-operations.md
Normal file
75
docs/data-and-operations.md
Normal 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
94
docs/getting-started.md
Normal 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`.
|
||||||
BIN
docs/phase-1/screenshots/command-center-hero-between.png
Normal file
BIN
docs/phase-1/screenshots/command-center-hero-between.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
BIN
docs/phase-1/screenshots/live-event-tyre-radio-mocked.png
Normal file
BIN
docs/phase-1/screenshots/live-event-tyre-radio-mocked.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/phase-1/screenshots/race-hub-compare-telemetry-delta.png
Normal file
BIN
docs/phase-1/screenshots/race-hub-compare-telemetry-delta.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
BIN
docs/phase-1/screenshots/race-hub-strategy-timeline.png
Normal file
BIN
docs/phase-1/screenshots/race-hub-strategy-timeline.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
59
docs/testing.md
Normal file
59
docs/testing.md
Normal 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__/`.
|
||||||
179
documentations/refactor/01-data-sources.md
Normal file
179
documentations/refactor/01-data-sources.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# Data Sources
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`box-box` should treat data sources as inputs to a local product database, not as
|
||||||
|
page-level dependencies. The current app fetches too much data on demand from
|
||||||
|
OpenF1, which breaks down during free-tier lockouts and makes non-live screens
|
||||||
|
feel empty. The live mode succeeds because it uses the official F1 live timing
|
||||||
|
feed directly.
|
||||||
|
|
||||||
|
## Confirmed Sources
|
||||||
|
|
||||||
|
### OpenF1 REST API
|
||||||
|
|
||||||
|
Reference: https://openf1.org/docs/
|
||||||
|
|
||||||
|
Current usage:
|
||||||
|
|
||||||
|
- Meetings and sessions.
|
||||||
|
- Drivers.
|
||||||
|
- Championship standings.
|
||||||
|
- Session results and starting grid.
|
||||||
|
- Laps, stints, pit stops, positions, intervals.
|
||||||
|
- Race control, weather, overtakes.
|
||||||
|
- Car data, location, team radio metadata.
|
||||||
|
|
||||||
|
Strengths:
|
||||||
|
|
||||||
|
- Good historical/session data source.
|
||||||
|
- JSON over simple HTTP.
|
||||||
|
- Broad endpoint coverage.
|
||||||
|
- Query filtering by fields and time ranges.
|
||||||
|
|
||||||
|
Limitations:
|
||||||
|
|
||||||
|
- Free-tier access can be locked during live sessions.
|
||||||
|
- On-demand fetching is unreliable as a product behavior.
|
||||||
|
- API schema or access rules can change.
|
||||||
|
- High-volume endpoints can be expensive to fetch repeatedly.
|
||||||
|
|
||||||
|
Policy:
|
||||||
|
|
||||||
|
- Use OpenF1 primarily for ingestion and backfill.
|
||||||
|
- Do not make historical Web pages depend on fresh OpenF1 calls.
|
||||||
|
- Store successful fetches into the local domain database and raw payload log.
|
||||||
|
|
||||||
|
### Official F1 SignalR Live Feed
|
||||||
|
|
||||||
|
Endpoint: https://livetiming.formula1.com/signalr
|
||||||
|
|
||||||
|
Current code connects to the old ASP.NET SignalR protocol, negotiates a
|
||||||
|
connection token, opens a websocket, and subscribes to the `Streaming` hub.
|
||||||
|
|
||||||
|
Current subscribed topics:
|
||||||
|
|
||||||
|
- `Heartbeat`
|
||||||
|
- `TimingData`
|
||||||
|
- `DriverList`
|
||||||
|
- `LapCount`
|
||||||
|
- `ExtrapolatedClock`
|
||||||
|
- `TrackStatus`
|
||||||
|
- `RaceControlMessages`
|
||||||
|
- `WeatherData`
|
||||||
|
- `SessionInfo`
|
||||||
|
- `CurrentTyres`
|
||||||
|
- `TimingAppData`
|
||||||
|
- `TimingStats`
|
||||||
|
|
||||||
|
Strengths:
|
||||||
|
|
||||||
|
- Best current source for live timing.
|
||||||
|
- Provides race-control updates quickly.
|
||||||
|
- Avoids OpenF1 REST lockout during active sessions.
|
||||||
|
- Powers the strongest part of the existing app.
|
||||||
|
|
||||||
|
Limitations:
|
||||||
|
|
||||||
|
- Payloads are less formally documented than OpenF1.
|
||||||
|
- Topic schemas can drift.
|
||||||
|
- Testing live behavior is difficult outside active sessions.
|
||||||
|
- Current parser lives in `internal/ui`, which couples live source handling to
|
||||||
|
the TUI layer.
|
||||||
|
|
||||||
|
Policy:
|
||||||
|
|
||||||
|
- Treat SignalR as the authoritative live source while a session is active.
|
||||||
|
- Extract parsing and live-state logic into reusable backend/domain code.
|
||||||
|
- Forward live state to the Web UI through SSE initially.
|
||||||
|
- Research whether live snapshots/events should be persisted.
|
||||||
|
|
||||||
|
### Existing SQLite HTTP Cache
|
||||||
|
|
||||||
|
Current location: user cache directory under `box-box/cache.db`.
|
||||||
|
|
||||||
|
Current behavior:
|
||||||
|
|
||||||
|
- Stores raw HTTP responses by URL.
|
||||||
|
- Applies TTL rules based on URL patterns.
|
||||||
|
- Can return stale responses when OpenF1 fails.
|
||||||
|
- Stores track outlines in a structured table.
|
||||||
|
|
||||||
|
Strengths:
|
||||||
|
|
||||||
|
- Useful as a fallback.
|
||||||
|
- Already integrated with the OpenF1 client.
|
||||||
|
- Reduces repeated network calls.
|
||||||
|
|
||||||
|
Limitations:
|
||||||
|
|
||||||
|
- Not a queryable domain model.
|
||||||
|
- URL keys are poor product identifiers.
|
||||||
|
- Cannot easily power analytics, replay, ingestion status, or data provenance.
|
||||||
|
- Pruning/TTL behavior is cache-oriented, not history-oriented.
|
||||||
|
|
||||||
|
Policy:
|
||||||
|
|
||||||
|
- Keep the raw cache as a fallback layer.
|
||||||
|
- Do not use it as the primary application database.
|
||||||
|
- Add a separate domain schema for product features.
|
||||||
|
|
||||||
|
## Candidate Source
|
||||||
|
|
||||||
|
### Official F1 Static Archived Timing Files
|
||||||
|
|
||||||
|
Reference:
|
||||||
|
https://livef1.goktugocal.com/livetimingf1/data_topics.html
|
||||||
|
|
||||||
|
Examples in public references include:
|
||||||
|
|
||||||
|
- `SessionInfo.json`
|
||||||
|
- `ArchiveStatus.json`
|
||||||
|
- `TrackStatus.jsonStream`
|
||||||
|
- `SessionData.json`
|
||||||
|
- `TyreStintSeries.json`
|
||||||
|
- `SessionStatus.json`
|
||||||
|
- `TimingDataF1.json`
|
||||||
|
|
||||||
|
Potential strengths:
|
||||||
|
|
||||||
|
- Could provide replay-quality archived live timing.
|
||||||
|
- May fill gaps between OpenF1 REST data and SignalR live data.
|
||||||
|
- May support historical race reconstruction.
|
||||||
|
|
||||||
|
Known uncertainties:
|
||||||
|
|
||||||
|
- Session path mapping must be researched.
|
||||||
|
- Stability and access guarantees are unclear.
|
||||||
|
- Topic schemas and file availability may vary by year/session.
|
||||||
|
- Legal and operational usage expectations need review.
|
||||||
|
|
||||||
|
Policy for now:
|
||||||
|
|
||||||
|
- Do not make core architecture depend on this source yet.
|
||||||
|
- Assign a dedicated research track to validate feasibility.
|
||||||
|
- If adopted, ingest it through the same raw-plus-normalized source pipeline.
|
||||||
|
|
||||||
|
## Source Authority Tiers
|
||||||
|
|
||||||
|
1. Local SQLite domain database.
|
||||||
|
- Primary read source for Web UI historical and completed-session data.
|
||||||
|
2. Official F1 SignalR live feed.
|
||||||
|
- Primary source during active sessions.
|
||||||
|
3. OpenF1 REST ingestion/backfill.
|
||||||
|
- Primary source for populating local historical data.
|
||||||
|
4. Optional F1 static archive source.
|
||||||
|
- Research candidate for richer replay and archived live timing.
|
||||||
|
5. Raw HTTP cache fallback.
|
||||||
|
- Last-resort resilience layer, not a product data model.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Should SignalR snapshots/events be persisted during live sessions?
|
||||||
|
- If persisted, should live data become the authoritative record for that
|
||||||
|
session or a supplemental event stream?
|
||||||
|
- Which OpenF1 endpoints are essential for v1 local-first Race Hub?
|
||||||
|
- Can static archived timing files be mapped reliably from OpenF1 sessions?
|
||||||
|
- What data should be refreshed after a session ends, and when should it become
|
||||||
|
immutable?
|
||||||
|
|
||||||
216
documentations/refactor/02-backend-architecture.md
Normal file
216
documentations/refactor/02-backend-architecture.md
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
# Backend Architecture
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The backend should move from direct page handlers calling OpenF1 into a layered
|
||||||
|
local-first architecture. Source clients fetch data, ingestion persists it,
|
||||||
|
store/query packages expose domain reads, and Web handlers return read models
|
||||||
|
with source and freshness metadata.
|
||||||
|
|
||||||
|
## Proposed Package Boundaries
|
||||||
|
|
||||||
|
### `internal/store`
|
||||||
|
|
||||||
|
Owns SQLite as the local domain database.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- Schema creation and migrations.
|
||||||
|
- Typed upsert methods for domain records.
|
||||||
|
- Typed read methods for screens and backend services.
|
||||||
|
- Raw payload storage.
|
||||||
|
- Ingestion metadata and provenance.
|
||||||
|
- Transactions and batch writes.
|
||||||
|
|
||||||
|
Non-goals:
|
||||||
|
|
||||||
|
- Calling OpenF1 directly.
|
||||||
|
- Knowing Web UI route behavior.
|
||||||
|
- Rendering derived frontend-specific structures unless they are shared read
|
||||||
|
models.
|
||||||
|
|
||||||
|
### `internal/ingest`
|
||||||
|
|
||||||
|
Coordinates backfill, refresh, and opportunistic fetches.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- Ingest year, meeting, or session.
|
||||||
|
- Fetch required endpoints through source clients.
|
||||||
|
- Persist raw payloads and normalized rows.
|
||||||
|
- Track partial successes and failures.
|
||||||
|
- Support resumable, idempotent runs.
|
||||||
|
- Respect rate limits and free-tier constraints.
|
||||||
|
|
||||||
|
Default ingestion modes:
|
||||||
|
|
||||||
|
- CLI bulk ingestion for years, meetings, and sessions.
|
||||||
|
- Opportunistic small fetches in Web mode when a user opens missing data.
|
||||||
|
- Explicit refresh mode for completed data when needed.
|
||||||
|
|
||||||
|
Rate-limit defaults:
|
||||||
|
|
||||||
|
- Bulk ingestion must be resumable and idempotent.
|
||||||
|
- Bulk ingestion should default to conservative sequential fetching with a
|
||||||
|
delay between OpenF1 requests.
|
||||||
|
- Failed requests should use bounded exponential backoff with jitter.
|
||||||
|
- HTTP 429 and live-session lockout should pause or stop the current run rather
|
||||||
|
than tight-loop retries.
|
||||||
|
- `--dry-run` should show planned datasets and estimated request count before a
|
||||||
|
large ingest.
|
||||||
|
|
||||||
|
### OpenF1 Source Client Layer
|
||||||
|
|
||||||
|
The current `internal/api` client can remain, but it should become one source
|
||||||
|
adapter rather than the main application data layer.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- Build OpenF1 URLs.
|
||||||
|
- Apply auth headers when `OPENF1_API_KEY` exists.
|
||||||
|
- Decode source payloads into source/domain structs.
|
||||||
|
- Preserve stale fallback behavior where useful.
|
||||||
|
|
||||||
|
Future direction:
|
||||||
|
|
||||||
|
- Make source fetches observable by ingestion metadata.
|
||||||
|
- Avoid direct UI route dependency on source calls.
|
||||||
|
|
||||||
|
### Live Timing Bridge
|
||||||
|
|
||||||
|
The current live parser should be extracted out of `internal/ui` into reusable
|
||||||
|
backend/domain logic.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- Connect to official F1 SignalR.
|
||||||
|
- Parse topic payloads into typed live events/state.
|
||||||
|
- Maintain current live snapshot.
|
||||||
|
- Broadcast snapshots to Web clients through SSE.
|
||||||
|
- Feed TUI live mode without coupling parser code to Bubble Tea.
|
||||||
|
- Persist live events/snapshots as an append-only stream once the bridge is
|
||||||
|
extracted.
|
||||||
|
|
||||||
|
Persistence policy:
|
||||||
|
|
||||||
|
- Live SignalR data should be stored separately from normalized post-session
|
||||||
|
OpenF1 records.
|
||||||
|
- Live data represents what was broadcast at the time, not necessarily the
|
||||||
|
corrected final historical record.
|
||||||
|
- A later reconciliation step can compare live stream data with OpenF1
|
||||||
|
post-session records.
|
||||||
|
|
||||||
|
### Web API Read Models
|
||||||
|
|
||||||
|
Web handlers should become thin adapters from query services to JSON.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- Validate route/query parameters.
|
||||||
|
- Call local-first query/read services.
|
||||||
|
- Return consistent response envelopes.
|
||||||
|
- Include source/freshness metadata.
|
||||||
|
|
||||||
|
Suggested response metadata:
|
||||||
|
|
||||||
|
- `source`: `local`, `api`, `cache`, `live`, or `missing`.
|
||||||
|
- `last_ingested_at`.
|
||||||
|
- `is_stale`.
|
||||||
|
- `missing_datasets`.
|
||||||
|
- `errors` where partial data is returned.
|
||||||
|
|
||||||
|
### CLI Ingestion Commands
|
||||||
|
|
||||||
|
CLI commands should make bulk ingestion explicit and user-controlled.
|
||||||
|
|
||||||
|
Candidate commands/flags:
|
||||||
|
|
||||||
|
- `--ingest-year 2024`
|
||||||
|
- `--ingest-meeting <meeting_key>`
|
||||||
|
- `--ingest-session <session_key>`
|
||||||
|
- `--refresh`
|
||||||
|
- `--dry-run`
|
||||||
|
|
||||||
|
CLI output should include:
|
||||||
|
|
||||||
|
- What will be fetched.
|
||||||
|
- What is already local.
|
||||||
|
- What succeeded.
|
||||||
|
- What failed.
|
||||||
|
- Whether the run is resumable.
|
||||||
|
|
||||||
|
## Local-First Read Behavior
|
||||||
|
|
||||||
|
Default rule:
|
||||||
|
|
||||||
|
1. Read from local domain DB.
|
||||||
|
2. If missing and request scope is small, optionally fetch from OpenF1.
|
||||||
|
3. Persist successful fetches.
|
||||||
|
4. Return local/read-model data with metadata.
|
||||||
|
5. If OpenF1 is unavailable, return partial local data and clear missing/stale
|
||||||
|
metadata rather than an empty page.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Opening a completed race with all local data should perform no OpenF1 calls.
|
||||||
|
- Opening a completed race with missing weather may opportunistically fetch only
|
||||||
|
weather.
|
||||||
|
- Opening a whole season should not silently trigger a large backfill.
|
||||||
|
- During live-session lockout, historical pages should still render from local
|
||||||
|
data.
|
||||||
|
|
||||||
|
## Opportunistic Fetch Policy
|
||||||
|
|
||||||
|
Allowed by default:
|
||||||
|
|
||||||
|
- Single meeting sessions.
|
||||||
|
- Single session results/grid/weather/race control.
|
||||||
|
- Small metadata gaps needed to render a screen.
|
||||||
|
|
||||||
|
Not allowed by default:
|
||||||
|
|
||||||
|
- Full season backfills.
|
||||||
|
- High-volume telemetry/location/car data.
|
||||||
|
- Repeated refresh loops during API lockout.
|
||||||
|
- Silent destructive refresh of completed local data.
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
|
||||||
|
The existing SQLite HTTP cache should remain operational during the refactor.
|
||||||
|
The new domain database should be introduced without requiring users to delete
|
||||||
|
their current cache.
|
||||||
|
|
||||||
|
Default migration stance:
|
||||||
|
|
||||||
|
- Keep the current cache tables and stale fallback behavior intact.
|
||||||
|
- Introduce domain tables through `internal/store`.
|
||||||
|
- Prefer a separate domain database file at first if it materially reduces
|
||||||
|
migration risk; using the same SQLite file remains acceptable if table names
|
||||||
|
and migrations are carefully isolated.
|
||||||
|
- Do not attempt to transform arbitrary URL-keyed cache entries into domain rows
|
||||||
|
automatically.
|
||||||
|
- New ingestion runs should populate domain tables from fresh source fetches or
|
||||||
|
explicitly supported raw payloads.
|
||||||
|
- Web routes can migrate endpoint by endpoint from source-first to local-first.
|
||||||
|
|
||||||
|
## Failure Modes
|
||||||
|
|
||||||
|
The backend should explicitly represent:
|
||||||
|
|
||||||
|
- Local data available.
|
||||||
|
- Local data partial.
|
||||||
|
- Local data missing.
|
||||||
|
- OpenF1 locked/unavailable.
|
||||||
|
- Stale cache fallback used.
|
||||||
|
- Live feed connected/disconnected.
|
||||||
|
- Ingestion partial failure.
|
||||||
|
|
||||||
|
The Web UI should be able to show these states without guesswork.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Should API response envelopes be introduced globally or per endpoint during
|
||||||
|
migration?
|
||||||
|
- How should source schema drift be detected and surfaced?
|
||||||
|
- What is the minimum dataset required for a Race Hub to be considered
|
||||||
|
complete?
|
||||||
195
documentations/refactor/03-database-design.md
Normal file
195
documentations/refactor/03-database-design.md
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
# Database Design
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
SQLite should become the local source of truth for historical and completed
|
||||||
|
session data. The design should store both raw source payloads and normalized
|
||||||
|
domain rows. Raw payloads preserve source fidelity and make reprocessing
|
||||||
|
possible. Normalized rows power fast product queries, analytics, and stable Web
|
||||||
|
screens.
|
||||||
|
|
||||||
|
## Storage Strategy
|
||||||
|
|
||||||
|
Use two layers:
|
||||||
|
|
||||||
|
1. Raw source storage.
|
||||||
|
- Preserve fetched payloads exactly enough to reprocess later.
|
||||||
|
- Track source, endpoint/topic, parameters, fetch time, status, and errors.
|
||||||
|
2. Normalized domain tables.
|
||||||
|
- Queryable application data keyed by F1 identifiers.
|
||||||
|
- Built from successful source payloads.
|
||||||
|
- Safe to upsert idempotently.
|
||||||
|
|
||||||
|
## Raw Payload Tables
|
||||||
|
|
||||||
|
Candidate tables:
|
||||||
|
|
||||||
|
- `source_payloads`
|
||||||
|
- `id`
|
||||||
|
- `source`
|
||||||
|
- `resource`
|
||||||
|
- `request_key`
|
||||||
|
- `url_or_topic`
|
||||||
|
- `params_json`
|
||||||
|
- `payload_json`
|
||||||
|
- `fetched_at`
|
||||||
|
- `status`
|
||||||
|
- `error`
|
||||||
|
- `schema_version`
|
||||||
|
|
||||||
|
- `ingestion_runs`
|
||||||
|
- `id`
|
||||||
|
- `scope_type`
|
||||||
|
- `scope_key`
|
||||||
|
- `started_at`
|
||||||
|
- `finished_at`
|
||||||
|
- `status`
|
||||||
|
- `refresh`
|
||||||
|
- `summary_json`
|
||||||
|
|
||||||
|
- `ingestion_items`
|
||||||
|
- `id`
|
||||||
|
- `run_id`
|
||||||
|
- `dataset`
|
||||||
|
- `meeting_key`
|
||||||
|
- `session_key`
|
||||||
|
- `status`
|
||||||
|
- `source`
|
||||||
|
- `started_at`
|
||||||
|
- `finished_at`
|
||||||
|
- `error`
|
||||||
|
|
||||||
|
## Normalized Domain Tables
|
||||||
|
|
||||||
|
Core calendar/session tables:
|
||||||
|
|
||||||
|
- `meetings`
|
||||||
|
- `sessions`
|
||||||
|
- `circuits`
|
||||||
|
|
||||||
|
Participant tables:
|
||||||
|
|
||||||
|
- `drivers`
|
||||||
|
- `session_drivers`
|
||||||
|
- `teams` or team snapshots by season/session.
|
||||||
|
|
||||||
|
Classification and standings:
|
||||||
|
|
||||||
|
- `session_results`
|
||||||
|
- `starting_grids`
|
||||||
|
- `driver_championship_standings`
|
||||||
|
- `constructor_championship_standings`
|
||||||
|
|
||||||
|
Race/session analysis:
|
||||||
|
|
||||||
|
- `laps`
|
||||||
|
- `stints`
|
||||||
|
- `pit_stops`
|
||||||
|
- `positions`
|
||||||
|
- `intervals`
|
||||||
|
- `race_control_messages`
|
||||||
|
- `weather_samples`
|
||||||
|
- `overtakes`
|
||||||
|
|
||||||
|
Telemetry and spatial data:
|
||||||
|
|
||||||
|
- `car_data_samples`
|
||||||
|
- `location_samples`
|
||||||
|
- `track_outlines`
|
||||||
|
|
||||||
|
Media metadata:
|
||||||
|
|
||||||
|
- `team_radio_messages`
|
||||||
|
|
||||||
|
Derived/read-model candidates:
|
||||||
|
|
||||||
|
- `session_dataset_status`
|
||||||
|
- `race_key_moments`
|
||||||
|
- `driver_session_summaries`
|
||||||
|
- `race_lap_snapshots`
|
||||||
|
|
||||||
|
Derived tables should be added only when query cost or UI complexity justifies
|
||||||
|
them. Start with normalized source tables and build read models in Go unless
|
||||||
|
performance argues otherwise.
|
||||||
|
|
||||||
|
## Provenance and Freshness
|
||||||
|
|
||||||
|
Each normalized dataset should be traceable to source ingestion metadata.
|
||||||
|
|
||||||
|
Track:
|
||||||
|
|
||||||
|
- Source: OpenF1, SignalR, static archive, manual, cache.
|
||||||
|
- First ingested time.
|
||||||
|
- Last ingested time.
|
||||||
|
- Last successful refresh.
|
||||||
|
- Last error.
|
||||||
|
- Completion status.
|
||||||
|
- Whether stale fallback was used.
|
||||||
|
|
||||||
|
This metadata supports the Data Library screen and makes partial data honest.
|
||||||
|
|
||||||
|
## Immutability Policy
|
||||||
|
|
||||||
|
Completed historical sessions:
|
||||||
|
|
||||||
|
- Treat as immutable after successful ingestion.
|
||||||
|
- Do not refetch unless `--refresh` is explicitly requested.
|
||||||
|
- Allow reprocessing from raw payloads if schema or read models change.
|
||||||
|
|
||||||
|
Current/future sessions:
|
||||||
|
|
||||||
|
- Treat as refreshable.
|
||||||
|
- Allow opportunistic metadata fetches.
|
||||||
|
- Avoid high-volume refreshes without explicit action.
|
||||||
|
|
||||||
|
Live sessions:
|
||||||
|
|
||||||
|
- SignalR is authoritative for live state.
|
||||||
|
- Persist live data as an append-only event/snapshot stream after the live
|
||||||
|
bridge is extracted.
|
||||||
|
- Keep live data separate from normalized post-session OpenF1 records until
|
||||||
|
reconciliation is designed.
|
||||||
|
- Treat live data as the record of what was seen during the session, not as the
|
||||||
|
corrected final historical truth.
|
||||||
|
|
||||||
|
## Migration And File Layout
|
||||||
|
|
||||||
|
The current project already creates a SQLite cache database for raw HTTP
|
||||||
|
responses. The domain database should be introduced without breaking that cache.
|
||||||
|
|
||||||
|
Default stance:
|
||||||
|
|
||||||
|
- Existing cache tables are infrastructure, not product domain state.
|
||||||
|
- New domain tables should be owned by `internal/store`.
|
||||||
|
- A separate domain DB file is the lower-risk first implementation unless a
|
||||||
|
schema design pass shows strong reasons to reuse the same file.
|
||||||
|
- If the same file is reused, domain tables must be namespaced clearly and
|
||||||
|
migrations must avoid touching the current `cache` table except through
|
||||||
|
deliberate cache work.
|
||||||
|
- Do not auto-migrate URL-keyed cache entries into domain rows.
|
||||||
|
- Use explicit ingestion to populate the new domain tables.
|
||||||
|
|
||||||
|
## High-Volume Data
|
||||||
|
|
||||||
|
High-volume tables need careful indexing and retention decisions:
|
||||||
|
|
||||||
|
- `car_data_samples`
|
||||||
|
- `location_samples`
|
||||||
|
- `positions`
|
||||||
|
- `intervals`
|
||||||
|
- `laps` for full-season analysis
|
||||||
|
|
||||||
|
Initial policy:
|
||||||
|
|
||||||
|
- Ingest high-volume telemetry only when explicitly requested.
|
||||||
|
- Keep Race Hub v1 focused on results, strategy, laps, race control, weather,
|
||||||
|
positions, and track outlines.
|
||||||
|
|
||||||
|
## Research Questions
|
||||||
|
|
||||||
|
- Exact indexes for Race Hub, Live Replay, Driver Explorer, and Standings.
|
||||||
|
- Whether `positions` and `intervals` should be downsampled or stored in full.
|
||||||
|
- Whether `car_data_samples` and `location_samples` should be optional datasets.
|
||||||
|
- How to map official F1 static archive sessions to OpenF1 `session_key`.
|
||||||
|
- Whether to use SQLite FTS for race-control/team-radio search.
|
||||||
|
- How to version schema migrations without adding unnecessary framework weight.
|
||||||
231
documentations/refactor/04-web-ui-product.md
Normal file
231
documentations/refactor/04-web-ui-product.md
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
# Web UI Product
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The Web UI should become the primary way to use `box-box`. The product should
|
||||||
|
feel like an F1 operations room: fast, dense when needed, precise, and native to
|
||||||
|
race-weekend workflows. It should work well on phone and iPad, while still
|
||||||
|
scaling into a richer desktop dashboard.
|
||||||
|
|
||||||
|
## Product Priorities
|
||||||
|
|
||||||
|
- Race-weekend first.
|
||||||
|
- Live Timing and Race Hub receive the highest polish.
|
||||||
|
- Historical pages should be local-first and reliable.
|
||||||
|
- Data availability should be visible, not mysterious.
|
||||||
|
- Density should be configurable.
|
||||||
|
- TUI live mode remains supported but does not require Web feature parity.
|
||||||
|
|
||||||
|
## Core Screens
|
||||||
|
|
||||||
|
### Command Center
|
||||||
|
|
||||||
|
Default landing screen.
|
||||||
|
|
||||||
|
Shows:
|
||||||
|
|
||||||
|
- Current or upcoming race weekend.
|
||||||
|
- Next session countdown.
|
||||||
|
- Live session state.
|
||||||
|
- Weekend schedule.
|
||||||
|
- Weather snapshot.
|
||||||
|
- Championship context.
|
||||||
|
- Local data availability.
|
||||||
|
- Shortcuts into Live Timing, Weekend, Race Hub, Standings, and Data Library.
|
||||||
|
|
||||||
|
### Season Calendar
|
||||||
|
|
||||||
|
Year-based browsing screen.
|
||||||
|
|
||||||
|
Shows:
|
||||||
|
|
||||||
|
- All meetings for the selected year.
|
||||||
|
- Round, country, circuit, date range.
|
||||||
|
- Upcoming/live/completed state.
|
||||||
|
- Local ingestion status.
|
||||||
|
- Key outcomes after completion: winner, pole, fastest lap where available.
|
||||||
|
- Filters for missing data, completed races, sprint weekends, and upcoming
|
||||||
|
rounds.
|
||||||
|
|
||||||
|
### Weekend Page
|
||||||
|
|
||||||
|
One workspace per Grand Prix weekend.
|
||||||
|
|
||||||
|
Shows:
|
||||||
|
|
||||||
|
- Meeting metadata.
|
||||||
|
- Circuit and location.
|
||||||
|
- Session cards.
|
||||||
|
- Schedule and status.
|
||||||
|
- Dataset completeness.
|
||||||
|
- Entry points into each session view.
|
||||||
|
|
||||||
|
### Race / Session Hub
|
||||||
|
|
||||||
|
Main historical analysis workspace.
|
||||||
|
|
||||||
|
For races, prioritize the strategy story:
|
||||||
|
|
||||||
|
- Final classification.
|
||||||
|
- Starting grid and grid delta.
|
||||||
|
- Stint chart with compounds and pit stops.
|
||||||
|
- Safety car and VSC overlays.
|
||||||
|
- Position evolution.
|
||||||
|
- Lap-time comparison.
|
||||||
|
- Race-control timeline.
|
||||||
|
- Weather timeline.
|
||||||
|
- Driver race execution summaries.
|
||||||
|
- Replay scrubber with lap-by-lap standings and events.
|
||||||
|
|
||||||
|
For practice and qualifying:
|
||||||
|
|
||||||
|
- Classification.
|
||||||
|
- Best laps and sector breakdown.
|
||||||
|
- Lap progression.
|
||||||
|
- Driver comparison.
|
||||||
|
- Session events and weather context.
|
||||||
|
|
||||||
|
### Live Timing
|
||||||
|
|
||||||
|
Primary active-session screen.
|
||||||
|
|
||||||
|
Shows:
|
||||||
|
|
||||||
|
- Timing tower.
|
||||||
|
- Session clock, lap count, and track status.
|
||||||
|
- Position, gap, interval, tyre, tyre age, pit state.
|
||||||
|
- Last lap, best lap, sector state, DRS/track status where available.
|
||||||
|
- Race-control messages.
|
||||||
|
- Battles.
|
||||||
|
- Pit window predictions.
|
||||||
|
- Pinned drivers.
|
||||||
|
- Visual in-app alerts.
|
||||||
|
|
||||||
|
### Live Track View
|
||||||
|
|
||||||
|
Initially a mode inside Live Timing.
|
||||||
|
|
||||||
|
Shows:
|
||||||
|
|
||||||
|
- Circuit outline.
|
||||||
|
- Live car positions.
|
||||||
|
- Team/driver coloring.
|
||||||
|
- Selected/pinned driver focus.
|
||||||
|
- Mini timing list.
|
||||||
|
- Track/flag context where available.
|
||||||
|
|
||||||
|
### Drivers
|
||||||
|
|
||||||
|
Driver explorer.
|
||||||
|
|
||||||
|
Shows:
|
||||||
|
|
||||||
|
- Current season driver list.
|
||||||
|
- Driver profile data.
|
||||||
|
- Team, number, and headshot where available.
|
||||||
|
- Season points and trend.
|
||||||
|
- Race-by-race result table.
|
||||||
|
- Teammate comparison.
|
||||||
|
- Tyre/stint tendencies.
|
||||||
|
- Live pinned-driver mode during active sessions.
|
||||||
|
|
||||||
|
### Standings
|
||||||
|
|
||||||
|
Championship context screen.
|
||||||
|
|
||||||
|
Shows:
|
||||||
|
|
||||||
|
- Driver standings.
|
||||||
|
- Constructor standings.
|
||||||
|
- Points gaps.
|
||||||
|
- Movement since previous race.
|
||||||
|
- Race-by-race points accumulation.
|
||||||
|
- What changed after a selected Grand Prix.
|
||||||
|
|
||||||
|
### Data Library
|
||||||
|
|
||||||
|
Local data transparency screen.
|
||||||
|
|
||||||
|
Shows:
|
||||||
|
|
||||||
|
- Seasons available locally.
|
||||||
|
- Weekend and session dataset completeness.
|
||||||
|
- Missing datasets.
|
||||||
|
- Last ingested timestamps.
|
||||||
|
- Source/staleness state.
|
||||||
|
- Suggested ingestion commands.
|
||||||
|
- API lockout and stale cache explanations.
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
|
||||||
|
Local app preferences.
|
||||||
|
|
||||||
|
Shows:
|
||||||
|
|
||||||
|
- Density mode.
|
||||||
|
- Theme accents.
|
||||||
|
- Preferred season.
|
||||||
|
- Pinned drivers.
|
||||||
|
- API key status.
|
||||||
|
- Data/cache path.
|
||||||
|
- Live alert preferences.
|
||||||
|
|
||||||
|
## Navigation Model
|
||||||
|
|
||||||
|
Primary flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Season -> Weekend -> Session / Race Hub
|
||||||
|
```
|
||||||
|
|
||||||
|
Live shortcut:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Command Center -> Live Timing -> Track / Battles / Pit Window / Race Control
|
||||||
|
```
|
||||||
|
|
||||||
|
Data/support flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Data Library -> ingestion status / missing data
|
||||||
|
```
|
||||||
|
|
||||||
|
Candidate routes:
|
||||||
|
|
||||||
|
- `/`
|
||||||
|
- `/season/:year`
|
||||||
|
- `/weekend/:meetingKey`
|
||||||
|
- `/session/:sessionKey`
|
||||||
|
- `/live`
|
||||||
|
- `/drivers`
|
||||||
|
- `/drivers/:driverNumber`
|
||||||
|
- `/standings/:year`
|
||||||
|
- `/data`
|
||||||
|
- `/settings`
|
||||||
|
|
||||||
|
## Responsive Expectations
|
||||||
|
|
||||||
|
Phone:
|
||||||
|
|
||||||
|
- Stacked panels.
|
||||||
|
- Sticky session/status header.
|
||||||
|
- Bottom navigation.
|
||||||
|
- Swipeable live panels.
|
||||||
|
- Compact timing rows.
|
||||||
|
|
||||||
|
iPad:
|
||||||
|
|
||||||
|
- Split-pane layout.
|
||||||
|
- Timing plus side panel.
|
||||||
|
- Touch-friendly controls.
|
||||||
|
- Comfortable chart inspection.
|
||||||
|
|
||||||
|
Desktop:
|
||||||
|
|
||||||
|
- Dense multi-column operations layout.
|
||||||
|
- Persistent side panels.
|
||||||
|
- More simultaneous context.
|
||||||
|
|
||||||
|
Density modes should influence row height, visible columns, chart spacing, and
|
||||||
|
panel compactness.
|
||||||
|
|
||||||
152
documentations/refactor/05-frontend-stack.md
Normal file
152
documentations/refactor/05-frontend-stack.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# Frontend Stack
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The next Web UI should move from embedded Alpine/static assets to a real React
|
||||||
|
application. The target is a local-first, data-heavy, live-updating race
|
||||||
|
dashboard served by the Go backend.
|
||||||
|
|
||||||
|
## Current Web UI
|
||||||
|
|
||||||
|
Current stack:
|
||||||
|
|
||||||
|
- Go `net/http` server.
|
||||||
|
- Go `embed.FS` static assets.
|
||||||
|
- Plain HTML/CSS/JavaScript.
|
||||||
|
- Alpine.js from CDN.
|
||||||
|
- D3.js from CDN.
|
||||||
|
- Hash routing.
|
||||||
|
- Raw `fetch`.
|
||||||
|
- `EventSource` for live SSE.
|
||||||
|
- No frontend build system.
|
||||||
|
- No TypeScript.
|
||||||
|
- No package-managed frontend dependencies.
|
||||||
|
|
||||||
|
This is a good prototype shape but not a good long-term foundation for the
|
||||||
|
planned Web UI.
|
||||||
|
|
||||||
|
## Recommended Stack
|
||||||
|
|
||||||
|
### Vite
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- Frontend dev server.
|
||||||
|
- Fast TypeScript build.
|
||||||
|
- Production asset bundling.
|
||||||
|
- Clean integration with Go embedded static assets.
|
||||||
|
|
||||||
|
### React
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- Component model for complex screens.
|
||||||
|
- Good fit for live timing, charts, tables, filters, replay controls, and
|
||||||
|
persistent interaction state.
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- Stronger contracts for OpenF1, local API, and live timing payloads.
|
||||||
|
- Safer refactors.
|
||||||
|
- Better developer experience across data-heavy UI.
|
||||||
|
|
||||||
|
### TanStack Query
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- Server-state fetching and caching.
|
||||||
|
- Loading/error/stale states.
|
||||||
|
- Background refresh.
|
||||||
|
- Clear handling of local DB data, API fallback, and partial data.
|
||||||
|
|
||||||
|
### Router
|
||||||
|
|
||||||
|
Preferred candidates:
|
||||||
|
|
||||||
|
- TanStack Router for stronger type safety.
|
||||||
|
- React Router if simplicity and familiarity matter more.
|
||||||
|
|
||||||
|
Routes should model product workflows rather than mimic current hash routing.
|
||||||
|
|
||||||
|
### D3
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- Bespoke F1 visuals:
|
||||||
|
- Strategy charts.
|
||||||
|
- Track maps.
|
||||||
|
- Position evolution.
|
||||||
|
- Lap-time comparison.
|
||||||
|
- Gap history.
|
||||||
|
- Telemetry traces.
|
||||||
|
|
||||||
|
D3 should be used where the visual is genuinely custom. Simpler chart libraries
|
||||||
|
can be considered later for generic charts.
|
||||||
|
|
||||||
|
### Zustand
|
||||||
|
|
||||||
|
Optional.
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- Local UI preferences and cross-screen client state:
|
||||||
|
- Pinned drivers.
|
||||||
|
- Density mode.
|
||||||
|
- Selected comparison drivers.
|
||||||
|
- Visible live panels.
|
||||||
|
- Replay speed.
|
||||||
|
|
||||||
|
Avoid adding it until React state and URL state become awkward.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
Vitest:
|
||||||
|
|
||||||
|
- Formatting helpers.
|
||||||
|
- Data transforms.
|
||||||
|
- Race calculations.
|
||||||
|
- Chart input shaping.
|
||||||
|
|
||||||
|
Playwright:
|
||||||
|
|
||||||
|
- Page routing.
|
||||||
|
- Race Hub rendering.
|
||||||
|
- Live SSE behavior with mocked events.
|
||||||
|
- Responsive layouts.
|
||||||
|
- Data Library states.
|
||||||
|
|
||||||
|
## Why Not Astro As The App Shell
|
||||||
|
|
||||||
|
Astro is excellent when pages are mostly static and only specific islands need
|
||||||
|
JavaScript. `box-box` is primarily an interactive application:
|
||||||
|
|
||||||
|
- Live timing updates.
|
||||||
|
- SSE streams.
|
||||||
|
- Dense tables.
|
||||||
|
- Replay scrubbers.
|
||||||
|
- Driver pinning.
|
||||||
|
- Interactive charts.
|
||||||
|
- Local-first data states.
|
||||||
|
|
||||||
|
Astro could wrap React islands, but most important screens would become React
|
||||||
|
islands anyway. That adds split architecture without much benefit for this app.
|
||||||
|
|
||||||
|
Astro may still be useful for:
|
||||||
|
|
||||||
|
- Public docs.
|
||||||
|
- A marketing/project site.
|
||||||
|
- Static release notes.
|
||||||
|
|
||||||
|
For the product UI, Vite + React + TypeScript is the cleaner fit.
|
||||||
|
|
||||||
|
## Build Integration
|
||||||
|
|
||||||
|
Target behavior:
|
||||||
|
|
||||||
|
- During frontend development, Vite serves the React app.
|
||||||
|
- During normal `go run cmd/main.go --web`, Go serves compiled frontend assets.
|
||||||
|
- The backend remains responsible for SQLite, ingestion, OpenF1, SignalR, REST,
|
||||||
|
and SSE.
|
||||||
|
|
||||||
123
documentations/refactor/06-visual-design-direction.md
Normal file
123
documentations/refactor/06-visual-design-direction.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# Visual Design Direction
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The visual direction should be F1-native without falling into generic dashboard
|
||||||
|
habits. The app should feel like an operations room for following a race
|
||||||
|
weekend: technical, fast, sharp, and legible. It should avoid AI-slop patterns
|
||||||
|
such as endless decorative cards, vague gradient panels, giant generic hero
|
||||||
|
sections, and meaningless visual chrome.
|
||||||
|
|
||||||
|
## Design North Star
|
||||||
|
|
||||||
|
Use the phrase "F1 Ops Room" as the working direction.
|
||||||
|
|
||||||
|
Qualities:
|
||||||
|
|
||||||
|
- Dense but controlled.
|
||||||
|
- High signal.
|
||||||
|
- Fast to scan.
|
||||||
|
- Precise typography.
|
||||||
|
- Strong hierarchy.
|
||||||
|
- Team color used as information, not decoration.
|
||||||
|
- Good on phone and iPad, not just desktop.
|
||||||
|
|
||||||
|
## Density
|
||||||
|
|
||||||
|
Density should be configurable:
|
||||||
|
|
||||||
|
- Compact: timing-wall mode, maximum data per viewport.
|
||||||
|
- Comfortable: default mode for most users.
|
||||||
|
- Touch: larger hit targets and panel spacing for phone/iPad.
|
||||||
|
|
||||||
|
Density affects:
|
||||||
|
|
||||||
|
- Table row height.
|
||||||
|
- Visible columns.
|
||||||
|
- Panel spacing.
|
||||||
|
- Chart label detail.
|
||||||
|
- Header size.
|
||||||
|
- Control grouping.
|
||||||
|
|
||||||
|
## Timing-Wall Ergonomics
|
||||||
|
|
||||||
|
Live timing should prioritize scan speed:
|
||||||
|
|
||||||
|
- Position and driver identity must be easy to locate.
|
||||||
|
- Gap/interval changes should be visually distinct.
|
||||||
|
- Pit state, retired state, and tyre state should be obvious.
|
||||||
|
- Race-control alerts should interrupt without becoming noisy.
|
||||||
|
- Pinned drivers should remain available across live views.
|
||||||
|
|
||||||
|
## Team Color Discipline
|
||||||
|
|
||||||
|
Team colors are useful data, but they can quickly become visual noise.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Use team color for identity markers, row accents, chart lines, and selected
|
||||||
|
driver focus.
|
||||||
|
- Avoid flooding large surfaces with saturated team color.
|
||||||
|
- Always preserve contrast and legibility.
|
||||||
|
- Avoid making the whole interface a rainbow unless the context is explicitly
|
||||||
|
comparative.
|
||||||
|
|
||||||
|
## Layout Principles
|
||||||
|
|
||||||
|
Prefer:
|
||||||
|
|
||||||
|
- Full-width information bands.
|
||||||
|
- Dense tables with strong alignment.
|
||||||
|
- Split panes.
|
||||||
|
- Sticky session headers.
|
||||||
|
- Bottom navigation on phone.
|
||||||
|
- Clear panel switching on smaller screens.
|
||||||
|
- Charts that explain race state, not just decorate.
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
|
||||||
|
- Card sludge: every concept boxed into a decorative card.
|
||||||
|
- Floating cards inside cards.
|
||||||
|
- Generic SaaS dashboard grids.
|
||||||
|
- Purple/blue gradient panels with no product meaning.
|
||||||
|
- Decorative orbs, bokeh, or random glow effects.
|
||||||
|
- Vague hero sections.
|
||||||
|
- Overly large typography inside operational surfaces.
|
||||||
|
|
||||||
|
## F1-Native References To Research
|
||||||
|
|
||||||
|
Research should study:
|
||||||
|
|
||||||
|
- Official F1 timing tower ergonomics.
|
||||||
|
- Broadcast graphics hierarchy.
|
||||||
|
- FIA timing/result sheet density.
|
||||||
|
- Race control message formatting.
|
||||||
|
- Pit wall and telemetry workstation patterns.
|
||||||
|
- Motorsport data overlays.
|
||||||
|
|
||||||
|
The goal is not to copy official F1 branding. The goal is to understand the
|
||||||
|
information hierarchy and pacing of motorsport interfaces.
|
||||||
|
|
||||||
|
## Mobile And iPad
|
||||||
|
|
||||||
|
The app should work well on phone and iPad because those are likely primary
|
||||||
|
second-screen devices during race sessions.
|
||||||
|
|
||||||
|
Phone:
|
||||||
|
|
||||||
|
- Prioritize Live Timing, alerts, pinned drivers, and quick switching.
|
||||||
|
- Use stacked panels and sticky status.
|
||||||
|
- Keep interactions thumb-friendly.
|
||||||
|
|
||||||
|
iPad:
|
||||||
|
|
||||||
|
- Use two-pane and three-pane layouts.
|
||||||
|
- Keep charts inspectable.
|
||||||
|
- Make side panels easy to swap.
|
||||||
|
|
||||||
|
Desktop:
|
||||||
|
|
||||||
|
- Allow dense multi-panel layouts.
|
||||||
|
- Show more simultaneous context.
|
||||||
|
- Preserve keyboard and pointer efficiency.
|
||||||
|
|
||||||
206
documentations/refactor/07-research-agents-brief.md
Normal file
206
documentations/refactor/07-research-agents-brief.md
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
# Research Agents Brief
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Before implementation tickets are written, dedicated research agents should
|
||||||
|
investigate the uncertain parts of the refactor. Their outputs should feed a
|
||||||
|
product/architecture planning pass that turns findings into phased work.
|
||||||
|
|
||||||
|
Each research track should separate confirmed facts, assumptions, risks, and
|
||||||
|
recommendations.
|
||||||
|
|
||||||
|
## 1. OpenF1 Contract Research
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
|
||||||
|
- Document the exact OpenF1 endpoint contract needed by `box-box`.
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
|
||||||
|
- Existing `internal/api` client.
|
||||||
|
- OpenF1 docs: https://openf1.org/docs/
|
||||||
|
- Current app screens and planned Race Hub requirements.
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
- Endpoint inventory.
|
||||||
|
- Field/schema notes.
|
||||||
|
- Update cadence by endpoint.
|
||||||
|
- Auth/free-tier behavior.
|
||||||
|
- Rate-limit and lockout notes.
|
||||||
|
- Essential vs optional datasets for v1.
|
||||||
|
|
||||||
|
Key questions:
|
||||||
|
|
||||||
|
- Which endpoints are immutable after session completion?
|
||||||
|
- Which endpoints are high-volume enough to require explicit ingestion?
|
||||||
|
- What errors are returned during live-session lockout?
|
||||||
|
- Which endpoints can be filtered to reduce ingestion cost?
|
||||||
|
|
||||||
|
## 2. Official F1 Live Timing Research
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
|
||||||
|
- Document the SignalR live feed contract and parser risks.
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
|
||||||
|
- Current `internal/ui/official_live.go`.
|
||||||
|
- SignalR endpoint: https://livetiming.formula1.com/signalr
|
||||||
|
- OpenF1.Data package notes:
|
||||||
|
https://www.nuget.org/packages/OpenF1.Data/1.0.87
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
- Topic inventory.
|
||||||
|
- Payload examples where available.
|
||||||
|
- Parser fragility notes.
|
||||||
|
- Recommended domain event/state model.
|
||||||
|
- Testing strategy for non-live periods.
|
||||||
|
|
||||||
|
Key questions:
|
||||||
|
|
||||||
|
- Are current subscribed topics sufficient for the planned Web live mode?
|
||||||
|
- Which topics should be parsed as events vs current state?
|
||||||
|
- How should disconnections and reconnections be represented?
|
||||||
|
- Should live snapshots/events be persisted?
|
||||||
|
|
||||||
|
## 3. Static Archive Feasibility Research
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
|
||||||
|
- Determine whether official F1 static archived timing files should become a
|
||||||
|
supported source.
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
|
||||||
|
- LiveF1 data topic reference:
|
||||||
|
https://livef1.goktugocal.com/livetimingf1/data_topics.html
|
||||||
|
- Public static archive URL patterns.
|
||||||
|
- OpenF1 meeting/session metadata.
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
- Feasibility assessment.
|
||||||
|
- Session path mapping strategy.
|
||||||
|
- Available years/session types.
|
||||||
|
- Topic/file inventory.
|
||||||
|
- Risks and legal/operational considerations.
|
||||||
|
|
||||||
|
Key questions:
|
||||||
|
|
||||||
|
- Can OpenF1 sessions be mapped reliably to static archive paths?
|
||||||
|
- Are static archive files available consistently?
|
||||||
|
- Which files provide replay-quality timing?
|
||||||
|
- Is this source stable enough for v1 or later only?
|
||||||
|
|
||||||
|
## 4. SQLite Schema And Indexing Design
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
|
||||||
|
- Turn the domain database design into a concrete schema proposal.
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
|
||||||
|
- `03-database-design.md`.
|
||||||
|
- Existing `internal/models/types.go`.
|
||||||
|
- Race Hub and Live Replay query requirements.
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
- Table definitions.
|
||||||
|
- Primary keys and foreign keys.
|
||||||
|
- Index proposal.
|
||||||
|
- Raw payload strategy.
|
||||||
|
- Migration strategy.
|
||||||
|
- High-volume data retention recommendations.
|
||||||
|
|
||||||
|
Design questions:
|
||||||
|
|
||||||
|
- Which tables need composite primary keys?
|
||||||
|
- Which read paths need covering indexes?
|
||||||
|
- Should telemetry/location be optional datasets?
|
||||||
|
- Should derived read-model tables exist in v1?
|
||||||
|
|
||||||
|
## 5. Backend API And Read-Model Design
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
|
||||||
|
- Design the Web API shape that React will consume.
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
|
||||||
|
- Existing `internal/web/api.go`.
|
||||||
|
- Planned Web screens.
|
||||||
|
- Store/query requirements.
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
- Endpoint proposal.
|
||||||
|
- Response envelope proposal.
|
||||||
|
- Source/staleness metadata shape.
|
||||||
|
- Error/partial-data behavior.
|
||||||
|
- Migration strategy from existing endpoints.
|
||||||
|
|
||||||
|
Design questions:
|
||||||
|
|
||||||
|
- Should existing `/api/v1` routes be preserved and expanded?
|
||||||
|
- What metadata should every response include?
|
||||||
|
- How should partial data be represented?
|
||||||
|
- Which read models should be backend-computed vs frontend-computed?
|
||||||
|
|
||||||
|
## 6. F1-Native Visual System Research
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
|
||||||
|
- Produce visual principles and examples for the React UI before components are
|
||||||
|
built.
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
|
||||||
|
- `06-visual-design-direction.md`.
|
||||||
|
- F1 broadcast timing graphics.
|
||||||
|
- FIA timing/result sheets.
|
||||||
|
- Motorsport telemetry and timing tools.
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
- Moodboard or written reference guide.
|
||||||
|
- Layout principles.
|
||||||
|
- Typography and density guidance.
|
||||||
|
- Color usage rules.
|
||||||
|
- Anti-pattern list.
|
||||||
|
|
||||||
|
Key questions:
|
||||||
|
|
||||||
|
- How should the app look F1-native without copying official branding?
|
||||||
|
- What visual hierarchy makes live timing fastest to scan?
|
||||||
|
- How should phone/iPad layouts differ from desktop?
|
||||||
|
- How can the UI avoid generic card-heavy dashboard design?
|
||||||
|
|
||||||
|
## 7. Testing Strategy Research
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
|
||||||
|
- Define a test strategy for backend, ingestion, frontend, and live behavior.
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
|
||||||
|
- Existing tests.
|
||||||
|
- Planned store/ingestion architecture.
|
||||||
|
- Live feed limitations outside active sessions.
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
- Backend unit/integration test plan.
|
||||||
|
- Ingestion fixture strategy.
|
||||||
|
- Frontend Vitest and Playwright strategy.
|
||||||
|
- Mock SSE/live fixture plan.
|
||||||
|
- Manual acceptance checklist.
|
||||||
|
|
||||||
|
Key questions:
|
||||||
|
|
||||||
|
- How should live SignalR behavior be tested without an active session?
|
||||||
|
- What source payload fixtures are needed?
|
||||||
|
- Which scenarios require real OpenF1 integration tests?
|
||||||
|
- How should local DB migrations be tested?
|
||||||
186
documentations/refactor/08-v1-scope-and-phasing.md
Normal file
186
documentations/refactor/08-v1-scope-and-phasing.md
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
# V1 Scope And Phasing
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The refactor vision is intentionally broad, but the first shippable milestone
|
||||||
|
must be narrow. V1 should prove the new architecture without attempting to
|
||||||
|
finish every screen. The goal is a reliable local-first Race Hub and a cleaner
|
||||||
|
live foundation, with the existing app kept usable throughout the transition.
|
||||||
|
|
||||||
|
## V1 Goal
|
||||||
|
|
||||||
|
V1 is done when `box-box` can:
|
||||||
|
|
||||||
|
- Ingest one completed race weekend into a local domain database.
|
||||||
|
- Open a Web Race Hub for that weekend without relying on fresh OpenF1 calls.
|
||||||
|
- Show honest data availability metadata.
|
||||||
|
- Continue using the existing live timing capability through an extracted live
|
||||||
|
package.
|
||||||
|
- Preserve the current TUI live mode.
|
||||||
|
|
||||||
|
This is the first proof that the app has moved from "OpenF1 page client" to
|
||||||
|
"local-first F1 command center."
|
||||||
|
|
||||||
|
## V1 Product Scope
|
||||||
|
|
||||||
|
Included screens:
|
||||||
|
|
||||||
|
- Command Center, minimal version.
|
||||||
|
- Season or Weekend entry path, minimal version.
|
||||||
|
- Race / Session Hub for completed race sessions.
|
||||||
|
- Data Library, minimal version showing local dataset status.
|
||||||
|
- Existing Web Live Timing preserved, with backend live extraction started.
|
||||||
|
|
||||||
|
Race Hub v1 data:
|
||||||
|
|
||||||
|
- Meeting and session metadata.
|
||||||
|
- Drivers.
|
||||||
|
- Final classification.
|
||||||
|
- Starting grid.
|
||||||
|
- Laps.
|
||||||
|
- Stints.
|
||||||
|
- Pit stops.
|
||||||
|
- Positions.
|
||||||
|
- Race control.
|
||||||
|
- Weather.
|
||||||
|
- Track outline when available.
|
||||||
|
|
||||||
|
Race Hub v1 views:
|
||||||
|
|
||||||
|
- Classification.
|
||||||
|
- Grid delta.
|
||||||
|
- Strategy chart.
|
||||||
|
- Position evolution.
|
||||||
|
- Lap comparison.
|
||||||
|
- Race-control timeline.
|
||||||
|
- Weather timeline.
|
||||||
|
- Dataset status.
|
||||||
|
|
||||||
|
## V1 Non-Goals
|
||||||
|
|
||||||
|
Not required for v1:
|
||||||
|
|
||||||
|
- Full React replacement of every current Web screen.
|
||||||
|
- Full season backfill as a default workflow.
|
||||||
|
- Team radio audio playback.
|
||||||
|
- High-volume car telemetry ingestion by default.
|
||||||
|
- Full live-session replay from persisted SignalR data.
|
||||||
|
- Static archive ingestion.
|
||||||
|
- Browser/system notifications.
|
||||||
|
- TUI feature parity with the new Web Race Hub.
|
||||||
|
|
||||||
|
## TUI Scope
|
||||||
|
|
||||||
|
The TUI remains a supported live-session surface, especially because its live
|
||||||
|
mode is currently one of the strongest parts of the app. New historical,
|
||||||
|
analytics, and richer navigation work should target the Web UI first.
|
||||||
|
|
||||||
|
TUI requirements during v1:
|
||||||
|
|
||||||
|
- Continue compiling.
|
||||||
|
- Continue launching by default with `go run cmd/main.go`.
|
||||||
|
- Continue supporting live mode after SignalR extraction.
|
||||||
|
- Do not require Race Hub, Data Library, or React-era feature parity.
|
||||||
|
|
||||||
|
## Backend Phase Order
|
||||||
|
|
||||||
|
### Phase 1: Live Extraction
|
||||||
|
|
||||||
|
- Extract SignalR connection, topic parsing, live state, and live event types
|
||||||
|
out of `internal/ui` into a reusable package such as `internal/live`.
|
||||||
|
- Keep TUI and Web mode consuming the same live package.
|
||||||
|
- Add fixture-based tests for parser behavior where possible.
|
||||||
|
- Persist live events/snapshots as a separate append-only stream only after the
|
||||||
|
extracted package has stable event/state types.
|
||||||
|
- See [09 Phase 1 Live Extraction](09-phase-1-live-extraction.md) for the
|
||||||
|
original implementation brief.
|
||||||
|
|
||||||
|
### Phase 2: Store Foundation
|
||||||
|
|
||||||
|
- Add `internal/store`.
|
||||||
|
- Add schema/migration initialization.
|
||||||
|
- Add raw payload storage.
|
||||||
|
- Add ingestion metadata tables.
|
||||||
|
- Add normalized tables required for Race Hub v1.
|
||||||
|
- Keep existing HTTP cache behavior unchanged.
|
||||||
|
- See [10 Phase 2 Store Foundation](10-phase-2-store-foundation.md) for the
|
||||||
|
original implementation brief.
|
||||||
|
|
||||||
|
### Phase 3: Ingestion Foundation
|
||||||
|
|
||||||
|
- Add `internal/ingest`.
|
||||||
|
- Support session-level and meeting-level ingestion first.
|
||||||
|
- Add dry-run output.
|
||||||
|
- Add conservative request delay, bounded retry, and 429/live-lockout handling.
|
||||||
|
- Make ingestion idempotent and resumable.
|
||||||
|
- See [11 Phase 3 Ingestion Foundation](11-phase-3-ingestion-foundation.md) for
|
||||||
|
the original implementation brief.
|
||||||
|
|
||||||
|
### Phase 4: Local-First Web API
|
||||||
|
|
||||||
|
- Add local-first read services for Race Hub v1.
|
||||||
|
- Introduce response metadata for source, freshness, and missing datasets.
|
||||||
|
- Migrate selected Web endpoints from direct OpenF1 calls to local-first reads.
|
||||||
|
- Allow small opportunistic fetches only for missing screen-level data.
|
||||||
|
- See [12 Phase 4 Local-First Web API](12-phase-4-local-first-web-api.md) for
|
||||||
|
the original implementation brief.
|
||||||
|
|
||||||
|
### Phase 5: React Race Hub Slice
|
||||||
|
|
||||||
|
- Add Vite + React + TypeScript frontend foundation.
|
||||||
|
- Build the Race Hub v1 route and components.
|
||||||
|
- Use TanStack Query for server data.
|
||||||
|
- Use D3 for strategy, position evolution, and lap comparison visuals.
|
||||||
|
- Keep the old Web UI available until the replacement route is credible.
|
||||||
|
- This is the first frontend phase. Use Claude for this phase.
|
||||||
|
- See [13 Phase 5 React Race Hub](13-phase-5-react-race-hub.md) for the
|
||||||
|
original implementation brief.
|
||||||
|
|
||||||
|
## Ingestion Rate-Limit Defaults
|
||||||
|
|
||||||
|
All bulk ingestion should be polite by default:
|
||||||
|
|
||||||
|
- Sequential requests unless a later test proves safe concurrency.
|
||||||
|
- Configurable delay between requests.
|
||||||
|
- Bounded exponential backoff with jitter.
|
||||||
|
- Stop or pause on HTTP 429.
|
||||||
|
- Stop or pause on live-session lockout.
|
||||||
|
- Print enough progress to resume intentionally.
|
||||||
|
- Never silently launch a full-season backfill from normal Web browsing.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
V1 acceptance:
|
||||||
|
|
||||||
|
- A completed race session can be ingested from OpenF1 into SQLite.
|
||||||
|
- Re-opening that Race Hub uses local data without fresh OpenF1 calls.
|
||||||
|
- Missing datasets are visible in the API response and UI.
|
||||||
|
- API lockout or network failure does not blank a locally ingested Race Hub.
|
||||||
|
- Existing TUI live mode still works through the extracted live package.
|
||||||
|
- The Data Library can show the ingested weekend/session and dataset state.
|
||||||
|
|
||||||
|
## Follow-Up Phases
|
||||||
|
|
||||||
|
After v1:
|
||||||
|
|
||||||
|
- Expand ingestion to full seasons.
|
||||||
|
- Add static archive source if research validates it.
|
||||||
|
- Add richer live persistence and reconciliation.
|
||||||
|
- Build full Command Center, Standings, Drivers, and Settings.
|
||||||
|
- Improve mobile/iPad live layouts.
|
||||||
|
- Add broader Playwright coverage and visual regression checks.
|
||||||
|
|
||||||
|
### Phase 6: React Race Hub Analytics
|
||||||
|
|
||||||
|
- Add Race Hub tabs or segmented views.
|
||||||
|
- Keep classification and grid intact.
|
||||||
|
- Add Dataset Status, Strategy, and Position Evolution views.
|
||||||
|
- Use real local-first data where available and honest missing states otherwise.
|
||||||
|
- Continue frontend work with Claude.
|
||||||
|
|
||||||
|
### Phase 7: Analytics Data Foundation
|
||||||
|
|
||||||
|
- Return to Cursor for backend work.
|
||||||
|
- Add local-first store, ingestion, and Race Hub API support for stints,
|
||||||
|
positions, and related analytics datasets.
|
||||||
|
- Keep React Strategy/Position views honest until real data is available.
|
||||||
195
documentations/refactor/09-phase-1-live-extraction.md
Normal file
195
documentations/refactor/09-phase-1-live-extraction.md
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
# Phase 1 Live Extraction
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Phase 1 creates a stable live timing foundation without changing the product
|
||||||
|
surface. The current live mode is the strongest part of `box-box`, but the core
|
||||||
|
SignalR connection and parsing code lives inside `internal/ui`. That creates a
|
||||||
|
bad dependency direction: the Web server imports TUI code only to access live
|
||||||
|
data types and `ConnectToF1LiveTiming`.
|
||||||
|
|
||||||
|
The goal is to extract the reusable live timing core into `internal/live`, keep
|
||||||
|
the TUI and Web UI working, and add fixture-based tests around the parsing
|
||||||
|
surface. This is a foundation phase, not a frontend redesign phase.
|
||||||
|
|
||||||
|
## Manager Decision
|
||||||
|
|
||||||
|
I agree with Claude that Race Hub is the safest first React product slice.
|
||||||
|
However, before React work starts, the live timing backend should be separated
|
||||||
|
from the TUI. The current Web UI already depends on live data through SSE, and
|
||||||
|
future React live screens will need that source without importing terminal UI
|
||||||
|
code.
|
||||||
|
|
||||||
|
Therefore Phase 1 is:
|
||||||
|
|
||||||
|
- Extract the live SignalR bridge into `internal/live`.
|
||||||
|
- Update TUI live mode to consume `internal/live`.
|
||||||
|
- Update Web SSE live mode to consume `internal/live`.
|
||||||
|
- Add tests for live message parsing/state updates.
|
||||||
|
- Do not add persistence, React, or new UI behavior yet.
|
||||||
|
|
||||||
|
## Current Coupling To Remove
|
||||||
|
|
||||||
|
Current state:
|
||||||
|
|
||||||
|
- `internal/ui/official_live.go` owns SignalR protocol types, live data types,
|
||||||
|
topic parsing, connection setup, and TUI rendering.
|
||||||
|
- `internal/web/live.go` imports `internal/ui` for `ui.LiveStreamData` and
|
||||||
|
`ui.ConnectToF1LiveTiming`.
|
||||||
|
|
||||||
|
Target state:
|
||||||
|
|
||||||
|
- `internal/live` owns reusable live data structures, SignalR protocol parsing,
|
||||||
|
connection setup, and state update logic.
|
||||||
|
- `internal/ui` owns Bubble Tea model state, keyboard behavior, and terminal
|
||||||
|
rendering.
|
||||||
|
- `internal/web` owns SSE clients, HTTP handlers, reconnect/backoff policy, and
|
||||||
|
JSON responses.
|
||||||
|
|
||||||
|
## Proposed Package Boundary
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```text
|
||||||
|
internal/live/
|
||||||
|
types.go LiveStreamData, LiveDriverData, weather, race control, tyres
|
||||||
|
signalr.go negotiate/connect/subscribe to official F1 SignalR
|
||||||
|
parser.go raw message parsing and topic dispatch
|
||||||
|
state.go mutable live state accumulator and snapshot copying
|
||||||
|
parser_test.go fixture-driven tests
|
||||||
|
testdata/ small captured/synthetic SignalR messages
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact file split can change during implementation, but the boundary should
|
||||||
|
stay clear: `internal/live` must not import `internal/ui` or Bubble Tea.
|
||||||
|
|
||||||
|
## API Shape
|
||||||
|
|
||||||
|
Keep a small API compatible with current callers:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package live
|
||||||
|
|
||||||
|
type StreamData = LiveStreamData // or a normal exported type if clearer
|
||||||
|
|
||||||
|
func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional improvements are allowed only if they stay small and do not force broad
|
||||||
|
behavior changes:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Client struct {
|
||||||
|
// future room for custom http client, logger, topic list, clock, etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Connect(dataChan chan LiveStreamData) error
|
||||||
|
```
|
||||||
|
|
||||||
|
If a `Client` is introduced, preserve the top-level
|
||||||
|
`ConnectToF1LiveTiming(dataChan)` as a convenience wrapper so TUI and Web changes
|
||||||
|
remain boring.
|
||||||
|
|
||||||
|
## What Moves From `internal/ui/official_live.go`
|
||||||
|
|
||||||
|
Move or duplicate-then-delete these reusable concerns into `internal/live`:
|
||||||
|
|
||||||
|
- `F1SignalRMessage`
|
||||||
|
- `F1TimingLine`
|
||||||
|
- `F1DriverListEntry`
|
||||||
|
- `LiveTyreData`
|
||||||
|
- `LiveRCMessage`
|
||||||
|
- `LiveWeatherData`
|
||||||
|
- `LiveSessionMeta`
|
||||||
|
- `LiveSectorData`
|
||||||
|
- `LiveDriverData`
|
||||||
|
- `LiveStintData`
|
||||||
|
- `LiveStreamData`
|
||||||
|
- `ConnectToF1LiveTiming`
|
||||||
|
- topic parsing and state accumulation helpers currently embedded in the
|
||||||
|
connection goroutine
|
||||||
|
- snapshot-copying logic used before sending updates
|
||||||
|
|
||||||
|
Keep these TUI-specific concerns in `internal/ui/official_live.go`:
|
||||||
|
|
||||||
|
- `OfficialLiveModel`
|
||||||
|
- Bubble Tea messages and commands
|
||||||
|
- viewport handling
|
||||||
|
- keybindings
|
||||||
|
- terminal render functions
|
||||||
|
- battle/pit-window display logic unless it is already pure and clearly useful
|
||||||
|
to share
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Live sessions are not always available, so Phase 1 tests must not depend on a
|
||||||
|
current race weekend. Add fixture-based tests in `internal/live`.
|
||||||
|
|
||||||
|
Minimum test coverage:
|
||||||
|
|
||||||
|
- Parse a SignalR `R` full-state message.
|
||||||
|
- Parse a SignalR `M` incremental update message.
|
||||||
|
- Handle known topics without panicking:
|
||||||
|
- `TimingData`
|
||||||
|
- `DriverList`
|
||||||
|
- `LapCount`
|
||||||
|
- `ExtrapolatedClock`
|
||||||
|
- `TrackStatus`
|
||||||
|
- `RaceControlMessages`
|
||||||
|
- `WeatherData`
|
||||||
|
- `SessionInfo`
|
||||||
|
- `CurrentTyres`
|
||||||
|
- `TimingAppData`
|
||||||
|
- `TimingStats`
|
||||||
|
- Preserve existing string/float/nested-value handling in timing fields.
|
||||||
|
- Ignore unknown topics without failing.
|
||||||
|
- Verify snapshots copy maps/slices so downstream consumers cannot mutate
|
||||||
|
internal accumulator state accidentally.
|
||||||
|
|
||||||
|
Fixtures can be small synthetic messages shaped like the official feed. They do
|
||||||
|
not need to be full captured race payloads.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
Phase 1 is complete when:
|
||||||
|
|
||||||
|
- `internal/web/live.go` no longer imports `internal/ui`.
|
||||||
|
- `internal/ui/official_live.go` compiles while consuming `internal/live`.
|
||||||
|
- The existing TUI live mode still uses the official F1 SignalR feed.
|
||||||
|
- The existing Web live SSE path still uses the official F1 SignalR feed.
|
||||||
|
- `go test ./...` passes.
|
||||||
|
- Parser tests run without internet access.
|
||||||
|
- No local database, React, or visual redesign work has been started as part of
|
||||||
|
this phase.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
Do not include these in Phase 1:
|
||||||
|
|
||||||
|
- React/Vite frontend setup.
|
||||||
|
- SQLite domain database or migrations.
|
||||||
|
- OpenF1 ingestion refactor.
|
||||||
|
- Live event persistence.
|
||||||
|
- Race Hub implementation.
|
||||||
|
- Static archive research.
|
||||||
|
- Browser notification work.
|
||||||
|
- Major rewrite of TUI live rendering.
|
||||||
|
|
||||||
|
## Risks And Guardrails
|
||||||
|
|
||||||
|
- The live parser currently works in practice; avoid clever rewrites that change
|
||||||
|
behavior without tests.
|
||||||
|
- Official F1 SignalR topic schemas can drift. Keep parsing tolerant of missing,
|
||||||
|
empty, string, numeric, and nested values.
|
||||||
|
- Do not make Web reconnect/backoff policy part of `internal/live` yet. The Web
|
||||||
|
server can keep owning that operational behavior.
|
||||||
|
- Do not make the TUI import Web code. Shared logic should flow through
|
||||||
|
`internal/live`.
|
||||||
|
- Preserve existing logs and user-facing behavior unless a small compile-time
|
||||||
|
adjustment requires otherwise.
|
||||||
|
|
||||||
|
## Next Phase After This
|
||||||
|
|
||||||
|
After Phase 1, Phase 2 should start `internal/store` and the local SQLite domain
|
||||||
|
database. Live persistence should still wait until the live data/event types have
|
||||||
|
settled and the database provenance design is ready.
|
||||||
136
documentations/refactor/10-phase-2-store-foundation.md
Normal file
136
documentations/refactor/10-phase-2-store-foundation.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# Phase 2 Store Foundation
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Phase 2 introduces the local domain database foundation. The current SQLite
|
||||||
|
database is an HTTP response cache. That should remain intact, but it is not the
|
||||||
|
same thing as an app-owned F1 domain store.
|
||||||
|
|
||||||
|
The goal of this phase is to add `internal/store` with schema initialization,
|
||||||
|
migrations, provenance-aware raw payload storage, and a small set of typed
|
||||||
|
domain tables needed by Race Hub v1. This phase should not build ingestion
|
||||||
|
commands or change the Web UI yet.
|
||||||
|
|
||||||
|
## Manager Decision
|
||||||
|
|
||||||
|
Keep this phase boring and structural. Do not try to ingest a full weekend yet.
|
||||||
|
The deliverable is a tested store package that later phases can call.
|
||||||
|
|
||||||
|
Phase 2 should prove:
|
||||||
|
|
||||||
|
- the app can create/open a domain SQLite database;
|
||||||
|
- migrations are repeatable and idempotent;
|
||||||
|
- raw source payloads can be stored with provenance;
|
||||||
|
- basic meeting/session/driver/session result records can be upserted and read;
|
||||||
|
- existing HTTP cache behavior is untouched.
|
||||||
|
|
||||||
|
## Package Boundary
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```text
|
||||||
|
internal/store/
|
||||||
|
db.go open/close database, pragmas, transaction helper
|
||||||
|
migrations.go embedded SQL migrations and schema versioning
|
||||||
|
models.go store-layer structs for v1 domain records
|
||||||
|
raw.go raw payload/provenance writes and reads
|
||||||
|
meetings.go typed meeting/session upserts and reads
|
||||||
|
results.go typed driver/result/grid-style records as initial slice
|
||||||
|
store_test.go temp-db migration and CRUD tests
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact file split can change, but `internal/store` should not import
|
||||||
|
`internal/ui` or `internal/web`.
|
||||||
|
|
||||||
|
## Database Location
|
||||||
|
|
||||||
|
Use a conservative default path separate from the existing HTTP cache:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.local/share/box-box/boxbox.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests must use temporary databases, not the user's real home directory.
|
||||||
|
|
||||||
|
## Initial Schema Scope
|
||||||
|
|
||||||
|
Create tables for:
|
||||||
|
|
||||||
|
- `schema_migrations`
|
||||||
|
- `raw_payloads`
|
||||||
|
- `ingestion_runs`
|
||||||
|
- `meetings`
|
||||||
|
- `sessions`
|
||||||
|
- `drivers`
|
||||||
|
- `session_drivers`
|
||||||
|
- `session_results`
|
||||||
|
- `starting_grid`
|
||||||
|
|
||||||
|
It is acceptable to include additional Race Hub v1 tables if doing so is
|
||||||
|
straightforward, but do not overbuild high-volume telemetry yet.
|
||||||
|
|
||||||
|
## Raw Payload Strategy
|
||||||
|
|
||||||
|
`raw_payloads` should preserve source truth before normalization.
|
||||||
|
|
||||||
|
Recommended columns:
|
||||||
|
|
||||||
|
- source name, such as `openf1`
|
||||||
|
- endpoint or topic
|
||||||
|
- request key or URL
|
||||||
|
- meeting key when known
|
||||||
|
- session key when known
|
||||||
|
- payload JSON text/blob
|
||||||
|
- payload hash
|
||||||
|
- fetched timestamp
|
||||||
|
- provenance metadata JSON
|
||||||
|
|
||||||
|
Raw payload storage should be idempotent by source/request/hash or another
|
||||||
|
clear uniqueness rule.
|
||||||
|
|
||||||
|
## Domain Table Strategy
|
||||||
|
|
||||||
|
Use stable OpenF1 identifiers where available:
|
||||||
|
|
||||||
|
- `meeting_key`
|
||||||
|
- `session_key`
|
||||||
|
- `driver_number`
|
||||||
|
|
||||||
|
Prefer explicit upserts over blind inserts. Completed historical data should be
|
||||||
|
safe to re-run without duplicating rows.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Minimum tests:
|
||||||
|
|
||||||
|
- opening a temp database applies migrations;
|
||||||
|
- migrations can be run twice;
|
||||||
|
- schema version is recorded;
|
||||||
|
- raw payload insert/read works and preserves provenance;
|
||||||
|
- duplicate raw payload writes do not create accidental duplicates;
|
||||||
|
- meeting/session/driver/result upserts are idempotent;
|
||||||
|
- basic Race Hub read helpers can retrieve inserted meeting/session/result data.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
Do not include these in Phase 2:
|
||||||
|
|
||||||
|
- OpenF1 backfill orchestration.
|
||||||
|
- CLI ingestion commands.
|
||||||
|
- Web UI changes.
|
||||||
|
- React setup.
|
||||||
|
- Replacing existing `internal/api/cache.go`.
|
||||||
|
- High-volume telemetry tables for car data/location.
|
||||||
|
- Live SignalR persistence.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
Phase 2 is complete when:
|
||||||
|
|
||||||
|
- `internal/store` exists with tested migration and CRUD behavior.
|
||||||
|
- The package can create a fresh SQLite domain database.
|
||||||
|
- Running migrations repeatedly is safe.
|
||||||
|
- Store tests pass without internet access.
|
||||||
|
- `go test ./internal/store/...` passes.
|
||||||
|
- `go test ./...` either passes or only fails because existing OpenF1
|
||||||
|
integration tests cannot reach the network/API.
|
||||||
163
documentations/refactor/11-phase-3-ingestion-foundation.md
Normal file
163
documentations/refactor/11-phase-3-ingestion-foundation.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# Phase 3 Ingestion Foundation
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Phase 3 connects OpenF1 REST data to the local domain store introduced in Phase
|
||||||
|
2. The goal is to ingest a meeting or session intentionally, record provenance,
|
||||||
|
write raw payloads, normalize the initial Race Hub datasets, and make the work
|
||||||
|
idempotent and resumable.
|
||||||
|
|
||||||
|
This phase should still avoid Web UI replacement work. It creates the backend
|
||||||
|
path that later Race Hub APIs and React screens can trust.
|
||||||
|
|
||||||
|
## Manager Decision
|
||||||
|
|
||||||
|
Build ingestion as an explicit backend workflow first, not as an automatic Web
|
||||||
|
side effect. Normal browsing must not accidentally trigger a full weekend
|
||||||
|
backfill or burn through API quota.
|
||||||
|
|
||||||
|
Phase 3 should add:
|
||||||
|
|
||||||
|
- `internal/ingest` orchestration.
|
||||||
|
- OpenF1 source-to-store mapping for the Phase 2 tables.
|
||||||
|
- A small CLI command path for manual ingestion.
|
||||||
|
- Dry-run and progress output.
|
||||||
|
- conservative retry/rate-limit behavior.
|
||||||
|
|
||||||
|
## Package Boundary
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```text
|
||||||
|
internal/ingest/
|
||||||
|
ingest.go orchestrator, options, result summary
|
||||||
|
openf1.go OpenF1 source adapter and model mapping
|
||||||
|
progress.go progress event/output helpers if useful
|
||||||
|
ingest_test.go fake-source/fake-store or temp-db tests
|
||||||
|
```
|
||||||
|
|
||||||
|
The package should depend on:
|
||||||
|
|
||||||
|
- `internal/api` for OpenF1 reads;
|
||||||
|
- `internal/store` for writes;
|
||||||
|
- `internal/models` for current OpenF1 response structs.
|
||||||
|
|
||||||
|
It should not depend on:
|
||||||
|
|
||||||
|
- `internal/ui`;
|
||||||
|
- `internal/web`;
|
||||||
|
- React/frontend code.
|
||||||
|
|
||||||
|
## Initial Ingestion Scope
|
||||||
|
|
||||||
|
Support these commands/workflows first:
|
||||||
|
|
||||||
|
- ingest meetings for a year;
|
||||||
|
- ingest sessions for a meeting;
|
||||||
|
- ingest a single session's Race Hub v1 datasets.
|
||||||
|
|
||||||
|
For a race session, ingest:
|
||||||
|
|
||||||
|
- meeting metadata when available;
|
||||||
|
- session metadata;
|
||||||
|
- drivers;
|
||||||
|
- session result;
|
||||||
|
- starting grid;
|
||||||
|
- raw payload records for each fetched endpoint.
|
||||||
|
|
||||||
|
If Cursor chooses to include laps, stints, pits, race control, or weather, the
|
||||||
|
store schema must support them first. Otherwise leave those datasets for Phase
|
||||||
|
4 or a Phase 3 follow-up. Do not jam JSON blobs into unrelated tables just to
|
||||||
|
claim coverage.
|
||||||
|
|
||||||
|
## CLI Shape
|
||||||
|
|
||||||
|
Extend `cmd/main.go` conservatively. Keep the default TUI and `--web` behavior
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
Recommended flags:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run cmd/main.go --ingest-year 2025
|
||||||
|
go run cmd/main.go --ingest-meeting 1229
|
||||||
|
go run cmd/main.go --ingest-session 9472
|
||||||
|
go run cmd/main.go --ingest-session 9472 --dry-run
|
||||||
|
go run cmd/main.go --ingest-session 9472 --db /path/to/boxbox.db
|
||||||
|
```
|
||||||
|
|
||||||
|
This is acceptable as a first CLI slice. A richer subcommand framework can wait.
|
||||||
|
|
||||||
|
## Ingestion Behavior
|
||||||
|
|
||||||
|
Defaults:
|
||||||
|
|
||||||
|
- sequential requests;
|
||||||
|
- small delay between endpoint calls;
|
||||||
|
- bounded retry for transient failures;
|
||||||
|
- stop cleanly on OpenF1 live-session lockout;
|
||||||
|
- no silent full-season backfills;
|
||||||
|
- print progress and final summary;
|
||||||
|
- write raw payload provenance for each endpoint;
|
||||||
|
- upsert normalized records so reruns are safe.
|
||||||
|
|
||||||
|
## Raw Payload Provenance
|
||||||
|
|
||||||
|
Each fetched endpoint should record:
|
||||||
|
|
||||||
|
- source: `openf1`;
|
||||||
|
- endpoint name;
|
||||||
|
- request key;
|
||||||
|
- meeting key when known;
|
||||||
|
- session key when known;
|
||||||
|
- fetched timestamp;
|
||||||
|
- raw JSON payload;
|
||||||
|
- HTTP/API provenance when available;
|
||||||
|
- whether data came from stale cache if that signal is available.
|
||||||
|
|
||||||
|
If the current API client does not expose raw JSON easily, prefer a small source
|
||||||
|
adapter enhancement over duplicating HTTP logic wildly. Keep existing cache
|
||||||
|
behavior intact.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Tests should avoid real network calls.
|
||||||
|
|
||||||
|
Minimum tests:
|
||||||
|
|
||||||
|
- ingesting a fake session writes drivers, results, grid rows, and raw payloads;
|
||||||
|
- rerunning the same ingestion does not duplicate normalized rows;
|
||||||
|
- dry-run does not write domain rows;
|
||||||
|
- source errors stop the run and record/report failure;
|
||||||
|
- live-session lockout is surfaced as a controlled failure;
|
||||||
|
- CLI flag parsing does not break default TUI/Web behavior if covered cheaply.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
Do not include these in Phase 3:
|
||||||
|
|
||||||
|
- React/Vite frontend implementation.
|
||||||
|
- Web Race Hub API replacement.
|
||||||
|
- automatic Web-triggered backfill.
|
||||||
|
- live SignalR persistence.
|
||||||
|
- full-season default backfill.
|
||||||
|
- static archive ingestion.
|
||||||
|
- high-volume car telemetry ingestion.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
Phase 3 is complete when:
|
||||||
|
|
||||||
|
- `internal/ingest` exists and is covered by offline tests.
|
||||||
|
- A user can manually ingest a year, meeting, or session from the CLI.
|
||||||
|
- Rerunning ingestion is idempotent.
|
||||||
|
- Raw payloads and normalized records are both written.
|
||||||
|
- `go test ./internal/ingest/... ./internal/store/...` passes.
|
||||||
|
- `go build -o /tmp/box-box ./cmd/main.go` passes.
|
||||||
|
- `go test ./...` either passes or only fails because existing OpenF1
|
||||||
|
integration tests cannot reach the network/API.
|
||||||
|
|
||||||
|
## Next Phase After This
|
||||||
|
|
||||||
|
Phase 4 should add local-first backend read models and Web API endpoints for
|
||||||
|
Race Hub v1. It should make the Web API prefer local SQLite data and report
|
||||||
|
missing datasets honestly.
|
||||||
139
documentations/refactor/12-phase-4-local-first-web-api.md
Normal file
139
documentations/refactor/12-phase-4-local-first-web-api.md
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# Phase 4 Local-First Web API
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Phase 4 makes the Web API start behaving like a local-first product. Phases 2
|
||||||
|
and 3 created the domain store and explicit ingestion path; this phase adds
|
||||||
|
read models that prefer local SQLite data and report data availability honestly.
|
||||||
|
|
||||||
|
This is still a backend phase. Do not start React yet.
|
||||||
|
|
||||||
|
## Manager Decision
|
||||||
|
|
||||||
|
Build one credible local-first Race Hub API slice before replacing the frontend.
|
||||||
|
The current Web UI can keep working from the existing endpoints, but the backend
|
||||||
|
should expose store-backed responses that a future React Race Hub can trust.
|
||||||
|
|
||||||
|
Phase 4 should add:
|
||||||
|
|
||||||
|
- store-backed read models for ingested meetings, sessions, drivers, results,
|
||||||
|
and grid;
|
||||||
|
- dataset/status metadata so the UI knows what is local, missing, or stale;
|
||||||
|
- optional small API fallbacks only when explicitly requested;
|
||||||
|
- tests for local-first behavior without network.
|
||||||
|
|
||||||
|
## Package Boundary
|
||||||
|
|
||||||
|
Prefer adding a backend read-model layer instead of embedding SQL inside HTTP
|
||||||
|
handlers.
|
||||||
|
|
||||||
|
Recommended shape:
|
||||||
|
|
||||||
|
```text
|
||||||
|
internal/query/
|
||||||
|
racehub.go Race Hub read model assembly
|
||||||
|
metadata.go dataset availability/source metadata
|
||||||
|
query_test.go temp-db tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Then wire `internal/web` to use that layer.
|
||||||
|
|
||||||
|
If the implementation keeps the read layer inside `internal/web` temporarily,
|
||||||
|
it must still avoid duplicating store SQL across handlers.
|
||||||
|
|
||||||
|
## Initial API Scope
|
||||||
|
|
||||||
|
Add a new Race Hub endpoint:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/v1/race-hub?session_key=9472
|
||||||
|
```
|
||||||
|
|
||||||
|
Response should include:
|
||||||
|
|
||||||
|
- meeting;
|
||||||
|
- session;
|
||||||
|
- drivers;
|
||||||
|
- session results enriched with driver/team fields;
|
||||||
|
- starting grid enriched with driver/team fields;
|
||||||
|
- dataset availability metadata.
|
||||||
|
|
||||||
|
Recommended metadata shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"source": "local",
|
||||||
|
"session_key": 9472,
|
||||||
|
"datasets": {
|
||||||
|
"meeting": {"status": "available", "source": "local"},
|
||||||
|
"session": {"status": "available", "source": "local"},
|
||||||
|
"drivers": {"status": "available", "source": "local", "count": 20},
|
||||||
|
"results": {"status": "missing", "source": "none", "count": 0},
|
||||||
|
"starting_grid": {"status": "available", "source": "local", "count": 20}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Exact field names can vary, but the response must make missing datasets visible
|
||||||
|
instead of silently returning empty app states.
|
||||||
|
|
||||||
|
## Existing Endpoint Policy
|
||||||
|
|
||||||
|
Do not rewrite every existing endpoint yet. It is enough to:
|
||||||
|
|
||||||
|
- add the new local-first Race Hub endpoint;
|
||||||
|
- optionally make `/api/v1/meetings`, `/api/v1/sessions`, `/api/v1/drivers`,
|
||||||
|
`/api/v1/results`, and `/api/v1/grid` read from local data when present;
|
||||||
|
- preserve old OpenF1 behavior when local data is absent unless the request asks
|
||||||
|
for local-only behavior.
|
||||||
|
|
||||||
|
Recommended query controls:
|
||||||
|
|
||||||
|
```text
|
||||||
|
?source=local local only; no OpenF1 fallback
|
||||||
|
?source=auto local first, existing OpenF1 fallback when missing
|
||||||
|
```
|
||||||
|
|
||||||
|
Default should be conservative for existing endpoints. The new Race Hub endpoint
|
||||||
|
can default to local-first with honest missing metadata.
|
||||||
|
|
||||||
|
## Server Wiring
|
||||||
|
|
||||||
|
`web.Server` currently only receives `*api.OpenF1Client`. Add an optional
|
||||||
|
`*store.Store` or query service so Web mode can read the domain DB.
|
||||||
|
|
||||||
|
CLI/server behavior should remain simple:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run cmd/main.go --web
|
||||||
|
go run cmd/main.go --web --db /path/to/boxbox.db
|
||||||
|
```
|
||||||
|
|
||||||
|
If the DB does not exist or has no ingested data, Web mode should still start.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
Do not include these in Phase 4:
|
||||||
|
|
||||||
|
- React/Vite frontend setup.
|
||||||
|
- replacing the current static Web UI;
|
||||||
|
- automatic ingestion from Web browsing;
|
||||||
|
- live SignalR persistence;
|
||||||
|
- laps/stints/pits/weather/race-control read models unless the store schema is
|
||||||
|
expanded and tested first.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
Phase 4 is complete when:
|
||||||
|
|
||||||
|
- a local-first Race Hub endpoint exists;
|
||||||
|
- it can return ingested session data without OpenF1 calls;
|
||||||
|
- it reports missing datasets explicitly;
|
||||||
|
- Web mode can be pointed at a domain DB with `--db`;
|
||||||
|
- offline tests cover the read model and HTTP handler behavior;
|
||||||
|
- focused tests and build pass.
|
||||||
|
|
||||||
|
## Next Phase After This
|
||||||
|
|
||||||
|
Phase 5 is the first frontend implementation phase. That is the point to switch
|
||||||
|
from Cursor to Claude for React/UI work.
|
||||||
105
documentations/refactor/13-phase-5-react-race-hub.md
Normal file
105
documentations/refactor/13-phase-5-react-race-hub.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Phase 5 React Race Hub
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Phase 5 begins the production Web UI. The backend now has the foundation needed
|
||||||
|
for a local-first Race Hub: live timing is shared, a domain store exists,
|
||||||
|
ingestion can populate it, and `/api/v1/race-hub` can read from local data with
|
||||||
|
dataset metadata.
|
||||||
|
|
||||||
|
This is the point to switch from Cursor to Claude for frontend/UI work.
|
||||||
|
|
||||||
|
## Manager Decision
|
||||||
|
|
||||||
|
Start with a focused React Race Hub slice, not a full app rewrite. The goal is
|
||||||
|
to prove the chosen frontend stack, visual language, responsive layout, and API
|
||||||
|
contract against the new local-first backend.
|
||||||
|
|
||||||
|
Keep the old Web UI available until the React route is credible.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Add a Vite + React + TypeScript frontend foundation and build a first Race Hub
|
||||||
|
route around:
|
||||||
|
|
||||||
|
- meeting/session header;
|
||||||
|
- dataset/source status strip;
|
||||||
|
- classification table;
|
||||||
|
- starting grid table;
|
||||||
|
- driver/team color treatment;
|
||||||
|
- missing dataset states;
|
||||||
|
- compact Race Hub navigation shell;
|
||||||
|
- responsive desktop, tablet, and phone layouts.
|
||||||
|
|
||||||
|
Use `/api/v1/race-hub?session_key=...` as the primary API.
|
||||||
|
|
||||||
|
## Stack Defaults
|
||||||
|
|
||||||
|
- Vite
|
||||||
|
- React
|
||||||
|
- TypeScript
|
||||||
|
- TanStack Query
|
||||||
|
- TanStack Router, unless integration cost argues for React Router
|
||||||
|
- D3 only for bespoke charts later; do not use it for basic layout tables
|
||||||
|
- Vitest for component/unit tests
|
||||||
|
- Playwright for at least one smoke path if practical
|
||||||
|
|
||||||
|
## Visual Direction
|
||||||
|
|
||||||
|
The temporary static mockups used during early product exploration have been
|
||||||
|
removed now that the production React routes exist. Use the implemented React
|
||||||
|
screens as the current source of truth, and keep this visual direction in mind
|
||||||
|
for future refinement.
|
||||||
|
|
||||||
|
The UI should feel like an F1 operations room:
|
||||||
|
|
||||||
|
- dense but readable;
|
||||||
|
- technical, not generic SaaS;
|
||||||
|
- restrained use of panels;
|
||||||
|
- no card sludge;
|
||||||
|
- no decorative gradient blobs;
|
||||||
|
- strong timing-table ergonomics;
|
||||||
|
- team colors used as data, not wallpaper;
|
||||||
|
- mobile views designed directly, not merely squeezed desktop.
|
||||||
|
|
||||||
|
## Integration Policy
|
||||||
|
|
||||||
|
Do not rip out the existing static Web UI on day one. Add the React app in a way
|
||||||
|
that can coexist while the route is built and tested.
|
||||||
|
|
||||||
|
Acceptable approaches:
|
||||||
|
|
||||||
|
- add a Vite app under a dedicated frontend directory and document the dev flow;
|
||||||
|
- serve built assets from Go only after the React slice is stable;
|
||||||
|
- expose a `/react` or equivalent route temporarily if needed.
|
||||||
|
|
||||||
|
The implementation should avoid large backend changes except for tiny API
|
||||||
|
contract fixes discovered while integrating.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
Do not include these in Phase 5:
|
||||||
|
|
||||||
|
- full replacement of every existing Web screen;
|
||||||
|
- live timing React rewrite;
|
||||||
|
- ingest UI;
|
||||||
|
- settings UI;
|
||||||
|
- full season/calendar rebuild;
|
||||||
|
- new backend ingestion features;
|
||||||
|
- persistence of live SignalR events.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
Phase 5 is complete when:
|
||||||
|
|
||||||
|
- the React app can run locally;
|
||||||
|
- a Race Hub screen loads from `/api/v1/race-hub`;
|
||||||
|
- available and missing datasets are visibly distinct;
|
||||||
|
- the layout is usable on desktop and phone widths;
|
||||||
|
- tests or smoke checks cover the Race Hub happy path;
|
||||||
|
- the old Web UI still works.
|
||||||
|
|
||||||
|
## Next Phase After This
|
||||||
|
|
||||||
|
Phase 6 should expand the React app around the Race Hub: strategy chart,
|
||||||
|
position evolution, lap comparison, and richer Data Library/status workflows.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Phase 6 React Race Hub Analytics
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Phase 6 expands the React Race Hub from a classification/grid slice into a more
|
||||||
|
useful race analysis surface. Phase 5 proved the React stack, API contract,
|
||||||
|
desktop layout, and phone table behavior. This phase should add the first
|
||||||
|
bespoke F1 analysis views without replacing the whole Web app.
|
||||||
|
|
||||||
|
This remains a frontend-led phase for Claude.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Add Race Hub tabs or segmented views for:
|
||||||
|
|
||||||
|
- Overview / Classification
|
||||||
|
- Starting Grid
|
||||||
|
- Strategy
|
||||||
|
- Position Evolution
|
||||||
|
- Dataset Status
|
||||||
|
|
||||||
|
Strategy and position views should be built from local-first backend data only
|
||||||
|
when the backend exposes the needed datasets. If laps/stints/positions are not
|
||||||
|
yet available through `/api/v1/race-hub`, add clear missing states instead of
|
||||||
|
fake charts.
|
||||||
|
|
||||||
|
## Backend Contract
|
||||||
|
|
||||||
|
Current Race Hub API:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/v1/race-hub?session_key=...
|
||||||
|
```
|
||||||
|
|
||||||
|
Current datasets:
|
||||||
|
|
||||||
|
- meeting
|
||||||
|
- session
|
||||||
|
- drivers
|
||||||
|
- results
|
||||||
|
- starting_grid
|
||||||
|
|
||||||
|
If analytics require laps, stints, pit stops, or position samples, keep backend
|
||||||
|
changes small and explicit. Do not reintroduce direct OpenF1 reads from the
|
||||||
|
React app.
|
||||||
|
|
||||||
|
## Design Direction
|
||||||
|
|
||||||
|
Improve the information hierarchy without drifting into generic dashboard UI:
|
||||||
|
|
||||||
|
- stronger timing-wall readability;
|
||||||
|
- compact controls;
|
||||||
|
- minimal panel framing;
|
||||||
|
- no decorative gradients or card sludge;
|
||||||
|
- team colors as data accents;
|
||||||
|
- mobile views that fit the active columns rather than relying on horizontal
|
||||||
|
scrolling.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
Do not include these in Phase 6:
|
||||||
|
|
||||||
|
- live timing React rewrite;
|
||||||
|
- full season calendar rebuild;
|
||||||
|
- settings UI;
|
||||||
|
- ingest UI;
|
||||||
|
- static archive support;
|
||||||
|
- replacing the old Go-served Web UI entirely.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
Phase 6 is complete when:
|
||||||
|
|
||||||
|
- Race Hub has an ergonomic tab/segmented-view structure;
|
||||||
|
- classification and grid remain intact;
|
||||||
|
- analytics views show either real local data or honest missing states;
|
||||||
|
- desktop and phone layouts have been visually checked;
|
||||||
|
- frontend tests/build pass;
|
||||||
|
- Go build still passes.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Phase 7 Analytics Data Foundation
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Phase 7 returns to backend work. Phase 6 added honest frontend placeholders for
|
||||||
|
strategy and position evolution, but the Race Hub API does not yet expose the
|
||||||
|
local datasets needed to draw those views.
|
||||||
|
|
||||||
|
The goal is to expand the local store, ingestion, and Race Hub read model with
|
||||||
|
the first analytics datasets.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Add local-first support for:
|
||||||
|
|
||||||
|
- laps;
|
||||||
|
- stints;
|
||||||
|
- pit stops;
|
||||||
|
- race control;
|
||||||
|
- weather;
|
||||||
|
- positions, if volume and schema stay manageable.
|
||||||
|
|
||||||
|
Prioritize stints and positions because they unlock the Strategy and Position
|
||||||
|
Evolution views.
|
||||||
|
|
||||||
|
## Backend Work
|
||||||
|
|
||||||
|
Expected changes:
|
||||||
|
|
||||||
|
- add SQLite tables and migrations for the selected datasets;
|
||||||
|
- add store upsert/read methods;
|
||||||
|
- extend `internal/ingest` session ingestion;
|
||||||
|
- extend `internal/query.RaceHub`;
|
||||||
|
- extend `/api/v1/race-hub` metadata counts;
|
||||||
|
- keep raw payload provenance for every fetched endpoint.
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- Keep ingestion idempotent.
|
||||||
|
- Keep tests offline.
|
||||||
|
- Do not fetch OpenF1 directly from React.
|
||||||
|
- Do not persist high-volume car telemetry yet.
|
||||||
|
- If positions are too large for this phase, document the limit and implement
|
||||||
|
stints/pits first.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- Store migrations and CRUD tests pass.
|
||||||
|
- Ingestion writes new datasets and raw payloads.
|
||||||
|
- Race Hub API exposes new datasets with metadata.
|
||||||
|
- Existing React placeholders can detect available stints/positions.
|
||||||
|
- Focused Go tests pass.
|
||||||
65
documentations/refactor/16-phase-8-analytics-visuals.md
Normal file
65
documentations/refactor/16-phase-8-analytics-visuals.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Phase 8 Analytics Visuals
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Phase 7 added the backend data foundation for Race Hub analytics: stints, pit
|
||||||
|
stops, positions, race control, weather, and laps now flow through the local
|
||||||
|
SQLite store, ingestion, query layer, and `/api/v1/race-hub`.
|
||||||
|
|
||||||
|
Phase 8 returns to frontend work. The goal is to replace the Strategy and
|
||||||
|
Position placeholder states with useful, production-minded views that consume
|
||||||
|
the real local-first analytics arrays now present in the Race Hub payload.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Build the first real analytics views for:
|
||||||
|
|
||||||
|
- race strategy from stints and pit stops;
|
||||||
|
- position evolution from position samples;
|
||||||
|
- lightweight supporting context from race control, weather, and laps where it
|
||||||
|
improves the view without making the screen noisy.
|
||||||
|
|
||||||
|
The work should stay inside the React Race Hub surface. Do not redesign the
|
||||||
|
whole application shell in this phase.
|
||||||
|
|
||||||
|
## Frontend Work
|
||||||
|
|
||||||
|
Expected changes:
|
||||||
|
|
||||||
|
- pass `stints`, `pit_stops`, `positions`, `race_control`, `weather`, and `laps`
|
||||||
|
into the relevant Race Hub components;
|
||||||
|
- replace "chart not yet implemented" placeholders with real visual treatment;
|
||||||
|
- preserve honest missing-data states for sessions that only have core datasets;
|
||||||
|
- keep the design dense, technical, and F1-native;
|
||||||
|
- add focused component/unit tests for available and missing analytics data;
|
||||||
|
- update Playwright coverage so seeded analytics views prove the real data path
|
||||||
|
works.
|
||||||
|
|
||||||
|
## Visual Direction
|
||||||
|
|
||||||
|
Prefer timing-wall clarity over dashboard decoration:
|
||||||
|
|
||||||
|
- stint bars should be compact and scan-friendly;
|
||||||
|
- team colors should identify drivers without overpowering compound colors;
|
||||||
|
- compound colors should be disciplined and legible;
|
||||||
|
- position evolution should make gain/loss and driver comparison obvious;
|
||||||
|
- avoid decorative cards, giant empty panels, vague gradients, and generic SaaS
|
||||||
|
chart chrome.
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- Do not fetch OpenF1 directly from React.
|
||||||
|
- Do not add a heavy charting library unless the local interaction genuinely
|
||||||
|
needs it; SVG/CSS is enough for this first slice.
|
||||||
|
- Do not hide missing datasets behind fake mock data in runtime views.
|
||||||
|
- Keep mobile and iPad layouts usable, not just desktop-polished.
|
||||||
|
- Keep backend changes out of scope unless a clear API bug is discovered.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- Strategy tab renders real stint/pit information when analytics data exists.
|
||||||
|
- Position tab renders real position information when position samples exist.
|
||||||
|
- Missing-data sessions still show clear unavailable states.
|
||||||
|
- Existing Race Hub views keep working.
|
||||||
|
- Frontend tests and build pass.
|
||||||
|
- Playwright Race Hub e2e passes against the seeded local database.
|
||||||
52
documentations/refactor/17-phase-9-navigation-data-api.md
Normal file
52
documentations/refactor/17-phase-9-navigation-data-api.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Phase 9 Navigation Data API
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Race Hub now has useful local-first session views, but it still depends on a
|
||||||
|
manual `session_key`. Phase 9 should make the backend expose enough local
|
||||||
|
navigation data for the Web UI to become race-weekend-first: season calendar,
|
||||||
|
meeting detail, sessions, and ingestion coverage.
|
||||||
|
|
||||||
|
This is a backend/read-model slice for Cursor. Keep the React redesign for the
|
||||||
|
following phase.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Add local-first Web API endpoints/read models for:
|
||||||
|
|
||||||
|
- seasons or available years in the domain database;
|
||||||
|
- meetings for a year;
|
||||||
|
- one meeting/weekend with its sessions;
|
||||||
|
- per-session dataset coverage using the same dataset vocabulary as Race Hub;
|
||||||
|
- a sensible "latest available" or "default session" helper if it can be done
|
||||||
|
without guessing from remote API data.
|
||||||
|
|
||||||
|
## Backend Work
|
||||||
|
|
||||||
|
Expected changes:
|
||||||
|
|
||||||
|
- add query-layer read models in `internal/query` for calendar/weekend data;
|
||||||
|
- add store reads if existing methods are insufficient;
|
||||||
|
- add HTTP handlers in `internal/web`;
|
||||||
|
- keep responses local-first and deterministic;
|
||||||
|
- expose empty but well-shaped responses when the database has no ingested
|
||||||
|
meetings;
|
||||||
|
- add offline tests using temporary SQLite databases.
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- Do not fetch OpenF1 from these read endpoints.
|
||||||
|
- Do not make React depend on OpenF1 directly.
|
||||||
|
- Do not start frontend navigation implementation in this phase.
|
||||||
|
- Keep endpoint names stable and boring; this is app infrastructure, not a
|
||||||
|
product copywriting exercise.
|
||||||
|
- Keep the existing Race Hub API working unchanged.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- Web API can list ingested years and meetings.
|
||||||
|
- Web API can return a meeting/weekend with sessions.
|
||||||
|
- Each session includes dataset coverage needed to guide users into Race Hub.
|
||||||
|
- Empty database behavior is explicit and tested.
|
||||||
|
- Focused Go tests pass.
|
||||||
|
- Existing frontend unit/build/e2e checks still pass.
|
||||||
47
documentations/refactor/18-phase-10-navigation-ui.md
Normal file
47
documentations/refactor/18-phase-10-navigation-ui.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Phase 10 Navigation UI
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Phase 9 added local-first navigation APIs for seasons, weekends, sessions, and
|
||||||
|
dataset coverage. Phase 10 should use those APIs in the React Web UI so users
|
||||||
|
can browse ingested data instead of manually typing a `session_key`.
|
||||||
|
|
||||||
|
This is a frontend slice. Keep it functional and restrained; full visual polish
|
||||||
|
can come after the navigation workflow exists.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Add React UI for:
|
||||||
|
|
||||||
|
- available seasons from `/api/v1/seasons`;
|
||||||
|
- locally ingested meetings for a selected year;
|
||||||
|
- one weekend view from `/api/v1/weekend?meeting_key=...`;
|
||||||
|
- session selection that routes into existing Race Hub views.
|
||||||
|
|
||||||
|
The existing Race Hub analytics views should stay intact.
|
||||||
|
|
||||||
|
## Product Behavior
|
||||||
|
|
||||||
|
- If local data exists, users should be able to reach Race Hub without knowing a
|
||||||
|
raw session key.
|
||||||
|
- Empty local database states should be explicit and calm.
|
||||||
|
- Weekend/session rows should show dataset coverage so users understand why a
|
||||||
|
session may be partial.
|
||||||
|
- Race Hub should continue accepting `session_key` in the URL for direct links.
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- Do not fetch OpenF1 directly from React.
|
||||||
|
- Do not redesign every screen.
|
||||||
|
- Do not remove the manual session key entry yet; keep it as a fallback.
|
||||||
|
- Do not add a large UI framework or chart dependency.
|
||||||
|
- Keep mobile and iPad usable.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- Users can select a local year, meeting, and session.
|
||||||
|
- Selecting a session opens Race Hub for that session.
|
||||||
|
- Empty states are covered.
|
||||||
|
- Existing Race Hub e2e tests continue passing.
|
||||||
|
- Add focused frontend tests for navigation behavior where practical.
|
||||||
|
- Frontend tests and build pass.
|
||||||
40
documentations/refactor/19-phase-11-weekend-ingestion.md
Normal file
40
documentations/refactor/19-phase-11-weekend-ingestion.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Phase 11 Weekend Ingestion
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Phase 10 made local data navigable in the Web UI, but the app still needs a
|
||||||
|
practical way to populate a complete weekend. Phase 11 should make ingestion
|
||||||
|
work at the same shape users browse: meeting/weekend first, then sessions.
|
||||||
|
|
||||||
|
This is a backend/CLI slice for Cursor.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Add or refine CLI ingestion so a user can ingest a whole meeting/weekend into
|
||||||
|
the domain database without manually running one command per session.
|
||||||
|
|
||||||
|
The target workflow is:
|
||||||
|
|
||||||
|
- ingest meeting metadata and sessions for a `meeting_key`;
|
||||||
|
- for each session in that meeting, ingest Race Hub datasets;
|
||||||
|
- report per-session success, partial failure, and row counts clearly;
|
||||||
|
- keep raw payload provenance.
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- Do not remove single-session ingestion.
|
||||||
|
- Do not fetch data from React.
|
||||||
|
- Do not make failed optional analytics endpoints destroy already-ingested
|
||||||
|
meeting/session metadata.
|
||||||
|
- Keep tests offline with fake sources.
|
||||||
|
- Be careful with live/current sessions; completed historical sessions are the
|
||||||
|
primary target.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- A single CLI path can ingest all sessions for a meeting.
|
||||||
|
- Ingestion summaries make per-session results clear.
|
||||||
|
- Existing `--ingest-session` behavior still works.
|
||||||
|
- Store/query/web/frontend tests still pass.
|
||||||
|
- Add focused ingestion tests for full-weekend orchestration and partial
|
||||||
|
failures where practical.
|
||||||
40
documentations/refactor/20-phase-12-data-library-ui.md
Normal file
40
documentations/refactor/20-phase-12-data-library-ui.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Phase 12 Data Library UI
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
The app can now ingest full weekends and browse local seasons, meetings, and
|
||||||
|
sessions. Phase 12 should make local data health visible in the Web UI so users
|
||||||
|
can understand what is stored, what is partial, and what command to run next.
|
||||||
|
|
||||||
|
This is a frontend slice. Keep it practical and built on the APIs already
|
||||||
|
available.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Add a Data Library style surface that shows:
|
||||||
|
|
||||||
|
- local seasons and meetings;
|
||||||
|
- sessions per meeting;
|
||||||
|
- dataset coverage per session;
|
||||||
|
- clear empty states;
|
||||||
|
- suggested CLI commands for ingestion/backfill.
|
||||||
|
|
||||||
|
This can be a new route or a tab/section reachable from the existing Race Hub
|
||||||
|
shell, depending on the current router structure.
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- Do not fetch OpenF1 from React.
|
||||||
|
- Do not trigger ingestion from the browser.
|
||||||
|
- Keep Race Hub direct links working.
|
||||||
|
- Reuse existing local navigation APIs unless a small backend gap is genuinely
|
||||||
|
blocking.
|
||||||
|
- Keep styling dense, operational, and restrained.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- User can inspect local data coverage without opening a specific session.
|
||||||
|
- Partial weekends/sessions are visibly distinct from complete ones.
|
||||||
|
- Empty database state explains the relevant CLI command.
|
||||||
|
- Existing Race Hub navigation continues to work.
|
||||||
|
- Frontend tests/build/e2e pass.
|
||||||
127
documentations/refactor/21-mvp-completion-checklist.md
Normal file
127
documentations/refactor/21-mvp-completion-checklist.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# MVP Completion Checklist
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
The local-first Web UI MVP is now functionally assembled. The app can ingest
|
||||||
|
OpenF1 data into the SQLite domain store, serve local-first Race Hub and
|
||||||
|
navigation APIs, render the React Race Hub/Data Library/Live Timing routes, and
|
||||||
|
serve the built React app from Go web mode when `frontend/dist` is present.
|
||||||
|
|
||||||
|
This document replaces the temporary agent handoff prompts as the main
|
||||||
|
checkpoint for what has been completed and what remains.
|
||||||
|
|
||||||
|
## Completed
|
||||||
|
|
||||||
|
- Live SignalR code extracted into `internal/live` and reused by TUI and Web
|
||||||
|
mode.
|
||||||
|
- SQLite domain store added for local historical data.
|
||||||
|
- Session, meeting, and weekend ingestion paths added with dry-run support.
|
||||||
|
- Optional analytics ingestion failures are partial, not hard blockers.
|
||||||
|
- Local-first Race Hub API added with dataset availability metadata.
|
||||||
|
- Local-first season, meeting, and weekend navigation APIs added.
|
||||||
|
- React + TypeScript frontend added with TanStack Query and Router.
|
||||||
|
- Race Hub route added for classification, grid, strategy, positions, laps,
|
||||||
|
race control, weather, and dataset status.
|
||||||
|
- Admin / Data Health route added for local season/weekend coverage and CLI
|
||||||
|
guidance; `/data-library` remains a legacy alias.
|
||||||
|
- Live Timing route added for current backend live snapshot/SSE state.
|
||||||
|
- Go web mode serves the built React app from `frontend/dist` and falls back to
|
||||||
|
embedded legacy assets when no build is present.
|
||||||
|
|
||||||
|
## Verification Commands
|
||||||
|
|
||||||
|
Run these before cutting an MVP tag or handing the repo to another agent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/live ./internal/models ./internal/store ./internal/ingest ./internal/query ./internal/web
|
||||||
|
npm --prefix frontend test -- --run
|
||||||
|
npm --prefix frontend run build
|
||||||
|
npm run test:e2e
|
||||||
|
npm run test:e2e:prod
|
||||||
|
npm run test:visual
|
||||||
|
npm run test:visual:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dev proxy smoke (Vite + Go API)
|
||||||
|
|
||||||
|
For a local manual smoke test with the Vite dev server proxying API calls:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./scripts/seed-e2e-db/main.go --db /tmp/boxbox-mvp.db
|
||||||
|
BOXBOX_DISABLE_LIVE=1 go run ./cmd/main.go --web --db /tmp/boxbox-mvp.db --port 18080
|
||||||
|
BOXBOX_API_PORT=18080 npm run dev --prefix frontend -- --host 127.0.0.1 --port 15173 --strictPort
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open:
|
||||||
|
|
||||||
|
- `http://127.0.0.1:15173/race-hub?session_key=9472`
|
||||||
|
- `http://127.0.0.1:15173/admin`
|
||||||
|
- `http://127.0.0.1:15173/data-library` (legacy alias)
|
||||||
|
- `http://127.0.0.1:15173/live`
|
||||||
|
|
||||||
|
### Production web smoke (Go serves built React)
|
||||||
|
|
||||||
|
Verify the same routes when Go serves `frontend/dist` directly (no Vite):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --prefix frontend run build
|
||||||
|
go run ./scripts/seed-e2e-db/main.go --db /tmp/boxbox-mvp.db
|
||||||
|
BOXBOX_DISABLE_LIVE=1 go run ./cmd/main.go --web --db /tmp/boxbox-mvp.db --port 18080
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open:
|
||||||
|
|
||||||
|
- `http://127.0.0.1:18080/race-hub?session_key=9472`
|
||||||
|
- `http://127.0.0.1:18080/admin`
|
||||||
|
- `http://127.0.0.1:18080/data-library` (legacy alias)
|
||||||
|
- `http://127.0.0.1:18080/live`
|
||||||
|
|
||||||
|
Automated production-serving coverage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:e2e:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs `playwright.prod.config.ts`, which builds the frontend, seeds
|
||||||
|
`.playwright/boxbox-prod-e2e.db`, starts Go web mode on port 18080, and
|
||||||
|
exercises Race Hub, Data Library, Live empty state, and nav links against the
|
||||||
|
built SPA.
|
||||||
|
|
||||||
|
### Visual regression (Playwright screenshots)
|
||||||
|
|
||||||
|
Screenshot baselines for Race Hub, Data Library, and Live (disabled-live empty
|
||||||
|
state) at desktop, tablet, and mobile viewports:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:visual
|
||||||
|
npm run test:visual:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
Refresh baselines after intentional UI changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:visual:update
|
||||||
|
npm run test:visual:prod:update
|
||||||
|
```
|
||||||
|
|
||||||
|
Snapshots are stored under `tests/visual/__snapshots__/`. See
|
||||||
|
[22 Phase 14 Visual Regression](22-phase-14-visual-regression.md).
|
||||||
|
|
||||||
|
## Remaining Post-MVP Work
|
||||||
|
- Improve high-density mobile/iPad behavior for Live Timing and Race Hub tables.
|
||||||
|
- Add persisted live-event capture and reconciliation only after defining the
|
||||||
|
live storage model.
|
||||||
|
- Add track outline ingestion/read models to the React app if the local data
|
||||||
|
source is reliable enough.
|
||||||
|
- Expand from weekend/session ingestion toward safe full-season backfill.
|
||||||
|
- Add Drivers, Standings, and Settings as separate product phases.
|
||||||
|
- Revisit static archive feasibility after source mapping is proven.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `--ingest-year` currently discovers season meetings and sessions. Use
|
||||||
|
`--ingest-meeting <meeting_key>` for full weekend ingestion.
|
||||||
|
- The React app should continue avoiding direct OpenF1 reads. New Web UI routes
|
||||||
|
should call local-first Go APIs.
|
||||||
|
- The TUI live mode remains intentionally preserved. Historical Web UI parity
|
||||||
|
with the TUI is not required for this MVP.
|
||||||
55
documentations/refactor/22-phase-14-visual-regression.md
Normal file
55
documentations/refactor/22-phase-14-visual-regression.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Phase 14: Visual Regression and Responsive QA
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add Playwright screenshot coverage for the MVP Web UI routes across desktop,
|
||||||
|
tablet, and mobile viewports before broader product expansion.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
|
||||||
|
- `/race-hub?session_key=9472`
|
||||||
|
- `/data-library`
|
||||||
|
- `/live` (empty state with `BOXBOX_DISABLE_LIVE=1`)
|
||||||
|
|
||||||
|
Viewports (deterministic Chromium):
|
||||||
|
|
||||||
|
| Project | Size |
|
||||||
|
|---------|------|
|
||||||
|
| desktop | 1280×800 |
|
||||||
|
| tablet | 768×1024 |
|
||||||
|
| mobile | 390×844 |
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dev proxy (Vite + seeded Go API) — same stack as test:e2e
|
||||||
|
npm run test:visual
|
||||||
|
npm run test:visual:update
|
||||||
|
|
||||||
|
# Production serving (Go + frontend/dist) — canonical for committed snapshots
|
||||||
|
npm run test:visual:prod
|
||||||
|
npm run test:visual:prod:update
|
||||||
|
```
|
||||||
|
|
||||||
|
Snapshots live under `tests/visual/__snapshots__/{desktop,tablet,mobile}/`.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Reuses `scripts/seed-e2e-db` and `BOXBOX_DISABLE_LIVE=1`; no live F1 session
|
||||||
|
or OpenF1 network calls.
|
||||||
|
- Screenshots are taken only after route-specific ready conditions (classification
|
||||||
|
loaded, data library detail visible, live empty state).
|
||||||
|
- Animations disabled; full-page captures; no loading-state screenshots.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Live timing tower screenshots (requires an active session and live SignalR).
|
||||||
|
- Cross-browser matrix beyond Chromium.
|
||||||
|
- Pixel-perfect parity between Vite dev and production builds (use prod update
|
||||||
|
when refreshing committed baselines).
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [21 MVP Completion Checklist](21-mvp-completion-checklist.md)
|
||||||
48
documentations/refactor/23-phase-15-command-center.md
Normal file
48
documentations/refactor/23-phase-15-command-center.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Phase 15: Command Center V1
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make the Web UI default route a useful local-first operations screen instead of
|
||||||
|
requiring users to know a raw Race Hub session key.
|
||||||
|
|
||||||
|
## Completed Scope
|
||||||
|
|
||||||
|
- Added `/` as the Command Center route.
|
||||||
|
- Added a top-level Command nav item while preserving Race Hub, Live, and Data
|
||||||
|
Library routes.
|
||||||
|
- Shows local season coverage, weekend coverage, local session counts, and live
|
||||||
|
availability.
|
||||||
|
- Selects a focus weekend from local data using current, upcoming, then recent
|
||||||
|
weekend priority.
|
||||||
|
- Provides quick actions into Live Timing, Race Hub for the default local
|
||||||
|
session, and Data Library.
|
||||||
|
- Lists recent local sessions with direct Race Hub links.
|
||||||
|
- Added unit coverage for schedule selection helpers and the Command Center
|
||||||
|
page.
|
||||||
|
- Added Playwright E2E and production smoke coverage for `/`.
|
||||||
|
- Added visual regression coverage for Command Center at desktop, tablet, and
|
||||||
|
mobile viewports.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- React continues to call only local-first Go APIs; no direct OpenF1 reads were
|
||||||
|
added.
|
||||||
|
- Live state remains read-only status from the existing Web live endpoint.
|
||||||
|
- The page stays dense and operational rather than becoming a marketing landing
|
||||||
|
page.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --prefix frontend test -- --run
|
||||||
|
npm --prefix frontend run build
|
||||||
|
npm run test:e2e
|
||||||
|
npm run test:e2e:prod
|
||||||
|
npm run test:visual
|
||||||
|
npm run test:visual:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [21 MVP Completion Checklist](21-mvp-completion-checklist.md)
|
||||||
|
- [22 Phase 14 Visual Regression](22-phase-14-visual-regression.md)
|
||||||
43
documentations/refactor/24-phase-16-live-timing-polish.md
Normal file
43
documentations/refactor/24-phase-16-live-timing-polish.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Phase 16: Live Timing Polish
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Improve the React Live Timing route as an operations screen while preserving the
|
||||||
|
existing official F1 SignalR bridge and TUI live behavior.
|
||||||
|
|
||||||
|
## Completed Scope
|
||||||
|
|
||||||
|
- Added pure helpers for position delta styling and race-control flag classes.
|
||||||
|
- Improved the timing tower with podium position styling, colored position
|
||||||
|
deltas, best-lap/lap-count columns, and compact status badges.
|
||||||
|
- Reworked the session banner so track status, lap count, clock, live/stale
|
||||||
|
state, and weather read as dense operational metadata.
|
||||||
|
- Improved race-control feed treatment with color-coded flag badges, category
|
||||||
|
labels for non-flag messages, and bounded scrolling.
|
||||||
|
- Reworked the live route layout into a two-column desktop view with timing
|
||||||
|
tower priority and race control alongside it.
|
||||||
|
- Improved empty and disconnected states without requiring a real live F1
|
||||||
|
session.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- No backend live bridge or TUI live code was changed.
|
||||||
|
- No persisted live storage was added.
|
||||||
|
- Tests continue to use disabled-live/empty-state coverage because an active F1
|
||||||
|
session is not guaranteed.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --prefix frontend test -- --run
|
||||||
|
npm --prefix frontend run build
|
||||||
|
npm run test:e2e
|
||||||
|
npm run test:e2e:prod
|
||||||
|
npm run test:visual
|
||||||
|
npm run test:visual:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [21 MVP Completion Checklist](21-mvp-completion-checklist.md)
|
||||||
|
- [23 Phase 15 Command Center](23-phase-15-command-center.md)
|
||||||
97
documentations/refactor/25-phase-18-fan-command-center.md
Normal file
97
documentations/refactor/25-phase-18-fan-command-center.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# Phase 18: Fan Command Center and Admin Split
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Shift the Web UI from a local-data console toward an F1 fan race-weekend
|
||||||
|
command center, while keeping the dark, dense, ops-room aesthetic. Move
|
||||||
|
local-data and ingestion concerns into a thin Admin / Data Health area so they
|
||||||
|
no longer dominate the fan-facing first screen.
|
||||||
|
|
||||||
|
## Completed Scope
|
||||||
|
|
||||||
|
- Reworked `/` (`CommandCenterPage`) around the focus race weekend:
|
||||||
|
- GP identity band with country decal, GP name, location, circuit, date
|
||||||
|
range, status eyebrow (Live now / Current / Next / Recent), and a
|
||||||
|
country-derived left-edge accent strip.
|
||||||
|
- Countdown block — LIVE state, current-session label, next-session
|
||||||
|
`Xd HHh MMm SSs` countdown, or "Weekend finished" — anchored to the band.
|
||||||
|
- Primary actions row: Watch Live, Open Analysis (preselects race → qual →
|
||||||
|
first local session), Schedule jump.
|
||||||
|
- Session schedule as responsive cards instead of a table, with per-session
|
||||||
|
coverage dot, status pill, and direct Race Hub link (keeps the existing
|
||||||
|
`cc-session-{key}` testid).
|
||||||
|
- Recent local weekends rendered as a chip strip with country decals.
|
||||||
|
- Empty state reframed: short eyebrow, single-paragraph instruction, and
|
||||||
|
links to Live + Admin (no inline CLI on the fan-facing surface).
|
||||||
|
- Added `frontend/src/lib/gpIdentity.ts` for country accent, 3-letter decal,
|
||||||
|
and short date-range formatting.
|
||||||
|
- Reframed `DataLibraryPage` as **Admin · Data Health**: page header,
|
||||||
|
utility-style stats banner (seasons / full / partial / missing), and a
|
||||||
|
back-link to Command Center in the footer.
|
||||||
|
- Added `/admin` route rendering the same Data Health page. `/data-library`
|
||||||
|
remains as a legacy alias so existing links keep working.
|
||||||
|
- Updated `Nav` so primary fan destinations (Command / Live / Race Hub) sit
|
||||||
|
next to the logo, and a small monospace **Admin** chip is anchored to the
|
||||||
|
far right as a utility link.
|
||||||
|
|
||||||
|
## What Moved Into Admin / Data Health
|
||||||
|
|
||||||
|
- Five-stat coverage strip (Seasons / Full / Partial / Missing / Sessions
|
||||||
|
Local) — now lives in the admin banner.
|
||||||
|
- CLI ingest guidance — only shown under Admin and inside meeting detail.
|
||||||
|
- Per-meeting weekend table, per-session dataset status panel, and ingest
|
||||||
|
command blocks — unchanged content, but no longer reachable from the fan
|
||||||
|
nav directly.
|
||||||
|
|
||||||
|
## What Did Not Change
|
||||||
|
|
||||||
|
- Race Hub session-key table flow is unchanged (deeper rework is deferred).
|
||||||
|
- No new backend endpoints. Command Center still calls the existing local-first
|
||||||
|
APIs (`/api/v1/seasons`, `/api/v1/meetings`, `/api/v1/weekend`,
|
||||||
|
`/api/v1/live/state`).
|
||||||
|
- Live SignalR bridge, Live Timing page, and TUI live mode are untouched.
|
||||||
|
|
||||||
|
## Tests and Visual Coverage
|
||||||
|
|
||||||
|
Updated:
|
||||||
|
|
||||||
|
- `frontend/src/test/CommandCenterPage.test.tsx` — covers new band, decal,
|
||||||
|
empty-state copy, and analysis action label.
|
||||||
|
- `tests/command-center.spec.ts` — exercises new actions container and the
|
||||||
|
reframed admin route alongside existing routes.
|
||||||
|
- `tests/data-library.spec.ts` — renamed describe block, exercises both
|
||||||
|
`/admin` and the `/data-library` alias, uses the new Admin nav link.
|
||||||
|
- `tests/production-smoke.spec.ts` — covers `/admin`, the legacy
|
||||||
|
`/data-library` route, and the updated nav labels.
|
||||||
|
- `tests/visual/__snapshots__/{desktop,tablet,mobile}/{command-center,data-library}.png`
|
||||||
|
regenerated against the new layout.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --prefix frontend test -- --run
|
||||||
|
npm --prefix frontend run build
|
||||||
|
npm run test:e2e
|
||||||
|
npm run test:e2e:prod
|
||||||
|
npm run test:visual
|
||||||
|
npm run test:visual:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
All commands above were run in this branch and pass: 94 unit tests, 15 E2E,
|
||||||
|
6 prod smoke, 12 visual baseline, 12 prod visual baseline.
|
||||||
|
|
||||||
|
## Limitations and Follow-ups
|
||||||
|
|
||||||
|
- The Schedule action only renders when a next session exists. During a
|
||||||
|
live session the row shows two actions instead of three; intentional.
|
||||||
|
- Country accent palette in `gpIdentity.ts` is a hand-tuned subset of country
|
||||||
|
codes; unknown codes fall back to a neutral gray.
|
||||||
|
- Race Hub remains the next target — its session-selection flow is still the
|
||||||
|
most dated part of the fan path, especially on tablet.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [21 MVP Completion Checklist](21-mvp-completion-checklist.md)
|
||||||
|
- [22 Phase 14 Visual Regression](22-phase-14-visual-regression.md)
|
||||||
|
- [23 Phase 15 Command Center](23-phase-15-command-center.md)
|
||||||
|
- [24 Phase 16 Live Timing Polish](24-phase-16-live-timing-polish.md)
|
||||||
128
documentations/refactor/26-phase-19-weekend-workspace.md
Normal file
128
documentations/refactor/26-phase-19-weekend-workspace.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# Phase 19: Weekend Workspace / Race Hub Flow V1
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Rework `/race-hub` from an "admin-style table on top, analysis below" page
|
||||||
|
into a Weekend Workspace that feels like a modern session companion: a
|
||||||
|
compact GP identity band, a horizontal session rail, an Overview snapshot,
|
||||||
|
and fan-oriented analysis tabs. Keep `/race-hub?session_key=…` working,
|
||||||
|
keep ingestion/admin concerns out of the fan surface, and make mobile/iPad
|
||||||
|
first-class.
|
||||||
|
|
||||||
|
## Completed Scope
|
||||||
|
|
||||||
|
- Replaced the legacy Race Hub layout with a Weekend Workspace:
|
||||||
|
- **Topbar**: `box-box · race hub · <year>` eyebrow, weekend source badge,
|
||||||
|
and a `Switch Weekend` toggle that opens an inline switcher panel.
|
||||||
|
- **GP identity band**: country decal, GP name, location · circuit, date
|
||||||
|
range, with a left-edge `--gp-accent` strip derived from
|
||||||
|
`frontend/src/lib/gpIdentity.ts`.
|
||||||
|
- **Session rail**: horizontal segmented strip of session cards (FP1,
|
||||||
|
FP2, FP3, Q, Sprint, R …) showing abbreviation, name, time, source dot,
|
||||||
|
and coverage hint. Switching is a single click; selected chip pulses
|
||||||
|
with the GP accent.
|
||||||
|
- **Active session sub-bar**: compact line with session name, scheduled
|
||||||
|
time, coverage label, and `key <session_key>` for power users.
|
||||||
|
- New tab grouping (`frontend/src/components/TabBar.tsx`):
|
||||||
|
Overview · Race Story · Strategy · Lap Data · Conditions · Race Control ·
|
||||||
|
Data Status. Race Story bundles classification, starting grid, and
|
||||||
|
position evolution behind a sub-segmented control so the operational
|
||||||
|
feel is preserved without exploding the top-level tab list.
|
||||||
|
- New **Overview** tab (`components/OverviewView.tsx`): operational stat
|
||||||
|
cards (Winner / Pole / Fastest Lap / Podium) plus compact panels for
|
||||||
|
Conditions, latest Race Control messages, and a Local Coverage meter
|
||||||
|
that links to the relevant Data Status tab.
|
||||||
|
- Inline **Weekend Switcher** (`components/WeekendSwitcher.tsx`): season
|
||||||
|
tabs, meeting cards with country decals, and expandable per-meeting
|
||||||
|
session lists that navigate via `useNavigate`. Replaces the old fullscreen
|
||||||
|
`LocalDataNavigator` table on the Race Hub surface.
|
||||||
|
- Auto-resolution when `/race-hub` is opened without `session_key`: the
|
||||||
|
page resolves the focus weekend via the same `pickFocusMeeting` helper
|
||||||
|
Command Center uses and `navigate(replace: true)` to the focus
|
||||||
|
session (race → qualifying → first local session).
|
||||||
|
- **Data Status** tab now points at `/admin` for missing datasets instead of
|
||||||
|
inlining CLI commands. Admin remains the home for ingestion guidance.
|
||||||
|
- GP accent is plumbed through CSS custom property `--gp-accent`, used by
|
||||||
|
session chips, story sub-control underline, overview stat cards, and the
|
||||||
|
topbar `Switch Weekend` border.
|
||||||
|
|
||||||
|
## Route Behavior
|
||||||
|
|
||||||
|
- `/race-hub?session_key=9472` — unchanged contract; loads the workspace
|
||||||
|
for that session and opens Overview by default.
|
||||||
|
- `/race-hub` (no key) — resolves locally via `fetchSeasons` →
|
||||||
|
`fetchLocalMeetings` → `pickFocusMeeting` → `fetchWeekend`, then
|
||||||
|
`navigate({ replace: true })` to the focus session's race/qualifying.
|
||||||
|
- `/data-library` and `/admin` remain untouched.
|
||||||
|
|
||||||
|
## What Did Not Change
|
||||||
|
|
||||||
|
- Backend APIs (`/api/v1/race-hub`, `/api/v1/seasons`, `/api/v1/meetings`,
|
||||||
|
`/api/v1/weekend`).
|
||||||
|
- Live SignalR bridge, `/live` page, TUI live mode.
|
||||||
|
- Command Center, Admin / Data Health flows.
|
||||||
|
- Existing chart and table components (`ClassificationTable`,
|
||||||
|
`StartingGridTable`, `StrategyView`, `PositionEvolutionView`, `LapsView`,
|
||||||
|
`RaceControlView`, `WeatherView`) are reused inside the new shell.
|
||||||
|
- The legacy `LocalDataNavigator` component is kept (still unit-tested) so
|
||||||
|
any future surfaces can reuse it, but it is no longer mounted on
|
||||||
|
`/race-hub`.
|
||||||
|
|
||||||
|
## Tests and Visual Coverage
|
||||||
|
|
||||||
|
Updated:
|
||||||
|
|
||||||
|
- `frontend/src/test/TabBar.test.tsx` — new tab list (Overview / Race Story
|
||||||
|
/ Strategy / Lap Data / Conditions / Race Control / Data Status).
|
||||||
|
- `frontend/src/test/DatasetStatusView.test.tsx` — rewritten against the
|
||||||
|
fan-facing dataset list (11/11), the new `Manage ingestion → /admin`
|
||||||
|
link, and the removal of inline CLI hints.
|
||||||
|
- `frontend/src/test/RaceHubPage.test.tsx` — new test file covering the
|
||||||
|
identity band, session rail, Race Story sub-controls, Data Status admin
|
||||||
|
link, and the inline weekend switcher.
|
||||||
|
- `tests/race-hub.spec.ts` — rewritten E2E spec covering Overview default,
|
||||||
|
Race Story sub-views, Strategy and Positions missing-data notices,
|
||||||
|
weekend switcher toggle, Data Status admin link, and the bare
|
||||||
|
`/race-hub` redirect.
|
||||||
|
- `tests/command-center.spec.ts`, `tests/data-library.spec.ts`,
|
||||||
|
`tests/production-smoke.spec.ts` — updated assertions to land on the
|
||||||
|
new workspace shell rather than the old "Final Classification" headline.
|
||||||
|
- `tests/visual/helpers.ts` — `gotoRaceHubReady` now waits for
|
||||||
|
`race-hub` + `rh-identity` + `rh-session-<key>` + `rh-overview`.
|
||||||
|
- `tests/visual/__snapshots__/{desktop,tablet,mobile}/race-hub.png` —
|
||||||
|
regenerated. Command Center, Admin, and Live snapshots untouched.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --prefix frontend test -- --run # 98 unit tests pass
|
||||||
|
npm --prefix frontend run build # tsc + vite, clean
|
||||||
|
npm run test:e2e # 18 E2E pass
|
||||||
|
npm run test:e2e:prod # 6 prod smoke pass
|
||||||
|
npm run test:visual # 12 baseline pass after regen
|
||||||
|
npm run test:visual:prod # 12 prod baseline pass after regen
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limitations and Follow-ups
|
||||||
|
|
||||||
|
- The Race Story tab keeps three legacy datasets behind a sub-segmented
|
||||||
|
control. A future pass could merge classification + position evolution
|
||||||
|
into a single scrollable "story" canvas.
|
||||||
|
- Overview's "Fastest Lap" picks the minimum non-pit-out `lap_duration` from
|
||||||
|
the ingested laps payload. Sessions that don't ingest laps show "No data
|
||||||
|
ingested" — accurate, but a future phase could fall back to OpenF1's
|
||||||
|
`fastest_lap` field if/when that lands locally.
|
||||||
|
- The inline weekend switcher fetches the active meeting's `/weekend`
|
||||||
|
payload only when expanded. Switching seasons or browsing many
|
||||||
|
meetings does not pre-warm sibling weekend queries; this is intentional
|
||||||
|
to avoid the N×weekend fan-out that Command Center already pays.
|
||||||
|
- Visual baselines are regenerated against the current seeded e2e DB. If
|
||||||
|
the seeded session list grows, the desktop snapshot will widen.
|
||||||
|
- Country accents in `gpIdentity.ts` remain a hand-tuned subset; unknown
|
||||||
|
codes fall back to a neutral gray (same behavior as Command Center).
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [21 MVP Completion Checklist](21-mvp-completion-checklist.md)
|
||||||
|
- [22 Phase 14 Visual Regression](22-phase-14-visual-regression.md)
|
||||||
|
- [25 Phase 18 Fan Command Center](25-phase-18-fan-command-center.md)
|
||||||
110
documentations/refactor/27-phase-19b-paddock-briefing-rss.md
Normal file
110
documentations/refactor/27-phase-19b-paddock-briefing-rss.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# Phase 19B Paddock Briefing RSS Backend Spike
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Prototype the backend foundation for a future fan-facing Paddock Briefing module
|
||||||
|
without scraping article pages or touching Race Hub UI. The spike keeps news as a
|
||||||
|
local-first cache: feeds can be fetched and normalized by backend code, stored in
|
||||||
|
SQLite, and read through a small API shape.
|
||||||
|
|
||||||
|
## Source Evaluation
|
||||||
|
|
||||||
|
Recommended first-party or publisher-owned feeds:
|
||||||
|
|
||||||
|
- FIA official RSS, `https://www.fia.com/rss/news`: keep as the official source
|
||||||
|
for federation announcements and regulatory context.
|
||||||
|
- BBC Sport F1, `https://feeds.bbci.co.uk/sport/formula1`: strong free headline
|
||||||
|
source for UK-centered coverage.
|
||||||
|
- Autosport F1, `https://www.autosport.com/rss/f1/news/`: useful motorsport
|
||||||
|
specialist feed; retain summaries only when provided by the feed.
|
||||||
|
- RaceFans F1, `https://www.racefans.net/category/f1-news/feed/`: useful
|
||||||
|
independent specialist feed with a clean WordPress RSS surface.
|
||||||
|
- Guardian Formula One,
|
||||||
|
`https://www.theguardian.com/sport/formulaone/rss`: broad editorial coverage
|
||||||
|
and stable RSS conventions.
|
||||||
|
|
||||||
|
Optional sources to evaluate before shipping:
|
||||||
|
|
||||||
|
- RACER F1, `https://racer.com/f1/feed`: reasonable supplemental specialist
|
||||||
|
feed.
|
||||||
|
- Formula 1 YouTube Atom,
|
||||||
|
`https://www.youtube.com/feeds/videos.xml?channel_id=UCB_qr75-ydFVKSF9Dmo6izg`:
|
||||||
|
video-only briefing cards, separate from article news.
|
||||||
|
- Motorsport.com F1, `https://www.motorsport.com/rss/f1/news/`: not included in
|
||||||
|
the default prototype list until terms and caching expectations are reviewed.
|
||||||
|
|
||||||
|
Avoid Formula1.com scraping or hidden endpoints, X/Twitter scraping, Reddit as a
|
||||||
|
primary news source, and feed aggregator products such as RSS.app or Feedspot.
|
||||||
|
|
||||||
|
## Legal And Product Caveats
|
||||||
|
|
||||||
|
Only fetch publisher-provided RSS/Atom XML. Do not fetch article bodies, bypass
|
||||||
|
paywalls, scrape Open Graph metadata, or store full article content. The product
|
||||||
|
surface should show source, title, canonical URL, publish time, category, and a
|
||||||
|
short feed-provided summary/snippet when available. Each card should link users
|
||||||
|
to the publisher site for the article.
|
||||||
|
|
||||||
|
Before enabling a source by default, review the publisher feed terms, robots/TOS
|
||||||
|
language around caching, and whether feed summaries are intended for display.
|
||||||
|
Keep TTLs conservative and make source attribution visible in the UI.
|
||||||
|
|
||||||
|
## Implemented Proof
|
||||||
|
|
||||||
|
This spike adds:
|
||||||
|
|
||||||
|
- `internal/news`: a standard-library RSS/Atom parser and polite fetch helper
|
||||||
|
with a 10-second default timeout and a box-box User-Agent.
|
||||||
|
- URL-based deduplication with UTM parameter stripping.
|
||||||
|
- `news_sources` and `news_items` tables in SQLite migration `003_news.sql`.
|
||||||
|
- Store/query methods for upserting cached feed metadata/items and listing
|
||||||
|
newest cached items.
|
||||||
|
- `GET /api/v1/news`, with optional `limit` and `source` query params.
|
||||||
|
- Unit tests using local XML fixtures only.
|
||||||
|
|
||||||
|
The endpoint is intentionally read-only against the local SQLite cache. It does
|
||||||
|
not fetch feeds during web requests, avoiding unexpected network work in the
|
||||||
|
product UI path. A later ingestion command can call `internal/news.Fetch`, upsert
|
||||||
|
sources/items, and mark `fetched_at`/`expires_at` according to a TTL policy.
|
||||||
|
|
||||||
|
## API Shape
|
||||||
|
|
||||||
|
`GET /api/v1/news?limit=25&source=racefans-f1`
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"source": "racefans-f1",
|
||||||
|
"title": "Example headline",
|
||||||
|
"url": "https://publisher.example/story",
|
||||||
|
"published_at": "2026-05-25T14:00:00Z",
|
||||||
|
"summary": "Feed-provided snippet",
|
||||||
|
"category": "news",
|
||||||
|
"fetched_at": "2026-05-25T14:10:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Default limit is 25; maximum accepted limit is 100.
|
||||||
|
|
||||||
|
## Follow-Up Frontend Plan
|
||||||
|
|
||||||
|
Add a Paddock Briefing surface outside Race Hub while Race Hub redesign work is
|
||||||
|
active. Recommended first UI slice:
|
||||||
|
|
||||||
|
- Query `/api/v1/news?limit=12`.
|
||||||
|
- Group by recency with source badges and external-link treatment.
|
||||||
|
- Show snippets only when present, with clear publisher attribution.
|
||||||
|
- Add source filters after the cache refresh command exists.
|
||||||
|
- Treat video feed items as a separate rail or filter, not mixed into hard-news
|
||||||
|
headlines by default.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Should news refresh live behind an explicit CLI command, opportunistic startup
|
||||||
|
refresh, or a manual button in an admin/data-health screen?
|
||||||
|
- What default TTL should each source use? A 15-30 minute TTL is reasonable for
|
||||||
|
race weekends; longer may be enough outside live sessions.
|
||||||
|
- Should the cache keep historical briefing items indefinitely, or prune after a
|
||||||
|
rolling window such as 30-90 days?
|
||||||
235
documentations/refactor/28-orchestrator-handoff.md
Normal file
235
documentations/refactor/28-orchestrator-handoff.md
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
# Orchestrator Handoff
|
||||||
|
|
||||||
|
You are taking over as the primary orchestration/coding agent for the box-box refactor.
|
||||||
|
|
||||||
|
Repo:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/Users/aman/HomeBase/Programming/Projects/box-box
|
||||||
|
```
|
||||||
|
|
||||||
|
Branch:
|
||||||
|
|
||||||
|
```text
|
||||||
|
box-refactor
|
||||||
|
```
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
You are the engineering manager/orchestrator. Inspect before acting, keep changes scoped, review agent work before committing, prune stale docs after phases, and commit cleanly after each accepted phase. The user prefers Cursor for backend/test/hardening work and Claude for major frontend/product/design work, but you may implement directly when appropriate.
|
||||||
|
|
||||||
|
## Operating Rules
|
||||||
|
|
||||||
|
- Do not rush into implementation if the user wants to discuss.
|
||||||
|
- If implementing, keep phases small and commit-ready.
|
||||||
|
- Commit after each completed/reviewed phase.
|
||||||
|
- Never revert user/other-agent changes without explicit permission.
|
||||||
|
- Use `rg` for searches.
|
||||||
|
- Use `apply_patch` for manual edits.
|
||||||
|
- For frontend work, run browser or Playwright verification where practical.
|
||||||
|
- For review requests, lead with findings and file/line references.
|
||||||
|
- `frontend/dist` is ignored and should not be committed.
|
||||||
|
- Preserve TUI live mode and official F1 SignalR live behavior carefully.
|
||||||
|
|
||||||
|
## Project Direction
|
||||||
|
|
||||||
|
box-box started as a Go Bubble Tea F1 TUI backed mostly by OpenF1. The refactor direction is now:
|
||||||
|
|
||||||
|
- Web UI is the primary product surface.
|
||||||
|
- React + TypeScript frontend is the production Web UI stack.
|
||||||
|
- Go backend remains the API/server.
|
||||||
|
- Historical/completed-session data should be local-first from SQLite.
|
||||||
|
- OpenF1 ingestion is explicit via CLI, not fetched live on every page load.
|
||||||
|
- Official F1 SignalR remains the live source.
|
||||||
|
- Desired product feel: clean, dense, technical F1 operations room. Avoid card-heavy AI-slop.
|
||||||
|
|
||||||
|
## Important Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --short
|
||||||
|
git log --oneline -10
|
||||||
|
|
||||||
|
go test ./...
|
||||||
|
npm --prefix frontend test -- --run
|
||||||
|
npm --prefix frontend run build
|
||||||
|
npm run test:e2e
|
||||||
|
npm run test:e2e:prod
|
||||||
|
npm run test:visual
|
||||||
|
npm run test:visual:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
Known test note: do not run Playwright suites that share the same seeded SQLite DB in parallel. Run prod E2E and prod visual sequentially, or they may hit `database is locked`.
|
||||||
|
|
||||||
|
## Recent Commits
|
||||||
|
|
||||||
|
- `a0f135a Update operator documentation`
|
||||||
|
- `79b0b9f Rework command center for race weekends`
|
||||||
|
- `84a8827 Add paddock briefing RSS backend spike`
|
||||||
|
- `ee88a07 Add paddock briefing feed ingestion`
|
||||||
|
- `Rework Race Hub as weekend workspace` (latest Phase 19 commit)
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
Phase 19 and Phase 20 have been reviewed and committed. Start new work from a
|
||||||
|
clean tree unless `git status --short` shows user changes made after this
|
||||||
|
handoff.
|
||||||
|
|
||||||
|
## Completed Phase 19: Weekend Workspace / Race Hub Flow V1
|
||||||
|
|
||||||
|
Claude implemented, and Codex reviewed/committed, a Race Hub redesign that turns
|
||||||
|
`/race-hub` into a Weekend Workspace.
|
||||||
|
|
||||||
|
- New:
|
||||||
|
- `frontend/src/components/OverviewView.tsx`
|
||||||
|
- `frontend/src/components/WeekendSwitcher.tsx`
|
||||||
|
- `frontend/src/test/RaceHubPage.test.tsx`
|
||||||
|
- `documentations/refactor/26-phase-19-weekend-workspace.md`
|
||||||
|
- Modified:
|
||||||
|
- `frontend/src/pages/RaceHubPage.tsx`
|
||||||
|
- `frontend/src/components/TabBar.tsx`
|
||||||
|
- `frontend/src/components/DatasetStatusView.tsx`
|
||||||
|
- `frontend/src/styles/app.css`
|
||||||
|
- tests for TabBar, DatasetStatusView, race-hub, command-center, data-library, production-smoke
|
||||||
|
- `tests/visual/helpers.ts`
|
||||||
|
- race-hub visual snapshots
|
||||||
|
- root `README.md`
|
||||||
|
- `documentations/refactor/README.md`
|
||||||
|
|
||||||
|
UX changes:
|
||||||
|
|
||||||
|
- Race Hub is now a Weekend Workspace.
|
||||||
|
- Compact GP identity band with country decal/accent strip.
|
||||||
|
- Horizontal session rail replaces “big table then analysis below.”
|
||||||
|
- Active session context stays visible above tabs.
|
||||||
|
- Tabs regrouped into Overview, Race Story, Strategy, Lap Data, Conditions, Race Control, Data Status.
|
||||||
|
- New Overview tab with winner/pole/fastest/podium cards, condition chips, latest race control, and local coverage meter.
|
||||||
|
- Inline Switch Weekend panel replaces legacy LocalDataNavigator on Race Hub.
|
||||||
|
- Data Status links to `/admin`; no CLI/admin text on fan surface.
|
||||||
|
- Mobile/iPad improved with wrapping identity band, horizontal session rail, single-column stats.
|
||||||
|
- `/race-hub?session_key=9472` still works and loads Bahrain GP 2024 seeded session.
|
||||||
|
- Bare `/race-hub` now resolves to a focus weekend/session via `pickFocusMeeting` and navigation replace.
|
||||||
|
|
||||||
|
Verification run by Codex before commit:
|
||||||
|
|
||||||
|
- `npm --prefix frontend test -- --run`
|
||||||
|
- `npm --prefix frontend run build`
|
||||||
|
- `npm run test:e2e`
|
||||||
|
- `npm run test:e2e:prod`
|
||||||
|
- `npm run test:visual`
|
||||||
|
- `npm run test:visual:prod`
|
||||||
|
|
||||||
|
Small review fix included: `frontend/src/test/setup.ts` stubs
|
||||||
|
`window.scrollTo` so TanStack Router scroll restoration does not spam jsdom test
|
||||||
|
stderr.
|
||||||
|
|
||||||
|
## Completed Phase 20: Paddock Briefing Ingestion CLI
|
||||||
|
|
||||||
|
A backend subagent implemented, and Codex reviewed/committed, Phase 20 after the
|
||||||
|
RSS backend spike.
|
||||||
|
|
||||||
|
Phase 20 changes:
|
||||||
|
|
||||||
|
- Modified:
|
||||||
|
- `cmd/main.go`
|
||||||
|
- New:
|
||||||
|
- `internal/news/refresh.go`
|
||||||
|
- `internal/news/refresh_test.go`
|
||||||
|
- `documentations/refactor/29-phase-20-paddock-briefing-ingestion.md`
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- Adds `--ingest-news` as a CLI mode.
|
||||||
|
- Keeps it mutually exclusive with `--ingest-year`, `--ingest-meeting`, and
|
||||||
|
`--ingest-session`.
|
||||||
|
- Reuses `--db` for the domain SQLite path.
|
||||||
|
- Reuses `--dry-run` to fetch and report feed counts without opening or writing
|
||||||
|
the domain database.
|
||||||
|
- Uses `internal/news.Refresh`, which fetches `DefaultSources`, upserts
|
||||||
|
`news_sources`, upserts URL-deduped `news_items`, records `fetched_at` and
|
||||||
|
`expires_at`, and continues through individual feed failures before returning
|
||||||
|
a summary error.
|
||||||
|
- Web requests still do not fetch feeds; `/api/v1/news` remains read-only
|
||||||
|
against SQLite.
|
||||||
|
|
||||||
|
Commands added:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/main.go --ingest-news
|
||||||
|
go run ./cmd/main.go --dry-run --ingest-news
|
||||||
|
go run ./cmd/main.go --ingest-news --db /tmp/boxbox.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Verification run by Codex before commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./cmd/... ./internal/news ./internal/store
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Immediate Task
|
||||||
|
|
||||||
|
Start with a quick sync:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --short
|
||||||
|
git diff --stat
|
||||||
|
```
|
||||||
|
|
||||||
|
Then continue with the next requested phase. The most natural next phase is
|
||||||
|
Phase 21: Paddock Briefing UI, unless the user wants to deepen Race Story first.
|
||||||
|
|
||||||
|
## RSS / Paddock Briefing Context
|
||||||
|
|
||||||
|
Cursor completed and Codex committed a backend spike as `84a8827 Add paddock briefing RSS backend spike`.
|
||||||
|
|
||||||
|
Implemented:
|
||||||
|
|
||||||
|
- `internal/news`: RSS/Atom parser and fetch helper.
|
||||||
|
- SQLite tables:
|
||||||
|
- `news_sources`
|
||||||
|
- `news_items`
|
||||||
|
- Store/query methods for cached news.
|
||||||
|
- Read-only API:
|
||||||
|
- `GET /api/v1/news?limit=25&source=racefans-f1`
|
||||||
|
- No request-time network fetching.
|
||||||
|
- Unit tests use local XML fixtures.
|
||||||
|
|
||||||
|
Recommended feed sources:
|
||||||
|
|
||||||
|
- FIA official RSS
|
||||||
|
- BBC Sport F1
|
||||||
|
- Autosport F1
|
||||||
|
- RaceFans F1
|
||||||
|
- Guardian Formula One
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- Motorsport.com
|
||||||
|
- RACER
|
||||||
|
- Formula 1 YouTube Atom
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
|
||||||
|
- Formula1.com scraping/hidden endpoints
|
||||||
|
- X/Twitter scraping
|
||||||
|
- Reddit as primary source
|
||||||
|
- RSS.app/Feedspot as primary source
|
||||||
|
|
||||||
|
## Likely Next Phases After Phase 19 And 20
|
||||||
|
|
||||||
|
1. Phase 21: Paddock Briefing UI
|
||||||
|
- Claude/frontend.
|
||||||
|
- Add fan-facing briefing module, likely on Command Center first.
|
||||||
|
- Query `/api/v1/news`.
|
||||||
|
- Show source, title, age, category, short feed-provided snippet, external link.
|
||||||
|
- Keep publisher attribution visible.
|
||||||
|
- Avoid full article storage or scraping.
|
||||||
|
|
||||||
|
2. Phase 22: Race Story Deepening
|
||||||
|
- Claude/frontend or mixed.
|
||||||
|
- Collapse legacy classification/grid/position components into a more fluid Race Story canvas.
|
||||||
|
- Improve mobile scanning and session narrative.
|
||||||
|
|
||||||
|
3. Phase 23: Full Season Backfill / ingest hardening
|
||||||
|
- Cursor/backend.
|
||||||
|
- Safer season workflows, resumability, rate-limit controls, coverage reporting.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Phase 20 Paddock Briefing Ingestion CLI
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Turn the Phase 19B RSS backend spike into an explicit local refresh command for
|
||||||
|
the Paddock Briefing cache. Feed fetching remains a CLI-only operation; web
|
||||||
|
requests continue to read SQLite only.
|
||||||
|
|
||||||
|
## Implemented
|
||||||
|
|
||||||
|
- Added `--ingest-news` as a CLI ingestion mode on `cmd/main.go`.
|
||||||
|
- Reused `--db` path behavior from the existing OpenF1 ingestion flows.
|
||||||
|
- Reused `--dry-run` to fetch and report feed counts without opening or writing
|
||||||
|
the domain database.
|
||||||
|
- Added `internal/news.Refresh`, which:
|
||||||
|
- fetches `internal/news.DefaultSources` unless tests provide a custom list;
|
||||||
|
- uses `internal/news.Fetch` with a 10-second HTTP client timeout;
|
||||||
|
- upserts `news_sources` with `fetched_at` and `expires_at`;
|
||||||
|
- upserts URL-deduped `news_items`;
|
||||||
|
- continues after individual source failures and returns a summary error after
|
||||||
|
successful sources are stored.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/main.go --ingest-news
|
||||||
|
go run ./cmd/main.go --dry-run --ingest-news
|
||||||
|
go run ./cmd/main.go --ingest-news --db /tmp/boxbox.db
|
||||||
|
```
|
||||||
|
|
||||||
|
`--ingest-news` is mutually exclusive with `--ingest-year`, `--ingest-meeting`,
|
||||||
|
and `--ingest-session`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Tests use local `httptest.Server` feeds only. No live internet test is required
|
||||||
|
for the refresh logic.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/news ./internal/store
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
The refresh TTL is currently 30 minutes for all sources. A future phase can add
|
||||||
|
source-specific TTLs, retention/pruning, or admin UI controls without changing
|
||||||
|
the read-only `/api/v1/news` contract.
|
||||||
128
documentations/refactor/README.md
Normal file
128
documentations/refactor/README.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# box-box Refactor Brief
|
||||||
|
|
||||||
|
## Operator guide
|
||||||
|
|
||||||
|
For day-to-day build, run, ingest, test, and Web route commands, use the
|
||||||
|
top-level [README.md](../../README.md). This directory is for architecture,
|
||||||
|
phasing, and research — not the first stop for new contributors.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This directory captures the planning baseline for the next major evolution of
|
||||||
|
`box-box`. The current project has a strong live timing core, especially through
|
||||||
|
the official F1 live feed, but the rest of the app still behaves like an
|
||||||
|
on-demand OpenF1 client. That makes historical and session data unreliable,
|
||||||
|
especially during live-session API lockouts.
|
||||||
|
|
||||||
|
The refactor direction is to make the Web UI the primary product surface, make
|
||||||
|
historical data local-first, and preserve the TUI live mode that already works
|
||||||
|
well. These documents are intentionally strategic and research-ready. They are
|
||||||
|
not implementation tickets yet.
|
||||||
|
|
||||||
|
## Strategic Defaults
|
||||||
|
|
||||||
|
- Frontend: React + TypeScript, built with Vite.
|
||||||
|
- Backend: Go remains the application and API server.
|
||||||
|
- Storage: SQLite becomes a real local domain database, not only an HTTP cache.
|
||||||
|
- Historical ingestion: OpenF1 REST is the first ingestion/backfill source.
|
||||||
|
- Live timing: official F1 SignalR remains the live source.
|
||||||
|
- Ingestion model: explicit CLI backfill plus opportunistic small web fetches.
|
||||||
|
- TUI: preserve the current live mode; new historical/analytics work focuses on
|
||||||
|
the Web UI first.
|
||||||
|
- Product stance: race-weekend first, local-first, no rushed implementation.
|
||||||
|
- Live persistence: persist live SignalR events/snapshots as a separate
|
||||||
|
append-only stream once the live bridge is extracted; do not merge them into
|
||||||
|
post-session OpenF1 records without a reconciliation design.
|
||||||
|
- Migration: keep the current raw HTTP cache behavior intact while introducing
|
||||||
|
the new domain database incrementally.
|
||||||
|
|
||||||
|
## Documents
|
||||||
|
|
||||||
|
- [01 Data Sources](01-data-sources.md): current and candidate data sources,
|
||||||
|
source authority, limitations, and open questions.
|
||||||
|
- [02 Backend Architecture](02-backend-architecture.md): proposed backend
|
||||||
|
packages, local-first reads, ingestion policy, and live bridge boundaries.
|
||||||
|
- [03 Database Design](03-database-design.md): target SQLite strategy, raw
|
||||||
|
payload storage, normalized tables, provenance, and research questions.
|
||||||
|
- [04 Web UI Product](04-web-ui-product.md): screen architecture, navigation,
|
||||||
|
responsive behavior, and product priorities.
|
||||||
|
- [05 Frontend Stack](05-frontend-stack.md): React stack choice and supporting
|
||||||
|
libraries.
|
||||||
|
- [06 Visual Design Direction](06-visual-design-direction.md): F1-native visual
|
||||||
|
principles and anti-patterns to avoid.
|
||||||
|
- [07 Research Agents Brief](07-research-agents-brief.md): research tracks for
|
||||||
|
dedicated agents before ticket planning.
|
||||||
|
- [08 V1 Scope and Phasing](08-v1-scope-and-phasing.md): first shippable
|
||||||
|
milestone, non-goals, phase order, and early implementation sequence.
|
||||||
|
- [09 Phase 1 Live Extraction](09-phase-1-live-extraction.md): first coding
|
||||||
|
slice, package boundaries, tests, acceptance criteria, and non-goals.
|
||||||
|
- [10 Phase 2 Store Foundation](10-phase-2-store-foundation.md): second coding
|
||||||
|
slice for introducing the local SQLite domain store without changing product
|
||||||
|
behavior.
|
||||||
|
- [11 Phase 3 Ingestion Foundation](11-phase-3-ingestion-foundation.md): third
|
||||||
|
coding slice for OpenF1-to-store ingestion orchestration.
|
||||||
|
- [12 Phase 4 Local-First Web API](12-phase-4-local-first-web-api.md): fourth
|
||||||
|
coding slice for store-backed Race Hub read models and Web API metadata.
|
||||||
|
- [13 Phase 5 React Race Hub](13-phase-5-react-race-hub.md): first frontend
|
||||||
|
implementation slice for the production Web UI.
|
||||||
|
- [14 Phase 6 React Race Hub Analytics](14-phase-6-react-race-hub-analytics.md):
|
||||||
|
next frontend slice for strategy, position, and richer Race Hub views.
|
||||||
|
- [15 Phase 7 Analytics Data Foundation](15-phase-7-analytics-data-foundation.md):
|
||||||
|
backend slice for laps, stints, pits, race control, weather, and positions.
|
||||||
|
- [16 Phase 8 Analytics Visuals](16-phase-8-analytics-visuals.md): frontend
|
||||||
|
slice for turning the newly available analytics datasets into useful Race Hub
|
||||||
|
views.
|
||||||
|
- [17 Phase 9 Navigation Data API](17-phase-9-navigation-data-api.md): backend
|
||||||
|
slice for local-first season/weekend/session navigation so users do not need
|
||||||
|
raw session keys.
|
||||||
|
- [18 Phase 10 Navigation UI](18-phase-10-navigation-ui.md): frontend slice for
|
||||||
|
adding local-first season/weekend navigation around Race Hub.
|
||||||
|
- [19 Phase 11 Weekend Ingestion](19-phase-11-weekend-ingestion.md): backend
|
||||||
|
slice for making one command ingest a whole race weekend into the local DB.
|
||||||
|
- [20 Phase 12 Data Library UI](20-phase-12-data-library-ui.md): frontend slice
|
||||||
|
for showing local ingestion coverage and next CLI actions.
|
||||||
|
- [21 MVP Completion Checklist](21-mvp-completion-checklist.md): current
|
||||||
|
implementation status, verification commands, and remaining post-MVP work.
|
||||||
|
- [22 Phase 14 Visual Regression](22-phase-14-visual-regression.md): Playwright
|
||||||
|
screenshot coverage for MVP routes and responsive viewports.
|
||||||
|
- [23 Phase 15 Command Center](23-phase-15-command-center.md): default Web
|
||||||
|
entry screen for local coverage, weekend focus, live status, and next actions.
|
||||||
|
- [24 Phase 16 Live Timing Polish](24-phase-16-live-timing-polish.md): denser
|
||||||
|
React live timing layout, status treatment, and race-control polish.
|
||||||
|
- [25 Phase 18 Fan Command Center](25-phase-18-fan-command-center.md): reworks
|
||||||
|
`/` around race-weekend identity and splits ingestion/admin concerns into
|
||||||
|
the new `/admin` (Data Health) route.
|
||||||
|
- [26 Phase 19 Weekend Workspace](26-phase-19-weekend-workspace.md): rebuilds
|
||||||
|
`/race-hub` as a session-card-rail workspace with Overview / Race Story /
|
||||||
|
Strategy / Lap Data / Conditions / Race Control / Data Status tabs,
|
||||||
|
an inline weekend switcher, and GP-accent identity treatment.
|
||||||
|
- [27 Phase 19B Paddock Briefing RSS](27-phase-19b-paddock-briefing-rss.md):
|
||||||
|
backend spike for publisher-owned RSS/Atom feeds, local SQLite caching, and a
|
||||||
|
future fan-facing briefing API.
|
||||||
|
- [29 Phase 20 Paddock Briefing Ingestion](29-phase-20-paddock-briefing-ingestion.md):
|
||||||
|
backend CLI slice for refreshing RSS/Atom feeds into the local news cache.
|
||||||
|
|
||||||
|
## External References
|
||||||
|
|
||||||
|
- OpenF1 documentation: https://openf1.org/docs/
|
||||||
|
- Official F1 SignalR endpoint: https://livetiming.formula1.com/signalr
|
||||||
|
- LiveF1 timing topic reference:
|
||||||
|
https://livef1.goktugocal.com/livetimingf1/data_topics.html
|
||||||
|
- OpenF1.Data package notes on F1 SignalR:
|
||||||
|
https://www.nuget.org/packages/OpenF1.Data/1.0.87
|
||||||
|
|
||||||
|
## Current Repo Context
|
||||||
|
|
||||||
|
The existing application already has:
|
||||||
|
|
||||||
|
- A Go OpenF1 client in `internal/api`.
|
||||||
|
- A SQLite-backed raw HTTP cache and track outline persistence.
|
||||||
|
- A Bubble Tea TUI in `internal/ui`.
|
||||||
|
- A Go-served Web UI in `internal/web`.
|
||||||
|
- A live SignalR bridge extracted into `internal/live` and reused by both TUI
|
||||||
|
and Web mode through server-sent events.
|
||||||
|
|
||||||
|
The refactor should build on that progress instead of replacing it blindly.
|
||||||
|
The goal is to separate source fetching, domain persistence, query/read models,
|
||||||
|
and frontend experience so each layer can be improved without destabilizing the
|
||||||
|
others.
|
||||||
33
frontend/README.md
Normal file
33
frontend/README.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# box-box Web Frontend
|
||||||
|
|
||||||
|
React Race Hub slice for the production Web UI.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
npm test -- --run
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
The Vite dev server proxies `/api` requests to the Go server on
|
||||||
|
`http://localhost:8080`.
|
||||||
|
|
||||||
|
Run the backend separately:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run cmd/main.go --web
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open the React app, usually:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:5173/race-hub
|
||||||
|
```
|
||||||
|
|
||||||
|
Load an ingested session with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:5173/race-hub?session_key=9472
|
||||||
|
```
|
||||||
32
frontend/index.html
Normal file
32
frontend/index.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<meta name="theme-color" content="#0d0d0d" />
|
||||||
|
<meta name="description" content="box-box: A premium Live F1 Dashboard and Timing Tower" />
|
||||||
|
|
||||||
|
<!-- Open Graph / Social -->
|
||||||
|
<meta property="og:title" content="box-box | Live F1 Dashboard" />
|
||||||
|
<meta property="og:description" content="Real-time F1 timing, telemetry, and race dashboard." />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://box-box.amantahiliani.com/" />
|
||||||
|
<meta property="og:image" content="https://box-box.amantahiliani.com/favicon.jpg" />
|
||||||
|
|
||||||
|
<!-- Icons & PWA -->
|
||||||
|
<link rel="icon" type="image/jpeg" href="/favicon.jpg" />
|
||||||
|
<link rel="apple-touch-icon" href="/favicon.jpg" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
|
||||||
|
<!-- Google Fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700;800&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<title>box-box | Live F1 Dashboard</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4465
frontend/package-lock.json
generated
Normal file
4465
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
frontend/package.json
Normal file
32
frontend/package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "box-box-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@formkit/auto-animate": "^0.9.0",
|
||||||
|
"@tanstack/react-query": "^5.62.0",
|
||||||
|
"@tanstack/react-router": "^1.81.0",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.1.0",
|
||||||
|
"@types/react": "^18.3.17",
|
||||||
|
"@types/react-dom": "^18.3.5",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^6.0.7",
|
||||||
|
"vitest": "^2.1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/public/favicon.jpg
Normal file
BIN
frontend/public/favicon.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 392 KiB |
15
frontend/public/manifest.json
Normal file
15
frontend/public/manifest.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"short_name": "box-box",
|
||||||
|
"name": "box-box | Live F1 Dashboard",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/favicon.jpg",
|
||||||
|
"type": "image/jpeg",
|
||||||
|
"sizes": "192x192 512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#0d0d0d",
|
||||||
|
"background_color": "#0d0d0d"
|
||||||
|
}
|
||||||
159
frontend/src/api.ts
Normal file
159
frontend/src/api.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import type {
|
||||||
|
ArticleContent,
|
||||||
|
CarDataSample,
|
||||||
|
ChampionshipHub,
|
||||||
|
LapsComparisonResponse,
|
||||||
|
LiveStateResponse,
|
||||||
|
LiveSessionMeta,
|
||||||
|
Meeting,
|
||||||
|
NewsItem,
|
||||||
|
RaceHub,
|
||||||
|
Session,
|
||||||
|
TrackOutline,
|
||||||
|
Weekend,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
|
||||||
|
const res = await fetch(`/api/v1/race-hub?session_key=${sessionKey}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSeasons(): Promise<number[]> {
|
||||||
|
const res = await fetch('/api/v1/seasons')
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
const years = await res.json()
|
||||||
|
return Array.isArray(years) ? years : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLocalMeetings(year: number): Promise<Meeting[]> {
|
||||||
|
const res = await fetch(`/api/v1/meetings?year=${year}&source=local`)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
const meetings = await res.json()
|
||||||
|
return Array.isArray(meetings) ? meetings : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
|
||||||
|
const res = await fetch(`/api/v1/meetings?year=${year}&source=openf1`)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
const meetings = await res.json()
|
||||||
|
return Array.isArray(meetings) ? meetings : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise<Session[]> {
|
||||||
|
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
const sessions = await res.json()
|
||||||
|
return Array.isArray(sessions) ? sessions : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
|
||||||
|
const res = await fetch(`/api/v1/weekend?meeting_key=${meetingKey}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchChampionshipHub(year?: number): Promise<ChampionshipHub> {
|
||||||
|
const params = new URLSearchParams({ source: 'auto' })
|
||||||
|
if (year) params.set('year', year.toString())
|
||||||
|
const url = `/api/v1/championship/hub?${params.toString()}`
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLiveState(): Promise<LiveStateResponse> {
|
||||||
|
const res = await fetch('/api/v1/live/state')
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLiveTrackOutline(
|
||||||
|
session: LiveSessionMeta,
|
||||||
|
year = new Date().getFullYear(),
|
||||||
|
): Promise<TrackOutline> {
|
||||||
|
const params = new URLSearchParams({ year: year.toString() })
|
||||||
|
if (session.MeetingName) params.set('meeting_name', session.MeetingName)
|
||||||
|
if (session.CircuitName) params.set('circuit_name', session.CircuitName)
|
||||||
|
const res = await fetch(`/api/v1/track-outline?${params.toString()}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNews(limit?: number, source?: string): Promise<NewsItem[]> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (limit) params.set('limit', limit.toString())
|
||||||
|
if (source) params.set('source', source)
|
||||||
|
|
||||||
|
const query = params.toString()
|
||||||
|
const url = query ? `/api/v1/news?${query}` : '/api/v1/news'
|
||||||
|
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNewsArticle(articleUrl: string): Promise<ArticleContent> {
|
||||||
|
const res = await fetch(`/api/v1/news/article?url=${encodeURIComponent(articleUrl)}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markNewsRead(articleUrl: string): Promise<void> {
|
||||||
|
await fetch('/api/v1/news/read', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: articleUrl }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTelemetry(
|
||||||
|
sessionKey: number,
|
||||||
|
driverNumber: number,
|
||||||
|
): Promise<CarDataSample[]> {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/v1/telemetry?session_key=${sessionKey}&driver_number=${driverNumber}`,
|
||||||
|
)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
return Array.isArray(data) ? data : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLapsComparison(
|
||||||
|
sessionKey: number,
|
||||||
|
drivers?: number[],
|
||||||
|
): Promise<LapsComparisonResponse> {
|
||||||
|
const params = new URLSearchParams({ session_key: String(sessionKey) })
|
||||||
|
if (drivers?.length) {
|
||||||
|
params.set('drivers', drivers.join(','))
|
||||||
|
}
|
||||||
|
const res = await fetch(`/api/v1/laps/comparison?${params}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
233
frontend/src/components/ChampionshipSimulator.tsx
Normal file
233
frontend/src/components/ChampionshipSimulator.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import type { ChampionshipHub } from '../types'
|
||||||
|
import { teamColor } from '../utils'
|
||||||
|
import {
|
||||||
|
assignPosition,
|
||||||
|
defaultRound,
|
||||||
|
defaultScenario,
|
||||||
|
normalizeScenario,
|
||||||
|
pointsForPosition,
|
||||||
|
projectStandings,
|
||||||
|
} from '../lib/simulator'
|
||||||
|
import type { Scenario } from '../lib/simulator'
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = 'box-box.champ.sim'
|
||||||
|
|
||||||
|
function storageKey(season: number): string {
|
||||||
|
return `${STORAGE_PREFIX}.${season}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadScenario(hub: ChampionshipHub): Scenario {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(storageKey(hub.season))
|
||||||
|
if (!raw) return defaultScenario(hub.drivers, hub.rounds_left)
|
||||||
|
return normalizeScenario(JSON.parse(raw), hub.drivers, hub.rounds_left)
|
||||||
|
} catch {
|
||||||
|
return defaultScenario(hub.drivers, hub.rounds_left)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveScenario(season: number, scenario: Scenario) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(storageKey(season), JSON.stringify(scenario))
|
||||||
|
} catch {
|
||||||
|
// storage unavailable (private mode, quota) — simulator still works in memory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Label for the i-th remaining round (0-based), e.g. "R7" or "Round 7". */
|
||||||
|
function roundLabel(hub: ChampionshipHub, index: number): string {
|
||||||
|
return hub.round_labels[hub.round + index] ?? `Round ${hub.round + index + 1}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPts(n: number): string {
|
||||||
|
return Number.isInteger(n) ? String(n) : n.toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChampionshipSimulator({ hub }: { hub: ChampionshipHub }) {
|
||||||
|
const [scenario, setScenario] = useState<Scenario>(() => loadScenario(hub))
|
||||||
|
const [selected, setSelected] = useState(0)
|
||||||
|
|
||||||
|
// Reload when the season changes (new hub, new storage key).
|
||||||
|
useEffect(() => {
|
||||||
|
setScenario(loadScenario(hub))
|
||||||
|
setSelected(0)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hub.season, hub.rounds_left])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scenario.length > 0) saveScenario(hub.season, scenario)
|
||||||
|
}, [hub.season, scenario])
|
||||||
|
|
||||||
|
const projected = useMemo(
|
||||||
|
() => projectStandings(hub.drivers, scenario, hub.rounds_left),
|
||||||
|
[hub.drivers, scenario, hub.rounds_left],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (hub.rounds_left <= 0 || hub.drivers.length === 0 || scenario.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="champ-chart-empty" data-testid="champ-view-simulator">
|
||||||
|
Season complete — nothing left to simulate.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundIdx = Math.min(selected, scenario.length - 1)
|
||||||
|
const round = scenario[roundIdx]
|
||||||
|
|
||||||
|
const setRound = (nextRound: (number | null)[]) => {
|
||||||
|
setScenario((prev) => prev.map((r, i) => (i === roundIdx ? nextRound : r)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const driverByNumber = new Map(hub.drivers.map((d) => [d.driver_number, d]))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="champ-sim" data-testid="champ-view-simulator">
|
||||||
|
<div className="champ-sim-head">
|
||||||
|
<span className="champ-chart-title mono">What-if simulator — remaining rounds</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="champ-sim-btn"
|
||||||
|
onClick={() => setScenario(defaultScenario(hub.drivers, hub.rounds_left))}
|
||||||
|
data-testid="sim-reset-all"
|
||||||
|
>
|
||||||
|
Reset all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="champ-sim-rounds" role="tablist" aria-label="Remaining rounds">
|
||||||
|
{scenario.map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
className={`champ-sim-round${i === roundIdx ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setSelected(i)}
|
||||||
|
data-testid={`sim-round-${i}`}
|
||||||
|
>
|
||||||
|
{roundLabel(hub, i)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="champ-sim-grid">
|
||||||
|
<div className="champ-sim-editor" data-testid="sim-editor">
|
||||||
|
<div className="champ-sim-editor-head">
|
||||||
|
<span className="champ-sim-editor-title mono">{roundLabel(hub, roundIdx)} finishing order</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="champ-sim-btn"
|
||||||
|
onClick={() => setRound(defaultRound(hub.drivers))}
|
||||||
|
data-testid="sim-reset-round"
|
||||||
|
>
|
||||||
|
Reset round
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{round.map((driverNumber, p) => {
|
||||||
|
const driver = driverNumber != null ? driverByNumber.get(driverNumber) : undefined
|
||||||
|
return (
|
||||||
|
<div className="champ-sim-slot" key={p}>
|
||||||
|
<span className="champ-sim-slot-pos mono">P{p + 1}</span>
|
||||||
|
<span
|
||||||
|
className="champ-sim-slot-bar"
|
||||||
|
style={{ background: driver ? teamColor(driver.team_colour) : 'var(--border)' }}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="champ-sim-select mono"
|
||||||
|
value={driverNumber ?? ''}
|
||||||
|
aria-label={`P${p + 1} driver`}
|
||||||
|
data-testid={`sim-pos-${p + 1}`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value
|
||||||
|
setRound(assignPosition(round, p, value === '' ? null : Number(value)))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{hub.drivers.map((d) => (
|
||||||
|
<option key={d.driver_number} value={d.driver_number}>
|
||||||
|
{d.name_acronym} · {d.full_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="champ-sim-slot-pts mono">+{pointsForPosition(p + 1)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="champ-sim-table-wrap">
|
||||||
|
<div className="champ-sim-editor-head">
|
||||||
|
<span className="champ-sim-editor-title mono">Projected standings</span>
|
||||||
|
</div>
|
||||||
|
<div className="champ-scroll">
|
||||||
|
<table className="champ-table champ-sim-table" data-testid="sim-projected">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="l">Pos</th>
|
||||||
|
<th className="c">Δ</th>
|
||||||
|
<th className="l">Driver</th>
|
||||||
|
<th className="r">Now</th>
|
||||||
|
<th className="r">+Sim</th>
|
||||||
|
<th className="r">Proj</th>
|
||||||
|
<th className="r">Title</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{projected.map((row) => {
|
||||||
|
const d = row.driver
|
||||||
|
const moved = row.delta !== 0
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={d.driver_number}
|
||||||
|
className={moved ? 'champ-sim-row-moved' : undefined}
|
||||||
|
data-testid={`sim-row-${d.driver_number}`}
|
||||||
|
>
|
||||||
|
<td className="mono" style={{ fontWeight: 700 }}>
|
||||||
|
P{row.projectedPosition}
|
||||||
|
</td>
|
||||||
|
<td className="c mono">
|
||||||
|
{row.delta > 0 && (
|
||||||
|
<span className="champ-sim-delta up">▲{row.delta}</span>
|
||||||
|
)}
|
||||||
|
{row.delta < 0 && (
|
||||||
|
<span className="champ-sim-delta down">▼{-row.delta}</span>
|
||||||
|
)}
|
||||||
|
{row.delta === 0 && <span className="champ-td-dim">—</span>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="champ-drv">
|
||||||
|
<span
|
||||||
|
className="champ-drv-bar"
|
||||||
|
style={{ background: teamColor(d.team_colour) }}
|
||||||
|
/>
|
||||||
|
<span className="champ-drv-code mono">{d.name_acronym}</span>
|
||||||
|
<span className="champ-drv-name">{d.full_name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="r mono champ-td-muted">{fmtPts(row.currentPoints)}</td>
|
||||||
|
<td className="r mono champ-td-dim">+{fmtPts(row.simPoints)}</td>
|
||||||
|
<td className="r mono champ-td-pts">{fmtPts(row.projectedPoints)}</td>
|
||||||
|
<td className="r">
|
||||||
|
<span
|
||||||
|
className="champ-alive mono"
|
||||||
|
style={{ color: row.titleAlive ? 'var(--green)' : 'var(--text-3)' }}
|
||||||
|
>
|
||||||
|
{row.titleAlive ? 'ALIVE' : 'OUT'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="champ-sim-caption">
|
||||||
|
Simplified model: every remaining round is scored as a standard Grand Prix
|
||||||
|
(25-18-15-12-10-8-6-4-2-1, no fastest-lap point). Sprint weekends are ignored. Title status
|
||||||
|
uses the max-points-remaining bound against the leader's projected total.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
frontend/src/components/CliCommands.tsx
Normal file
67
frontend/src/components/CliCommands.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Copy, Check } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Command {
|
||||||
|
comment?: string
|
||||||
|
cmd: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
commands: Command[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CliCommands({ commands }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="cli-block" data-testid="cli-commands">
|
||||||
|
{commands.map(({ comment, cmd }, i) => (
|
||||||
|
<div key={cmd} className="cli-entry">
|
||||||
|
{comment && <div className="cli-comment">{comment}</div>}
|
||||||
|
<CliCommandLine cmd={cmd} />
|
||||||
|
{i < commands.length - 1 && <div className="cli-spacer" />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CliCommandLine({ cmd }: { cmd: string }) {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
async function handleCopy() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(cmd)
|
||||||
|
setCopied(true)
|
||||||
|
window.setTimeout(() => setCopied(false), 1500)
|
||||||
|
} catch {
|
||||||
|
// clipboard may be unavailable in tests
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cli-cmd-row">
|
||||||
|
<code className="cli-cmd">{cmd}</code>
|
||||||
|
<button type="button" className="cli-copy-btn interactive" onClick={handleCopy} aria-label={`Copy ${cmd}`} style={{ display: 'flex', alignItems: 'center', gap: '4px', padding: '4px 8px', background: copied ? 'var(--green)' : 'rgba(255,255,255,0.05)', color: copied ? '#000' : 'var(--text)', border: 'none', borderRadius: '4px', cursor: 'pointer', transition: 'all 0.2s' }}>
|
||||||
|
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||||
|
<span>{copied ? 'Copied' : 'Copy'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ingestYearCommands(year: number): Command[] {
|
||||||
|
return [
|
||||||
|
{ comment: '# Discover season meetings and sessions', cmd: `box-box --ingest-year ${year}` },
|
||||||
|
{ comment: '# Preview season discovery only', cmd: `box-box --ingest-year ${year} --dry-run` },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ingestMeetingCommands(meetingKey: number): Command[] {
|
||||||
|
return [
|
||||||
|
{ comment: '# Full weekend ingest (all sessions)', cmd: `box-box --ingest-meeting ${meetingKey}` },
|
||||||
|
{ comment: '# Preview without downloading', cmd: `box-box --ingest-meeting ${meetingKey} --dry-run` },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ingestSessionCommands(sessionKey: number): Command[] {
|
||||||
|
return [{ comment: '# Race Hub datasets for one session', cmd: `box-box --ingest-session ${sessionKey}` }]
|
||||||
|
}
|
||||||
415
frontend/src/components/CommandCenterHero.tsx
Normal file
415
frontend/src/components/CommandCenterHero.tsx
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { Activity, ArrowRight, Play } from 'lucide-react'
|
||||||
|
import { sessionTypeAbbrev } from '../lib/coverage'
|
||||||
|
import { countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
|
||||||
|
import { classifySessionStatus, type HeroStateKind } from '../lib/hero'
|
||||||
|
import { sortLiveTimingRows, trackStatusInfo } from '../lib/live'
|
||||||
|
import {
|
||||||
|
formatCountdown,
|
||||||
|
formatSessionScheduleTime,
|
||||||
|
meetingStartTime,
|
||||||
|
sessionStartTime,
|
||||||
|
type FocusMeetingKind,
|
||||||
|
} from '../lib/schedule'
|
||||||
|
import type { EnrichedResult, LiveStreamData, Meeting, Session, WeekendSession } from '../types'
|
||||||
|
import '../styles/hero.css'
|
||||||
|
|
||||||
|
export interface CommandCenterHeroProps {
|
||||||
|
state: HeroStateKind
|
||||||
|
now: Date
|
||||||
|
accent: string
|
||||||
|
liveActive: boolean
|
||||||
|
liveData: LiveStreamData | null | undefined
|
||||||
|
focusMeeting: Meeting
|
||||||
|
focusKind: FocusMeetingKind
|
||||||
|
sessions: WeekendSession[]
|
||||||
|
currentSession: Session | null
|
||||||
|
nextSession: Session | null
|
||||||
|
analysisSessionKey?: number
|
||||||
|
analysisSessionName?: string
|
||||||
|
lastRaceName: string
|
||||||
|
lastRacePodium: EnrichedResult[]
|
||||||
|
lastRaceSessionKey?: number
|
||||||
|
nextMeeting: Meeting | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandCenterHero({
|
||||||
|
state,
|
||||||
|
now,
|
||||||
|
accent,
|
||||||
|
liveActive,
|
||||||
|
liveData,
|
||||||
|
focusMeeting,
|
||||||
|
focusKind,
|
||||||
|
sessions,
|
||||||
|
currentSession,
|
||||||
|
nextSession,
|
||||||
|
analysisSessionKey,
|
||||||
|
analysisSessionName,
|
||||||
|
lastRaceName,
|
||||||
|
lastRacePodium,
|
||||||
|
lastRaceSessionKey,
|
||||||
|
nextMeeting,
|
||||||
|
}: CommandCenterHeroProps) {
|
||||||
|
const accentStyle = {
|
||||||
|
'--gp-accent': accent,
|
||||||
|
'--hero-accent': accent,
|
||||||
|
} as React.CSSProperties
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hero-panel cc-hero" data-testid="cc-focus">
|
||||||
|
<section className="hero-card ui-card glass-panel" style={accentStyle}>
|
||||||
|
<div
|
||||||
|
className={`hero-accent${state === 'live' ? ' hero-accent--live' : ''}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="hero-body">
|
||||||
|
<div className="hero-inner">
|
||||||
|
{state === 'live' && (
|
||||||
|
<LiveHero
|
||||||
|
liveActive={liveActive}
|
||||||
|
liveData={liveData}
|
||||||
|
currentSession={currentSession}
|
||||||
|
focusMeeting={focusMeeting}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{state === 'upcoming' && (
|
||||||
|
<UpcomingHero
|
||||||
|
now={now}
|
||||||
|
focusMeeting={focusMeeting}
|
||||||
|
focusKind={focusKind}
|
||||||
|
sessions={sessions}
|
||||||
|
currentSession={currentSession}
|
||||||
|
nextSession={nextSession}
|
||||||
|
liveActive={liveActive}
|
||||||
|
analysisSessionKey={analysisSessionKey}
|
||||||
|
analysisSessionName={analysisSessionName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{state === 'between' && (
|
||||||
|
<BetweenHero
|
||||||
|
now={now}
|
||||||
|
lastRaceName={lastRaceName}
|
||||||
|
lastRacePodium={lastRacePodium}
|
||||||
|
lastRaceSessionKey={lastRaceSessionKey}
|
||||||
|
nextMeeting={nextMeeting}
|
||||||
|
analysisSessionKey={analysisSessionKey}
|
||||||
|
analysisSessionName={analysisSessionName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LiveHero({
|
||||||
|
liveActive,
|
||||||
|
liveData,
|
||||||
|
currentSession,
|
||||||
|
focusMeeting,
|
||||||
|
}: {
|
||||||
|
liveActive: boolean
|
||||||
|
liveData: LiveStreamData | null | undefined
|
||||||
|
currentSession: Session | null
|
||||||
|
focusMeeting: Meeting
|
||||||
|
}) {
|
||||||
|
const sessionName =
|
||||||
|
liveData?.Session?.SessionName ?? currentSession?.session_name ?? 'Live session'
|
||||||
|
const trackStatus = trackStatusInfo(liveData?.TrackStatus)
|
||||||
|
const topThree = sortLiveTimingRows(liveData).filter((r) => r.Position > 0).slice(0, 3)
|
||||||
|
const decal = countryDecal(focusMeeting)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="hero-row">
|
||||||
|
<span className="hero-decal mono">{decal}</span>
|
||||||
|
<div className="hero-identity">
|
||||||
|
<div className="hero-eyebrow hero-eyebrow--live mono">● Live now</div>
|
||||||
|
<h1 className="hero-title">{sessionName}</h1>
|
||||||
|
<div className="hero-sub mono">
|
||||||
|
{[focusMeeting.meeting_name, focusMeeting.circuit_short_name].filter(Boolean).join(' · ')}
|
||||||
|
</div>
|
||||||
|
<div className={`hero-track-status hero-track-status--${trackStatus.key}`}>
|
||||||
|
{trackStatus.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{topThree.length > 0 && (
|
||||||
|
<div className="hero-timing" data-testid="hero-live-timing">
|
||||||
|
{topThree.map((row) => (
|
||||||
|
<div key={row.RacingNumber} className="hero-timing-row">
|
||||||
|
<span className={`hero-timing-pos hero-timing-pos--p${row.Position}`}>
|
||||||
|
P{row.Position}
|
||||||
|
</span>
|
||||||
|
<span className="hero-timing-driver">
|
||||||
|
<span
|
||||||
|
className="hero-timing-bar"
|
||||||
|
style={{ background: `#${row.Info?.TeamColour ?? '9aa0a6'}` }}
|
||||||
|
/>
|
||||||
|
{row.Info?.Tla ?? row.RacingNumber}
|
||||||
|
</span>
|
||||||
|
<span className="hero-timing-gap">
|
||||||
|
{row.Position === 1 ? 'LEAD' : row.Driver.Interval || row.Driver.GapToLeader || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="hero-countdown">
|
||||||
|
<div className="hero-cd-label mono">{liveActive ? 'SignalR' : 'On track'}</div>
|
||||||
|
<div className="hero-cd-value hero-cd-value--live mono">LIVE</div>
|
||||||
|
<div className="hero-cd-sub mono">{sessionName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hero-actions">
|
||||||
|
<Link to="/live" className="hero-cta hero-cta--primary" data-testid="hero-live-link">
|
||||||
|
Open Live Timing <ArrowRight size={16} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UpcomingHero({
|
||||||
|
now,
|
||||||
|
focusMeeting,
|
||||||
|
focusKind,
|
||||||
|
sessions,
|
||||||
|
currentSession,
|
||||||
|
nextSession,
|
||||||
|
liveActive,
|
||||||
|
analysisSessionKey,
|
||||||
|
analysisSessionName,
|
||||||
|
}: {
|
||||||
|
now: Date
|
||||||
|
focusMeeting: Meeting
|
||||||
|
focusKind: FocusMeetingKind
|
||||||
|
sessions: WeekendSession[]
|
||||||
|
currentSession: Session | null
|
||||||
|
nextSession: Session | null
|
||||||
|
liveActive: boolean
|
||||||
|
analysisSessionKey?: number
|
||||||
|
analysisSessionName?: string
|
||||||
|
}) {
|
||||||
|
const decal = countryDecal(focusMeeting)
|
||||||
|
const countdownTarget =
|
||||||
|
nextSession && sessionStartTime(nextSession)
|
||||||
|
? sessionStartTime(nextSession)!
|
||||||
|
: meetingStartTime(focusMeeting)
|
||||||
|
|
||||||
|
const kindLabel =
|
||||||
|
focusKind === 'current'
|
||||||
|
? 'Current weekend'
|
||||||
|
: focusKind === 'next'
|
||||||
|
? 'Next weekend'
|
||||||
|
: 'Weekend'
|
||||||
|
|
||||||
|
const sortedSessions = [...sessions].sort((a, b) => {
|
||||||
|
const left = sessionStartTime(a.session)?.getTime() ?? 0
|
||||||
|
const right = sessionStartTime(b.session)?.getTime() ?? 0
|
||||||
|
return left - right
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="hero-row">
|
||||||
|
<span className="hero-decal mono">{decal}</span>
|
||||||
|
<div className="hero-identity">
|
||||||
|
<div className="hero-eyebrow mono">{kindLabel}</div>
|
||||||
|
<h1 className="hero-title">{focusMeeting.meeting_name}</h1>
|
||||||
|
<div className="hero-sub mono">
|
||||||
|
{[focusMeeting.location, focusMeeting.circuit_short_name].filter(Boolean).join(' · ')}
|
||||||
|
</div>
|
||||||
|
<div className="hero-sub mono">{formatGpDateRange(focusMeeting)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="hero-countdown" data-testid="hero-countdown">
|
||||||
|
{nextSession ? (
|
||||||
|
<>
|
||||||
|
<div className="hero-cd-label mono">Next · {nextSession.session_name}</div>
|
||||||
|
{countdownTarget && (
|
||||||
|
<div className="hero-cd-value mono">{formatCountdown(countdownTarget, now)}</div>
|
||||||
|
)}
|
||||||
|
<div className="hero-cd-sub mono">{formatSessionScheduleTime(nextSession.date_start)}</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="hero-cd-label mono">Status</div>
|
||||||
|
<div className="hero-cd-value mono">Complete</div>
|
||||||
|
<div className="hero-cd-sub mono">Weekend finished</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sortedSessions.length > 0 && (
|
||||||
|
<div className="hero-schedule-strip" data-testid="hero-schedule-strip" role="list">
|
||||||
|
{sortedSessions.map(({ session }) => {
|
||||||
|
const status = classifySessionStatus(session, now)
|
||||||
|
const isNext = nextSession?.session_key === session.session_key
|
||||||
|
const isCurrent = currentSession?.session_key === session.session_key
|
||||||
|
const isLive = isCurrent && liveActive
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={session.session_key}
|
||||||
|
to="/race-hub"
|
||||||
|
search={{ session_key: session.session_key }}
|
||||||
|
className={`hero-schedule-card${isNext ? ' is-next' : ''}${
|
||||||
|
status === 'done' ? ' is-done' : ''
|
||||||
|
}${isLive ? ' is-live' : ''}`}
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
<div className="hero-schedule-abbrev mono">
|
||||||
|
{sessionTypeAbbrev(session.session_type, session.session_name)}
|
||||||
|
</div>
|
||||||
|
<div className="hero-schedule-name">{session.session_name}</div>
|
||||||
|
<div className="hero-schedule-time mono">
|
||||||
|
{formatSessionScheduleTime(session.date_start)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`hero-schedule-marker mono${
|
||||||
|
isLive ? ' hero-schedule-marker--live' : isNext ? ' hero-schedule-marker--next' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isCurrent ? 'On track' : status === 'done' ? 'Done' : isNext ? 'Next' : 'Upcoming'}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="hero-actions">
|
||||||
|
<Link
|
||||||
|
to="/live"
|
||||||
|
className="hero-secondary-link"
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}
|
||||||
|
>
|
||||||
|
<Play size={14} /> Watch Live
|
||||||
|
</Link>
|
||||||
|
{analysisSessionKey != null && (
|
||||||
|
<Link
|
||||||
|
to="/race-hub"
|
||||||
|
search={{ session_key: analysisSessionKey }}
|
||||||
|
className="hero-secondary-link"
|
||||||
|
data-testid="hero-analysis-link"
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}
|
||||||
|
>
|
||||||
|
<Activity size={14} /> Open Analysis
|
||||||
|
{analysisSessionName ? ` · ${analysisSessionName}` : ''}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BetweenHero({
|
||||||
|
now,
|
||||||
|
lastRaceName,
|
||||||
|
lastRacePodium,
|
||||||
|
lastRaceSessionKey,
|
||||||
|
nextMeeting,
|
||||||
|
analysisSessionKey,
|
||||||
|
analysisSessionName,
|
||||||
|
}: {
|
||||||
|
now: Date
|
||||||
|
lastRaceName: string
|
||||||
|
lastRacePodium: EnrichedResult[]
|
||||||
|
lastRaceSessionKey?: number
|
||||||
|
nextMeeting: Meeting | null
|
||||||
|
analysisSessionKey?: number
|
||||||
|
analysisSessionName?: string
|
||||||
|
}) {
|
||||||
|
const nextStart = nextMeeting ? meetingStartTime(nextMeeting) : null
|
||||||
|
const podium = lastRacePodium
|
||||||
|
.filter((r) => r.position >= 1 && r.position <= 3)
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="hero-row">
|
||||||
|
<div className="hero-identity">
|
||||||
|
<div className="hero-eyebrow mono">Between race weekends</div>
|
||||||
|
{lastRaceName && (
|
||||||
|
<h1 className="hero-title" data-testid="hero-last-race">
|
||||||
|
After {lastRaceName}
|
||||||
|
</h1>
|
||||||
|
)}
|
||||||
|
{!lastRaceName && <h1 className="hero-title">Season pause</h1>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{podium.length > 0 && (
|
||||||
|
<div className="hero-podium" data-testid="hero-podium">
|
||||||
|
{podium.map((r) => (
|
||||||
|
<div key={r.driver_number} className="hero-podium-row">
|
||||||
|
<span className={`hero-podium-pos hero-podium-pos--p${r.position} mono`}>
|
||||||
|
P{r.position}
|
||||||
|
</span>
|
||||||
|
<span className="hero-podium-driver">
|
||||||
|
<span
|
||||||
|
className="hero-timing-bar"
|
||||||
|
style={{ background: `#${r.team_colour}` }}
|
||||||
|
/>
|
||||||
|
{r.name_acronym}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nextMeeting && (
|
||||||
|
<div className="hero-countdown" data-testid="hero-next-gp-countdown">
|
||||||
|
<div className="hero-between-next">
|
||||||
|
{countryFlag(nextMeeting) && (
|
||||||
|
<span className="hero-flag" aria-hidden="true">
|
||||||
|
{countryFlag(nextMeeting)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="hero-cd-label mono">Next GP</div>
|
||||||
|
<div className="hero-title" style={{ fontSize: '20px' }}>
|
||||||
|
{nextMeeting.meeting_name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{nextStart && (
|
||||||
|
<>
|
||||||
|
<div className="hero-cd-value mono" style={{ marginTop: 'var(--s3)' }}>
|
||||||
|
{formatCountdown(nextStart, now)}
|
||||||
|
</div>
|
||||||
|
<div className="hero-cd-sub mono">{formatGpDateRange(nextMeeting)}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hero-actions">
|
||||||
|
{lastRaceSessionKey != null && (
|
||||||
|
<Link
|
||||||
|
to="/race-hub"
|
||||||
|
search={{ session_key: lastRaceSessionKey }}
|
||||||
|
className="hero-secondary-link"
|
||||||
|
data-testid="hero-last-race-link"
|
||||||
|
>
|
||||||
|
View {lastRaceName || 'last race'} results →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{analysisSessionKey != null && analysisSessionKey !== lastRaceSessionKey && (
|
||||||
|
<Link
|
||||||
|
to="/race-hub"
|
||||||
|
search={{ session_key: analysisSessionKey }}
|
||||||
|
className="hero-secondary-link"
|
||||||
|
data-testid="hero-analysis-link"
|
||||||
|
>
|
||||||
|
Open Analysis{analysisSessionName ? ` · ${analysisSessionName}` : ''}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Link to="/live" className="hero-secondary-link">
|
||||||
|
Live Timing →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
251
frontend/src/components/CompareView.tsx
Normal file
251
frontend/src/components/CompareView.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { fetchLapsComparison, fetchTelemetry } from '../api'
|
||||||
|
import {
|
||||||
|
buildBestLapTraceSeries,
|
||||||
|
compareDriverOptions,
|
||||||
|
comparisonToDeltaSeries,
|
||||||
|
defaultCompareDriverNumbers,
|
||||||
|
formatPitLapsCaption,
|
||||||
|
} from '../lib/compare'
|
||||||
|
import type { Driver, EnrichedResult } from '../types'
|
||||||
|
import { teamColor } from '../utils'
|
||||||
|
import { DriverCell } from './DriverCell'
|
||||||
|
import { TelemetryTraceChart } from './charts/TelemetryTraceChart'
|
||||||
|
import { DeltaTimeGraph } from './charts/DeltaTimeGraph'
|
||||||
|
import '../styles/compare-view.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sessionKey: number
|
||||||
|
results: EnrichedResult[]
|
||||||
|
drivers: Driver[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionState({
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
empty,
|
||||||
|
emptyMessage,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
loading: boolean
|
||||||
|
error: Error | null
|
||||||
|
empty: boolean
|
||||||
|
emptyMessage: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
if (loading) {
|
||||||
|
return <div className="loading-state">loading…</div>
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return <div className="error-box">{error.message}</div>
|
||||||
|
}
|
||||||
|
if (empty) {
|
||||||
|
return <div className="missing-notice">{emptyMessage}</div>
|
||||||
|
}
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompareView({ sessionKey, results, drivers }: Props) {
|
||||||
|
const driverOptions = useMemo(
|
||||||
|
() => compareDriverOptions(drivers, results),
|
||||||
|
[drivers, results],
|
||||||
|
)
|
||||||
|
|
||||||
|
const initialPair = useMemo(
|
||||||
|
() => defaultCompareDriverNumbers(results, drivers),
|
||||||
|
[results, drivers],
|
||||||
|
)
|
||||||
|
|
||||||
|
const previousSessionKey = useRef(sessionKey)
|
||||||
|
const [driverA, setDriverA] = useState<number | null>(initialPair?.[0] ?? null)
|
||||||
|
const [driverB, setDriverB] = useState<number | null>(initialPair?.[1] ?? null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (previousSessionKey.current !== sessionKey) {
|
||||||
|
previousSessionKey.current = sessionKey
|
||||||
|
setDriverA(initialPair?.[0] ?? null)
|
||||||
|
setDriverB(initialPair?.[1] ?? null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (driverA != null && driverB != null) return
|
||||||
|
if (!initialPair) return
|
||||||
|
setDriverA(initialPair[0])
|
||||||
|
setDriverB(initialPair[1])
|
||||||
|
}, [sessionKey, initialPair, driverA, driverB])
|
||||||
|
|
||||||
|
const pair = useMemo((): [number, number] | null => {
|
||||||
|
if (driverA == null || driverB == null || driverA === driverB) return null
|
||||||
|
return [driverA, driverB]
|
||||||
|
}, [driverA, driverB])
|
||||||
|
|
||||||
|
const comparisonQuery = useQuery({
|
||||||
|
queryKey: ['laps-comparison', sessionKey, pair?.[0], pair?.[1]],
|
||||||
|
queryFn: () => fetchLapsComparison(sessionKey, pair!),
|
||||||
|
enabled: pair != null,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const telemetryAQuery = useQuery({
|
||||||
|
queryKey: ['telemetry', sessionKey, pair?.[0]],
|
||||||
|
queryFn: () => fetchTelemetry(sessionKey, pair![0]),
|
||||||
|
enabled: pair != null,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const telemetryBQuery = useQuery({
|
||||||
|
queryKey: ['telemetry', sessionKey, pair?.[1]],
|
||||||
|
queryFn: () => fetchTelemetry(sessionKey, pair![1]),
|
||||||
|
enabled: pair != null,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const comparison = comparisonQuery.data
|
||||||
|
const referenceLabel = useMemo(() => {
|
||||||
|
if (!pair) return undefined
|
||||||
|
const meta = comparison?.drivers.find((d) => d.driver_number === pair[0])
|
||||||
|
const session = drivers.find((d) => d.driver_number === pair[0])
|
||||||
|
return meta?.name_acronym || session?.name_acronym
|
||||||
|
}, [pair, comparison, drivers])
|
||||||
|
|
||||||
|
const deltaSeries = useMemo(() => {
|
||||||
|
if (!comparison || !pair) return []
|
||||||
|
return comparisonToDeltaSeries(comparison, pair, drivers)
|
||||||
|
}, [comparison, pair, drivers])
|
||||||
|
|
||||||
|
const pitCaption = useMemo(() => {
|
||||||
|
if (!comparison || !pair) return null
|
||||||
|
return formatPitLapsCaption(comparison.pit_laps, pair, comparison, drivers)
|
||||||
|
}, [comparison, pair, drivers])
|
||||||
|
|
||||||
|
const traceSeries = useMemo(() => {
|
||||||
|
if (!pair || !comparison) return []
|
||||||
|
const out = []
|
||||||
|
|
||||||
|
for (const dn of pair) {
|
||||||
|
const comp = comparison.drivers.find((d) => d.driver_number === dn)
|
||||||
|
const session = drivers.find((d) => d.driver_number === dn)
|
||||||
|
const label = comp?.name_acronym || session?.name_acronym || `#${dn}`
|
||||||
|
const color = teamColor(comp?.team_colour || session?.team_colour)
|
||||||
|
|
||||||
|
const carData =
|
||||||
|
dn === pair[0] ? (telemetryAQuery.data ?? []) : (telemetryBQuery.data ?? [])
|
||||||
|
const series = buildBestLapTraceSeries(carData, comp?.laps ?? [], label, color)
|
||||||
|
if (series) out.push(series)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}, [pair, comparison, drivers, telemetryAQuery.data, telemetryBQuery.data])
|
||||||
|
|
||||||
|
const telemetryLoading = telemetryAQuery.isLoading || telemetryBQuery.isLoading
|
||||||
|
const telemetryError = telemetryAQuery.error ?? telemetryBQuery.error
|
||||||
|
|
||||||
|
if (driverOptions.length < 2) {
|
||||||
|
return (
|
||||||
|
<div className="missing-notice" data-testid="compare-view-empty">
|
||||||
|
Need at least two drivers in this session to compare.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const driverAInfo = driverOptions.find((d) => d.driver_number === driverA)
|
||||||
|
const driverBInfo = driverOptions.find((d) => d.driver_number === driverB)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="compare-view" data-testid="compare-view">
|
||||||
|
<div className="compare-pickers">
|
||||||
|
<div className="compare-picker">
|
||||||
|
<span className="compare-picker-label">Reference</span>
|
||||||
|
<select
|
||||||
|
className="compare-picker-select"
|
||||||
|
value={driverA ?? ''}
|
||||||
|
onChange={(e) => setDriverA(Number(e.target.value))}
|
||||||
|
data-testid="compare-picker-a"
|
||||||
|
>
|
||||||
|
{driverOptions.map((d) => (
|
||||||
|
<option key={d.driver_number} value={d.driver_number}>
|
||||||
|
{d.name_acronym} · #{d.driver_number}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{driverAInfo && (
|
||||||
|
<DriverCell
|
||||||
|
acronym={driverAInfo.name_acronym}
|
||||||
|
number={driverAInfo.driver_number}
|
||||||
|
colour={driverAInfo.team_colour}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="compare-picker">
|
||||||
|
<span className="compare-picker-label">Challenger</span>
|
||||||
|
<select
|
||||||
|
className="compare-picker-select"
|
||||||
|
value={driverB ?? ''}
|
||||||
|
onChange={(e) => setDriverB(Number(e.target.value))}
|
||||||
|
data-testid="compare-picker-b"
|
||||||
|
>
|
||||||
|
{driverOptions.map((d) => (
|
||||||
|
<option key={d.driver_number} value={d.driver_number}>
|
||||||
|
{d.name_acronym} · #{d.driver_number}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{driverBInfo && (
|
||||||
|
<DriverCell
|
||||||
|
acronym={driverBInfo.name_acronym}
|
||||||
|
number={driverBInfo.driver_number}
|
||||||
|
colour={driverBInfo.team_colour}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{driverA === driverB && (
|
||||||
|
<div className="analysis-notice">
|
||||||
|
<strong>Pick two different drivers</strong> to run a comparison.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="compare-section" data-testid="compare-telemetry-section">
|
||||||
|
<div>
|
||||||
|
<div className="compare-section-title">Best lap telemetry</div>
|
||||||
|
<div className="compare-section-meta">
|
||||||
|
Speed, throttle, and brake traces for each driver's fastest lap
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SectionState
|
||||||
|
loading={telemetryLoading}
|
||||||
|
error={telemetryError instanceof Error ? telemetryError : null}
|
||||||
|
empty={!telemetryLoading && !telemetryError && traceSeries.length === 0}
|
||||||
|
emptyMessage="No telemetry samples for the best laps. Car data may not be available for this session."
|
||||||
|
>
|
||||||
|
<TelemetryTraceChart series={traceSeries} />
|
||||||
|
</SectionState>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="compare-section" data-testid="compare-pace-section">
|
||||||
|
<div>
|
||||||
|
<div className="compare-section-title">Race pace</div>
|
||||||
|
<div className="compare-section-meta">
|
||||||
|
Cumulative lap-time delta vs {referenceLabel ?? 'reference'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SectionState
|
||||||
|
loading={comparisonQuery.isLoading}
|
||||||
|
error={comparisonQuery.error instanceof Error ? comparisonQuery.error : null}
|
||||||
|
empty={!comparisonQuery.isLoading && !comparisonQuery.error && deltaSeries.length < 2}
|
||||||
|
emptyMessage="No lap comparison data for the selected drivers."
|
||||||
|
>
|
||||||
|
<DeltaTimeGraph series={deltaSeries} referenceLabel={referenceLabel} />
|
||||||
|
{pitCaption && (
|
||||||
|
<p className="compare-pit-caption" data-testid="compare-pit-caption">
|
||||||
|
{pitCaption}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SectionState>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
88
frontend/src/components/DatasetStatusView.tsx
Normal file
88
frontend/src/components/DatasetStatusView.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { RACE_HUB_DATASETS } from '../lib/coverage'
|
||||||
|
import type { DatasetInfo } from '../types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
datasets: Record<string, DatasetInfo>
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATASET_LABELS: Record<string, string> = {
|
||||||
|
meeting: 'Meeting',
|
||||||
|
session: 'Session',
|
||||||
|
drivers: 'Drivers',
|
||||||
|
results: 'Results',
|
||||||
|
starting_grid: 'Starting Grid',
|
||||||
|
stints: 'Stints',
|
||||||
|
pit_stops: 'Pit Stops',
|
||||||
|
positions: 'Positions',
|
||||||
|
race_control: 'Race Control',
|
||||||
|
weather: 'Weather',
|
||||||
|
laps: 'Laps',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DatasetStatusView({ datasets }: Props) {
|
||||||
|
const entries = RACE_HUB_DATASETS.map((key) => ({
|
||||||
|
key,
|
||||||
|
label: DATASET_LABELS[key] ?? key,
|
||||||
|
info: datasets[key] as DatasetInfo | undefined,
|
||||||
|
}))
|
||||||
|
const available = entries.filter((e) => e.info?.status === 'available').length
|
||||||
|
const total = entries.length
|
||||||
|
const missing = total - available
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="rh-data-status">
|
||||||
|
<div className="rh-coverage-meter" aria-hidden="true">
|
||||||
|
<div
|
||||||
|
className="rh-coverage-fill"
|
||||||
|
style={{ width: `${(available / total) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ds-legend">
|
||||||
|
<span className="mono" style={{ color: 'var(--text-2)' }}>
|
||||||
|
{available}/{total} datasets local
|
||||||
|
</span>
|
||||||
|
{missing > 0 && (
|
||||||
|
<span style={{ color: 'var(--text-3)' }}>
|
||||||
|
{missing} dataset{missing === 1 ? '' : 's'} still missing —{' '}
|
||||||
|
<Link to="/admin" className="rh-inline-link">
|
||||||
|
manage ingestion
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="data-table" style={{ maxWidth: 480 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Dataset</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th className="r">Records</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{entries.map(({ key, label, info }) => (
|
||||||
|
<tr key={key}>
|
||||||
|
<td className="mono" style={{ color: 'var(--text-2)' }}>
|
||||||
|
{label}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{info?.status === 'available' ? (
|
||||||
|
<span className="badge badge-local">Local</span>
|
||||||
|
) : info?.status === 'skipped' ? (
|
||||||
|
<span className="badge badge-none">N/A</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-none">Missing</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="r mono" style={{ color: 'var(--text-3)' }}>
|
||||||
|
{info?.count != null ? info.count : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
frontend/src/components/DatasetStrip.tsx
Normal file
35
frontend/src/components/DatasetStrip.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { DatasetInfo } from '../types'
|
||||||
|
|
||||||
|
const DATASET_LABELS: Record<string, string> = {
|
||||||
|
meeting: 'meeting',
|
||||||
|
session: 'session',
|
||||||
|
drivers: 'drivers',
|
||||||
|
results: 'results',
|
||||||
|
starting_grid: 'grid',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
datasets: Record<string, DatasetInfo>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DatasetStrip({ datasets }: Props) {
|
||||||
|
const keys = Object.keys(DATASET_LABELS)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dataset-strip">
|
||||||
|
{keys.map((key) => {
|
||||||
|
const info = datasets[key]
|
||||||
|
const available = info?.status === 'available' || info?.status === 'skipped'
|
||||||
|
return (
|
||||||
|
<div key={key} className="ds-item" title={info ? `${info.status === 'skipped' ? 'N/A' : info.source} · ${info.count ?? 0} rows` : 'missing'}>
|
||||||
|
<div className={`ds-dot ${available ? 'ds-dot-local' : 'ds-dot-missing'}`} />
|
||||||
|
<span>{DATASET_LABELS[key]}</span>
|
||||||
|
{available && info.count != null && info.count > 0 && (
|
||||||
|
<span style={{ opacity: 0.5 }}>·{info.count}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
frontend/src/components/DriverCell.tsx
Normal file
17
frontend/src/components/DriverCell.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { teamColor } from '../utils'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
acronym: string
|
||||||
|
number: number
|
||||||
|
colour: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DriverCell({ acronym, number, colour }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="drv-cell">
|
||||||
|
<div className="drv-bar" style={{ background: teamColor(colour) }} />
|
||||||
|
<span className="drv-code">{acronym}</span>
|
||||||
|
<span className="drv-num">{number}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
119
frontend/src/components/LapsView.tsx
Normal file
119
frontend/src/components/LapsView.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import type { Driver, Lap } from '../types'
|
||||||
|
import { formatGap, formatLapTime, teamColor } from '../utils'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
laps: Lap[]
|
||||||
|
drivers?: Driver[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DriverLapSummary {
|
||||||
|
driver_number: number
|
||||||
|
total: number
|
||||||
|
best: Lap | null
|
||||||
|
lastLap: number
|
||||||
|
pitOuts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LapsView({ laps, drivers = [] }: Props) {
|
||||||
|
if (laps.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="missing-notice">
|
||||||
|
Laps not ingested. Run <code>box-box --ingest-session <key></code> to
|
||||||
|
load this dataset.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const byDriver = new Map<number, DriverLapSummary>()
|
||||||
|
for (const lap of laps) {
|
||||||
|
const summary =
|
||||||
|
byDriver.get(lap.driver_number) ??
|
||||||
|
{
|
||||||
|
driver_number: lap.driver_number,
|
||||||
|
total: 0,
|
||||||
|
best: null,
|
||||||
|
lastLap: 0,
|
||||||
|
pitOuts: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.total += 1
|
||||||
|
summary.lastLap = Math.max(summary.lastLap, lap.lap_number)
|
||||||
|
if (lap.is_pit_out_lap) summary.pitOuts += 1
|
||||||
|
if (
|
||||||
|
lap.lap_duration != null &&
|
||||||
|
lap.lap_duration > 0 &&
|
||||||
|
(!summary.best ||
|
||||||
|
summary.best.lap_duration == null ||
|
||||||
|
lap.lap_duration < summary.best.lap_duration)
|
||||||
|
) {
|
||||||
|
summary.best = lap
|
||||||
|
}
|
||||||
|
|
||||||
|
byDriver.set(lap.driver_number, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [...byDriver.values()].sort((a, b) => {
|
||||||
|
const aBest = a.best?.lap_duration ?? Number.POSITIVE_INFINITY
|
||||||
|
const bBest = b.best?.lap_duration ?? Number.POSITIVE_INFINITY
|
||||||
|
if (aBest !== bBest) return aBest - bBest
|
||||||
|
return a.driver_number - b.driver_number
|
||||||
|
})
|
||||||
|
|
||||||
|
const driversByNumber = new Map(drivers.map((driver) => [driver.driver_number, driver]))
|
||||||
|
const fastest = rows.find((row) => row.best?.lap_duration != null)?.best
|
||||||
|
const fastestTime = fastest?.lap_duration ?? null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="scroll-x" data-testid="laps-view">
|
||||||
|
<table className="data-table" style={{ minWidth: 540, maxWidth: 700 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Driver</th>
|
||||||
|
<th className="c">Best Lap</th>
|
||||||
|
<th className="r">Best Time</th>
|
||||||
|
<th className="r">Gap</th>
|
||||||
|
<th className="r hide-mobile">Laps</th>
|
||||||
|
<th className="r hide-mobile">Pit Outs</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => {
|
||||||
|
const driver = driversByNumber.get(row.driver_number)
|
||||||
|
const driverName =
|
||||||
|
driver?.full_name || driver?.broadcast_name || driver?.name_acronym || `#${row.driver_number}`
|
||||||
|
const colour = teamColor(driver?.team_colour)
|
||||||
|
const isFastest =
|
||||||
|
fastest &&
|
||||||
|
row.best?.driver_number === fastest.driver_number &&
|
||||||
|
row.best?.lap_number === fastest.lap_number
|
||||||
|
const gap =
|
||||||
|
row.best?.lap_duration != null && fastestTime != null
|
||||||
|
? row.best.lap_duration - fastestTime
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={row.driver_number} className={isFastest ? 'lap-fastest-row' : undefined}>
|
||||||
|
<td style={{ fontWeight: 700 }}>
|
||||||
|
<span className="drv-cell">
|
||||||
|
<span className="drv-bar" style={{ background: colour }} />
|
||||||
|
<span>{driverName}</span>
|
||||||
|
<span className="drv-num">{row.driver_number}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="c mono">
|
||||||
|
{row.best ? row.best.lap_number : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="r">{formatLapTime(row.best?.lap_duration)}</td>
|
||||||
|
<td className="r">{isFastest ? '—' : formatGap(gap)}</td>
|
||||||
|
<td className="r hide-mobile">{row.lastLap || row.total}</td>
|
||||||
|
<td className="r hide-mobile" style={{ color: 'var(--text-3)' }}>
|
||||||
|
{row.pitOuts || '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
268
frontend/src/components/LocalDataNavigator.tsx
Normal file
268
frontend/src/components/LocalDataNavigator.tsx
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
|
||||||
|
import { formatCoverageHint } from '../lib/coverage'
|
||||||
|
import { SourceBadge } from './SourceBadge'
|
||||||
|
import { SessionCoverageDots } from './SessionCoverageDots'
|
||||||
|
import type { Meeting, WeekendSession } from '../types'
|
||||||
|
|
||||||
|
function formatMeetingDates(meeting: Meeting): string {
|
||||||
|
const start = meeting.date_start?.slice(0, 10)
|
||||||
|
const end = meeting.date_end?.slice(0, 10)
|
||||||
|
if (start && end && start !== end) return `${start} – ${end}`
|
||||||
|
return start || end || '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionSourceBadge(source: WeekendSession['source']) {
|
||||||
|
return <SourceBadge source={source} />
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSelectSession?: (sessionKey: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocalDataNavigator({ onSelectSession }: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [selectedYear, setSelectedYear] = useState<number | null>(null)
|
||||||
|
const [selectedMeetingKey, setSelectedMeetingKey] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const seasonsQuery = useQuery({
|
||||||
|
queryKey: ['seasons'],
|
||||||
|
queryFn: fetchSeasons,
|
||||||
|
})
|
||||||
|
|
||||||
|
const meetingsQuery = useQuery({
|
||||||
|
queryKey: ['meetings', selectedYear],
|
||||||
|
queryFn: () => fetchLocalMeetings(selectedYear!),
|
||||||
|
enabled: selectedYear != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const weekendQuery = useQuery({
|
||||||
|
queryKey: ['weekend', selectedMeetingKey],
|
||||||
|
queryFn: () => fetchWeekend(selectedMeetingKey!),
|
||||||
|
enabled: selectedMeetingKey != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (seasonsQuery.data?.length && selectedYear == null) {
|
||||||
|
setSelectedYear(seasonsQuery.data[0])
|
||||||
|
}
|
||||||
|
}, [seasonsQuery.data, selectedYear])
|
||||||
|
|
||||||
|
function handleSelectSession(sessionKey: number) {
|
||||||
|
if (onSelectSession) {
|
||||||
|
onSelectSession(sessionKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigate({ to: '/race-hub', search: { session_key: sessionKey } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectYear(year: number) {
|
||||||
|
setSelectedYear(year)
|
||||||
|
setSelectedMeetingKey(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectMeeting(meetingKey: number) {
|
||||||
|
setSelectedMeetingKey((prev) => (prev === meetingKey ? null : meetingKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seasonsQuery.isLoading) {
|
||||||
|
return <div className="nav-panel loading-state">loading local seasons…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seasonsQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="nav-panel error-box">
|
||||||
|
{seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const seasons = seasonsQuery.data ?? []
|
||||||
|
|
||||||
|
if (seasons.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="nav-panel" data-testid="local-nav-empty">
|
||||||
|
<div className="nav-panel-title">Local Data</div>
|
||||||
|
<div className="empty-state" style={{ padding: 'var(--s5) 0' }}>
|
||||||
|
<div className="empty-state-title">No ingested seasons yet</div>
|
||||||
|
<div className="empty-state-desc">
|
||||||
|
Ingest a session with <code>box-box --ingest-session <key></code>, then browse
|
||||||
|
here or enter a session key below.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const meetings = meetingsQuery.data ?? []
|
||||||
|
const weekend = weekendQuery.data
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="nav-panel" data-testid="local-nav">
|
||||||
|
<div className="nav-panel-head">
|
||||||
|
<span className="nav-panel-title">Local Data</span>
|
||||||
|
<div className="year-list" role="listbox" aria-label="Season">
|
||||||
|
{seasons.map((year) => (
|
||||||
|
<button
|
||||||
|
key={year}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={year === selectedYear}
|
||||||
|
className={`year-btn ${year === selectedYear ? 'active' : ''}`}
|
||||||
|
onClick={() => handleSelectYear(year)}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{meetingsQuery.isLoading && (
|
||||||
|
<div className="nav-section-meta">loading meetings…</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{meetingsQuery.isError && (
|
||||||
|
<div className="error-box" style={{ marginTop: 'var(--s4)' }}>
|
||||||
|
{meetingsQuery.error instanceof Error ? meetingsQuery.error.message : 'Failed to load meetings'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && (
|
||||||
|
<div className="nav-section-meta">No meetings ingested for {selectedYear}.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{meetings.length > 0 && (
|
||||||
|
<div className="nav-section">
|
||||||
|
<div className="sec-header">
|
||||||
|
<span className="sec-title">Meetings</span>
|
||||||
|
<span className="sec-meta">{meetings.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="scroll-x">
|
||||||
|
<table className="data-table nav-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Grand Prix</th>
|
||||||
|
<th className="hide-mobile">Country</th>
|
||||||
|
<th className="hide-mobile">Dates</th>
|
||||||
|
<th className="r">Open</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{meetings.map((meeting) => {
|
||||||
|
const selected = meeting.meeting_key === selectedMeetingKey
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={meeting.meeting_key}
|
||||||
|
className={selected ? 'nav-row-selected' : ''}
|
||||||
|
data-testid={`meeting-row-${meeting.meeting_key}`}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<span style={{ fontWeight: 600 }}>{meeting.meeting_name}</span>
|
||||||
|
{meeting.circuit_short_name && meeting.circuit_short_name !== meeting.meeting_name && (
|
||||||
|
<span className="nav-sub">{meeting.circuit_short_name}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="hide-mobile mono" style={{ color: 'var(--text-2)' }}>
|
||||||
|
{meeting.country_code || meeting.country_name}
|
||||||
|
</td>
|
||||||
|
<td className="hide-mobile mono" style={{ color: 'var(--text-3)' }}>
|
||||||
|
{formatMeetingDates(meeting)}
|
||||||
|
</td>
|
||||||
|
<td className="r">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`nav-action-btn ${selected ? 'active' : ''}`}
|
||||||
|
aria-expanded={selected}
|
||||||
|
onClick={() => handleSelectMeeting(meeting.meeting_key)}
|
||||||
|
>
|
||||||
|
{selected ? 'Hide' : 'Sessions'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedMeetingKey != null && weekendQuery.isLoading && (
|
||||||
|
<div className="nav-section-meta">loading sessions…</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedMeetingKey != null && weekendQuery.isError && (
|
||||||
|
<div className="error-box" style={{ marginTop: 'var(--s4)' }}>
|
||||||
|
{weekendQuery.error instanceof Error ? weekendQuery.error.message : 'Failed to load weekend'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{weekend && (
|
||||||
|
<div className="nav-section" data-testid="weekend-sessions">
|
||||||
|
<div className="sec-header">
|
||||||
|
<span className="sec-title">{weekend.meeting.meeting_name} Sessions</span>
|
||||||
|
<span className="sec-meta">{weekend.sessions.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{weekend.sessions.length === 0 ? (
|
||||||
|
<div className="nav-section-meta">No sessions stored for this meeting.</div>
|
||||||
|
) : (
|
||||||
|
<div className="scroll-x">
|
||||||
|
<table className="data-table nav-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Session</th>
|
||||||
|
<th className="hide-mobile">Type</th>
|
||||||
|
<th>Coverage</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th className="r">Open</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{weekend.sessions.map(({ session, source, datasets }) => {
|
||||||
|
const coverage = formatCoverageHint(datasets)
|
||||||
|
const isDefault = session.session_key === weekend.default_session_key
|
||||||
|
return (
|
||||||
|
<tr key={session.session_key} data-testid={`session-row-${session.session_key}`}>
|
||||||
|
<td>
|
||||||
|
<span style={{ fontWeight: 600 }}>{session.session_name}</span>
|
||||||
|
{isDefault && <span className="nav-sub">default</span>}
|
||||||
|
<span className="nav-sub mono">{session.session_key}</span>
|
||||||
|
</td>
|
||||||
|
<td className="hide-mobile mono" style={{ color: 'var(--text-3)' }}>
|
||||||
|
{session.session_type}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="mono" style={{ color: 'var(--text-2)' }}>
|
||||||
|
{coverage}
|
||||||
|
</span>
|
||||||
|
<SessionCoverageDots datasets={datasets} />
|
||||||
|
</td>
|
||||||
|
<td>{sessionSourceBadge(source)}</td>
|
||||||
|
<td className="r">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="nav-action-btn nav-action-primary"
|
||||||
|
data-testid={`open-session-${session.session_key}`}
|
||||||
|
onClick={() => handleSelectSession(session.session_key)}
|
||||||
|
>
|
||||||
|
Race Hub
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export helpers used by tests
|
||||||
|
export { countRaceHubDatasets, formatCoverageHint } from '../lib/coverage'
|
||||||
26
frontend/src/components/Meaning.tsx
Normal file
26
frontend/src/components/Meaning.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import '../styles/meaning.css'
|
||||||
|
|
||||||
|
export interface MeaningProps {
|
||||||
|
value: ReactNode
|
||||||
|
meaning?: string | null
|
||||||
|
/** Long-form explanation for the native tooltip; falls back to meaning. */
|
||||||
|
title?: string | null
|
||||||
|
tone?: 'good' | 'bad' | 'neutral' | 'warn'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Meaning({ value, meaning, title, tone }: MeaningProps) {
|
||||||
|
if (!meaning) {
|
||||||
|
return <>{value}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltip = title ?? meaning
|
||||||
|
const toneClass = tone ? `meaning-caption--${tone}` : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="meaning" title={tooltip}>
|
||||||
|
<span className="meaning-value">{value}</span>
|
||||||
|
<span className={`meaning-caption ${toneClass}`.trim()}>{meaning}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
108
frontend/src/components/MeetingDetailPanel.tsx
Normal file
108
frontend/src/components/MeetingDetailPanel.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { RACE_HUB_DATASETS, formatCoverageHint } from '../lib/coverage'
|
||||||
|
import { SourceBadge } from './SourceBadge'
|
||||||
|
import { SessionCoverageDots } from './SessionCoverageDots'
|
||||||
|
import {
|
||||||
|
CliCommands,
|
||||||
|
ingestMeetingCommands,
|
||||||
|
ingestSessionCommands,
|
||||||
|
} from './CliCommands'
|
||||||
|
import type { Weekend, WeekendSession } from '../types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
weekend: Weekend
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MeetingDetailPanel({ weekend }: Props) {
|
||||||
|
const { meeting, sessions, source } = weekend
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dl-detail" data-testid="meeting-detail">
|
||||||
|
<div className="detail-header">
|
||||||
|
<div className="detail-header-row">
|
||||||
|
<span className="detail-title">{meeting.meeting_name}</span>
|
||||||
|
<SourceBadge source={source} label={source === 'local' ? 'Full' : undefined} />
|
||||||
|
</div>
|
||||||
|
<div className="detail-meta">
|
||||||
|
{meeting.country_name} · meeting_key {meeting.meeting_key}
|
||||||
|
</div>
|
||||||
|
<div className="detail-meta">
|
||||||
|
{sessions.length} session{sessions.length === 1 ? '' : 's'} stored locally
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<div className="missing-notice">
|
||||||
|
No sessions ingested for this meeting. Run{' '}
|
||||||
|
<code>box-box --ingest-meeting {meeting.meeting_key}</code>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sessions.map((entry) => (
|
||||||
|
<SessionDetailBlock key={entry.session.session_key} entry={entry} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="dl-cli-section">
|
||||||
|
<div className="sec-header">
|
||||||
|
<span className="sec-title">Ingest Commands</span>
|
||||||
|
</div>
|
||||||
|
<CliCommands commands={ingestMeetingCommands(meeting.meeting_key)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionDetailBlock({ entry }: { entry: WeekendSession }) {
|
||||||
|
const { session, source, datasets } = entry
|
||||||
|
const coverage = formatCoverageHint(datasets)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="session-detail-row" data-testid={`session-detail-${session.session_key}`}>
|
||||||
|
<div className="session-detail-head">
|
||||||
|
<SourceBadge source={source} />
|
||||||
|
<span>{session.session_name}</span>
|
||||||
|
<span className="session-detail-key mono">{session.session_key}</span>
|
||||||
|
<span className="session-detail-coverage mono">{coverage}</span>
|
||||||
|
<SessionCoverageDots datasets={datasets} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="data-table ds-detail-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Dataset</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th className="r">Records</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{RACE_HUB_DATASETS.map((key) => {
|
||||||
|
const info = datasets[key]
|
||||||
|
const available = info?.status === 'available'
|
||||||
|
return (
|
||||||
|
<tr key={key}>
|
||||||
|
<td className="mono" style={{ color: 'var(--text-2)' }}>
|
||||||
|
{key}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{info?.status === 'available' ? (
|
||||||
|
<span className="badge badge-local">Local</span>
|
||||||
|
) : info?.status === 'skipped' ? (
|
||||||
|
<span className="badge badge-none">N/A</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-none">Missing</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="r mono" style={{ color: 'var(--text-3)' }}>
|
||||||
|
{info?.count != null ? info.count : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div className="session-cli">
|
||||||
|
<CliCommands commands={ingestSessionCommands(session.session_key)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
frontend/src/components/Nav.tsx
Normal file
33
frontend/src/components/Nav.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export function Nav() {
|
||||||
|
return (
|
||||||
|
<nav className="app-nav">
|
||||||
|
<Link to="/" className="nav-logo">
|
||||||
|
box<em>-</em>box
|
||||||
|
</Link>
|
||||||
|
<div className="nav-links">
|
||||||
|
<Link to="/" activeProps={{ className: 'active' }} activeOptions={{ exact: true }}>
|
||||||
|
Command
|
||||||
|
</Link>
|
||||||
|
<Link to="/live" activeProps={{ className: 'active' }}>
|
||||||
|
Live
|
||||||
|
</Link>
|
||||||
|
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
||||||
|
Race Hub
|
||||||
|
</Link>
|
||||||
|
<Link to="/championship" activeProps={{ className: 'active' }}>
|
||||||
|
Championship
|
||||||
|
</Link>
|
||||||
|
<Link to="/briefing" activeProps={{ className: 'active' }}>
|
||||||
|
Briefing
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="nav-utility">
|
||||||
|
<Link to="/admin" className="nav-utility-link" activeProps={{ className: 'nav-utility-link active' }}>
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
288
frontend/src/components/OverviewView.tsx
Normal file
288
frontend/src/components/OverviewView.tsx
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import type { RaceHub } from '../types'
|
||||||
|
import { compareFinishPosition, formatDuration, formatGap, formatLapTime } from '../utils'
|
||||||
|
import { countRaceHubDatasets } from '../lib/coverage'
|
||||||
|
import { Thermometer, Map, Droplets, Wind, CloudRain } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: RaceHub
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverviewView({ data }: Props) {
|
||||||
|
const sortedResults = [...data.results].sort((a, b) =>
|
||||||
|
compareFinishPosition(a.position, b.position),
|
||||||
|
)
|
||||||
|
const winner = sortedResults[0]
|
||||||
|
const podium = sortedResults.filter((r) => r.position > 0).slice(0, 3)
|
||||||
|
const pole = data.starting_grid.find((g) => g.position === 1)
|
||||||
|
const fastest = pickFastestLap(data)
|
||||||
|
const latestWeather = data.weather.length > 0 ? data.weather[data.weather.length - 1] : null
|
||||||
|
const rcHighlights = data.race_control.slice(-3).reverse()
|
||||||
|
const coverage = countRaceHubDatasets(data.datasets)
|
||||||
|
|
||||||
|
const sessionType = (data.session?.session_type ?? '').toLowerCase()
|
||||||
|
const isRace = sessionType.includes('race')
|
||||||
|
const sessionLabel = isRace ? 'Race' : data.session?.session_type ?? 'Session'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rh-overview" data-testid="rh-overview">
|
||||||
|
<div className="rh-stat-grid">
|
||||||
|
{winner && winner.position > 0 ? (
|
||||||
|
<StatCard
|
||||||
|
label={isRace ? 'Winner' : `${sessionLabel} P1`}
|
||||||
|
primary={winner.name_acronym || `#${winner.driver_number}`}
|
||||||
|
primaryColor={winner.team_colour ? `#${winner.team_colour}` : undefined}
|
||||||
|
secondary={winner.full_name}
|
||||||
|
tertiary={winner.team_name}
|
||||||
|
highlight={
|
||||||
|
isRace
|
||||||
|
? formatDuration(winner.duration)
|
||||||
|
: winner.duration
|
||||||
|
? formatDuration(winner.duration)
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StatCard label={isRace ? 'Winner' : `${sessionLabel} P1`} placeholder />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PodiumCard podium={podium} />
|
||||||
|
|
||||||
|
{pole ? (
|
||||||
|
<StatCard
|
||||||
|
label={isRace ? 'Pole' : 'P1'}
|
||||||
|
primary={pole.name_acronym || `#${pole.driver_number}`}
|
||||||
|
primaryColor={pole.team_colour ? `#${pole.team_colour}` : undefined}
|
||||||
|
secondary={pole.full_name}
|
||||||
|
tertiary={pole.team_name}
|
||||||
|
highlight={pole.lap_duration ? formatLapTime(pole.lap_duration) : ''}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StatCard label={isRace ? 'Pole' : 'Grid'} placeholder />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fastest ? (
|
||||||
|
<StatCard
|
||||||
|
label="Fastest Lap"
|
||||||
|
primary={fastest.acronym}
|
||||||
|
primaryColor={fastest.colour ? `#${fastest.colour}` : undefined}
|
||||||
|
secondary={fastest.fullName}
|
||||||
|
tertiary={`Lap ${fastest.lap}`}
|
||||||
|
highlight={formatLapTime(fastest.time)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StatCard label="Fastest Lap" placeholder />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rh-overview-row">
|
||||||
|
<section className="rh-panel ui-card">
|
||||||
|
<div className="sec-header">
|
||||||
|
<span className="sec-title">Conditions</span>
|
||||||
|
{latestWeather && (
|
||||||
|
<span className="sec-meta mono">{shortTime(latestWeather.date)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{latestWeather ? (
|
||||||
|
<div className="rh-condition-strip" data-testid="rh-conditions">
|
||||||
|
<ConditionChip icon={Thermometer} label="Air" value={`${latestWeather.air_temperature.toFixed(1)}°C`} />
|
||||||
|
<ConditionChip
|
||||||
|
icon={Map}
|
||||||
|
label="Track"
|
||||||
|
value={`${latestWeather.track_temperature.toFixed(1)}°C`}
|
||||||
|
/>
|
||||||
|
<ConditionChip icon={Droplets} label="Humidity" value={`${latestWeather.humidity.toFixed(0)}%`} />
|
||||||
|
<ConditionChip
|
||||||
|
icon={Wind}
|
||||||
|
label="Wind"
|
||||||
|
value={`${latestWeather.wind_speed.toFixed(1)} m/s`}
|
||||||
|
/>
|
||||||
|
<ConditionChip
|
||||||
|
icon={CloudRain}
|
||||||
|
label="Rain"
|
||||||
|
value={latestWeather.rainfall > 0 ? 'Yes' : 'No'}
|
||||||
|
accent={latestWeather.rainfall > 0 ? 'wet' : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rh-empty-line">No weather samples ingested.</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rh-panel ui-card">
|
||||||
|
<div className="sec-header">
|
||||||
|
<span className="sec-title">Race Control · Latest</span>
|
||||||
|
<span className="sec-meta mono">{data.race_control.length}</span>
|
||||||
|
</div>
|
||||||
|
{rcHighlights.length === 0 ? (
|
||||||
|
<div className="rh-empty-line">No race-control messages.</div>
|
||||||
|
) : (
|
||||||
|
<ul className="rh-rc-list">
|
||||||
|
{rcHighlights.map((m, i) => (
|
||||||
|
<li key={i} className="rh-rc-row">
|
||||||
|
<span className="rh-rc-time mono">{shortTime(m.date)}</span>
|
||||||
|
<span className={`rh-rc-flag rh-rc-flag-${(m.flag || 'none').toLowerCase()}`}>
|
||||||
|
{m.flag || m.category || '—'}
|
||||||
|
</span>
|
||||||
|
<span className="rh-rc-msg">{m.message}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rh-panel ui-card">
|
||||||
|
<div className="sec-header">
|
||||||
|
<span className="sec-title">Local Coverage</span>
|
||||||
|
<span className="sec-meta mono">
|
||||||
|
{coverage.available}/{coverage.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="rh-coverage-meter" aria-hidden="true">
|
||||||
|
<div
|
||||||
|
className="rh-coverage-fill"
|
||||||
|
style={{ width: `${(coverage.available / coverage.total) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="rh-empty-line" style={{ marginTop: 'var(--s2)' }}>
|
||||||
|
{coverage.available === coverage.total
|
||||||
|
? 'Every Race Hub dataset is local for this session.'
|
||||||
|
: `${coverage.total - coverage.available} dataset${
|
||||||
|
coverage.total - coverage.available === 1 ? '' : 's'
|
||||||
|
} not ingested yet — see Data Status tab.`}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
label: string
|
||||||
|
primary?: string
|
||||||
|
primaryColor?: string
|
||||||
|
secondary?: string
|
||||||
|
tertiary?: string
|
||||||
|
highlight?: string
|
||||||
|
placeholder?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
primary,
|
||||||
|
primaryColor,
|
||||||
|
secondary,
|
||||||
|
tertiary,
|
||||||
|
highlight,
|
||||||
|
placeholder,
|
||||||
|
}: StatCardProps) {
|
||||||
|
if (placeholder) {
|
||||||
|
return (
|
||||||
|
<div className="rh-stat-card ui-card rh-stat-empty">
|
||||||
|
<div className="rh-stat-label mono">{label}</div>
|
||||||
|
<div className="rh-stat-primary">—</div>
|
||||||
|
<div className="rh-stat-secondary">No data ingested</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="rh-stat-card ui-card interactive">
|
||||||
|
<div className="rh-stat-label mono">{label}</div>
|
||||||
|
<div className="rh-stat-primary" style={primaryColor ? { color: primaryColor } : undefined}>
|
||||||
|
{primary}
|
||||||
|
</div>
|
||||||
|
{secondary && <div className="rh-stat-secondary">{secondary}</div>}
|
||||||
|
{tertiary && <div className="rh-stat-tertiary">{tertiary}</div>}
|
||||||
|
{highlight && <div className="rh-stat-highlight mono">{highlight}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PodiumCard({ podium }: { podium: Array<{ name_acronym: string; team_colour: string; position: number; full_name: string; gap_to_leader: number | string | number[] | null; duration: number | number[] | null; driver_number: number }> }) {
|
||||||
|
if (podium.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rh-stat-card ui-card rh-stat-empty">
|
||||||
|
<div className="rh-stat-label mono">Podium</div>
|
||||||
|
<div className="rh-stat-primary">—</div>
|
||||||
|
<div className="rh-stat-secondary">No classified finishers</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="rh-stat-card ui-card interactive">
|
||||||
|
<div className="rh-stat-label mono">Podium</div>
|
||||||
|
<ol className="rh-podium-list">
|
||||||
|
{podium.map((r) => (
|
||||||
|
<li key={r.driver_number} className={`rh-podium-row rh-podium-p${r.position}`}>
|
||||||
|
<span className="rh-podium-pos mono">P{r.position}</span>
|
||||||
|
<span
|
||||||
|
className="rh-podium-driver"
|
||||||
|
style={r.team_colour ? { color: `#${r.team_colour}` } : undefined}
|
||||||
|
>
|
||||||
|
{r.name_acronym || `#${r.driver_number}`}
|
||||||
|
</span>
|
||||||
|
<span className="rh-podium-gap mono">
|
||||||
|
{r.position === 1
|
||||||
|
? formatDuration(r.duration)
|
||||||
|
: formatGap(r.gap_to_leader)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConditionChip({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
accent,
|
||||||
|
icon: Icon,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
accent?: 'wet'
|
||||||
|
icon?: React.ElementType
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`rh-condition-chip${accent === 'wet' ? ' rh-condition-wet' : ''}`} style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
{Icon && <Icon size={14} style={{ opacity: 0.7 }} />}
|
||||||
|
<span className="rh-condition-label mono">{label}</span>
|
||||||
|
<span className="rh-condition-value">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortTime(iso: string): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (Number.isNaN(d.getTime())) return iso.slice(11, 16)
|
||||||
|
return d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFastestLap(
|
||||||
|
data: RaceHub,
|
||||||
|
): { lap: number; time: number; acronym: string; fullName: string; colour: string } | null {
|
||||||
|
const candidates = data.laps.filter(
|
||||||
|
(l) => l.lap_duration != null && l.lap_duration > 0 && !l.is_pit_out_lap,
|
||||||
|
)
|
||||||
|
if (candidates.length === 0) return null
|
||||||
|
let best = candidates[0]
|
||||||
|
for (const lap of candidates) {
|
||||||
|
if ((lap.lap_duration ?? 0) < (best.lap_duration ?? Infinity)) {
|
||||||
|
best = lap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const driverInfo = data.results.find((r) => r.driver_number === best.driver_number)
|
||||||
|
?? data.drivers.find((d) => d.driver_number === best.driver_number)
|
||||||
|
return {
|
||||||
|
lap: best.lap_number,
|
||||||
|
time: best.lap_duration ?? 0,
|
||||||
|
acronym:
|
||||||
|
('name_acronym' in (driverInfo ?? {}) ? (driverInfo as { name_acronym: string }).name_acronym : '')
|
||||||
|
|| `#${best.driver_number}`,
|
||||||
|
fullName:
|
||||||
|
('full_name' in (driverInfo ?? {}) ? (driverInfo as { full_name: string }).full_name : '') || '',
|
||||||
|
colour:
|
||||||
|
('team_colour' in (driverInfo ?? {}) ? (driverInfo as { team_colour: string }).team_colour : '') || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
73
frontend/src/components/PaddockBriefing.tsx
Normal file
73
frontend/src/components/PaddockBriefing.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { fetchNews } from '../api'
|
||||||
|
import { timeAgo } from '../utils'
|
||||||
|
|
||||||
|
const SOURCE_DISPLAY: Record<string, string> = {
|
||||||
|
'fia': 'FIA',
|
||||||
|
'bbc-f1': 'BBC Sport',
|
||||||
|
'autosport-f1': 'Autosport',
|
||||||
|
'racefans-f1': 'RaceFans',
|
||||||
|
'guardian-f1': 'Guardian',
|
||||||
|
'racer-f1': 'RACER',
|
||||||
|
'f1-youtube': 'F1 YouTube',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaddockBriefing() {
|
||||||
|
const { data: news, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['news'],
|
||||||
|
queryFn: () => fetchNews(100),
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const unreadCount = news?.filter((i) => !i.read_at).length ?? 0
|
||||||
|
const preview = news?.slice(0, 5) ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="cc-briefing" data-testid="paddock-briefing">
|
||||||
|
<div className="sec-header">
|
||||||
|
<span className="sec-title">
|
||||||
|
Paddock Briefing
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="cc-brief-unread">{unreadCount}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<Link to="/briefing" className="sec-action mono">
|
||||||
|
View all →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && <div className="briefing-state loading-state">loading…</div>}
|
||||||
|
{isError && <div className="briefing-state error-box">Failed to load briefing</div>}
|
||||||
|
|
||||||
|
{!isLoading && !isError && preview.length === 0 && (
|
||||||
|
<div className="briefing-state">
|
||||||
|
No items. Run <code>box-box --ingest-news</code> to populate.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preview.length > 0 && (
|
||||||
|
<div className="cc-brief-strip" role="list">
|
||||||
|
{preview.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.url}
|
||||||
|
to="/briefing"
|
||||||
|
className={`cc-brief-item${item.read_at ? ' is-read' : ''}`}
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
<div className="cc-brief-item-meta mono">
|
||||||
|
<span className="cc-brief-source">
|
||||||
|
{SOURCE_DISPLAY[item.source] ?? item.source}
|
||||||
|
</span>
|
||||||
|
<span className="cc-brief-age">
|
||||||
|
{timeAgo(item.published_at ?? item.fetched_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="cc-brief-title">{item.title}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
frontend/src/components/RaceControlView.tsx
Normal file
91
frontend/src/components/RaceControlView.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import type { RaceControlMessage } from '../types'
|
||||||
|
import { rcFlagClass } from '../lib/live'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
messages: RaceControlMessage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEventTime(date: string): string {
|
||||||
|
if (!date) return '—'
|
||||||
|
const parsed = new Date(date)
|
||||||
|
if (Number.isNaN(parsed.getTime())) return date
|
||||||
|
return parsed.toLocaleTimeString('en-GB', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventLabel(message: RaceControlMessage): string {
|
||||||
|
return message.flag || message.category || 'Message'
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventClass(message: RaceControlMessage): string {
|
||||||
|
const flagClass = rcFlagClass(message.flag ?? '')
|
||||||
|
if (flagClass) return flagClass
|
||||||
|
|
||||||
|
const category = (message.category ?? '').toLowerCase()
|
||||||
|
const text = `${message.message ?? ''} ${message.category ?? ''}`.toLowerCase()
|
||||||
|
|
||||||
|
if (category.includes('safety') || text.includes('safety car')) return 'rc-flag-sc'
|
||||||
|
if (category === 'drs' || text.includes('drs')) return 'rc-flag-drs'
|
||||||
|
if (text.includes('virtual safety car')) return 'rc-flag-vsc'
|
||||||
|
if (text.includes('red flag')) return 'rc-flag-red'
|
||||||
|
if (text.includes('yellow')) return 'rc-flag-yellow'
|
||||||
|
if (text.includes('green light') || text.includes('green flag')) return 'rc-flag-green'
|
||||||
|
if (text.includes('chequered') || text.includes('checkered')) return 'rc-flag-chequered'
|
||||||
|
|
||||||
|
return 'rc-flag-other'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RaceControlView({ messages }: Props) {
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="missing-notice">
|
||||||
|
Race control messages not ingested. Run{' '}
|
||||||
|
<code>box-box --ingest-session <key></code> to load this dataset.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [...messages].sort((a, b) => a.date.localeCompare(b.date))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="scroll-x" data-testid="race-control-view">
|
||||||
|
<table className="data-table" style={{ minWidth: 620 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th className="c">Lap</th>
|
||||||
|
<th>Event</th>
|
||||||
|
<th className="c hide-mobile">Driver</th>
|
||||||
|
<th>Message</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((message, index) => {
|
||||||
|
const visualClass = eventClass(message)
|
||||||
|
return (
|
||||||
|
<tr className={`race-control-row ${visualClass}`} key={`${message.date}-${index}`}>
|
||||||
|
<td className="mono rc-time-cell">
|
||||||
|
{formatEventTime(message.date)}
|
||||||
|
</td>
|
||||||
|
<td className="c mono">{message.lap_number ?? '—'}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`rc-event-pill rc-flag ${visualClass}`}>{eventLabel(message)}</span>
|
||||||
|
{message.scope && (
|
||||||
|
<span className="rc-scope">
|
||||||
|
{message.scope.toLowerCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="c mono hide-mobile">{message.driver_number ?? '—'}</td>
|
||||||
|
<td className="rc-message-cell">{message.message || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
frontend/src/components/RaceHubHeader.tsx
Normal file
39
frontend/src/components/RaceHubHeader.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { Meeting, Session, RaceHub } from '../types'
|
||||||
|
import { formatDate } from '../utils'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
meeting?: Meeting
|
||||||
|
session?: Session
|
||||||
|
source: RaceHub['source']
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceBadge({ source }: { source: RaceHub['source'] }) {
|
||||||
|
if (source === 'local') return <span className="badge badge-local">Local</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>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RaceHubHeader({ meeting, session, source }: Props) {
|
||||||
|
const meetingName = meeting?.meeting_name ?? 'Unknown Meeting'
|
||||||
|
const sessionName = session?.session_name ?? 'Unknown Session'
|
||||||
|
const dateStr = formatDate(session?.date_start ?? meeting?.date_start)
|
||||||
|
const location = meeting ? `${meeting.location} · ${meeting.country_name}` : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rh-header">
|
||||||
|
<div className="rh-title-group">
|
||||||
|
<div className="rh-meeting">{meetingName}</div>
|
||||||
|
<div className="rh-session">{sessionName}</div>
|
||||||
|
<div className="rh-meta">
|
||||||
|
{dateStr && <span className="rh-meta-item">{dateStr}</span>}
|
||||||
|
{location && <span className="rh-meta-item" style={{ opacity: 0.6 }}>·</span>}
|
||||||
|
{location && <span className="rh-meta-item">{location}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flexShrink: 0, paddingTop: 2 }}>
|
||||||
|
<SourceBadge source={source} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
377
frontend/src/components/RaceStoryCanvas.tsx
Normal file
377
frontend/src/components/RaceStoryCanvas.tsx
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
import { useState, useMemo, useRef, useCallback } from 'react'
|
||||||
|
import type { EnrichedResult, EnrichedGrid, PositionSample, Lap, Session } from '../types'
|
||||||
|
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: {
|
||||||
|
results: EnrichedResult[]
|
||||||
|
starting_grid: EnrichedGrid[]
|
||||||
|
positions: PositionSample[]
|
||||||
|
laps: Lap[]
|
||||||
|
datasets: Record<string, any>
|
||||||
|
race_control?: any[]
|
||||||
|
pit_stops?: any[]
|
||||||
|
session?: Session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RaceStoryCanvas({ data }: Props) {
|
||||||
|
const { results, starting_grid: grid, positions, datasets, race_control = [], pit_stops = [], laps = [] } = data
|
||||||
|
const hasPositions = datasets['positions']?.status === 'available'
|
||||||
|
|
||||||
|
const [scrubTime, setScrubTime] = useState<number | null>(null)
|
||||||
|
const [hoverDriver, setHoverDriver] = useState<number | null>(null)
|
||||||
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
|
|
||||||
|
// Position Evolution Chart Logic
|
||||||
|
const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions])
|
||||||
|
const hasChartData = hasPositions && allTimes.length > 0
|
||||||
|
|
||||||
|
let chartContent = null
|
||||||
|
let displayResults = results
|
||||||
|
|
||||||
|
if (hasChartData) {
|
||||||
|
const tMin = new Date(allTimes[0]).getTime()
|
||||||
|
const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
|
||||||
|
const tRange = Math.max(tMax - tMin, 1)
|
||||||
|
|
||||||
|
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
|
||||||
|
for (const p of positions) {
|
||||||
|
if (!byDriver.has(p.driver_number)) byDriver.set(p.driver_number, [])
|
||||||
|
byDriver.get(p.driver_number)!.push({
|
||||||
|
t: (new Date(p.date).getTime() - tMin) / tRange,
|
||||||
|
pos: p.position,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const dnfSet = new Set(results.filter(r => r.dnf || r.dns || r.dsq).map(r => r.driver_number))
|
||||||
|
for (const [dNum, samples] of byDriver.entries()) {
|
||||||
|
samples.sort((a, b) => a.t - b.t)
|
||||||
|
if (samples.length > 0 && !dnfSet.has(dNum)) {
|
||||||
|
samples.push({ t: 1, pos: samples[samples.length - 1].pos })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInterpPos = (samples: {t: number, pos: number}[], t: number) => {
|
||||||
|
if (!samples || samples.length === 0) return null
|
||||||
|
if (t <= samples[0].t) return samples[0].pos
|
||||||
|
if (t >= samples[samples.length - 1].t) return samples[samples.length - 1].pos
|
||||||
|
for (let i = 0; i < samples.length - 1; i++) {
|
||||||
|
if (samples[i].t <= t && samples[i+1].t >= t) {
|
||||||
|
const dt = samples[i+1].t - samples[i].t
|
||||||
|
if (dt === 0) return samples[i].pos
|
||||||
|
const frac = (t - samples[i].t) / dt
|
||||||
|
return samples[i].pos + (samples[i+1].pos - samples[i].pos) * frac
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scrubTime !== null) {
|
||||||
|
const currentPos = new Map<number, number>()
|
||||||
|
for (const [dNum, samples] of byDriver.entries()) {
|
||||||
|
const pos = getInterpPos(samples, scrubTime)
|
||||||
|
if (pos !== null) {
|
||||||
|
currentPos.set(dNum, pos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
displayResults = [...results].sort((a, b) => {
|
||||||
|
const posA = currentPos.get(a.driver_number) ?? 999
|
||||||
|
const posB = currentPos.get(b.driver_number) ?? 999
|
||||||
|
return posA - posB
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxPos = Math.max(...positions.map((p) => p.position), results.length, 2)
|
||||||
|
const colorByDriver = new Map(results.map((r) => [r.driver_number, r.team_colour]))
|
||||||
|
const acronymByDriver = new Map(results.map((r) => [r.driver_number, r.name_acronym]))
|
||||||
|
|
||||||
|
const W = 640
|
||||||
|
const H = 180
|
||||||
|
const PL = 40
|
||||||
|
const PR = 48
|
||||||
|
const PT = 8
|
||||||
|
const PB = 20
|
||||||
|
const plotW = W - PL - PR
|
||||||
|
const plotH = H - PT - PB
|
||||||
|
|
||||||
|
const toX = (t: number) => PL + t * plotW
|
||||||
|
const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH
|
||||||
|
|
||||||
|
const winner = results.find(r => r.position === 1)
|
||||||
|
const winnerLaps = winner ? laps.filter(l => l.driver_number === winner.driver_number) : []
|
||||||
|
const lapTicks: { lap: number, t: number }[] = []
|
||||||
|
const lapInterval = winnerLaps.length < 30 ? 5 : 10
|
||||||
|
|
||||||
|
for (const lap of winnerLaps) {
|
||||||
|
if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) {
|
||||||
|
const t = (new Date(lap.date_start).getTime() - tMin) / tRange
|
||||||
|
if (t >= 0 && t <= 1) {
|
||||||
|
lapTicks.push({ lap: lap.lap_number, t })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety Car / VSC periods
|
||||||
|
const scPeriods: { start: number; end: number | null; type: 'SC' | 'VSC' }[] = []
|
||||||
|
let activeSC: { start: number; type: 'SC' | 'VSC' } | null = null
|
||||||
|
const rc = [...race_control].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||||
|
|
||||||
|
for (const msg of rc) {
|
||||||
|
const t = new Date(msg.date).getTime()
|
||||||
|
const m = msg.message?.toUpperCase() || ''
|
||||||
|
const cat = msg.category?.toUpperCase() || ''
|
||||||
|
|
||||||
|
if (m.includes('VIRTUAL SAFETY CAR DEPLOYED') || cat === 'VIRTUALSAFETYCAR') {
|
||||||
|
if (!activeSC) activeSC = { start: t, type: 'VSC' }
|
||||||
|
} else if (m.includes('SAFETY CAR DEPLOYED') || cat === 'SAFETYCAR') {
|
||||||
|
if (!activeSC) activeSC = { start: t, type: 'SC' }
|
||||||
|
} else if (m.includes('TRACK CLEAR') || m.includes('CLEAR')) {
|
||||||
|
if (activeSC) {
|
||||||
|
scPeriods.push({ start: activeSC.start, end: t, type: activeSC.type })
|
||||||
|
activeSC = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (activeSC) {
|
||||||
|
scPeriods.push({ start: activeSC.start, end: null, type: activeSC.type })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
|
||||||
|
if (!svgRef.current) return
|
||||||
|
const rect = svgRef.current.getBoundingClientRect()
|
||||||
|
const x = e.clientX - rect.left
|
||||||
|
const t = Math.max(0, Math.min(1, (x - PL) / plotW))
|
||||||
|
setScrubTime(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
chartContent = (
|
||||||
|
<div className="rs-chart-container scroll-x" data-testid="position-chart">
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
style={{ width: '100%', minWidth: 280, maxWidth: W, display: 'block' }}
|
||||||
|
role="img"
|
||||||
|
aria-label="Position evolution chart"
|
||||||
|
>
|
||||||
|
{scPeriods.map((sc, i) => {
|
||||||
|
const startT = (sc.start - tMin) / tRange
|
||||||
|
const endT = sc.end ? (sc.end - tMin) / tRange : 1
|
||||||
|
const x1 = toX(Math.max(0, startT))
|
||||||
|
const x2 = toX(Math.min(1, endT))
|
||||||
|
if (x2 <= PL || x1 >= W - PR) return null
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
key={`sc-${i}`}
|
||||||
|
x={x1}
|
||||||
|
y={PT}
|
||||||
|
width={Math.max(0, x2 - x1)}
|
||||||
|
height={plotH}
|
||||||
|
fill={sc.type === 'SC' ? 'rgba(255, 153, 0, 0.15)' : 'rgba(255, 204, 0, 0.1)'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{Array.from({ length: maxPos }, (_, i) => i + 1).map((pos) => (
|
||||||
|
<g key={pos}>
|
||||||
|
<line
|
||||||
|
x1={PL}
|
||||||
|
x2={W - PR}
|
||||||
|
y1={toY(pos)}
|
||||||
|
y2={toY(pos)}
|
||||||
|
stroke="var(--border)"
|
||||||
|
strokeWidth={0.5}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={PL - 4}
|
||||||
|
y={toY(pos) + 4}
|
||||||
|
textAnchor="end"
|
||||||
|
fill="var(--text-3)"
|
||||||
|
fontSize={8}
|
||||||
|
fontFamily="var(--f-mono)"
|
||||||
|
>
|
||||||
|
P{pos}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{lapTicks.map(tick => (
|
||||||
|
<g key={`lap-${tick.lap}`}>
|
||||||
|
<line x1={toX(tick.t)} x2={toX(tick.t)} y1={H - PB} y2={H - PB + 4} stroke="var(--border)" strokeWidth={1} />
|
||||||
|
<text x={toX(tick.t)} y={H - PB + 14} textAnchor="middle" fill="var(--text-3)" fontSize={9} fontFamily="var(--f-mono)">
|
||||||
|
L{tick.lap}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{Array.from(byDriver.entries()).map(([dNum, samples]) => {
|
||||||
|
const colour = colorByDriver.get(dNum)
|
||||||
|
const color = colour ? `#${colour}` : '#888'
|
||||||
|
const pts = samples.map((s) => `${toX(s.t)},${toY(s.pos)}`).join(' ')
|
||||||
|
const last = samples[samples.length - 1]
|
||||||
|
const isHovered = hoverDriver === dNum
|
||||||
|
const isFaded = hoverDriver !== null && !isHovered
|
||||||
|
|
||||||
|
const driverPits = pit_stops.filter(p => p.driver_number === dNum)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={dNum}
|
||||||
|
style={{ opacity: isFaded ? 0.2 : 1, transition: 'opacity 0.2s' }}
|
||||||
|
onMouseEnter={() => setHoverDriver(dNum)}
|
||||||
|
onMouseLeave={() => setHoverDriver(null)}
|
||||||
|
>
|
||||||
|
<polyline
|
||||||
|
points={pts}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={isHovered ? 3 : 2}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="rs-driver-line"
|
||||||
|
pathLength={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{driverPits.map((p, i) => {
|
||||||
|
const t = (new Date(p.date).getTime() - tMin) / tRange
|
||||||
|
if (t < 0 || t > 1) return null
|
||||||
|
const pos = getInterpPos(samples, t)
|
||||||
|
if (pos === null) return null
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
key={`pit-${i}`}
|
||||||
|
cx={toX(t)}
|
||||||
|
cy={toY(pos)}
|
||||||
|
r={3}
|
||||||
|
fill="var(--bg)"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
className="rs-pit-dot"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{last && (
|
||||||
|
<text
|
||||||
|
x={toX(last.t) + 6}
|
||||||
|
y={toY(last.pos) + 4}
|
||||||
|
fill={color}
|
||||||
|
fontSize={isHovered ? 11 : 9}
|
||||||
|
fontFamily="var(--f-mono)"
|
||||||
|
fontWeight={700}
|
||||||
|
style={{ cursor: 'default' }}
|
||||||
|
>
|
||||||
|
{acronymByDriver.get(dNum) ?? dNum}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{scrubTime !== null && (
|
||||||
|
<line
|
||||||
|
x1={toX(scrubTime)}
|
||||||
|
x2={toX(scrubTime)}
|
||||||
|
y1={PT}
|
||||||
|
y2={H - PB}
|
||||||
|
stroke="var(--text)"
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeDasharray="4 2"
|
||||||
|
className="rs-playhead"
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<rect
|
||||||
|
x={PL}
|
||||||
|
y={PT}
|
||||||
|
width={plotW}
|
||||||
|
height={plotH}
|
||||||
|
fill="transparent"
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerLeave={() => setScrubTime(null)}
|
||||||
|
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="race-story-canvas">
|
||||||
|
{hasChartData ? (
|
||||||
|
chartContent
|
||||||
|
) : (
|
||||||
|
<div className="analysis-notice">
|
||||||
|
<strong>Lap-by-lap positions not available.</strong> This session does not
|
||||||
|
have ingested position samples in <code>/api/v1/race-hub</code>.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayResults.length > 0 && (
|
||||||
|
<div className="rs-field-list">
|
||||||
|
{displayResults.map((r, i) => {
|
||||||
|
const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
|
||||||
|
const currentPos = scrubTime !== null ? i + 1 : r.position
|
||||||
|
const isWinner = i === 0 && r.position === 1
|
||||||
|
const pClass = currentPos === 1 ? 'rs-pos-p1' : currentPos === 2 ? 'rs-pos-p2' : currentPos === 3 ? 'rs-pos-p3' : ''
|
||||||
|
|
||||||
|
let currentPoints: number | string = r.points
|
||||||
|
if (scrubTime !== null) {
|
||||||
|
const isSprint = data.session?.session_type?.toLowerCase().includes('sprint')
|
||||||
|
const ptsArray = isSprint ? [8, 7, 6, 5, 4, 3, 2, 1] : [25, 18, 15, 12, 10, 8, 6, 4, 2, 1]
|
||||||
|
currentPoints = currentPos <= ptsArray.length ? ptsArray[currentPos - 1] : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={r.driver_number}
|
||||||
|
className={`rs-driver-row ${hoverDriver === r.driver_number ? 'rs-driver-row-hover' : ''}`}
|
||||||
|
onMouseEnter={() => setHoverDriver(r.driver_number)}
|
||||||
|
onMouseLeave={() => setHoverDriver(null)}
|
||||||
|
>
|
||||||
|
<div className="rs-driver-left">
|
||||||
|
<div className={`rs-pos-col ${pClass}`}>
|
||||||
|
{currentPos}
|
||||||
|
</div>
|
||||||
|
<div className="rs-driver-cell">
|
||||||
|
<div
|
||||||
|
className="rs-driver-color"
|
||||||
|
style={{ background: r.team_colour ? `#${r.team_colour}` : 'var(--border)' }}
|
||||||
|
/>
|
||||||
|
<div className="rs-driver-identity">
|
||||||
|
<span className="rs-driver-name">{r.name_acronym || r.driver_number}</span>
|
||||||
|
<span className="rs-driver-team">{r.team_name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rs-driver-right">
|
||||||
|
<div className="rs-metric">
|
||||||
|
<span>
|
||||||
|
<span className={gridDeltaClass(currentPos, gridPos)}>
|
||||||
|
{gridDelta(currentPos, gridPos)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="rs-metric-label">Grid</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rs-metric" style={{ width: '80px', opacity: scrubTime !== null ? 0.3 : 1 }}>
|
||||||
|
<span>{isWinner ? formatDuration(r.duration) : formatGap(r.gap_to_leader)}</span>
|
||||||
|
<span className="rs-metric-label">{isWinner ? 'Time' : 'Gap'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rs-metric" style={{ width: '40px' }}>
|
||||||
|
<span style={{ color: Number(currentPoints) > 0 ? 'var(--text)' : 'var(--text-3)' }}>
|
||||||
|
{currentPoints}
|
||||||
|
</span>
|
||||||
|
<span className="rs-metric-label">Pts</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
frontend/src/components/SessionCoverageDots.tsx
Normal file
17
frontend/src/components/SessionCoverageDots.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { RACE_HUB_DATASETS } from '../lib/coverage'
|
||||||
|
import type { DatasetInfo } from '../types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
datasets: Record<string, DatasetInfo>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionCoverageDots({ datasets }: Props) {
|
||||||
|
return (
|
||||||
|
<span className="coverage-dots" aria-hidden="true">
|
||||||
|
{RACE_HUB_DATASETS.map((key) => {
|
||||||
|
const available = datasets[key]?.status === 'available' || datasets[key]?.status === 'skipped'
|
||||||
|
return <span key={key} className={`coverage-dot ${available ? 'on' : 'off'}`} />
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
frontend/src/components/SourceBadge.tsx
Normal file
32
frontend/src/components/SourceBadge.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
type Source = 'local' | 'partial' | 'none' | 'cancelled'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
source: Source
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SourceBadge({ source, label }: Props) {
|
||||||
|
switch (source) {
|
||||||
|
case 'local':
|
||||||
|
return <span className="badge badge-local">{label ?? 'Local'}</span>
|
||||||
|
case 'partial':
|
||||||
|
return <span className="badge badge-partial">{label ?? 'Partial'}</span>
|
||||||
|
case 'cancelled':
|
||||||
|
return <span className="badge badge-cancelled">{label ?? 'Cancelled'}</span>
|
||||||
|
default:
|
||||||
|
return <span className="badge badge-none">{label ?? 'None'}</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function weekendStatusLabel(source: Source): string {
|
||||||
|
switch (source) {
|
||||||
|
case 'local':
|
||||||
|
return 'Full'
|
||||||
|
case 'partial':
|
||||||
|
return 'Partial'
|
||||||
|
case 'cancelled':
|
||||||
|
return 'Cancelled'
|
||||||
|
default:
|
||||||
|
return 'Missing'
|
||||||
|
}
|
||||||
|
}
|
||||||
98
frontend/src/components/StrategyView.tsx
Normal file
98
frontend/src/components/StrategyView.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import type { EnrichedResult, Stint, PitStop } from '../types'
|
||||||
|
import { compareFinishPosition } from '../utils'
|
||||||
|
import {
|
||||||
|
TyreStintTimeline,
|
||||||
|
type StintTimelineRow,
|
||||||
|
} from './charts/TyreStintTimeline'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
results: EnrichedResult[]
|
||||||
|
stints: Stint[]
|
||||||
|
pit_stops: PitStop[]
|
||||||
|
hasStints: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StrategyView({ results, stints, pit_stops: _pitStops, hasStints }: Props) {
|
||||||
|
if (!hasStints) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="analysis-notice">
|
||||||
|
<strong>Stints not available.</strong> This session does not have ingested
|
||||||
|
tyre compound and stint ranges in <code>/api/v1/race-hub</code>. Strategy
|
||||||
|
charts require per-driver stints: compound, lap_start, lap_end.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{results.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="sec-header" style={{ marginTop: 'var(--s5)' }}>
|
||||||
|
<span className="sec-title">Laps Completed</span>
|
||||||
|
<span className="sec-meta">from results — hint at pit count</span>
|
||||||
|
</div>
|
||||||
|
<table className="data-table" style={{ maxWidth: 360 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="c" style={{ width: 28 }}>P</th>
|
||||||
|
<th>Driver</th>
|
||||||
|
<th className="r">Laps</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{results.map((r) => (
|
||||||
|
<tr key={r.driver_number}>
|
||||||
|
<td className="c mono" style={{ color: 'var(--text-3)' }}>
|
||||||
|
{r.position}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--f-mono)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: r.team_colour ? `#${r.team_colour}` : 'var(--text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.name_acronym || r.driver_number}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="r mono" style={{ color: r.number_of_laps > 0 ? 'var(--text)' : 'var(--text-3)' }}>
|
||||||
|
{r.number_of_laps > 0 ? r.number_of_laps : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedDrivers = [...results].sort((a, b) => {
|
||||||
|
const cmp = compareFinishPosition(a.position, b.position)
|
||||||
|
return cmp !== 0 ? cmp : a.driver_number - b.driver_number
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalLaps = Math.max(
|
||||||
|
...stints.map((s) => s.lap_end),
|
||||||
|
...results.map((r) => r.number_of_laps),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
const timelineRows: StintTimelineRow[] = sortedDrivers.map((driver) => ({
|
||||||
|
label: driver.name_acronym || String(driver.driver_number),
|
||||||
|
color: driver.team_colour ? `#${driver.team_colour}` : '#888',
|
||||||
|
stints: stints
|
||||||
|
.filter((s) => s.driver_number === driver.driver_number)
|
||||||
|
.map((s) => ({
|
||||||
|
compound: s.compound,
|
||||||
|
lapStart: s.lap_start,
|
||||||
|
lapEnd: s.lap_end,
|
||||||
|
isNew: s.tyre_age_at_start === 0,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="strategy-chart">
|
||||||
|
<TyreStintTimeline rows={timelineRows} totalLaps={totalLaps} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
frontend/src/components/TabBar.tsx
Normal file
43
frontend/src/components/TabBar.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
export type Tab =
|
||||||
|
| 'overview'
|
||||||
|
| 'race_story'
|
||||||
|
| 'strategy'
|
||||||
|
| 'compare'
|
||||||
|
| 'lap_data'
|
||||||
|
| 'conditions'
|
||||||
|
| 'race_control'
|
||||||
|
| 'data_status'
|
||||||
|
|
||||||
|
const TABS: { id: Tab; label: string }[] = [
|
||||||
|
{ id: 'overview', label: 'Overview' },
|
||||||
|
{ id: 'race_story', label: 'Race Story' },
|
||||||
|
{ id: 'strategy', label: 'Strategy' },
|
||||||
|
{ id: 'compare', label: 'Compare' },
|
||||||
|
{ id: 'lap_data', label: 'Lap Data' },
|
||||||
|
{ id: 'conditions', label: 'Conditions' },
|
||||||
|
{ id: 'race_control', label: 'Race Control' },
|
||||||
|
{ id: 'data_status', label: 'Data Status' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
active: Tab
|
||||||
|
onChange: (tab: Tab) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabBar({ active, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="tab-bar" role="tablist">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active === t.id}
|
||||||
|
className={`tab-btn${active === t.id ? ' active' : ''}`}
|
||||||
|
onClick={() => onChange(t.id)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
98
frontend/src/components/WeatherView.tsx
Normal file
98
frontend/src/components/WeatherView.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import type { WeatherSample } from '../types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
weather: WeatherSample[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function avg(values: number[]): number {
|
||||||
|
if (values.length === 0) return 0
|
||||||
|
return values.reduce((sum, val) => sum + val, 0) / values.length
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number, digits = 1): string {
|
||||||
|
return Number.isFinite(value) ? value.toFixed(digits) : '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(date: string): string {
|
||||||
|
if (!date) return '—'
|
||||||
|
const parsed = new Date(date)
|
||||||
|
if (Number.isNaN(parsed.getTime())) return date
|
||||||
|
return parsed.toLocaleTimeString('en-GB', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeatherView({ weather }: Props) {
|
||||||
|
if (weather.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="missing-notice">
|
||||||
|
Weather samples not ingested. Run <code>box-box --ingest-session <key></code>{' '}
|
||||||
|
to load this dataset.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [...weather].sort((a, b) => a.date.localeCompare(b.date))
|
||||||
|
const latest = rows[rows.length - 1]
|
||||||
|
const rainfallSamples = rows.filter((sample) => sample.rainfall > 0).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="weather-view">
|
||||||
|
<table className="data-table" style={{ maxWidth: 520, marginBottom: 'var(--s5)' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Summary</th>
|
||||||
|
<th className="r">Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Latest sample</td>
|
||||||
|
<td className="r">{formatTime(latest.date)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Avg air / track</td>
|
||||||
|
<td className="r">
|
||||||
|
{formatNumber(avg(rows.map((sample) => sample.air_temperature)))}C /{' '}
|
||||||
|
{formatNumber(avg(rows.map((sample) => sample.track_temperature)))}C
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Rain samples</td>
|
||||||
|
<td className="r">{rainfallSamples}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div className="scroll-x">
|
||||||
|
<table className="data-table" style={{ minWidth: 560 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th className="r">Air</th>
|
||||||
|
<th className="r">Track</th>
|
||||||
|
<th className="r hide-mobile">Humidity</th>
|
||||||
|
<th className="r hide-mobile">Rain</th>
|
||||||
|
<th className="r hide-mobile">Wind</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.slice(-12).map((sample) => (
|
||||||
|
<tr key={sample.date}>
|
||||||
|
<td className="mono" style={{ color: 'var(--text-3)' }}>
|
||||||
|
{formatTime(sample.date)}
|
||||||
|
</td>
|
||||||
|
<td className="r">{formatNumber(sample.air_temperature)}C</td>
|
||||||
|
<td className="r">{formatNumber(sample.track_temperature)}C</td>
|
||||||
|
<td className="r hide-mobile">{formatNumber(sample.humidity, 0)}%</td>
|
||||||
|
<td className="r hide-mobile">{formatNumber(sample.rainfall)}</td>
|
||||||
|
<td className="r hide-mobile">{formatNumber(sample.wind_speed)} m/s</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
140
frontend/src/components/WeekendSwitcher.tsx
Normal file
140
frontend/src/components/WeekendSwitcher.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
|
||||||
|
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
|
||||||
|
import { countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentMeetingKey?: number
|
||||||
|
currentSessionKey?: number
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose }: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons })
|
||||||
|
const [year, setYear] = useState<number | null>(null)
|
||||||
|
const [openMeetingKey, setOpenMeetingKey] = useState<number | null>(
|
||||||
|
currentMeetingKey ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (year == null && seasonsQuery.data?.length) {
|
||||||
|
setYear(seasonsQuery.data[0])
|
||||||
|
}
|
||||||
|
}, [seasonsQuery.data, year])
|
||||||
|
|
||||||
|
const meetingsQuery = useQuery({
|
||||||
|
queryKey: ['meetings', year],
|
||||||
|
queryFn: () => fetchLocalMeetings(year!),
|
||||||
|
enabled: year != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const weekendQuery = useQuery({
|
||||||
|
queryKey: ['weekend', openMeetingKey],
|
||||||
|
queryFn: () => fetchWeekend(openMeetingKey!),
|
||||||
|
enabled: openMeetingKey != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const seasons = seasonsQuery.data ?? []
|
||||||
|
const meetings = meetingsQuery.data ?? []
|
||||||
|
const weekend = weekendQuery.data
|
||||||
|
|
||||||
|
function openSession(sessionKey: number) {
|
||||||
|
navigate({ to: '/race-hub', search: { session_key: sessionKey } })
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rh-switcher" data-testid="rh-switcher">
|
||||||
|
<div className="rh-switcher-head">
|
||||||
|
<span className="sec-title">Switch Weekend</span>
|
||||||
|
<div className="rh-switcher-years">
|
||||||
|
{seasons.map((y) => (
|
||||||
|
<button
|
||||||
|
key={y}
|
||||||
|
type="button"
|
||||||
|
className={`rh-switcher-year${y === year ? ' active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setYear(y)
|
||||||
|
setOpenMeetingKey(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{y}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="rh-switcher-close" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{meetingsQuery.isLoading && (
|
||||||
|
<div className="rh-switcher-empty">loading meetings…</div>
|
||||||
|
)}
|
||||||
|
{!meetingsQuery.isLoading && meetings.length === 0 && (
|
||||||
|
<div className="rh-switcher-empty">No meetings ingested for {year}.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{meetings.length > 0 && (
|
||||||
|
<div className="rh-switcher-grid">
|
||||||
|
{meetings.map((m) => {
|
||||||
|
const expanded = m.meeting_key === openMeetingKey
|
||||||
|
const isCurrent = m.meeting_key === currentMeetingKey
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={m.meeting_key}
|
||||||
|
className={`rh-switcher-mtg${expanded ? ' expanded' : ''}${isCurrent ? ' current' : ''}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rh-switcher-mtg-head"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={() =>
|
||||||
|
setOpenMeetingKey((prev) => (prev === m.meeting_key ? null : m.meeting_key))
|
||||||
|
}
|
||||||
|
data-testid={`rh-switcher-meeting-${m.meeting_key}`}
|
||||||
|
>
|
||||||
|
<span className="rh-switcher-decal mono">{countryDecal(m)}</span>
|
||||||
|
<span className="rh-switcher-mtg-name">{m.meeting_name}</span>
|
||||||
|
<span className="rh-switcher-mtg-meta mono">{formatGpDateRange(m)}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="rh-switcher-sessions">
|
||||||
|
{weekendQuery.isLoading && (
|
||||||
|
<div className="rh-switcher-empty">loading sessions…</div>
|
||||||
|
)}
|
||||||
|
{weekend && weekend.meeting_key === m.meeting_key &&
|
||||||
|
weekend.sessions.map(({ session, source, datasets }) => {
|
||||||
|
const active = session.session_key === currentSessionKey
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={session.session_key}
|
||||||
|
type="button"
|
||||||
|
className={`rh-switcher-session${active ? ' active' : ''}`}
|
||||||
|
onClick={() => openSession(session.session_key)}
|
||||||
|
data-testid={`rh-switcher-session-${session.session_key}`}
|
||||||
|
>
|
||||||
|
<span className="rh-switcher-sess-abbrev mono">
|
||||||
|
{sessionTypeAbbrev(session.session_type, session.session_name)}
|
||||||
|
</span>
|
||||||
|
<span className="rh-switcher-sess-name">{session.session_name}</span>
|
||||||
|
<span className="rh-switcher-sess-cov mono">
|
||||||
|
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
|
||||||
|
{formatCoverageHint(datasets)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
282
frontend/src/components/charts/DeltaTimeGraph.tsx
Normal file
282
frontend/src/components/charts/DeltaTimeGraph.tsx
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
computeCumulativeDeltas,
|
||||||
|
deltaPolylineSegments,
|
||||||
|
formatDeltaSeconds,
|
||||||
|
type DeltaSeries,
|
||||||
|
type DriverDeltaResult,
|
||||||
|
} from '../../lib/delta'
|
||||||
|
import '../../styles/delta-graph.css'
|
||||||
|
|
||||||
|
export type { DeltaSeries }
|
||||||
|
|
||||||
|
export interface DeltaTimeGraphProps {
|
||||||
|
series: DeltaSeries[]
|
||||||
|
referenceLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const W = 640
|
||||||
|
const H = 220
|
||||||
|
const PL = 44
|
||||||
|
const PR = 16
|
||||||
|
const PT = 12
|
||||||
|
const PB = 28
|
||||||
|
|
||||||
|
function lapCount(series: ReadonlyArray<DeltaSeries>): number {
|
||||||
|
return series.reduce((max, s) => Math.max(max, s.lapTimes.length), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function yExtent(drivers: ReadonlyArray<DriverDeltaResult>): { min: number; max: number } {
|
||||||
|
let min = 0
|
||||||
|
let max = 0
|
||||||
|
for (const driver of drivers) {
|
||||||
|
for (const delta of driver.deltas) {
|
||||||
|
if (delta === null) continue
|
||||||
|
min = Math.min(min, delta)
|
||||||
|
max = Math.max(max, delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (min === max) {
|
||||||
|
const pad = 1
|
||||||
|
return { min: min - pad, max: max + pad }
|
||||||
|
}
|
||||||
|
const span = max - min
|
||||||
|
const pad = span * 0.08
|
||||||
|
return { min: min - pad, max: max + pad }
|
||||||
|
}
|
||||||
|
|
||||||
|
function niceYTicks(min: number, max: number): number[] {
|
||||||
|
const span = max - min
|
||||||
|
if (span <= 0) return [0]
|
||||||
|
const rough = span / 4
|
||||||
|
const magnitude = Math.pow(10, Math.floor(Math.log10(rough)))
|
||||||
|
const step = Math.ceil(rough / magnitude) * magnitude
|
||||||
|
const ticks: number[] = []
|
||||||
|
const start = Math.ceil(min / step) * step
|
||||||
|
for (let v = start; v <= max + step * 0.01; v += step) {
|
||||||
|
ticks.push(Number(v.toFixed(6)))
|
||||||
|
}
|
||||||
|
if (!ticks.some((t) => Math.abs(t) < step * 0.01)) {
|
||||||
|
ticks.push(0)
|
||||||
|
ticks.sort((a, b) => a - b)
|
||||||
|
}
|
||||||
|
return ticks
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeltaTimeGraph({ series, referenceLabel }: DeltaTimeGraphProps) {
|
||||||
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
|
const [hoverLap, setHoverLap] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const drivers = useMemo(
|
||||||
|
() => computeCumulativeDeltas(series, referenceLabel),
|
||||||
|
[series, referenceLabel],
|
||||||
|
)
|
||||||
|
|
||||||
|
const laps = useMemo(() => lapCount(series), [series])
|
||||||
|
const plotW = W - PL - PR
|
||||||
|
const plotH = H - PT - PB
|
||||||
|
const { min: yMin, max: yMax } = useMemo(() => yExtent(drivers), [drivers])
|
||||||
|
const yTicks = useMemo(() => niceYTicks(yMin, yMax), [yMin, yMax])
|
||||||
|
|
||||||
|
const toX = useCallback(
|
||||||
|
(lapIndex: number) => {
|
||||||
|
if (laps <= 1) return PL + plotW / 2
|
||||||
|
return PL + (lapIndex / (laps - 1)) * plotW
|
||||||
|
},
|
||||||
|
[laps, plotW],
|
||||||
|
)
|
||||||
|
|
||||||
|
const toY = useCallback(
|
||||||
|
(delta: number) => {
|
||||||
|
const span = yMax - yMin || 1
|
||||||
|
return PT + ((delta - yMin) / span) * plotH
|
||||||
|
},
|
||||||
|
[yMin, yMax, plotH],
|
||||||
|
)
|
||||||
|
|
||||||
|
const lapTickNumbers = useMemo(() => {
|
||||||
|
const ticks: number[] = []
|
||||||
|
for (let lap = 5; lap <= laps; lap += 5) {
|
||||||
|
ticks.push(lap)
|
||||||
|
}
|
||||||
|
return ticks
|
||||||
|
}, [laps])
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback(
|
||||||
|
(e: React.PointerEvent<SVGRectElement> | React.MouseEvent<SVGRectElement>) => {
|
||||||
|
if (!svgRef.current || laps === 0) return
|
||||||
|
const rect = svgRef.current.getBoundingClientRect()
|
||||||
|
if (rect.width <= 0) return
|
||||||
|
const x = ((e.clientX - rect.left) / rect.width) * W
|
||||||
|
const frac = Math.max(0, Math.min(1, (x - PL) / plotW))
|
||||||
|
const lapIndex = laps <= 1 ? 0 : Math.round(frac * (laps - 1))
|
||||||
|
if (!Number.isFinite(lapIndex)) return
|
||||||
|
setHoverLap(Math.max(0, Math.min(laps - 1, lapIndex)))
|
||||||
|
},
|
||||||
|
[laps, plotW],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handlePointerLeave = useCallback(() => setHoverLap(null), [])
|
||||||
|
|
||||||
|
if (series.length === 0 || laps === 0) {
|
||||||
|
return (
|
||||||
|
<div className="delta-graph" data-testid="delta-time-graph-empty">
|
||||||
|
<p className="delta-graph-empty">No lap data to compare.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drivers.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="delta-graph" data-testid="delta-time-graph-empty">
|
||||||
|
<p className="delta-graph-empty">Select at least two drivers to compare.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoverX = hoverLap !== null ? toX(hoverLap) : null
|
||||||
|
const tooltipRows = hoverLap !== null
|
||||||
|
? drivers
|
||||||
|
.map((d) => {
|
||||||
|
const delta = d.deltas[hoverLap]
|
||||||
|
if (delta == null) return null
|
||||||
|
return { label: d.label, color: d.color, delta }
|
||||||
|
})
|
||||||
|
.filter((row): row is { label: string; color: string; delta: number } => row !== null)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const tooltipH = 18 + tooltipRows.length * 14
|
||||||
|
const tooltipW = 120
|
||||||
|
const tooltipX = hoverX !== null ? Math.min(Math.max(hoverX + 8, PL), W - PR - tooltipW) : 0
|
||||||
|
const tooltipY = PT
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="delta-graph" data-testid="delta-time-graph">
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
className="delta-graph-svg"
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
role="img"
|
||||||
|
aria-label="Cumulative delta time chart"
|
||||||
|
>
|
||||||
|
{yTicks.map((tick) => (
|
||||||
|
<g key={`y-${tick}`}>
|
||||||
|
<line
|
||||||
|
x1={PL}
|
||||||
|
x2={W - PR}
|
||||||
|
y1={toY(tick)}
|
||||||
|
y2={toY(tick)}
|
||||||
|
className={Math.abs(tick) < 1e-9 ? 'delta-graph-zero-line' : 'delta-graph-grid-line'}
|
||||||
|
data-testid={Math.abs(tick) < 1e-9 ? 'delta-zero-line' : undefined}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={PL - 6}
|
||||||
|
y={toY(tick) + 3}
|
||||||
|
textAnchor="end"
|
||||||
|
className="delta-graph-axis-label"
|
||||||
|
>
|
||||||
|
{formatDeltaSeconds(tick)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{lapTickNumbers.map((lap) => {
|
||||||
|
const x = toX(lap - 1)
|
||||||
|
return (
|
||||||
|
<g key={`lap-${lap}`}>
|
||||||
|
<line
|
||||||
|
x1={x}
|
||||||
|
x2={x}
|
||||||
|
y1={H - PB}
|
||||||
|
y2={H - PB + 4}
|
||||||
|
className="delta-graph-grid-line"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={x}
|
||||||
|
y={H - PB + 16}
|
||||||
|
textAnchor="middle"
|
||||||
|
className="delta-graph-axis-label"
|
||||||
|
>
|
||||||
|
{lap}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{drivers.map((driver) => {
|
||||||
|
const segments = deltaPolylineSegments(driver.deltas, (lapIndex, delta) =>
|
||||||
|
`${toX(lapIndex).toFixed(1)},${toY(delta).toFixed(1)}`,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<g key={driver.label} data-testid={`delta-line-${driver.label}`}>
|
||||||
|
{segments.map((points, i) => (
|
||||||
|
<polyline
|
||||||
|
key={`${driver.label}-${i}`}
|
||||||
|
points={points}
|
||||||
|
className="delta-graph-driver-line"
|
||||||
|
stroke={driver.color}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{hoverX !== null && (
|
||||||
|
<>
|
||||||
|
<line
|
||||||
|
x1={hoverX}
|
||||||
|
x2={hoverX}
|
||||||
|
y1={PT}
|
||||||
|
y2={H - PB}
|
||||||
|
className="delta-graph-crosshair"
|
||||||
|
data-testid="delta-crosshair"
|
||||||
|
/>
|
||||||
|
{tooltipRows.length > 0 && (
|
||||||
|
<g className="delta-graph-tooltip" data-testid="delta-tooltip">
|
||||||
|
<rect
|
||||||
|
x={tooltipX}
|
||||||
|
y={tooltipY}
|
||||||
|
width={tooltipW}
|
||||||
|
height={tooltipH}
|
||||||
|
rx={4}
|
||||||
|
className="delta-graph-tooltip-bg"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={tooltipX + 8}
|
||||||
|
y={tooltipY + 14}
|
||||||
|
className="delta-graph-tooltip-title"
|
||||||
|
>
|
||||||
|
Lap {hoverLap! + 1}
|
||||||
|
</text>
|
||||||
|
{tooltipRows.map((row, i) => (
|
||||||
|
<text
|
||||||
|
key={row.label}
|
||||||
|
x={tooltipX + 8}
|
||||||
|
y={tooltipY + 28 + i * 14}
|
||||||
|
className="delta-graph-tooltip-row"
|
||||||
|
fill={row.color}
|
||||||
|
>
|
||||||
|
{row.label} {formatDeltaSeconds(row.delta)}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<rect
|
||||||
|
x={PL}
|
||||||
|
y={PT}
|
||||||
|
width={plotW}
|
||||||
|
height={plotH}
|
||||||
|
fill="transparent"
|
||||||
|
className="delta-graph-hover-layer"
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onMouseMove={handlePointerMove}
|
||||||
|
onPointerLeave={handlePointerLeave}
|
||||||
|
onMouseLeave={handlePointerLeave}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
194
frontend/src/components/charts/TelemetryTraceChart.tsx
Normal file
194
frontend/src/components/charts/TelemetryTraceChart.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { useMemo, useRef, useState } from 'react'
|
||||||
|
import '../../styles/telemetry-trace.css'
|
||||||
|
|
||||||
|
export interface TelemetryTraceSample {
|
||||||
|
speed: number
|
||||||
|
throttle: number
|
||||||
|
brake: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelemetryTraceSeries {
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
samples: TelemetryTraceSample[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TelemetryTraceChannel = 'speed' | 'throttle' | 'brake'
|
||||||
|
|
||||||
|
export interface TelemetryTraceChartProps {
|
||||||
|
series: TelemetryTraceSeries[]
|
||||||
|
channels?: TelemetryTraceChannel[]
|
||||||
|
/** Height of each channel panel in SVG units (viewBox space). */
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIEW_WIDTH = 800
|
||||||
|
const PANEL_GAP = 18
|
||||||
|
const PAD_TOP = 6
|
||||||
|
const PAD_BOTTOM = 6
|
||||||
|
|
||||||
|
const CHANNEL_LABELS: Record<TelemetryTraceChannel, string> = {
|
||||||
|
speed: 'Speed',
|
||||||
|
throttle: 'Throttle',
|
||||||
|
brake: 'Brake',
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v))
|
||||||
|
|
||||||
|
function channelValue(sample: TelemetryTraceSample, channel: TelemetryTraceChannel): number {
|
||||||
|
const raw = sample[channel]
|
||||||
|
// Throttle/brake are percentages; clamp so out-of-range API values can't
|
||||||
|
// draw outside the panel. Speed is clamped to >= 0.
|
||||||
|
if (channel === 'speed') return Math.max(0, raw)
|
||||||
|
return clamp(raw, 0, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value: number, channel: TelemetryTraceChannel): string {
|
||||||
|
if (channel === 'speed') return `${Math.round(value)} km/h`
|
||||||
|
return `${Math.round(value)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TelemetryTraceChart({
|
||||||
|
series,
|
||||||
|
channels = ['speed', 'throttle', 'brake'],
|
||||||
|
height = 110,
|
||||||
|
}: TelemetryTraceChartProps) {
|
||||||
|
const svgRef = useRef<SVGSVGElement | null>(null)
|
||||||
|
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Series are index-aligned; mismatched lengths are clamped to the shortest
|
||||||
|
// series so every drawn x has a value for every driver.
|
||||||
|
const sampleCount = useMemo(
|
||||||
|
() => (series.length === 0 ? 0 : Math.min(...series.map((s) => s.samples.length))),
|
||||||
|
[series],
|
||||||
|
)
|
||||||
|
|
||||||
|
const speedMax = useMemo(() => {
|
||||||
|
let max = 0
|
||||||
|
for (const s of series) {
|
||||||
|
for (let i = 0; i < sampleCount; i++) {
|
||||||
|
max = Math.max(max, channelValue(s.samples[i], 'speed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return max > 0 ? max : 1
|
||||||
|
}, [series, sampleCount])
|
||||||
|
|
||||||
|
if (series.length === 0 || sampleCount === 0 || channels.length === 0) {
|
||||||
|
return <div className="telemetry-trace-empty">No telemetry data</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp a stale hover index in case the series prop shrank between renders.
|
||||||
|
const hover = hoverIndex === null ? null : Math.min(hoverIndex, sampleCount - 1)
|
||||||
|
|
||||||
|
const panelHeight = height
|
||||||
|
const totalHeight = channels.length * panelHeight + (channels.length - 1) * PANEL_GAP
|
||||||
|
const xAt = (i: number) => (i / Math.max(1, sampleCount - 1)) * VIEW_WIDTH
|
||||||
|
|
||||||
|
const channelMax = (channel: TelemetryTraceChannel) => (channel === 'speed' ? speedMax : 100)
|
||||||
|
|
||||||
|
const yAt = (value: number, channel: TelemetryTraceChannel, panelTop: number) => {
|
||||||
|
const usable = panelHeight - PAD_TOP - PAD_BOTTOM
|
||||||
|
const frac = channelValue({ speed: value, throttle: value, brake: value }, channel) / channelMax(channel)
|
||||||
|
return panelTop + PAD_TOP + (1 - frac) * usable
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||||
|
const rect = svgRef.current?.getBoundingClientRect()
|
||||||
|
if (!rect || rect.width === 0) {
|
||||||
|
setHoverIndex(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const frac = clamp((e.clientX - rect.left) / rect.width, 0, 1)
|
||||||
|
setHoverIndex(clamp(Math.round(frac * (sampleCount - 1)), 0, sampleCount - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="telemetry-trace" data-testid="telemetry-trace">
|
||||||
|
<div className="telemetry-trace-legend">
|
||||||
|
{series.map((s) => (
|
||||||
|
<span key={s.label} className="telemetry-trace-legend-item">
|
||||||
|
<span className="telemetry-trace-swatch" style={{ background: s.color }} aria-hidden="true" />
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
className="telemetry-trace-svg"
|
||||||
|
viewBox={`0 0 ${VIEW_WIDTH} ${totalHeight}`}
|
||||||
|
role="img"
|
||||||
|
aria-label="Telemetry trace chart"
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseLeave={() => setHoverIndex(null)}
|
||||||
|
>
|
||||||
|
{channels.map((channel, panelIdx) => {
|
||||||
|
const panelTop = panelIdx * (panelHeight + PANEL_GAP)
|
||||||
|
return (
|
||||||
|
<g key={channel} className="telemetry-trace-panel" data-channel={channel}>
|
||||||
|
<rect
|
||||||
|
className="telemetry-trace-panel-bg"
|
||||||
|
x={0}
|
||||||
|
y={panelTop}
|
||||||
|
width={VIEW_WIDTH}
|
||||||
|
height={panelHeight}
|
||||||
|
/>
|
||||||
|
<text className="telemetry-trace-panel-title" x={6} y={panelTop + 13}>
|
||||||
|
{CHANNEL_LABELS[channel]}
|
||||||
|
</text>
|
||||||
|
<text className="telemetry-trace-axis-max" x={VIEW_WIDTH - 6} y={panelTop + 13} textAnchor="end">
|
||||||
|
{channel === 'speed' ? `${Math.round(speedMax)} km/h` : '100%'}
|
||||||
|
</text>
|
||||||
|
{series.map((s) => {
|
||||||
|
const points = Array.from({ length: sampleCount }, (_, i) => {
|
||||||
|
const x = xAt(i)
|
||||||
|
const y = yAt(s.samples[i][channel], channel, panelTop)
|
||||||
|
return `${x.toFixed(2)},${y.toFixed(2)}`
|
||||||
|
}).join(' ')
|
||||||
|
return (
|
||||||
|
<polyline
|
||||||
|
key={s.label}
|
||||||
|
className="telemetry-trace-line"
|
||||||
|
data-channel={channel}
|
||||||
|
data-series={s.label}
|
||||||
|
points={points}
|
||||||
|
fill="none"
|
||||||
|
stroke={s.color}
|
||||||
|
strokeWidth={1.6}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{hover !== null && (
|
||||||
|
<line
|
||||||
|
className="telemetry-trace-crosshair"
|
||||||
|
data-testid="telemetry-trace-crosshair"
|
||||||
|
x1={xAt(hover)}
|
||||||
|
y1={0}
|
||||||
|
x2={xAt(hover)}
|
||||||
|
y2={totalHeight}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
{hover !== null && (
|
||||||
|
<div className="telemetry-trace-readout" data-testid="telemetry-trace-readout">
|
||||||
|
<span className="telemetry-trace-readout-index">Sample {hover}</span>
|
||||||
|
{series.map((s) => (
|
||||||
|
<span key={s.label} className="telemetry-trace-readout-driver">
|
||||||
|
<span className="telemetry-trace-swatch" style={{ background: s.color }} aria-hidden="true" />
|
||||||
|
<span className="telemetry-trace-readout-label">{s.label}</span>
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<span key={channel} className="telemetry-trace-readout-value">
|
||||||
|
{formatValue(channelValue(s.samples[hover], channel), channel)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
182
frontend/src/components/charts/TyreStintTimeline.tsx
Normal file
182
frontend/src/components/charts/TyreStintTimeline.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { compoundClass } from '../../lib/live'
|
||||||
|
import '../../styles/stint-timeline.css'
|
||||||
|
|
||||||
|
export interface StintTimelineStint {
|
||||||
|
compound: string
|
||||||
|
lapStart: number
|
||||||
|
lapEnd: number
|
||||||
|
isNew?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StintTimelineRow {
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
stints: StintTimelineStint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TyreStintTimelineProps {
|
||||||
|
rows: StintTimelineRow[]
|
||||||
|
totalLaps: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const SVG_W = 640
|
||||||
|
const LEFT = 48
|
||||||
|
const RIGHT = 12
|
||||||
|
const ROW_H = 28
|
||||||
|
const BAR_H = 14
|
||||||
|
const BAR_Y = 7
|
||||||
|
const AXIS_H = 20
|
||||||
|
const BAR_W = SVG_W - LEFT - RIGHT
|
||||||
|
|
||||||
|
const COMPOUND_ORDER = ['SOFT', 'MEDIUM', 'HARD', 'INTERMEDIATE', 'WET'] as const
|
||||||
|
|
||||||
|
function compoundLabel(compound: string): string {
|
||||||
|
const upper = compound.toUpperCase()
|
||||||
|
if (upper === 'INTERMEDIATE') return 'Intermediate'
|
||||||
|
return upper.charAt(0) + upper.slice(1).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stintLength(stint: StintTimelineStint): number {
|
||||||
|
return stint.lapEnd - stint.lapStart + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function stintTitle(stint: StintTimelineStint): string {
|
||||||
|
const length = stintLength(stint)
|
||||||
|
return `${compoundLabel(stint.compound)} · L${stint.lapStart}–${stint.lapEnd} · ${length} lap${length === 1 ? '' : 's'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function lapX(lap: number, totalLaps: number): number {
|
||||||
|
return LEFT + (lap / totalLaps) * BAR_W
|
||||||
|
}
|
||||||
|
|
||||||
|
function stintBarX(stint: StintTimelineStint, totalLaps: number): number {
|
||||||
|
return LEFT + ((stint.lapStart - 1) / totalLaps) * BAR_W
|
||||||
|
}
|
||||||
|
|
||||||
|
function stintBarW(stint: StintTimelineStint, totalLaps: number): number {
|
||||||
|
return Math.max(2, (stintLength(stint) / totalLaps) * BAR_W)
|
||||||
|
}
|
||||||
|
|
||||||
|
function axisTicks(totalLaps: number): number[] {
|
||||||
|
const ticks: number[] = []
|
||||||
|
for (let lap = 0; lap <= totalLaps; lap += 10) {
|
||||||
|
ticks.push(lap)
|
||||||
|
}
|
||||||
|
return ticks
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectUsedCompounds(rows: StintTimelineRow[]): string[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const stint of row.stints) {
|
||||||
|
seen.add(stint.compound.toUpperCase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ordered = COMPOUND_ORDER.filter((c) => seen.has(c))
|
||||||
|
const extras = [...seen]
|
||||||
|
.filter((c) => !COMPOUND_ORDER.includes(c as (typeof COMPOUND_ORDER)[number]))
|
||||||
|
.sort()
|
||||||
|
return [...ordered, ...extras]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TyreStintTimeline({ rows, totalLaps }: TyreStintTimelineProps) {
|
||||||
|
const safeTotal = Math.max(totalLaps, 1)
|
||||||
|
const usedCompounds = collectUsedCompounds(rows)
|
||||||
|
const ticks = axisTicks(safeTotal)
|
||||||
|
const chartH = rows.length * ROW_H
|
||||||
|
const svgH = chartH + AXIS_H + 4
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="stint-timeline" data-testid="stint-timeline-empty">
|
||||||
|
<div className="stint-timeline__empty">No stint data to display.</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stint-timeline" data-testid="stint-timeline">
|
||||||
|
<div className="stint-timeline__scroll">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${SVG_W} ${svgH}`}
|
||||||
|
className="stint-timeline__svg"
|
||||||
|
role="img"
|
||||||
|
aria-label="Tyre stint timeline"
|
||||||
|
>
|
||||||
|
{rows.map((row, i) => {
|
||||||
|
const rowY = i * ROW_H
|
||||||
|
return (
|
||||||
|
<g key={`${row.label}-${i}`} transform={`translate(0,${rowY})`}>
|
||||||
|
<text
|
||||||
|
x={LEFT - 5}
|
||||||
|
y={BAR_Y + BAR_H / 2 + 4}
|
||||||
|
textAnchor="end"
|
||||||
|
fill={row.color}
|
||||||
|
className="stint-timeline__label"
|
||||||
|
>
|
||||||
|
{row.label}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{row.stints.map((stint, si) => (
|
||||||
|
<rect
|
||||||
|
key={si}
|
||||||
|
x={stintBarX(stint, safeTotal)}
|
||||||
|
y={BAR_Y}
|
||||||
|
width={stintBarW(stint, safeTotal)}
|
||||||
|
height={BAR_H}
|
||||||
|
rx={3}
|
||||||
|
className={`stint-timeline__bar ${compoundClass(stint.compound)}${stint.isNew ? ' stint-timeline__bar--new' : ''}`}
|
||||||
|
>
|
||||||
|
<title>{stintTitle(stint)}</title>
|
||||||
|
</rect>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<g transform={`translate(0,${chartH})`}>
|
||||||
|
<line
|
||||||
|
x1={LEFT}
|
||||||
|
x2={LEFT + BAR_W}
|
||||||
|
y1={0}
|
||||||
|
y2={0}
|
||||||
|
className="stint-timeline__axis-line"
|
||||||
|
/>
|
||||||
|
{ticks.map((lap) => (
|
||||||
|
<g key={lap}>
|
||||||
|
<line
|
||||||
|
x1={lapX(lap, safeTotal)}
|
||||||
|
x2={lapX(lap, safeTotal)}
|
||||||
|
y1={0}
|
||||||
|
y2={4}
|
||||||
|
className="stint-timeline__axis-line"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={lapX(lap, safeTotal)}
|
||||||
|
y={14}
|
||||||
|
textAnchor="middle"
|
||||||
|
className="stint-timeline__axis-tick"
|
||||||
|
>
|
||||||
|
{lap}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stint-timeline__legend">
|
||||||
|
{usedCompounds.map((compound) => (
|
||||||
|
<span key={compound} className="stint-timeline__legend-item">
|
||||||
|
<span
|
||||||
|
className={`stint-timeline__legend-swatch ${compoundClass(compound)}`}
|
||||||
|
data-testid={`legend-${compound.toLowerCase()}`}
|
||||||
|
/>
|
||||||
|
{compoundLabel(compound)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span className="stint-timeline__meta">{safeTotal} laps</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
frontend/src/components/live/BattleChips.tsx
Normal file
21
frontend/src/components/live/BattleChips.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Battle } from '../../lib/battles'
|
||||||
|
import { battleLabel } from '../../lib/battles'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
battles: Battle[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BattleChips({ battles }: Props) {
|
||||||
|
if (battles.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="battle-chips" data-testid="battle-chips">
|
||||||
|
<span className="battle-chips-label">Battles</span>
|
||||||
|
{battles.map((battle) => (
|
||||||
|
<span className="battle-chip mono" key={battle.drivers.map((d) => d.racingNumber).join('-')}>
|
||||||
|
{battleLabel(battle)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
72
frontend/src/components/live/EventRail.tsx
Normal file
72
frontend/src/components/live/EventRail.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useAutoAnimate } from '@formkit/auto-animate/react'
|
||||||
|
import {
|
||||||
|
Flag,
|
||||||
|
LogOut,
|
||||||
|
Megaphone,
|
||||||
|
OctagonX,
|
||||||
|
Swords,
|
||||||
|
Timer,
|
||||||
|
TrendingDown,
|
||||||
|
TrendingUp,
|
||||||
|
Wrench,
|
||||||
|
Zap,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import type { LiveDriverInfo } from '../../types'
|
||||||
|
import type { LiveEvent, LiveEventKind } from '../../lib/events'
|
||||||
|
import { teamColor } from '../../utils'
|
||||||
|
import '../../styles/event-rail.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
events: LiveEvent[]
|
||||||
|
driverInfo?: Record<string, LiveDriverInfo>
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_ICONS: Record<LiveEventKind, LucideIcon> = {
|
||||||
|
overtake: Swords,
|
||||||
|
'position-gain': TrendingUp,
|
||||||
|
'position-loss': TrendingDown,
|
||||||
|
'pit-in': Wrench,
|
||||||
|
'pit-out': LogOut,
|
||||||
|
'personal-best': Timer,
|
||||||
|
'fastest-lap': Zap,
|
||||||
|
retirement: OctagonX,
|
||||||
|
'track-status': Flag,
|
||||||
|
'race-control': Megaphone,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventRail({ events, driverInfo }: Props) {
|
||||||
|
const [listRef] = useAutoAnimate<HTMLDivElement>()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="event-rail panel-glass" data-testid="event-rail">
|
||||||
|
<div className="sec-header sticky-header">
|
||||||
|
<span className="sec-title">What Just Happened</span>
|
||||||
|
{events.length > 0 && <span className="sec-meta">{events.length} events</span>}
|
||||||
|
</div>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<div className="missing-notice">Events will appear here as the session unfolds.</div>
|
||||||
|
) : (
|
||||||
|
<div className="event-rail-list" ref={listRef}>
|
||||||
|
{events.map((event) => {
|
||||||
|
const Icon = KIND_ICONS[event.kind] ?? Megaphone
|
||||||
|
const colour = driverInfo?.[event.racingNumbers[0]]?.TeamColour
|
||||||
|
const accent = event.racingNumbers.length > 0 ? teamColor(colour) : 'var(--border-2)'
|
||||||
|
return (
|
||||||
|
<div className={`event-rail-row event-kind-${event.kind}`} key={event.id} style={{ borderLeftColor: accent }}>
|
||||||
|
<span className="event-rail-icon" aria-hidden="true">
|
||||||
|
<Icon size={14} />
|
||||||
|
</span>
|
||||||
|
<div className="event-rail-body">
|
||||||
|
<span className="event-rail-headline">{event.headline}</span>
|
||||||
|
{event.detail && <span className="event-rail-detail">{event.detail}</span>}
|
||||||
|
</div>
|
||||||
|
{event.lap > 0 && <span className="event-rail-lap">L{event.lap}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
frontend/src/components/live/GapSparkline.tsx
Normal file
44
frontend/src/components/live/GapSparkline.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { gapTrend, sparklinePoints } from '../../lib/gapHistory'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
samples: number[] | undefined
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GapSparkline({ samples, width = 56, height = 14 }: Props) {
|
||||||
|
if (!samples || samples.length < 2) {
|
||||||
|
return <span className="gap-spark gap-spark-empty">·</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
const trend = gapTrend(samples)
|
||||||
|
const points = sparklinePoints(samples, width, height)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`gap-spark trend-${trend ?? 'steady'}`} data-testid="gap-spark">
|
||||||
|
<svg
|
||||||
|
className="gap-spark-svg"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
<polyline
|
||||||
|
points={points}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{trend === 'closing' && (
|
||||||
|
<span className="trend-arrow trend-arrow-closing" title="Gap closing">▼</span>
|
||||||
|
)}
|
||||||
|
{trend === 'opening' && (
|
||||||
|
<span className="trend-arrow trend-arrow-opening" title="Gap opening">▲</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
63
frontend/src/components/live/PinnedDrivers.tsx
Normal file
63
frontend/src/components/live/PinnedDrivers.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { teamColor } from '../../utils'
|
||||||
|
import type { LiveTimingRow } from '../../lib/live'
|
||||||
|
import { driverCode, tyreClass, tyreLabel } from '../../lib/live'
|
||||||
|
import type { GapHistoryMap } from '../../lib/gapHistory'
|
||||||
|
import { GapSparkline } from './GapSparkline'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
rows: LiveTimingRow[]
|
||||||
|
history: GapHistoryMap
|
||||||
|
pinned: string[]
|
||||||
|
onToggle: (racingNumber: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PinnedDrivers({ rows, history, pinned, onToggle }: Props) {
|
||||||
|
if (pinned.length === 0) return null
|
||||||
|
|
||||||
|
const rowsByNumber = new Map(rows.map((row) => [row.RacingNumber, row]))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pinned-strip" data-testid="pinned-strip">
|
||||||
|
{pinned.map((number) => {
|
||||||
|
const row = rowsByNumber.get(number)
|
||||||
|
if (!row) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pinned-card pinned-card-missing"
|
||||||
|
key={number}
|
||||||
|
onClick={() => onToggle(number)}
|
||||||
|
title="Unpin driver"
|
||||||
|
>
|
||||||
|
<span className="drv-code">#{number}</span>
|
||||||
|
<span className="pinned-nodata">no data</span>
|
||||||
|
<span className="pinned-unpin" aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const driver = row.Driver
|
||||||
|
const gap = driver.Interval || driver.GapToLeader || '-'
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pinned-card"
|
||||||
|
key={number}
|
||||||
|
onClick={() => onToggle(number)}
|
||||||
|
title="Unpin driver"
|
||||||
|
>
|
||||||
|
<span className="pinned-pos mono">P{row.Position}</span>
|
||||||
|
<span className="drv-bar" style={{ background: teamColor(row.Info?.TeamColour) }} />
|
||||||
|
<span className="drv-code">{driverCode(row)}</span>
|
||||||
|
<span className={`tyre-badge ${tyreClass(row.Tyre)}`}>{tyreLabel(row.Tyre)}</span>
|
||||||
|
<span className="pinned-gap mono">{gap}</span>
|
||||||
|
<GapSparkline samples={history[number]} />
|
||||||
|
{driver.InPit && <span className="badge badge-pit">PIT</span>}
|
||||||
|
{driver.Retired && <span className="badge badge-out">RET</span>}
|
||||||
|
<span className="pinned-unpin" aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
121
frontend/src/components/live/RaceControlFeed.tsx
Normal file
121
frontend/src/components/live/RaceControlFeed.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import type { LiveRCMessage, LiveDriverInfo } from '../../types'
|
||||||
|
import { latestRaceControl, rcFlagClass } from '../../lib/live'
|
||||||
|
import { teamColor } from '../../utils'
|
||||||
|
import { useAutoAnimate } from '@formkit/auto-animate/react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
messages: LiveRCMessage[]
|
||||||
|
driverInfo?: Record<string, LiveDriverInfo>
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormattedMessage({ text, driverInfo }: { text: string, driverInfo?: Record<string, LiveDriverInfo> }) {
|
||||||
|
if (!text) return null;
|
||||||
|
const regex = /(CAR \d+(?: \([A-Z]{3}\))?|DELETED|TRACK LIMITS|INVESTIGATING|NO FURTHER INVESTIGATION|TIME PENALTY|DRIVE THROUGH PENALTY|STOP AND GO PENALTY|BLACK AND WHITE FLAG|DISQUALIFIED)/g;
|
||||||
|
const parts = text.split(regex);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="rc-message">
|
||||||
|
{parts.map((part, i) => {
|
||||||
|
if (!part) return null;
|
||||||
|
if (part.startsWith('CAR ')) {
|
||||||
|
let style = {}
|
||||||
|
const match = part.match(/CAR (\d+)/)
|
||||||
|
if (match && driverInfo && driverInfo[match[1]]) {
|
||||||
|
const hex = teamColor(driverInfo[match[1]].TeamColour)
|
||||||
|
style = {
|
||||||
|
backgroundColor: hex,
|
||||||
|
color: '#fff',
|
||||||
|
textShadow: '0 1px 2px rgba(0,0,0,0.5)',
|
||||||
|
border: 'none'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return <span key={i} className="rc-hlt rc-hlt-car" style={style}>{part}</span>
|
||||||
|
}
|
||||||
|
if (part === 'DELETED' || part.includes('PENALTY') || part === 'DISQUALIFIED') return <span key={i} className="rc-hlt rc-hlt-bad">{part}</span>;
|
||||||
|
if (part === 'TRACK LIMITS' || part === 'INVESTIGATING' || part === 'BLACK AND WHITE FLAG') return <span key={i} className="rc-hlt rc-hlt-warn">{part}</span>;
|
||||||
|
if (part === 'NO FURTHER INVESTIGATION') return <span key={i} className="rc-hlt rc-hlt-ok">{part}</span>;
|
||||||
|
return <span key={i}>{part}</span>;
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RaceControlFeed({ messages, driverInfo }: Props) {
|
||||||
|
const latest = latestRaceControl(messages)
|
||||||
|
const [listRef] = useAutoAnimate<HTMLDivElement>()
|
||||||
|
const prevMessagesRef = useRef<LiveRCMessage[]>([])
|
||||||
|
const [newKeys, setNewKeys] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = prevMessagesRef.current
|
||||||
|
if (prev.length > 0 && messages.length > prev.length) {
|
||||||
|
// Find new messages
|
||||||
|
const newlyAdded = messages.filter(
|
||||||
|
(m) => !prev.some((p) => p.Time === m.Time && p.Message === m.Message)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (newlyAdded.length > 0) {
|
||||||
|
const keys = newlyAdded.map((m) => `${m.Time}-${m.Message}`)
|
||||||
|
setNewKeys((current) => {
|
||||||
|
const updated = new Set(current)
|
||||||
|
keys.forEach(k => updated.add(k))
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clear highlight after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
setNewKeys((current) => {
|
||||||
|
const updated = new Set(current)
|
||||||
|
keys.forEach(k => updated.delete(k))
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevMessagesRef.current = messages
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="live-rc panel-glass">
|
||||||
|
<div className="sec-header sticky-header">
|
||||||
|
<span className="sec-title">Race Control</span>
|
||||||
|
{messages.length > 0 && <span className="sec-meta">{messages.length} messages</span>}
|
||||||
|
</div>
|
||||||
|
{latest.length === 0 ? (
|
||||||
|
<div className="missing-notice">No race control messages in the current live snapshot.</div>
|
||||||
|
) : (
|
||||||
|
<div className="live-rc-list live-rc-scroll" ref={listRef}>
|
||||||
|
{latest.map((message, index) => {
|
||||||
|
const key = `${message.Time}-${message.Message}`
|
||||||
|
const isNew = newKeys.has(key)
|
||||||
|
let flashClass = ''
|
||||||
|
if (isNew) {
|
||||||
|
if (message.Flag === 'YELLOW' || message.Flag === 'DOUBLE YELLOW') {
|
||||||
|
flashClass = ' rc-item-yellow'
|
||||||
|
} else if (message.Flag === 'RED') {
|
||||||
|
flashClass = ' rc-item-red'
|
||||||
|
} else {
|
||||||
|
flashClass = ' rc-item-new'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`live-rc-row${flashClass}`} key={key}>
|
||||||
|
<span className="rc-time">{message.Time || '--:--'}</span>
|
||||||
|
{message.Lap > 0 && <span className="rc-lap">L{message.Lap}</span>}
|
||||||
|
{message.Flag
|
||||||
|
? <span className={`rc-flag ${rcFlagClass(message.Flag)}`}>{message.Flag}</span>
|
||||||
|
: message.Category && message.Category !== 'Other'
|
||||||
|
? <span className="rc-category">{message.Category}</span>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
<FormattedMessage text={message.Message} driverInfo={driverInfo} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
49
frontend/src/components/live/SessionBanner.tsx
Normal file
49
frontend/src/components/live/SessionBanner.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { LiveStreamData } from '../../types'
|
||||||
|
import type { LiveTimingRow } from '../../lib/live'
|
||||||
|
import { extrapolateClock, liveSessionDisplay } from '../../lib/live'
|
||||||
|
import { WeatherStrip } from './WeatherStrip'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isLive: boolean
|
||||||
|
snapshot: LiveStreamData
|
||||||
|
rows: LiveTimingRow[]
|
||||||
|
connection: 'connected' | 'connecting' | 'disconnected' | 'error'
|
||||||
|
now: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionBanner({ isLive, snapshot, rows, connection, now }: Props) {
|
||||||
|
const session = snapshot.Session
|
||||||
|
const clock = extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now)
|
||||||
|
const display = liveSessionDisplay(session, rows)
|
||||||
|
const atRiskLabel =
|
||||||
|
display.atRiskStart && display.atRiskEnd ? `P${display.atRiskStart}-P${display.atRiskEnd} at risk` : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="live-banner">
|
||||||
|
<div className="live-banner-row">
|
||||||
|
<div className="live-banner-main">
|
||||||
|
<span className={`live-conn live-conn-${connection}`}>{connection}</span>
|
||||||
|
<div>
|
||||||
|
<h1>{session?.MeetingName || 'Live Timing'}</h1>
|
||||||
|
<p>
|
||||||
|
{[session?.SessionName, session?.CircuitName].filter(Boolean).join(' · ') || 'F1 live feed'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="live-session-board">
|
||||||
|
{display.phaseLabel && <span className="live-phase-pill mono">{display.phaseLabel}</span>}
|
||||||
|
<div className="live-clock mono" data-testid="live-clock">{clock || '--:--:--'}</div>
|
||||||
|
<div className="live-banner-meta">
|
||||||
|
{display.advanceCount && <span>{display.advanceCount} advance</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>
|
||||||
|
<WeatherStrip weather={snapshot.Weather} />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
29
frontend/src/components/live/StintHistory.tsx
Normal file
29
frontend/src/components/live/StintHistory.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { LiveStintData } from '../../types'
|
||||||
|
import { compoundClass, compoundLetter } from '../../lib/live'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
stints: LiveStintData[] | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StintHistory({ stints }: Props) {
|
||||||
|
if (!stints || stints.length === 0) {
|
||||||
|
return <span className="stint-empty">-</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="stint-seq" data-testid="stint-seq">
|
||||||
|
{stints.map((stint, index) => (
|
||||||
|
<span className="stint-item" key={index}>
|
||||||
|
{index > 0 && <span className="stint-arrow">›</span>}
|
||||||
|
<span
|
||||||
|
className={`stint-dot ${compoundClass(stint.Compound)}`}
|
||||||
|
title={`${stint.Compound || 'Unknown'}${stint.New ? ' (new)' : ''} · ${stint.Laps} laps`}
|
||||||
|
>
|
||||||
|
{compoundLetter(stint.Compound)}
|
||||||
|
</span>
|
||||||
|
{stint.Laps > 0 && <span className="stint-laps">{stint.Laps}</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
108
frontend/src/components/live/TeamRadioTicker.tsx
Normal file
108
frontend/src/components/live/TeamRadioTicker.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useMemo, useRef, useState } from 'react'
|
||||||
|
import { Pause, Play } from 'lucide-react'
|
||||||
|
import type { LiveDriverInfo, LiveRadioCapture, LiveSessionMeta } from '../../types'
|
||||||
|
import { radioCaptureKey, radioClipUrl } from '../../lib/radio'
|
||||||
|
import { teamColor } from '../../utils'
|
||||||
|
import '../../styles/team-radio.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
captures?: LiveRadioCapture[]
|
||||||
|
driverInfo?: Record<string, LiveDriverInfo>
|
||||||
|
session?: LiveSessionMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeTime(utc: string): string {
|
||||||
|
const timestamp = new Date(utc).getTime()
|
||||||
|
if (!utc || Number.isNaN(timestamp)) return '--'
|
||||||
|
|
||||||
|
const diffSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000))
|
||||||
|
if (diffSeconds < 60) return `${diffSeconds}s ago`
|
||||||
|
const minutes = Math.floor(diffSeconds / 60)
|
||||||
|
if (minutes < 60) return `${minutes}m ago`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours}h ago`
|
||||||
|
return new Date(utc).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function driverLabel(info: LiveDriverInfo | undefined, racingNumber: string): string {
|
||||||
|
return info?.Tla || racingNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamRadioTicker({ captures = [], driverInfo = {}, session }: Props) {
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
const [playingKey, setPlayingKey] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const newestFirst = useMemo(() => [...captures].reverse(), [captures])
|
||||||
|
|
||||||
|
const toggleCapture = (capture: LiveRadioCapture) => {
|
||||||
|
const key = radioCaptureKey(capture)
|
||||||
|
const audio = audioRef.current
|
||||||
|
const url = radioClipUrl(session, capture)
|
||||||
|
if (!audio || !url) return
|
||||||
|
|
||||||
|
if (playingKey === key) {
|
||||||
|
audio.pause()
|
||||||
|
setPlayingKey(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.pause()
|
||||||
|
audio.src = url
|
||||||
|
setPlayingKey(key)
|
||||||
|
const play = audio.play()
|
||||||
|
if (play) {
|
||||||
|
play.catch(() => setPlayingKey(null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="team-radio panel-glass" data-testid="team-radio-ticker">
|
||||||
|
<div className="sec-header sticky-header">
|
||||||
|
<span className="sec-title">Team Radio</span>
|
||||||
|
{captures.length > 0 && <span className="sec-meta">{captures.length} clips</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
className="team-radio-audio"
|
||||||
|
onEnded={() => setPlayingKey(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{newestFirst.length === 0 ? (
|
||||||
|
<div className="missing-notice">No team radio clips in the current live snapshot.</div>
|
||||||
|
) : (
|
||||||
|
<div className="team-radio-list">
|
||||||
|
{newestFirst.map((capture) => {
|
||||||
|
const key = radioCaptureKey(capture)
|
||||||
|
const info = driverInfo[capture.RacingNumber]
|
||||||
|
const label = driverLabel(info, capture.RacingNumber)
|
||||||
|
const isPlaying = playingKey === key
|
||||||
|
const url = radioClipUrl(session, capture)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="team-radio-row" key={key}>
|
||||||
|
<button
|
||||||
|
className="team-radio-play"
|
||||||
|
type="button"
|
||||||
|
aria-label={`${isPlaying ? 'Pause' : 'Play'} ${label} radio`}
|
||||||
|
disabled={!url}
|
||||||
|
onClick={() => toggleCapture(capture)}
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause size={14} /> : <Play size={14} />}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
className="team-radio-driver"
|
||||||
|
style={{ borderColor: teamColor(info?.TeamColour) }}
|
||||||
|
title={info?.TeamName || undefined}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="team-radio-time">{relativeTime(capture.Utc)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user