Compare commits
39 Commits
feat/issue
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8bd183c26 | ||
|
|
01ec1f24e7 | ||
|
|
3f022a0cf9 | ||
|
|
5970649a9c | ||
|
|
7212eb9b44 | ||
|
|
785dccdca2 | ||
|
|
e8de2068c1 | ||
|
|
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 | ||
|
|
2d49a8bd00 |
@@ -24,7 +24,7 @@ bin/dev CLI: `dev implement <issue#> --harness <name> [--dry-run]`
|
||||
- **Groom** (interactive, Claude): `/groom <issue#>` runs a seeded grill-me → writes a
|
||||
Ready spec into the issue body → sets Effort/Priority → leaves Stage at `Research`.
|
||||
You review and flip to `Ready`.
|
||||
- **Implement** (any harness): `.agents/bin/dev implement <issue#> --harness <name>`
|
||||
- **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
|
||||
@@ -42,8 +42,17 @@ local adapter state.
|
||||
## Adding / fixing a harness
|
||||
|
||||
Edit one function in `harnesses.sh`: `harness_<name> <workdir> <promptfile>`, running the
|
||||
tool non-interactively in `<workdir>` on the prompt. `claude`/`codex`/`opencode` are
|
||||
wired; `pi`/`cursor` are stubs — confirm their headless flags before trusting.
|
||||
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).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# .agents/bin/dev — box-box agentic dev CLI. Works from any harness's shell.
|
||||
#
|
||||
# dev implement <issue#> --harness <claude|codex|opencode|pi|cursor> [--dry-run] [--base <branch>]
|
||||
# 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).
|
||||
@@ -15,7 +15,7 @@ box-box dev CLI
|
||||
|
||||
dev implement <issue#> --harness <name> [--dry-run] [--base <branch>]
|
||||
|
||||
harnesses: claude, codex, opencode (supported) · pi, cursor (verify flags in .agents/harnesses.sh)
|
||||
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
|
||||
|
||||
@@ -11,6 +11,18 @@
|
||||
|
||||
# ---- 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"
|
||||
( cd "$dir" && claude -p "$(cat "$prompt")" --permission-mode acceptEdits )
|
||||
@@ -31,14 +43,11 @@ harness_cursor() { # Cursor CLI agent — composer-2.5, hea
|
||||
( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --model composer-2.5 --force --trust )
|
||||
}
|
||||
|
||||
harness_agy() { # Antigravity CLI — UNRELIABLE headless (2026-07): with
|
||||
# --new-project it ignores the prompt and asks to scaffold a project; without it, it
|
||||
# resumes the previous conversation (silently keeping its old model — --model only
|
||||
# applies to new conversations) and can hang past the print timeout. Do not trust for
|
||||
# dispatch until fixed upstream; verify with a trivial prompt first.
|
||||
local dir="$1" prompt="$2"
|
||||
( cd "$dir" && agy --print --print-timeout 60m \
|
||||
--model="Gemini 3.1 Pro (High)" --dangerously-skip-permissions "$(cat "$prompt")" )
|
||||
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) ----
|
||||
|
||||
@@ -52,6 +52,15 @@ dispatch() { # <issue#> <harness> [--dry-run] [--base <branch>]
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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.
|
||||
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]
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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.
|
||||
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]
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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.
|
||||
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>
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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.
|
||||
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]
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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.
|
||||
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>
|
||||
---
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
46
cmd/main.go
@@ -27,6 +27,7 @@ func main() {
|
||||
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")
|
||||
@@ -76,11 +77,21 @@ func main() {
|
||||
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, or --ingest-news may be set")
|
||||
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)
|
||||
@@ -88,7 +99,7 @@ func main() {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
yearVal := *ingestYear
|
||||
if *backfillSeason != 0 {
|
||||
yearVal = *backfillSeason
|
||||
@@ -174,6 +185,35 @@ func runIngestion(client *api.OpenF1Client, year, meetingKey, sessionKey int, fo
|
||||
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)
|
||||
|
||||
@@ -275,7 +315,7 @@ func runCoverageReport(year int, dbPath string) error {
|
||||
}
|
||||
|
||||
fmt.Printf("\n--- Season %d Coverage Report ---\n\n", year)
|
||||
fmt.Printf("%-35s | %-5s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s\n",
|
||||
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))
|
||||
|
||||
|
||||
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 |
@@ -1,6 +1,11 @@
|
||||
import type {
|
||||
ArticleContent,
|
||||
CarDataSample,
|
||||
ChampionshipHub,
|
||||
DriverSummary,
|
||||
EnrichedGrid,
|
||||
EnrichedResult,
|
||||
LapsComparisonResponse,
|
||||
LiveStateResponse,
|
||||
LiveSessionMeta,
|
||||
Meeting,
|
||||
@@ -38,7 +43,11 @@ export async function fetchLocalMeetings(year: number): Promise<Meeting[]> {
|
||||
}
|
||||
|
||||
export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
|
||||
const res = await fetch(`/api/v1/meetings?year=${year}&source=openf1`)
|
||||
return fetchMeetings(year, 'openf1')
|
||||
}
|
||||
|
||||
export async function fetchMeetings(year: number, source = 'auto'): Promise<Meeting[]> {
|
||||
const res = await fetch(`/api/v1/meetings?year=${year}&source=${source}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
@@ -46,6 +55,32 @@ export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
|
||||
return Array.isArray(meetings) ? meetings : []
|
||||
}
|
||||
|
||||
export async function fetchResults(sessionKey: number, source = 'auto'): Promise<EnrichedResult[]> {
|
||||
const res = await fetch(`/api/v1/results?session_key=${sessionKey}&source=${source}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
const results = await res.json()
|
||||
return Array.isArray(results) ? results : []
|
||||
}
|
||||
|
||||
export async function fetchStartingGrid(sessionKey: number, source = 'auto'): Promise<EnrichedGrid[]> {
|
||||
const res = await fetch(`/api/v1/grid?session_key=${sessionKey}&source=${source}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
const grid = await res.json()
|
||||
return Array.isArray(grid) ? grid : []
|
||||
}
|
||||
|
||||
export async function fetchTrackOutline(circuitKey: number, year: number): Promise<TrackOutline | null> {
|
||||
const res = await fetch(`/api/v1/track-outline?circuit_key=${circuitKey}&year=${year}`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
if (data?.error || !Array.isArray(data?.points) || data.points.length < 2) return null
|
||||
return data as TrackOutline
|
||||
}
|
||||
|
||||
export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise<Session[]> {
|
||||
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
|
||||
if (!res.ok) {
|
||||
@@ -74,6 +109,19 @@ export async function fetchChampionshipHub(year?: number): Promise<ChampionshipH
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchDriverSummary(
|
||||
driverNumber: number,
|
||||
year?: number,
|
||||
): Promise<DriverSummary> {
|
||||
const params = new URLSearchParams({ driver_number: String(driverNumber) })
|
||||
if (year) params.set('year', String(year))
|
||||
const res = await fetch(`/api/v1/driver/summary?${params.toString()}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchLiveState(): Promise<LiveStateResponse> {
|
||||
const res = await fetch('/api/v1/live/state')
|
||||
if (!res.ok) {
|
||||
@@ -126,3 +174,32 @@ export async function markNewsRead(articleUrl: string): Promise<void> {
|
||||
body: JSON.stringify({ url: articleUrl }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchTelemetry(
|
||||
sessionKey: number,
|
||||
driverNumber: number,
|
||||
): Promise<CarDataSample[]> {
|
||||
const res = await fetch(
|
||||
`/api/v1/telemetry?session_key=${sessionKey}&driver_number=${driverNumber}`,
|
||||
)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
return Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
export async function fetchLapsComparison(
|
||||
sessionKey: number,
|
||||
drivers?: number[],
|
||||
): Promise<LapsComparisonResponse> {
|
||||
const params = new URLSearchParams({ session_key: String(sessionKey) })
|
||||
if (drivers?.length) {
|
||||
params.set('drivers', drivers.join(','))
|
||||
}
|
||||
const res = await fetch(`/api/v1/laps/comparison?${params}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,9 @@ export function Nav() {
|
||||
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
||||
Race Hub
|
||||
</Link>
|
||||
<Link to="/preview" activeProps={{ className: 'active' }}>
|
||||
Preview
|
||||
</Link>
|
||||
<Link to="/championship" activeProps={{ className: 'active' }}>
|
||||
Championship
|
||||
</Link>
|
||||
|
||||
@@ -12,7 +12,7 @@ interface Props {
|
||||
hasStints: boolean
|
||||
}
|
||||
|
||||
export function StrategyView({ results, stints, pit_stops: _pitStops, hasStints }: Props) {
|
||||
export function StrategyView({ results, stints, pit_stops, hasStints }: Props) {
|
||||
if (!hasStints) {
|
||||
return (
|
||||
<div>
|
||||
@@ -88,6 +88,10 @@ export function StrategyView({ results, stints, pit_stops: _pitStops, hasStints
|
||||
lapEnd: s.lap_end,
|
||||
isNew: s.tyre_age_at_start === 0,
|
||||
})),
|
||||
pitStops: pit_stops
|
||||
.filter((p) => p.driver_number === driver.driver_number)
|
||||
.map((p) => p.lap_number)
|
||||
.sort((a, b) => a - b),
|
||||
}))
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ export type Tab =
|
||||
| 'overview'
|
||||
| 'race_story'
|
||||
| 'strategy'
|
||||
| 'compare'
|
||||
| 'lap_data'
|
||||
| 'conditions'
|
||||
| 'race_control'
|
||||
@@ -11,6 +12,7 @@ const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'race_story', label: 'Race Story' },
|
||||
{ id: 'strategy', label: 'Strategy' },
|
||||
{ id: 'compare', label: 'Compare' },
|
||||
{ id: 'lap_data', label: 'Lap Data' },
|
||||
{ id: 'conditions', label: 'Conditions' },
|
||||
{ id: 'race_control', label: 'Race Control' },
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface StintTimelineRow {
|
||||
label: string
|
||||
color: string
|
||||
stints: StintTimelineStint[]
|
||||
/** Lap numbers where the driver pitted; optional — rows without stops render normally. */
|
||||
pitStops?: number[]
|
||||
}
|
||||
|
||||
interface TyreStintTimelineProps {
|
||||
@@ -57,6 +59,14 @@ function stintBarW(stint: StintTimelineStint, totalLaps: number): number {
|
||||
return Math.max(2, (stintLength(stint) / totalLaps) * BAR_W)
|
||||
}
|
||||
|
||||
function pitMarkerX(lapNumber: number, totalLaps: number): number {
|
||||
return LEFT + ((lapNumber - 1) / totalLaps) * BAR_W
|
||||
}
|
||||
|
||||
function pitMarkerTitle(driverLabel: string, lapNumber: number): string {
|
||||
return `${driverLabel} pit stop · L${lapNumber}`
|
||||
}
|
||||
|
||||
function axisTicks(totalLaps: number): number[] {
|
||||
const ticks: number[] = []
|
||||
for (let lap = 0; lap <= totalLaps; lap += 10) {
|
||||
@@ -130,6 +140,21 @@ export function TyreStintTimeline({ rows, totalLaps }: TyreStintTimelineProps) {
|
||||
<title>{stintTitle(stint)}</title>
|
||||
</rect>
|
||||
))}
|
||||
|
||||
{(row.pitStops ?? []).map((lapNumber, pi) => (
|
||||
<line
|
||||
key={`pit-${pi}`}
|
||||
x1={pitMarkerX(lapNumber, safeTotal)}
|
||||
x2={pitMarkerX(lapNumber, safeTotal)}
|
||||
y1={BAR_Y - 3}
|
||||
y2={BAR_Y + BAR_H + 3}
|
||||
className="stint-timeline__pit-marker"
|
||||
data-testid="pit-marker"
|
||||
data-lap={lapNumber}
|
||||
>
|
||||
<title>{pitMarkerTitle(row.label, lapNumber)}</title>
|
||||
</line>
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
72
frontend/src/components/live/EventRail.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useAutoAnimate } from '@formkit/auto-animate/react'
|
||||
import {
|
||||
Flag,
|
||||
LogOut,
|
||||
Megaphone,
|
||||
OctagonX,
|
||||
Swords,
|
||||
Timer,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Wrench,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { LiveDriverInfo } from '../../types'
|
||||
import type { LiveEvent, LiveEventKind } from '../../lib/events'
|
||||
import { teamColor } from '../../utils'
|
||||
import '../../styles/event-rail.css'
|
||||
|
||||
interface Props {
|
||||
events: LiveEvent[]
|
||||
driverInfo?: Record<string, LiveDriverInfo>
|
||||
}
|
||||
|
||||
const KIND_ICONS: Record<LiveEventKind, LucideIcon> = {
|
||||
overtake: Swords,
|
||||
'position-gain': TrendingUp,
|
||||
'position-loss': TrendingDown,
|
||||
'pit-in': Wrench,
|
||||
'pit-out': LogOut,
|
||||
'personal-best': Timer,
|
||||
'fastest-lap': Zap,
|
||||
retirement: OctagonX,
|
||||
'track-status': Flag,
|
||||
'race-control': Megaphone,
|
||||
}
|
||||
|
||||
export function EventRail({ events, driverInfo }: Props) {
|
||||
const [listRef] = useAutoAnimate<HTMLDivElement>()
|
||||
|
||||
return (
|
||||
<section className="event-rail panel-glass" data-testid="event-rail">
|
||||
<div className="sec-header sticky-header">
|
||||
<span className="sec-title">What Just Happened</span>
|
||||
{events.length > 0 && <span className="sec-meta">{events.length} events</span>}
|
||||
</div>
|
||||
{events.length === 0 ? (
|
||||
<div className="missing-notice">Events will appear here as the session unfolds.</div>
|
||||
) : (
|
||||
<div className="event-rail-list" ref={listRef}>
|
||||
{events.map((event) => {
|
||||
const Icon = KIND_ICONS[event.kind] ?? Megaphone
|
||||
const colour = driverInfo?.[event.racingNumbers[0]]?.TeamColour
|
||||
const accent = event.racingNumbers.length > 0 ? teamColor(colour) : 'var(--border-2)'
|
||||
return (
|
||||
<div className={`event-rail-row event-kind-${event.kind}`} key={event.id} style={{ borderLeftColor: accent }}>
|
||||
<span className="event-rail-icon" aria-hidden="true">
|
||||
<Icon size={14} />
|
||||
</span>
|
||||
<div className="event-rail-body">
|
||||
<span className="event-rail-headline">{event.headline}</span>
|
||||
{event.detail && <span className="event-rail-detail">{event.detail}</span>}
|
||||
</div>
|
||||
{event.lap > 0 && <span className="event-rail-lap">L{event.lap}</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -5,18 +5,20 @@ import { WeatherStrip } from './WeatherStrip'
|
||||
|
||||
interface Props {
|
||||
isLive: boolean
|
||||
isArchive?: boolean
|
||||
snapshot: LiveStreamData
|
||||
rows: LiveTimingRow[]
|
||||
connection: 'connected' | 'connecting' | 'disconnected' | 'error'
|
||||
now: number
|
||||
}
|
||||
|
||||
export function SessionBanner({ isLive, snapshot, rows, connection, now }: Props) {
|
||||
export function SessionBanner({ isLive, isArchive = false, snapshot, rows, connection, now }: Props) {
|
||||
const session = snapshot.Session
|
||||
const clock = extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now)
|
||||
const display = liveSessionDisplay(session, rows)
|
||||
const atRiskLabel =
|
||||
display.atRiskStart && display.atRiskEnd ? `P${display.atRiskStart}-P${display.atRiskEnd} at risk` : ''
|
||||
const stateLabel = isLive ? 'live' : isArchive ? 'archive' : 'stale'
|
||||
|
||||
return (
|
||||
<section className="live-banner">
|
||||
@@ -39,7 +41,7 @@ export function SessionBanner({ isLive, snapshot, rows, connection, now }: Props
|
||||
<span>
|
||||
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
|
||||
</span>
|
||||
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{isLive ? 'live' : 'stale'}</span>
|
||||
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{stateLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
tyreLabel,
|
||||
} from '../../lib/live'
|
||||
import type { GapHistoryMap } from '../../lib/gapHistory'
|
||||
import { parseIntervalSeconds } from '../../lib/gapHistory'
|
||||
import { intervalMeaning } from '../../lib/meaning'
|
||||
import { Meaning } from '../Meaning'
|
||||
import { GapSparkline } from './GapSparkline'
|
||||
import { StintHistory } from './StintHistory'
|
||||
import { Pin } from 'lucide-react'
|
||||
@@ -100,7 +103,11 @@ export function TimingTower({
|
||||
const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition
|
||||
|
||||
const gapText = gapMode === 'interval' && isRace ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader
|
||||
|
||||
const intervalAnnotation =
|
||||
gapMode === 'interval' && isRace && row.Position > 1
|
||||
? intervalMeaning(parseIntervalSeconds(gapText))
|
||||
: null
|
||||
|
||||
const renderSector = (idx: number) => {
|
||||
const sec = driver.Sectors?.[idx]
|
||||
if (!sec) return '-'
|
||||
@@ -149,7 +156,14 @@ export function TimingTower({
|
||||
<td className={driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono'}>
|
||||
{driver.LastLapTime || '-'}
|
||||
</td>
|
||||
<td className="mono">{gapText || '-'}</td>
|
||||
<td className="mono">
|
||||
<Meaning
|
||||
value={gapText || '-'}
|
||||
meaning={intervalAnnotation?.caption}
|
||||
title={intervalAnnotation?.title}
|
||||
tone={intervalAnnotation?.tone}
|
||||
/>
|
||||
</td>
|
||||
|
||||
{isRace && (
|
||||
<td className="spark-cell">
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
recordStintSamples,
|
||||
stintInputFromRow,
|
||||
} from '../../lib/tyredeg'
|
||||
import { tyreAgeMeaning } from '../../lib/meaning'
|
||||
import { Meaning } from '../Meaning'
|
||||
import '../../styles/tyredeg.css'
|
||||
|
||||
const TOP_DRIVER_COUNT = 10
|
||||
@@ -92,12 +94,20 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
||||
{visible.map((row) => {
|
||||
const model = degradationModel(stints[row.RacingNumber]?.samples ?? [])
|
||||
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
|
||||
const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age)
|
||||
return (
|
||||
<div className="tyredeg-row" key={row.RacingNumber} data-testid="tyredeg-row">
|
||||
<span className="tyredeg-pos mono">P{row.Position}</span>
|
||||
<span className="drv-bar" style={{ background: teamColor(row.Info?.TeamColour) }} />
|
||||
<span className="drv-code">{driverCode(row)}</span>
|
||||
<span className={`tyre-badge ${compoundClass(row.Tyre?.Compound)}`}>{tyreLabel(row.Tyre)}</span>
|
||||
<span className={`tyre-badge ${compoundClass(row.Tyre?.Compound)}`}>
|
||||
<Meaning
|
||||
value={tyreLabel(row.Tyre)}
|
||||
meaning={ageAnnotation?.caption}
|
||||
title={ageAnnotation?.title}
|
||||
tone={ageAnnotation?.tone}
|
||||
/>
|
||||
</span>
|
||||
{model ? (
|
||||
<>
|
||||
<span className={`tyredeg-trend tyredeg-trend-${model.trend}`}>
|
||||
|
||||
202
frontend/src/lib/compare.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
// Driver comparison mapping — pure functions for telemetry traces and lap deltas.
|
||||
|
||||
import type { TelemetryTraceSeries } from '../components/charts/TelemetryTraceChart'
|
||||
import type { DeltaSeries } from '../lib/delta'
|
||||
import type {
|
||||
CarDataSample,
|
||||
ComparisonLap,
|
||||
Driver,
|
||||
EnrichedResult,
|
||||
LapsComparisonResponse,
|
||||
} from '../types'
|
||||
import { compareFinishPosition, teamColor } from '../utils'
|
||||
|
||||
function sortDriversByResults(drivers: Driver[], results: EnrichedResult[]): Driver[] {
|
||||
const order = new Map(
|
||||
[...results]
|
||||
.filter((r) => r.position > 0)
|
||||
.sort((a, b) => compareFinishPosition(a.position, b.position))
|
||||
.map((r, i) => [r.driver_number, i]),
|
||||
)
|
||||
return [...drivers].sort((a, b) => {
|
||||
const ao = order.get(a.driver_number) ?? 999
|
||||
const bo = order.get(b.driver_number) ?? 999
|
||||
if (ao !== bo) return ao - bo
|
||||
return a.driver_number - b.driver_number
|
||||
})
|
||||
}
|
||||
|
||||
/** Default compare pair: top two classified finishers, else first two session drivers. */
|
||||
export function defaultCompareDriverNumbers(
|
||||
results: EnrichedResult[],
|
||||
drivers: Driver[],
|
||||
): [number, number] | null {
|
||||
const classified = [...results]
|
||||
.filter((r) => r.position > 0)
|
||||
.sort((a, b) => compareFinishPosition(a.position, b.position))
|
||||
|
||||
if (classified.length >= 2) {
|
||||
return [classified[0].driver_number, classified[1].driver_number]
|
||||
}
|
||||
|
||||
if (drivers.length >= 2) {
|
||||
const sorted = sortDriversByResults(drivers, results)
|
||||
return [sorted[0].driver_number, sorted[1].driver_number]
|
||||
}
|
||||
|
||||
if (classified.length === 1 && drivers.length >= 1) {
|
||||
const other = drivers.find((d) => d.driver_number !== classified[0].driver_number)
|
||||
if (other) return [classified[0].driver_number, other.driver_number]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** Lap records → per-lap duration array (index 0 = lap 1); null for pit/out laps. */
|
||||
export function lapsToLapTimes(laps: ComparisonLap[]): (number | null)[] {
|
||||
if (laps.length === 0) return []
|
||||
|
||||
const maxLap = laps.reduce((max, lap) => Math.max(max, lap.lap_number), 0)
|
||||
const times: (number | null)[] = Array.from({ length: maxLap }, () => null)
|
||||
|
||||
for (const lap of laps) {
|
||||
const idx = lap.lap_number - 1
|
||||
if (idx < 0) continue
|
||||
if (lap.is_pit_out_lap || lap.lap_duration == null || lap.lap_duration <= 0) {
|
||||
times[idx] = null
|
||||
} else {
|
||||
times[idx] = lap.lap_duration
|
||||
}
|
||||
}
|
||||
|
||||
return times
|
||||
}
|
||||
|
||||
export function findBestLap(laps: ComparisonLap[]): ComparisonLap | null {
|
||||
let best: ComparisonLap | null = null
|
||||
for (const lap of laps) {
|
||||
if (lap.lap_duration == null || lap.lap_duration <= 0) continue
|
||||
if (!best || (best.lap_duration ?? Infinity) > lap.lap_duration) {
|
||||
best = lap
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** Keep car-data samples whose timestamps fall within a lap window. */
|
||||
export function filterCarDataToLap(
|
||||
samples: CarDataSample[],
|
||||
lap: ComparisonLap,
|
||||
): CarDataSample[] {
|
||||
if (!lap.date_start || samples.length === 0) return samples
|
||||
|
||||
const start = new Date(lap.date_start).getTime()
|
||||
if (!Number.isFinite(start)) return samples
|
||||
|
||||
const end = start + (lap.lap_duration ?? 0) * 1000 + 500
|
||||
|
||||
return samples.filter((s) => {
|
||||
const t = new Date(s.date).getTime()
|
||||
return Number.isFinite(t) && t >= start && t <= end
|
||||
})
|
||||
}
|
||||
|
||||
export function carDataToTraceSeries(
|
||||
samples: CarDataSample[],
|
||||
label: string,
|
||||
color: string,
|
||||
): TelemetryTraceSeries {
|
||||
return {
|
||||
label,
|
||||
color,
|
||||
samples: samples.map((s) => ({
|
||||
speed: s.speed,
|
||||
throttle: s.throttle,
|
||||
brake: s.brake,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBestLapTraceSeries(
|
||||
carData: CarDataSample[],
|
||||
laps: ComparisonLap[],
|
||||
label: string,
|
||||
color: string,
|
||||
): TelemetryTraceSeries | null {
|
||||
const best = findBestLap(laps)
|
||||
if (!best) return null
|
||||
|
||||
const filtered = filterCarDataToLap(carData, best)
|
||||
if (filtered.length === 0) return null
|
||||
|
||||
return carDataToTraceSeries(filtered, label, color)
|
||||
}
|
||||
|
||||
function driverMeta(
|
||||
driverNumber: number,
|
||||
comparison: LapsComparisonResponse | undefined,
|
||||
drivers: Driver[],
|
||||
): { label: string; color: string; laps: ComparisonLap[] } {
|
||||
const comp = comparison?.drivers.find((d) => d.driver_number === driverNumber)
|
||||
const session = drivers.find((d) => d.driver_number === driverNumber)
|
||||
return {
|
||||
label: comp?.name_acronym || session?.name_acronym || `#${driverNumber}`,
|
||||
color: teamColor(comp?.team_colour || session?.team_colour),
|
||||
laps: comp?.laps ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export function comparisonToDeltaSeries(
|
||||
comparison: LapsComparisonResponse,
|
||||
driverNumbers: [number, number],
|
||||
drivers: Driver[],
|
||||
): DeltaSeries[] {
|
||||
return driverNumbers.map((dn) => {
|
||||
const meta = driverMeta(dn, comparison, drivers)
|
||||
return {
|
||||
label: meta.label,
|
||||
color: meta.color,
|
||||
lapTimes: lapsToLapTimes(meta.laps),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function formatPitLapsCaption(
|
||||
pitLaps: Record<string, number[]>,
|
||||
driverNumbers: [number, number],
|
||||
comparison: LapsComparisonResponse | undefined,
|
||||
drivers: Driver[],
|
||||
): string | null {
|
||||
const parts: string[] = []
|
||||
|
||||
for (const dn of driverNumbers) {
|
||||
const laps = pitLaps[String(dn)]
|
||||
if (!laps?.length) continue
|
||||
const meta = driverMeta(dn, comparison, drivers)
|
||||
parts.push(`${meta.label}: L${laps.join(', L')}`)
|
||||
}
|
||||
|
||||
return parts.length > 0 ? `Pit stops — ${parts.join(' · ')}` : null
|
||||
}
|
||||
|
||||
export function compareDriverOptions(
|
||||
drivers: Driver[],
|
||||
results: EnrichedResult[],
|
||||
): Driver[] {
|
||||
if (drivers.length > 0) {
|
||||
return sortDriversByResults(drivers, results)
|
||||
}
|
||||
return results.map((r) => ({
|
||||
driver_number: r.driver_number,
|
||||
name_acronym: r.name_acronym,
|
||||
full_name: r.full_name,
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
team_name: r.team_name,
|
||||
team_colour: r.team_colour,
|
||||
headshot_url: '',
|
||||
broadcast_name: r.full_name,
|
||||
session_key: r.session_key,
|
||||
meeting_key: r.meeting_key,
|
||||
}))
|
||||
}
|
||||
@@ -21,15 +21,31 @@ export function formatDeltaSeconds(delta: number): string {
|
||||
return `${sign}${delta.toFixed(1)}s`
|
||||
}
|
||||
|
||||
function buildCumulative(lapTimes: ReadonlyArray<number | null>): number[] {
|
||||
/**
|
||||
* Cumulative lap time aligned to reference-valid laps only.
|
||||
* Laps where the reference is null are skipped for every series so later deltas
|
||||
* do not compare against a frozen baseline while challengers keep accumulating.
|
||||
*/
|
||||
function buildAlignedCumulative(
|
||||
lapTimes: ReadonlyArray<number | null>,
|
||||
referenceLapTimes: ReadonlyArray<number | null>,
|
||||
): number[] {
|
||||
const cumulative: number[] = []
|
||||
let running = 0
|
||||
for (const lap of lapTimes) {
|
||||
if (lap !== null) {
|
||||
const length = Math.max(lapTimes.length, referenceLapTimes.length)
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (referenceLapTimes[i] == null) {
|
||||
cumulative.push(running)
|
||||
continue
|
||||
}
|
||||
const lap = lapTimes[i]
|
||||
if (lap != null) {
|
||||
running += lap
|
||||
}
|
||||
cumulative.push(running)
|
||||
}
|
||||
|
||||
return cumulative
|
||||
}
|
||||
|
||||
@@ -47,7 +63,8 @@ function resolveReference(
|
||||
/**
|
||||
* Compute per-lap cumulative time delta for each non-reference driver.
|
||||
* Positive = behind reference; negative = ahead.
|
||||
* Null laps carry cumulative forward but emit null in deltas (skip when plotting).
|
||||
* Deltas are only emitted where the reference lap is valid; reference-null laps
|
||||
* gap every series. Challenger-null laps gap only that driver's line.
|
||||
*/
|
||||
export function computeCumulativeDeltas(
|
||||
series: ReadonlyArray<DeltaSeries>,
|
||||
@@ -56,24 +73,22 @@ export function computeCumulativeDeltas(
|
||||
const reference = resolveReference(series, referenceLabel)
|
||||
if (!reference) return []
|
||||
|
||||
const refCumulative = buildCumulative(reference.lapTimes)
|
||||
const refLapTimes = reference.lapTimes
|
||||
const refCumulative = buildAlignedCumulative(refLapTimes, refLapTimes)
|
||||
|
||||
return series
|
||||
.filter((s) => s.label !== reference.label)
|
||||
.map((driver) => {
|
||||
const driverCumulative = buildCumulative(driver.lapTimes)
|
||||
const driverCumulative = buildAlignedCumulative(driver.lapTimes, refLapTimes)
|
||||
const lapCount = Math.max(driver.lapTimes.length, refCumulative.length)
|
||||
const deltas: (number | null)[] = []
|
||||
|
||||
for (let i = 0; i < lapCount; i++) {
|
||||
if (driver.lapTimes[i] === null) {
|
||||
if (refLapTimes[i] == null || driver.lapTimes[i] == null) {
|
||||
deltas.push(null)
|
||||
continue
|
||||
}
|
||||
const refValue = refCumulative[i] ?? refCumulative[refCumulative.length - 1] ?? 0
|
||||
const driverValue =
|
||||
driverCumulative[i] ?? driverCumulative[driverCumulative.length - 1] ?? 0
|
||||
deltas.push(driverValue - refValue)
|
||||
deltas.push(driverCumulative[i] - refCumulative[i])
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
69
frontend/src/lib/driverProfile.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { DriverSummaryRound } from '../types'
|
||||
|
||||
/** One round mapped for the grid-vs-finish chart and results table. */
|
||||
export interface RoundDelta {
|
||||
round: number // 1-based round index within the completed season
|
||||
label: string // short GP label, e.g. "Bahrain"
|
||||
grid: number | null
|
||||
finish: number | null
|
||||
/** grid − finish: positive = places gained on Sunday, null when unknowable. */
|
||||
delta: number | null
|
||||
points: number
|
||||
status: 'classified' | 'dnf' | 'dns' | 'dsq' | 'absent'
|
||||
}
|
||||
|
||||
/** Strip the "Grand Prix" boilerplate so round labels fit chart axes. */
|
||||
export function shortGpLabel(meetingName: string): string {
|
||||
const short = meetingName
|
||||
.replace(/\s*grand prix\s*/i, ' ')
|
||||
.replace(/\s+gp\s*$/i, ' ')
|
||||
.trim()
|
||||
return short || meetingName
|
||||
}
|
||||
|
||||
function roundStatus(r: DriverSummaryRound): RoundDelta['status'] {
|
||||
if (r.dns) return 'dns'
|
||||
if (r.dsq) return 'dsq'
|
||||
if (r.dnf) return 'dnf'
|
||||
if (r.race_position <= 0 && r.grid_position <= 0) return 'absent'
|
||||
return 'classified'
|
||||
}
|
||||
|
||||
/**
|
||||
* Map summary rounds to grid-vs-finish deltas. Positions of 0 mean "no data"
|
||||
* (driver absent, pit-lane start, …) and become null; a delta is only computed
|
||||
* when both ends are known and the driver actually started the race.
|
||||
*/
|
||||
export function gridFinishDeltas(rounds: DriverSummaryRound[]): RoundDelta[] {
|
||||
return rounds.map((r, i) => {
|
||||
const status = roundStatus(r)
|
||||
const grid = r.grid_position > 0 ? r.grid_position : null
|
||||
const finish = r.race_position > 0 ? r.race_position : null
|
||||
const delta =
|
||||
grid != null && finish != null && status !== 'dns' && status !== 'absent'
|
||||
? grid - finish
|
||||
: null
|
||||
return {
|
||||
round: i + 1,
|
||||
label: shortGpLabel(r.meeting_name),
|
||||
grid,
|
||||
finish,
|
||||
delta,
|
||||
points: r.points,
|
||||
status,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 'P4' for known positions, '—' when unknown. */
|
||||
export function formatPosition(pos: number | null): string {
|
||||
return pos != null && pos > 0 ? `P${pos}` : '—'
|
||||
}
|
||||
|
||||
/** '+3' (gained), '−2' (lost), '=' (held), '—' (unknown). */
|
||||
export function formatDelta(delta: number | null): string {
|
||||
if (delta == null) return '—'
|
||||
if (delta > 0) return `+${delta}`
|
||||
if (delta < 0) return `−${Math.abs(delta)}`
|
||||
return '='
|
||||
}
|
||||
318
frontend/src/lib/events.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
// "What just happened" event synthesis for the live page.
|
||||
// Pure functions only — no React, no side effects — so everything is unit-testable.
|
||||
// Mirrors the gapHistory/battles precedent: successive LiveStreamData snapshots
|
||||
// in, typed events out; the page owns the buffer, this module owns the diffing.
|
||||
|
||||
import type {
|
||||
LiveDriverData,
|
||||
LiveRCMessage,
|
||||
LiveSessionMeta,
|
||||
LiveStreamData,
|
||||
} from '../types'
|
||||
|
||||
export const MAX_EVENTS = 30
|
||||
|
||||
export type LiveEventKind =
|
||||
| 'overtake'
|
||||
| 'position-gain'
|
||||
| 'position-loss'
|
||||
| 'pit-in'
|
||||
| 'pit-out'
|
||||
| 'personal-best'
|
||||
| 'fastest-lap'
|
||||
| 'retirement'
|
||||
| 'track-status'
|
||||
| 'race-control'
|
||||
|
||||
export interface LiveEvent {
|
||||
id: string
|
||||
kind: LiveEventKind
|
||||
lap: number
|
||||
timestamp: string
|
||||
headline: string
|
||||
detail?: string
|
||||
racingNumbers: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity of the running session, used to reset the event buffer when a new
|
||||
* session starts. LiveSessionMeta carries no numeric session key, so the
|
||||
* meeting + session name/type triple stands in for one.
|
||||
*/
|
||||
export function sessionSignature(session: LiveSessionMeta | null | undefined): string {
|
||||
if (!session) return ''
|
||||
return [session.MeetingName, session.SessionName, session.SessionType].join('|')
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend freshly diffed events onto the newest-first buffer, dropping any
|
||||
* whose id is already present (re-renders and replayed diffs must not
|
||||
* duplicate) and evicting the oldest events beyond `cap`.
|
||||
*/
|
||||
export function appendEvents(
|
||||
buffer: ReadonlyArray<LiveEvent>,
|
||||
incoming: ReadonlyArray<LiveEvent>,
|
||||
cap = MAX_EVENTS,
|
||||
): LiveEvent[] {
|
||||
const seen = new Set(buffer.map((event) => event.id))
|
||||
const fresh = incoming.filter((event) => {
|
||||
if (seen.has(event.id)) return false
|
||||
seen.add(event.id)
|
||||
return true
|
||||
})
|
||||
if (fresh.length === 0) return [...buffer]
|
||||
// Within one diff the array is oldest-first; reverse so the last-produced
|
||||
// event ends up at the top of the newest-first rail.
|
||||
return [...fresh.reverse(), ...buffer].slice(0, cap)
|
||||
}
|
||||
|
||||
const TRACK_STATUS_HEADLINES: Record<string, string> = {
|
||||
'1': 'Green flag — track clear',
|
||||
'2': 'Yellow flag',
|
||||
'4': 'Safety Car deployed',
|
||||
'5': 'Red flag — session stopped',
|
||||
'6': 'Virtual Safety Car deployed',
|
||||
'7': 'Virtual Safety Car ending',
|
||||
}
|
||||
|
||||
const RC_CATEGORIES = new Set(['flag', 'safetycar', 'drs'])
|
||||
|
||||
/**
|
||||
* Diff two successive live snapshots into a chronological list of synthesized
|
||||
* events. Callers are expected to feed genuinely consecutive snapshots of the
|
||||
* same session; diffing across a session boundary is the caller's job to
|
||||
* prevent (see sessionSignature).
|
||||
*/
|
||||
export function diffSnapshots(prev: LiveStreamData, next: LiveStreamData): LiveEvent[] {
|
||||
if (!prev || !next || prev === next) return []
|
||||
|
||||
const events: LiveEvent[] = []
|
||||
const lap = next.CurrentLap || 0
|
||||
const timestamp = next.Clock || ''
|
||||
const code = (racingNumber: string): string =>
|
||||
next.DriverInfo?.[racingNumber]?.Tla ||
|
||||
prev.DriverInfo?.[racingNumber]?.Tla ||
|
||||
`#${racingNumber}`
|
||||
|
||||
diffTrackStatus(prev, next, lap, timestamp, events)
|
||||
diffRaceControl(prev, next, events)
|
||||
|
||||
const prevDrivers = prev.Drivers ?? {}
|
||||
const nextDrivers = next.Drivers ?? {}
|
||||
const numbers = Object.keys(nextDrivers).filter((number) => prevDrivers[number])
|
||||
|
||||
// Racing numbers whose position loss is already explained by an overtake
|
||||
// event, so we don't also report a generic "drops to Pn".
|
||||
const overtakenNumbers = new Set<string>()
|
||||
|
||||
for (const number of numbers) {
|
||||
const before = prevDrivers[number]
|
||||
const after = nextDrivers[number]
|
||||
|
||||
if (!before.Retired && after.Retired) {
|
||||
events.push({
|
||||
id: `ret:${number}`,
|
||||
kind: 'retirement',
|
||||
lap,
|
||||
timestamp,
|
||||
headline: `${code(number)} retires`,
|
||||
detail: before.Position > 0 ? `Was running P${before.Position}` : undefined,
|
||||
racingNumbers: [number],
|
||||
})
|
||||
}
|
||||
|
||||
if (!before.InPit && after.InPit && !after.Retired) {
|
||||
const fromPosition = before.Position > 0 ? ` from P${before.Position}` : ''
|
||||
events.push({
|
||||
id: `pit-in:${number}:l${lap}`,
|
||||
kind: 'pit-in',
|
||||
lap,
|
||||
timestamp,
|
||||
headline: `${code(number)} pits${fromPosition}`,
|
||||
racingNumbers: [number],
|
||||
})
|
||||
}
|
||||
|
||||
if (before.InPit && !after.InPit && !after.Retired) {
|
||||
const compound = next.Tyres?.[number]?.Compound || ''
|
||||
const stops = Math.max(0, (next.Stints?.[number]?.length ?? 0) - 1)
|
||||
const parts = [compound, stops > 0 ? `${ordinal(stops)} stop` : ''].filter(Boolean)
|
||||
const rejoined = after.Position > 0 ? ` P${after.Position}` : ''
|
||||
events.push({
|
||||
id: `pit-out:${number}:l${lap}`,
|
||||
kind: 'pit-out',
|
||||
lap,
|
||||
timestamp,
|
||||
headline: `${code(number)} rejoins${rejoined}${parts.length > 0 ? ` (${parts.join(', ')})` : ''}`,
|
||||
racingNumbers: [number],
|
||||
})
|
||||
}
|
||||
|
||||
diffBestLap(number, before, after, code, lap, timestamp, events)
|
||||
}
|
||||
|
||||
// Position gains, with overtake attribution via position swaps.
|
||||
for (const number of numbers) {
|
||||
const before = prevDrivers[number]
|
||||
const after = nextDrivers[number]
|
||||
if (before.Position <= 0 || after.Position <= 0) continue
|
||||
if (after.Position >= before.Position) continue
|
||||
if (after.InPit || after.Retired) continue
|
||||
|
||||
const overtaken = numbers.find((other) => {
|
||||
if (other === number) return false
|
||||
const otherBefore = prevDrivers[other]
|
||||
const otherAfter = nextDrivers[other]
|
||||
return (
|
||||
otherBefore.Position === after.Position &&
|
||||
otherAfter.Position > otherBefore.Position &&
|
||||
!otherAfter.InPit &&
|
||||
!otherAfter.Retired
|
||||
)
|
||||
})
|
||||
|
||||
if (overtaken) {
|
||||
overtakenNumbers.add(overtaken)
|
||||
events.push({
|
||||
id: `overtake:${number}:${overtaken}:p${after.Position}:l${lap}`,
|
||||
kind: 'overtake',
|
||||
lap,
|
||||
timestamp,
|
||||
headline: `${code(number)} overtakes ${code(overtaken)} for P${after.Position}`,
|
||||
detail: before.Position - after.Position > 1 ? `Up from P${before.Position}` : undefined,
|
||||
racingNumbers: [number, overtaken],
|
||||
})
|
||||
} else {
|
||||
events.push({
|
||||
id: `pos:${number}:${before.Position}-${after.Position}:l${lap}`,
|
||||
kind: 'position-gain',
|
||||
lap,
|
||||
timestamp,
|
||||
headline: `${code(number)} moves up to P${after.Position} (from P${before.Position})`,
|
||||
racingNumbers: [number],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Position losses not already explained by an overtake or a pit visit.
|
||||
for (const number of numbers) {
|
||||
const before = prevDrivers[number]
|
||||
const after = nextDrivers[number]
|
||||
if (before.Position <= 0 || after.Position <= 0) continue
|
||||
if (after.Position <= before.Position) continue
|
||||
if (overtakenNumbers.has(number)) continue
|
||||
if (after.InPit || after.PitOut || after.Retired || before.InPit) continue
|
||||
|
||||
events.push({
|
||||
id: `pos:${number}:${before.Position}-${after.Position}:l${lap}`,
|
||||
kind: 'position-loss',
|
||||
lap,
|
||||
timestamp,
|
||||
headline: `${code(number)} drops to P${after.Position} (from P${before.Position})`,
|
||||
racingNumbers: [number],
|
||||
})
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
function diffTrackStatus(
|
||||
prev: LiveStreamData,
|
||||
next: LiveStreamData,
|
||||
lap: number,
|
||||
timestamp: string,
|
||||
events: LiveEvent[],
|
||||
): void {
|
||||
if (!prev.TrackStatus || !next.TrackStatus) return
|
||||
if (prev.TrackStatus === next.TrackStatus) return
|
||||
events.push({
|
||||
id: `track:${next.TrackStatus}:l${lap}`,
|
||||
kind: 'track-status',
|
||||
lap,
|
||||
timestamp,
|
||||
headline: TRACK_STATUS_HEADLINES[next.TrackStatus] ?? `Track status ${next.TrackStatus}`,
|
||||
racingNumbers: [],
|
||||
})
|
||||
}
|
||||
|
||||
function diffRaceControl(prev: LiveStreamData, next: LiveStreamData, events: LiveEvent[]): void {
|
||||
const prevMessages = prev.RCMessages ?? []
|
||||
const seen = new Set(prevMessages.map(rcMessageKey))
|
||||
for (const message of next.RCMessages ?? []) {
|
||||
if (!message.Message || seen.has(rcMessageKey(message))) continue
|
||||
if (!RC_CATEGORIES.has((message.Category || '').toLowerCase())) continue
|
||||
events.push({
|
||||
id: `rc:${rcMessageKey(message)}`,
|
||||
kind: 'race-control',
|
||||
lap: message.Lap || 0,
|
||||
timestamp: message.Time || '',
|
||||
headline: humanizeRaceControl(message.Message),
|
||||
racingNumbers: carNumbersIn(message.Message),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function diffBestLap(
|
||||
number: string,
|
||||
before: LiveDriverData,
|
||||
after: LiveDriverData,
|
||||
code: (racingNumber: string) => string,
|
||||
lap: number,
|
||||
timestamp: string,
|
||||
events: LiveEvent[],
|
||||
): void {
|
||||
if (!after.LastLapTime) return
|
||||
const isNewLap = after.LastLapTime !== before.LastLapTime
|
||||
|
||||
if (after.LastLapOB && (isNewLap || !before.LastLapOB)) {
|
||||
events.push({
|
||||
id: `ob:${number}:${after.LastLapTime}`,
|
||||
kind: 'fastest-lap',
|
||||
lap,
|
||||
timestamp,
|
||||
headline: `${code(number)} sets the fastest lap — ${after.LastLapTime}`,
|
||||
racingNumbers: [number],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (after.LastLapPB && !after.LastLapOB && (isNewLap || !before.LastLapPB)) {
|
||||
events.push({
|
||||
id: `pb:${number}:${after.LastLapTime}`,
|
||||
kind: 'personal-best',
|
||||
lap,
|
||||
timestamp,
|
||||
headline: `${code(number)} sets a personal best — ${after.LastLapTime}`,
|
||||
racingNumbers: [number],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function rcMessageKey(message: LiveRCMessage): string {
|
||||
return `${message.Time}:${message.Message}`
|
||||
}
|
||||
|
||||
/** "SAFETY CAR DEPLOYED" -> "Safety car deployed", keeping known acronyms upper-cased. */
|
||||
export function humanizeRaceControl(message: string): string {
|
||||
const lower = message.trim().toLowerCase()
|
||||
if (!lower) return ''
|
||||
const sentence = lower.charAt(0).toUpperCase() + lower.slice(1)
|
||||
return sentence.replace(/\b(drs|vsc|sc|fia)\b/gi, (acronym) => acronym.toUpperCase())
|
||||
}
|
||||
|
||||
function carNumbersIn(message: string): string[] {
|
||||
const numbers: string[] = []
|
||||
for (const match of message.matchAll(/CAR (\d+)/gi)) {
|
||||
if (!numbers.includes(match[1])) numbers.push(match[1])
|
||||
}
|
||||
return numbers
|
||||
}
|
||||
|
||||
function ordinal(n: number): string {
|
||||
const rem10 = n % 10
|
||||
const rem100 = n % 100
|
||||
if (rem10 === 1 && rem100 !== 11) return `${n}st`
|
||||
if (rem10 === 2 && rem100 !== 12) return `${n}nd`
|
||||
if (rem10 === 3 && rem100 !== 13) return `${n}rd`
|
||||
return `${n}th`
|
||||
}
|
||||
@@ -47,7 +47,9 @@ export function countryDecal(meeting: Meeting | undefined | null): string {
|
||||
return name.slice(0, 3).toUpperCase() || '—'
|
||||
}
|
||||
|
||||
export function countryFlag(meeting: Meeting | undefined | null): string {
|
||||
export function countryFlag(
|
||||
meeting: Pick<Meeting, 'country_code' | 'country_flag'> | undefined | null,
|
||||
): string {
|
||||
if (meeting?.country_flag && !/^https?:\/\//i.test(meeting.country_flag)) return meeting.country_flag
|
||||
const code = meeting?.country_code?.toUpperCase()
|
||||
const iso2 = code ? CODE3_TO_2[code] : ''
|
||||
|
||||
146
frontend/src/lib/meaning.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
// Pure interpretation helpers for pairing numbers with their "so-what".
|
||||
// Thresholds are exported consts so they are cheap to tune in one place.
|
||||
|
||||
import { PIT_LOSS_SECONDS } from './tyredeg'
|
||||
|
||||
/** Gaps under this (seconds) are DRS attack range. */
|
||||
export const INTERVAL_DRS_MAX_SECONDS = 1.0
|
||||
|
||||
/** Lower bound of the undercut window (seconds); contiguous with DRS range. */
|
||||
export const INTERVAL_UNDERCUT_MIN_SECONDS = INTERVAL_DRS_MAX_SECONDS
|
||||
|
||||
/**
|
||||
* Upper bound of the undercut window (seconds). Kept well below typical pit
|
||||
* loss ({@link PIT_LOSS_SECONDS}s) — only a few seconds matter for strategy.
|
||||
*/
|
||||
export const INTERVAL_UNDERCUT_MAX_SECONDS = Math.min(3.0, PIT_LOSS_SECONDS / 7)
|
||||
|
||||
/**
|
||||
* Rough per-compound cliff lap estimates (dry compounds). Wet/intermediate
|
||||
* values are conservative — deg varies wildly with conditions.
|
||||
*/
|
||||
export const TYRE_CLIFF_LAPS: Readonly<Record<string, number>> = {
|
||||
SOFT: 18,
|
||||
MEDIUM: 28,
|
||||
HARD: 38,
|
||||
INTERMEDIATE: 20,
|
||||
WET: 15,
|
||||
}
|
||||
|
||||
/** Default cliff when compound is unknown. */
|
||||
export const TYRE_CLIFF_DEFAULT_LAPS = 25
|
||||
|
||||
/** Championship max points per race (winner). */
|
||||
export const MAX_POINTS_PER_ROUND = 25
|
||||
|
||||
export interface MeaningAnnotation {
|
||||
caption: string
|
||||
title: string
|
||||
tone?: 'good' | 'bad' | 'neutral' | 'warn'
|
||||
}
|
||||
|
||||
function cliffLaps(compound: string | null | undefined): number {
|
||||
if (!compound) return TYRE_CLIFF_DEFAULT_LAPS
|
||||
return TYRE_CLIFF_LAPS[compound.toUpperCase()] ?? TYRE_CLIFF_DEFAULT_LAPS
|
||||
}
|
||||
|
||||
/**
|
||||
* Interval / gap-to-ahead meaning for the live timing tower.
|
||||
* Returns null for leader gaps, out-of-range values, or unparsable input.
|
||||
*/
|
||||
export function intervalMeaning(seconds: number | null | undefined): MeaningAnnotation | null {
|
||||
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return null
|
||||
|
||||
if (seconds < INTERVAL_DRS_MAX_SECONDS) {
|
||||
return {
|
||||
caption: 'DRS range',
|
||||
title: `Within ${INTERVAL_DRS_MAX_SECONDS}s — DRS enabled next straight`,
|
||||
tone: 'good',
|
||||
}
|
||||
}
|
||||
|
||||
if (seconds >= INTERVAL_UNDERCUT_MIN_SECONDS && seconds <= INTERVAL_UNDERCUT_MAX_SECONDS) {
|
||||
return {
|
||||
caption: 'undercut window',
|
||||
title: `${INTERVAL_UNDERCUT_MIN_SECONDS}–${INTERVAL_UNDERCUT_MAX_SECONDS}s — pit now could gain a position (vs ~${PIT_LOSS_SECONDS}s stop)`,
|
||||
tone: 'warn',
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Tyre-age meaning for deg / stint panels.
|
||||
*/
|
||||
export function tyreAgeMeaning(
|
||||
compound: string | null | undefined,
|
||||
age: number | null | undefined,
|
||||
): MeaningAnnotation | null {
|
||||
if (age == null || !Number.isFinite(age) || age < 0) return null
|
||||
|
||||
const cliff = cliffLaps(compound)
|
||||
const freshEnd = Math.ceil(cliff * 0.25)
|
||||
const midEnd = Math.ceil(cliff * 0.65)
|
||||
|
||||
if (age <= freshEnd) {
|
||||
return {
|
||||
caption: 'fresh',
|
||||
title: `${age} lap${age === 1 ? '' : 's'} on ${compound ?? 'tyre'} — early stint grip`,
|
||||
tone: 'good',
|
||||
}
|
||||
}
|
||||
|
||||
if (age <= midEnd) {
|
||||
return {
|
||||
caption: 'mid-life',
|
||||
title: `${age} laps — tyre in its working window before cliff (~${cliff} laps)`,
|
||||
tone: 'neutral',
|
||||
}
|
||||
}
|
||||
|
||||
const lapsToCliff = cliff - age
|
||||
if (lapsToCliff <= 0) {
|
||||
return {
|
||||
caption: 'past cliff',
|
||||
title: `${age} laps — beyond typical ${compound ?? 'tyre'} cliff (~${cliff} laps)`,
|
||||
tone: 'bad',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
caption: `~${lapsToCliff} laps to cliff`,
|
||||
title: `${age} of ~${cliff} laps before deg cliff on ${compound ?? 'tyre'}`,
|
||||
tone: 'warn',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Points gap to the driver directly ahead — catchable-or-not v1.
|
||||
*/
|
||||
export function pointsGapMeaning(
|
||||
gapToAhead: number | null | undefined,
|
||||
roundsLeft: number,
|
||||
driverAhead?: string | null,
|
||||
): MeaningAnnotation | null {
|
||||
if (gapToAhead == null || !Number.isFinite(gapToAhead) || gapToAhead <= 0) return null
|
||||
if (!Number.isFinite(roundsLeft) || roundsLeft <= 0) return null
|
||||
|
||||
const maxCatchable = roundsLeft * MAX_POINTS_PER_ROUND
|
||||
const ahead = driverAhead?.trim() || 'ahead'
|
||||
|
||||
if (gapToAhead > maxCatchable) {
|
||||
return {
|
||||
caption: 'out of reach',
|
||||
title: `+${gapToAhead} pts with ${roundsLeft} round${roundsLeft === 1 ? '' : 's'} left (max ${maxCatchable} available)`,
|
||||
tone: 'bad',
|
||||
}
|
||||
}
|
||||
|
||||
const perRound = Math.ceil(gapToAhead / roundsLeft)
|
||||
return {
|
||||
caption: `~${perRound} pts/round`,
|
||||
title: `Needs ~${perRound} pts per round on ${ahead} to catch (${gapToAhead} pts in ${roundsLeft} round${roundsLeft === 1 ? '' : 's'})`,
|
||||
tone: perRound <= 10 ? 'good' : 'warn',
|
||||
}
|
||||
}
|
||||
123
frontend/src/lib/preview.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session } from '../types'
|
||||
import {
|
||||
currentAndNextSession,
|
||||
nextUpcomingMeeting,
|
||||
sessionStartTime,
|
||||
sortSessionsByStart,
|
||||
} from './schedule'
|
||||
|
||||
export const RACE_WIN_POINTS = 25
|
||||
export const SPRINT_WIN_POINTS = 8
|
||||
export const SPRINT_WEEKEND_MAX_POINTS = RACE_WIN_POINTS + SPRINT_WIN_POINTS
|
||||
|
||||
export function pickPreviewMeeting(meetings: Meeting[], now: Date): Meeting | null {
|
||||
const active = meetings.filter((m) => !m.is_cancelled)
|
||||
return nextUpcomingMeeting(active, now)
|
||||
}
|
||||
|
||||
export function findPriorYearMeetingByCircuit(
|
||||
priorYearMeetings: Meeting[],
|
||||
circuitKey: number | undefined,
|
||||
): Meeting | null {
|
||||
if (!circuitKey) return null
|
||||
return priorYearMeetings.find((m) => m.circuit_key === circuitKey && !m.is_cancelled) ?? null
|
||||
}
|
||||
|
||||
export function findRaceSession(sessions: Session[]): Session | null {
|
||||
const sorted = sortSessionsByStart(sessions)
|
||||
return (
|
||||
sorted.find((s) => {
|
||||
const type = s.session_type?.toLowerCase() ?? ''
|
||||
return type.includes('race') && !type.includes('sprint')
|
||||
}) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
export function isSprintWeekend(sessions: Session[]): boolean {
|
||||
return sessions.some((s) => {
|
||||
const label = `${s.session_type} ${s.session_name}`.toLowerCase()
|
||||
return /\bsprint\b/.test(label) && !label.includes('sprint qualifying') && !label.includes('sprint shootout')
|
||||
})
|
||||
}
|
||||
|
||||
export interface PodiumPlace {
|
||||
position: number
|
||||
name_acronym: string
|
||||
full_name: string
|
||||
team_name: string
|
||||
team_colour: string
|
||||
}
|
||||
|
||||
export interface PolePosition {
|
||||
name_acronym: string
|
||||
full_name: string
|
||||
team_name: string
|
||||
team_colour: string
|
||||
}
|
||||
|
||||
export function extractPodium(results: EnrichedResult[]): PodiumPlace[] {
|
||||
return results
|
||||
.filter((r) => r.position >= 1 && r.position <= 3 && !r.dns && !r.dsq)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((r) => ({
|
||||
position: r.position,
|
||||
name_acronym: r.name_acronym,
|
||||
full_name: r.full_name,
|
||||
team_name: r.team_name,
|
||||
team_colour: r.team_colour,
|
||||
}))
|
||||
}
|
||||
|
||||
export function extractPole(grid: EnrichedGrid[]): PolePosition | null {
|
||||
const pole = grid.find((g) => g.position === 1)
|
||||
if (!pole) return null
|
||||
return {
|
||||
name_acronym: pole.name_acronym,
|
||||
full_name: pole.full_name,
|
||||
team_name: pole.team_name,
|
||||
team_colour: pole.team_colour,
|
||||
}
|
||||
}
|
||||
|
||||
export interface TitleFightDriver {
|
||||
name_acronym: string
|
||||
full_name: string
|
||||
team_colour: string
|
||||
points: number
|
||||
position: number
|
||||
gapToLeader: number
|
||||
gapAfterRaceWin: number
|
||||
gapAfterSprintWeekendMax: number
|
||||
}
|
||||
|
||||
export function buildTitleFightContext(hub: ChampionshipHub): TitleFightDriver[] {
|
||||
if (hub.drivers.length === 0) return []
|
||||
const leader = hub.drivers[0]
|
||||
return hub.drivers.slice(0, 3).map((d) => ({
|
||||
name_acronym: d.name_acronym,
|
||||
full_name: d.full_name,
|
||||
team_colour: d.team_colour,
|
||||
points: d.points,
|
||||
position: d.position,
|
||||
gapToLeader: leader.points - d.points,
|
||||
gapAfterRaceWin: leader.points - (d.points + RACE_WIN_POINTS),
|
||||
gapAfterSprintWeekendMax: leader.points - (d.points + SPRINT_WEEKEND_MAX_POINTS),
|
||||
}))
|
||||
}
|
||||
|
||||
export function countdownTargetSession(sessions: Session[], now: Date): Session | null {
|
||||
const sorted = sortSessionsByStart(sessions)
|
||||
const { next } = currentAndNextSession(sorted, now)
|
||||
if (next) return next
|
||||
for (const session of sorted) {
|
||||
const start = sessionStartTime(session)
|
||||
if (start && start > now) return session
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function formatPointsGap(gap: number): string {
|
||||
if (gap === 0) return 'LEADER'
|
||||
const sign = gap > 0 ? '+' : ''
|
||||
return `${sign}${Number.isInteger(gap) ? gap : gap.toFixed(1)}`
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { fetchChampionshipHub, fetchSeasons } from '../api'
|
||||
import { teamColor } from '../utils'
|
||||
import type { ChampHubDriver, ChampionshipHub } from '../types'
|
||||
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
|
||||
import { Meaning } from '../components/Meaning'
|
||||
import { pointsGapMeaning } from '../lib/meaning'
|
||||
|
||||
type View = 'drivers' | 'constructors' | 'progression' | 'simulator'
|
||||
|
||||
@@ -138,6 +141,8 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
||||
color: teamColor(d.team_colour),
|
||||
gapLeader: i === 0 ? 'LEADER' : `+${fmtPts(gapLeaderNum)}`,
|
||||
gapAhead: gapAheadNum == null ? '—' : `+${fmtPts(gapAheadNum)}`,
|
||||
gapAheadNum,
|
||||
driverAhead: i === 0 ? null : drivers[i - 1].name_acronym,
|
||||
spark: sparkPoints(d.form),
|
||||
h2h: `${d.teammate_wins}–${d.teammate_losses}`,
|
||||
h2hWin: d.teammate_wins >= d.teammate_losses,
|
||||
@@ -233,7 +238,13 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
||||
</div>
|
||||
|
||||
{view === 'drivers' && (
|
||||
<DriversView enriched={enriched} leaderPoints={leader.points} titleMath={titleMath} />
|
||||
<DriversView
|
||||
enriched={enriched}
|
||||
leaderPoints={leader.points}
|
||||
titleMath={titleMath}
|
||||
roundsLeft={hub.rounds_left}
|
||||
season={hub.season}
|
||||
/>
|
||||
)}
|
||||
{view === 'constructors' && <ConstructorsView hub={hub} />}
|
||||
{view === 'progression' && <ProgressionView hub={hub} />}
|
||||
@@ -248,6 +259,8 @@ interface EnrichedDriver {
|
||||
color: string
|
||||
gapLeader: string
|
||||
gapAhead: string
|
||||
gapAheadNum: number | null
|
||||
driverAhead: string | null
|
||||
spark: string
|
||||
h2h: string
|
||||
h2hWin: boolean
|
||||
@@ -259,10 +272,14 @@ function DriversView({
|
||||
enriched,
|
||||
leaderPoints,
|
||||
titleMath,
|
||||
roundsLeft,
|
||||
season,
|
||||
}: {
|
||||
enriched: EnrichedDriver[]
|
||||
leaderPoints: number
|
||||
titleMath: string
|
||||
roundsLeft: number
|
||||
season: number
|
||||
}) {
|
||||
const podium = enriched.slice(0, 3)
|
||||
return (
|
||||
@@ -347,17 +364,36 @@ function DriversView({
|
||||
P{e.pos}
|
||||
</td>
|
||||
<td>
|
||||
<div className="champ-drv">
|
||||
<Link
|
||||
to="/drivers/$driverNumber"
|
||||
params={{ driverNumber: String(e.d.driver_number) }}
|
||||
search={{ year: season }}
|
||||
className="champ-drv"
|
||||
style={{ color: 'inherit', textDecoration: 'none' }}
|
||||
data-testid={`champ-driver-link-${e.d.driver_number}`}
|
||||
>
|
||||
<span className="champ-drv-bar" style={{ background: e.color }} />
|
||||
<span className="champ-drv-code mono">{e.d.name_acronym}</span>
|
||||
<span className="champ-drv-name">{e.d.full_name}</span>
|
||||
<span className="champ-drv-num mono">#{e.d.driver_number}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="champ-td-team">{e.d.team_name}</td>
|
||||
<td className="r mono champ-td-pts">{fmtPts(e.d.points)}</td>
|
||||
<td className="r mono champ-td-muted">{e.gapLeader}</td>
|
||||
<td className="r mono champ-td-dim">{e.gapAhead}</td>
|
||||
<td className="r mono champ-td-dim">
|
||||
{(() => {
|
||||
const gapAnnotation = pointsGapMeaning(e.gapAheadNum, roundsLeft, e.driverAhead)
|
||||
return (
|
||||
<Meaning
|
||||
value={e.gapAhead}
|
||||
meaning={gapAnnotation?.caption}
|
||||
title={gapAnnotation?.title}
|
||||
tone={gapAnnotation?.tone}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</td>
|
||||
<td className="c mono" style={{ color: e.d.wins > 0 ? 'var(--text)' : 'var(--text-3)' }}>
|
||||
{e.d.wins}
|
||||
</td>
|
||||
|
||||
369
frontend/src/pages/DriverProfilePage.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { fetchDriverSummary, fetchSeasons } from '../api'
|
||||
import { teamColor } from '../utils'
|
||||
import { countryFlag } from '../lib/gpIdentity'
|
||||
import {
|
||||
formatDelta,
|
||||
formatPosition,
|
||||
gridFinishDeltas,
|
||||
type RoundDelta,
|
||||
} from '../lib/driverProfile'
|
||||
import type { DriverSummary } from '../types'
|
||||
import '../styles/driver-profile.css'
|
||||
|
||||
interface Props {
|
||||
driverNumber: number
|
||||
year?: number
|
||||
}
|
||||
|
||||
function fmtPts(n: number): string {
|
||||
return Number.isInteger(n) ? String(n) : n.toFixed(1)
|
||||
}
|
||||
|
||||
export function DriverProfilePage({ driverNumber, year }: Props) {
|
||||
// Without an explicit ?year=, default to the latest ingested season; if the
|
||||
// seasons list is empty/unavailable the backend falls back to the current year.
|
||||
const seasonsQuery = useQuery({
|
||||
queryKey: ['seasons'],
|
||||
queryFn: fetchSeasons,
|
||||
enabled: year == null,
|
||||
})
|
||||
const resolvedYear = year ?? seasonsQuery.data?.[0]
|
||||
const seasonsSettled = year != null || !seasonsQuery.isLoading
|
||||
|
||||
const summaryQuery = useQuery({
|
||||
queryKey: ['driver-summary', driverNumber, resolvedYear ?? 'latest'],
|
||||
queryFn: () => fetchDriverSummary(driverNumber, resolvedYear),
|
||||
enabled: seasonsSettled && driverNumber > 0,
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
|
||||
if (driverNumber <= 0) {
|
||||
return <div className="page error-box">Invalid driver number</div>
|
||||
}
|
||||
if (!seasonsSettled || summaryQuery.isLoading) {
|
||||
return <div className="page loading-state">loading driver profile…</div>
|
||||
}
|
||||
if (summaryQuery.isError) {
|
||||
return (
|
||||
<div className="page error-box">
|
||||
{summaryQuery.error instanceof Error
|
||||
? summaryQuery.error.message
|
||||
: 'Failed to load driver profile'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const summary = summaryQuery.data
|
||||
if (!summary) {
|
||||
return <div className="page error-box">No driver data</div>
|
||||
}
|
||||
return <DriverProfileBody summary={summary} />
|
||||
}
|
||||
|
||||
function DriverProfileBody({ summary }: { summary: DriverSummary }) {
|
||||
const color = teamColor(summary.team_colour)
|
||||
const deltas = gridFinishDeltas(summary.rounds)
|
||||
|
||||
return (
|
||||
<div className="dp-page" data-testid="driver-profile">
|
||||
<header className="dp-header" data-testid="dp-header" style={{ borderLeftColor: color }}>
|
||||
<div className="dp-identity">
|
||||
<div className="dp-title-row">
|
||||
<span className="dp-code mono">{summary.name_acronym || `#${summary.driver_number}`}</span>
|
||||
<span className="dp-num mono" style={{ color }}>
|
||||
#{summary.driver_number}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="dp-name">{summary.full_name || `Driver ${summary.driver_number}`}</h1>
|
||||
<div className="dp-team">
|
||||
<span className="dp-team-swatch" style={{ background: color }} />
|
||||
{summary.team_name || 'Unknown team'}
|
||||
<span className="dp-season mono">{summary.season}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dp-stat-rail">
|
||||
<div className="dp-stat">
|
||||
<div className="dp-stat-value mono">{summary.position > 0 ? `P${summary.position}` : '—'}</div>
|
||||
<div className="dp-stat-label">Championship</div>
|
||||
</div>
|
||||
<div className="dp-stat">
|
||||
<div className="dp-stat-value mono">{fmtPts(summary.points)}</div>
|
||||
<div className="dp-stat-label">Points</div>
|
||||
</div>
|
||||
<div className="dp-stat">
|
||||
<div className="dp-stat-value mono">{summary.wins}</div>
|
||||
<div className="dp-stat-label">Wins</div>
|
||||
</div>
|
||||
<div className="dp-stat">
|
||||
<div className="dp-stat-value mono">{summary.podiums}</div>
|
||||
<div className="dp-stat-label">Podiums</div>
|
||||
</div>
|
||||
<div className="dp-stat">
|
||||
<div className="dp-stat-value mono">{summary.poles}</div>
|
||||
<div className="dp-stat-label">Poles</div>
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/championship" className="dp-back mono">
|
||||
← Championship
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<section className="dp-section" data-testid="dp-form">
|
||||
<h2 className="dp-section-title mono">Season form</h2>
|
||||
{summary.cumulative.length > 0 ? (
|
||||
<div className="dp-form-grid">
|
||||
<CumulativeLine
|
||||
cumulative={summary.cumulative}
|
||||
labels={summary.round_labels}
|
||||
color={color}
|
||||
/>
|
||||
<FormStrip form={summary.form} color={color} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="dp-empty">No completed rounds yet.</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="dp-section" data-testid="dp-quali-race">
|
||||
<h2 className="dp-section-title mono">Quali vs race</h2>
|
||||
{deltas.some((d) => d.delta != null) ? (
|
||||
<GridVsRaceChart deltas={deltas} color={color} />
|
||||
) : (
|
||||
<div className="dp-empty">No grid-vs-finish data yet.</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="dp-section" data-testid="dp-rounds">
|
||||
<h2 className="dp-section-title mono">Track by track</h2>
|
||||
{summary.rounds.length > 0 ? (
|
||||
<RoundsTable summary={summary} deltas={deltas} />
|
||||
) : (
|
||||
<div className="dp-empty">No completed rounds yet.</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LINE_W = 640
|
||||
const LINE_H = 180
|
||||
const LINE_PL = 44
|
||||
const LINE_PR = 16
|
||||
const LINE_PT = 12
|
||||
const LINE_PB = 24
|
||||
|
||||
function CumulativeLine({
|
||||
cumulative,
|
||||
labels,
|
||||
color,
|
||||
}: {
|
||||
cumulative: number[]
|
||||
labels: string[]
|
||||
color: string
|
||||
}) {
|
||||
const n = cumulative.length
|
||||
const maxY = Math.max(25, ...cumulative)
|
||||
const plotW = LINE_W - LINE_PL - LINE_PR
|
||||
const plotH = LINE_H - LINE_PT - LINE_PB
|
||||
const x = (i: number) => (n <= 1 ? LINE_PL + plotW / 2 : LINE_PL + (i * plotW) / (n - 1))
|
||||
const y = (v: number) => LINE_PT + plotH - (v / maxY) * plotH
|
||||
const points = cumulative.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ')
|
||||
|
||||
const tickStep = Math.max(1, Math.ceil(n / 8))
|
||||
const ticks: { x: number; label: string }[] = []
|
||||
for (let i = 0; i < n; i += tickStep) {
|
||||
ticks.push({ x: x(i), label: labels[i] ?? `R${i + 1}` })
|
||||
}
|
||||
|
||||
return (
|
||||
<figure className="dp-chart">
|
||||
<figcaption className="dp-chart-caption mono">Cumulative points</figcaption>
|
||||
<svg viewBox={`0 0 ${LINE_W} ${LINE_H}`} className="dp-chart-svg" role="img" aria-label="Cumulative points per round">
|
||||
{[0, 0.5, 1].map((f) => {
|
||||
const v = Math.round(maxY * f)
|
||||
return (
|
||||
<g key={f}>
|
||||
<line x1={LINE_PL} y1={y(v)} x2={LINE_W - LINE_PR} y2={y(v)} className="dp-gridline" />
|
||||
<text x={LINE_PL - 6} y={y(v) + 3} textAnchor="end" className="dp-axis">
|
||||
{v}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{ticks.map((t) => (
|
||||
<text key={t.label} x={t.x} y={LINE_H - 8} textAnchor="middle" className="dp-axis">
|
||||
{t.label}
|
||||
</text>
|
||||
))}
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2.2"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx={x(n - 1)} cy={y(cumulative[n - 1])} r="3.2" fill={color} />
|
||||
</svg>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
function FormStrip({ form, color }: { form: number[]; color: string }) {
|
||||
const max = Math.max(25, ...form)
|
||||
return (
|
||||
<div className="dp-form-strip">
|
||||
<div className="dp-chart-caption mono">Last {form.length} races</div>
|
||||
<div className="dp-form-bars">
|
||||
{form.map((pts, i) => (
|
||||
<div className="dp-form-bar" key={i} title={`${fmtPts(pts)} pts`}>
|
||||
<div
|
||||
className="dp-form-bar-fill"
|
||||
style={{ height: `${Math.max(4, (pts / max) * 100)}%`, background: color }}
|
||||
/>
|
||||
<span className="dp-form-bar-val mono">{fmtPts(pts)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DELTA_H = 190
|
||||
const DELTA_PT = 14
|
||||
const DELTA_PB = 30
|
||||
|
||||
function GridVsRaceChart({ deltas, color }: { deltas: RoundDelta[]; color: string }) {
|
||||
const maxAbs = Math.max(1, ...deltas.map((d) => Math.abs(d.delta ?? 0)))
|
||||
const colW = 34
|
||||
const w = Math.max(240, deltas.length * colW)
|
||||
const plotH = DELTA_H - DELTA_PT - DELTA_PB
|
||||
const zeroY = DELTA_PT + plotH / 2
|
||||
const scale = plotH / 2 / maxAbs
|
||||
|
||||
return (
|
||||
<figure className="dp-chart">
|
||||
<figcaption className="dp-chart-caption mono">
|
||||
Positions gained (▲) / lost (▼) from grid to flag, per round
|
||||
</figcaption>
|
||||
<div className="dp-chart-scroll">
|
||||
<svg
|
||||
viewBox={`0 0 ${w} ${DELTA_H}`}
|
||||
width={w}
|
||||
height={DELTA_H}
|
||||
className="dp-delta-svg"
|
||||
role="img"
|
||||
aria-label="Positions gained or lost per round"
|
||||
>
|
||||
<line x1={0} y1={zeroY} x2={w} y2={zeroY} className="dp-zeroline" />
|
||||
{deltas.map((d, i) => {
|
||||
const cx = i * colW + colW / 2
|
||||
if (d.delta == null) {
|
||||
return (
|
||||
<g key={d.round}>
|
||||
<text x={cx} y={zeroY - 6} textAnchor="middle" className="dp-axis">
|
||||
·
|
||||
</text>
|
||||
<text x={cx} y={DELTA_H - 8} textAnchor="middle" className="dp-axis">
|
||||
R{d.round}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
const h = Math.abs(d.delta) * scale
|
||||
const yTop = d.delta >= 0 ? zeroY - h : zeroY
|
||||
return (
|
||||
<g key={d.round}>
|
||||
<title>{`${d.label}: ${formatPosition(d.grid)} → ${formatPosition(d.finish)} (${formatDelta(d.delta)})`}</title>
|
||||
<rect
|
||||
x={cx - 8}
|
||||
y={yTop}
|
||||
width={16}
|
||||
height={Math.max(1.5, h)}
|
||||
rx={2}
|
||||
fill={color}
|
||||
opacity={d.delta >= 0 ? 0.95 : 0.45}
|
||||
/>
|
||||
<text
|
||||
x={cx}
|
||||
y={d.delta >= 0 ? yTop - 4 : yTop + h + 11}
|
||||
textAnchor="middle"
|
||||
className="dp-delta-val mono"
|
||||
>
|
||||
{formatDelta(d.delta)}
|
||||
</text>
|
||||
<text x={cx} y={DELTA_H - 8} textAnchor="middle" className="dp-axis">
|
||||
R{d.round}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
function statusNote(d: RoundDelta): string {
|
||||
if (d.status === 'dnf') return 'DNF'
|
||||
if (d.status === 'dns') return 'DNS'
|
||||
if (d.status === 'dsq') return 'DSQ'
|
||||
if (d.status === 'absent') return '—'
|
||||
return ''
|
||||
}
|
||||
|
||||
function RoundsTable({ summary, deltas }: { summary: DriverSummary; deltas: RoundDelta[] }) {
|
||||
return (
|
||||
<div className="dp-scroll">
|
||||
<table className="dp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="l">Rnd</th>
|
||||
<th className="l">Grand Prix</th>
|
||||
<th className="r">Grid</th>
|
||||
<th className="r">Race</th>
|
||||
<th className="r">Δ</th>
|
||||
<th className="r">Pts</th>
|
||||
<th className="l"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{summary.rounds.map((r, i) => {
|
||||
const d = deltas[i]
|
||||
return (
|
||||
<tr key={r.meeting_key}>
|
||||
<td className="mono dp-td-dim">R{i + 1}</td>
|
||||
<td>
|
||||
<span className="dp-flag" aria-hidden="true">
|
||||
{countryFlag({ country_code: r.country_code, country_flag: '' })}
|
||||
</span>
|
||||
{r.meeting_name}
|
||||
</td>
|
||||
<td className="r mono">{formatPosition(d.grid)}</td>
|
||||
<td className="r mono">{formatPosition(d.finish)}</td>
|
||||
<td
|
||||
className="r mono"
|
||||
style={{
|
||||
color:
|
||||
d.delta == null
|
||||
? 'var(--text-3)'
|
||||
: d.delta > 0
|
||||
? 'var(--green)'
|
||||
: d.delta < 0
|
||||
? 'var(--red)'
|
||||
: 'var(--text-2)',
|
||||
}}
|
||||
>
|
||||
{formatDelta(d.delta)}
|
||||
</td>
|
||||
<td className="r mono">{fmtPts(r.points)}</td>
|
||||
<td className="dp-td-dim mono">{statusNote(d)}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchLiveState, fetchLiveTrackOutline } from '../api'
|
||||
import { fetchLiveState } from '../api'
|
||||
import type { LivePosition, LiveStreamData } from '../types'
|
||||
import {
|
||||
loadPinnedDrivers,
|
||||
@@ -15,21 +15,27 @@ import type { VisibleSectorState } from '../lib/live'
|
||||
import type { GapHistoryMap } from '../lib/gapHistory'
|
||||
import { recordGapSamples } from '../lib/gapHistory'
|
||||
import { battleNumbers, detectBattles } from '../lib/battles'
|
||||
import type { LiveEvent } from '../lib/events'
|
||||
import { appendEvents, diffSnapshots, sessionSignature } from '../lib/events'
|
||||
import { SessionBanner } from '../components/live/SessionBanner'
|
||||
import { TrackStatusBanner } from '../components/live/TrackStatusBanner'
|
||||
import { TimingTower } from '../components/live/TimingTower'
|
||||
import { BattleChips } from '../components/live/BattleChips'
|
||||
import { PinnedDrivers } from '../components/live/PinnedDrivers'
|
||||
import { RaceControlFeed } from '../components/live/RaceControlFeed'
|
||||
import { EventRail } from '../components/live/EventRail'
|
||||
import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
|
||||
import { TrackMap } from '../components/live/TrackMap'
|
||||
import { TyreDegPanel } from '../components/live/TyreDegPanel'
|
||||
import { Radio } from 'lucide-react'
|
||||
import { Archive, Radio } from 'lucide-react'
|
||||
|
||||
type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
|
||||
|
||||
export function LiveTimingPage() {
|
||||
const [snapshot, setSnapshot] = useState<LiveStreamData | null>(null)
|
||||
const [activeSnapshot, setActiveSnapshot] = useState<LiveStreamData | null>(null)
|
||||
const [archiveSnapshot, setArchiveSnapshot] = useState<LiveStreamData | null>(null)
|
||||
const [archivePositions, setArchivePositions] = useState<Record<string, LivePosition>>({})
|
||||
const [archiveSnapshotAt, setArchiveSnapshotAt] = useState<string | null>(null)
|
||||
const [archiveMode, setArchiveMode] = useState(false)
|
||||
const [isLive, setIsLive] = useState(false)
|
||||
const [streamStatus, setStreamStatus] = useState<StreamStatus>('connecting')
|
||||
const [now, setNow] = useState(Date.now())
|
||||
@@ -37,6 +43,13 @@ export function LiveTimingPage() {
|
||||
const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers())
|
||||
const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({})
|
||||
const [positions, setPositions] = useState<Record<string, LivePosition>>({})
|
||||
const [events, setEvents] = useState<LiveEvent[]>([])
|
||||
const prevSnapshotRef = useRef<LiveStreamData | null>(null)
|
||||
const sessionSigRef = useRef('')
|
||||
const isLiveRef = useRef(false)
|
||||
const archiveModeRef = useRef(false)
|
||||
const snapshot = isLive ? activeSnapshot : archiveMode ? archiveSnapshot : null
|
||||
const hasArchive = Boolean(archiveSnapshot)
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ['live-state'],
|
||||
@@ -44,24 +57,31 @@ export function LiveTimingPage() {
|
||||
staleTime: 5_000,
|
||||
})
|
||||
|
||||
const trackOutlineQuery = useQuery({
|
||||
queryKey: [
|
||||
'live-track-outline',
|
||||
snapshot?.Session?.MeetingName ?? '',
|
||||
snapshot?.Session?.CircuitName ?? '',
|
||||
],
|
||||
queryFn: () => fetchLiveTrackOutline(snapshot!.Session),
|
||||
enabled: Boolean(snapshot?.Session?.MeetingName || snapshot?.Session?.CircuitName),
|
||||
staleTime: Infinity,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
setIsLive(data.is_live)
|
||||
setSnapshot(data.data)
|
||||
const nextLive = data.is_live && Boolean(data.data)
|
||||
setIsLive(nextLive)
|
||||
isLiveRef.current = nextLive
|
||||
if (nextLive && data.data) {
|
||||
setActiveSnapshot(data.data)
|
||||
setArchiveMode(false)
|
||||
return
|
||||
}
|
||||
|
||||
setActiveSnapshot(null)
|
||||
setArchiveSnapshot(data.last_snapshot ?? null)
|
||||
setArchivePositions(data.last_positions ?? {})
|
||||
setArchiveSnapshotAt(data.last_snapshot_at ?? null)
|
||||
setArchiveMode((current) => current && Boolean(data.last_snapshot))
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
archiveModeRef.current = archiveMode
|
||||
if (archiveMode) {
|
||||
setPositions(archivePositions)
|
||||
}
|
||||
}, [archiveMode, archivePositions])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
@@ -84,8 +104,23 @@ export function LiveTimingPage() {
|
||||
events.addEventListener('snapshot', (event) => {
|
||||
const state = parseLiveStateEvent(event.data)
|
||||
if (!state || cancelled) return
|
||||
setIsLive(state.is_live)
|
||||
setSnapshot(state.data)
|
||||
const nextLive = state.is_live && Boolean(state.data)
|
||||
setIsLive(nextLive)
|
||||
if (nextLive && state.data) {
|
||||
if (!isLiveRef.current || archiveModeRef.current) {
|
||||
setPositions({})
|
||||
}
|
||||
isLiveRef.current = true
|
||||
setActiveSnapshot(state.data)
|
||||
setArchiveMode(false)
|
||||
} else {
|
||||
isLiveRef.current = false
|
||||
setActiveSnapshot(null)
|
||||
setArchiveSnapshot(state.last_snapshot ?? null)
|
||||
setArchivePositions(state.last_positions ?? {})
|
||||
setArchiveSnapshotAt(state.last_snapshot_at ?? null)
|
||||
setArchiveMode((current) => current && Boolean(state.last_snapshot))
|
||||
}
|
||||
setStreamStatus('connected')
|
||||
})
|
||||
|
||||
@@ -114,6 +149,23 @@ export function LiveTimingPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Synthesize "what just happened" events from successive snapshots; the
|
||||
// buffer resets whenever a different session starts streaming.
|
||||
useEffect(() => {
|
||||
if (!snapshot) return
|
||||
const signature = sessionSignature(snapshot.Session)
|
||||
const prev = prevSnapshotRef.current
|
||||
prevSnapshotRef.current = snapshot
|
||||
if (signature !== sessionSigRef.current) {
|
||||
sessionSigRef.current = signature
|
||||
setEvents([])
|
||||
return
|
||||
}
|
||||
if (!prev || prev === snapshot) return
|
||||
const fresh = diffSnapshots(prev, snapshot)
|
||||
if (fresh.length > 0) setEvents((current) => appendEvents(current, fresh))
|
||||
}, [snapshot])
|
||||
|
||||
const rawRows = useMemo(() => sortLiveTimingRows(snapshot), [snapshot])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -151,6 +203,19 @@ export function LiveTimingPage() {
|
||||
setPinned((prev) => togglePin(prev, racingNumber))
|
||||
}
|
||||
|
||||
const handleViewArchive = () => {
|
||||
if (!archiveSnapshot) return
|
||||
setIsLive(false)
|
||||
setArchiveMode(true)
|
||||
setPositions(archivePositions)
|
||||
}
|
||||
|
||||
const archiveTimestamp = archiveSnapshotAt ? new Date(archiveSnapshotAt) : null
|
||||
const archiveLabel =
|
||||
archiveTimestamp && !Number.isNaN(archiveTimestamp.getTime())
|
||||
? `Archived snapshot from ${archiveTimestamp.toLocaleString()}`
|
||||
: 'Archived live timing snapshot'
|
||||
|
||||
return (
|
||||
<div className="page live-page" data-testid="live-page">
|
||||
{isError && (
|
||||
@@ -159,12 +224,18 @@ export function LiveTimingPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{streamStatus === 'disconnected' && snapshot && (
|
||||
{streamStatus === 'disconnected' && snapshot && !archiveMode && (
|
||||
<div className="live-status-strip live-status-warn">
|
||||
Stream disconnected — showing last received snapshot
|
||||
</div>
|
||||
)}
|
||||
|
||||
{archiveMode && snapshot && (
|
||||
<div className="live-status-strip live-status-archive" data-testid="live-archive-strip">
|
||||
{archiveLabel} — live updates are paused for this archive view
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && !snapshot && (
|
||||
<div className="loading-state">connecting to live timing…</div>
|
||||
)}
|
||||
@@ -179,22 +250,27 @@ export function LiveTimingPage() {
|
||||
<p className="empty-state-desc" style={{ color: 'var(--text-2)' }}>
|
||||
The telemetry feed is currently offline. <br /><br /> Check the <a href="/" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Command Center</a> for the weekend schedule or explore historical data in the <a href="/race-hub" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Race Hub</a>.
|
||||
</p>
|
||||
{hasArchive && (
|
||||
<button type="button" className="live-archive-btn" onClick={handleViewArchive}>
|
||||
<Archive size={15} />
|
||||
View Last Session
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{snapshot && (
|
||||
<>
|
||||
<SessionBanner isLive={isLive} snapshot={snapshot} rows={rows} connection={streamStatus} now={now} />
|
||||
<SessionBanner
|
||||
isLive={isLive}
|
||||
isArchive={archiveMode}
|
||||
snapshot={snapshot}
|
||||
rows={rows}
|
||||
connection={streamStatus}
|
||||
now={now}
|
||||
/>
|
||||
<TrackStatusBanner status={snapshot.TrackStatus} />
|
||||
<PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} />
|
||||
<TrackMap
|
||||
outline={trackOutlineQuery.data}
|
||||
positions={positions}
|
||||
telemetry={snapshot.Telemetry}
|
||||
drivers={snapshot.Drivers}
|
||||
driverInfo={snapshot.DriverInfo}
|
||||
loading={trackOutlineQuery.isLoading}
|
||||
/>
|
||||
<TyreDegPanel rows={rows} sessionType={snapshot.Session?.SessionType} pinned={pinned} />
|
||||
<div className="live-columns">
|
||||
<div className="live-tower-col">
|
||||
@@ -220,6 +296,7 @@ export function LiveTimingPage() {
|
||||
session={snapshot.Session}
|
||||
/>
|
||||
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
|
||||
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { TabBar, type Tab } from '../components/TabBar'
|
||||
import { DatasetStatusView } from '../components/DatasetStatusView'
|
||||
import { StrategyView } from '../components/StrategyView'
|
||||
import { LapsView } from '../components/LapsView'
|
||||
import { CompareView } from '../components/CompareView'
|
||||
import { RaceControlView } from '../components/RaceControlView'
|
||||
import { WeatherView } from '../components/WeatherView'
|
||||
import { OverviewView } from '../components/OverviewView'
|
||||
@@ -307,6 +308,19 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'compare' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Driver Compare</span>
|
||||
</div>
|
||||
<CompareView
|
||||
sessionKey={sessionKey}
|
||||
results={data.results}
|
||||
drivers={data.drivers}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'lap_data' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
|
||||
442
frontend/src/pages/RacePreviewPage.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
fetchChampionshipHub,
|
||||
fetchMeetings,
|
||||
fetchResults,
|
||||
fetchSeasons,
|
||||
fetchSessions,
|
||||
fetchStartingGrid,
|
||||
fetchTrackOutline,
|
||||
} from '../api'
|
||||
import { countryAccent, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
|
||||
import {
|
||||
buildTitleFightContext,
|
||||
countdownTargetSession,
|
||||
extractPodium,
|
||||
extractPole,
|
||||
findPriorYearMeetingByCircuit,
|
||||
findRaceSession,
|
||||
formatPointsGap,
|
||||
isSprintWeekend,
|
||||
pickPreviewMeeting,
|
||||
RACE_WIN_POINTS,
|
||||
SPRINT_WEEKEND_MAX_POINTS,
|
||||
} from '../lib/preview'
|
||||
import { formatCountdown, formatSessionScheduleTime, sessionStartTime, sortSessionsByStart } from '../lib/schedule'
|
||||
import { buildOutlinePath } from '../lib/trackmap'
|
||||
import { teamColor } from '../utils'
|
||||
import type { Meeting, Session, TrackOutline } from '../types'
|
||||
import '../styles/preview.css'
|
||||
|
||||
function SectionState({
|
||||
loading,
|
||||
error,
|
||||
empty,
|
||||
emptyMessage,
|
||||
children,
|
||||
}: {
|
||||
loading?: boolean
|
||||
error?: Error | null
|
||||
empty?: boolean
|
||||
emptyMessage?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
if (loading) {
|
||||
return <div className="preview-section-state">Loading…</div>
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="preview-section-state error">
|
||||
{error instanceof Error ? error.message : 'Failed to load'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (empty) {
|
||||
return <div className="preview-section-state">{emptyMessage ?? 'No data available'}</div>
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function TrackOutlineCard({
|
||||
outline,
|
||||
loading,
|
||||
error,
|
||||
accent,
|
||||
}: {
|
||||
outline: TrackOutline | null | undefined
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
accent: string
|
||||
}) {
|
||||
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
|
||||
|
||||
return (
|
||||
<section className="preview-card" data-testid="preview-track-card">
|
||||
<h2 className="preview-card-title">Circuit</h2>
|
||||
<SectionState
|
||||
loading={loading}
|
||||
error={error}
|
||||
empty={!outline || !outlinePath}
|
||||
emptyMessage="Track outline unavailable for this circuit"
|
||||
>
|
||||
<div className="preview-track-stage" style={{ ['--preview-accent' as string]: accent }}>
|
||||
<svg className="preview-track-svg" viewBox="0 0 100 100" role="img" aria-label="Circuit outline">
|
||||
<path className="preview-track-shadow" d={outlinePath} />
|
||||
<path className="preview-track-outline" d={outlinePath} />
|
||||
</svg>
|
||||
</div>
|
||||
</SectionState>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function LastYearCard({
|
||||
year,
|
||||
loading,
|
||||
error,
|
||||
podium,
|
||||
pole,
|
||||
isFirstTime,
|
||||
}: {
|
||||
year: number | null
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
podium: ReturnType<typeof extractPodium>
|
||||
pole: ReturnType<typeof extractPole>
|
||||
isFirstTime: boolean
|
||||
}) {
|
||||
return (
|
||||
<section className="preview-card" data-testid="preview-last-year-card">
|
||||
<h2 className="preview-card-title">Last year here</h2>
|
||||
{year != null && !isFirstTime && <p className="preview-card-sub">{year} race weekend</p>}
|
||||
<SectionState
|
||||
loading={loading}
|
||||
error={error}
|
||||
empty={isFirstTime || podium.length === 0}
|
||||
emptyMessage={
|
||||
isFirstTime
|
||||
? 'First time on the calendar — no prior race at this circuit'
|
||||
: 'No race results available for the previous visit'
|
||||
}
|
||||
>
|
||||
<div className="preview-podium">
|
||||
{podium.map((place) => (
|
||||
<div key={place.position} className="preview-podium-row">
|
||||
<span className="preview-podium-pos">P{place.position}</span>
|
||||
<span className="preview-podium-code" style={{ color: teamColor(place.team_colour) }}>
|
||||
{place.name_acronym}
|
||||
</span>
|
||||
<span className="preview-podium-team">{place.team_name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{pole && (
|
||||
<p className="preview-pole">
|
||||
Pole: <strong style={{ color: teamColor(pole.team_colour) }}>{pole.name_acronym}</strong>{' '}
|
||||
({pole.team_name})
|
||||
</p>
|
||||
)}
|
||||
</SectionState>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function TitleFightCard({
|
||||
loading,
|
||||
error,
|
||||
drivers,
|
||||
sprintWeekend,
|
||||
season,
|
||||
}: {
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
drivers: ReturnType<typeof buildTitleFightContext>
|
||||
sprintWeekend: boolean
|
||||
season: number | null
|
||||
}) {
|
||||
return (
|
||||
<section className="preview-card preview-card--wide" data-testid="preview-title-fight-card">
|
||||
<h2 className="preview-card-title">Title fight context</h2>
|
||||
{season != null && <p className="preview-card-sub">{season} drivers' championship · top 3</p>}
|
||||
<SectionState
|
||||
loading={loading}
|
||||
error={error}
|
||||
empty={drivers.length === 0}
|
||||
emptyMessage="Championship standings not available"
|
||||
>
|
||||
<table className="preview-title-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Driver</th>
|
||||
<th>Pts</th>
|
||||
<th>Gap</th>
|
||||
<th>If win (+{RACE_WIN_POINTS})</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{drivers.map((d) => (
|
||||
<tr key={d.name_acronym}>
|
||||
<td>
|
||||
<span style={{ color: teamColor(d.team_colour), fontWeight: 600 }}>{d.name_acronym}</span>
|
||||
</td>
|
||||
<td className="mono">{d.points}</td>
|
||||
<td className="mono">{formatPointsGap(d.gapToLeader)}</td>
|
||||
<td className="mono">
|
||||
{d.position === 1 ? 'LEADER' : formatPointsGap(d.gapAfterRaceWin)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{sprintWeekend && (
|
||||
<p className="preview-sprint-note" data-testid="preview-sprint-note">
|
||||
Sprint weekend: up to {SPRINT_WEEKEND_MAX_POINTS} pts available (race win {RACE_WIN_POINTS} + sprint win
|
||||
8). Max gain would reduce gaps by {SPRINT_WEEKEND_MAX_POINTS} vs a standard race win.
|
||||
</p>
|
||||
)}
|
||||
</SectionState>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewHeader({
|
||||
meeting,
|
||||
sessions,
|
||||
countdownSession,
|
||||
now,
|
||||
accent,
|
||||
}: {
|
||||
meeting: Meeting
|
||||
sessions: Session[]
|
||||
countdownSession: Session | null
|
||||
now: Date
|
||||
accent: string
|
||||
}) {
|
||||
const countdownTarget = countdownSession ? sessionStartTime(countdownSession) : null
|
||||
|
||||
return (
|
||||
<header
|
||||
className="preview-header"
|
||||
data-testid="preview-header"
|
||||
style={{ ['--preview-accent' as string]: accent }}
|
||||
>
|
||||
<div className="preview-header-accent" />
|
||||
<div className="preview-header-body">
|
||||
<div className="preview-header-top">
|
||||
<div>
|
||||
<h1 className="preview-gp-name">
|
||||
{countryFlag(meeting) && <span className="preview-gp-flag">{countryFlag(meeting)}</span>}
|
||||
{meeting.meeting_name}
|
||||
</h1>
|
||||
<p className="preview-circuit">
|
||||
{meeting.circuit_short_name}
|
||||
{meeting.location ? ` · ${meeting.location}` : ''}
|
||||
{formatGpDateRange(meeting) ? ` · ${formatGpDateRange(meeting)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{countdownTarget && (
|
||||
<div className="preview-countdown" data-testid="preview-countdown">
|
||||
<span className="preview-countdown-label">
|
||||
{countdownSession?.session_name ?? 'Next session'}
|
||||
</span>
|
||||
<span className="preview-countdown-value">{formatCountdown(countdownTarget, now)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{sessions.length > 0 && (
|
||||
<div className="preview-schedule" data-testid="preview-schedule">
|
||||
{sessions.map((session) => (
|
||||
<div key={session.session_key} className="preview-schedule-item">
|
||||
<span className="preview-schedule-name">{session.session_name}</span>
|
||||
<span className="preview-schedule-time">{formatSessionScheduleTime(session.date_start)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export function RacePreviewPage() {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
const nowDate = useMemo(() => new Date(now), [now])
|
||||
|
||||
const seasonsQuery = useQuery({
|
||||
queryKey: ['seasons'],
|
||||
queryFn: fetchSeasons,
|
||||
})
|
||||
|
||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||
|
||||
const meetingsQuery = useQuery({
|
||||
queryKey: ['meetings', latestSeason, 'auto'],
|
||||
queryFn: () => fetchMeetings(latestSeason!, 'auto'),
|
||||
enabled: latestSeason != null,
|
||||
})
|
||||
|
||||
const previewMeeting = useMemo(
|
||||
() => pickPreviewMeeting(meetingsQuery.data ?? [], nowDate),
|
||||
[meetingsQuery.data, nowDate],
|
||||
)
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
queryKey: ['sessions', previewMeeting?.meeting_key, 'auto'],
|
||||
queryFn: () => fetchSessions(previewMeeting!.meeting_key, 'auto'),
|
||||
enabled: previewMeeting != null,
|
||||
})
|
||||
|
||||
const sessions = useMemo(
|
||||
() => sortSessionsByStart(sessionsQuery.data ?? []),
|
||||
[sessionsQuery.data],
|
||||
)
|
||||
|
||||
const countdownSession = useMemo(() => countdownTargetSession(sessions, nowDate), [sessions, nowDate])
|
||||
const sprintWeekend = useMemo(() => isSprintWeekend(sessions), [sessions])
|
||||
|
||||
const priorYear = previewMeeting ? previewMeeting.year - 1 : null
|
||||
|
||||
const priorMeetingsQuery = useQuery({
|
||||
queryKey: ['meetings', priorYear, 'auto'],
|
||||
queryFn: () => fetchMeetings(priorYear!, 'auto'),
|
||||
enabled: priorYear != null && previewMeeting != null,
|
||||
})
|
||||
|
||||
const priorMeeting = useMemo(
|
||||
() => findPriorYearMeetingByCircuit(priorMeetingsQuery.data ?? [], previewMeeting?.circuit_key),
|
||||
[priorMeetingsQuery.data, previewMeeting?.circuit_key],
|
||||
)
|
||||
|
||||
const priorSessionsQuery = useQuery({
|
||||
queryKey: ['sessions', priorMeeting?.meeting_key, 'auto'],
|
||||
queryFn: () => fetchSessions(priorMeeting!.meeting_key, 'auto'),
|
||||
enabled: priorMeeting != null,
|
||||
})
|
||||
|
||||
const priorRaceSession = useMemo(
|
||||
() => findRaceSession(priorSessionsQuery.data ?? []),
|
||||
[priorSessionsQuery.data],
|
||||
)
|
||||
|
||||
const priorResultsQuery = useQuery({
|
||||
queryKey: ['results', priorRaceSession?.session_key, 'auto'],
|
||||
queryFn: () => fetchResults(priorRaceSession!.session_key, 'auto'),
|
||||
enabled: priorRaceSession != null,
|
||||
})
|
||||
|
||||
const priorGridQuery = useQuery({
|
||||
queryKey: ['grid', priorRaceSession?.session_key, 'auto'],
|
||||
queryFn: () => fetchStartingGrid(priorRaceSession!.session_key, 'auto'),
|
||||
enabled: priorRaceSession != null,
|
||||
})
|
||||
|
||||
const trackOutlineQuery = useQuery({
|
||||
queryKey: ['track-outline', previewMeeting?.circuit_key, previewMeeting?.year],
|
||||
queryFn: () => fetchTrackOutline(previewMeeting!.circuit_key!, previewMeeting!.year),
|
||||
enabled: previewMeeting?.circuit_key != null && previewMeeting.circuit_key > 0,
|
||||
})
|
||||
|
||||
const championshipQuery = useQuery({
|
||||
queryKey: ['championship-hub', latestSeason],
|
||||
queryFn: () => fetchChampionshipHub(latestSeason!),
|
||||
enabled: latestSeason != null,
|
||||
})
|
||||
|
||||
const podium = useMemo(() => extractPodium(priorResultsQuery.data ?? []), [priorResultsQuery.data])
|
||||
const pole = useMemo(() => extractPole(priorGridQuery.data ?? []), [priorGridQuery.data])
|
||||
const titleFight = useMemo(
|
||||
() => (championshipQuery.data ? buildTitleFightContext(championshipQuery.data) : []),
|
||||
[championshipQuery.data],
|
||||
)
|
||||
|
||||
const accent = countryAccent(previewMeeting)
|
||||
const isFirstTimeCircuit = priorMeeting == null && priorYear != null && !priorMeetingsQuery.isLoading
|
||||
|
||||
if (seasonsQuery.isLoading || meetingsQuery.isLoading) {
|
||||
return <div className="page loading-state" data-testid="preview-loading">loading preview…</div>
|
||||
}
|
||||
|
||||
if (seasonsQuery.isError || meetingsQuery.isError) {
|
||||
const err = seasonsQuery.error ?? meetingsQuery.error
|
||||
return (
|
||||
<div className="page error-box" data-testid="preview-error">
|
||||
{err instanceof Error ? err.message : 'Failed to load preview'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!previewMeeting) {
|
||||
return (
|
||||
<div className="preview-page" data-testid="preview-season-over">
|
||||
<div className="preview-empty">
|
||||
<span className="preview-empty-eyebrow mono">box-box · race preview</span>
|
||||
<h1 className="preview-empty-title">Season complete</h1>
|
||||
<p className="preview-empty-sub">
|
||||
No upcoming races on the {latestSeason ?? 'current'} calendar. Check back when the next season schedule is
|
||||
published.
|
||||
</p>
|
||||
</div>
|
||||
{titleFight.length > 0 && (
|
||||
<TitleFightCard
|
||||
loading={championshipQuery.isLoading}
|
||||
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
|
||||
drivers={titleFight}
|
||||
sprintWeekend={false}
|
||||
season={latestSeason}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="preview-page" data-testid="preview-page">
|
||||
<PreviewHeader
|
||||
meeting={previewMeeting}
|
||||
sessions={sessions}
|
||||
countdownSession={countdownSession}
|
||||
now={nowDate}
|
||||
accent={accent}
|
||||
/>
|
||||
|
||||
<div className="preview-grid">
|
||||
<TrackOutlineCard
|
||||
outline={trackOutlineQuery.data}
|
||||
loading={trackOutlineQuery.isLoading}
|
||||
error={trackOutlineQuery.isError ? (trackOutlineQuery.error as Error) : null}
|
||||
accent={accent}
|
||||
/>
|
||||
|
||||
<LastYearCard
|
||||
year={priorMeeting?.year ?? priorYear}
|
||||
loading={priorMeetingsQuery.isLoading || priorSessionsQuery.isLoading || priorResultsQuery.isLoading}
|
||||
error={
|
||||
priorMeetingsQuery.isError
|
||||
? (priorMeetingsQuery.error as Error)
|
||||
: priorResultsQuery.isError
|
||||
? (priorResultsQuery.error as Error)
|
||||
: null
|
||||
}
|
||||
podium={podium}
|
||||
pole={pole}
|
||||
isFirstTime={isFirstTimeCircuit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TitleFightCard
|
||||
loading={championshipQuery.isLoading}
|
||||
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
|
||||
drivers={titleFight}
|
||||
sprintWeekend={sprintWeekend}
|
||||
season={latestSeason}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,11 +6,17 @@ import { DataLibraryPage } from './pages/DataLibraryPage'
|
||||
import { LiveTimingPage } from './pages/LiveTimingPage'
|
||||
import { BriefingPage } from './pages/BriefingPage'
|
||||
import { ChampionshipPage } from './pages/ChampionshipPage'
|
||||
import { DriverProfilePage } from './pages/DriverProfilePage'
|
||||
import { RacePreviewPage } from './pages/RacePreviewPage'
|
||||
|
||||
type RaceHubSearch = {
|
||||
session_key?: number
|
||||
}
|
||||
|
||||
type DriverProfileSearch = {
|
||||
year?: number
|
||||
}
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<>
|
||||
@@ -64,12 +70,32 @@ export const championshipRoute = createRoute({
|
||||
component: ChampionshipPage,
|
||||
})
|
||||
|
||||
export const driverProfileRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/drivers/$driverNumber',
|
||||
validateSearch: (search: Record<string, unknown>): DriverProfileSearch => {
|
||||
const year = Number(search.year)
|
||||
return Number.isFinite(year) && year > 0 ? { year } : {}
|
||||
},
|
||||
component: function DriverProfileRoute() {
|
||||
const { driverNumber } = driverProfileRoute.useParams()
|
||||
const { year } = driverProfileRoute.useSearch()
|
||||
return <DriverProfilePage driverNumber={Number(driverNumber) || 0} year={year} />
|
||||
},
|
||||
})
|
||||
|
||||
export const briefingRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/briefing',
|
||||
component: BriefingPage,
|
||||
})
|
||||
|
||||
export const previewRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/preview',
|
||||
component: RacePreviewPage,
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
commandCenterRoute,
|
||||
raceHubRoute,
|
||||
@@ -77,7 +103,9 @@ const routeTree = rootRoute.addChildren([
|
||||
dataLibraryRoute,
|
||||
liveTimingRoute,
|
||||
championshipRoute,
|
||||
driverProfileRoute,
|
||||
briefingRoute,
|
||||
previewRoute,
|
||||
])
|
||||
|
||||
export const router = createRouter({ routeTree })
|
||||
|
||||
@@ -808,6 +808,12 @@ a { color: inherit; text-decoration: none; }
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.live-status-archive {
|
||||
background: rgba(70, 140, 255, 0.08);
|
||||
border: 1px solid rgba(70, 140, 255, 0.24);
|
||||
color: #8bb7ff;
|
||||
}
|
||||
|
||||
/* ── Live empty state ── */
|
||||
.live-empty-status {
|
||||
display: flex;
|
||||
@@ -815,6 +821,29 @@ a { color: inherit; text-decoration: none; }
|
||||
margin-bottom: var(--s4);
|
||||
}
|
||||
|
||||
.live-archive-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s2);
|
||||
margin-top: var(--s5);
|
||||
min-height: 36px;
|
||||
padding: 0 var(--s4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text);
|
||||
font-family: var(--f-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.live-archive-btn:hover {
|
||||
border-color: rgba(255, 255, 255, 0.28);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.mono { font-family: var(--f-mono); }
|
||||
|
||||
.live-conn,
|
||||
|
||||
78
frontend/src/styles/compare-view.css
Normal file
@@ -0,0 +1,78 @@
|
||||
.compare-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s5);
|
||||
}
|
||||
|
||||
.compare-pickers {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s4);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.compare-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s3);
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.compare-picker-label {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.compare-picker-select {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 13px;
|
||||
padding: var(--s2) var(--s3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r1);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.compare-picker-select:focus {
|
||||
outline: 2px solid var(--gp-accent, var(--red));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.compare-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.compare-section-title {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.compare-section-meta {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.compare-pit-caption {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.compare-stale-notice {
|
||||
font-size: 12px;
|
||||
color: var(--amber, #f59e0b);
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||
border-radius: var(--r1);
|
||||
padding: var(--s2) var(--s3);
|
||||
}
|
||||
289
frontend/src/styles/driver-profile.css
Normal file
@@ -0,0 +1,289 @@
|
||||
/* Driver profile page (/drivers/$driverNumber) */
|
||||
|
||||
.dp-page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: var(--s6) var(--s5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s6);
|
||||
}
|
||||
|
||||
/* --- header --- */
|
||||
|
||||
.dp-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--s5);
|
||||
padding: var(--s5);
|
||||
padding-left: var(--s6);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 4px solid var(--red);
|
||||
border-radius: var(--r2);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.dp-title-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.dp-code {
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-2);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dp-num {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dp-name {
|
||||
margin: var(--s2) 0;
|
||||
font-size: 30px;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.dp-team {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.dp-team-swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.dp-season {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.dp-stat-rail {
|
||||
display: flex;
|
||||
gap: var(--s5);
|
||||
}
|
||||
|
||||
.dp-stat {
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.dp-stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dp-stat-label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.dp-back {
|
||||
position: absolute;
|
||||
top: var(--s4);
|
||||
right: var(--s5);
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dp-back:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* --- sections --- */
|
||||
|
||||
.dp-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s4);
|
||||
}
|
||||
|
||||
.dp-section-title {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.dp-empty {
|
||||
padding: var(--s5);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--r2);
|
||||
color: var(--text-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* --- charts --- */
|
||||
|
||||
.dp-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(200px, 1fr);
|
||||
gap: var(--s5);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.dp-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.dp-chart {
|
||||
margin: 0;
|
||||
padding: var(--s4);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r2);
|
||||
background: var(--surface);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dp-chart-caption {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-3);
|
||||
margin-bottom: var(--s3);
|
||||
}
|
||||
|
||||
.dp-chart-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.dp-chart-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.dp-gridline {
|
||||
stroke: var(--border);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.dp-zeroline {
|
||||
stroke: var(--text-3);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 3 3;
|
||||
}
|
||||
|
||||
.dp-axis {
|
||||
fill: var(--text-3);
|
||||
font-family: var(--f-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.dp-delta-svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dp-delta-val {
|
||||
fill: var(--text-2);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* --- form strip --- */
|
||||
|
||||
.dp-form-strip {
|
||||
padding: var(--s4);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r2);
|
||||
background: var(--surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dp-form-bars {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--s3);
|
||||
min-height: 110px;
|
||||
}
|
||||
|
||||
.dp-form-bar {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
}
|
||||
|
||||
.dp-form-bar-fill {
|
||||
width: 100%;
|
||||
max-width: 34px;
|
||||
border-radius: 2px 2px 0 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.dp-form-bar-val {
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
/* --- rounds table --- */
|
||||
|
||||
.dp-scroll {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r2);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.dp-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dp-table th {
|
||||
padding: var(--s3) var(--s4);
|
||||
font-family: var(--f-mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.dp-table td {
|
||||
padding: var(--s3) var(--s4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dp-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dp-table th.l,
|
||||
.dp-table td.l {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dp-table th.r,
|
||||
.dp-table td.r {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dp-td-dim {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.dp-flag {
|
||||
margin-right: var(--s3);
|
||||
}
|
||||
67
frontend/src/styles/event-rail.css
Normal file
@@ -0,0 +1,67 @@
|
||||
/* ── "What just happened" event rail (live page) ── */
|
||||
|
||||
.event-rail {
|
||||
margin-top: var(--s5);
|
||||
}
|
||||
|
||||
.event-rail-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s3);
|
||||
padding-top: var(--s4);
|
||||
}
|
||||
|
||||
.event-rail-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--s4);
|
||||
padding: var(--s3) var(--s4);
|
||||
border-left: 3px solid var(--border-2);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.event-rail-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--text-2);
|
||||
padding-top: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-kind-fastest-lap .event-rail-icon { color: var(--purple); }
|
||||
.event-kind-personal-best .event-rail-icon { color: var(--green); }
|
||||
.event-kind-overtake .event-rail-icon,
|
||||
.event-kind-position-gain .event-rail-icon { color: var(--green); }
|
||||
.event-kind-position-loss .event-rail-icon { color: var(--yellow); }
|
||||
.event-kind-retirement .event-rail-icon,
|
||||
.event-kind-track-status .event-rail-icon { color: var(--red); }
|
||||
.event-kind-pit-in .event-rail-icon,
|
||||
.event-kind-pit-out .event-rail-icon { color: var(--blue); }
|
||||
|
||||
.event-rail-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s1);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.event-rail-headline {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.event-rail-detail {
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.event-rail-lap {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
48
frontend/src/styles/meaning.css
Normal file
@@ -0,0 +1,48 @@
|
||||
/* Compact value + muted meaning caption (issue #18). */
|
||||
|
||||
.meaning {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: inherit;
|
||||
gap: 1px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.meaning-value {
|
||||
/* inherits table cell mono styling from parent */
|
||||
}
|
||||
|
||||
.meaning-caption {
|
||||
font-size: 10px;
|
||||
font-family: var(--f-mono);
|
||||
color: var(--text-3);
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meaning-caption--good {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.meaning-caption--bad {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.meaning-caption--warn {
|
||||
color: var(--yellow, #e8c547);
|
||||
}
|
||||
|
||||
.meaning-caption--neutral {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* Table cells: right-align caption under numeric values */
|
||||
td.r .meaning,
|
||||
.champ-td-dim .meaning,
|
||||
.champ-td-muted .meaning {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.tyredeg-row .meaning {
|
||||
align-items: flex-start;
|
||||
}
|
||||
309
frontend/src/styles/preview.css
Normal file
@@ -0,0 +1,309 @@
|
||||
.preview-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s6);
|
||||
padding: var(--s6);
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s4);
|
||||
min-height: 320px;
|
||||
text-align: center;
|
||||
padding: var(--s8);
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.preview-empty-eyebrow {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.preview-empty-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-empty-sub {
|
||||
color: var(--text-2);
|
||||
max-width: 36rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
min-height: 140px;
|
||||
}
|
||||
|
||||
.preview-header-accent {
|
||||
width: 4px;
|
||||
flex-shrink: 0;
|
||||
background: var(--preview-accent, var(--gp-accent));
|
||||
box-shadow: 0 0 12px var(--preview-accent, var(--gp-accent));
|
||||
}
|
||||
|
||||
.preview-header-body {
|
||||
flex: 1;
|
||||
padding: var(--s5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preview-header-top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--s4);
|
||||
}
|
||||
|
||||
.preview-gp-name {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.preview-gp-flag {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.preview-circuit {
|
||||
color: var(--text-2);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-countdown {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.preview-countdown-label {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
margin-bottom: var(--s1);
|
||||
}
|
||||
|
||||
.preview-countdown-value {
|
||||
font-family: var(--mono);
|
||||
font-size: 1.1rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
.preview-schedule {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: var(--s2);
|
||||
}
|
||||
|
||||
.preview-schedule-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--s2) var(--s3);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.preview-schedule-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
.preview-schedule-time {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: var(--s5);
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
padding: var(--s5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s4);
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.preview-card-title {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-card-sub {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-2);
|
||||
margin: calc(-1 * var(--s2)) 0 0;
|
||||
}
|
||||
|
||||
.preview-track-stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
min-height: 180px;
|
||||
padding: var(--s4);
|
||||
}
|
||||
|
||||
.preview-track-svg {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.preview-track-outline {
|
||||
fill: none;
|
||||
stroke: var(--preview-accent, var(--gp-accent));
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.preview-track-shadow {
|
||||
fill: none;
|
||||
stroke: rgba(0, 0, 0, 0.5);
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.preview-podium {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.preview-podium-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.preview-podium-pos {
|
||||
width: 1.5rem;
|
||||
font-family: var(--mono);
|
||||
font-weight: 600;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.preview-podium-code {
|
||||
font-weight: 600;
|
||||
min-width: 2.5rem;
|
||||
}
|
||||
|
||||
.preview-podium-team {
|
||||
color: var(--text-2);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.preview-pole {
|
||||
padding-top: var(--s3);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.preview-pole strong {
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
.preview-title-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.preview-title-table th,
|
||||
.preview-title-table td {
|
||||
padding: var(--s2) var(--s3);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.preview-title-table th {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.preview-title-table td.mono {
|
||||
font-family: var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.preview-sprint-note {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-2);
|
||||
margin: 0;
|
||||
padding: var(--s3);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.preview-section-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-3);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
padding: var(--s5);
|
||||
}
|
||||
|
||||
.preview-section-state.error {
|
||||
color: var(--red, #e74c3c);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.preview-page {
|
||||
padding: var(--s4);
|
||||
}
|
||||
|
||||
.preview-header-top {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-countdown {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,13 @@
|
||||
stroke-dasharray: 2 1;
|
||||
}
|
||||
|
||||
.stint-timeline__pit-marker {
|
||||
stroke: var(--text);
|
||||
stroke-width: 1.5;
|
||||
opacity: 0.7;
|
||||
pointer-events: stroke;
|
||||
}
|
||||
|
||||
.stint-timeline__axis-tick {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 9px;
|
||||
|
||||
@@ -112,6 +112,7 @@ describe('ChampionshipPage', () => {
|
||||
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Monaco GP', { exact: false })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('champ-titlemath')).toHaveTextContent('mathematically win the title')
|
||||
expect(screen.getAllByText('~10 pts/round').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('switches to constructors and progression views', async () => {
|
||||
|
||||
@@ -156,6 +156,7 @@ describe('CommandCenterPage', () => {
|
||||
race_control: [],
|
||||
weather: [],
|
||||
laps: [],
|
||||
chapters: [],
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
160
frontend/src/test/DriverProfilePage.test.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
|
||||
import { DriverProfilePage } from '../pages/DriverProfilePage'
|
||||
import type { DriverSummary } from '../types'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
fetchSeasons: vi.fn(),
|
||||
fetchDriverSummary: vi.fn(),
|
||||
}))
|
||||
|
||||
import { fetchSeasons, fetchDriverSummary } from '../api'
|
||||
|
||||
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
||||
const mockFetchSummary = vi.mocked(fetchDriverSummary)
|
||||
|
||||
const summary: DriverSummary = {
|
||||
season: 2025,
|
||||
driver_number: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull',
|
||||
team_colour: '3671c6',
|
||||
headshot_url: '',
|
||||
points: 50,
|
||||
position: 1,
|
||||
wins: 2,
|
||||
podiums: 2,
|
||||
poles: 1,
|
||||
form: [25, 25],
|
||||
cumulative: [25, 50],
|
||||
round_labels: ['R1', 'R2'],
|
||||
rounds: [
|
||||
{
|
||||
meeting_key: 1201,
|
||||
meeting_name: 'Bahrain Grand Prix',
|
||||
country_code: 'BHR',
|
||||
country_name: 'Bahrain',
|
||||
race_position: 1,
|
||||
grid_position: 1,
|
||||
quali_position: 1,
|
||||
points: 25,
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
},
|
||||
{
|
||||
meeting_key: 1202,
|
||||
meeting_name: 'Saudi Arabian Grand Prix',
|
||||
country_code: 'SAU',
|
||||
country_name: 'Saudi Arabia',
|
||||
race_position: 1,
|
||||
grid_position: 4,
|
||||
quali_position: 4,
|
||||
points: 25,
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function renderPage(props: { driverNumber: number; year?: number }) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DriverProfilePage driverNumber={props.driverNumber} year={props.year} />
|
||||
</QueryClientProvider>
|
||||
),
|
||||
})
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: () => null,
|
||||
})
|
||||
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
|
||||
return render(<RouterProvider router={router} />)
|
||||
}
|
||||
|
||||
describe('DriverProfilePage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchSeasons.mockResolvedValue([2025])
|
||||
mockFetchSummary.mockResolvedValue(summary)
|
||||
})
|
||||
|
||||
it('renders header, form, quali-vs-race, and rounds sections', async () => {
|
||||
renderPage({ driverNumber: 1, year: 2025 })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('driver-profile')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Header: identity + season stats.
|
||||
expect(screen.getByTestId('dp-header')).toBeInTheDocument()
|
||||
expect(screen.getByText('Max Verstappen')).toBeInTheDocument()
|
||||
expect(screen.getByText('#1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Red Bull', { exact: false })).toBeInTheDocument()
|
||||
// 'P1' / '50' also appear in the rounds table and chart axis.
|
||||
expect(screen.getAllByText('P1').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('50').length).toBeGreaterThan(0)
|
||||
|
||||
// Season form strip.
|
||||
expect(screen.getByTestId('dp-form')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cumulative points')).toBeInTheDocument()
|
||||
|
||||
// Quali-vs-race chart shows the delta from the Saudi round (P4 → P1 = +3).
|
||||
expect(screen.getByTestId('dp-quali-race')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('+3').length).toBeGreaterThan(0)
|
||||
|
||||
// Track-by-track table rows with flags.
|
||||
const rounds = screen.getByTestId('dp-rounds')
|
||||
expect(rounds).toBeInTheDocument()
|
||||
expect(screen.getByText('Bahrain Grand Prix')).toBeInTheDocument()
|
||||
expect(screen.getByText('Saudi Arabian Grand Prix')).toBeInTheDocument()
|
||||
expect(rounds.textContent).toContain('🇧🇭')
|
||||
expect(rounds.textContent).toContain('🇸🇦')
|
||||
|
||||
// With an explicit year, the seasons list is not needed.
|
||||
expect(mockFetchSeasons).not.toHaveBeenCalled()
|
||||
expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025)
|
||||
})
|
||||
|
||||
it('defaults to the latest season when no year is given', async () => {
|
||||
renderPage({ driverNumber: 1 })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('driver-profile')).toBeInTheDocument()
|
||||
})
|
||||
expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025)
|
||||
})
|
||||
|
||||
it('shows the API error message on failure', async () => {
|
||||
mockFetchSummary.mockRejectedValue(new Error('API 404: Not Found'))
|
||||
renderPage({ driverNumber: 99, year: 2025 })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('API 404: Not Found')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows empty states when no rounds are completed', async () => {
|
||||
mockFetchSummary.mockResolvedValue({
|
||||
...summary,
|
||||
form: [],
|
||||
cumulative: [],
|
||||
round_labels: [],
|
||||
rounds: [],
|
||||
})
|
||||
renderPage({ driverNumber: 1, year: 2025 })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('driver-profile')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getAllByText('No completed rounds yet.').length).toBe(2)
|
||||
expect(screen.getByText('No grid-vs-finish data yet.')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -189,6 +189,26 @@ describe('TimingTower', () => {
|
||||
expect(screen.getByText(/no driver timing rows/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('annotates DRS-range intervals in race mode', () => {
|
||||
const raceRows = [
|
||||
makeRow('1', 1, 'VER'),
|
||||
makeRow('4', 2, 'NOR', { Interval: '+0.4', GapToLeader: '+0.4' }),
|
||||
]
|
||||
render(
|
||||
<TimingTower
|
||||
rows={raceRows}
|
||||
session={{
|
||||
MeetingName: 'Monaco Grand Prix',
|
||||
CircuitName: 'Monaco',
|
||||
SessionType: 'Race',
|
||||
SessionName: 'Race',
|
||||
Path: '',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('DRS range')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the SQ1 cutoff after P17 and marks rows below as at risk', () => {
|
||||
const sprintRows = Array.from({ length: 22 }, (_, index) =>
|
||||
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
||||
|
||||
166
frontend/src/test/LiveTimingPage.test.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { LiveTimingPage } from '../pages/LiveTimingPage'
|
||||
import type { LiveStateResponse, LiveStreamData } from '../types'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
fetchLiveState: vi.fn(),
|
||||
fetchLiveTrackOutline: vi.fn(),
|
||||
}))
|
||||
|
||||
import { fetchLiveState, fetchLiveTrackOutline } from '../api'
|
||||
|
||||
const mockFetchLiveState = vi.mocked(fetchLiveState)
|
||||
const mockFetchLiveTrackOutline = vi.mocked(fetchLiveTrackOutline)
|
||||
|
||||
class MockEventSource {
|
||||
onopen: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
|
||||
constructor() {
|
||||
setTimeout(() => this.onopen?.(), 0)
|
||||
}
|
||||
|
||||
addEventListener() {}
|
||||
close() {}
|
||||
}
|
||||
|
||||
const archivedSnapshot: LiveStreamData = {
|
||||
Drivers: {
|
||||
'1': {
|
||||
RacingNumber: '1',
|
||||
Position: 1,
|
||||
PrevPosition: 1,
|
||||
GapToLeader: '',
|
||||
Interval: '',
|
||||
LastLapTime: '1:21.345',
|
||||
LastLapPB: false,
|
||||
LastLapOB: false,
|
||||
BestLapTime: '1:20.987',
|
||||
BestLapPB: false,
|
||||
BestLapOB: false,
|
||||
BestLapNum: 22,
|
||||
InPit: false,
|
||||
PitOut: false,
|
||||
Retired: false,
|
||||
KnockedOut: false,
|
||||
Cutoff: false,
|
||||
OnFlyingLap: false,
|
||||
NumberOfLaps: 30,
|
||||
SpeedTrap: '',
|
||||
Sectors: [],
|
||||
},
|
||||
},
|
||||
DriverInfo: {
|
||||
'1': {
|
||||
RacingNumber: '1',
|
||||
BroadcastName: 'M VERSTAPPEN',
|
||||
Tla: 'VER',
|
||||
TeamName: 'Red Bull Racing',
|
||||
TeamColour: '3671C6',
|
||||
FirstName: 'Max',
|
||||
LastName: 'Verstappen',
|
||||
},
|
||||
},
|
||||
Tyres: {
|
||||
'1': { Compound: 'HARD', New: false, Age: 12 },
|
||||
},
|
||||
Telemetry: {},
|
||||
RCMessages: [],
|
||||
Weather: {
|
||||
AirTemp: 22,
|
||||
TrackTemp: 41,
|
||||
Humidity: 58,
|
||||
WindSpeed: 3,
|
||||
WindDir: 180,
|
||||
Rainfall: false,
|
||||
},
|
||||
Session: {
|
||||
MeetingName: 'Testonia Grand Prix',
|
||||
CircuitName: 'Testring',
|
||||
SessionType: 'Race',
|
||||
SessionName: 'Race',
|
||||
Path: '',
|
||||
},
|
||||
TeamRadio: [],
|
||||
SessionStatus: 'Finished',
|
||||
TrackStatus: '1',
|
||||
CurrentLap: 57,
|
||||
TotalLaps: 57,
|
||||
Clock: '',
|
||||
ClockRefTime: '',
|
||||
ClockExtrapolating: false,
|
||||
Stints: {},
|
||||
}
|
||||
|
||||
function renderPage(response: LiveStateResponse) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
mockFetchLiveState.mockResolvedValue(response)
|
||||
mockFetchLiveTrackOutline.mockResolvedValue({
|
||||
circuit_key: 1,
|
||||
points: [],
|
||||
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LiveTimingPage />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('LiveTimingPage archive mode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.defineProperty(window, 'EventSource', {
|
||||
value: MockEventSource,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps archived snapshots behind the View Last Session action', async () => {
|
||||
renderPage({
|
||||
is_live: false,
|
||||
data: null,
|
||||
last_snapshot: archivedSnapshot,
|
||||
last_positions: {
|
||||
'1': { x: 10, y: 20, z: 0, status: 'OnTrack' },
|
||||
},
|
||||
last_snapshot_at: '2026-07-04T14:00:00Z',
|
||||
})
|
||||
|
||||
expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active')
|
||||
expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /view last session/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('live-archive-strip')).toHaveTextContent('Archived snapshot')
|
||||
})
|
||||
expect(screen.getByText('Timing Tower')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('archive')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show the archive action when no snapshot is retained', async () => {
|
||||
renderPage({ is_live: false, data: null })
|
||||
|
||||
expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active')
|
||||
expect(screen.queryByRole('button', { name: /view last session/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('temporarily omits the track map while live GPS is unavailable', async () => {
|
||||
renderPage({
|
||||
is_live: true,
|
||||
data: { ...archivedSnapshot, SessionStatus: 'Started' },
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Timing Tower')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Track Map')).not.toBeInTheDocument()
|
||||
expect(mockFetchLiveTrackOutline).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
26
frontend/src/test/Meaning.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Meaning } from '../components/Meaning'
|
||||
|
||||
describe('Meaning', () => {
|
||||
it('renders bare value when meaning is null', () => {
|
||||
render(<Meaning value="+1.234" meaning={null} />)
|
||||
expect(screen.getByText('+1.234')).toBeInTheDocument()
|
||||
expect(screen.queryByText('DRS range')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders value with caption and tooltip title', () => {
|
||||
render(
|
||||
<Meaning
|
||||
value="+0.4"
|
||||
meaning="DRS range"
|
||||
title="Within 1.0s — DRS enabled next straight"
|
||||
tone="good"
|
||||
/>,
|
||||
)
|
||||
const value = screen.getByText('+0.4')
|
||||
expect(value).toBeInTheDocument()
|
||||
expect(screen.getByText('DRS range')).toHaveClass('meaning-caption--good')
|
||||
expect(value.closest('.meaning')).toHaveAttribute('title', 'Within 1.0s — DRS enabled next straight')
|
||||
})
|
||||
})
|
||||
@@ -155,6 +155,7 @@ const raceHub: RaceHub = {
|
||||
is_pit_out_lap: false,
|
||||
},
|
||||
],
|
||||
chapters: [],
|
||||
}
|
||||
|
||||
const weekend: Weekend = {
|
||||
|
||||
276
frontend/src/test/RacePreviewPage.test.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
|
||||
import { RacePreviewPage } from '../pages/RacePreviewPage'
|
||||
import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session, TrackOutline } from '../types'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
fetchSeasons: vi.fn(),
|
||||
fetchMeetings: vi.fn(),
|
||||
fetchSessions: vi.fn(),
|
||||
fetchResults: vi.fn(),
|
||||
fetchStartingGrid: vi.fn(),
|
||||
fetchTrackOutline: vi.fn(),
|
||||
fetchChampionshipHub: vi.fn(),
|
||||
}))
|
||||
|
||||
import {
|
||||
fetchSeasons,
|
||||
fetchMeetings,
|
||||
fetchSessions,
|
||||
fetchResults,
|
||||
fetchStartingGrid,
|
||||
fetchTrackOutline,
|
||||
fetchChampionshipHub,
|
||||
} from '../api'
|
||||
|
||||
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
||||
const mockFetchMeetings = vi.mocked(fetchMeetings)
|
||||
const mockFetchSessions = vi.mocked(fetchSessions)
|
||||
const mockFetchResults = vi.mocked(fetchResults)
|
||||
const mockFetchStartingGrid = vi.mocked(fetchStartingGrid)
|
||||
const mockFetchTrackOutline = vi.mocked(fetchTrackOutline)
|
||||
const mockFetchChampionshipHub = vi.mocked(fetchChampionshipHub)
|
||||
|
||||
const upcomingMeeting: Meeting = {
|
||||
meeting_key: 100,
|
||||
meeting_name: 'Monaco',
|
||||
meeting_official_name: 'Monaco GP',
|
||||
location: 'Monaco',
|
||||
country_name: 'Monaco',
|
||||
country_code: 'MON',
|
||||
country_flag: '',
|
||||
circuit_key: 10,
|
||||
circuit_short_name: 'Monaco',
|
||||
date_start: '2099-05-22T00:00:00+00:00',
|
||||
date_end: '2099-05-24T23:59:59+00:00',
|
||||
year: 2099,
|
||||
}
|
||||
|
||||
const priorMeeting: Meeting = {
|
||||
...upcomingMeeting,
|
||||
meeting_key: 90,
|
||||
year: 2098,
|
||||
date_start: '2098-05-22T00:00:00+00:00',
|
||||
date_end: '2098-05-24T23:59:59+00:00',
|
||||
}
|
||||
|
||||
const sessions: Session[] = [
|
||||
{
|
||||
session_key: 1,
|
||||
session_name: 'FP1',
|
||||
session_type: 'Practice',
|
||||
meeting_key: 100,
|
||||
date_start: '2099-05-22T10:00:00+00:00',
|
||||
date_end: '2099-05-22T11:00:00+00:00',
|
||||
gmt_offset: '02:00:00',
|
||||
},
|
||||
{
|
||||
session_key: 2,
|
||||
session_name: 'Sprint',
|
||||
session_type: 'Sprint',
|
||||
meeting_key: 100,
|
||||
date_start: '2099-05-23T10:00:00+00:00',
|
||||
date_end: '2099-05-23T11:00:00+00:00',
|
||||
gmt_offset: '02:00:00',
|
||||
},
|
||||
{
|
||||
session_key: 3,
|
||||
session_name: 'Race',
|
||||
session_type: 'Race',
|
||||
meeting_key: 100,
|
||||
date_start: '2099-05-24T13:00:00+00:00',
|
||||
date_end: '2099-05-24T15:00:00+00:00',
|
||||
gmt_offset: '02:00:00',
|
||||
},
|
||||
]
|
||||
|
||||
const priorRaceSession: Session = {
|
||||
session_key: 50,
|
||||
session_name: 'Race',
|
||||
session_type: 'Race',
|
||||
meeting_key: 90,
|
||||
date_start: '2098-05-24T13:00:00+00:00',
|
||||
date_end: '2098-05-24T15:00:00+00:00',
|
||||
gmt_offset: '02:00:00',
|
||||
}
|
||||
|
||||
const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
|
||||
driver_number: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull',
|
||||
team_colour: '3671c6',
|
||||
points: 200,
|
||||
position: 1,
|
||||
wins: 5,
|
||||
podiums: 8,
|
||||
poles: 4,
|
||||
form: [25],
|
||||
cumulative: [200],
|
||||
teammate_wins: 9,
|
||||
teammate_losses: 1,
|
||||
...over,
|
||||
})
|
||||
|
||||
const hub: ChampionshipHub = {
|
||||
season: 2099,
|
||||
round: 5,
|
||||
total_rounds: 24,
|
||||
rounds_left: 19,
|
||||
last_race: 'Monaco GP',
|
||||
round_labels: ['R1'],
|
||||
drivers: [
|
||||
hubDriver({}),
|
||||
hubDriver({ driver_number: 4, name_acronym: 'NOR', points: 160, position: 2 }),
|
||||
hubDriver({ driver_number: 16, name_acronym: 'LEC', points: 120, position: 3 }),
|
||||
],
|
||||
teams: [],
|
||||
}
|
||||
|
||||
const results: EnrichedResult[] = [
|
||||
{
|
||||
driver_number: 1,
|
||||
position: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull',
|
||||
team_colour: '3671c6',
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
duration: 100,
|
||||
gap_to_leader: 0,
|
||||
number_of_laps: 78,
|
||||
points: 25,
|
||||
session_key: 50,
|
||||
meeting_key: 90,
|
||||
},
|
||||
]
|
||||
|
||||
const grid: EnrichedGrid[] = [
|
||||
{
|
||||
driver_number: 4,
|
||||
position: 1,
|
||||
name_acronym: 'NOR',
|
||||
full_name: 'Lando Norris',
|
||||
team_name: 'McLaren',
|
||||
team_colour: 'ff8000',
|
||||
session_key: 50,
|
||||
meeting_key: 90,
|
||||
lap_duration: 70,
|
||||
},
|
||||
]
|
||||
|
||||
const outline: TrackOutline = {
|
||||
circuit_key: 10,
|
||||
points: [
|
||||
{ x: 0.1, y: 0.2 },
|
||||
{ x: 0.5, y: 0.5 },
|
||||
{ x: 0.9, y: 0.8 },
|
||||
],
|
||||
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RacePreviewPage />
|
||||
</QueryClientProvider>
|
||||
),
|
||||
})
|
||||
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: RacePreviewPage })
|
||||
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
|
||||
return render(<RouterProvider router={router} />)
|
||||
}
|
||||
|
||||
describe('RacePreviewPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchSeasons.mockResolvedValue([2099])
|
||||
mockFetchChampionshipHub.mockResolvedValue(hub)
|
||||
mockFetchTrackOutline.mockResolvedValue(outline)
|
||||
mockFetchResults.mockResolvedValue(results)
|
||||
mockFetchStartingGrid.mockResolvedValue(grid)
|
||||
})
|
||||
|
||||
it('renders upcoming race preview with sections', async () => {
|
||||
mockFetchMeetings.mockImplementation(async (year: number) => {
|
||||
if (year === 2099) return [upcomingMeeting]
|
||||
if (year === 2098) return [priorMeeting]
|
||||
return []
|
||||
})
|
||||
mockFetchSessions.mockImplementation(async (meetingKey: number) => {
|
||||
if (meetingKey === 100) return sessions
|
||||
if (meetingKey === 90) return [priorRaceSession]
|
||||
return []
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('preview-page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('preview-countdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('preview-header')).toHaveTextContent('Monaco')
|
||||
expect(screen.getByTestId('preview-schedule')).toHaveTextContent('FP1')
|
||||
expect(screen.getByTestId('preview-track-card')).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('VER')
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('preview-title-fight-card')).toHaveTextContent('NOR')
|
||||
expect(screen.getByTestId('preview-sprint-note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders season-over state when no upcoming meeting', async () => {
|
||||
mockFetchMeetings.mockResolvedValue([
|
||||
{
|
||||
...upcomingMeeting,
|
||||
date_start: '2020-05-22T00:00:00+00:00',
|
||||
date_end: '2020-05-24T23:59:59+00:00',
|
||||
},
|
||||
])
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('preview-season-over')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText('Season complete')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('preview-title-fight-card')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('preview-header')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows first-time circuit empty state', async () => {
|
||||
const newCircuit: Meeting = {
|
||||
...upcomingMeeting,
|
||||
meeting_key: 200,
|
||||
circuit_key: 999,
|
||||
meeting_name: 'New GP',
|
||||
}
|
||||
|
||||
mockFetchMeetings.mockImplementation(async (year: number) => {
|
||||
if (year === 2099) return [newCircuit]
|
||||
if (year === 2098) return [priorMeeting]
|
||||
return []
|
||||
})
|
||||
mockFetchSessions.mockResolvedValue(sessions)
|
||||
mockFetchTrackOutline.mockResolvedValue(null)
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('First time on the calendar')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -106,6 +106,17 @@ describe('StrategyView — stints available', () => {
|
||||
)
|
||||
expect(screen.queryByText(/Stints not available/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('maps pit_stops into timeline pit markers for the matching driver', () => {
|
||||
const { container } = render(
|
||||
<StrategyView results={results} stints={stints} pit_stops={pitStops} hasStints={true} />
|
||||
)
|
||||
const markers = container.querySelectorAll('[data-testid="pit-marker"]')
|
||||
expect(markers).toHaveLength(1)
|
||||
expect(markers[0]).toHaveAttribute('data-lap', '19')
|
||||
const titles = [...container.querySelectorAll('title')].map((t) => t.textContent)
|
||||
expect(titles).toContain('HAM pit stop · L19')
|
||||
})
|
||||
})
|
||||
|
||||
describe('StrategyView — stints missing', () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ describe('TabBar', () => {
|
||||
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Race Story' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Strategy' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Compare' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Conditions' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument()
|
||||
|
||||
@@ -52,9 +52,18 @@ describe('TyreDegPanel', () => {
|
||||
const panel = screen.getByTestId('tyredeg-panel')
|
||||
expect(panel).toHaveTextContent('VER')
|
||||
expect(panel).toHaveTextContent('M +5')
|
||||
expect(panel).toHaveTextContent('fresh')
|
||||
expect(panel).toHaveTextContent('warming up')
|
||||
})
|
||||
|
||||
it('annotates tyre age meaning on stint rows', () => {
|
||||
const rows = [
|
||||
makeRow('1', 1, 'VER', { NumberOfLaps: 10, LastLapTime: '1:30.000' }, { Compound: 'MEDIUM', Age: 12 }),
|
||||
]
|
||||
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={[]} />)
|
||||
expect(screen.getByText('mid-life')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders slope and rejoin estimate once laps accumulate across snapshots', () => {
|
||||
const { rerender } = render(
|
||||
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
|
||||
|
||||
439
frontend/src/test/compare-view.test.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { CompareView } from '../components/CompareView'
|
||||
import type { Driver, EnrichedResult, LapsComparisonResponse } from '../types'
|
||||
import {
|
||||
buildBestLapTraceSeries,
|
||||
carDataToTraceSeries,
|
||||
comparisonToDeltaSeries,
|
||||
defaultCompareDriverNumbers,
|
||||
filterCarDataToLap,
|
||||
findBestLap,
|
||||
formatPitLapsCaption,
|
||||
lapsToLapTimes,
|
||||
} from '../lib/compare'
|
||||
import { computeCumulativeDeltas } from '../lib/delta'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
fetchTelemetry: vi.fn(),
|
||||
fetchLapsComparison: vi.fn(),
|
||||
}))
|
||||
|
||||
import { fetchTelemetry, fetchLapsComparison } from '../api'
|
||||
|
||||
const mockFetchTelemetry = vi.mocked(fetchTelemetry)
|
||||
const mockFetchLapsComparison = vi.mocked(fetchLapsComparison)
|
||||
|
||||
const results: EnrichedResult[] = [
|
||||
{
|
||||
driver_number: 1,
|
||||
position: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull Racing',
|
||||
team_colour: '3671C6',
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
duration: null,
|
||||
gap_to_leader: null,
|
||||
number_of_laps: 78,
|
||||
points: 25,
|
||||
session_key: 9472,
|
||||
meeting_key: 1229,
|
||||
},
|
||||
{
|
||||
driver_number: 44,
|
||||
position: 2,
|
||||
name_acronym: 'HAM',
|
||||
full_name: 'Lewis Hamilton',
|
||||
team_name: 'Ferrari',
|
||||
team_colour: 'E8002D',
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
duration: null,
|
||||
gap_to_leader: 5.1,
|
||||
number_of_laps: 78,
|
||||
points: 18,
|
||||
session_key: 9472,
|
||||
meeting_key: 1229,
|
||||
},
|
||||
]
|
||||
|
||||
const drivers: Driver[] = [
|
||||
{
|
||||
driver_number: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
first_name: 'Max',
|
||||
last_name: 'Verstappen',
|
||||
team_name: 'Red Bull Racing',
|
||||
team_colour: '3671C6',
|
||||
headshot_url: '',
|
||||
broadcast_name: 'M VERSTAPPEN',
|
||||
session_key: 9472,
|
||||
meeting_key: 1229,
|
||||
},
|
||||
{
|
||||
driver_number: 44,
|
||||
name_acronym: 'HAM',
|
||||
full_name: 'Lewis Hamilton',
|
||||
first_name: 'Lewis',
|
||||
last_name: 'Hamilton',
|
||||
team_name: 'Ferrari',
|
||||
team_colour: 'E8002D',
|
||||
headshot_url: '',
|
||||
broadcast_name: 'L HAMILTON',
|
||||
session_key: 9472,
|
||||
meeting_key: 1229,
|
||||
},
|
||||
]
|
||||
|
||||
const comparison: LapsComparisonResponse = {
|
||||
session_key: 9472,
|
||||
sc_periods: [],
|
||||
pit_laps: { '44': [19, 40] },
|
||||
drivers: [
|
||||
{
|
||||
driver_number: 1,
|
||||
name_acronym: 'VER',
|
||||
team_colour: '3671C6',
|
||||
laps: [
|
||||
{
|
||||
session_key: 9472,
|
||||
driver_number: 1,
|
||||
meeting_key: 1229,
|
||||
lap_number: 1,
|
||||
date_start: '2025-05-25T13:00:00.000Z',
|
||||
lap_duration: 92.1,
|
||||
is_pit_out_lap: false,
|
||||
},
|
||||
{
|
||||
session_key: 9472,
|
||||
driver_number: 1,
|
||||
meeting_key: 1229,
|
||||
lap_number: 2,
|
||||
date_start: '2025-05-25T13:01:32.100Z',
|
||||
lap_duration: 90.5,
|
||||
is_pit_out_lap: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
driver_number: 44,
|
||||
name_acronym: 'HAM',
|
||||
team_colour: 'E8002D',
|
||||
laps: [
|
||||
{
|
||||
session_key: 9472,
|
||||
driver_number: 44,
|
||||
meeting_key: 1229,
|
||||
lap_number: 1,
|
||||
date_start: '2025-05-25T13:00:01.000Z',
|
||||
lap_duration: 93.0,
|
||||
is_pit_out_lap: false,
|
||||
},
|
||||
{
|
||||
session_key: 9472,
|
||||
driver_number: 44,
|
||||
meeting_key: 1229,
|
||||
lap_number: 2,
|
||||
date_start: '2025-05-25T13:01:34.000Z',
|
||||
lap_duration: null,
|
||||
is_pit_out_lap: true,
|
||||
},
|
||||
{
|
||||
session_key: 9472,
|
||||
driver_number: 44,
|
||||
meeting_key: 1229,
|
||||
lap_number: 3,
|
||||
date_start: '2025-05-25T13:03:10.000Z',
|
||||
lap_duration: 91.2,
|
||||
is_pit_out_lap: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const nextSessionResults: EnrichedResult[] = [
|
||||
{
|
||||
driver_number: 16,
|
||||
position: 1,
|
||||
name_acronym: 'LEC',
|
||||
full_name: 'Charles Leclerc',
|
||||
team_name: 'Ferrari',
|
||||
team_colour: 'E8002D',
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
duration: null,
|
||||
gap_to_leader: null,
|
||||
number_of_laps: 57,
|
||||
points: 25,
|
||||
session_key: 9550,
|
||||
meeting_key: 1234,
|
||||
},
|
||||
{
|
||||
driver_number: 55,
|
||||
position: 2,
|
||||
name_acronym: 'SAI',
|
||||
full_name: 'Carlos Sainz',
|
||||
team_name: 'Williams',
|
||||
team_colour: '64C4FF',
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
duration: null,
|
||||
gap_to_leader: 3.2,
|
||||
number_of_laps: 57,
|
||||
points: 18,
|
||||
session_key: 9550,
|
||||
meeting_key: 1234,
|
||||
},
|
||||
]
|
||||
|
||||
const nextSessionDrivers: Driver[] = [
|
||||
{
|
||||
driver_number: 16,
|
||||
name_acronym: 'LEC',
|
||||
full_name: 'Charles Leclerc',
|
||||
first_name: 'Charles',
|
||||
last_name: 'Leclerc',
|
||||
team_name: 'Ferrari',
|
||||
team_colour: 'E8002D',
|
||||
headshot_url: '',
|
||||
broadcast_name: 'C LECLERC',
|
||||
session_key: 9550,
|
||||
meeting_key: 1234,
|
||||
},
|
||||
{
|
||||
driver_number: 55,
|
||||
name_acronym: 'SAI',
|
||||
full_name: 'Carlos Sainz',
|
||||
first_name: 'Carlos',
|
||||
last_name: 'Sainz',
|
||||
team_name: 'Williams',
|
||||
team_colour: '64C4FF',
|
||||
headshot_url: '',
|
||||
broadcast_name: 'C SAINZ',
|
||||
session_key: 9550,
|
||||
meeting_key: 1234,
|
||||
},
|
||||
]
|
||||
|
||||
function renderCompareView(
|
||||
props: {
|
||||
sessionKey?: number
|
||||
results?: EnrichedResult[]
|
||||
drivers?: Driver[]
|
||||
} = {},
|
||||
) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<CompareView
|
||||
sessionKey={props.sessionKey ?? 9472}
|
||||
results={props.results ?? results}
|
||||
drivers={props.drivers ?? drivers}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('compare mapping helpers', () => {
|
||||
it('defaults to top two finishers', () => {
|
||||
expect(defaultCompareDriverNumbers(results, drivers)).toEqual([1, 44])
|
||||
})
|
||||
|
||||
it('maps comparison laps to delta series with null pit laps', () => {
|
||||
const series = comparisonToDeltaSeries(comparison, [1, 44], drivers)
|
||||
expect(series).toHaveLength(2)
|
||||
expect(series[0].label).toBe('VER')
|
||||
expect(series[1].lapTimes[1]).toBeNull()
|
||||
|
||||
const deltas = computeCumulativeDeltas(series, 'VER')
|
||||
expect(deltas[0].deltas[0]).toBeCloseTo(0.9)
|
||||
})
|
||||
|
||||
it('maps car data to trace series', () => {
|
||||
const trace = carDataToTraceSeries(
|
||||
[
|
||||
{
|
||||
speed: 300,
|
||||
throttle: 100,
|
||||
brake: 0,
|
||||
date: '',
|
||||
driver_number: 1,
|
||||
drs: 0,
|
||||
meeting_key: 1,
|
||||
n_gear: 8,
|
||||
rpm: 12000,
|
||||
session_key: 1,
|
||||
},
|
||||
],
|
||||
'VER',
|
||||
'#3671C6',
|
||||
)
|
||||
expect(trace.samples[0]).toEqual({ speed: 300, throttle: 100, brake: 0 })
|
||||
})
|
||||
|
||||
it('filters car data to best lap window', () => {
|
||||
const best = findBestLap(comparison.drivers[0].laps)
|
||||
expect(best?.lap_number).toBe(2)
|
||||
|
||||
const samples = [
|
||||
{
|
||||
date: '2025-05-25T13:01:32.100Z',
|
||||
speed: 280,
|
||||
throttle: 90,
|
||||
brake: 0,
|
||||
driver_number: 1,
|
||||
drs: 0,
|
||||
meeting_key: 1229,
|
||||
n_gear: 7,
|
||||
rpm: 11000,
|
||||
session_key: 9472,
|
||||
},
|
||||
{
|
||||
date: '2025-05-25T13:00:00.000Z',
|
||||
speed: 200,
|
||||
throttle: 50,
|
||||
brake: 10,
|
||||
driver_number: 1,
|
||||
drs: 0,
|
||||
meeting_key: 1229,
|
||||
n_gear: 4,
|
||||
rpm: 9000,
|
||||
session_key: 9472,
|
||||
},
|
||||
]
|
||||
|
||||
const filtered = filterCarDataToLap(samples, best!)
|
||||
expect(filtered).toHaveLength(1)
|
||||
expect(filtered[0].speed).toBe(280)
|
||||
})
|
||||
|
||||
it('builds best-lap trace series from car data and laps', () => {
|
||||
const series = buildBestLapTraceSeries(
|
||||
[
|
||||
{
|
||||
date: '2025-05-25T13:01:33.000Z',
|
||||
speed: 310,
|
||||
throttle: 100,
|
||||
brake: 0,
|
||||
driver_number: 1,
|
||||
drs: 10,
|
||||
meeting_key: 1229,
|
||||
n_gear: 8,
|
||||
rpm: 12000,
|
||||
session_key: 9472,
|
||||
},
|
||||
],
|
||||
comparison.drivers[0].laps,
|
||||
'VER',
|
||||
'#3671C6',
|
||||
)
|
||||
expect(series?.samples).toHaveLength(1)
|
||||
expect(series?.samples[0].speed).toBe(310)
|
||||
})
|
||||
|
||||
it('maps laps to lap time arrays with nulls', () => {
|
||||
expect(lapsToLapTimes(comparison.drivers[1].laps)).toEqual([93.0, null, 91.2])
|
||||
})
|
||||
|
||||
it('formats pit lap captions', () => {
|
||||
expect(formatPitLapsCaption(comparison.pit_laps, [1, 44], comparison, drivers)).toBe(
|
||||
'Pit stops — HAM: L19, L40',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CompareView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchLapsComparison.mockResolvedValue(comparison)
|
||||
mockFetchTelemetry.mockResolvedValue([
|
||||
{
|
||||
date: '2025-05-25T13:01:33.000Z',
|
||||
speed: 310,
|
||||
throttle: 100,
|
||||
brake: 0,
|
||||
driver_number: 1,
|
||||
drs: 10,
|
||||
meeting_key: 1229,
|
||||
n_gear: 8,
|
||||
rpm: 12000,
|
||||
session_key: 9472,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('renders pickers defaulting to top two finishers', async () => {
|
||||
renderCompareView()
|
||||
expect(screen.getByTestId('compare-picker-a')).toHaveValue('1')
|
||||
expect(screen.getByTestId('compare-picker-b')).toHaveValue('44')
|
||||
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('HAM').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('resets the selected pair when the mounted session changes', async () => {
|
||||
const { rerender } = renderCompareView()
|
||||
|
||||
fireEvent.change(screen.getByTestId('compare-picker-a'), { target: { value: '44' } })
|
||||
expect(screen.getByTestId('compare-picker-a')).toHaveValue('44')
|
||||
|
||||
rerender(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<CompareView
|
||||
sessionKey={9550}
|
||||
results={nextSessionResults}
|
||||
drivers={nextSessionDrivers}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('compare-picker-a')).toHaveValue('16')
|
||||
expect(screen.getByTestId('compare-picker-b')).toHaveValue('55')
|
||||
})
|
||||
})
|
||||
|
||||
it('renders telemetry and pace sections with mocked queries', async () => {
|
||||
renderCompareView()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchLapsComparison).toHaveBeenCalledWith(9472, [1, 44])
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('telemetry-trace')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('delta-time-graph')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('compare-pit-caption')).toHaveTextContent('HAM: L19, L40')
|
||||
})
|
||||
|
||||
it('shows error state when comparison fetch fails', async () => {
|
||||
mockFetchLapsComparison.mockRejectedValue(new Error('comparison unavailable'))
|
||||
renderCompareView()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('comparison unavailable')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows empty state when fewer than two drivers', () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<CompareView sessionKey={9472} results={[]} drivers={[drivers[0]]} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
expect(screen.getByTestId('compare-view-empty')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -47,7 +47,7 @@ describe('computeCumulativeDeltas', () => {
|
||||
expect(result[0].deltas[2]).toBeCloseTo(2)
|
||||
})
|
||||
|
||||
it('emits null for missing lap times while carrying cumulative forward', () => {
|
||||
it('emits null for challenger missing lap times while carrying cumulative forward', () => {
|
||||
const withNull: DeltaSeries = {
|
||||
label: 'NOR',
|
||||
color: '#FF8000',
|
||||
@@ -60,6 +60,41 @@ describe('computeCumulativeDeltas', () => {
|
||||
expect(result[0].deltas[2]).toBeCloseTo(-92)
|
||||
})
|
||||
|
||||
it('gaps all drivers when the reference lap is null and resumes without that window', () => {
|
||||
const refWithNull: DeltaSeries = {
|
||||
label: 'VER',
|
||||
color: '#3671C6',
|
||||
lapTimes: [90, null, 92],
|
||||
}
|
||||
const validChallenger: DeltaSeries = {
|
||||
label: 'HAM',
|
||||
color: '#E8002D',
|
||||
lapTimes: [89, 91, 90],
|
||||
}
|
||||
const result = computeCumulativeDeltas([refWithNull, validChallenger])
|
||||
expect(result[0].deltas[0]).toBeCloseTo(-1)
|
||||
expect(result[0].deltas[1]).toBeNull()
|
||||
// Lap 3 excludes the reference-null window for both: (89+90) - (90+92) = -3
|
||||
expect(result[0].deltas[2]).toBeCloseTo(-3)
|
||||
})
|
||||
|
||||
it('gaps challenger laps beyond a shorter reference series', () => {
|
||||
const shortReference: DeltaSeries = {
|
||||
label: 'VER',
|
||||
color: '#3671C6',
|
||||
lapTimes: [90, 91],
|
||||
}
|
||||
const longerChallenger: DeltaSeries = {
|
||||
label: 'HAM',
|
||||
color: '#E8002D',
|
||||
lapTimes: [89, 92, 90],
|
||||
}
|
||||
const result = computeCumulativeDeltas([shortReference, longerChallenger])
|
||||
expect(result[0].deltas[0]).toBeCloseTo(-1)
|
||||
expect(result[0].deltas[1]).toBeCloseTo(0)
|
||||
expect(result[0].deltas[2]).toBeNull()
|
||||
})
|
||||
|
||||
it('returns an empty array when only one series is provided', () => {
|
||||
expect(computeCumulativeDeltas([reference])).toEqual([])
|
||||
})
|
||||
@@ -97,6 +132,19 @@ describe('DeltaTimeGraph', () => {
|
||||
expect(screen.queryByTestId('delta-line-VER')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('splits polylines at reference-null laps', () => {
|
||||
const refWithNull: DeltaSeries = {
|
||||
label: 'VER',
|
||||
color: '#3671C6',
|
||||
lapTimes: [90, null, 92],
|
||||
}
|
||||
const { container } = render(
|
||||
<DeltaTimeGraph series={[refWithNull, challenger]} />,
|
||||
)
|
||||
const lines = container.querySelectorAll('.delta-graph-driver-line')
|
||||
expect(lines.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('shows a crosshair tooltip on hover', () => {
|
||||
vi.spyOn(SVGSVGElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
@@ -118,4 +166,31 @@ describe('DeltaTimeGraph', () => {
|
||||
expect(screen.getByText(/Lap 1/)).toBeInTheDocument()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('omits tooltip rows on reference-null laps', () => {
|
||||
vi.spyOn(SVGSVGElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 640,
|
||||
height: 220,
|
||||
right: 640,
|
||||
bottom: 220,
|
||||
toJSON: () => ({}),
|
||||
})
|
||||
const refWithNull: DeltaSeries = {
|
||||
label: 'VER',
|
||||
color: '#3671C6',
|
||||
lapTimes: [90, null, 92],
|
||||
}
|
||||
const { container } = render(
|
||||
<DeltaTimeGraph series={[refWithNull, challenger]} />,
|
||||
)
|
||||
const hoverLayer = container.querySelector('.delta-graph-hover-layer')
|
||||
fireEvent.mouseMove(hoverLayer!, { clientX: 352, clientY: 100 })
|
||||
expect(screen.getByTestId('delta-crosshair')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('delta-tooltip')).not.toBeInTheDocument()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
})
|
||||
|
||||
104
frontend/src/test/driverProfile.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
formatDelta,
|
||||
formatPosition,
|
||||
gridFinishDeltas,
|
||||
shortGpLabel,
|
||||
} from '../lib/driverProfile'
|
||||
import type { DriverSummaryRound } from '../types'
|
||||
|
||||
function round(over: Partial<DriverSummaryRound>): DriverSummaryRound {
|
||||
return {
|
||||
meeting_key: 1201,
|
||||
meeting_name: 'Bahrain Grand Prix',
|
||||
country_code: 'BHR',
|
||||
country_name: 'Bahrain',
|
||||
race_position: 1,
|
||||
grid_position: 1,
|
||||
quali_position: 1,
|
||||
points: 25,
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('gridFinishDeltas', () => {
|
||||
it('computes positive delta for places gained', () => {
|
||||
const [d] = gridFinishDeltas([round({ grid_position: 5, race_position: 2, points: 18 })])
|
||||
expect(d.grid).toBe(5)
|
||||
expect(d.finish).toBe(2)
|
||||
expect(d.delta).toBe(3)
|
||||
expect(d.points).toBe(18)
|
||||
expect(d.status).toBe('classified')
|
||||
expect(d.round).toBe(1)
|
||||
})
|
||||
|
||||
it('computes negative delta for places lost', () => {
|
||||
const [d] = gridFinishDeltas([round({ grid_position: 1, race_position: 4 })])
|
||||
expect(d.delta).toBe(-3)
|
||||
})
|
||||
|
||||
it('returns null delta when the grid slot is unknown (position 0)', () => {
|
||||
const [d] = gridFinishDeltas([round({ grid_position: 0, race_position: 6 })])
|
||||
expect(d.grid).toBeNull()
|
||||
expect(d.finish).toBe(6)
|
||||
expect(d.delta).toBeNull()
|
||||
})
|
||||
|
||||
it('flags DNS rounds and never computes a delta for them', () => {
|
||||
const [d] = gridFinishDeltas([round({ dns: true, grid_position: 8, race_position: 0 })])
|
||||
expect(d.status).toBe('dns')
|
||||
expect(d.delta).toBeNull()
|
||||
})
|
||||
|
||||
it('marks rounds the driver did not enter as absent', () => {
|
||||
const [d] = gridFinishDeltas([round({ grid_position: 0, race_position: 0, points: 0 })])
|
||||
expect(d.status).toBe('absent')
|
||||
expect(d.grid).toBeNull()
|
||||
expect(d.finish).toBeNull()
|
||||
expect(d.delta).toBeNull()
|
||||
})
|
||||
|
||||
it('still computes the delta for a classified DNF with known positions', () => {
|
||||
const [d] = gridFinishDeltas([round({ dnf: true, grid_position: 3, race_position: 15 })])
|
||||
expect(d.status).toBe('dnf')
|
||||
expect(d.delta).toBe(-12)
|
||||
})
|
||||
|
||||
it('numbers rounds sequentially and shortens labels', () => {
|
||||
const ds = gridFinishDeltas([
|
||||
round({}),
|
||||
round({ meeting_name: 'Saudi Arabian Grand Prix', meeting_key: 1202 }),
|
||||
])
|
||||
expect(ds.map((d) => d.round)).toEqual([1, 2])
|
||||
expect(ds[1].label).toBe('Saudi Arabian')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shortGpLabel', () => {
|
||||
it('strips "Grand Prix" wherever it appears', () => {
|
||||
expect(shortGpLabel('Bahrain Grand Prix')).toBe('Bahrain')
|
||||
expect(shortGpLabel('Grand Prix of Monaco')).toBe('of Monaco')
|
||||
})
|
||||
|
||||
it('falls back to the original name when stripping empties it', () => {
|
||||
expect(shortGpLabel('Grand Prix')).toBe('Grand Prix')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatPosition / formatDelta', () => {
|
||||
it('formats positions', () => {
|
||||
expect(formatPosition(4)).toBe('P4')
|
||||
expect(formatPosition(null)).toBe('—')
|
||||
expect(formatPosition(0)).toBe('—')
|
||||
})
|
||||
|
||||
it('formats deltas', () => {
|
||||
expect(formatDelta(3)).toBe('+3')
|
||||
expect(formatDelta(-2)).toBe('−2')
|
||||
expect(formatDelta(0)).toBe('=')
|
||||
expect(formatDelta(null)).toBe('—')
|
||||
})
|
||||
})
|
||||
268
frontend/src/test/events.test.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MAX_EVENTS,
|
||||
appendEvents,
|
||||
diffSnapshots,
|
||||
humanizeRaceControl,
|
||||
sessionSignature,
|
||||
} from '../lib/events'
|
||||
import type { LiveEvent } from '../lib/events'
|
||||
import type {
|
||||
LiveDriverData,
|
||||
LiveDriverInfo,
|
||||
LiveRCMessage,
|
||||
LiveStreamData,
|
||||
} from '../types'
|
||||
|
||||
function driver(number: string, position: number, overrides: Partial<LiveDriverData> = {}): LiveDriverData {
|
||||
return {
|
||||
RacingNumber: number,
|
||||
Position: position,
|
||||
PrevPosition: position,
|
||||
GapToLeader: '',
|
||||
Interval: '',
|
||||
LastLapTime: '',
|
||||
LastLapPB: false,
|
||||
LastLapOB: false,
|
||||
BestLapTime: '',
|
||||
BestLapPB: false,
|
||||
BestLapOB: false,
|
||||
BestLapNum: 0,
|
||||
InPit: false,
|
||||
PitOut: false,
|
||||
Retired: false,
|
||||
KnockedOut: false,
|
||||
Cutoff: false,
|
||||
OnFlyingLap: false,
|
||||
NumberOfLaps: 10,
|
||||
SpeedTrap: '',
|
||||
Sectors: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function info(number: string, tla: string): LiveDriverInfo {
|
||||
return {
|
||||
RacingNumber: number,
|
||||
BroadcastName: tla,
|
||||
Tla: tla,
|
||||
TeamName: '',
|
||||
TeamColour: '3671C6',
|
||||
FirstName: '',
|
||||
LastName: '',
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(
|
||||
drivers: Record<string, LiveDriverData>,
|
||||
overrides: Partial<LiveStreamData> = {},
|
||||
): LiveStreamData {
|
||||
return {
|
||||
Drivers: drivers,
|
||||
DriverInfo: {
|
||||
'1': info('1', 'VER'),
|
||||
'4': info('4', 'NOR'),
|
||||
'44': info('44', 'HAM'),
|
||||
},
|
||||
Tyres: {},
|
||||
RCMessages: [],
|
||||
TeamRadio: [],
|
||||
Weather: {} as LiveStreamData['Weather'],
|
||||
Session: {
|
||||
MeetingName: 'Test Grand Prix',
|
||||
CircuitName: 'Test Circuit',
|
||||
SessionType: 'Race',
|
||||
SessionName: 'Race',
|
||||
Path: '',
|
||||
},
|
||||
TrackStatus: '1',
|
||||
CurrentLap: 12,
|
||||
TotalLaps: 57,
|
||||
Clock: '01:23:45',
|
||||
ClockRefTime: '',
|
||||
ClockExtrapolating: false,
|
||||
Stints: {},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function rc(message: string, category: string, overrides: Partial<LiveRCMessage> = {}): LiveRCMessage {
|
||||
return { Time: '2026-07-03T14:00:00Z', Category: category, Flag: '', Message: message, Lap: 12, ...overrides }
|
||||
}
|
||||
|
||||
describe('diffSnapshots', () => {
|
||||
it('returns no events for identical snapshots', () => {
|
||||
const prev = snapshot({ '1': driver('1', 1), '44': driver('44', 2) })
|
||||
const next = snapshot({ '1': driver('1', 1), '44': driver('44', 2) })
|
||||
expect(diffSnapshots(prev, next)).toEqual([])
|
||||
})
|
||||
|
||||
it('reports an overtake when two drivers swap positions', () => {
|
||||
const prev = snapshot({ '1': driver('1', 3), '44': driver('44', 2) })
|
||||
const next = snapshot({ '1': driver('1', 2), '44': driver('44', 3) })
|
||||
const events = diffSnapshots(prev, next)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0].kind).toBe('overtake')
|
||||
expect(events[0].headline).toBe('VER overtakes HAM for P2')
|
||||
expect(events[0].racingNumbers).toEqual(['1', '44'])
|
||||
expect(events[0].lap).toBe(12)
|
||||
})
|
||||
|
||||
it('reports a generic gain when the displaced driver pitted instead', () => {
|
||||
const prev = snapshot({ '1': driver('1', 3), '44': driver('44', 2) })
|
||||
const next = snapshot({ '1': driver('1', 2), '44': driver('44', 5, { InPit: true }) })
|
||||
const events = diffSnapshots(prev, next)
|
||||
const gain = events.find((event) => event.kind === 'position-gain')
|
||||
expect(gain?.headline).toBe('VER moves up to P2 (from P3)')
|
||||
// HAM's drop is covered by the pit-in event, not a position-loss one.
|
||||
expect(events.some((event) => event.kind === 'position-loss')).toBe(false)
|
||||
expect(events.some((event) => event.kind === 'pit-in')).toBe(true)
|
||||
})
|
||||
|
||||
it('reports a position loss not explained by an overtake or pit stop', () => {
|
||||
const prev = snapshot({ '44': driver('44', 3) })
|
||||
const next = snapshot({ '44': driver('44', 6) })
|
||||
const events = diffSnapshots(prev, next)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0].kind).toBe('position-loss')
|
||||
expect(events[0].headline).toBe('HAM drops to P6 (from P3)')
|
||||
})
|
||||
|
||||
it('reports pit entry with the position pitted from', () => {
|
||||
const prev = snapshot({ '4': driver('4', 3) })
|
||||
const next = snapshot({ '4': driver('4', 3, { InPit: true }) })
|
||||
const events = diffSnapshots(prev, next)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0].kind).toBe('pit-in')
|
||||
expect(events[0].headline).toBe('NOR pits from P3')
|
||||
expect(events[0].racingNumbers).toEqual(['4'])
|
||||
})
|
||||
|
||||
it('reports pit exit with rejoin position, compound, and stop count', () => {
|
||||
const prev = snapshot({ '4': driver('4', 3, { InPit: true }) })
|
||||
const next = snapshot(
|
||||
{ '4': driver('4', 6, { PitOut: true }) },
|
||||
{
|
||||
Tyres: { '4': { Compound: 'MEDIUM', New: true, Age: 0 } },
|
||||
Stints: {
|
||||
'4': [
|
||||
{ Compound: 'SOFT', New: true, Laps: 12 },
|
||||
{ Compound: 'HARD', New: true, Laps: 20 },
|
||||
{ Compound: 'MEDIUM', New: true, Laps: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
const events = diffSnapshots(prev, next)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0].kind).toBe('pit-out')
|
||||
expect(events[0].headline).toBe('NOR rejoins P6 (MEDIUM, 2nd stop)')
|
||||
})
|
||||
|
||||
it('reports personal and overall best laps once per lap time', () => {
|
||||
const prev = snapshot({
|
||||
'1': driver('1', 1, { LastLapTime: '1:24.500' }),
|
||||
'4': driver('4', 2, { LastLapTime: '1:25.100' }),
|
||||
})
|
||||
const next = snapshot({
|
||||
'1': driver('1', 1, { LastLapTime: '1:23.456', LastLapPB: true, LastLapOB: true }),
|
||||
'4': driver('4', 2, { LastLapTime: '1:24.900', LastLapPB: true }),
|
||||
})
|
||||
const events = diffSnapshots(prev, next)
|
||||
const fastest = events.find((event) => event.kind === 'fastest-lap')
|
||||
const personal = events.find((event) => event.kind === 'personal-best')
|
||||
expect(fastest?.headline).toBe('VER sets the fastest lap — 1:23.456')
|
||||
expect(personal?.headline).toBe('NOR sets a personal best — 1:24.900')
|
||||
// A repeat diff of the same state produces nothing new.
|
||||
expect(diffSnapshots(next, snapshot(next.Drivers))).toEqual([])
|
||||
})
|
||||
|
||||
it('reports a retirement with the last running position', () => {
|
||||
const prev = snapshot({ '44': driver('44', 5) })
|
||||
const next = snapshot({ '44': driver('44', 5, { Retired: true }) })
|
||||
const events = diffSnapshots(prev, next)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0].kind).toBe('retirement')
|
||||
expect(events[0].headline).toBe('HAM retires')
|
||||
expect(events[0].detail).toBe('Was running P5')
|
||||
})
|
||||
|
||||
it('reports track status transitions as human sentences', () => {
|
||||
const prev = snapshot({ '1': driver('1', 1) })
|
||||
const next = snapshot({ '1': driver('1', 1) }, { TrackStatus: '4' })
|
||||
const events = diffSnapshots(prev, next)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0].kind).toBe('track-status')
|
||||
expect(events[0].headline).toBe('Safety Car deployed')
|
||||
})
|
||||
|
||||
it('reports new Flag/SafetyCar/Drs race-control messages and skips other categories', () => {
|
||||
const existing = rc('YELLOW IN TRACK SECTOR 7', 'Flag', { Time: '2026-07-03T13:00:00Z' })
|
||||
const prev = snapshot({ '1': driver('1', 1) }, { RCMessages: [existing] })
|
||||
const next = snapshot({ '1': driver('1', 1) }, {
|
||||
RCMessages: [
|
||||
existing,
|
||||
rc('DRS ENABLED', 'Drs'),
|
||||
rc('FIA STEWARDS: CAR 4 (NOR) UNDER INVESTIGATION', 'Other'),
|
||||
],
|
||||
})
|
||||
const events = diffSnapshots(prev, next)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0].kind).toBe('race-control')
|
||||
expect(events[0].headline).toBe('DRS enabled')
|
||||
})
|
||||
|
||||
it('produces stable ids across repeated diffs of the same snapshots', () => {
|
||||
const prev = snapshot({ '1': driver('1', 3), '44': driver('44', 2) })
|
||||
const next = snapshot({ '1': driver('1', 2), '44': driver('44', 3) })
|
||||
const first = diffSnapshots(prev, next)
|
||||
const second = diffSnapshots(prev, next)
|
||||
expect(first.map((event) => event.id)).toEqual(second.map((event) => event.id))
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanizeRaceControl', () => {
|
||||
it('sentence-cases while preserving acronyms', () => {
|
||||
expect(humanizeRaceControl('SAFETY CAR DEPLOYED')).toBe('Safety car deployed')
|
||||
expect(humanizeRaceControl('DRS ENABLED')).toBe('DRS enabled')
|
||||
expect(humanizeRaceControl('VSC DEPLOYED')).toBe('VSC deployed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendEvents', () => {
|
||||
const event = (id: string): LiveEvent => ({
|
||||
id,
|
||||
kind: 'race-control',
|
||||
lap: 1,
|
||||
timestamp: '',
|
||||
headline: id,
|
||||
racingNumbers: [],
|
||||
})
|
||||
|
||||
it('prepends new events newest-first and dedups by id', () => {
|
||||
const buffer = appendEvents([event('a')], [event('a'), event('b'), event('c')])
|
||||
expect(buffer.map((entry) => entry.id)).toEqual(['c', 'b', 'a'])
|
||||
// Re-appending the same diff output changes nothing.
|
||||
const again = appendEvents(buffer, [event('b'), event('c')])
|
||||
expect(again.map((entry) => entry.id)).toEqual(['c', 'b', 'a'])
|
||||
})
|
||||
|
||||
it('evicts the oldest events beyond the cap', () => {
|
||||
let buffer: LiveEvent[] = []
|
||||
for (let index = 0; index < MAX_EVENTS + 5; index += 1) {
|
||||
buffer = appendEvents(buffer, [event(`event-${index}`)])
|
||||
}
|
||||
expect(buffer).toHaveLength(MAX_EVENTS)
|
||||
expect(buffer[0].id).toBe(`event-${MAX_EVENTS + 4}`)
|
||||
expect(buffer[buffer.length - 1].id).toBe('event-5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessionSignature', () => {
|
||||
it('changes when the session changes, enabling buffer resets', () => {
|
||||
const race = snapshot({}).Session
|
||||
expect(sessionSignature(race)).toBe('Test Grand Prix|Race|Race')
|
||||
expect(sessionSignature({ ...race, SessionName: 'Qualifying' })).not.toBe(sessionSignature(race))
|
||||
expect(sessionSignature(null)).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -163,6 +163,20 @@ describe('live transforms', () => {
|
||||
expect(parsed?.data?.Drivers['16'].RacingNumber).toBe('16')
|
||||
})
|
||||
|
||||
it('parses archived EventSource snapshots separately from active data', () => {
|
||||
const parsed = parseLiveStateEvent(JSON.stringify({
|
||||
is_live: false,
|
||||
data: null,
|
||||
last_snapshot: snapshot,
|
||||
last_positions: { '16': { x: 1, y: 2, z: 3, status: 'OnTrack' } },
|
||||
last_snapshot_at: '2026-07-04T14:00:00Z',
|
||||
}))
|
||||
expect(parsed?.is_live).toBe(false)
|
||||
expect(parsed?.data).toBeNull()
|
||||
expect(parsed?.last_snapshot?.Drivers['16'].RacingNumber).toBe('16')
|
||||
expect(parsed?.last_positions?.['16'].status).toBe('OnTrack')
|
||||
})
|
||||
|
||||
it('sorts timing rows by live position and includes drivers with metadata only', () => {
|
||||
const rows = sortLiveTimingRows(snapshot)
|
||||
expect(rows.map((row) => row.RacingNumber)).toEqual(['16', '1', '44'])
|
||||
|
||||
71
frontend/src/test/meaning.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
INTERVAL_DRS_MAX_SECONDS,
|
||||
INTERVAL_UNDERCUT_MAX_SECONDS,
|
||||
INTERVAL_UNDERCUT_MIN_SECONDS,
|
||||
MAX_POINTS_PER_ROUND,
|
||||
TYRE_CLIFF_LAPS,
|
||||
intervalMeaning,
|
||||
pointsGapMeaning,
|
||||
tyreAgeMeaning,
|
||||
} from '../lib/meaning'
|
||||
|
||||
describe('intervalMeaning', () => {
|
||||
it('returns DRS range below the threshold', () => {
|
||||
expect(intervalMeaning(0.4)?.caption).toBe('DRS range')
|
||||
expect(intervalMeaning(INTERVAL_DRS_MAX_SECONDS - 0.01)?.caption).toBe('DRS range')
|
||||
})
|
||||
|
||||
it('returns undercut window in the middle band', () => {
|
||||
expect(intervalMeaning(INTERVAL_UNDERCUT_MIN_SECONDS)?.caption).toBe('undercut window')
|
||||
expect(intervalMeaning(2.0)?.caption).toBe('undercut window')
|
||||
expect(intervalMeaning(INTERVAL_UNDERCUT_MAX_SECONDS)?.caption).toBe('undercut window')
|
||||
})
|
||||
|
||||
it('returns null outside known bands', () => {
|
||||
expect(intervalMeaning(INTERVAL_UNDERCUT_MAX_SECONDS + 0.5)).toBeNull()
|
||||
expect(intervalMeaning(10)).toBeNull()
|
||||
expect(intervalMeaning(null)).toBeNull()
|
||||
expect(intervalMeaning(undefined)).toBeNull()
|
||||
expect(intervalMeaning(-1)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tyreAgeMeaning', () => {
|
||||
it('labels fresh, mid-life, and laps-to-cliff for SOFT', () => {
|
||||
const cliff = TYRE_CLIFF_LAPS.SOFT
|
||||
expect(tyreAgeMeaning('SOFT', 2)?.caption).toBe('fresh')
|
||||
expect(tyreAgeMeaning('SOFT', Math.ceil(cliff * 0.5))?.caption).toBe('mid-life')
|
||||
expect(tyreAgeMeaning('SOFT', cliff - 2)?.caption).toBe('~2 laps to cliff')
|
||||
expect(tyreAgeMeaning('SOFT', cliff + 5)?.caption).toBe('past cliff')
|
||||
})
|
||||
|
||||
it('handles unknown compounds with defaults', () => {
|
||||
expect(tyreAgeMeaning('UNKNOWN', 3)?.caption).toBe('fresh')
|
||||
expect(tyreAgeMeaning(undefined, 3)?.caption).toBe('fresh')
|
||||
})
|
||||
|
||||
it('returns null for invalid age', () => {
|
||||
expect(tyreAgeMeaning('MEDIUM', null)).toBeNull()
|
||||
expect(tyreAgeMeaning('MEDIUM', -1)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointsGapMeaning', () => {
|
||||
it('computes catchable pts/round', () => {
|
||||
const result = pointsGapMeaning(40, 4, 'VER')
|
||||
expect(result?.caption).toBe('~10 pts/round')
|
||||
expect(result?.title).toContain('VER')
|
||||
})
|
||||
|
||||
it('marks uncatchable gaps', () => {
|
||||
const max = 3 * MAX_POINTS_PER_ROUND
|
||||
expect(pointsGapMeaning(max + 1, 3, 'VER')?.caption).toBe('out of reach')
|
||||
})
|
||||
|
||||
it('returns null for leader or invalid input', () => {
|
||||
expect(pointsGapMeaning(0, 4)).toBeNull()
|
||||
expect(pointsGapMeaning(10, 0)).toBeNull()
|
||||
expect(pointsGapMeaning(null, 4)).toBeNull()
|
||||
})
|
||||
})
|
||||
226
frontend/src/test/preview.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session } from '../types'
|
||||
import {
|
||||
buildTitleFightContext,
|
||||
countdownTargetSession,
|
||||
extractPodium,
|
||||
extractPole,
|
||||
findPriorYearMeetingByCircuit,
|
||||
findRaceSession,
|
||||
formatPointsGap,
|
||||
isSprintWeekend,
|
||||
pickPreviewMeeting,
|
||||
RACE_WIN_POINTS,
|
||||
SPRINT_WEEKEND_MAX_POINTS,
|
||||
} from '../lib/preview'
|
||||
|
||||
const meeting = (overrides: Partial<Meeting> = {}): Meeting => ({
|
||||
meeting_key: 1,
|
||||
meeting_name: 'Monaco',
|
||||
meeting_official_name: 'Monaco GP',
|
||||
location: 'Monaco',
|
||||
country_name: 'Monaco',
|
||||
country_code: 'MON',
|
||||
country_flag: '',
|
||||
circuit_key: 10,
|
||||
circuit_short_name: 'Monaco',
|
||||
date_start: '2026-05-22T00:00:00+00:00',
|
||||
date_end: '2026-05-24T23:59:59+00:00',
|
||||
year: 2026,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const session = (overrides: Partial<Session> = {}): Session => ({
|
||||
session_key: 100,
|
||||
session_name: 'Race',
|
||||
session_type: 'Race',
|
||||
meeting_key: 1,
|
||||
date_start: '2026-05-24T13:00:00+00:00',
|
||||
date_end: '2026-05-24T15:00:00+00:00',
|
||||
gmt_offset: '02:00:00',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
|
||||
driver_number: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull',
|
||||
team_colour: '3671c6',
|
||||
points: 200,
|
||||
position: 1,
|
||||
wins: 5,
|
||||
podiums: 8,
|
||||
poles: 4,
|
||||
form: [25],
|
||||
cumulative: [200],
|
||||
teammate_wins: 9,
|
||||
teammate_losses: 1,
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('preview lib', () => {
|
||||
it('picks the next upcoming meeting', () => {
|
||||
const now = new Date('2026-01-01T00:00:00Z')
|
||||
const meetings = [
|
||||
meeting({ meeting_key: 1, date_start: '2026-05-22T00:00:00+00:00' }),
|
||||
meeting({ meeting_key: 2, meeting_name: 'Spain', date_start: '2026-06-12T00:00:00+00:00' }),
|
||||
]
|
||||
expect(pickPreviewMeeting(meetings, now)?.meeting_key).toBe(1)
|
||||
})
|
||||
|
||||
it('returns null when no upcoming meeting exists', () => {
|
||||
const now = new Date('2027-01-01T00:00:00Z')
|
||||
const meetings = [meeting({ date_start: '2026-05-22T00:00:00+00:00' })]
|
||||
expect(pickPreviewMeeting(meetings, now)).toBeNull()
|
||||
})
|
||||
|
||||
it('skips cancelled meetings', () => {
|
||||
const now = new Date('2026-01-01T00:00:00Z')
|
||||
const meetings = [meeting({ is_cancelled: true })]
|
||||
expect(pickPreviewMeeting(meetings, now)).toBeNull()
|
||||
})
|
||||
|
||||
it('matches prior-year meeting by circuit_key', () => {
|
||||
const prior = [
|
||||
meeting({ meeting_key: 50, year: 2025, circuit_key: 10 }),
|
||||
meeting({ meeting_key: 51, year: 2025, circuit_key: 20, meeting_name: 'Spain' }),
|
||||
]
|
||||
expect(findPriorYearMeetingByCircuit(prior, 10)?.meeting_key).toBe(50)
|
||||
expect(findPriorYearMeetingByCircuit(prior, 99)).toBeNull()
|
||||
expect(findPriorYearMeetingByCircuit(prior, undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('finds the grand prix race session', () => {
|
||||
const sessions = [
|
||||
session({ session_key: 1, session_name: 'FP1', session_type: 'Practice', date_start: '2026-05-22T10:00:00+00:00' }),
|
||||
session({ session_key: 2, session_name: 'Sprint', session_type: 'Sprint', date_start: '2026-05-23T10:00:00+00:00' }),
|
||||
session({ session_key: 3, session_name: 'Race', session_type: 'Race', date_start: '2026-05-24T13:00:00+00:00' }),
|
||||
]
|
||||
expect(findRaceSession(sessions)?.session_key).toBe(3)
|
||||
})
|
||||
|
||||
it('detects sprint weekends', () => {
|
||||
const sprint = [
|
||||
session({ session_type: 'Practice' }),
|
||||
session({ session_name: 'Sprint', session_type: 'Sprint' }),
|
||||
session({ session_type: 'Race' }),
|
||||
]
|
||||
const normal = [session({ session_type: 'Practice' }), session({ session_type: 'Race' })]
|
||||
expect(isSprintWeekend(sprint)).toBe(true)
|
||||
expect(isSprintWeekend(normal)).toBe(false)
|
||||
})
|
||||
|
||||
it('extracts podium and pole', () => {
|
||||
const results: EnrichedResult[] = [
|
||||
{
|
||||
driver_number: 1,
|
||||
position: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull',
|
||||
team_colour: '3671c6',
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
duration: 100,
|
||||
gap_to_leader: 0,
|
||||
number_of_laps: 78,
|
||||
points: 25,
|
||||
session_key: 1,
|
||||
meeting_key: 1,
|
||||
},
|
||||
{
|
||||
driver_number: 4,
|
||||
position: 2,
|
||||
name_acronym: 'NOR',
|
||||
full_name: 'Lando Norris',
|
||||
team_name: 'McLaren',
|
||||
team_colour: 'ff8000',
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
duration: 101,
|
||||
gap_to_leader: 1,
|
||||
number_of_laps: 78,
|
||||
points: 18,
|
||||
session_key: 1,
|
||||
meeting_key: 1,
|
||||
},
|
||||
{
|
||||
driver_number: 16,
|
||||
position: 3,
|
||||
name_acronym: 'LEC',
|
||||
full_name: 'Charles Leclerc',
|
||||
team_name: 'Ferrari',
|
||||
team_colour: 'e8002d',
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
duration: 102,
|
||||
gap_to_leader: 2,
|
||||
number_of_laps: 78,
|
||||
points: 15,
|
||||
session_key: 1,
|
||||
meeting_key: 1,
|
||||
},
|
||||
]
|
||||
|
||||
const grid: EnrichedGrid[] = [
|
||||
{
|
||||
driver_number: 4,
|
||||
position: 1,
|
||||
name_acronym: 'NOR',
|
||||
full_name: 'Lando Norris',
|
||||
team_name: 'McLaren',
|
||||
team_colour: 'ff8000',
|
||||
session_key: 1,
|
||||
meeting_key: 1,
|
||||
lap_duration: 70,
|
||||
},
|
||||
]
|
||||
|
||||
const podium = extractPodium(results)
|
||||
expect(podium.map((p) => p.name_acronym)).toEqual(['VER', 'NOR', 'LEC'])
|
||||
expect(extractPole(grid)?.name_acronym).toBe('NOR')
|
||||
})
|
||||
|
||||
it('builds title fight gaps for top 3', () => {
|
||||
const hub: ChampionshipHub = {
|
||||
season: 2026,
|
||||
round: 5,
|
||||
total_rounds: 24,
|
||||
rounds_left: 19,
|
||||
last_race: 'Monaco GP',
|
||||
round_labels: ['R1'],
|
||||
drivers: [
|
||||
hubDriver({ points: 200, position: 1 }),
|
||||
hubDriver({ driver_number: 4, name_acronym: 'NOR', points: 160, position: 2 }),
|
||||
hubDriver({ driver_number: 16, name_acronym: 'LEC', points: 120, position: 3 }),
|
||||
],
|
||||
teams: [],
|
||||
}
|
||||
|
||||
const rows = buildTitleFightContext(hub)
|
||||
expect(rows).toHaveLength(3)
|
||||
expect(rows[0].gapToLeader).toBe(0)
|
||||
expect(rows[1].gapToLeader).toBe(40)
|
||||
expect(rows[1].gapAfterRaceWin).toBe(15)
|
||||
expect(rows[2].gapAfterSprintWeekendMax).toBe(200 - (120 + SPRINT_WEEKEND_MAX_POINTS))
|
||||
expect(RACE_WIN_POINTS).toBe(25)
|
||||
})
|
||||
|
||||
it('formats points gaps', () => {
|
||||
expect(formatPointsGap(0)).toBe('LEADER')
|
||||
expect(formatPointsGap(40)).toBe('+40')
|
||||
})
|
||||
|
||||
it('picks countdown target as next future session', () => {
|
||||
const sessions = [
|
||||
session({ session_key: 1, session_name: 'FP1', session_type: 'Practice', date_start: '2026-05-22T10:00:00+00:00', date_end: '2026-05-22T11:00:00+00:00' }),
|
||||
session({ session_key: 2, session_name: 'Race', session_type: 'Race', date_start: '2026-05-24T13:00:00+00:00', date_end: '2026-05-24T15:00:00+00:00' }),
|
||||
]
|
||||
const now = new Date('2026-05-23T12:00:00Z')
|
||||
expect(countdownTargetSession(sessions, now)?.session_key).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -71,6 +71,61 @@ describe('TyreStintTimeline', () => {
|
||||
expect(screen.getByTestId('stint-timeline-empty')).toBeInTheDocument()
|
||||
expect(screen.getByText(/No stint data/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders one pit marker per stop at the correct lap position', () => {
|
||||
const rowsWithPits: StintTimelineRow[] = [
|
||||
{
|
||||
label: 'HAM',
|
||||
color: '#E8002D',
|
||||
stints: [{ compound: 'SOFT', lapStart: 1, lapEnd: 18 }],
|
||||
pitStops: [19],
|
||||
},
|
||||
{
|
||||
label: 'VER',
|
||||
color: '#3671C6',
|
||||
stints: [
|
||||
{ compound: 'MEDIUM', lapStart: 1, lapEnd: 30 },
|
||||
{ compound: 'SOFT', lapStart: 31, lapEnd: 78 },
|
||||
],
|
||||
pitStops: [31, 52],
|
||||
},
|
||||
]
|
||||
const { container } = render(
|
||||
<TyreStintTimeline rows={rowsWithPits} totalLaps={78} />,
|
||||
)
|
||||
const markers = container.querySelectorAll('[data-testid="pit-marker"]')
|
||||
expect(markers).toHaveLength(3)
|
||||
expect(markers[0]).toHaveAttribute('data-lap', '19')
|
||||
expect(markers[1]).toHaveAttribute('data-lap', '31')
|
||||
expect(markers[2]).toHaveAttribute('data-lap', '52')
|
||||
expect(container.querySelectorAll('.stint-timeline__bar')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('positions pit markers using lap_number and includes driver in tooltip', () => {
|
||||
const rows: StintTimelineRow[] = [
|
||||
{
|
||||
label: 'HAM',
|
||||
color: '#E8002D',
|
||||
stints: [{ compound: 'SOFT', lapStart: 1, lapEnd: 18 }],
|
||||
pitStops: [19],
|
||||
},
|
||||
]
|
||||
const { container } = render(<TyreStintTimeline rows={rows} totalLaps={78} />)
|
||||
const marker = container.querySelector('[data-testid="pit-marker"]') as SVGLineElement
|
||||
expect(marker).toBeTruthy()
|
||||
// lap 19 → x = 48 + (18/78) * 580 ≈ 181.85
|
||||
expect(Number(marker.getAttribute('x1'))).toBeCloseTo(181.85, 1)
|
||||
const titles = [...container.querySelectorAll('title')].map((t) => t.textContent)
|
||||
expect(titles).toContain('HAM pit stop · L19')
|
||||
})
|
||||
|
||||
it('leaves rows without pit data unchanged', () => {
|
||||
const { container } = render(
|
||||
<TyreStintTimeline rows={sampleRows} totalLaps={78} />,
|
||||
)
|
||||
expect(container.querySelectorAll('[data-testid="pit-marker"]')).toHaveLength(0)
|
||||
expect(container.querySelectorAll('.stint-timeline__bar')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
const results: EnrichedResult[] = [
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface Meeting {
|
||||
country_name: string
|
||||
country_code: string
|
||||
country_flag: string
|
||||
circuit_key?: number
|
||||
circuit_short_name: string
|
||||
date_start: string
|
||||
date_end: string
|
||||
@@ -23,6 +24,7 @@ export interface Session {
|
||||
session_key: number
|
||||
session_name: string
|
||||
session_type: string
|
||||
circuit_key?: number
|
||||
meeting_key: number
|
||||
date_start: string
|
||||
date_end: string
|
||||
@@ -89,6 +91,17 @@ export interface RaceHub {
|
||||
race_control: RaceControlMessage[]
|
||||
weather: WeatherSample[]
|
||||
laps: Lap[]
|
||||
chapters: Chapter[]
|
||||
}
|
||||
|
||||
export interface Chapter {
|
||||
kind: 'start' | 'safety_car' | 'virtual_safety_car' | 'red_flag' | 'pit_phase' | 'decisive_swing' | 'finish' | string
|
||||
title: string
|
||||
start_lap: number
|
||||
end_lap: number
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
driver_numbers: number[]
|
||||
}
|
||||
|
||||
export interface Stint {
|
||||
@@ -158,6 +171,37 @@ export interface Lap {
|
||||
is_pit_out_lap: boolean
|
||||
}
|
||||
|
||||
export interface CarDataSample {
|
||||
brake: number
|
||||
date: string
|
||||
driver_number: number
|
||||
drs: number
|
||||
meeting_key: number
|
||||
n_gear: number
|
||||
rpm: number
|
||||
session_key: number
|
||||
speed: number
|
||||
throttle: number
|
||||
}
|
||||
|
||||
export interface ComparisonLap extends Lap {
|
||||
compound?: string
|
||||
}
|
||||
|
||||
export interface ComparisonDriver {
|
||||
driver_number: number
|
||||
name_acronym: string
|
||||
team_colour: string
|
||||
laps: ComparisonLap[]
|
||||
}
|
||||
|
||||
export interface LapsComparisonResponse {
|
||||
session_key: number
|
||||
sc_periods: unknown[]
|
||||
pit_laps: Record<string, number[]>
|
||||
drivers: ComparisonDriver[]
|
||||
}
|
||||
|
||||
export interface WeekendSession {
|
||||
session: Session
|
||||
source: 'local' | 'partial' | 'none' | 'cancelled'
|
||||
@@ -175,6 +219,9 @@ export interface Weekend {
|
||||
export interface LiveStateResponse {
|
||||
is_live: boolean
|
||||
data: LiveStreamData | null
|
||||
last_snapshot?: LiveStreamData | null
|
||||
last_positions?: Record<string, LivePosition> | null
|
||||
last_snapshot_at?: string
|
||||
}
|
||||
|
||||
export interface LivePosition {
|
||||
@@ -285,6 +332,7 @@ export interface LiveStreamData {
|
||||
Weather: LiveWeatherData
|
||||
Session: LiveSessionMeta
|
||||
TeamRadio: LiveRadioCapture[]
|
||||
SessionStatus?: string
|
||||
TrackStatus: string
|
||||
CurrentLap: number
|
||||
TotalLaps: number
|
||||
@@ -348,6 +396,39 @@ export interface ChampionshipHub {
|
||||
teams: ChampHubTeam[]
|
||||
}
|
||||
|
||||
export interface DriverSummaryRound {
|
||||
meeting_key: number
|
||||
meeting_name: string
|
||||
country_code: string
|
||||
country_name: string
|
||||
race_position: number
|
||||
grid_position: number
|
||||
quali_position?: number
|
||||
points: number
|
||||
dnf: boolean
|
||||
dns: boolean
|
||||
dsq: boolean
|
||||
}
|
||||
|
||||
export interface DriverSummary {
|
||||
season: number
|
||||
driver_number: number
|
||||
name_acronym: string
|
||||
full_name: string
|
||||
team_name: string
|
||||
team_colour: string
|
||||
headshot_url: string
|
||||
points: number
|
||||
position: number
|
||||
wins: number
|
||||
podiums: number
|
||||
poles: number
|
||||
form: number[]
|
||||
cumulative: number[]
|
||||
round_labels: string[]
|
||||
rounds: DriverSummaryRound[]
|
||||
}
|
||||
|
||||
export interface NewsItem {
|
||||
source: string
|
||||
title: string
|
||||
|
||||
@@ -94,6 +94,12 @@ func cacheDBPath() string {
|
||||
return filepath.Join(".cache", "box-box", "cache.db")
|
||||
}
|
||||
|
||||
// DefaultCacheDBPath returns the HTTP cache database path used by the OpenF1
|
||||
// client in both TUI and web modes.
|
||||
func DefaultCacheDBPath() string {
|
||||
return cacheDBPath()
|
||||
}
|
||||
|
||||
// ttlForURL determines the appropriate TTL based on the URL pattern.
|
||||
// Returns 0 (CacheTTLForever) for historical data that will never change.
|
||||
func ttlForURL(url string) time.Duration {
|
||||
|
||||
@@ -623,6 +623,19 @@ func (c *OpenF1Client) GetTeamRadio(sessionKey, driverNumber int) ([]models.Team
|
||||
// to maximise the chance of finding data quickly.
|
||||
var candidateDrivers = []int{1, 11, 44, 16, 55, 4, 14, 63, 81, 24}
|
||||
|
||||
// TrackOutlinePrefetchResult summarizes a season track-outline cache warming
|
||||
// run. Counts are scoped to the unique non-zero circuit keys in the provided
|
||||
// meeting list.
|
||||
type TrackOutlinePrefetchResult struct {
|
||||
Year int
|
||||
UniqueCircuits int
|
||||
CachedBefore int
|
||||
CachedAfter int
|
||||
Skipped int
|
||||
Fetched int
|
||||
Failed int
|
||||
}
|
||||
|
||||
// PrefetchTrackOutlines fetches GPS location data for every circuit in the
|
||||
// provided meeting list and stores it in the cache so the track map tab can
|
||||
// render during live sessions when the free-tier API is locked.
|
||||
@@ -632,28 +645,58 @@ var candidateDrivers = []int{1, 11, 44, 16, 55, 4, 14, 63, 81, 24}
|
||||
// Errors per-circuit are silently ignored — this is a best-effort operation
|
||||
// and must never block or crash the main UI.
|
||||
func (c *OpenF1Client) PrefetchTrackOutlines(meetings []models.Meeting) {
|
||||
year := time.Now().Year()
|
||||
for _, m := range meetings {
|
||||
if m.Year != 0 {
|
||||
year = m.Year
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = c.PrefetchTrackOutlinesForYear(year, meetings)
|
||||
}
|
||||
|
||||
// PrefetchTrackOutlinesForYear fetches and caches track outlines for unique
|
||||
// circuits in the provided meeting list, storing them under the explicit season
|
||||
// year. Unlike PrefetchTrackOutlines, it returns accounting suitable for CLI
|
||||
// cache-warming workflows.
|
||||
func (c *OpenF1Client) PrefetchTrackOutlinesForYear(year int, meetings []models.Meeting) TrackOutlinePrefetchResult {
|
||||
const maxWorkers = 3
|
||||
|
||||
year := time.Now().Year()
|
||||
|
||||
// Filter to meetings that need fetching.
|
||||
var pending []models.Meeting
|
||||
result := TrackOutlinePrefetchResult{Year: year}
|
||||
uniqueByCircuit := make(map[int]models.Meeting)
|
||||
var unique []models.Meeting
|
||||
for _, m := range meetings {
|
||||
if m.CircuitKey == 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := uniqueByCircuit[m.CircuitKey]; exists {
|
||||
continue
|
||||
}
|
||||
uniqueByCircuit[m.CircuitKey] = m
|
||||
unique = append(unique, m)
|
||||
}
|
||||
|
||||
result.UniqueCircuits = len(unique)
|
||||
|
||||
// Filter to meetings that need fetching.
|
||||
var pending []models.Meeting
|
||||
for _, m := range unique {
|
||||
if _, ok := c.cache.GetTrackOutline(m.CircuitKey, year); ok {
|
||||
result.CachedBefore++
|
||||
result.Skipped++
|
||||
continue // already cached for this season
|
||||
}
|
||||
pending = append(pending, m)
|
||||
}
|
||||
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
result.CachedAfter = result.CachedBefore
|
||||
return result
|
||||
}
|
||||
|
||||
sem := make(chan struct{}, maxWorkers)
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
for _, mtg := range pending {
|
||||
mtg := mtg // capture
|
||||
@@ -662,19 +705,34 @@ func (c *OpenF1Client) PrefetchTrackOutlines(meetings []models.Meeting) {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
c.prefetchCircuit(mtg, year)
|
||||
ok := c.prefetchCircuit(mtg, year)
|
||||
mu.Lock()
|
||||
if ok {
|
||||
result.Fetched++
|
||||
} else {
|
||||
result.Failed++
|
||||
}
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
for _, m := range unique {
|
||||
if _, ok := c.cache.GetTrackOutline(m.CircuitKey, year); ok {
|
||||
result.CachedAfter++
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// prefetchCircuit fetches the track outline for a single meeting and stores it.
|
||||
// It prefers completed sessions (past date_end) so the data is full and stable.
|
||||
func (c *OpenF1Client) prefetchCircuit(mtg models.Meeting, year int) {
|
||||
func (c *OpenF1Client) prefetchCircuit(mtg models.Meeting, year int) bool {
|
||||
sessions, err := c.GetSessionsForMeeting(int(mtg.MeetingKey))
|
||||
if err != nil || len(sessions) == 0 {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// Pick the best session: prefer a completed race, then any session with
|
||||
@@ -696,7 +754,7 @@ func (c *OpenF1Client) prefetchCircuit(mtg models.Meeting, year int) {
|
||||
}
|
||||
}
|
||||
if bestSession == nil {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// Try candidate drivers in order until we find one with enough points.
|
||||
@@ -706,7 +764,7 @@ func (c *OpenF1Client) prefetchCircuit(mtg models.Meeting, year int) {
|
||||
continue
|
||||
}
|
||||
// Store under the circuit key for this year and stop.
|
||||
_ = c.cache.SetTrackOutline(mtg.CircuitKey, year, locs)
|
||||
return
|
||||
return c.cache.SetTrackOutline(mtg.CircuitKey, year, locs) == nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
109
internal/api/track_outline_prefetch_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
)
|
||||
|
||||
func newTrackOutlineTestClient(t *testing.T, srvURL string) *OpenF1Client {
|
||||
t.Helper()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
|
||||
c := NewOpenF1Client(srvURL, 5*time.Second)
|
||||
c.pacer = &requestPacer{}
|
||||
t.Cleanup(func() { _ = c.Close() })
|
||||
return c
|
||||
}
|
||||
|
||||
func TestPrefetchTrackOutlinesForYearSkipsCachedAndWritesLocations(t *testing.T) {
|
||||
var sessionsByMeeting = map[string][]models.Session{
|
||||
"202": {
|
||||
{
|
||||
SessionKey: 9002,
|
||||
SessionName: "Race",
|
||||
MeetingKey: 202,
|
||||
CircuitKey: 2,
|
||||
DateEnd: "2026-01-01T12:00:00+00:00",
|
||||
},
|
||||
},
|
||||
}
|
||||
var sessionsRequested []string
|
||||
var locationsRequested []string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/sessions":
|
||||
meetingKey := r.URL.Query().Get("meeting_key")
|
||||
sessionsRequested = append(sessionsRequested, meetingKey)
|
||||
_ = json.NewEncoder(w).Encode(sessionsByMeeting[meetingKey])
|
||||
case "/v1/location":
|
||||
sessionKey := r.URL.Query().Get("session_key")
|
||||
driverNumber := r.URL.Query().Get("driver_number")
|
||||
locationsRequested = append(locationsRequested, sessionKey+"/"+driverNumber)
|
||||
_ = json.NewEncoder(w).Encode(testLocations(9002, 1, 51))
|
||||
default:
|
||||
t.Fatalf("unexpected request path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := newTrackOutlineTestClient(t, srv.URL)
|
||||
if err := client.Cache().SetTrackOutline(1, 2026, testLocations(9001, 1, 51)); err != nil {
|
||||
t.Fatalf("SetTrackOutline() error = %v", err)
|
||||
}
|
||||
|
||||
result := client.PrefetchTrackOutlinesForYear(2026, []models.Meeting{
|
||||
{MeetingKey: 101, Year: 2026, Circuit: models.Circuit{CircuitKey: 1}},
|
||||
{MeetingKey: 202, Year: 2026, Circuit: models.Circuit{CircuitKey: 2}},
|
||||
{MeetingKey: 303, Year: 2026, Circuit: models.Circuit{CircuitKey: 2}},
|
||||
})
|
||||
|
||||
if result.UniqueCircuits != 2 {
|
||||
t.Fatalf("UniqueCircuits = %d, want 2", result.UniqueCircuits)
|
||||
}
|
||||
if result.CachedBefore != 1 || result.Skipped != 1 || result.Fetched != 1 || result.Failed != 0 || result.CachedAfter != 2 {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
if got, want := len(sessionsRequested), 1; got != want {
|
||||
t.Fatalf("sessions requested %d time(s), want %d: %v", got, want, sessionsRequested)
|
||||
}
|
||||
if sessionsRequested[0] != "202" {
|
||||
t.Fatalf("requested meeting %s, want 202", sessionsRequested[0])
|
||||
}
|
||||
if got, want := len(locationsRequested), 1; got != want {
|
||||
t.Fatalf("locations requested %d time(s), want %d: %v", got, want, locationsRequested)
|
||||
}
|
||||
if locationsRequested[0] != "9002/1" {
|
||||
t.Fatalf("requested location %s, want 9002/1", locationsRequested[0])
|
||||
}
|
||||
|
||||
locs, ok := client.Cache().GetTrackOutline(2, 2026)
|
||||
if !ok {
|
||||
t.Fatal("expected circuit 2 outline to be cached")
|
||||
}
|
||||
if len(locs) != 51 {
|
||||
t.Fatalf("cached %d locations, want 51", len(locs))
|
||||
}
|
||||
}
|
||||
|
||||
func testLocations(sessionKey, driverNumber, count int) []models.Location {
|
||||
locs := make([]models.Location, count)
|
||||
for i := range locs {
|
||||
locs[i] = models.Location{
|
||||
Date: "2026-01-01T12:00:" + strconv.Itoa(i%60) + "+00:00",
|
||||
DriverNumber: driverNumber,
|
||||
MeetingKey: 202,
|
||||
SessionKey: sessionKey,
|
||||
X: float64(i),
|
||||
Y: float64(i * 2),
|
||||
}
|
||||
}
|
||||
return locs
|
||||
}
|
||||
628
internal/chapters/chapters.go
Normal file
@@ -0,0 +1,628 @@
|
||||
package chapters
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
KindStart = "start"
|
||||
KindSafetyCar = "safety_car"
|
||||
KindVirtualSafetyCar = "virtual_safety_car"
|
||||
KindRedFlag = "red_flag"
|
||||
KindPitPhase = "pit_phase"
|
||||
KindDecisiveSwing = "decisive_swing"
|
||||
KindFinish = "finish"
|
||||
|
||||
pitPhaseWindowLaps = 3
|
||||
pitPhaseShare = 0.30
|
||||
minPitPhaseStops = 2
|
||||
maxDecisiveSwings = 3
|
||||
decisiveAfterLap = 5
|
||||
structuralPriority = 110
|
||||
flagPriority = 100
|
||||
pitPhasePriority = 50
|
||||
decisivePriority = 40
|
||||
)
|
||||
|
||||
type RaceControl = models.RaceControl
|
||||
type PositionSample = models.Position
|
||||
type Lap = models.Lap
|
||||
|
||||
// Chapter is a deterministic replay segment derived from timing and race-control data.
|
||||
type Chapter struct {
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
StartLap int `json:"start_lap"`
|
||||
EndLap int `json:"end_lap"`
|
||||
StartTime string `json:"start_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
DriverNumbers []int `json:"driver_numbers"`
|
||||
}
|
||||
|
||||
// Detect builds replay chapters from already-loaded race-hub datasets.
|
||||
func Detect(rc []RaceControl, positions []PositionSample, laps []Lap, totalLaps int) []Chapter {
|
||||
totalLaps = normalizeTotalLaps(totalLaps, laps, rc)
|
||||
if totalLaps <= 0 && len(rc) == 0 && len(positions) == 0 && len(laps) == 0 {
|
||||
return []Chapter{}
|
||||
}
|
||||
if totalLaps <= 0 {
|
||||
totalLaps = 1
|
||||
}
|
||||
|
||||
lapIndex := buildLapIndex(laps)
|
||||
chapters := []Chapter{
|
||||
{
|
||||
Kind: KindStart,
|
||||
Title: "Start",
|
||||
StartLap: 1,
|
||||
EndLap: minInt(1, totalLaps),
|
||||
StartTime: lapIndex.lapStart(1),
|
||||
EndTime: lapIndex.lapEnd(1),
|
||||
},
|
||||
}
|
||||
chapters = append(chapters, detectFlagPeriods(rc, lapIndex, totalLaps)...)
|
||||
chapters = append(chapters, detectPitPhases(laps, lapIndex)...)
|
||||
chapters = append(chapters, detectDecisiveSwings(positions, lapIndex, totalLaps)...)
|
||||
chapters = append(chapters, detectFinish(rc, lapIndex, totalLaps))
|
||||
|
||||
return resolveConflicts(chapters)
|
||||
}
|
||||
|
||||
func normalizeTotalLaps(totalLaps int, laps []Lap, rc []RaceControl) int {
|
||||
for _, l := range laps {
|
||||
if l.LapNumber > totalLaps {
|
||||
totalLaps = l.LapNumber
|
||||
}
|
||||
}
|
||||
for _, msg := range rc {
|
||||
if msg.LapNumber != nil && *msg.LapNumber > totalLaps {
|
||||
totalLaps = *msg.LapNumber
|
||||
}
|
||||
}
|
||||
return totalLaps
|
||||
}
|
||||
|
||||
type lapIndex struct {
|
||||
byLap map[int]string
|
||||
events []lapEvent
|
||||
}
|
||||
|
||||
type lapEvent struct {
|
||||
lap int
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func buildLapIndex(laps []Lap) lapIndex {
|
||||
idx := lapIndex{byLap: map[int]string{}}
|
||||
for _, l := range laps {
|
||||
if l.LapNumber <= 0 || l.DateStart == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := idx.byLap[l.LapNumber]; !ok {
|
||||
idx.byLap[l.LapNumber] = l.DateStart
|
||||
}
|
||||
at, ok := parseTime(l.DateStart)
|
||||
if ok {
|
||||
idx.events = append(idx.events, lapEvent{lap: l.LapNumber, at: at})
|
||||
}
|
||||
}
|
||||
sort.Slice(idx.events, func(i, j int) bool {
|
||||
if idx.events[i].at.Equal(idx.events[j].at) {
|
||||
return idx.events[i].lap < idx.events[j].lap
|
||||
}
|
||||
return idx.events[i].at.Before(idx.events[j].at)
|
||||
})
|
||||
return idx
|
||||
}
|
||||
|
||||
func (idx lapIndex) lapStart(lap int) string {
|
||||
return idx.byLap[lap]
|
||||
}
|
||||
|
||||
func (idx lapIndex) lapEnd(lap int) string {
|
||||
if v := idx.byLap[lap+1]; v != "" {
|
||||
return v
|
||||
}
|
||||
return idx.byLap[lap]
|
||||
}
|
||||
|
||||
func (idx lapIndex) lapForTime(raw string) int {
|
||||
at, ok := parseTime(raw)
|
||||
if !ok || len(idx.events) == 0 {
|
||||
return 0
|
||||
}
|
||||
lap := 0
|
||||
for _, event := range idx.events {
|
||||
if event.at.After(at) {
|
||||
break
|
||||
}
|
||||
lap = event.lap
|
||||
}
|
||||
if lap == 0 {
|
||||
return idx.events[0].lap
|
||||
}
|
||||
return lap
|
||||
}
|
||||
|
||||
type flagState struct {
|
||||
startLap int
|
||||
startTime string
|
||||
}
|
||||
|
||||
func detectFlagPeriods(rc []RaceControl, idx lapIndex, totalLaps int) []Chapter {
|
||||
var chapters []Chapter
|
||||
active := map[string]flagState{}
|
||||
for _, msg := range rc {
|
||||
kind, ok := flagKind(msg)
|
||||
if !ok && greenFlagClear(msg) {
|
||||
for activeKind, st := range active {
|
||||
lap := messageLap(msg, idx)
|
||||
if lap <= 0 {
|
||||
lap = st.startLap
|
||||
}
|
||||
endLap := clampLap(lap, st.startLap, totalLaps)
|
||||
chapters = append(chapters, Chapter{
|
||||
Kind: activeKind,
|
||||
Title: flagTitle(activeKind, st.startLap, endLap),
|
||||
StartLap: st.startLap,
|
||||
EndLap: endLap,
|
||||
StartTime: st.startTime,
|
||||
EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)),
|
||||
})
|
||||
delete(active, activeKind)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
lap := messageLap(msg, idx)
|
||||
if lap <= 0 {
|
||||
lap = 1
|
||||
}
|
||||
if flagCleared(msg) {
|
||||
st, ok := active[kind]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
endLap := clampLap(lap, st.startLap, totalLaps)
|
||||
chapters = append(chapters, Chapter{
|
||||
Kind: kind,
|
||||
Title: flagTitle(kind, st.startLap, endLap),
|
||||
StartLap: st.startLap,
|
||||
EndLap: endLap,
|
||||
StartTime: st.startTime,
|
||||
EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)),
|
||||
})
|
||||
delete(active, kind)
|
||||
continue
|
||||
}
|
||||
if flagStarted(msg) {
|
||||
active[kind] = flagState{
|
||||
startLap: clampLap(lap, 1, totalLaps),
|
||||
startTime: firstNonEmpty(msg.Date, idx.lapStart(lap)),
|
||||
}
|
||||
}
|
||||
}
|
||||
for kind, st := range active {
|
||||
endLap := totalLaps
|
||||
chapters = append(chapters, Chapter{
|
||||
Kind: kind,
|
||||
Title: flagTitle(kind, st.startLap, endLap),
|
||||
StartLap: st.startLap,
|
||||
EndLap: endLap,
|
||||
StartTime: st.startTime,
|
||||
EndTime: idx.lapEnd(endLap),
|
||||
})
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
|
||||
func flagKind(msg RaceControl) (string, bool) {
|
||||
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
|
||||
if strings.Contains(text, "VSC") || strings.Contains(text, "VIRTUAL SAFETY CAR") {
|
||||
return KindVirtualSafetyCar, true
|
||||
}
|
||||
if strings.Contains(text, "RED FLAG") || string(msg.Flag) == string(models.FlagRed) {
|
||||
return KindRedFlag, true
|
||||
}
|
||||
if strings.Contains(text, "SAFETY CAR") || msg.Category == models.CategorySafetyCar {
|
||||
return KindSafetyCar, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func flagStarted(msg RaceControl) bool {
|
||||
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
|
||||
if strings.Contains(text, "CLEAR") || strings.Contains(text, "ENDING") || strings.Contains(text, "IN THIS LAP") || strings.Contains(text, "GREEN") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(text, "DEPLOY") ||
|
||||
strings.Contains(text, "RED FLAG") ||
|
||||
strings.Contains(text, "VIRTUAL SAFETY CAR") ||
|
||||
strings.Contains(text, "VSC") ||
|
||||
strings.Contains(text, "SAFETY CAR") ||
|
||||
string(msg.Flag) == string(models.FlagRed)
|
||||
}
|
||||
|
||||
func flagCleared(msg RaceControl) bool {
|
||||
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
|
||||
return strings.Contains(text, "CLEAR") ||
|
||||
strings.Contains(text, "ENDING") ||
|
||||
strings.Contains(text, "IN THIS LAP") ||
|
||||
strings.Contains(text, "GREEN")
|
||||
}
|
||||
|
||||
func greenFlagClear(msg RaceControl) bool {
|
||||
text := upperText(string(msg.Flag), msg.Message)
|
||||
return strings.Contains(text, "GREEN")
|
||||
}
|
||||
|
||||
func flagTitle(kind string, startLap, endLap int) string {
|
||||
name := "Flag period"
|
||||
switch kind {
|
||||
case KindSafetyCar:
|
||||
name = "Safety Car"
|
||||
case KindVirtualSafetyCar:
|
||||
name = "Virtual Safety Car"
|
||||
case KindRedFlag:
|
||||
name = "Red Flag"
|
||||
}
|
||||
return fmt.Sprintf("%s (L%d-L%d)", name, startLap, endLap)
|
||||
}
|
||||
|
||||
func detectPitPhases(laps []Lap, idx lapIndex) []Chapter {
|
||||
type pitOut struct {
|
||||
lap int
|
||||
driver int
|
||||
}
|
||||
var stops []pitOut
|
||||
for _, l := range laps {
|
||||
if l.IsPitOutLap && l.LapNumber > 0 {
|
||||
stops = append(stops, pitOut{lap: l.LapNumber, driver: l.DriverNumber})
|
||||
}
|
||||
}
|
||||
if len(stops) < minPitPhaseStops {
|
||||
return nil
|
||||
}
|
||||
sort.Slice(stops, func(i, j int) bool {
|
||||
if stops[i].lap == stops[j].lap {
|
||||
return stops[i].driver < stops[j].driver
|
||||
}
|
||||
return stops[i].lap < stops[j].lap
|
||||
})
|
||||
needed := int(math.Ceil(float64(len(stops)) * pitPhaseShare))
|
||||
if needed < minPitPhaseStops {
|
||||
needed = minPitPhaseStops
|
||||
}
|
||||
|
||||
var windows []Chapter
|
||||
for i := 0; i < len(stops); i++ {
|
||||
start := stops[i].lap
|
||||
end := start + pitPhaseWindowLaps - 1
|
||||
drivers := map[int]bool{}
|
||||
count := 0
|
||||
for _, stop := range stops {
|
||||
if stop.lap < start || stop.lap > end {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
drivers[stop.driver] = true
|
||||
}
|
||||
if count < needed {
|
||||
continue
|
||||
}
|
||||
ch := Chapter{
|
||||
Kind: KindPitPhase,
|
||||
Title: fmt.Sprintf("Pit phase (L%d-L%d)", start, end),
|
||||
StartLap: start,
|
||||
EndLap: end,
|
||||
StartTime: idx.lapStart(start),
|
||||
EndTime: idx.lapEnd(end),
|
||||
DriverNumbers: sortedDriverNumbers(drivers),
|
||||
}
|
||||
if len(windows) > 0 && ch.StartLap <= windows[len(windows)-1].EndLap+1 {
|
||||
last := &windows[len(windows)-1]
|
||||
if ch.EndLap > last.EndLap {
|
||||
last.EndLap = ch.EndLap
|
||||
last.EndTime = idx.lapEnd(last.EndLap)
|
||||
}
|
||||
drivers := sliceToSet(last.DriverNumbers)
|
||||
for _, driver := range ch.DriverNumbers {
|
||||
drivers[driver] = true
|
||||
}
|
||||
last.DriverNumbers = sortedDriverNumbers(drivers)
|
||||
last.Title = fmt.Sprintf("Pit phase (L%d-L%d)", last.StartLap, last.EndLap)
|
||||
continue
|
||||
}
|
||||
windows = append(windows, ch)
|
||||
}
|
||||
return windows
|
||||
}
|
||||
|
||||
type swingCandidate struct {
|
||||
chapter Chapter
|
||||
significance int
|
||||
}
|
||||
|
||||
func detectDecisiveSwings(positions []PositionSample, idx lapIndex, totalLaps int) []Chapter {
|
||||
if len(positions) == 0 || len(idx.events) == 0 || totalLaps <= decisiveAfterLap {
|
||||
return nil
|
||||
}
|
||||
snapshots := buildPositionSnapshots(positions, idx, totalLaps)
|
||||
if len(snapshots) == 0 {
|
||||
return nil
|
||||
}
|
||||
final := snapshots[totalLaps]
|
||||
if len(final) == 0 {
|
||||
for lap := totalLaps - 1; lap >= 1; lap-- {
|
||||
if len(snapshots[lap]) > 0 {
|
||||
final = snapshots[lap]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
var candidates []swingCandidate
|
||||
seenDriver := map[int]bool{}
|
||||
for lap := decisiveAfterLap + 1; lap <= totalLaps; lap++ {
|
||||
prev := snapshots[lap-1]
|
||||
curr := snapshots[lap]
|
||||
if len(prev) == 0 || len(curr) == 0 {
|
||||
continue
|
||||
}
|
||||
for driver, pos := range curr {
|
||||
prevPos, ok := prev[driver]
|
||||
if !ok || prevPos <= pos || pos > 5 || pos <= 0 || seenDriver[driver] {
|
||||
continue
|
||||
}
|
||||
finalPos, ok := final[driver]
|
||||
if !ok || finalPos > pos {
|
||||
continue
|
||||
}
|
||||
overtaken := driverAtPosition(curr, prevPos, driver)
|
||||
drivers := []int{driver}
|
||||
if overtaken != 0 {
|
||||
drivers = append(drivers, overtaken)
|
||||
}
|
||||
candidates = append(candidates, swingCandidate{
|
||||
chapter: Chapter{
|
||||
Kind: KindDecisiveSwing,
|
||||
Title: fmt.Sprintf("Decisive swing: #%d to P%d (L%d)", driver, pos, lap),
|
||||
StartLap: lap,
|
||||
EndLap: lap,
|
||||
StartTime: idx.lapStart(lap),
|
||||
EndTime: idx.lapEnd(lap),
|
||||
DriverNumbers: drivers,
|
||||
},
|
||||
significance: (prevPos-pos)*10 + (6 - pos),
|
||||
})
|
||||
seenDriver[driver] = true
|
||||
}
|
||||
}
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].significance == candidates[j].significance {
|
||||
return candidates[i].chapter.StartLap < candidates[j].chapter.StartLap
|
||||
}
|
||||
return candidates[i].significance > candidates[j].significance
|
||||
})
|
||||
if len(candidates) > maxDecisiveSwings {
|
||||
candidates = candidates[:maxDecisiveSwings]
|
||||
}
|
||||
out := make([]Chapter, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
out = append(out, c.chapter)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildPositionSnapshots(positions []PositionSample, idx lapIndex, totalLaps int) map[int]map[int]int {
|
||||
byLap := map[int][]PositionSample{}
|
||||
for _, p := range positions {
|
||||
if p.Position <= 0 {
|
||||
continue
|
||||
}
|
||||
lap := idx.lapForTime(p.Date)
|
||||
if lap <= 0 || lap > totalLaps {
|
||||
continue
|
||||
}
|
||||
byLap[lap] = append(byLap[lap], p)
|
||||
}
|
||||
last := map[int]int{}
|
||||
snapshots := map[int]map[int]int{}
|
||||
for lap := 1; lap <= totalLaps; lap++ {
|
||||
for _, p := range byLap[lap] {
|
||||
last[p.DriverNumber] = p.Position
|
||||
}
|
||||
if len(last) == 0 {
|
||||
continue
|
||||
}
|
||||
cp := make(map[int]int, len(last))
|
||||
for driver, pos := range last {
|
||||
cp[driver] = pos
|
||||
}
|
||||
snapshots[lap] = cp
|
||||
}
|
||||
return snapshots
|
||||
}
|
||||
|
||||
func driverAtPosition(snapshot map[int]int, pos int, exclude int) int {
|
||||
for driver, driverPos := range snapshot {
|
||||
if driver != exclude && driverPos == pos {
|
||||
return driver
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func detectFinish(rc []RaceControl, idx lapIndex, totalLaps int) Chapter {
|
||||
finishLap := totalLaps
|
||||
finishTime := idx.lapEnd(totalLaps)
|
||||
for _, msg := range rc {
|
||||
text := upperText(string(msg.Flag), msg.Message)
|
||||
if strings.Contains(text, "CHEQUER") || string(msg.Flag) == string(models.FlagChequered) {
|
||||
if lap := messageLap(msg, idx); lap > 0 {
|
||||
finishLap = lap
|
||||
}
|
||||
finishTime = firstNonEmpty(msg.Date, finishTime)
|
||||
}
|
||||
}
|
||||
startLap := finishLap - 1
|
||||
if startLap < 1 {
|
||||
startLap = 1
|
||||
}
|
||||
return Chapter{
|
||||
Kind: KindFinish,
|
||||
Title: fmt.Sprintf("Finish (L%d-L%d)", startLap, finishLap),
|
||||
StartLap: startLap,
|
||||
EndLap: finishLap,
|
||||
StartTime: idx.lapStart(startLap),
|
||||
EndTime: finishTime,
|
||||
}
|
||||
}
|
||||
|
||||
func resolveConflicts(chapters []Chapter) []Chapter {
|
||||
normalized := make([]Chapter, 0, len(chapters))
|
||||
for _, ch := range chapters {
|
||||
if ch.StartLap <= 0 {
|
||||
ch.StartLap = 1
|
||||
}
|
||||
if ch.EndLap <= 0 {
|
||||
ch.EndLap = ch.StartLap
|
||||
}
|
||||
if ch.EndLap < ch.StartLap {
|
||||
ch.EndLap = ch.StartLap
|
||||
}
|
||||
if ch.DriverNumbers == nil {
|
||||
ch.DriverNumbers = []int{}
|
||||
}
|
||||
normalized = append(normalized, ch)
|
||||
}
|
||||
sort.SliceStable(normalized, func(i, j int) bool {
|
||||
if normalized[i].StartLap == normalized[j].StartLap {
|
||||
return priority(normalized[i].Kind) > priority(normalized[j].Kind)
|
||||
}
|
||||
return normalized[i].StartLap < normalized[j].StartLap
|
||||
})
|
||||
|
||||
out := make([]Chapter, 0, len(normalized))
|
||||
for _, ch := range normalized {
|
||||
if len(out) == 0 {
|
||||
out = append(out, ch)
|
||||
continue
|
||||
}
|
||||
last := &out[len(out)-1]
|
||||
if ch.StartLap > last.EndLap {
|
||||
out = append(out, ch)
|
||||
continue
|
||||
}
|
||||
if isFlag(last.Kind) && priority(ch.Kind) < priority(last.Kind) {
|
||||
continue
|
||||
}
|
||||
if priority(ch.Kind) > priority(last.Kind) {
|
||||
if last.StartLap < ch.StartLap {
|
||||
last.EndLap = ch.StartLap - 1
|
||||
out = append(out, ch)
|
||||
} else {
|
||||
*last = ch
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ch.EndLap > last.EndLap {
|
||||
ch.StartLap = last.EndLap + 1
|
||||
if ch.StartLap <= ch.EndLap {
|
||||
out = append(out, ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isFlag(kind string) bool {
|
||||
return kind == KindSafetyCar || kind == KindVirtualSafetyCar || kind == KindRedFlag
|
||||
}
|
||||
|
||||
func priority(kind string) int {
|
||||
switch kind {
|
||||
case KindStart, KindFinish:
|
||||
return structuralPriority
|
||||
case KindSafetyCar, KindVirtualSafetyCar, KindRedFlag:
|
||||
return flagPriority
|
||||
case KindPitPhase:
|
||||
return pitPhasePriority
|
||||
case KindDecisiveSwing:
|
||||
return decisivePriority
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func messageLap(msg RaceControl, idx lapIndex) int {
|
||||
if msg.LapNumber != nil && *msg.LapNumber > 0 {
|
||||
return *msg.LapNumber
|
||||
}
|
||||
return idx.lapForTime(msg.Date)
|
||||
}
|
||||
|
||||
func clampLap(lap, minLap, maxLap int) int {
|
||||
if lap < minLap {
|
||||
return minLap
|
||||
}
|
||||
if maxLap > 0 && lap > maxLap {
|
||||
return maxLap
|
||||
}
|
||||
return lap
|
||||
}
|
||||
|
||||
func parseTime(raw string) (time.Time, bool) {
|
||||
if raw == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
at, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return at, true
|
||||
}
|
||||
|
||||
func upperText(parts ...string) string {
|
||||
return strings.ToUpper(strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sortedDriverNumbers(drivers map[int]bool) []int {
|
||||
out := make([]int, 0, len(drivers))
|
||||
for driver := range drivers {
|
||||
out = append(out, driver)
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sliceToSet(values []int) map[int]bool {
|
||||
out := make(map[int]bool, len(values))
|
||||
for _, value := range values {
|
||||
out[value] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
218
internal/chapters/chapters_test.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package chapters
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
)
|
||||
|
||||
func TestDetectStructuralChapters(t *testing.T) {
|
||||
chapters := Detect(nil, nil, testLaps(10, nil), 10)
|
||||
|
||||
if len(chapters) < 2 {
|
||||
t.Fatalf("chapters len = %d, want at least start and finish", len(chapters))
|
||||
}
|
||||
if got := chapters[0]; got.Kind != KindStart || got.StartLap != 1 || got.EndLap != 1 {
|
||||
t.Fatalf("start chapter = %+v, want L1-L1", got)
|
||||
}
|
||||
got := chapters[len(chapters)-1]
|
||||
if got.Kind != KindFinish || got.StartLap != 9 || got.EndLap != 10 {
|
||||
t.Fatalf("finish chapter = %+v, want L9-L10", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectReturnsEmptyWithoutData(t *testing.T) {
|
||||
if chapters := Detect(nil, nil, nil, 0); len(chapters) != 0 {
|
||||
t.Fatalf("chapters = %+v, want empty", chapters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFlagPeriods(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
start RaceControl
|
||||
end RaceControl
|
||||
wantKind string
|
||||
wantTitle string
|
||||
}{
|
||||
{
|
||||
name: "safety car",
|
||||
start: rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
|
||||
end: rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
|
||||
wantKind: KindSafetyCar,
|
||||
wantTitle: "Safety Car (L12-L15)",
|
||||
},
|
||||
{
|
||||
name: "virtual safety car",
|
||||
start: rc(22, models.CategoryOther, "", "VSC DEPLOYED"),
|
||||
end: rc(24, models.CategoryOther, "", "VSC ENDING"),
|
||||
wantKind: KindVirtualSafetyCar,
|
||||
wantTitle: "Virtual Safety Car (L22-L24)",
|
||||
},
|
||||
{
|
||||
name: "red flag",
|
||||
start: rc(31, models.CategoryFlag, models.FlagRed, "RED FLAG"),
|
||||
end: rc(33, models.CategoryFlag, models.FlagGreen, "GREEN FLAG"),
|
||||
wantKind: KindRedFlag,
|
||||
wantTitle: "Red Flag (L31-L33)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
chapters := Detect([]RaceControl{tt.start, tt.end}, nil, testLaps(40, nil), 40)
|
||||
got := findKind(chapters, tt.wantKind)
|
||||
if got == nil {
|
||||
t.Fatalf("chapters = %+v, want %s", chapters, tt.wantKind)
|
||||
}
|
||||
if got.StartLap != rcLap(tt.start) || got.EndLap != rcLap(tt.end) || got.Title != tt.wantTitle {
|
||||
t.Fatalf("flag chapter = %+v, want %s", *got, tt.wantTitle)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectPitPhaseFromPitOutLapCluster(t *testing.T) {
|
||||
pitOuts := map[int][]int{
|
||||
5: {1},
|
||||
10: {2},
|
||||
20: {3},
|
||||
21: {4},
|
||||
22: {5},
|
||||
30: {6},
|
||||
35: {7},
|
||||
40: {8},
|
||||
45: {9},
|
||||
50: {10},
|
||||
}
|
||||
chapters := Detect(nil, nil, testLaps(55, pitOuts), 55)
|
||||
|
||||
got := findKind(chapters, KindPitPhase)
|
||||
if got == nil {
|
||||
t.Fatalf("chapters = %+v, want pit phase", chapters)
|
||||
}
|
||||
if got.StartLap != 20 || got.EndLap != 22 {
|
||||
t.Fatalf("pit phase = %+v, want L20-L22", *got)
|
||||
}
|
||||
if len(got.DriverNumbers) != 3 || got.DriverNumbers[0] != 3 || got.DriverNumbers[2] != 5 {
|
||||
t.Fatalf("pit phase drivers = %v, want [3 4 5]", got.DriverNumbers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectDecisiveSwingPersistsToFinish(t *testing.T) {
|
||||
positions := []PositionSample{
|
||||
pos(1, 1, 1),
|
||||
pos(1, 16, 4),
|
||||
pos(1, 55, 3),
|
||||
pos(6, 16, 3),
|
||||
pos(6, 55, 4),
|
||||
pos(8, 44, 5),
|
||||
pos(8, 63, 6),
|
||||
pos(10, 44, 6),
|
||||
}
|
||||
|
||||
chapters := Detect(nil, positions, testLaps(12, nil), 12)
|
||||
got := findKind(chapters, KindDecisiveSwing)
|
||||
if got == nil {
|
||||
t.Fatalf("chapters = %+v, want decisive swing", chapters)
|
||||
}
|
||||
if got.StartLap != 6 || got.EndLap != 6 {
|
||||
t.Fatalf("swing lap = %+v, want L6", *got)
|
||||
}
|
||||
if len(got.DriverNumbers) != 2 || got.DriverNumbers[0] != 16 || got.DriverNumbers[1] != 55 {
|
||||
t.Fatalf("swing drivers = %v, want [16 55]", got.DriverNumbers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFlagPeriodsWinOverConflictingChapters(t *testing.T) {
|
||||
pitOuts := map[int][]int{
|
||||
12: {1, 2},
|
||||
13: {3, 4},
|
||||
14: {5, 6},
|
||||
}
|
||||
rcs := []RaceControl{
|
||||
rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
|
||||
rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
|
||||
}
|
||||
|
||||
chapters := Detect(rcs, nil, testLaps(20, pitOuts), 20)
|
||||
if got := findKind(chapters, KindSafetyCar); got == nil || got.StartLap != 12 || got.EndLap != 15 {
|
||||
t.Fatalf("chapters = %+v, want safety car L12-L15", chapters)
|
||||
}
|
||||
if got := findKind(chapters, KindPitPhase); got != nil {
|
||||
t.Fatalf("pit phase = %+v, want omitted under safety car", *got)
|
||||
}
|
||||
for i := 1; i < len(chapters); i++ {
|
||||
if chapters[i].StartLap <= chapters[i-1].EndLap {
|
||||
t.Fatalf("chapters overlap at %d: %+v then %+v", i, chapters[i-1], chapters[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rcLap(r RaceControl) int {
|
||||
if r.LapNumber == nil {
|
||||
return 0
|
||||
}
|
||||
return *r.LapNumber
|
||||
}
|
||||
|
||||
func findKind(chapters []Chapter, kind string) *Chapter {
|
||||
for i := range chapters {
|
||||
if chapters[i].Kind == kind {
|
||||
return &chapters[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func testLaps(total int, pitOuts map[int][]int) []Lap {
|
||||
var laps []Lap
|
||||
for lap := 1; lap <= total; lap++ {
|
||||
drivers := []int{1}
|
||||
if pitDrivers := pitOuts[lap]; len(pitDrivers) > 0 {
|
||||
drivers = pitDrivers
|
||||
}
|
||||
for _, driver := range drivers {
|
||||
laps = append(laps, Lap{
|
||||
DriverNumber: driver,
|
||||
LapNumber: lap,
|
||||
DateStart: lapTime(lap),
|
||||
IsPitOutLap: containsDriver(pitOuts[lap], driver),
|
||||
})
|
||||
}
|
||||
}
|
||||
return laps
|
||||
}
|
||||
|
||||
func rc(lap int, category models.RaceControlCategory, flag models.Flag, message string) RaceControl {
|
||||
return RaceControl{
|
||||
Category: category,
|
||||
Flag: flag,
|
||||
Message: message,
|
||||
LapNumber: &lap,
|
||||
Date: lapTime(lap),
|
||||
}
|
||||
}
|
||||
|
||||
func pos(lap int, driver int, position int) PositionSample {
|
||||
return PositionSample{
|
||||
DriverNumber: driver,
|
||||
Position: position,
|
||||
Date: lapTime(lap),
|
||||
}
|
||||
}
|
||||
|
||||
func lapTime(lap int) string {
|
||||
minute := lap - 1
|
||||
return fmt.Sprintf("2025-05-25T13:%02d:00Z", minute)
|
||||
}
|
||||
|
||||
func containsDriver(drivers []int, driver int) bool {
|
||||
for _, candidate := range drivers {
|
||||
if candidate == driver {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -273,6 +273,40 @@ func TestProcessTopicTrackStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessTopicSessionStatus(t *testing.T) {
|
||||
state := live.NewState()
|
||||
if !state.ProcessTopic("SessionStatus", json.RawMessage(`{"Status": "Finished"}`)) {
|
||||
t.Fatal("SessionStatus should update state")
|
||||
}
|
||||
snap := state.Snapshot()
|
||||
if snap.SessionStatus != "Finished" {
|
||||
t.Fatalf("session status = %q, want Finished", snap.SessionStatus)
|
||||
}
|
||||
if !snap.SnapshotUpdated {
|
||||
t.Fatal("SessionStatus should mark snapshot updated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStatusIsActive(t *testing.T) {
|
||||
tests := []struct {
|
||||
status string
|
||||
want bool
|
||||
}{
|
||||
{"Started", true},
|
||||
{"Resumed", true},
|
||||
{"Finished", false},
|
||||
{"Finalised", false},
|
||||
{"Ends", false},
|
||||
{"Aborted", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := live.SessionStatusIsActive(tt.status); got != tt.want {
|
||||
t.Errorf("SessionStatusIsActive(%q) = %v, want %v", tt.status, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessTopicRaceControlMessages(t *testing.T) {
|
||||
state := live.NewState()
|
||||
data := json.RawMessage(`{
|
||||
|
||||
@@ -197,7 +197,7 @@ func connectToF1LegacySignalR(dataChan chan LiveStreamData) error {
|
||||
return err
|
||||
}
|
||||
|
||||
subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","Position.z","CarData.z","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","TeamRadio","CurrentTyres","TimingAppData","TimingStats"]],"I":1}`)
|
||||
subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","Position.z","CarData.z","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","TeamRadio","CurrentTyres","TimingAppData","TimingStats","SessionStatus"]],"I":1}`)
|
||||
err = c.WriteMessage(websocket.TextMessage, subscribeMsg)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -26,6 +26,7 @@ type State struct {
|
||||
Weather LiveWeatherData
|
||||
Session LiveSessionMeta
|
||||
TeamRadio []LiveRadioCapture
|
||||
SessionStatus string
|
||||
TrackStatus string
|
||||
CurrentLap int
|
||||
TotalLaps int
|
||||
@@ -93,6 +94,7 @@ func (s *State) Snapshot() LiveStreamData {
|
||||
Weather: s.Weather,
|
||||
Session: s.Session,
|
||||
TeamRadio: cpyRadio,
|
||||
SessionStatus: s.SessionStatus,
|
||||
TrackStatus: s.TrackStatus,
|
||||
CurrentLap: s.CurrentLap,
|
||||
TotalLaps: s.TotalLaps,
|
||||
@@ -273,6 +275,14 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
|
||||
s.TrackStatus = ts.Status
|
||||
updated = true
|
||||
}
|
||||
case "SessionStatus":
|
||||
var ss struct {
|
||||
Status string `json:"Status"`
|
||||
}
|
||||
if json.Unmarshal(data, &ss) == nil && ss.Status != "" {
|
||||
s.SessionStatus = ss.Status
|
||||
updated = true
|
||||
}
|
||||
case "RaceControlMessages":
|
||||
var rcm struct {
|
||||
Messages json.RawMessage `json:"Messages"`
|
||||
|
||||
@@ -162,6 +162,7 @@ type LiveStreamData struct {
|
||||
Weather LiveWeatherData
|
||||
Session LiveSessionMeta
|
||||
TeamRadio []LiveRadioCapture
|
||||
SessionStatus string
|
||||
TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC
|
||||
CurrentLap int
|
||||
TotalLaps int
|
||||
@@ -173,3 +174,27 @@ type LiveStreamData struct {
|
||||
PositionUpdated bool `json:"-"`
|
||||
SnapshotUpdated bool `json:"-"`
|
||||
}
|
||||
|
||||
// SessionStatusIsActive reports whether a raw F1 live timing SessionStatus
|
||||
// value represents an actively running session.
|
||||
func SessionStatusIsActive(status string) bool {
|
||||
switch normalizeSessionStatus(status) {
|
||||
case "started", "resumed":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSessionStatus(status string) string {
|
||||
out := make([]rune, 0, len(status))
|
||||
for _, r := range status {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z':
|
||||
out = append(out, r+'a'-'A')
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,15 @@ type ChampionshipInputs struct {
|
||||
}
|
||||
|
||||
// GetChampionshipInputs derives championship hub inputs from ingested season data.
|
||||
//
|
||||
// 2026 note: Bahrain (meeting 1282 / session 11261) and Saudi Arabia (1283 / 11269)
|
||||
// are cancelled due to Force Majeure (regional conflict) — OpenF1 returns no results
|
||||
// for those sessions. This is intentional, which is why 2026 shows Round 9/24 after
|
||||
// Silverstone instead of Round 11.
|
||||
//
|
||||
// Points: F1 Sprint points (SessionName == "Sprint") must be included alongside Race
|
||||
// points. Both appear as SessionType "Race" in OpenF1 /api/v1/sessions, so we match
|
||||
// on SessionName. Fix for #57.
|
||||
func (s *Service) GetChampionshipInputs(year int) (ChampionshipInputs, error) {
|
||||
meetings, err := s.ListMeetingsByYear(year)
|
||||
if err != nil {
|
||||
@@ -51,60 +60,101 @@ func (s *Service) GetChampionshipInputs(year int) (ChampionshipInputs, error) {
|
||||
}
|
||||
|
||||
raceKey := 0
|
||||
sprintKey := 0
|
||||
for _, sess := range sessions {
|
||||
if strings.EqualFold(sess.SessionName, "Race") {
|
||||
raceKey = sess.SessionKey
|
||||
break
|
||||
} else if strings.EqualFold(sess.SessionName, "Sprint") {
|
||||
sprintKey = sess.SessionKey
|
||||
}
|
||||
}
|
||||
if raceKey == 0 {
|
||||
if raceKey == 0 && sprintKey == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
results, err := s.store.ListSessionResults(raceKey)
|
||||
if err != nil {
|
||||
return ChampionshipInputs{}, err
|
||||
}
|
||||
grid, err := s.store.ListStartingGrid(raceKey)
|
||||
if err != nil {
|
||||
return ChampionshipInputs{}, err
|
||||
}
|
||||
// Always build Races slice from the Race session only (for UI),
|
||||
// but include sprint points for standings.
|
||||
var raceResults []models.SessionResult
|
||||
var raceGrid []models.StartingGrid
|
||||
|
||||
race := ChampionshipRace{
|
||||
Meeting: meeting,
|
||||
RaceSessionKey: raceKey,
|
||||
Results: make([]models.SessionResult, 0, len(results)),
|
||||
Grid: make([]models.StartingGrid, 0, len(grid)),
|
||||
}
|
||||
for _, result := range results {
|
||||
race.Results = append(race.Results, resultToModel(result))
|
||||
}
|
||||
for _, entry := range grid {
|
||||
race.Grid = append(race.Grid, gridToModel(entry))
|
||||
}
|
||||
inputs.Races = append(inputs.Races, race)
|
||||
if raceKey != 0 {
|
||||
results, err := s.store.ListSessionResults(raceKey)
|
||||
if err != nil {
|
||||
return ChampionshipInputs{}, err
|
||||
}
|
||||
grid, err := s.store.ListStartingGrid(raceKey)
|
||||
if err != nil {
|
||||
return ChampionshipInputs{}, err
|
||||
}
|
||||
race := ChampionshipRace{
|
||||
Meeting: meeting,
|
||||
RaceSessionKey: raceKey,
|
||||
Results: make([]models.SessionResult, 0, len(results)),
|
||||
Grid: make([]models.StartingGrid, 0, len(grid)),
|
||||
}
|
||||
for _, result := range results {
|
||||
race.Results = append(race.Results, resultToModel(result))
|
||||
}
|
||||
for _, entry := range grid {
|
||||
race.Grid = append(race.Grid, gridToModel(entry))
|
||||
}
|
||||
inputs.Races = append(inputs.Races, race)
|
||||
raceResults = race.Results
|
||||
|
||||
if len(results) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
latestSessionKey = raceKey
|
||||
latestMeetingKey = int(meeting.MeetingKey)
|
||||
drivers, err := s.driversForSession(raceKey)
|
||||
if err != nil {
|
||||
return ChampionshipInputs{}, err
|
||||
}
|
||||
for _, d := range drivers {
|
||||
inputs.DriverMap[d.DriverNumber] = d
|
||||
}
|
||||
|
||||
for _, result := range results {
|
||||
pointsByDriver[result.DriverNumber] += result.Points
|
||||
team := inputs.DriverMap[result.DriverNumber].TeamName
|
||||
if team != "" {
|
||||
pointsByTeam[team] += result.Points
|
||||
if len(results) > 0 {
|
||||
latestSessionKey = raceKey
|
||||
latestMeetingKey = int(meeting.MeetingKey)
|
||||
drivers, err := s.driversForSession(raceKey)
|
||||
if err != nil {
|
||||
return ChampionshipInputs{}, err
|
||||
}
|
||||
for _, d := range drivers {
|
||||
inputs.DriverMap[d.DriverNumber] = d
|
||||
}
|
||||
for _, result := range results {
|
||||
pointsByDriver[result.DriverNumber] += result.Points
|
||||
team := inputs.DriverMap[result.DriverNumber].TeamName
|
||||
if team != "" {
|
||||
pointsByTeam[team] += result.Points
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Include sprint points if present.
|
||||
// Note: 2026 has 4 sprints up to British GP (Chinese GP 11240, Miami 11275,
|
||||
// Canadian 11286, British 11321) totaling e.g. ANT 21 = 4+3+6+8, RUS 26.
|
||||
if sprintKey != 0 {
|
||||
sprintResults, err := s.store.ListSessionResults(sprintKey)
|
||||
if err != nil {
|
||||
return ChampionshipInputs{}, err
|
||||
}
|
||||
if len(sprintResults) > 0 {
|
||||
// Ensure driver map includes sprint-only drivers if any.
|
||||
sprintDrivers, err := s.driversForSession(sprintKey)
|
||||
if err == nil {
|
||||
for _, d := range sprintDrivers {
|
||||
if _, ok := inputs.DriverMap[d.DriverNumber]; !ok {
|
||||
inputs.DriverMap[d.DriverNumber] = d
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, result := range sprintResults {
|
||||
pointsByDriver[result.DriverNumber] += result.Points
|
||||
team := inputs.DriverMap[result.DriverNumber].TeamName
|
||||
if team != "" {
|
||||
pointsByTeam[team] += result.Points
|
||||
}
|
||||
}
|
||||
// If this meeting had no race results (edge), still track latest.
|
||||
if len(raceResults) == 0 {
|
||||
latestSessionKey = sprintKey
|
||||
latestMeetingKey = int(meeting.MeetingKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = raceResults
|
||||
_ = raceGrid
|
||||
}
|
||||
|
||||
inputs.Champ = derivedDriverStandings(pointsByDriver, latestMeetingKey, latestSessionKey)
|
||||
|
||||
@@ -375,3 +375,56 @@ func TestGetWeekendWithSessions(t *testing.T) {
|
||||
t.Fatalf("first session drivers = %+v, want available", weekend.Sessions[0].Datasets["drivers"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetChampionshipInputsIncludesSprintPoints(t *testing.T) {
|
||||
// Regression for #57: Race-only aggregation dropped Sprint points.
|
||||
// Setup: same meeting 1229 has Race (9472) 25pts + Sprint (9473) 8pts => total 33.
|
||||
svc := openTestService(t)
|
||||
seedRaceHubData(t, svc.store)
|
||||
|
||||
// Add sprint session for same meeting
|
||||
if err := svc.store.UpsertSession(store.Session{
|
||||
SessionKey: 9473,
|
||||
MeetingKey: 1229,
|
||||
SessionName: "Sprint",
|
||||
SessionType: "Race", // OpenF1 uses SessionType Race even for Sprint
|
||||
DateStart: "2025-05-24T13:00:00+00:00",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertSession sprint error = %v", err)
|
||||
}
|
||||
if err := svc.store.UpsertSessionDriver(store.SessionDriver{
|
||||
SessionKey: 9473, DriverNumber: 1, MeetingKey: 1229, TeamName: "Red Bull Racing",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertSessionDriver sprint error = %v", err)
|
||||
}
|
||||
|
||||
// Race points 25
|
||||
if err := svc.store.UpsertSessionResult(store.SessionResult{
|
||||
SessionKey: 9472, DriverNumber: 1, MeetingKey: 1229, Position: 1, Points: 25,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertSessionResult race error = %v", err)
|
||||
}
|
||||
// Sprint points 8
|
||||
if err := svc.store.UpsertSessionResult(store.SessionResult{
|
||||
SessionKey: 9473, DriverNumber: 1, MeetingKey: 1229, Position: 1, Points: 8,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertSessionResult sprint error = %v", err)
|
||||
}
|
||||
|
||||
inputs, err := svc.GetChampionshipInputs(2025)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChampionshipInputs() error = %v", err)
|
||||
}
|
||||
if len(inputs.Races) != 1 {
|
||||
t.Fatalf("Races len = %d, want 1 race entry", len(inputs.Races))
|
||||
}
|
||||
if len(inputs.Champ) != 1 {
|
||||
t.Fatalf("Champ len = %d, want 1", len(inputs.Champ))
|
||||
}
|
||||
if inputs.Champ[0].PointsCurrent != 33 {
|
||||
t.Fatalf("PointsCurrent = %v, want 33 (25 race + 8 sprint)", inputs.Champ[0].PointsCurrent)
|
||||
}
|
||||
if len(inputs.Teams) != 1 || inputs.Teams[0].PointsCurrent != 33 {
|
||||
t.Fatalf("Teams = %+v, want Red Bull 33", inputs.Teams)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/chapters"
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
"github.com/AmanTahiliani/box-box/internal/store"
|
||||
)
|
||||
@@ -52,6 +53,7 @@ type RaceHub struct {
|
||||
RaceControl []models.RaceControl `json:"race_control"`
|
||||
Weather []models.Weather `json:"weather"`
|
||||
Laps []models.Lap `json:"laps"`
|
||||
Chapters []chapters.Chapter `json:"chapters"`
|
||||
}
|
||||
|
||||
// GetRaceHub loads ingested Race Hub datasets for a session from the local store.
|
||||
@@ -80,6 +82,7 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
|
||||
RaceControl: []models.RaceControl{},
|
||||
Weather: []models.Weather{},
|
||||
Laps: []models.Lap{},
|
||||
Chapters: []chapters.Chapter{},
|
||||
}
|
||||
|
||||
sess, err := s.store.GetSession(sessionKey)
|
||||
@@ -248,6 +251,22 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
|
||||
hub.Datasets["laps"] = availableLocal(len(hub.Laps))
|
||||
}
|
||||
|
||||
hub.Chapters = chapters.Detect(hub.RaceControl, hub.Positions, hub.Laps, totalLaps(hub.Results, hub.Laps))
|
||||
hub.Source = responseSource(hub.Datasets)
|
||||
return hub, nil
|
||||
}
|
||||
|
||||
func totalLaps(results []EnrichedResult, laps []models.Lap) int {
|
||||
total := 0
|
||||
for _, result := range results {
|
||||
if result.NumberOfLaps > total {
|
||||
total = result.NumberOfLaps
|
||||
}
|
||||
}
|
||||
for _, lap := range laps {
|
||||
if lap.LapNumber > total {
|
||||
total = lap.LapNumber
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
@@ -797,11 +797,6 @@ func (s *Server) localChampionshipHub(year int) (champHubResponse, bool, error)
|
||||
}
|
||||
|
||||
func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
|
||||
meetings, err := s.client.GetMeetingsForYear(year)
|
||||
if err != nil {
|
||||
return champHubResponse{}, err
|
||||
}
|
||||
|
||||
champ, err := s.client.GetDriverChampionshipForYear(year)
|
||||
if err != nil {
|
||||
return champHubResponse{}, err
|
||||
@@ -816,16 +811,39 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
|
||||
driverInfo = buildDriverMapFirst(ds)
|
||||
}
|
||||
|
||||
races, incomplete, err := s.fetchSeasonRaces(year)
|
||||
if err != nil {
|
||||
return champHubResponse{}, err
|
||||
}
|
||||
|
||||
resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo)
|
||||
ttl := champHubTTL(year, time.Now())
|
||||
if incomplete {
|
||||
// Any per-meeting fetch failure (network, rate limit) yields an incomplete
|
||||
// aggregate: serve it so the page still renders, but cache it only briefly
|
||||
// so a partial view of the season doesn't stick around for the full TTL.
|
||||
ttl = champHubIncompleteTTL
|
||||
}
|
||||
s.hubCache.put(year, resp, time.Now(), ttl)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// fetchSeasonRaces returns a season's GP meetings in date order, each bundled
|
||||
// with its race results and starting grid fetched from OpenF1. incomplete
|
||||
// reports whether any per-meeting fetch failed, so callers can avoid caching a
|
||||
// partial view of the season for long.
|
||||
func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete bool, err error) {
|
||||
meetings, err := s.client.GetMeetingsForYear(year)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
sort.Slice(meetings, func(i, j int) bool { return meetings[i].DateStart < meetings[j].DateStart })
|
||||
|
||||
// Any per-meeting fetch failure (network, rate limit) yields an incomplete
|
||||
// aggregate: serve it so the page still renders, but cache it only briefly
|
||||
// so a partial view of the season doesn't stick around for the full TTL.
|
||||
var incomplete atomic.Bool
|
||||
races := fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) {
|
||||
var failed atomic.Bool
|
||||
races = fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) {
|
||||
sessions, serr := s.client.GetSessionsForMeeting(int(m.MeetingKey))
|
||||
if serr != nil {
|
||||
incomplete.Store(true)
|
||||
failed.Store(true)
|
||||
return meetingRace{}, false
|
||||
}
|
||||
raceKey := 0
|
||||
@@ -841,18 +859,11 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
|
||||
results, rerr := s.client.GetSessionResult(raceKey)
|
||||
grid, gerr := s.client.GetStartingGrid(raceKey)
|
||||
if rerr != nil || gerr != nil {
|
||||
incomplete.Store(true)
|
||||
failed.Store(true)
|
||||
}
|
||||
return meetingRace{Meeting: m, RaceSessionKey: raceKey, Results: results, Grid: grid}, true
|
||||
})
|
||||
|
||||
resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo)
|
||||
ttl := champHubTTL(year, time.Now())
|
||||
if incomplete.Load() {
|
||||
ttl = champHubIncompleteTTL
|
||||
}
|
||||
s.hubCache.put(year, resp, time.Now(), ttl)
|
||||
return resp, nil
|
||||
return races, failed.Load(), nil
|
||||
}
|
||||
|
||||
// aggregateChampionshipHub is the pure aggregation core (no network) so it can be
|
||||
|
||||
177
internal/web/driversummary.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
)
|
||||
|
||||
// --- /api/v1/driver/summary ---
|
||||
// Per-driver season summary: championship standing plus per-round race results,
|
||||
// aggregated server-side from the same sources as the championship hub. Caching
|
||||
// relies on the OpenF1 client's HTTP cache TTLs — no extra layer here.
|
||||
|
||||
type driverSummaryRound struct {
|
||||
MeetingKey int `json:"meeting_key"`
|
||||
MeetingName string `json:"meeting_name"`
|
||||
CountryCode string `json:"country_code"`
|
||||
CountryName string `json:"country_name"`
|
||||
// RacePosition and GridPosition are 0 when the driver has no entry for the
|
||||
// round (did not enter, or data missing).
|
||||
RacePosition int `json:"race_position"`
|
||||
GridPosition int `json:"grid_position"`
|
||||
// QualiPosition is derived from the starting grid in v1 (full qualifying
|
||||
// session results are deferred); omitted when unknown.
|
||||
QualiPosition int `json:"quali_position,omitempty"`
|
||||
Points float64 `json:"points"`
|
||||
DNF bool `json:"dnf"`
|
||||
DNS bool `json:"dns"`
|
||||
DSQ bool `json:"dsq"`
|
||||
}
|
||||
|
||||
type driverSummaryResponse struct {
|
||||
Season int `json:"season"`
|
||||
DriverNumber int `json:"driver_number"`
|
||||
NameAcronym string `json:"name_acronym"`
|
||||
FullName string `json:"full_name"`
|
||||
TeamName string `json:"team_name"`
|
||||
TeamColour string `json:"team_colour"`
|
||||
HeadshotURL string `json:"headshot_url"`
|
||||
Points float64 `json:"points"`
|
||||
Position int `json:"position"`
|
||||
Wins int `json:"wins"`
|
||||
Podiums int `json:"podiums"`
|
||||
Poles int `json:"poles"`
|
||||
Form []float64 `json:"form"`
|
||||
Cumulative []float64 `json:"cumulative"`
|
||||
RoundLabels []string `json:"round_labels"`
|
||||
Rounds []driverSummaryRound `json:"rounds"`
|
||||
}
|
||||
|
||||
func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
|
||||
driverNumber, err := strconv.Atoi(r.URL.Query().Get("driver_number"))
|
||||
if err != nil || driverNumber <= 0 {
|
||||
http.Error(w, "driver_number required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
|
||||
if year == 0 {
|
||||
year = time.Now().Year()
|
||||
}
|
||||
|
||||
champ, err := s.client.GetDriverChampionshipForYear(year)
|
||||
if err != nil {
|
||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
||||
return
|
||||
}
|
||||
var entry *models.ChampionshipDriver
|
||||
for i := range champ {
|
||||
if champ[i].DriverNumber == driverNumber {
|
||||
entry = &champ[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if entry == nil {
|
||||
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
driverInfo := map[int]models.Driver{}
|
||||
if ds, derr := s.client.GetDriversForSession(champ[0].SessionKey); derr == nil {
|
||||
driverInfo = buildDriverMapFirst(ds)
|
||||
}
|
||||
if d, ok := s.championshipDriverInfo(entry.SessionKey, driverNumber, driverInfo); ok {
|
||||
driverInfo[driverNumber] = d
|
||||
}
|
||||
|
||||
races, _, err := s.fetchSeasonRaces(year)
|
||||
if err != nil {
|
||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
||||
return
|
||||
}
|
||||
|
||||
resp, ok := aggregateDriverSummary(year, driverNumber, races, champ, driverInfo)
|
||||
if !ok {
|
||||
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
// aggregateDriverSummary is the pure aggregation core (no network) so it can be
|
||||
// unit-tested with synthetic data. It reuses the championship-hub aggregation
|
||||
// for the derived season stats (wins, podiums, poles, form, cumulative) and
|
||||
// adds the per-round result rows. races must be ordered ascending by date and
|
||||
// contain only GP meetings. Returns ok=false when the driver is not in champ.
|
||||
func aggregateDriverSummary(
|
||||
year, driverNumber int,
|
||||
races []meetingRace,
|
||||
champ []models.ChampionshipDriver,
|
||||
driverInfo map[int]models.Driver,
|
||||
) (driverSummaryResponse, bool) {
|
||||
hub := aggregateChampionshipHub(year, races, champ, nil, driverInfo)
|
||||
|
||||
var hd *champHubDriver
|
||||
for i := range hub.Drivers {
|
||||
if hub.Drivers[i].DriverNumber == driverNumber {
|
||||
hd = &hub.Drivers[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if hd == nil {
|
||||
return driverSummaryResponse{}, false
|
||||
}
|
||||
|
||||
rounds := make([]driverSummaryRound, 0, len(races))
|
||||
for _, mr := range races {
|
||||
if len(mr.Results) == 0 {
|
||||
continue // round not completed yet
|
||||
}
|
||||
round := driverSummaryRound{
|
||||
MeetingKey: int(mr.Meeting.MeetingKey),
|
||||
MeetingName: mr.Meeting.MeetingName,
|
||||
CountryCode: mr.Meeting.CountryCode,
|
||||
CountryName: mr.Meeting.CountryName,
|
||||
}
|
||||
for _, res := range mr.Results {
|
||||
if res.DriverNumber == driverNumber {
|
||||
round.RacePosition = res.Position
|
||||
round.Points = res.Points
|
||||
round.DNF = res.DNF
|
||||
round.DNS = res.DNS
|
||||
round.DSQ = res.DSQ
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, g := range mr.Grid {
|
||||
if g.DriverNumber == driverNumber {
|
||||
round.GridPosition = g.Position
|
||||
round.QualiPosition = g.Position
|
||||
break
|
||||
}
|
||||
}
|
||||
rounds = append(rounds, round)
|
||||
}
|
||||
|
||||
return driverSummaryResponse{
|
||||
Season: year,
|
||||
DriverNumber: hd.DriverNumber,
|
||||
NameAcronym: hd.NameAcronym,
|
||||
FullName: hd.FullName,
|
||||
TeamName: hd.TeamName,
|
||||
TeamColour: hd.TeamColour,
|
||||
HeadshotURL: driverInfo[driverNumber].HeadshotURL,
|
||||
Points: hd.Points,
|
||||
Position: hd.Position,
|
||||
Wins: hd.Wins,
|
||||
Podiums: hd.Podiums,
|
||||
Poles: hd.Poles,
|
||||
Form: hd.Form,
|
||||
Cumulative: hd.Cumulative,
|
||||
RoundLabels: hub.RoundLabels,
|
||||
Rounds: rounds,
|
||||
}, true
|
||||
}
|
||||
136
internal/web/driversummary_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
)
|
||||
|
||||
func driverSummaryFixtures() ([]meetingRace, []models.ChampionshipDriver, map[int]models.Driver) {
|
||||
driverInfo := map[int]models.Driver{
|
||||
1: {DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull", TeamColour: "3671c6", HeadshotURL: "https://example.com/ver.png"},
|
||||
3: {DriverNumber: 3, NameAcronym: "HAM", FullName: "Lewis Hamilton", TeamName: "Mercedes", TeamColour: "27f4d2"},
|
||||
}
|
||||
|
||||
champ := []models.ChampionshipDriver{
|
||||
{DriverNumber: 1, PointsCurrent: 50, PositionCurrent: 1, SessionKey: 99},
|
||||
{DriverNumber: 3, PointsCurrent: 33, PositionCurrent: 2, SessionKey: 99},
|
||||
}
|
||||
|
||||
// Round 1: VER P1 from pole, HAM P2 from P3.
|
||||
// Round 2: VER P1 from P2, HAM P3 from pole.
|
||||
// Round 3: not yet run — must not appear in rounds.
|
||||
races := []meetingRace{
|
||||
{
|
||||
Meeting: models.Meeting{MeetingKey: 1201, MeetingName: "Bahrain GP", CountryCode: "BHR", CountryName: "Bahrain"},
|
||||
Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(3, 2, 18)},
|
||||
Grid: []models.StartingGrid{{DriverNumber: 1, Position: 1}, {DriverNumber: 3, Position: 3}},
|
||||
},
|
||||
{
|
||||
Meeting: models.Meeting{MeetingKey: 1202, MeetingName: "Saudi GP", CountryCode: "SAU", CountryName: "Saudi Arabia"},
|
||||
Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(3, 3, 15)},
|
||||
Grid: []models.StartingGrid{{DriverNumber: 3, Position: 1}, {DriverNumber: 1, Position: 2}},
|
||||
},
|
||||
{Meeting: models.Meeting{MeetingKey: 1203, MeetingName: "Australia GP"}},
|
||||
}
|
||||
return races, champ, driverInfo
|
||||
}
|
||||
|
||||
func TestAggregateDriverSummary(t *testing.T) {
|
||||
races, champ, driverInfo := driverSummaryFixtures()
|
||||
|
||||
resp, ok := aggregateDriverSummary(2025, 1, races, champ, driverInfo)
|
||||
if !ok {
|
||||
t.Fatal("aggregateDriverSummary ok = false, want true")
|
||||
}
|
||||
|
||||
if resp.Season != 2025 || resp.DriverNumber != 1 {
|
||||
t.Errorf("season/driver = %d/%d, want 2025/1", resp.Season, resp.DriverNumber)
|
||||
}
|
||||
if resp.NameAcronym != "VER" || resp.FullName != "Max Verstappen" || resp.TeamName != "Red Bull" || resp.TeamColour != "3671c6" {
|
||||
t.Errorf("driver identity = %s %s %s %s", resp.NameAcronym, resp.FullName, resp.TeamName, resp.TeamColour)
|
||||
}
|
||||
if resp.HeadshotURL != "https://example.com/ver.png" {
|
||||
t.Errorf("headshot = %q", resp.HeadshotURL)
|
||||
}
|
||||
if resp.Points != 50 || resp.Position != 1 {
|
||||
t.Errorf("points/position = %v/%d, want 50/1", resp.Points, resp.Position)
|
||||
}
|
||||
if resp.Wins != 2 || resp.Podiums != 2 || resp.Poles != 1 {
|
||||
t.Errorf("wins/podiums/poles = %d/%d/%d, want 2/2/1", resp.Wins, resp.Podiums, resp.Poles)
|
||||
}
|
||||
if len(resp.Form) != 2 || resp.Form[0] != 25 || resp.Form[1] != 25 {
|
||||
t.Errorf("form = %v, want [25 25]", resp.Form)
|
||||
}
|
||||
// Cumulative reconciles the last value to the official total.
|
||||
if len(resp.Cumulative) != 2 || resp.Cumulative[0] != 25 || resp.Cumulative[1] != 50 {
|
||||
t.Errorf("cumulative = %v, want [25 50]", resp.Cumulative)
|
||||
}
|
||||
if len(resp.RoundLabels) != 2 || resp.RoundLabels[0] != "R1" {
|
||||
t.Errorf("round labels = %v, want [R1 R2]", resp.RoundLabels)
|
||||
}
|
||||
|
||||
// Only completed rounds appear.
|
||||
if len(resp.Rounds) != 2 {
|
||||
t.Fatalf("rounds = %d, want 2", len(resp.Rounds))
|
||||
}
|
||||
r1 := resp.Rounds[0]
|
||||
if r1.MeetingKey != 1201 || r1.MeetingName != "Bahrain GP" || r1.CountryCode != "BHR" || r1.CountryName != "Bahrain" {
|
||||
t.Errorf("round 1 meeting = %+v", r1)
|
||||
}
|
||||
if r1.RacePosition != 1 || r1.GridPosition != 1 || r1.QualiPosition != 1 || r1.Points != 25 {
|
||||
t.Errorf("round 1 result = %+v, want P1 from P1 with 25 pts", r1)
|
||||
}
|
||||
r2 := resp.Rounds[1]
|
||||
if r2.RacePosition != 1 || r2.GridPosition != 2 || r2.Points != 25 {
|
||||
t.Errorf("round 2 result = %+v, want P1 from P2 with 25 pts", r2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateDriverSummaryDriverMissingFromRound(t *testing.T) {
|
||||
races, champ, driverInfo := driverSummaryFixtures()
|
||||
// Drop HAM from round 2's results and grid: the round still appears with
|
||||
// zero-valued positions so round indexing stays aligned with the season.
|
||||
races[1].Results = []models.SessionResult{raceResult(1, 1, 25)}
|
||||
races[1].Grid = []models.StartingGrid{{DriverNumber: 1, Position: 2}}
|
||||
|
||||
resp, ok := aggregateDriverSummary(2025, 3, races, champ, driverInfo)
|
||||
if !ok {
|
||||
t.Fatal("aggregateDriverSummary ok = false, want true")
|
||||
}
|
||||
if len(resp.Rounds) != 2 {
|
||||
t.Fatalf("rounds = %d, want 2", len(resp.Rounds))
|
||||
}
|
||||
r2 := resp.Rounds[1]
|
||||
if r2.RacePosition != 0 || r2.GridPosition != 0 || r2.QualiPosition != 0 || r2.Points != 0 {
|
||||
t.Errorf("round 2 for absent driver = %+v, want zero values", r2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateDriverSummaryUnknownDriver(t *testing.T) {
|
||||
races, champ, driverInfo := driverSummaryFixtures()
|
||||
if _, ok := aggregateDriverSummary(2025, 44, races, champ, driverInfo); ok {
|
||||
t.Error("unknown driver should return ok = false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDriverSummaryBadRequest(t *testing.T) {
|
||||
srv := testServer(t, nil)
|
||||
|
||||
// Missing, non-numeric, zero, and negative driver_number are all rejected.
|
||||
for _, url := range []string{
|
||||
"/api/v1/driver/summary?year=2025",
|
||||
"/api/v1/driver/summary?year=2025&driver_number=abc",
|
||||
"/api/v1/driver/summary?year=2025&driver_number=0",
|
||||
"/api/v1/driver/summary?year=2025&driver_number=-4",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleDriverSummary(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s: status = %d, want 400", url, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,21 @@ type SSEHub struct {
|
||||
deregister chan *sseClient
|
||||
broadcast chan sseEvent
|
||||
|
||||
mu sync.RWMutex
|
||||
lastSnapshot *live.LiveStreamData
|
||||
lastPositions map[string]live.LivePositionData
|
||||
isLive bool
|
||||
mu sync.RWMutex
|
||||
activeSnapshot *live.LiveStreamData
|
||||
activePositions map[string]live.LivePositionData
|
||||
lastSnapshot *live.LiveStreamData
|
||||
lastPositions map[string]live.LivePositionData
|
||||
lastSnapshotAt time.Time
|
||||
isLive bool
|
||||
}
|
||||
|
||||
type liveStatePayload struct {
|
||||
IsLive bool `json:"is_live"`
|
||||
Data *live.LiveStreamData `json:"data"`
|
||||
LastSnapshot *live.LiveStreamData `json:"last_snapshot,omitempty"`
|
||||
LastPositions map[string]live.LivePositionData `json:"last_positions,omitempty"`
|
||||
LastSnapshotAt *time.Time `json:"last_snapshot_at,omitempty"`
|
||||
}
|
||||
|
||||
func newSSEHub() *SSEHub {
|
||||
@@ -51,19 +62,16 @@ func (h *SSEHub) run() {
|
||||
case c := <-h.register:
|
||||
clients[c] = true
|
||||
// Send catch-up snapshot so new clients see current state immediately.
|
||||
h.mu.RLock()
|
||||
snap := h.lastSnapshot
|
||||
positions := cloneLivePositions(h.lastPositions)
|
||||
live := h.isLive
|
||||
h.mu.RUnlock()
|
||||
if snap != nil {
|
||||
if data, err := json.Marshal(map[string]any{"data": snap, "is_live": live}); err == nil {
|
||||
state := h.State()
|
||||
if state.Data != nil || state.LastSnapshot != nil {
|
||||
if data, err := json.Marshal(state); err == nil {
|
||||
select {
|
||||
case c.ch <- formatSSEFrame("snapshot", data):
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
positions := h.ActivePositions()
|
||||
if len(positions) > 0 {
|
||||
if data, err := json.Marshal(positions); err == nil {
|
||||
select {
|
||||
@@ -96,11 +104,125 @@ func formatSSEFrame(event string, data []byte) []byte {
|
||||
return []byte(fmt.Sprintf("event: %s\ndata: %s\n\n", event, data))
|
||||
}
|
||||
|
||||
// Snapshot returns the latest live data snapshot and whether a session is active.
|
||||
func (h *SSEHub) Snapshot() (*live.LiveStreamData, bool) {
|
||||
// State returns the active live snapshot and the retained in-memory archive.
|
||||
func (h *SSEHub) State() liveStatePayload {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.lastSnapshot, h.isLive
|
||||
payload := liveStatePayload{
|
||||
IsLive: h.isLive,
|
||||
}
|
||||
if h.isLive {
|
||||
payload.Data = h.activeSnapshot
|
||||
} else if h.lastSnapshot != nil {
|
||||
payload.LastSnapshot = h.lastSnapshot
|
||||
payload.LastPositions = cloneLivePositions(h.lastPositions)
|
||||
if !h.lastSnapshotAt.IsZero() {
|
||||
at := h.lastSnapshotAt
|
||||
payload.LastSnapshotAt = &at
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func (h *SSEHub) ActivePositions() map[string]live.LivePositionData {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return cloneLivePositions(h.activePositions)
|
||||
}
|
||||
|
||||
func (h *SSEHub) applySnapshot(data live.LiveStreamData, now time.Time) liveStatePayload {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if live.SessionStatusIsActive(data.SessionStatus) {
|
||||
h.isLive = true
|
||||
h.activeSnapshot = &data
|
||||
if data.PositionUpdated && len(data.Positions) > 0 {
|
||||
h.activePositions = cloneLivePositions(data.Positions)
|
||||
}
|
||||
return liveStatePayload{IsLive: true, Data: h.activeSnapshot}
|
||||
}
|
||||
|
||||
archivePositions := cloneLivePositions(h.activePositions)
|
||||
if data.PositionUpdated && len(data.Positions) > 0 {
|
||||
archivePositions = cloneLivePositions(data.Positions)
|
||||
}
|
||||
h.isLive = false
|
||||
h.activeSnapshot = nil
|
||||
h.activePositions = nil
|
||||
if hasLiveSnapshotData(data) {
|
||||
h.lastSnapshot = &data
|
||||
h.lastSnapshotAt = now
|
||||
h.lastPositions = archivePositions
|
||||
}
|
||||
return h.stateLocked()
|
||||
}
|
||||
|
||||
func (h *SSEHub) applyPositions(data live.LiveStreamData) map[string]live.LivePositionData {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if len(data.Positions) == 0 {
|
||||
return nil
|
||||
}
|
||||
positions := cloneLivePositions(data.Positions)
|
||||
if h.isLive {
|
||||
h.activePositions = positions
|
||||
return positions
|
||||
}
|
||||
if h.lastSnapshot != nil {
|
||||
h.lastPositions = positions
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *SSEHub) deactivate(now time.Time) liveStatePayload {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if h.activeSnapshot != nil {
|
||||
h.lastSnapshot = h.activeSnapshot
|
||||
h.lastPositions = cloneLivePositions(h.activePositions)
|
||||
h.lastSnapshotAt = now
|
||||
}
|
||||
h.isLive = false
|
||||
h.activeSnapshot = nil
|
||||
h.activePositions = nil
|
||||
return h.stateLocked()
|
||||
}
|
||||
|
||||
func (h *SSEHub) stateLocked() liveStatePayload {
|
||||
payload := liveStatePayload{IsLive: h.isLive}
|
||||
if h.isLive {
|
||||
payload.Data = h.activeSnapshot
|
||||
return payload
|
||||
}
|
||||
if h.lastSnapshot != nil {
|
||||
payload.LastSnapshot = h.lastSnapshot
|
||||
payload.LastPositions = cloneLivePositions(h.lastPositions)
|
||||
if !h.lastSnapshotAt.IsZero() {
|
||||
at := h.lastSnapshotAt
|
||||
payload.LastSnapshotAt = &at
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func hasLiveSnapshotData(data live.LiveStreamData) bool {
|
||||
return len(data.Drivers) > 0 ||
|
||||
len(data.DriverInfo) > 0 ||
|
||||
len(data.Tyres) > 0 ||
|
||||
len(data.Telemetry) > 0 ||
|
||||
len(data.RCMessages) > 0 ||
|
||||
len(data.TeamRadio) > 0 ||
|
||||
len(data.Stints) > 0 ||
|
||||
data.Session.MeetingName != "" ||
|
||||
data.Session.SessionName != "" ||
|
||||
data.Session.SessionType != "" ||
|
||||
data.TrackStatus != "" ||
|
||||
data.CurrentLap != 0 ||
|
||||
data.TotalLaps != 0 ||
|
||||
data.Clock != ""
|
||||
}
|
||||
|
||||
// runLiveFeeds launches background goroutines for the F1 SignalR feed and keepalive.
|
||||
@@ -129,13 +251,8 @@ func (s *Server) signalRLoop() {
|
||||
log.Printf("web: live feed ended: %v", err)
|
||||
}
|
||||
|
||||
s.hub.mu.Lock()
|
||||
s.hub.isLive = false
|
||||
s.hub.lastSnapshot = nil
|
||||
s.hub.lastPositions = nil
|
||||
s.hub.mu.Unlock()
|
||||
|
||||
if payload, err := json.Marshal(map[string]any{"data": nil, "is_live": false}); err == nil {
|
||||
state := s.hub.deactivate(time.Now())
|
||||
if payload, err := json.Marshal(state); err == nil {
|
||||
s.hub.broadcast <- sseEvent{name: "snapshot", data: payload}
|
||||
}
|
||||
|
||||
@@ -167,24 +284,20 @@ func (s *Server) connectAndDrain() error {
|
||||
select {
|
||||
case data := <-dataChan:
|
||||
now := time.Now()
|
||||
s.hub.mu.Lock()
|
||||
if data.SnapshotUpdated {
|
||||
s.hub.lastSnapshot = &data
|
||||
}
|
||||
if data.PositionUpdated && len(data.Positions) > 0 {
|
||||
s.hub.lastPositions = cloneLivePositions(data.Positions)
|
||||
}
|
||||
s.hub.isLive = true
|
||||
s.hub.mu.Unlock()
|
||||
|
||||
if data.SnapshotUpdated {
|
||||
if payload, err := json.Marshal(map[string]any{"data": data, "is_live": true}); err == nil {
|
||||
state := s.hub.applySnapshot(data, now)
|
||||
if payload, err := json.Marshal(state); err == nil {
|
||||
s.hub.broadcast <- sseEvent{name: "snapshot", data: payload}
|
||||
}
|
||||
}
|
||||
|
||||
if data.PositionUpdated && len(data.Positions) > 0 && now.Sub(lastPositionBroadcast) >= 250*time.Millisecond {
|
||||
if payload, err := json.Marshal(data.Positions); err == nil {
|
||||
positions := s.hub.applyPositions(data)
|
||||
if len(positions) > 0 {
|
||||
payload, err := json.Marshal(positions)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s.hub.broadcast <- sseEvent{name: "positions", data: payload}
|
||||
lastPositionBroadcast = now
|
||||
}
|
||||
@@ -217,11 +330,7 @@ func cloneLivePositions(in map[string]live.LivePositionData) map[string]live.Liv
|
||||
|
||||
// handleLiveState returns the current live data snapshot as JSON.
|
||||
func (s *Server) handleLiveState(w http.ResponseWriter, r *http.Request) {
|
||||
snap, isLive := s.hub.Snapshot()
|
||||
writeJSON(w, map[string]any{
|
||||
"is_live": isLive,
|
||||
"data": snap,
|
||||
})
|
||||
writeJSON(w, s.hub.State())
|
||||
}
|
||||
|
||||
// handleSSEStream is the persistent SSE endpoint for live data.
|
||||
|
||||
90
internal/web/live_archive_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/live"
|
||||
)
|
||||
|
||||
func TestSSEHubArchivesTerminalSessionSnapshot(t *testing.T) {
|
||||
hub := newSSEHub()
|
||||
now := time.Date(2026, 7, 4, 14, 0, 0, 0, time.UTC)
|
||||
active := live.LiveStreamData{
|
||||
SessionStatus: "Started",
|
||||
Drivers: map[string]live.LiveDriverData{
|
||||
"1": {RacingNumber: "1", Position: 1},
|
||||
},
|
||||
Positions: map[string]live.LivePositionData{
|
||||
"1": {X: 100, Y: -50, Z: 2, Status: "OnTrack"},
|
||||
},
|
||||
PositionUpdated: true,
|
||||
SnapshotUpdated: true,
|
||||
}
|
||||
if state := hub.applySnapshot(active, now); !state.IsLive || state.Data == nil {
|
||||
t.Fatalf("active state = %+v, want live data", state)
|
||||
}
|
||||
|
||||
terminal := active
|
||||
terminal.SessionStatus = "Finished"
|
||||
terminal.Positions = nil
|
||||
terminal.PositionUpdated = false
|
||||
state := hub.applySnapshot(terminal, now.Add(time.Minute))
|
||||
|
||||
if state.IsLive {
|
||||
t.Fatal("terminal SessionStatus should not be live")
|
||||
}
|
||||
if state.Data != nil {
|
||||
t.Fatalf("inactive state data = %+v, want nil", state.Data)
|
||||
}
|
||||
if state.LastSnapshot == nil || state.LastSnapshot.SessionStatus != "Finished" {
|
||||
t.Fatalf("last snapshot = %+v, want terminal snapshot", state.LastSnapshot)
|
||||
}
|
||||
if got := state.LastPositions["1"]; got.X != 100 || got.Status != "OnTrack" {
|
||||
t.Fatalf("last positions = %+v, want carried active positions", state.LastPositions)
|
||||
}
|
||||
if state.LastSnapshotAt == nil || !state.LastSnapshotAt.Equal(now.Add(time.Minute)) {
|
||||
t.Fatalf("last snapshot time = %v, want %v", state.LastSnapshotAt, now.Add(time.Minute))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLiveStateKeepsArchiveOutOfActiveData(t *testing.T) {
|
||||
hub := newSSEHub()
|
||||
now := time.Date(2026, 7, 4, 14, 0, 0, 0, time.UTC)
|
||||
hub.applySnapshot(live.LiveStreamData{
|
||||
SessionStatus: "Finished",
|
||||
Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", SessionName: "Race"},
|
||||
Drivers: map[string]live.LiveDriverData{
|
||||
"44": {RacingNumber: "44", Position: 1},
|
||||
},
|
||||
SnapshotUpdated: true,
|
||||
}, now)
|
||||
|
||||
srv := &Server{hub: hub}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/live/state", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleLiveState(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
var resp liveStatePayload
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.IsLive {
|
||||
t.Fatal("archived snapshot should report is_live=false")
|
||||
}
|
||||
if resp.Data != nil {
|
||||
t.Fatalf("archived snapshot leaked into data: %+v", resp.Data)
|
||||
}
|
||||
if resp.LastSnapshot == nil || resp.LastSnapshot.Session.MeetingName != "British Grand Prix" {
|
||||
t.Fatalf("last snapshot = %+v, want archived race", resp.LastSnapshot)
|
||||
}
|
||||
if resp.LastSnapshotAt == nil {
|
||||
t.Fatal("last_snapshot_at should be present for archived snapshots")
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/chapters"
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
"github.com/AmanTahiliani/box-box/internal/query"
|
||||
)
|
||||
@@ -42,5 +43,6 @@ func emptyRaceHub(sessionKey int) query.RaceHub {
|
||||
Drivers: []models.Driver{},
|
||||
Results: []query.EnrichedResult{},
|
||||
StartingGrid: []query.EnrichedGrid{},
|
||||
Chapters: []chapters.Chapter{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,66 @@ func TestHandleRaceHubWithLocalData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRaceHubIncludesChapters(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
seedRaceHubStore(t, st)
|
||||
sessionKey := 9472
|
||||
meetingKey := 1229
|
||||
|
||||
for lap := 1; lap <= 12; lap++ {
|
||||
if err := st.UpsertLap(store.Lap{
|
||||
SessionKey: sessionKey,
|
||||
DriverNumber: 1,
|
||||
MeetingKey: meetingKey,
|
||||
LapNumber: lap,
|
||||
DateStart: time.Date(2025, 5, 25, 13, lap-1, 0, 0, time.UTC).Format(time.RFC3339),
|
||||
LapDuration: 75,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertLap(%d) error = %v", lap, err)
|
||||
}
|
||||
}
|
||||
for _, sample := range []store.PositionSample{
|
||||
{SessionKey: sessionKey, DriverNumber: 1, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 1},
|
||||
{SessionKey: sessionKey, DriverNumber: 16, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 4},
|
||||
{SessionKey: sessionKey, DriverNumber: 55, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 3},
|
||||
{SessionKey: sessionKey, DriverNumber: 16, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00Z", Position: 3},
|
||||
{SessionKey: sessionKey, DriverNumber: 55, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00Z", Position: 4},
|
||||
} {
|
||||
if err := st.UpsertPositionSample(sample); err != nil {
|
||||
t.Fatalf("UpsertPositionSample() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
srv := testServer(t, st)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/race-hub?session_key=9472", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleRaceHub(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
|
||||
var hub query.RaceHub
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &hub); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(hub.Chapters) == 0 {
|
||||
t.Fatalf("chapters = %+v, want generated chapters", hub.Chapters)
|
||||
}
|
||||
if hub.Chapters[0].Kind != "start" {
|
||||
t.Fatalf("first chapter = %+v, want start", hub.Chapters[0])
|
||||
}
|
||||
foundSwing := false
|
||||
for _, chapter := range hub.Chapters {
|
||||
if chapter.Kind == "decisive_swing" {
|
||||
foundSwing = true
|
||||
}
|
||||
}
|
||||
if !foundSwing {
|
||||
t.Fatalf("chapters = %+v, want decisive_swing", hub.Chapters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMeetingsSourceLocal(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
seedRaceHubStore(t, st)
|
||||
|
||||
@@ -70,6 +70,9 @@ func (s *Server) routes() (http.Handler, error) {
|
||||
mux.HandleFunc("/api/v1/news", s.handleNews)
|
||||
mux.HandleFunc("/api/v1/meetings", s.handleMeetings)
|
||||
mux.HandleFunc("/api/v1/sessions", s.handleSessions)
|
||||
// /api/v1/driver/summary before /api/v1/drivers: distinct prefixes today,
|
||||
// but keep the more specific driver/* paths registered first.
|
||||
mux.HandleFunc("/api/v1/driver/summary", s.handleDriverSummary)
|
||||
mux.HandleFunc("/api/v1/drivers", s.handleDrivers)
|
||||
mux.HandleFunc("/api/v1/results", s.handleResults)
|
||||
mux.HandleFunc("/api/v1/grid", s.handleGrid)
|
||||
|
||||
264
scripts/debug/f1_live_socket_probe.py
Normal file
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Probe the official F1 live timing sockets for GPS position topics.
|
||||
|
||||
This intentionally does not call OpenF1 REST. It connects directly to the F1
|
||||
SignalR endpoints used by the app and reports which topics arrive.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import socket
|
||||
import ssl
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
RS = b"\x1e"
|
||||
CORE_NEGOTIATE = "https://livetiming.formula1.com/signalrcore/negotiate?negotiateVersion=1"
|
||||
CORE_WS_HOST = "livetiming.formula1.com"
|
||||
LEGACY_HUB = '[{"name":"Streaming"}]'
|
||||
TOPICS = [
|
||||
"Heartbeat",
|
||||
"TimingData",
|
||||
"Position",
|
||||
"Position.z",
|
||||
"CarData",
|
||||
"CarData.z",
|
||||
"DriverList",
|
||||
"LapCount",
|
||||
"ExtrapolatedClock",
|
||||
"TrackStatus",
|
||||
"RaceControlMessages",
|
||||
"WeatherData",
|
||||
"SessionInfo",
|
||||
"TeamRadio",
|
||||
"CurrentTyres",
|
||||
"TimingAppData",
|
||||
"TimingStats",
|
||||
"SessionStatus",
|
||||
"TopThree",
|
||||
]
|
||||
|
||||
|
||||
class WebSocket:
|
||||
def __init__(self, host: str, path: str, headers: dict[str, str] | None = None):
|
||||
self.host = host
|
||||
self.sock = ssl.create_default_context().wrap_socket(
|
||||
socket.create_connection((host, 443), timeout=10),
|
||||
server_hostname=host,
|
||||
)
|
||||
key = base64.b64encode(os.urandom(16)).decode("ascii")
|
||||
request_headers = {
|
||||
"Host": host,
|
||||
"Upgrade": "websocket",
|
||||
"Connection": "Upgrade",
|
||||
"Sec-WebSocket-Key": key,
|
||||
"Sec-WebSocket-Version": "13",
|
||||
"Origin": "https://www.formula1.com",
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
}
|
||||
request_headers.update(headers or {})
|
||||
lines = [f"GET {path} HTTP/1.1", *[f"{k}: {v}" for k, v in request_headers.items()], "", ""]
|
||||
self.sock.sendall("\r\n".join(lines).encode("utf-8"))
|
||||
response = self._read_http_response()
|
||||
if b" 101 " not in response.split(b"\r\n", 1)[0]:
|
||||
raise RuntimeError(response.decode("utf-8", "replace"))
|
||||
|
||||
def _read_http_response(self) -> bytes:
|
||||
data = b""
|
||||
while b"\r\n\r\n" not in data:
|
||||
data += self.sock.recv(4096)
|
||||
return data
|
||||
|
||||
def send_text(self, text: str) -> None:
|
||||
payload = text.encode("utf-8")
|
||||
header = bytearray([0x81])
|
||||
if len(payload) < 126:
|
||||
header.append(0x80 | len(payload))
|
||||
elif len(payload) < 65536:
|
||||
header.append(0x80 | 126)
|
||||
header.extend(struct.pack("!H", len(payload)))
|
||||
else:
|
||||
header.append(0x80 | 127)
|
||||
header.extend(struct.pack("!Q", len(payload)))
|
||||
mask = random.randbytes(4) if hasattr(random, "randbytes") else os.urandom(4)
|
||||
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
||||
self.sock.sendall(bytes(header) + mask + masked)
|
||||
|
||||
def recv_text(self, timeout: float = 5.0) -> str | None:
|
||||
self.sock.settimeout(timeout)
|
||||
while True:
|
||||
first = self.sock.recv(2)
|
||||
if not first:
|
||||
return None
|
||||
opcode = first[0] & 0x0F
|
||||
masked = bool(first[1] & 0x80)
|
||||
length = first[1] & 0x7F
|
||||
if length == 126:
|
||||
length = struct.unpack("!H", self._read_exact(2))[0]
|
||||
elif length == 127:
|
||||
length = struct.unpack("!Q", self._read_exact(8))[0]
|
||||
mask = self._read_exact(4) if masked else b""
|
||||
payload = self._read_exact(length)
|
||||
if masked:
|
||||
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
||||
if opcode == 0x8:
|
||||
return None
|
||||
if opcode == 0x9:
|
||||
self._send_pong(payload)
|
||||
continue
|
||||
if opcode == 0x1:
|
||||
return payload.decode("utf-8", "replace")
|
||||
|
||||
def _send_pong(self, payload: bytes) -> None:
|
||||
self.sock.sendall(bytes([0x8A, len(payload)]) + payload)
|
||||
|
||||
def _read_exact(self, n: int) -> bytes:
|
||||
chunks = []
|
||||
remaining = n
|
||||
while remaining:
|
||||
chunk = self.sock.recv(remaining)
|
||||
if not chunk:
|
||||
raise EOFError("socket closed")
|
||||
chunks.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
def close(self) -> None:
|
||||
self.sock.close()
|
||||
|
||||
|
||||
def http_json(url: str, method: str = "GET", headers: dict[str, str] | None = None) -> tuple[dict, list[str]]:
|
||||
req = urllib.request.Request(url, method=method, headers=headers or {})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
cookies = resp.headers.get_all("Set-Cookie") or []
|
||||
return json.loads(resp.read()), cookies
|
||||
|
||||
|
||||
def core_probe(seconds: int) -> None:
|
||||
print("== SignalR Core ==")
|
||||
neg, cookies = http_json(
|
||||
CORE_NEGOTIATE,
|
||||
method="POST",
|
||||
headers={"Origin": "https://www.formula1.com", "User-Agent": "Mozilla/5.0", "Content-Length": "0"},
|
||||
)
|
||||
token = urllib.parse.quote(neg["connectionToken"], safe="")
|
||||
ws = WebSocket(
|
||||
CORE_WS_HOST,
|
||||
f"/signalrcore?id={token}",
|
||||
{"Cookie": "; ".join(cookies)} if cookies else None,
|
||||
)
|
||||
try:
|
||||
ws.send_text(json.dumps({"protocol": "json", "version": 1}, separators=(",", ":")) + RS.decode())
|
||||
print("handshake:", repr(ws.recv_text()))
|
||||
ws.send_text(
|
||||
json.dumps(
|
||||
{"type": 1, "target": "subscribe", "arguments": [TOPICS], "invocationId": "1"},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
+ RS.decode()
|
||||
)
|
||||
collect(ws, seconds)
|
||||
finally:
|
||||
ws.close()
|
||||
|
||||
|
||||
def legacy_probe(seconds: int, bearer: str | None) -> None:
|
||||
print("\n== Legacy SignalR ==")
|
||||
query = urllib.parse.quote(LEGACY_HUB, safe="")
|
||||
headers = {"User-Agent": "BestHTTP"}
|
||||
if bearer:
|
||||
headers["Authorization"] = f"Bearer {bearer}"
|
||||
neg_url = f"https://livetiming.formula1.com/signalr/negotiate?clientProtocol=1.5&connectionData={query}"
|
||||
try:
|
||||
neg, cookies = http_json(neg_url, headers=headers)
|
||||
except Exception as exc:
|
||||
print("negotiate failed:", exc)
|
||||
return
|
||||
token = urllib.parse.quote(neg["ConnectionToken"], safe="")
|
||||
path = f"/signalr/connect?clientProtocol=1.5&transport=webSockets&connectionToken={token}&connectionData={query}"
|
||||
ws_headers = {"User-Agent": "BestHTTP"}
|
||||
if cookies:
|
||||
ws_headers["Cookie"] = "; ".join(cookies)
|
||||
if bearer:
|
||||
ws_headers["Authorization"] = f"Bearer {bearer}"
|
||||
ws = WebSocket(CORE_WS_HOST, path, ws_headers)
|
||||
try:
|
||||
ws.send_text(json.dumps({"H": "Streaming", "M": "Subscribe", "A": [TOPICS], "I": 1}, separators=(",", ":")))
|
||||
collect(ws, seconds)
|
||||
finally:
|
||||
ws.close()
|
||||
|
||||
|
||||
def collect(ws: WebSocket, seconds: int) -> None:
|
||||
deadline = time.time() + seconds
|
||||
seen: dict[str, int] = {}
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
message = ws.recv_text(timeout=min(5, max(1, deadline - time.time())))
|
||||
except TimeoutError:
|
||||
continue
|
||||
if not message:
|
||||
break
|
||||
for frame in message.split(RS.decode()):
|
||||
if not frame:
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(frame)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
record_topics(payload, seen)
|
||||
print("seen topics:")
|
||||
for topic in sorted(seen):
|
||||
print(f" {topic}: {seen[topic]}")
|
||||
if not any(topic.startswith("Position") for topic in seen):
|
||||
print(" (no Position / Position.z topics observed)")
|
||||
|
||||
|
||||
def record_topics(payload: dict, seen: dict[str, int]) -> None:
|
||||
result = payload.get("result") or payload.get("R")
|
||||
if isinstance(result, dict):
|
||||
for topic, data in result.items():
|
||||
seen[topic] = seen.get(topic, 0) + 1
|
||||
maybe_print_position(topic, data)
|
||||
for message in payload.get("M") or []:
|
||||
args = message.get("A") or []
|
||||
if len(args) >= 2:
|
||||
topic = args[0]
|
||||
seen[topic] = seen.get(topic, 0) + 1
|
||||
maybe_print_position(topic, args[1])
|
||||
if payload.get("target") == "feed":
|
||||
args = payload.get("arguments") or []
|
||||
if len(args) >= 2:
|
||||
topic = args[0]
|
||||
seen[topic] = seen.get(topic, 0) + 1
|
||||
maybe_print_position(topic, args[1])
|
||||
|
||||
|
||||
def maybe_print_position(topic: str, data) -> None:
|
||||
if topic in {"Position", "Position.z"}:
|
||||
text = json.dumps(data)[:600] if not isinstance(data, str) else data[:600]
|
||||
print(f"POSITION TOPIC {topic}: {text}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--seconds", type=int, default=20)
|
||||
parser.add_argument("--legacy", action="store_true", help="also probe legacy SignalR")
|
||||
args = parser.parse_args()
|
||||
core_probe(args.seconds)
|
||||
if args.legacy:
|
||||
legacy_probe(args.seconds, os.environ.get("BOXBOX_F1_LIVE_BEARER_TOKEN") or os.environ.get("F1_LIVE_BEARER_TOKEN"))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -28,6 +28,41 @@ test.describe('Command Center', () => {
|
||||
await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible()
|
||||
})
|
||||
|
||||
test('archived live snapshot does not mark command center live', async ({ page }) => {
|
||||
await page.route('**/api/v1/live/state', (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
is_live: false,
|
||||
data: null,
|
||||
last_snapshot: {
|
||||
Drivers: { '1': { RacingNumber: '1', Position: 1 } },
|
||||
DriverInfo: { '1': { RacingNumber: '1', Tla: 'VER', TeamColour: '3671C6' } },
|
||||
Tyres: {},
|
||||
RCMessages: [],
|
||||
Weather: {},
|
||||
Session: { MeetingName: 'Archived GP', SessionName: 'Race', SessionType: 'Race' },
|
||||
TeamRadio: [],
|
||||
SessionStatus: 'Finished',
|
||||
TrackStatus: '1',
|
||||
CurrentLap: 57,
|
||||
TotalLaps: 57,
|
||||
Clock: '',
|
||||
ClockRefTime: '',
|
||||
ClockExtrapolating: false,
|
||||
Stints: {},
|
||||
},
|
||||
last_snapshot_at: '2026-07-04T14:00:00Z',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto('/')
|
||||
await expect(page.getByTestId('command-center')).toBeVisible()
|
||||
await expect(page.getByTestId('cc-live-status')).toContainText('No live session')
|
||||
await expect(page.getByTestId('cc-live-status')).not.toContainText('Live session active')
|
||||
})
|
||||
|
||||
test('existing routes continue to work', async ({ page }) => {
|
||||
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||
|
||||
@@ -239,4 +239,39 @@ test.describe('Live Timing (no session)', () => {
|
||||
await expect(page.getByTestId('live-empty')).toBeVisible()
|
||||
await expect(page.getByTestId('live-page')).toContainText('No live session active')
|
||||
})
|
||||
|
||||
test('renders an archived snapshot only after View Last Session', async ({ page }) => {
|
||||
await page.route('**/api/v1/live/state', (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
is_live: false,
|
||||
data: null,
|
||||
last_snapshot: {
|
||||
...raceSnapshot.data,
|
||||
SessionStatus: 'Finished',
|
||||
},
|
||||
last_positions: {
|
||||
'1': { x: 100, y: -50, z: 2, status: 'OnTrack' },
|
||||
},
|
||||
last_snapshot_at: '2026-07-04T14:00:00Z',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.route('**/api/v1/live/stream', (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'text/event-stream',
|
||||
body: 'event: heartbeat\ndata: {}\n\n',
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto('/live')
|
||||
await expect(page.getByTestId('live-empty')).toContainText('No live session active')
|
||||
await expect(page.getByText('Timing Tower')).toHaveCount(0)
|
||||
|
||||
await page.getByRole('button', { name: 'View Last Session' }).click()
|
||||
await expect(page.getByTestId('live-archive-strip')).toContainText('Archived snapshot')
|
||||
await expect(page.getByText('Timing Tower')).toBeVisible()
|
||||
await expect(page.locator('.live-state')).toContainText('archive')
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 129 KiB |