Compare commits
160 Commits
161e871c53
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0ff8fa5a7 | ||
|
|
c0d1cf2892 | ||
|
|
0bba213652 | ||
|
|
01d291507d | ||
|
|
eabf87a2b8 | ||
|
|
5534ebf82c | ||
|
|
dafb9b6dc5 | ||
|
|
1a9e08c0ab | ||
|
|
14d9bd3dd4 | ||
|
|
15d81c8eab | ||
|
|
2651569453 | ||
|
|
ad0eb26e05 | ||
|
|
5ea8a4a9c4 | ||
|
|
b3d1730324 | ||
|
|
e8a1f15f8b | ||
|
|
e66a874755 | ||
|
|
ad870c040c | ||
|
|
dbe3ac644d | ||
|
|
1543a09ff1 | ||
|
|
408de2da0c | ||
|
|
469028b07a | ||
|
|
5d5987cc7e | ||
|
|
7c98489b91 | ||
|
|
84a9a3579f | ||
|
|
07e5857760 | ||
|
|
06223d992f | ||
|
|
ba55945b6f | ||
|
|
643af2355a | ||
|
|
45084558cd | ||
|
|
90c24ad6aa | ||
|
|
e028e3e3fe | ||
|
|
0a42c05487 | ||
|
|
08ff75b469 | ||
|
|
16c035b037 | ||
|
|
1dac73cdc5 | ||
|
|
02a84f67b4 | ||
|
|
f0a8e4e631 | ||
|
|
519053d340 | ||
|
|
e0d7b9981e | ||
|
|
79acf0579a | ||
|
|
82abbd6b35 | ||
|
|
faa173578c | ||
|
|
39f1560282 | ||
|
|
d3f7bab6dc | ||
|
|
5b112fb012 | ||
|
|
96d741424c | ||
|
|
c2617d39bb | ||
|
|
beacda278d | ||
|
|
f24ec4be94 | ||
|
|
09104451d0 | ||
|
|
f8bd183c26 | ||
|
|
01ec1f24e7 | ||
|
|
3f022a0cf9 | ||
|
|
5970649a9c | ||
|
|
7212eb9b44 | ||
|
|
785dccdca2 | ||
|
|
e8de2068c1 | ||
|
|
3cc6157337 | ||
|
|
1782fd313c | ||
|
|
be7eb3c034 | ||
|
|
71523641ad | ||
|
|
e51cabba62 | ||
|
|
fc6fc36d8f | ||
|
|
16d82afad5 | ||
|
|
91199ff5c0 | ||
|
|
144e93ee44 | ||
|
|
f4f5071e26 | ||
|
|
a1d71900d1 | ||
|
|
34b060238a | ||
|
|
a9920f0dd4 | ||
|
|
7c8a75f7ca | ||
|
|
e0174f7bf1 | ||
|
|
2d6fa531f2 | ||
|
|
8fd358748d | ||
|
|
b0fd252096 | ||
|
|
ad379e0f07 | ||
|
|
9ee14e5c89 | ||
|
|
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 |
58
.agents/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# `.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** (recommended harnesses: `codex` or `cursor`): `.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. For current implementation
|
||||
dispatch, prefer `codex` or `cursor`; `claude` and `opencode` remain available, and
|
||||
`pi` still needs flag verification before trusting.
|
||||
|
||||
`agy` / Antigravity is deliberately disabled for non-dry-run dispatch as of
|
||||
2026-07-04. Phase 1 testing found the headless path unreliable: with
|
||||
`--new-project` it ignored the prompt and tried to scaffold, while without it the CLI
|
||||
could resume a stale conversation and hang past the print timeout. Keep using
|
||||
`.agents/bin/dev implement <issue#> --harness agy --dry-run` for prompt inspection
|
||||
only; real dispatch should use `codex` or `cursor` until a fresh Antigravity
|
||||
headless invocation is verified and documented.
|
||||
|
||||
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
@@ -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 <codex|cursor|claude|opencode|pi|agy> [--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: codex, cursor (recommended) · claude, opencode (available) · agy (dry-run only, disabled for dispatch) · pi (verify flags)
|
||||
--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
|
||||
73
.agents/harnesses.sh
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/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_disabled_reason() { # <name> -> reason on stdout; 0 means disabled
|
||||
case "$1" in
|
||||
agy)
|
||||
cat <<'EOF'
|
||||
agy is disabled for implementation dispatch as of 2026-07-04: Antigravity headless mode was observed to ignore prompts with --new-project, resume stale conversations without it, and hang past print timeouts. Use codex or cursor until a fresh headless invocation is verified.
|
||||
EOF
|
||||
return 0
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
harness_claude() { # Claude Code — print mode, auto-accept edits
|
||||
local dir="$1" prompt="$2"
|
||||
local model="${BOXBOX_CLAUDE_MODEL:-opus}"
|
||||
( cd "$dir" && claude -p "$(cat "$prompt")" --model "$model" --effort high --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"
|
||||
local model="${BOXBOX_CURSOR_MODEL:-composer-2.5}"
|
||||
( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --model "$model" --force --trust )
|
||||
}
|
||||
|
||||
harness_agy() { # Antigravity CLI — disabled until headless is verified
|
||||
local reason
|
||||
reason="$(harness_disabled_reason agy)"
|
||||
echo "harness_agy: $reason" >&2
|
||||
return 2
|
||||
}
|
||||
|
||||
# ---- 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
|
||||
}
|
||||
130
.agents/lib/dispatch.sh
Normal file
@@ -0,0 +1,130 @@
|
||||
#!/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
|
||||
if [ "$dry" != 1 ] && declare -f harness_disabled_reason >/dev/null 2>&1; then
|
||||
local disabled_reason
|
||||
if disabled_reason="$(harness_disabled_reason "$harness")"; then
|
||||
echo "harness '$harness' is disabled for non-dry-run dispatch." >&2
|
||||
echo " $disabled_reason" >&2
|
||||
echo " Use --dry-run for prompt inspection, or dispatch with --harness codex/cursor." >&2
|
||||
return 2
|
||||
fi
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
59
.agents/test/dispatch_disabled_harness_test.sh
Executable file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "$ROOT/.agents/lib/dispatch.sh"
|
||||
|
||||
issue_title() { echo "Disable agy harness smoke"; }
|
||||
issue_body() { echo "## Spec"; echo; echo "Smoke prompt body"; }
|
||||
get_field() { echo "Ready"; }
|
||||
set_stage() { echo "unexpected set_stage $*" >&2; return 99; }
|
||||
run_gate() { echo "unexpected run_gate $*" >&2; return 99; }
|
||||
|
||||
unexpected_git_file="$(mktemp "${TMPDIR:-/tmp}/boxbox-agy-git.XXXX")"
|
||||
rm -f "$unexpected_git_file"
|
||||
git() {
|
||||
if [ "${1:-}" = "rev-parse" ]; then
|
||||
command git "$@"
|
||||
return
|
||||
fi
|
||||
echo "unexpected git $*" >&2
|
||||
touch "$unexpected_git_file"
|
||||
return 99
|
||||
}
|
||||
|
||||
set +e
|
||||
non_dry_output="$(dispatch 47 agy 2>&1)"
|
||||
non_dry_status=$?
|
||||
set -e
|
||||
|
||||
[ "$non_dry_status" -eq 2 ] || {
|
||||
echo "expected agy non-dry-run to exit 2, got $non_dry_status" >&2
|
||||
echo "$non_dry_output" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$non_dry_output" == *"harness 'agy' is disabled"* ]] || {
|
||||
echo "expected disabled-harness message" >&2
|
||||
echo "$non_dry_output" >&2
|
||||
exit 1
|
||||
}
|
||||
[ ! -e "$unexpected_git_file" ] || {
|
||||
echo "agy non-dry-run reached git before failing" >&2
|
||||
echo "$non_dry_output" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
dry_output="$(dispatch 47 agy --dry-run 2>&1)"
|
||||
[[ "$dry_output" == *"[dry-run] no worktree / harness / PR / state change"* ]] || {
|
||||
echo "expected agy dry-run to render dispatch preview" >&2
|
||||
echo "$dry_output" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$dry_output" == *"Smoke prompt body"* ]] || {
|
||||
echo "expected agy dry-run prompt body" >&2
|
||||
echo "$dry_output" >&2
|
||||
exit 1
|
||||
}
|
||||
1
.claude/skills
Symbolic link
@@ -0,0 +1 @@
|
||||
../.agents/skills
|
||||
23
.gitignore
vendored
@@ -7,6 +7,7 @@ build/
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# SQLite database files
|
||||
*.db
|
||||
@@ -15,3 +16,25 @@ build/
|
||||
|
||||
# Old file 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
@@ -3,18 +3,31 @@
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
go build -o box-box ./cmd/main.go # Build binary
|
||||
go run cmd/main.go # Run directly
|
||||
go test ./... # All tests
|
||||
go build -o box-box ./cmd/main.go # Build binary
|
||||
go run cmd/main.go # Run TUI
|
||||
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)
|
||||
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
|
||||
|
||||
**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
|
||||
|
||||
@@ -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)
|
||||
- **OpenF1 API** — F1 data at `https://api.openf1.org`
|
||||
- **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
|
||||
|
||||
```
|
||||
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/
|
||||
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
|
||||
pitwindow.go Sub-view: Pit stop rejoin position calculator
|
||||
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
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
- **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 keybinding**: Define in `keys.go`, handle in relevant model's `Update()`
|
||||
- **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
|
||||
|
||||
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
|
||||
|
||||
- `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)
|
||||
- Cache at `~/.cache/box-box/cache.db` (SQLite WAL mode, auto-created)
|
||||
- `BOXBOX_DISABLE_LIVE=1` — Skip the background SignalR live feed in web mode (used by e2e)
|
||||
- `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
@@ -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.
|
||||
- 📅 **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
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/AmanTahiliani/box-box.git
|
||||
cd box-box
|
||||
|
||||
# Build and run
|
||||
go run cmd/main.go
|
||||
npm install
|
||||
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** |
|
||||
| `2` | Switch to **Standings** |
|
||||
| `3` | Switch to **Calendar** |
|
||||
| `4` | Switch to **Race Details** |
|
||||
| `5` | Switch to **Drivers** |
|
||||
| `6` | Switch to **Live Timing** |
|
||||
| `7` | Switch to **Track Map** |
|
||||
| `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 |
|
||||
| `cmd/main.go` | Entry point for TUI, web server, and ingestion CLI |
|
||||
| `internal/web/` | Go REST API, SSE live bridge, SPA serving |
|
||||
| `internal/live/` | Official F1 SignalR client shared by Web and TUI |
|
||||
| `internal/store/`, `internal/ingest/`, `internal/query/` | Local SQLite domain database, ingestion, and read models |
|
||||
| `internal/ui/` | Bubble Tea TUI |
|
||||
| `frontend/` | React + Vite + TypeScript web app |
|
||||
| `tests/` | Playwright e2e and visual coverage |
|
||||
|
||||
## 🛠️ 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.
|
||||
- **[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).
|
||||
## Documentation
|
||||
|
||||
## 🚥 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
|
||||
# Run tests
|
||||
go test ./...
|
||||
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.
|
||||
|
||||
# View API integration tests (requires internet)
|
||||
go test -v ./internal/api
|
||||
```
|
||||
|
||||
## 📜 License
|
||||
## License
|
||||
|
||||
MIT © [Aman Tahiliani](https://github.com/AmanTahiliani)
|
||||
|
||||
---
|
||||
*Disclaimer: This project is unofficial and not associated with Formula 1 or the FIA in any way.*
|
||||
*Unofficial project; not associated with Formula 1 or the FIA.*
|
||||
|
||||
316
cmd/main.go
@@ -1,13 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/web"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -16,23 +22,113 @@ import (
|
||||
func main() {
|
||||
webMode := flag.Bool("web", false, "Start web companion server instead of TUI")
|
||||
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()
|
||||
|
||||
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
|
||||
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 {
|
||||
client = api.NewOpenF1Client("https://api.openf1.org", 15*time.Second)
|
||||
client = api.NewOpenF1Client(baseURL, 15*time.Second)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Clean up old file-based cache (one-time migration).
|
||||
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 {
|
||||
log.SetOutput(os.Stderr) // web mode logs to stderr, not file
|
||||
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())
|
||||
return
|
||||
}
|
||||
@@ -56,3 +152,217 @@ func main() {
|
||||
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
@@ -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
|
After Width: | Height: | Size: 99 KiB |
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
@@ -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
|
After Width: | Height: | Size: 66 KiB |
BIN
docs/phase-1/screenshots/live-event-tyre-radio-mocked.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/phase-1/screenshots/race-hub-compare-telemetry-delta.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
docs/phase-1/screenshots/race-hub-strategy-timeline.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
docs/phase-2/19-21-20-race-story.png
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
docs/phase-2/22-driver-profile.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
docs/phase-2/23-teammate-h2h.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
docs/phase-2/24-rivalry-compare.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
docs/phase-2/25-race-preview.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
docs/phase-2/26-briefing-digest.png
Normal file
|
After Width: | Height: | Size: 683 KiB |
32
docs/product/v0.4.0/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# v0.4.0 — Current Weekend & Product Truth
|
||||
|
||||
Product research references for the v0.4.0 feature sprint.
|
||||
|
||||
## Direction
|
||||
|
||||
The sprint replaces route-first navigation with a state-aware Weekend experience:
|
||||
|
||||
- Weekend is the adaptive home for what happened, what is live, and what is next.
|
||||
- Preview content folds into Weekend before a session.
|
||||
- Live remains a stable deep link and becomes Weekend's active-session state.
|
||||
- Race Hub remains explicit completed-session analysis rather than a primary landing destination.
|
||||
- Championship and Briefing remain dedicated destinations.
|
||||
- Explore owns secondary discovery; Admin moves to operator utility.
|
||||
|
||||
## Mockups
|
||||
|
||||
- `mockups/weekend-between-races.png` — desktop between-races/post-weekend state.
|
||||
- `mockups/weekend-live.png` — desktop active-session state; the circuit is static sector context, not live GPS.
|
||||
- `mockups/weekend-between-sessions-mobile.png` — 390×844 between-session state.
|
||||
|
||||
These are directional references, not pixel-perfect specifications. Implementations must preserve the established box-box visual language, accessibility, data constraints, and responsive behavior while satisfying their issue acceptance criteria.
|
||||
|
||||
## Constraints
|
||||
|
||||
- No OpenF1 REST dependency during active sessions.
|
||||
- Public live GPS is not assumed to be available.
|
||||
- Championship round numbers exclude tests and cancelled meetings.
|
||||
- Connection health, live-session state, archive availability, and local-analysis readiness are separate concepts.
|
||||
- Future sessions must not render empty post-session analysis.
|
||||
|
||||
The authoritative product decisions and research packet are recorded in GitHub issue #71 under epic #70.
|
||||
BIN
docs/product/v0.4.0/mockups/weekend-between-races.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/product/v0.4.0/mockups/weekend-between-sessions-mobile.png
Normal file
|
After Width: | Height: | Size: 366 KiB |
BIN
docs/product/v0.4.0/mockups/weekend-live.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
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
|
After Width: | Height: | Size: 392 KiB |
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"
|
||||
}
|
||||
256
frontend/src/api.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { apiFetch } from './lib/fetch'
|
||||
import type {
|
||||
ArticleContent,
|
||||
CarDataSample,
|
||||
ChampionshipHub,
|
||||
DriverSummary,
|
||||
EnrichedGrid,
|
||||
EnrichedResult,
|
||||
LapsComparisonResponse,
|
||||
LiveStateResponse,
|
||||
LiveSessionMeta,
|
||||
Meeting,
|
||||
NewsItem,
|
||||
RaceHub,
|
||||
ReplayFramesResponse,
|
||||
Session,
|
||||
TrackOutline,
|
||||
Weekend,
|
||||
WeekendContext,
|
||||
} from './types'
|
||||
|
||||
export async function fetchRaceHub(sessionKey: number, signal?: AbortSignal): Promise<RaceHub> {
|
||||
return apiFetch<RaceHub>(`/api/v1/race-hub?session_key=${sessionKey}`, {
|
||||
signal,
|
||||
dedupeKey: `race-hub:${sessionKey}`,
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchSeasons(signal?: AbortSignal): Promise<number[]> {
|
||||
const years = await apiFetch<number[]>('/api/v1/seasons', {
|
||||
signal,
|
||||
dedupeKey: 'seasons',
|
||||
})
|
||||
return Array.isArray(years) ? years : []
|
||||
}
|
||||
|
||||
export async function fetchLocalMeetings(year: number, signal?: AbortSignal): Promise<Meeting[]> {
|
||||
const meetings = await apiFetch<Meeting[]>(`/api/v1/meetings?year=${year}&source=local`, {
|
||||
signal,
|
||||
dedupeKey: `meetings:local:${year}`,
|
||||
})
|
||||
return Array.isArray(meetings) ? meetings : []
|
||||
}
|
||||
|
||||
export async function fetchSeasonMeetings(year: number, signal?: AbortSignal): Promise<Meeting[]> {
|
||||
return fetchMeetings(year, 'openf1', signal)
|
||||
}
|
||||
|
||||
export async function fetchMeetings(
|
||||
year: number,
|
||||
source = 'auto',
|
||||
signal?: AbortSignal,
|
||||
): Promise<Meeting[]> {
|
||||
const meetings = await apiFetch<Meeting[]>(`/api/v1/meetings?year=${year}&source=${source}`, {
|
||||
signal,
|
||||
dedupeKey: `meetings:${source}:${year}`,
|
||||
})
|
||||
return Array.isArray(meetings) ? meetings : []
|
||||
}
|
||||
|
||||
export async function fetchResults(
|
||||
sessionKey: number,
|
||||
source = 'auto',
|
||||
signal?: AbortSignal,
|
||||
): Promise<EnrichedResult[]> {
|
||||
const results = await apiFetch<EnrichedResult[]>(
|
||||
`/api/v1/results?session_key=${sessionKey}&source=${source}`,
|
||||
{ signal, dedupeKey: `results:${source}:${sessionKey}` },
|
||||
)
|
||||
return Array.isArray(results) ? results : []
|
||||
}
|
||||
|
||||
export async function fetchStartingGrid(
|
||||
sessionKey: number,
|
||||
source = 'auto',
|
||||
signal?: AbortSignal,
|
||||
): Promise<EnrichedGrid[]> {
|
||||
const grid = await apiFetch<EnrichedGrid[]>(
|
||||
`/api/v1/grid?session_key=${sessionKey}&source=${source}`,
|
||||
{ signal, dedupeKey: `grid:${source}:${sessionKey}` },
|
||||
)
|
||||
return Array.isArray(grid) ? grid : []
|
||||
}
|
||||
|
||||
export async function fetchTrackOutline(
|
||||
circuitKey: number,
|
||||
year: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TrackOutline | null> {
|
||||
try {
|
||||
const data = await apiFetch<TrackOutline & { error?: string }>(
|
||||
`/api/v1/track-outline?circuit_key=${circuitKey}&year=${year}`,
|
||||
{ signal, dedupeKey: `track-outline:${circuitKey}:${year}` },
|
||||
)
|
||||
if (data?.error || !Array.isArray(data?.points) || data.points.length < 2) return null
|
||||
return data as TrackOutline
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchReplayFrames(
|
||||
sessionKey: number,
|
||||
intervalMs = 5000,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ReplayFramesResponse> {
|
||||
const params = new URLSearchParams({
|
||||
session_key: String(sessionKey),
|
||||
interval_ms: String(intervalMs),
|
||||
})
|
||||
return apiFetch<ReplayFramesResponse>(`/api/v1/replay/frames?${params}`, {
|
||||
signal,
|
||||
dedupeKey: `replay-frames:${sessionKey}:${intervalMs}`,
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchSessions(
|
||||
meetingKey: number,
|
||||
source = 'openf1',
|
||||
signal?: AbortSignal,
|
||||
): Promise<Session[]> {
|
||||
const sessions = await apiFetch<Session[]>(
|
||||
`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`,
|
||||
{ signal, dedupeKey: `sessions:${source}:${meetingKey}` },
|
||||
)
|
||||
return Array.isArray(sessions) ? sessions : []
|
||||
}
|
||||
|
||||
export async function fetchWeekend(meetingKey: number, signal?: AbortSignal): Promise<Weekend> {
|
||||
return apiFetch<Weekend>(`/api/v1/weekend?meeting_key=${meetingKey}`, {
|
||||
signal,
|
||||
dedupeKey: `weekend:${meetingKey}`,
|
||||
})
|
||||
}
|
||||
|
||||
// fetchWeekendContext consumes the canonical /api/v1/weekend-context endpoint
|
||||
// (backend story #72). The response is the authoritative WeekendContext shape and
|
||||
// is used verbatim as the Weekend home's source of truth. Any HTTP error throws
|
||||
// so the hook can surface an explicit error state; there is no client-side
|
||||
// re-derivation of the contract. Race Hub bare-default landing also reads this
|
||||
// for `default_analysis_session` (#75).
|
||||
export async function fetchWeekendContext(signal?: AbortSignal): Promise<WeekendContext> {
|
||||
return apiFetch<WeekendContext>('/api/v1/weekend-context', {
|
||||
signal,
|
||||
dedupeKey: 'weekend-context',
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchChampionshipHub(
|
||||
year?: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ChampionshipHub> {
|
||||
const params = new URLSearchParams({ source: 'auto' })
|
||||
if (year) params.set('year', year.toString())
|
||||
return apiFetch<ChampionshipHub>(`/api/v1/championship/hub?${params.toString()}`, {
|
||||
signal,
|
||||
dedupeKey: `championship-hub:${year ?? 'latest'}`,
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchDriverSummary(
|
||||
driverNumber: number,
|
||||
year?: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DriverSummary> {
|
||||
const params = new URLSearchParams({ driver_number: String(driverNumber), source: 'auto' })
|
||||
if (year) params.set('year', String(year))
|
||||
return apiFetch<DriverSummary>(`/api/v1/driver/summary?${params.toString()}`, {
|
||||
signal,
|
||||
dedupeKey: `driver-summary:${driverNumber}:${year ?? 'latest'}`,
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchLiveState(signal?: AbortSignal): Promise<LiveStateResponse> {
|
||||
return apiFetch<LiveStateResponse>('/api/v1/live/state', {
|
||||
signal,
|
||||
dedupeKey: 'live-state',
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchLiveTrackOutline(
|
||||
session: LiveSessionMeta,
|
||||
year = new Date().getFullYear(),
|
||||
signal?: AbortSignal,
|
||||
): 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)
|
||||
return apiFetch<TrackOutline>(`/api/v1/track-outline?${params.toString()}`, {
|
||||
signal,
|
||||
dedupeKey: `live-track-outline:${year}:${session.MeetingName ?? ''}:${session.CircuitName ?? ''}`,
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchNews(
|
||||
limit?: number,
|
||||
source?: string,
|
||||
signal?: AbortSignal,
|
||||
): 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'
|
||||
|
||||
return apiFetch<NewsItem[]>(url, {
|
||||
signal,
|
||||
dedupeKey: `news:${limit ?? 'all'}:${source ?? 'all'}`,
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchNewsArticle(
|
||||
articleUrl: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ArticleContent> {
|
||||
return apiFetch<ArticleContent>(
|
||||
`/api/v1/news/article?url=${encodeURIComponent(articleUrl)}`,
|
||||
{ signal },
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CarDataSample[]> {
|
||||
const data = await apiFetch<CarDataSample[]>(
|
||||
`/api/v1/telemetry?session_key=${sessionKey}&driver_number=${driverNumber}`,
|
||||
{ signal, dedupeKey: `telemetry:${sessionKey}:${driverNumber}` },
|
||||
)
|
||||
return Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
export async function fetchLapsComparison(
|
||||
sessionKey: number,
|
||||
drivers?: number[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<LapsComparisonResponse> {
|
||||
const params = new URLSearchParams({ session_key: String(sessionKey) })
|
||||
if (drivers?.length) {
|
||||
params.set('drivers', drivers.join(','))
|
||||
}
|
||||
return apiFetch<LapsComparisonResponse>(`/api/v1/laps/comparison?${params}`, {
|
||||
signal,
|
||||
dedupeKey: `laps-comparison:${sessionKey}:${drivers?.join(',') ?? 'all'}`,
|
||||
})
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
116
frontend/src/components/ChapterStrip.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { BookOpen } from 'lucide-react'
|
||||
import type { Chapter } from '../types'
|
||||
import {
|
||||
activeChapterIndex,
|
||||
chapterKindLabel,
|
||||
chapterLapRange,
|
||||
chapterStartScrub,
|
||||
} from '../lib/chapters'
|
||||
import { EmptyStateCard } from './EmptyStateCard'
|
||||
import '../styles/chapters.css'
|
||||
|
||||
interface Props {
|
||||
chapters: Chapter[]
|
||||
scrubTime: number | null
|
||||
tMin: number
|
||||
tRange: number
|
||||
tourActive: boolean
|
||||
tourChapterIndex: number | null
|
||||
/** Explicit selection from a chapter click; wins over scrub-derived active. */
|
||||
selectedChapterIndex?: number | null
|
||||
onChapterClick: (index: number, scrub: number) => void
|
||||
onTourToggle: () => void
|
||||
}
|
||||
|
||||
export function ChapterStrip({
|
||||
chapters,
|
||||
scrubTime,
|
||||
tMin,
|
||||
tRange,
|
||||
tourActive,
|
||||
tourChapterIndex,
|
||||
selectedChapterIndex = null,
|
||||
onChapterClick,
|
||||
onTourToggle,
|
||||
}: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange)
|
||||
const highlightedIndex = tourActive
|
||||
? tourChapterIndex
|
||||
: (selectedChapterIndex ?? activeIndex)
|
||||
|
||||
useEffect(() => {
|
||||
if (highlightedIndex === null || !scrollRef.current) return
|
||||
const card = scrollRef.current.querySelector<HTMLElement>(
|
||||
`[data-testid="chapter-card-${highlightedIndex}"]`,
|
||||
)
|
||||
card?.scrollIntoView?.({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||
}, [highlightedIndex])
|
||||
|
||||
if (chapters.length === 0) {
|
||||
return (
|
||||
<div className="chapter-strip" data-testid="chapter-strip">
|
||||
<EmptyStateCard
|
||||
icon={BookOpen}
|
||||
title="No story chapters"
|
||||
hint="This session does not have enough race-control or position data to build narrative chapters."
|
||||
testId="chapter-strip-empty"
|
||||
className="chapter-strip-empty-card"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chapter-strip" data-testid="chapter-strip">
|
||||
<div className="chapter-strip-header">
|
||||
<span className="chapter-strip-title">Race chapters</span>
|
||||
<div className="chapter-strip-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`chapter-tour-btn ${tourActive ? 'active' : ''}`}
|
||||
onClick={onTourToggle}
|
||||
aria-pressed={tourActive}
|
||||
>
|
||||
{tourActive ? 'Exit 90s' : '90s tour'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="chapter-strip-scroll-wrap">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="chapter-strip-scroll"
|
||||
role="list"
|
||||
aria-label="Race story chapters"
|
||||
>
|
||||
{chapters.map((chapter, index) => {
|
||||
const scrub = chapterStartScrub(chapter, tMin, tRange) ?? index / Math.max(chapters.length - 1, 1)
|
||||
const isActive = highlightedIndex === index
|
||||
const headline = chapter.headline || chapter.title
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${chapter.kind}-${chapter.start_lap}-${index}`}
|
||||
type="button"
|
||||
role="listitem"
|
||||
className={`chapter-card ${isActive ? (tourActive ? 'tour-active' : 'active') : ''}`}
|
||||
onClick={() => onChapterClick(index, scrub)}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
data-testid={`chapter-card-${index}`}
|
||||
>
|
||||
<div className="chapter-card-top">
|
||||
<span className={`chapter-kind chapter-kind--${chapter.kind}`}>
|
||||
{chapterKindLabel(chapter.kind)}
|
||||
</span>
|
||||
<span className="chapter-lap-range">{chapterLapRange(chapter)}</span>
|
||||
</div>
|
||||
<span className="chapter-headline">{headline}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
253
frontend/src/components/CompareView.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
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'}. Deltas are plotted
|
||||
only where the reference lap is valid; gaps appear when the reference has no lap
|
||||
time.
|
||||
</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
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
20
frontend/src/components/EmptyStateCard.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
hint: ReactNode
|
||||
testId?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EmptyStateCard({ icon: Icon, title, hint, testId, className = '' }: Props) {
|
||||
return (
|
||||
<div className={`empty-state ui-card ${className}`.trim()} data-testid={testId}>
|
||||
<Icon size={32} className="empty-state-icon" aria-hidden />
|
||||
<div className="empty-state-title">{title}</div>
|
||||
<div className="empty-state-desc">{hint}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
291
frontend/src/components/LocalDataNavigator.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
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 { RouteState } from './RouteState'
|
||||
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: ({ signal }) => fetchSeasons(signal),
|
||||
})
|
||||
|
||||
const meetingsQuery = useQuery({
|
||||
queryKey: ['meetings', selectedYear],
|
||||
queryFn: ({ signal }) => fetchLocalMeetings(selectedYear!, signal),
|
||||
enabled: selectedYear != null,
|
||||
})
|
||||
|
||||
const weekendQuery = useQuery({
|
||||
queryKey: ['weekend', selectedMeetingKey],
|
||||
queryFn: ({ signal }) => fetchWeekend(selectedMeetingKey!, signal),
|
||||
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 <RouteState kind="loading" title="loading local seasons…" className="nav-panel" />
|
||||
}
|
||||
|
||||
if (seasonsQuery.isError) {
|
||||
return (
|
||||
<RouteState
|
||||
kind="error"
|
||||
className="nav-panel"
|
||||
title="Local seasons unavailable"
|
||||
error={seasonsQuery.error}
|
||||
onRetry={() => {
|
||||
if (!seasonsQuery.isFetching) void seasonsQuery.refetch()
|
||||
}}
|
||||
retrying={seasonsQuery.isFetching}
|
||||
testId="weekend-error"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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 && (
|
||||
<RouteState
|
||||
kind="error"
|
||||
title="Meetings unavailable"
|
||||
error={meetingsQuery.error}
|
||||
onRetry={() => {
|
||||
if (!meetingsQuery.isFetching) void meetingsQuery.refetch()
|
||||
}}
|
||||
retrying={meetingsQuery.isFetching}
|
||||
testId="weekend-meetings-error"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!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 && (
|
||||
<RouteState
|
||||
kind="error"
|
||||
title="Weekend unavailable"
|
||||
error={weekendQuery.error}
|
||||
onRetry={() => {
|
||||
if (!weekendQuery.isFetching) void weekendQuery.refetch()
|
||||
}}
|
||||
retrying={weekendQuery.isFetching}
|
||||
testId="weekend-error"
|
||||
/>
|
||||
)}
|
||||
|
||||
{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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
66
frontend/src/components/Nav.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Compass, FileText, Home, Trophy } from 'lucide-react'
|
||||
|
||||
const PRIMARY = [
|
||||
{ to: '/', label: 'Weekend', icon: Home, exact: true },
|
||||
{ to: '/championship', label: 'Championship', icon: Trophy, exact: false },
|
||||
{ to: '/briefing', label: 'Briefing', icon: FileText, exact: false },
|
||||
{ to: '/explore', label: 'Explore', icon: Compass, exact: false },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Nav renders one primary navigation system per breakpoint:
|
||||
* - Desktop/tablet: the top bar's `aria-label="Primary"` links.
|
||||
* - Mobile (≤640px): the bottom `aria-label="Primary"` bar; the top bar's links
|
||||
* are hidden via CSS so the two are never both active at once.
|
||||
*
|
||||
* Admin is an operator utility, deliberately outside every Primary landmark — it
|
||||
* lives in a plain toolbar slot and never appears in the mobile bottom nav.
|
||||
*/
|
||||
export function Nav() {
|
||||
return (
|
||||
<>
|
||||
<header className="app-nav">
|
||||
<Link to="/" className="nav-logo">
|
||||
box<em>-</em>box
|
||||
</Link>
|
||||
<nav className="nav-links" aria-label="Primary">
|
||||
{PRIMARY.map(({ to, label, exact }) => (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
activeProps={{ className: 'active' }}
|
||||
activeOptions={exact ? { exact: true } : undefined}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="nav-utility" role="toolbar" aria-label="Operator utilities">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="nav-utility-link"
|
||||
activeProps={{ className: 'nav-utility-link active' }}
|
||||
>
|
||||
Admin
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="app-bottom-nav" aria-label="Primary">
|
||||
{PRIMARY.map(({ to, label, icon: Icon, exact }) => (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
className="bottom-nav-link"
|
||||
activeProps={{ className: 'bottom-nav-link active' }}
|
||||
activeOptions={exact ? { exact: true } : undefined}
|
||||
>
|
||||
<Icon size={18} aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
264
frontend/src/components/OverviewView.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import type { RaceHub } from '../types'
|
||||
import { compareFinishPosition, formatDuration, formatGap, formatLapTime } from '../utils'
|
||||
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 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>
|
||||
</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 : '') || '',
|
||||
}
|
||||
}
|
||||
149
frontend/src/components/PaddockBriefing.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
fetchChampionshipHub,
|
||||
fetchNews,
|
||||
fetchSeasonMeetings,
|
||||
fetchSeasons,
|
||||
} from '../api'
|
||||
import {
|
||||
activeDigestWindow,
|
||||
gpWindows,
|
||||
itemsForWindow,
|
||||
sinceLastLabel,
|
||||
tagColour,
|
||||
tagItems,
|
||||
topTags,
|
||||
} from '../lib/digest'
|
||||
import { timeAgo } from '../utils'
|
||||
import '../styles/digest.css'
|
||||
|
||||
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 now = useMemo(() => new Date(), [])
|
||||
|
||||
const { data: news, isLoading, isError } = useQuery({
|
||||
queryKey: ['news'],
|
||||
queryFn: ({ signal }) => fetchNews(100, undefined, signal),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const seasonsQuery = useQuery({
|
||||
queryKey: ['seasons'],
|
||||
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||
})
|
||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||
|
||||
const meetingsQuery = useQuery({
|
||||
queryKey: ['season-meetings', latestSeason],
|
||||
queryFn: ({ signal }) => fetchSeasonMeetings(latestSeason!, signal),
|
||||
enabled: latestSeason != null,
|
||||
})
|
||||
|
||||
const hubQuery = useQuery({
|
||||
queryKey: ['championship-hub', latestSeason],
|
||||
queryFn: ({ signal }) => fetchChampionshipHub(latestSeason!, signal),
|
||||
enabled: latestSeason != null,
|
||||
})
|
||||
|
||||
const meetings = meetingsQuery.data ?? []
|
||||
const hub = hubQuery.data
|
||||
const tagged = useMemo(
|
||||
() => tagItems(news ?? [], hub?.drivers ?? [], hub?.teams ?? []),
|
||||
[news, hub],
|
||||
)
|
||||
|
||||
const windows = useMemo(() => gpWindows(meetings, now), [meetings, now])
|
||||
const activeWindow = useMemo(
|
||||
() => activeDigestWindow(windows, meetings, now),
|
||||
[windows, meetings, now],
|
||||
)
|
||||
const sinceItems = useMemo(
|
||||
() => itemsForWindow(tagged, activeWindow),
|
||||
[tagged, activeWindow],
|
||||
)
|
||||
const sinceTags = useMemo(() => topTags(sinceItems, 4), [sinceItems])
|
||||
const sinceLabel = sinceLastLabel(meetings, now)
|
||||
|
||||
const unreadCount = news?.filter((i) => !i.read_at).length ?? 0
|
||||
const preview = sinceItems.length > 0 ? sinceItems.slice(0, 5) : (tagged.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>
|
||||
|
||||
{meetings.length > 0 && (
|
||||
<div className="cc-brief-digest-meta" data-testid="cc-brief-digest-meta">
|
||||
<span>Since {sinceLabel}</span>
|
||||
<span>·</span>
|
||||
<span>{sinceItems.length} items</span>
|
||||
{sinceTags.length > 0 && (
|
||||
<div className="cc-brief-digest-tags">
|
||||
{sinceTags.map((tag) => (
|
||||
<span
|
||||
key={tag.key}
|
||||
className="cc-brief-tag"
|
||||
style={{ borderColor: tagColour(tag.colour) }}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
141
frontend/src/components/PreSessionView.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { Session } from '../types'
|
||||
import { RACE_HUB_DATASETS } from '../lib/coverage'
|
||||
import { formatCountdown, formatSessionScheduleTime, sessionStartTime } from '../lib/schedule'
|
||||
import {
|
||||
sessionStateDescription,
|
||||
type SessionState,
|
||||
} from '../lib/sessionState'
|
||||
|
||||
const EXPECTED_LABELS: Record<string, string> = {
|
||||
results: 'Final results',
|
||||
starting_grid: 'Starting grid',
|
||||
stints: 'Tyre strategy',
|
||||
pit_stops: 'Pit stops',
|
||||
positions: 'Position changes',
|
||||
laps: 'Lap times',
|
||||
race_control: 'Race control',
|
||||
weather: 'Track conditions',
|
||||
}
|
||||
|
||||
interface PreSessionProps {
|
||||
session: Session
|
||||
sessionName: string
|
||||
now: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Purpose-built view for a session that has not run yet. Instead of rendering
|
||||
* empty Winner / Podium / Pole / Strategy / Compare cards, it explains that the
|
||||
* session is upcoming and previews the analysis that will appear once the data
|
||||
* is ingested.
|
||||
*/
|
||||
export function PreSessionView({ session, sessionName, now }: PreSessionProps) {
|
||||
const start = sessionStartTime(session)
|
||||
const expected = RACE_HUB_DATASETS.filter((key) => EXPECTED_LABELS[key])
|
||||
|
||||
return (
|
||||
<div className="rh-presession" data-testid="rh-presession">
|
||||
<section className="rh-presession-band">
|
||||
<span className="rh-presession-eyebrow mono">Upcoming session</span>
|
||||
<h2 className="rh-presession-title">{sessionName}</h2>
|
||||
<p className="rh-presession-sub">
|
||||
This session hasn’t run yet, so there’s no result to analyse. Winner,
|
||||
podium, pole, strategy and comparison views will appear here once the
|
||||
session completes and its data is ingested.
|
||||
</p>
|
||||
<div className="rh-presession-countdown mono" data-testid="rh-presession-countdown">
|
||||
{start
|
||||
? `Starts ${formatSessionScheduleTime(session.date_start)} · in ${formatCountdown(start, now)}`
|
||||
: 'Start time to be confirmed.'}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Expected once complete</span>
|
||||
</div>
|
||||
<div className="rh-expected-grid">
|
||||
{expected.map((key) => (
|
||||
<div key={key} className="rh-expected-card">
|
||||
<span className="rh-expected-dot" aria-hidden="true" />
|
||||
<span>{EXPECTED_LABELS[key]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PhaseProps {
|
||||
state: Extract<SessionState, 'preparing' | 'unavailable' | 'cancelled'>
|
||||
sessionName: string
|
||||
onOpenDiagnostics?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct fan-facing surfaces for settling/preparing and unavailable sessions.
|
||||
* Genuine request failures stay on the page-level error recovery path.
|
||||
*/
|
||||
export function SessionPhaseView({ state, sessionName, onOpenDiagnostics }: PhaseProps) {
|
||||
const title =
|
||||
state === 'preparing'
|
||||
? 'Analysis preparing'
|
||||
: state === 'cancelled'
|
||||
? 'Session cancelled'
|
||||
: 'Analysis unavailable'
|
||||
|
||||
const testId =
|
||||
state === 'preparing'
|
||||
? 'rh-preparing'
|
||||
: state === 'cancelled'
|
||||
? 'rh-cancelled'
|
||||
: 'rh-unavailable'
|
||||
|
||||
return (
|
||||
<div className="rh-presession" data-testid={testId}>
|
||||
<section className="rh-presession-band">
|
||||
<span className="rh-presession-eyebrow mono">{sessionStateLabelEyebrow(state)}</span>
|
||||
<h2 className="rh-presession-title">{title}</h2>
|
||||
<p className="rh-presession-sub">
|
||||
{sessionName}: {sessionStateDescription(state)}
|
||||
</p>
|
||||
{state === 'preparing' && (
|
||||
<p className="rh-presession-sub">
|
||||
Check back shortly, or open Diagnostics if you need raw dataset coverage.
|
||||
</p>
|
||||
)}
|
||||
{onOpenDiagnostics && (state === 'preparing' || state === 'unavailable') && (
|
||||
<div className="rh-empty-actions" style={{ marginTop: 'var(--s4)' }}>
|
||||
<button type="button" className="rh-empty-action" onClick={onOpenDiagnostics}>
|
||||
Open Diagnostics
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function sessionStateLabelEyebrow(state: PhaseProps['state']): string {
|
||||
if (state === 'preparing') return 'Settling'
|
||||
if (state === 'cancelled') return 'Cancelled'
|
||||
return 'Unavailable'
|
||||
}
|
||||
|
||||
interface PartialBannerProps {
|
||||
onOpenDiagnostics?: () => void
|
||||
}
|
||||
|
||||
export function PartialAnalysisBanner({ onOpenDiagnostics }: PartialBannerProps) {
|
||||
return (
|
||||
<div className="rh-partial-banner" data-testid="rh-partial-banner" role="status">
|
||||
<span>Partial analysis — some datasets are still missing.</span>
|
||||
{onOpenDiagnostics && (
|
||||
<button type="button" className="rh-partial-banner-link" onClick={onOpenDiagnostics}>
|
||||
Diagnostics
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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
@@ -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>
|
||||
)
|
||||
}
|
||||
777
frontend/src/components/RaceStoryCanvas.tsx
Normal file
@@ -0,0 +1,777 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { LineChart } from 'lucide-react'
|
||||
import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session, Chapter } from '../types'
|
||||
import { fetchReplayFrames, fetchTrackOutline } from '../api'
|
||||
import { ReplayTrackMap } from './ReplayTrackMap'
|
||||
import { ChapterStrip } from './ChapterStrip'
|
||||
import { EmptyStateCard } from './EmptyStateCard'
|
||||
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
|
||||
import {
|
||||
chapterBandFill,
|
||||
chapterEndScrub,
|
||||
chapterStartScrub,
|
||||
chapterTourDurations,
|
||||
deCollideYPositions,
|
||||
decimatedPositionLabels,
|
||||
} from '../lib/chapters'
|
||||
import { isReplayMapAvailable } from '../lib/replayMap'
|
||||
import '../styles/race-story.css'
|
||||
|
||||
const CHAPTER_TOUR_MS = 90_000
|
||||
const CHART_W = 640
|
||||
const CHART_H = 180
|
||||
const CHART_PL = 40
|
||||
const CHART_PR = 48
|
||||
const CHART_PT = 8
|
||||
const CHART_PB = 20
|
||||
|
||||
interface ChartTiming {
|
||||
tMin: number
|
||||
tMax: number
|
||||
tRange: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
results: EnrichedResult[]
|
||||
starting_grid: EnrichedGrid[]
|
||||
positions: PositionSample[]
|
||||
laps: Lap[]
|
||||
datasets: Record<string, any>
|
||||
race_control?: any[]
|
||||
pit_stops?: any[]
|
||||
session?: Session
|
||||
meeting?: Meeting
|
||||
drivers?: Driver[]
|
||||
chapters?: Chapter[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Position samples are recorded for the entire session, including the pre-race
|
||||
* grid period. Use the winning driver's laps to define the race window so
|
||||
* that the x-axis starts at lights-out rather than at the earliest sample.
|
||||
*/
|
||||
function raceChartTiming(
|
||||
positions: PositionSample[],
|
||||
laps: Lap[],
|
||||
results: EnrichedResult[],
|
||||
): ChartTiming | null {
|
||||
const positionTimes = positions
|
||||
.map((position) => new Date(position.date).getTime())
|
||||
.filter(Number.isFinite)
|
||||
if (positionTimes.length === 0) return null
|
||||
|
||||
const fallbackMin = Math.min(...positionTimes)
|
||||
const fallbackMax = Math.max(...positionTimes)
|
||||
const fallback = {
|
||||
tMin: fallbackMin,
|
||||
tMax: fallbackMax,
|
||||
tRange: Math.max(fallbackMax - fallbackMin, 1),
|
||||
}
|
||||
|
||||
const winner = results.find((result) => result.position === 1)
|
||||
if (!winner) return fallback
|
||||
|
||||
const winnerLaps = laps
|
||||
.filter((lap) => lap.driver_number === winner.driver_number && lap.lap_number > 0)
|
||||
.map((lap) => ({ ...lap, start: new Date(lap.date_start).getTime() }))
|
||||
.filter((lap) => Number.isFinite(lap.start))
|
||||
.sort((a, b) => a.lap_number - b.lap_number)
|
||||
if (winnerLaps.length === 0) return fallback
|
||||
|
||||
const firstLap = winnerLaps[0]
|
||||
const lastLap = winnerLaps[winnerLaps.length - 1]
|
||||
const finalLapDuration = lastLap.lap_duration ?? 0
|
||||
const tMax = lastLap.start + (finalLapDuration > 0 ? finalLapDuration * 1000 : 0)
|
||||
if (tMax <= firstLap.start) return fallback
|
||||
|
||||
return { tMin: firstLap.start, tMax, tRange: tMax - firstLap.start }
|
||||
}
|
||||
|
||||
export function RaceStoryCanvas({ data }: Props) {
|
||||
const {
|
||||
results,
|
||||
starting_grid: grid,
|
||||
positions,
|
||||
datasets,
|
||||
race_control = [],
|
||||
pit_stops = [],
|
||||
laps = [],
|
||||
session,
|
||||
meeting,
|
||||
drivers = [],
|
||||
chapters = [],
|
||||
} = data
|
||||
const hasPositions = datasets['positions']?.status === 'available'
|
||||
|
||||
const [scrubTime, setScrubTime] = useState<number | null>(null)
|
||||
const [hoverDriver, setHoverDriver] = useState<number | null>(null)
|
||||
const [mapOpen, setMapOpen] = useState(false)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState(10)
|
||||
const [chapterTourActive, setChapterTourActive] = useState(false)
|
||||
const [tourChapterIndex, setTourChapterIndex] = useState<number | null>(null)
|
||||
const [selectedChapterIndex, setSelectedChapterIndex] = useState<number | null>(null)
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 })
|
||||
|
||||
const chartTiming = useMemo(() => raceChartTiming(positions, laps, results), [laps, positions, results])
|
||||
const hasChartData = hasPositions && chartTiming !== null
|
||||
const circuitKey = session?.circuit_key ?? meeting?.circuit_key ?? 0
|
||||
const outlineYear = meeting?.year ?? (session?.date_start ? new Date(session.date_start).getFullYear() : 0)
|
||||
const canProbeMap = Boolean(session?.session_key) && circuitKey > 0 && outlineYear > 0
|
||||
|
||||
const replayQuery = useQuery({
|
||||
queryKey: ['replay-frames', session?.session_key, 5000],
|
||||
queryFn: () => fetchReplayFrames(session!.session_key, 5000),
|
||||
enabled: canProbeMap,
|
||||
})
|
||||
const outlineQuery = useQuery({
|
||||
queryKey: ['track-outline', circuitKey, outlineYear],
|
||||
queryFn: () => fetchTrackOutline(circuitKey, outlineYear),
|
||||
enabled: canProbeMap,
|
||||
})
|
||||
|
||||
const mapProbeSettled = !canProbeMap || (!replayQuery.isLoading && !outlineQuery.isLoading)
|
||||
const mapAvailable = useMemo(
|
||||
() =>
|
||||
canProbeMap &&
|
||||
isReplayMapAvailable(
|
||||
replayQuery.data,
|
||||
outlineQuery.data,
|
||||
replayQuery.isError || outlineQuery.isError,
|
||||
),
|
||||
[canProbeMap, outlineQuery.data, outlineQuery.isError, replayQuery.data, replayQuery.isError],
|
||||
)
|
||||
const showMapPanel = mapOpen && mapAvailable
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapAvailable && mapOpen) {
|
||||
setMapOpen(false)
|
||||
}
|
||||
}, [mapAvailable, mapOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying || !chartTiming) return
|
||||
|
||||
let frame = 0
|
||||
let last = performance.now()
|
||||
const tick = (now: number) => {
|
||||
const delta = now - last
|
||||
last = now
|
||||
setScrubTime((current) => {
|
||||
const next = Math.min(1, (current ?? 0) + (delta * playbackSpeed) / chartTiming.tRange)
|
||||
if (next >= 1) {
|
||||
setIsPlaying(false)
|
||||
}
|
||||
return next
|
||||
})
|
||||
frame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
frame = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [chartTiming, isPlaying, playbackSpeed])
|
||||
|
||||
const stopChapterTour = () => {
|
||||
setChapterTourActive(false)
|
||||
setTourChapterIndex(null)
|
||||
}
|
||||
|
||||
const jumpToChapter = (index: number, scrub: number) => {
|
||||
setIsPlaying(false)
|
||||
stopChapterTour()
|
||||
setSelectedChapterIndex(index)
|
||||
setScrubTime(scrub)
|
||||
}
|
||||
|
||||
const clearChapterSelection = () => {
|
||||
setSelectedChapterIndex(null)
|
||||
}
|
||||
|
||||
const toggleChapterTour = () => {
|
||||
if (chapterTourActive) {
|
||||
stopChapterTour()
|
||||
return
|
||||
}
|
||||
if (!chartTiming || chapters.length === 0) return
|
||||
setIsPlaying(false)
|
||||
setSelectedChapterIndex(null)
|
||||
setChapterTourActive(true)
|
||||
setTourChapterIndex(0)
|
||||
const startScrub = chapterStartScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? 0
|
||||
setScrubTime(startScrub)
|
||||
const durations = chapterTourDurations(chapters, CHAPTER_TOUR_MS)
|
||||
tourRef.current = {
|
||||
chapterIndex: 0,
|
||||
startedAt: performance.now(),
|
||||
durationMs: durations[0] ?? CHAPTER_TOUR_MS / chapters.length,
|
||||
startScrub,
|
||||
endScrub: chapterEndScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? startScrub,
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!chapterTourActive || !chartTiming || chapters.length === 0) return
|
||||
|
||||
let frame = 0
|
||||
const tick = (now: number) => {
|
||||
const state = tourRef.current
|
||||
const elapsed = now - state.startedAt
|
||||
const progress = Math.min(1, elapsed / Math.max(state.durationMs, 1))
|
||||
const scrub = state.startScrub + (state.endScrub - state.startScrub) * progress
|
||||
setScrubTime(scrub)
|
||||
setTourChapterIndex(state.chapterIndex)
|
||||
|
||||
if (progress >= 1) {
|
||||
const nextIndex = state.chapterIndex + 1
|
||||
if (nextIndex >= chapters.length) {
|
||||
stopChapterTour()
|
||||
return
|
||||
}
|
||||
const durations = chapterTourDurations(chapters, CHAPTER_TOUR_MS)
|
||||
const startScrub = chapterStartScrub(chapters[nextIndex], chartTiming.tMin, chartTiming.tRange) ?? 0
|
||||
const endScrub = chapterEndScrub(chapters[nextIndex], chartTiming.tMin, chartTiming.tRange) ?? startScrub
|
||||
tourRef.current = {
|
||||
chapterIndex: nextIndex,
|
||||
startedAt: now,
|
||||
durationMs: durations[nextIndex] ?? CHAPTER_TOUR_MS / chapters.length,
|
||||
startScrub,
|
||||
endScrub,
|
||||
}
|
||||
}
|
||||
frame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
frame = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [chapterTourActive, chartTiming, chapters])
|
||||
|
||||
useEffect(() => {
|
||||
if (!chapterTourActive) return
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
stopChapterTour()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [chapterTourActive])
|
||||
|
||||
const replayTMs = useMemo(() => {
|
||||
const replay = replayQuery.data
|
||||
const frames = replay?.frames ?? []
|
||||
if (frames.length === 0) return 0
|
||||
const lastFrameT = frames[frames.length - 1].t
|
||||
const progress = scrubTime ?? 0
|
||||
if (!chartTiming || !replay?.start_time) {
|
||||
return Math.max(0, Math.min(lastFrameT, Math.round(progress * lastFrameT)))
|
||||
}
|
||||
const replayStart = new Date(replay.start_time).getTime()
|
||||
const chartTime = chartTiming.tMin + progress * chartTiming.tRange
|
||||
return Math.max(0, Math.min(lastFrameT, Math.round(chartTime - replayStart)))
|
||||
}, [chartTiming, replayQuery.data, scrubTime])
|
||||
|
||||
let chartContent = null
|
||||
let displayResults = results
|
||||
|
||||
if (hasChartData && chartTiming) {
|
||||
const { tMin, tRange } = chartTiming
|
||||
const normaliseTime = (time: number) => Math.max(0, Math.min(1, (time - tMin) / tRange))
|
||||
|
||||
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
|
||||
for (const p of positions) {
|
||||
const time = new Date(p.date).getTime()
|
||||
if (!Number.isFinite(time)) continue
|
||||
if (!byDriver.has(p.driver_number)) byDriver.set(p.driver_number, [])
|
||||
byDriver.get(p.driver_number)!.push({
|
||||
t: normaliseTime(time),
|
||||
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 positionLabels = decimatedPositionLabels(maxPos)
|
||||
|
||||
const W = CHART_W
|
||||
const H = CHART_H
|
||||
const PL = CHART_PL
|
||||
const PR = CHART_PR
|
||||
const PT = CHART_PT
|
||||
const PB = CHART_PB
|
||||
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 (let i = 0; i < winnerLaps.length; i++) {
|
||||
const lap = winnerLaps[i]
|
||||
if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) {
|
||||
const lapStart = new Date(lap.date_start).getTime()
|
||||
const nextLapStart = winnerLaps[i + 1] ? new Date(winnerLaps[i + 1].date_start).getTime() : NaN
|
||||
const lapEnd = lap.lap_duration && lap.lap_duration > 0
|
||||
? lapStart + lap.lap_duration * 1000
|
||||
: nextLapStart
|
||||
if (Number.isFinite(lapEnd)) lapTicks.push({ lap: lap.lap_number, t: normaliseTime(lapEnd) })
|
||||
}
|
||||
}
|
||||
|
||||
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 chapterBands = chapters
|
||||
.map((chapter, index) => {
|
||||
const startT = chapterStartScrub(chapter, tMin, tRange)
|
||||
const endT = chapterEndScrub(chapter, tMin, tRange)
|
||||
if (startT === null) return null
|
||||
const end = endT ?? startT
|
||||
return {
|
||||
key: `${chapter.kind}-${index}`,
|
||||
start: Math.min(startT, end),
|
||||
end: Math.max(startT, end),
|
||||
fill: chapterBandFill(chapter.kind),
|
||||
}
|
||||
})
|
||||
.filter((band): band is NonNullable<typeof band> => band !== null)
|
||||
|
||||
const labelCandidates = Array.from(byDriver.entries())
|
||||
.map(([dNum, samples]) => {
|
||||
const last = samples[samples.length - 1]
|
||||
if (!last) return null
|
||||
return { key: dNum, y: toY(last.pos) }
|
||||
})
|
||||
.filter((item): item is { key: number; y: number } => item !== null)
|
||||
const labelYByDriver = deCollideYPositions(labelCandidates, 12)
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
|
||||
setIsPlaying(false)
|
||||
stopChapterTour()
|
||||
clearChapterSelection()
|
||||
if (!svgRef.current) return
|
||||
const rect = svgRef.current.getBoundingClientRect()
|
||||
if (rect.width <= 0) return
|
||||
// Pointer coordinates are CSS pixels; convert them to the SVG viewBox
|
||||
// before comparing with the fixed chart margins and plot width.
|
||||
const x = ((e.clientX - rect.left) / rect.width) * W
|
||||
const t = Math.max(0, Math.min(1, (x - PL) / plotW))
|
||||
setScrubTime(t)
|
||||
}
|
||||
|
||||
chartContent = (
|
||||
<div
|
||||
className={`rs-chart-container scroll-x${showMapPanel ? '' : ' rs-chart-container--full'}`}
|
||||
data-testid="position-chart"
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="rs-position-chart-svg"
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
role="img"
|
||||
aria-label="Position evolution chart"
|
||||
>
|
||||
{chapterBands.map((band) => {
|
||||
const x1 = toX(Math.max(0, band.start))
|
||||
const x2 = toX(Math.min(1, band.end))
|
||||
if (x2 <= PL || x1 >= W - PR) return null
|
||||
return (
|
||||
<rect
|
||||
key={band.key}
|
||||
x={x1}
|
||||
y={PT}
|
||||
width={Math.max(0, x2 - x1)}
|
||||
height={plotH}
|
||||
fill={band.fill}
|
||||
data-testid="chapter-band"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{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)'}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{positionLabels.map((pos) => (
|
||||
<g key={pos}>
|
||||
<line
|
||||
x1={PL}
|
||||
x2={W - PR}
|
||||
y1={toY(pos)}
|
||||
y2={toY(pos)}
|
||||
className="rs-chart-grid-line"
|
||||
/>
|
||||
<text
|
||||
x={PL - 4}
|
||||
y={toY(pos) + 4}
|
||||
textAnchor="end"
|
||||
className="rs-chart-axis-label"
|
||||
>
|
||||
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}
|
||||
className="rs-chart-lap-tick"
|
||||
/>
|
||||
<text
|
||||
x={toX(tick.t)}
|
||||
y={H - PB + 14}
|
||||
textAnchor="middle"
|
||||
className="rs-chart-axis-label"
|
||||
>
|
||||
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 labelY = last ? (labelYByDriver.get(dNum) ?? toY(last.pos)) : 0
|
||||
|
||||
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 time = new Date(p.date).getTime()
|
||||
if (!Number.isFinite(time) || time < tMin || time > tMin + tRange) return null
|
||||
const t = normaliseTime(time)
|
||||
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={labelY + 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"
|
||||
data-testid="position-chart-interaction"
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={() => {
|
||||
if (!isPlaying) {
|
||||
clearChapterSelection()
|
||||
setScrubTime(null)
|
||||
}
|
||||
}}
|
||||
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="rs-segmented-control" aria-label="Race replay controls" data-testid="replay-controls">
|
||||
<button
|
||||
type="button"
|
||||
className={`rs-segment ${isPlaying ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setScrubTime((current) => current ?? 0)
|
||||
stopChapterTour()
|
||||
clearChapterSelection()
|
||||
setIsPlaying((current) => !current)
|
||||
}}
|
||||
aria-pressed={isPlaying}
|
||||
>
|
||||
{isPlaying ? 'Pause' : 'Play'}
|
||||
</button>
|
||||
<span className="rs-segment-divider" aria-hidden />
|
||||
{[1, 10, 30].map((speed) => (
|
||||
<button
|
||||
key={speed}
|
||||
type="button"
|
||||
className={`rs-segment ${playbackSpeed === speed ? 'active' : ''}`}
|
||||
onClick={() => setPlaybackSpeed(speed)}
|
||||
aria-pressed={playbackSpeed === speed}
|
||||
>
|
||||
{speed}x
|
||||
</button>
|
||||
))}
|
||||
{mapProbeSettled && mapAvailable && (
|
||||
<>
|
||||
<span className="rs-segment-divider" aria-hidden />
|
||||
<button
|
||||
type="button"
|
||||
className={`rs-segment ${mapOpen ? 'active' : ''}`}
|
||||
onClick={() => setMapOpen((current) => !current)}
|
||||
aria-pressed={mapOpen}
|
||||
data-testid="replay-map-toggle"
|
||||
>
|
||||
Map
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="race-story-canvas">
|
||||
{hasChartData && chartTiming && chapters.length > 0 && (
|
||||
<ChapterStrip
|
||||
chapters={chapters}
|
||||
scrubTime={scrubTime}
|
||||
tMin={chartTiming.tMin}
|
||||
tRange={chartTiming.tRange}
|
||||
tourActive={chapterTourActive}
|
||||
tourChapterIndex={tourChapterIndex}
|
||||
selectedChapterIndex={selectedChapterIndex}
|
||||
onChapterClick={jumpToChapter}
|
||||
onTourToggle={toggleChapterTour}
|
||||
/>
|
||||
)}
|
||||
<div className={`rs-replay-shell${showMapPanel ? ' rs-replay-shell--split' : ''}`}>
|
||||
<div className="rs-replay-main">
|
||||
{hasChartData ? (
|
||||
chartContent
|
||||
) : (
|
||||
<EmptyStateCard
|
||||
icon={LineChart}
|
||||
title="Lap-by-lap positions not available"
|
||||
hint={
|
||||
<>
|
||||
This session does not have ingested position samples in{' '}
|
||||
<code>/api/v1/race-hub</code>.
|
||||
</>
|
||||
}
|
||||
testId="race-story-no-positions"
|
||||
className="race-story-empty-card"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showMapPanel && (
|
||||
<div className="rs-replay-map-slot" data-testid="replay-map-slot">
|
||||
<ReplayTrackMap
|
||||
outline={outlineQuery.data}
|
||||
replay={replayQuery.data}
|
||||
tMs={replayTMs}
|
||||
drivers={drivers}
|
||||
results={results}
|
||||
loading={outlineQuery.isLoading || replayQuery.isLoading}
|
||||
error={outlineQuery.isError || replayQuery.isError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{displayResults.length > 0 && (
|
||||
<div
|
||||
className="rs-field-list"
|
||||
role="region"
|
||||
aria-label="Final running order"
|
||||
data-testid="race-story-outcome"
|
||||
>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
159
frontend/src/components/ReplayTrackMap.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Loader2, MapPin, Satellite } from 'lucide-react'
|
||||
import type { Driver, EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types'
|
||||
import { buildOutlinePath } from '../lib/trackmap'
|
||||
import { interpolateReplayCars, replayCarToSvg } from '../lib/replay'
|
||||
import { EmptyStateCard } from './EmptyStateCard'
|
||||
import '../styles/replay-map.css'
|
||||
|
||||
interface Props {
|
||||
outline?: TrackOutline | null
|
||||
replay?: ReplayFramesResponse | null
|
||||
tMs: number
|
||||
drivers: Driver[]
|
||||
results: EnrichedResult[]
|
||||
loading?: boolean
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
export function ReplayTrackMap({
|
||||
outline,
|
||||
replay,
|
||||
tMs,
|
||||
drivers,
|
||||
results,
|
||||
loading = false,
|
||||
error = false,
|
||||
}: Props) {
|
||||
const [pinned, setPinned] = useState<string | null>(null)
|
||||
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
|
||||
const driverInfo = useMemo(() => {
|
||||
const info = new Map<string, { label: string; color: string }>()
|
||||
for (const driver of drivers) {
|
||||
info.set(String(driver.driver_number), {
|
||||
label: driver.name_acronym || String(driver.driver_number),
|
||||
color: normalizeColor(driver.team_colour),
|
||||
})
|
||||
}
|
||||
for (const result of results) {
|
||||
const key = String(result.driver_number)
|
||||
if (!info.has(key)) {
|
||||
info.set(key, {
|
||||
label: result.name_acronym || key,
|
||||
color: normalizeColor(result.team_colour),
|
||||
})
|
||||
}
|
||||
}
|
||||
return info
|
||||
}, [drivers, results])
|
||||
|
||||
const cars = useMemo(() => {
|
||||
if (!outline?.bounds || !replay?.frames?.length) return []
|
||||
const positions = interpolateReplayCars(replay.frames, tMs)
|
||||
return Object.entries(positions)
|
||||
.map(([number, car]) => ({
|
||||
number,
|
||||
svg: replayCarToSvg(car, outline.bounds),
|
||||
info: driverInfo.get(number),
|
||||
}))
|
||||
.sort((a, b) => Number(a.number) - Number(b.number))
|
||||
}, [driverInfo, outline?.bounds, replay?.frames, tMs])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||
<EmptyStateCard
|
||||
icon={Loader2}
|
||||
title="Loading replay map"
|
||||
hint="Fetching track outline and historical GPS frames."
|
||||
testId="replay-map-loading"
|
||||
className="replay-map-empty-card"
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||
<EmptyStateCard
|
||||
icon={Satellite}
|
||||
title="Replay GPS unavailable"
|
||||
hint="This session does not have ingested location samples for the replay map."
|
||||
testId="replay-map-error"
|
||||
className="replay-map-empty-card"
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (!outline || !outlinePath) {
|
||||
return (
|
||||
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||
<EmptyStateCard
|
||||
icon={MapPin}
|
||||
title="Track outline unavailable"
|
||||
hint="Circuit GPS outline data is missing for this session."
|
||||
testId="replay-map-no-outline"
|
||||
className="replay-map-empty-card"
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (!replay?.frames?.length || cars.length === 0) {
|
||||
return (
|
||||
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||
<EmptyStateCard
|
||||
icon={Satellite}
|
||||
title="Historical GPS unavailable"
|
||||
hint="Fewer than two replay frames were returned for this session."
|
||||
testId="replay-map-no-frames"
|
||||
className="replay-map-empty-card"
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||
<div className="replay-map-stage">
|
||||
<svg className="replay-map-svg" viewBox="0 0 100 100" role="img" aria-label="Replay track map">
|
||||
<path className="replay-map-outline-shadow" d={outlinePath} />
|
||||
<path className="replay-map-outline" d={outlinePath} />
|
||||
{cars.map((car) => {
|
||||
const label = car.info?.label ?? car.number
|
||||
const selected = pinned === car.number
|
||||
return (
|
||||
<g
|
||||
key={car.number}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${label} replay position`}
|
||||
className={`replay-car ${selected ? 'replay-car-pinned' : ''}`}
|
||||
transform={`translate(${car.svg.x.toFixed(2)} ${car.svg.y.toFixed(2)})`}
|
||||
onClick={() => setPinned(selected ? null : car.number)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
setPinned(selected ? null : car.number)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<title>{label}</title>
|
||||
<circle r="2.7" fill={car.info?.color ?? '#777777'} />
|
||||
<text y="0.85">{label}</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeColor(color: string | undefined): string {
|
||||
const raw = color?.trim()
|
||||
if (!raw) return '#777777'
|
||||
return raw.startsWith('#') ? raw : `#${raw}`
|
||||
}
|
||||
271
frontend/src/components/RivalryCompare.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ChampHubDriver, ChampionshipHub } from '../types'
|
||||
import { teamColor } from '../utils'
|
||||
import { gapSeries, h2hTally, lastRounds } from '../lib/rivalry'
|
||||
import '../styles/rivalry.css'
|
||||
|
||||
const PAD_L = 48
|
||||
const PAD_T = 16
|
||||
const PLOT_W = 882
|
||||
const PLOT_H = 316
|
||||
const GAP_PLOT_H = 200
|
||||
|
||||
function fmtPts(n: number): string {
|
||||
return Number.isInteger(n) ? String(n) : n.toFixed(1)
|
||||
}
|
||||
|
||||
/** Up to ~7 evenly spaced round labels, always including the last round. */
|
||||
function xTicks(n: number, labels: string[], x: (i: number) => number): { x: number; label: string }[] {
|
||||
const step = Math.max(1, Math.ceil(n / 7))
|
||||
const ticks: { x: number; label: string }[] = []
|
||||
for (let i = 0; i < n; i += step) {
|
||||
ticks.push({ x: x(i), label: labels[i] ?? `R${i + 1}` })
|
||||
}
|
||||
const last = labels[n - 1] ?? `R${n}`
|
||||
if (ticks[ticks.length - 1]?.label !== last) {
|
||||
ticks.push({ x: x(n - 1), label: last })
|
||||
}
|
||||
return ticks
|
||||
}
|
||||
|
||||
function polyline(values: number[], x: (i: number) => number, y: (v: number) => number): string {
|
||||
return values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ')
|
||||
}
|
||||
|
||||
interface PickerProps {
|
||||
id: 'a' | 'b'
|
||||
label: string
|
||||
drivers: ChampHubDriver[]
|
||||
value: number
|
||||
color: string
|
||||
onChange: (driverNumber: number) => void
|
||||
}
|
||||
|
||||
function DriverPicker({ id, label, drivers, value, color, onChange }: PickerProps) {
|
||||
return (
|
||||
<label className="rivalry-picker">
|
||||
<span className="rivalry-picker-label">{label}</span>
|
||||
<select
|
||||
className="rivalry-picker-select"
|
||||
style={{ borderLeft: `3px solid ${color}` }}
|
||||
value={value}
|
||||
data-testid={`rivalry-pick-${id}`}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
>
|
||||
{drivers.map((d) => (
|
||||
<option key={d.driver_number} value={d.driver_number}>
|
||||
{d.name_acronym} · {d.full_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function RivalryCompare({ hub }: { hub: ChampionshipHub }) {
|
||||
const { drivers } = hub
|
||||
const [pickA, setPickA] = useState<number | null>(null)
|
||||
const [pickB, setPickB] = useState<number | null>(null)
|
||||
|
||||
// Default to the top two in the standings; fall back there if a picked
|
||||
// driver disappears (e.g. season switch re-fetches the hub).
|
||||
const a = drivers.find((d) => d.driver_number === pickA) ?? drivers[0]
|
||||
const b = drivers.find((d) => d.driver_number === pickB) ?? drivers[1]
|
||||
|
||||
const tally = useMemo(
|
||||
() => (a && b ? h2hTally(a.round_positions ?? [], b.round_positions ?? [], hub.round_labels) : null),
|
||||
[a, b, hub.round_labels],
|
||||
)
|
||||
|
||||
if (drivers.length < 2) {
|
||||
return (
|
||||
<div className="champ-chart-empty" data-testid="champ-view-rivalry">
|
||||
Need at least two drivers in the standings to compare a rivalry.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const colorA = teamColor(a.team_colour)
|
||||
const colorB = teamColor(b.team_colour)
|
||||
const sameTeam = a.team_name === b.team_name
|
||||
|
||||
const cumA = a.cumulative ?? []
|
||||
const cumB = b.cumulative ?? []
|
||||
const rounds = Math.min(cumA.length, cumB.length)
|
||||
const gaps = gapSeries(cumA, cumB)
|
||||
const lastGap = gaps[gaps.length - 1] ?? 0
|
||||
const strip = tally ? lastRounds(tally, 5) : []
|
||||
|
||||
const pickers = (
|
||||
<div className="rivalry-pickers">
|
||||
<DriverPicker id="a" label="Driver A" drivers={drivers} value={a.driver_number} color={colorA} onChange={setPickA} />
|
||||
<span className="rivalry-vs mono">vs</span>
|
||||
<DriverPicker id="b" label="Driver B" drivers={drivers} value={b.driver_number} color={colorB} onChange={setPickB} />
|
||||
</div>
|
||||
)
|
||||
|
||||
if (rounds === 0) {
|
||||
return (
|
||||
<div className="rivalry" data-testid="champ-view-rivalry">
|
||||
{pickers}
|
||||
<div className="champ-chart-empty">
|
||||
No completed rounds yet — the rivalry will appear after the first race.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Points race scales.
|
||||
const peak = Math.max(...cumA.slice(0, rounds), ...cumB.slice(0, rounds), 1)
|
||||
const maxY = Math.max(50, Math.ceil(peak / 50) * 50)
|
||||
const px = (i: number) => (rounds <= 1 ? PAD_L : PAD_L + (i * PLOT_W) / (rounds - 1))
|
||||
const py = (v: number) => PAD_T + PLOT_H - (v / maxY) * PLOT_H
|
||||
const pyGrid = [0, 0.25, 0.5, 0.75, 1].map((f) => ({ y: py(maxY * f), label: Math.round(maxY * f) }))
|
||||
const pxGrid = xTicks(rounds, hub.round_labels, px)
|
||||
|
||||
// Gap scales: symmetric around zero.
|
||||
const maxAbs = Math.max(10, Math.ceil(Math.max(...gaps.map(Math.abs), 1) / 10) * 10)
|
||||
const gy = (v: number) => PAD_T + GAP_PLOT_H / 2 - (v / maxAbs) * (GAP_PLOT_H / 2)
|
||||
const gyGrid = [maxAbs, 0, -maxAbs].map((v) => ({ y: gy(v), label: v > 0 ? `+${v}` : String(v) }))
|
||||
const gxGrid = xTicks(rounds, hub.round_labels, px)
|
||||
|
||||
const gapLeader = lastGap === 0 ? null : lastGap > 0 ? a : b
|
||||
const gapCaption = gapLeader
|
||||
? `${gapLeader.name_acronym} leads by ${fmtPts(Math.abs(lastGap))} pts after ${hub.round_labels[rounds - 1] ?? `R${rounds}`}.`
|
||||
: 'Dead level on points.'
|
||||
|
||||
return (
|
||||
<div className="rivalry" data-testid="champ-view-rivalry">
|
||||
{pickers}
|
||||
|
||||
<div className="rivalry-h2h" data-testid="rivalry-h2h">
|
||||
<div className="rivalry-h2h-score">
|
||||
<span className="rivalry-h2h-code mono" style={{ color: colorA }}>
|
||||
{a.name_acronym}
|
||||
</span>
|
||||
<span className="rivalry-h2h-num mono" data-testid="rivalry-h2h-num">
|
||||
{tally ? `${tally.a}–${tally.b}` : '—'}
|
||||
</span>
|
||||
<span className="rivalry-h2h-code mono" style={{ color: colorB }}>
|
||||
{b.name_acronym}
|
||||
</span>
|
||||
</div>
|
||||
<span className="rivalry-h2h-meta">
|
||||
Race head-to-head · {tally?.rounds.length ?? 0} round{(tally?.rounds.length ?? 0) === 1 ? '' : 's'} counted
|
||||
{tally && tally.skipped > 0 ? ` · ${tally.skipped} skipped` : ''}
|
||||
</span>
|
||||
{strip.length > 0 && (
|
||||
<div className="rivalry-strip" data-testid="rivalry-strip">
|
||||
<span className="rivalry-strip-label">Last {strip.length}</span>
|
||||
{strip.map((r) => (
|
||||
<span className="rivalry-chip" key={r.round} title={`${r.label}: ${a.name_acronym} P${r.posA} · ${b.name_acronym} P${r.posB}`}>
|
||||
<span className="rivalry-chip-round mono">{r.label}</span>
|
||||
<span className="rivalry-chip-winner mono" style={{ color: r.winner === 'a' ? colorA : colorB }}>
|
||||
{r.winner === 'a' ? a.name_acronym : b.name_acronym}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="rivalry-section" data-testid="rivalry-points-race">
|
||||
<div className="champ-chart-head">
|
||||
<span className="champ-chart-title mono">
|
||||
Points race — {a.name_acronym} vs {b.name_acronym}
|
||||
</span>
|
||||
<span className="champ-chart-meta">
|
||||
Rounds 1–{rounds} · {hub.season}
|
||||
</span>
|
||||
</div>
|
||||
<div className="champ-chart">
|
||||
<svg viewBox="0 0 1000 380" className="champ-chart-svg" preserveAspectRatio="none">
|
||||
{pyGrid.map((g) => (
|
||||
<g key={g.label}>
|
||||
<line x1={PAD_L} y1={g.y} x2={930} y2={g.y} stroke="var(--border)" strokeWidth="1" />
|
||||
<text x={40} y={g.y + 4} textAnchor="end" className="champ-chart-axis">
|
||||
{g.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{pxGrid.map((g, i) => (
|
||||
<text key={i} x={g.x} y={372} textAnchor="middle" className="champ-chart-axis">
|
||||
{g.label}
|
||||
</text>
|
||||
))}
|
||||
{[
|
||||
{ slot: 'a', d: a, color: colorA, dash: '0', values: cumA.slice(0, rounds) },
|
||||
{ slot: 'b', d: b, color: colorB, dash: sameTeam ? '5 4' : '0', values: cumB.slice(0, rounds) },
|
||||
].map((s) => (
|
||||
<g key={s.slot}>
|
||||
<polyline
|
||||
points={polyline(s.values, px, py)}
|
||||
fill="none"
|
||||
stroke={s.color}
|
||||
strokeWidth="2.4"
|
||||
strokeDasharray={s.dash}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx={px(s.values.length - 1)} cy={py(s.values[s.values.length - 1])} r="3" fill={s.color} />
|
||||
<text
|
||||
x={px(s.values.length - 1) + 8}
|
||||
y={py(s.values[s.values.length - 1]) + 4}
|
||||
fill={s.color}
|
||||
className="champ-chart-label"
|
||||
>
|
||||
{s.d.name_acronym}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rivalry-section" data-testid="rivalry-gap">
|
||||
<div className="champ-chart-head">
|
||||
<span className="champ-chart-title mono">
|
||||
Gap over season — {a.name_acronym} − {b.name_acronym}
|
||||
</span>
|
||||
<span className="champ-chart-meta">{gapCaption}</span>
|
||||
</div>
|
||||
<div className="champ-chart">
|
||||
<svg viewBox="0 0 1000 250" className="champ-chart-svg" preserveAspectRatio="none">
|
||||
{gyGrid.map((g) => (
|
||||
<g key={g.label}>
|
||||
<line
|
||||
x1={PAD_L}
|
||||
y1={g.y}
|
||||
x2={930}
|
||||
y2={g.y}
|
||||
stroke={g.label === '0' ? 'var(--border-2)' : 'var(--border)'}
|
||||
strokeWidth={g.label === '0' ? 1.5 : 1}
|
||||
/>
|
||||
<text x={40} y={g.y + 4} textAnchor="end" className="champ-chart-axis">
|
||||
{g.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{gxGrid.map((g, i) => (
|
||||
<text key={i} x={g.x} y={242} textAnchor="middle" className="champ-chart-axis">
|
||||
{g.label}
|
||||
</text>
|
||||
))}
|
||||
<polyline
|
||||
points={polyline(gaps, px, gy)}
|
||||
fill="none"
|
||||
stroke={colorA}
|
||||
strokeWidth="2.4"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx={px(gaps.length - 1)} cy={gy(lastGap)} r="3" fill={colorA} />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="rivalry-caption">
|
||||
Above the zero line: {a.name_acronym} ahead. Below: {b.name_acronym} ahead.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
163
frontend/src/components/RouteState.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { isTimeoutError, userFacingError } from '../lib/fetch'
|
||||
import { noticeMessage, type DataAvailability } from '../lib/availability'
|
||||
|
||||
export type { DataAvailability }
|
||||
|
||||
export function availabilityLabel(kind: DataAvailability): string {
|
||||
switch (kind) {
|
||||
case 'local':
|
||||
return 'Local'
|
||||
case 'partial':
|
||||
return 'Partial'
|
||||
case 'stale':
|
||||
return 'Stale'
|
||||
case 'archive':
|
||||
return 'Archive'
|
||||
case 'limited':
|
||||
return 'Limited'
|
||||
case 'missing':
|
||||
return 'Missing'
|
||||
}
|
||||
}
|
||||
|
||||
export function AvailabilityBadge({ kind, label }: { kind: DataAvailability; label?: string }) {
|
||||
return (
|
||||
<span className={`badge badge-${kind === 'archive' ? 'none' : kind}`} data-testid={`availability-${kind}`}>
|
||||
{label ?? availabilityLabel(kind)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export type RouteStateKind = 'loading' | 'empty' | 'error' | 'timeout'
|
||||
|
||||
interface RouteStateProps {
|
||||
kind: RouteStateKind
|
||||
title?: string
|
||||
message?: ReactNode
|
||||
error?: unknown
|
||||
onRetry?: () => void
|
||||
retrying?: boolean
|
||||
testId?: string
|
||||
/** Optional override for the Retry button's data-testid (defaults to none). */
|
||||
retryTestId?: string
|
||||
className?: string
|
||||
/** Optional availability strip (stale/limited/partial) above the state body. */
|
||||
availability?: DataAvailability
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const DEFAULT_TITLES: Record<RouteStateKind, string> = {
|
||||
loading: 'Loading…',
|
||||
empty: 'Nothing here yet',
|
||||
error: 'Could not load this view',
|
||||
timeout: 'Request timed out',
|
||||
}
|
||||
|
||||
const DEFAULT_MESSAGES: Record<RouteStateKind, string> = {
|
||||
loading: 'Fetching the latest local data.',
|
||||
empty: 'No data is available for this view yet.',
|
||||
error: 'Something went wrong. Retry to try again.',
|
||||
timeout: 'This request took too long. Check your connection, then retry.',
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared primary-route state surface: loading, empty, timeout/error + retry.
|
||||
* Retry is a real <button> (keyboard accessible) and callers should gate
|
||||
* concurrent refetches via React Query / deduped apiFetch.
|
||||
*/
|
||||
export function RouteState({
|
||||
kind,
|
||||
title,
|
||||
message,
|
||||
error,
|
||||
onRetry,
|
||||
retrying = false,
|
||||
testId,
|
||||
retryTestId,
|
||||
className = '',
|
||||
availability,
|
||||
children,
|
||||
}: RouteStateProps) {
|
||||
const resolvedKind: RouteStateKind =
|
||||
kind === 'error' && isTimeoutError(error) ? 'timeout' : kind
|
||||
|
||||
const resolvedMessage =
|
||||
message ??
|
||||
(error != null && (resolvedKind === 'error' || resolvedKind === 'timeout')
|
||||
? userFacingError(error)
|
||||
: DEFAULT_MESSAGES[resolvedKind])
|
||||
|
||||
const showRetry =
|
||||
(resolvedKind === 'error' || resolvedKind === 'timeout') && typeof onRetry === 'function'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`route-state route-state-${resolvedKind} ${className}`.trim()}
|
||||
data-testid={testId ?? `route-state-${resolvedKind}`}
|
||||
role={resolvedKind === 'error' || resolvedKind === 'timeout' ? 'alert' : undefined}
|
||||
>
|
||||
{availability && (
|
||||
<div className="route-state-availability">
|
||||
<AvailabilityBadge kind={availability} />
|
||||
</div>
|
||||
)}
|
||||
{resolvedKind === 'loading' ? (
|
||||
<div className="loading-state">{title ?? 'loading…'}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="route-state-title">{title ?? DEFAULT_TITLES[resolvedKind]}</div>
|
||||
<div className="route-state-message">{resolvedMessage}</div>
|
||||
{children}
|
||||
{showRetry && (
|
||||
<button
|
||||
type="button"
|
||||
className="route-state-retry"
|
||||
onClick={onRetry}
|
||||
disabled={retrying}
|
||||
aria-busy={retrying || undefined}
|
||||
data-testid={retryTestId}
|
||||
>
|
||||
{retrying ? 'Retrying…' : 'Retry'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface StaleNoticeProps {
|
||||
availability?: DataAvailability
|
||||
message?: string
|
||||
onRetry?: () => void
|
||||
retrying?: boolean
|
||||
testId?: string
|
||||
}
|
||||
|
||||
/** Inline notice when a successful payload is limited/stale/partial. */
|
||||
export function DataNotice({
|
||||
availability = 'stale',
|
||||
message,
|
||||
onRetry,
|
||||
retrying = false,
|
||||
testId = 'data-notice',
|
||||
}: StaleNoticeProps) {
|
||||
return (
|
||||
<div className="data-notice" data-testid={testId} role="status">
|
||||
<AvailabilityBadge kind={availability} />
|
||||
<span className="data-notice-text">{message ?? noticeMessage(availability)}</span>
|
||||
{onRetry && (
|
||||
<button
|
||||
type="button"
|
||||
className="route-state-retry data-notice-retry"
|
||||
onClick={onRetry}
|
||||
disabled={retrying}
|
||||
aria-busy={retrying || undefined}
|
||||
>
|
||||
{retrying ? 'Retrying…' : 'Retry'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||