Compare commits

..

14 Commits

Author SHA1 Message Date
AmanTahiliani
8fd358748d fix(agents): disable agy implementation dispatch
Codex handoff could not write .agents in its sandbox, so the orchestrator applied the intended fail-fast change: agy remains available for dry-run prompt inspection but non-dry-run dispatch exits before worktree or PR side effects.
2026-07-04 01:11:03 -04:00
Aman Tahiliani
cc4337be88 Merge pull request #43 from AmanTahiliani/phase-1
Phase 1 delivery
2026-07-04 00:37:54 -04:00
AmanTahiliani
addfd6d24d fix(compare): reset driver pair on session change 2026-07-04 00:37:12 -04:00
AmanTahiliani
b5d070e116 docs: add phase 1 PR screenshots 2026-07-04 00:32:20 -04:00
AmanTahiliani
233eefaf12 test: update phase 1 visual snapshots 2026-07-04 00:28:09 -04:00
Aman Tahiliani
51b0238b09 Merge pull request #42 from AmanTahiliani/feat/issue-18-annotate-every-number-with-meaning-ux-p
"Annotate every number with meaning" UX pass (#18)
2026-07-04 00:25:39 -04:00
AmanTahiliani
24bcac8038 feat(frontend): annotate key numbers with meaning (#18)
Add shared Meaning primitive and pure interpretation helpers for interval,
tyre age, and championship gap columns on the live tower, tyre deg panel,
and championship hub. Thresholds are exported consts (undercut window tied
to PIT_LOSS_SECONDS from #13).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 00:24:02 -04:00
Aman Tahiliani
93ac0ccebb Merge pull request #41 from AmanTahiliani/feat/issue-10-telemetry-compare-overlay-speed-throttle
Telemetry compare overlay (speed/throttle/brake + delta) (#10)
2026-07-04 00:20:52 -04:00
AmanTahiliani
66bf649729 feat(race-hub): add Compare tab with telemetry traces and lap deltas (#10)
Ship driver comparison in the race hub: two pickers (default top finishers),
best-lap telemetry overlays via TelemetryTraceChart, and cumulative pace
delta via DeltaTimeGraph with pit-lap captions. Car data is filtered to each
driver's fastest lap client-side (OpenF1 returns full-session samples).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 00:18:10 -04:00
Aman Tahiliani
2ddc930efe Merge pull request #40 from AmanTahiliani/feat/issue-11-what-just-happened-synthesized-event-rai
"What just happened" synthesized event rail (#11)
2026-07-04 00:10:06 -04:00
AmanTahiliani
7d0b25b66e fix(test): align events fixture with LiveSessionMeta.Path / TeamRadio from phase-1 2026-07-04 00:09:51 -04:00
AmanTahiliani
bc18a20829 Merge phase-1 (radio ticker, tyre-deg panel, hero) into event-rail branch 2026-07-04 00:08:24 -04:00
Aman Tahiliani
2795ccf11b Merge pull request #39 from AmanTahiliani/feat/issue-13-live-tyre-degradation-pit-window-panel
Live tyre-degradation & pit-window panel (#13)
2026-07-04 00:07:11 -04:00
AmanTahiliani
2d49a8bd00 feat(#11): "What just happened" synthesized event rail
Implemented by claude via .agents/dev dispatch.
2026-07-04 00:00:34 -04:00
38 changed files with 2299 additions and 19 deletions

View File

@@ -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 - **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`. Ready spec into the issue body → sets Effort/Priority → leaves Stage at `Research`.
You review and flip to `Ready`. 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 (or `/implement …` in Claude to supervise) → isolated worktree → runs the harness
headless on the spec → build gate → opens a PR → sets Stage `In Review`. 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 - **Review + merge**: use the `review` skill from a harness different from the
@@ -42,8 +42,17 @@ local adapter state.
## Adding / fixing a harness ## Adding / fixing a harness
Edit one function in `harnesses.sh`: `harness_<name> <workdir> <promptfile>`, running the Edit one function in `harnesses.sh`: `harness_<name> <workdir> <promptfile>`, running the
tool non-interactively in `<workdir>` on the prompt. `claude`/`codex`/`opencode` are tool non-interactively in `<workdir>` on the prompt. For current implementation
wired; `pi`/`cursor` are stubs — confirm their headless flags before trusting. 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 Always `--dry-run` a new harness first: it renders the exact prompt and plan, touching
nothing (no worktree, PR, or state change). nothing (no worktree, PR, or state change).

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# .agents/bin/dev — box-box agentic dev CLI. Works from any harness's shell. # .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 # 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). # 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>] 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) --dry-run render the prompt + plan, touch nothing (no worktree/PR/state change)
--base base branch for the worktree/PR (default: main) --base base branch for the worktree/PR (default: main)
EOF EOF

View File

@@ -11,6 +11,18 @@
# ---- MUST-HAVE ---- # ---- 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 harness_claude() { # Claude Code — print mode, auto-accept edits
local dir="$1" prompt="$2" local dir="$1" prompt="$2"
( cd "$dir" && claude -p "$(cat "$prompt")" --permission-mode acceptEdits ) ( 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 ) ( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --model composer-2.5 --force --trust )
} }
harness_agy() { # Antigravity CLI — UNRELIABLE headless (2026-07): with harness_agy() { # Antigravity CLI — disabled until headless is verified
# --new-project it ignores the prompt and asks to scaffold a project; without it, it local reason
# resumes the previous conversation (silently keeping its old model — --model only reason="$(harness_disabled_reason agy)"
# applies to new conversations) and can hang past the print timeout. Do not trust for echo "harness_agy: $reason" >&2
# dispatch until fixed upstream; verify with a trivial prompt first. return 2
local dir="$1" prompt="$2"
( cd "$dir" && agy --print --print-timeout 60m \
--model="Gemini 3.1 Pro (High)" --dangerously-skip-permissions "$(cat "$prompt")" )
} }
# ---- NICE-TO-HAVE (verify the exact invocation for your version before trusting) ---- # ---- NICE-TO-HAVE (verify the exact invocation for your version before trusting) ----

View File

@@ -52,6 +52,15 @@ dispatch() { # <issue#> <harness> [--dry-run] [--base <branch>]
if ! declare -f "harness_$harness" >/dev/null 2>&1; then 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 echo "no adapter for harness '$harness' — add harness_$harness() to .agents/harnesses.sh" >&2; return 2
fi 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 local repo_root title body slug branch wt prompt
repo_root="$(git rev-parse --show-toplevel)" || return 1 repo_root="$(git rev-parse --show-toplevel)" || return 1

View 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
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -1,6 +1,8 @@
import type { import type {
ArticleContent, ArticleContent,
CarDataSample,
ChampionshipHub, ChampionshipHub,
LapsComparisonResponse,
LiveStateResponse, LiveStateResponse,
LiveSessionMeta, LiveSessionMeta,
Meeting, Meeting,
@@ -126,3 +128,32 @@ export async function markNewsRead(articleUrl: string): Promise<void> {
body: JSON.stringify({ url: articleUrl }), 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()
}

View File

@@ -0,0 +1,251 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchLapsComparison, fetchTelemetry } from '../api'
import {
buildBestLapTraceSeries,
compareDriverOptions,
comparisonToDeltaSeries,
defaultCompareDriverNumbers,
formatPitLapsCaption,
} from '../lib/compare'
import type { Driver, EnrichedResult } from '../types'
import { teamColor } from '../utils'
import { DriverCell } from './DriverCell'
import { TelemetryTraceChart } from './charts/TelemetryTraceChart'
import { DeltaTimeGraph } from './charts/DeltaTimeGraph'
import '../styles/compare-view.css'
interface Props {
sessionKey: number
results: EnrichedResult[]
drivers: Driver[]
}
function SectionState({
loading,
error,
empty,
emptyMessage,
children,
}: {
loading: boolean
error: Error | null
empty: boolean
emptyMessage: string
children: React.ReactNode
}) {
if (loading) {
return <div className="loading-state">loading</div>
}
if (error) {
return <div className="error-box">{error.message}</div>
}
if (empty) {
return <div className="missing-notice">{emptyMessage}</div>
}
return <>{children}</>
}
export function CompareView({ sessionKey, results, drivers }: Props) {
const driverOptions = useMemo(
() => compareDriverOptions(drivers, results),
[drivers, results],
)
const initialPair = useMemo(
() => defaultCompareDriverNumbers(results, drivers),
[results, drivers],
)
const previousSessionKey = useRef(sessionKey)
const [driverA, setDriverA] = useState<number | null>(initialPair?.[0] ?? null)
const [driverB, setDriverB] = useState<number | null>(initialPair?.[1] ?? null)
useEffect(() => {
if (previousSessionKey.current !== sessionKey) {
previousSessionKey.current = sessionKey
setDriverA(initialPair?.[0] ?? null)
setDriverB(initialPair?.[1] ?? null)
return
}
if (driverA != null && driverB != null) return
if (!initialPair) return
setDriverA(initialPair[0])
setDriverB(initialPair[1])
}, [sessionKey, initialPair, driverA, driverB])
const pair = useMemo((): [number, number] | null => {
if (driverA == null || driverB == null || driverA === driverB) return null
return [driverA, driverB]
}, [driverA, driverB])
const comparisonQuery = useQuery({
queryKey: ['laps-comparison', sessionKey, pair?.[0], pair?.[1]],
queryFn: () => fetchLapsComparison(sessionKey, pair!),
enabled: pair != null,
staleTime: 60_000,
})
const telemetryAQuery = useQuery({
queryKey: ['telemetry', sessionKey, pair?.[0]],
queryFn: () => fetchTelemetry(sessionKey, pair![0]),
enabled: pair != null,
staleTime: 60_000,
})
const telemetryBQuery = useQuery({
queryKey: ['telemetry', sessionKey, pair?.[1]],
queryFn: () => fetchTelemetry(sessionKey, pair![1]),
enabled: pair != null,
staleTime: 60_000,
})
const comparison = comparisonQuery.data
const referenceLabel = useMemo(() => {
if (!pair) return undefined
const meta = comparison?.drivers.find((d) => d.driver_number === pair[0])
const session = drivers.find((d) => d.driver_number === pair[0])
return meta?.name_acronym || session?.name_acronym
}, [pair, comparison, drivers])
const deltaSeries = useMemo(() => {
if (!comparison || !pair) return []
return comparisonToDeltaSeries(comparison, pair, drivers)
}, [comparison, pair, drivers])
const pitCaption = useMemo(() => {
if (!comparison || !pair) return null
return formatPitLapsCaption(comparison.pit_laps, pair, comparison, drivers)
}, [comparison, pair, drivers])
const traceSeries = useMemo(() => {
if (!pair || !comparison) return []
const out = []
for (const dn of pair) {
const comp = comparison.drivers.find((d) => d.driver_number === dn)
const session = drivers.find((d) => d.driver_number === dn)
const label = comp?.name_acronym || session?.name_acronym || `#${dn}`
const color = teamColor(comp?.team_colour || session?.team_colour)
const carData =
dn === pair[0] ? (telemetryAQuery.data ?? []) : (telemetryBQuery.data ?? [])
const series = buildBestLapTraceSeries(carData, comp?.laps ?? [], label, color)
if (series) out.push(series)
}
return out
}, [pair, comparison, drivers, telemetryAQuery.data, telemetryBQuery.data])
const telemetryLoading = telemetryAQuery.isLoading || telemetryBQuery.isLoading
const telemetryError = telemetryAQuery.error ?? telemetryBQuery.error
if (driverOptions.length < 2) {
return (
<div className="missing-notice" data-testid="compare-view-empty">
Need at least two drivers in this session to compare.
</div>
)
}
const driverAInfo = driverOptions.find((d) => d.driver_number === driverA)
const driverBInfo = driverOptions.find((d) => d.driver_number === driverB)
return (
<div className="compare-view" data-testid="compare-view">
<div className="compare-pickers">
<div className="compare-picker">
<span className="compare-picker-label">Reference</span>
<select
className="compare-picker-select"
value={driverA ?? ''}
onChange={(e) => setDriverA(Number(e.target.value))}
data-testid="compare-picker-a"
>
{driverOptions.map((d) => (
<option key={d.driver_number} value={d.driver_number}>
{d.name_acronym} · #{d.driver_number}
</option>
))}
</select>
{driverAInfo && (
<DriverCell
acronym={driverAInfo.name_acronym}
number={driverAInfo.driver_number}
colour={driverAInfo.team_colour}
/>
)}
</div>
<div className="compare-picker">
<span className="compare-picker-label">Challenger</span>
<select
className="compare-picker-select"
value={driverB ?? ''}
onChange={(e) => setDriverB(Number(e.target.value))}
data-testid="compare-picker-b"
>
{driverOptions.map((d) => (
<option key={d.driver_number} value={d.driver_number}>
{d.name_acronym} · #{d.driver_number}
</option>
))}
</select>
{driverBInfo && (
<DriverCell
acronym={driverBInfo.name_acronym}
number={driverBInfo.driver_number}
colour={driverBInfo.team_colour}
/>
)}
</div>
</div>
{driverA === driverB && (
<div className="analysis-notice">
<strong>Pick two different drivers</strong> to run a comparison.
</div>
)}
<section className="compare-section" data-testid="compare-telemetry-section">
<div>
<div className="compare-section-title">Best lap telemetry</div>
<div className="compare-section-meta">
Speed, throttle, and brake traces for each driver&apos;s fastest lap
</div>
</div>
<SectionState
loading={telemetryLoading}
error={telemetryError instanceof Error ? telemetryError : null}
empty={!telemetryLoading && !telemetryError && traceSeries.length === 0}
emptyMessage="No telemetry samples for the best laps. Car data may not be available for this session."
>
<TelemetryTraceChart series={traceSeries} />
</SectionState>
</section>
<section className="compare-section" data-testid="compare-pace-section">
<div>
<div className="compare-section-title">Race pace</div>
<div className="compare-section-meta">
Cumulative lap-time delta vs {referenceLabel ?? 'reference'}
</div>
</div>
<SectionState
loading={comparisonQuery.isLoading}
error={comparisonQuery.error instanceof Error ? comparisonQuery.error : null}
empty={!comparisonQuery.isLoading && !comparisonQuery.error && deltaSeries.length < 2}
emptyMessage="No lap comparison data for the selected drivers."
>
<DeltaTimeGraph series={deltaSeries} referenceLabel={referenceLabel} />
{pitCaption && (
<p className="compare-pit-caption" data-testid="compare-pit-caption">
{pitCaption}
</p>
)}
</SectionState>
</section>
</div>
)
}

View 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>
)
}

View File

@@ -2,6 +2,7 @@ export type Tab =
| 'overview' | 'overview'
| 'race_story' | 'race_story'
| 'strategy' | 'strategy'
| 'compare'
| 'lap_data' | 'lap_data'
| 'conditions' | 'conditions'
| 'race_control' | 'race_control'
@@ -11,6 +12,7 @@ const TABS: { id: Tab; label: string }[] = [
{ id: 'overview', label: 'Overview' }, { id: 'overview', label: 'Overview' },
{ id: 'race_story', label: 'Race Story' }, { id: 'race_story', label: 'Race Story' },
{ id: 'strategy', label: 'Strategy' }, { id: 'strategy', label: 'Strategy' },
{ id: 'compare', label: 'Compare' },
{ id: 'lap_data', label: 'Lap Data' }, { id: 'lap_data', label: 'Lap Data' },
{ id: 'conditions', label: 'Conditions' }, { id: 'conditions', label: 'Conditions' },
{ id: 'race_control', label: 'Race Control' }, { id: 'race_control', label: 'Race Control' },

View 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>
)
}

View File

@@ -12,6 +12,9 @@ import {
tyreLabel, tyreLabel,
} from '../../lib/live' } from '../../lib/live'
import type { GapHistoryMap } from '../../lib/gapHistory' 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 { GapSparkline } from './GapSparkline'
import { StintHistory } from './StintHistory' import { StintHistory } from './StintHistory'
import { Pin } from 'lucide-react' import { Pin } from 'lucide-react'
@@ -100,6 +103,10 @@ export function TimingTower({
const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition
const gapText = gapMode === 'interval' && isRace ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader 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 renderSector = (idx: number) => {
const sec = driver.Sectors?.[idx] const sec = driver.Sectors?.[idx]
@@ -149,7 +156,14 @@ export function TimingTower({
<td className={driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono'}> <td className={driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono'}>
{driver.LastLapTime || '-'} {driver.LastLapTime || '-'}
</td> </td>
<td className="mono">{gapText || '-'}</td> <td className="mono">
<Meaning
value={gapText || '-'}
meaning={intervalAnnotation?.caption}
title={intervalAnnotation?.title}
tone={intervalAnnotation?.tone}
/>
</td>
{isRace && ( {isRace && (
<td className="spark-cell"> <td className="spark-cell">

View File

@@ -13,6 +13,8 @@ import {
recordStintSamples, recordStintSamples,
stintInputFromRow, stintInputFromRow,
} from '../../lib/tyredeg' } from '../../lib/tyredeg'
import { tyreAgeMeaning } from '../../lib/meaning'
import { Meaning } from '../Meaning'
import '../../styles/tyredeg.css' import '../../styles/tyredeg.css'
const TOP_DRIVER_COUNT = 10 const TOP_DRIVER_COUNT = 10
@@ -92,12 +94,20 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
{visible.map((row) => { {visible.map((row) => {
const model = degradationModel(stints[row.RacingNumber]?.samples ?? []) const model = degradationModel(stints[row.RacingNumber]?.samples ?? [])
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age)
return ( return (
<div className="tyredeg-row" key={row.RacingNumber} data-testid="tyredeg-row"> <div className="tyredeg-row" key={row.RacingNumber} data-testid="tyredeg-row">
<span className="tyredeg-pos mono">P{row.Position}</span> <span className="tyredeg-pos mono">P{row.Position}</span>
<span className="drv-bar" style={{ background: teamColor(row.Info?.TeamColour) }} /> <span className="drv-bar" style={{ background: teamColor(row.Info?.TeamColour) }} />
<span className="drv-code">{driverCode(row)}</span> <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 ? ( {model ? (
<> <>
<span className={`tyredeg-trend tyredeg-trend-${model.trend}`}> <span className={`tyredeg-trend tyredeg-trend-${model.trend}`}>

202
frontend/src/lib/compare.ts Normal file
View 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,
}))
}

318
frontend/src/lib/events.ts Normal file
View 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`
}

146
frontend/src/lib/meaning.ts Normal file
View 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',
}
}

View File

@@ -4,6 +4,8 @@ import { fetchChampionshipHub, fetchSeasons } from '../api'
import { teamColor } from '../utils' import { teamColor } from '../utils'
import type { ChampHubDriver, ChampionshipHub } from '../types' import type { ChampHubDriver, ChampionshipHub } from '../types'
import { ChampionshipSimulator } from '../components/ChampionshipSimulator' import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
import { Meaning } from '../components/Meaning'
import { pointsGapMeaning } from '../lib/meaning'
type View = 'drivers' | 'constructors' | 'progression' | 'simulator' type View = 'drivers' | 'constructors' | 'progression' | 'simulator'
@@ -138,6 +140,8 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
color: teamColor(d.team_colour), color: teamColor(d.team_colour),
gapLeader: i === 0 ? 'LEADER' : `+${fmtPts(gapLeaderNum)}`, gapLeader: i === 0 ? 'LEADER' : `+${fmtPts(gapLeaderNum)}`,
gapAhead: gapAheadNum == null ? '—' : `+${fmtPts(gapAheadNum)}`, gapAhead: gapAheadNum == null ? '—' : `+${fmtPts(gapAheadNum)}`,
gapAheadNum,
driverAhead: i === 0 ? null : drivers[i - 1].name_acronym,
spark: sparkPoints(d.form), spark: sparkPoints(d.form),
h2h: `${d.teammate_wins}${d.teammate_losses}`, h2h: `${d.teammate_wins}${d.teammate_losses}`,
h2hWin: d.teammate_wins >= d.teammate_losses, h2hWin: d.teammate_wins >= d.teammate_losses,
@@ -233,7 +237,12 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
</div> </div>
{view === 'drivers' && ( {view === 'drivers' && (
<DriversView enriched={enriched} leaderPoints={leader.points} titleMath={titleMath} /> <DriversView
enriched={enriched}
leaderPoints={leader.points}
titleMath={titleMath}
roundsLeft={hub.rounds_left}
/>
)} )}
{view === 'constructors' && <ConstructorsView hub={hub} />} {view === 'constructors' && <ConstructorsView hub={hub} />}
{view === 'progression' && <ProgressionView hub={hub} />} {view === 'progression' && <ProgressionView hub={hub} />}
@@ -248,6 +257,8 @@ interface EnrichedDriver {
color: string color: string
gapLeader: string gapLeader: string
gapAhead: string gapAhead: string
gapAheadNum: number | null
driverAhead: string | null
spark: string spark: string
h2h: string h2h: string
h2hWin: boolean h2hWin: boolean
@@ -259,10 +270,12 @@ function DriversView({
enriched, enriched,
leaderPoints, leaderPoints,
titleMath, titleMath,
roundsLeft,
}: { }: {
enriched: EnrichedDriver[] enriched: EnrichedDriver[]
leaderPoints: number leaderPoints: number
titleMath: string titleMath: string
roundsLeft: number
}) { }) {
const podium = enriched.slice(0, 3) const podium = enriched.slice(0, 3)
return ( return (
@@ -357,7 +370,19 @@ function DriversView({
<td className="champ-td-team">{e.d.team_name}</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-pts">{fmtPts(e.d.points)}</td>
<td className="r mono champ-td-muted">{e.gapLeader}</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)' }}> <td className="c mono" style={{ color: e.d.wins > 0 ? 'var(--text)' : 'var(--text-3)' }}>
{e.d.wins} {e.d.wins}
</td> </td>

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { fetchLiveState, fetchLiveTrackOutline } from '../api' import { fetchLiveState, fetchLiveTrackOutline } from '../api'
import type { LivePosition, LiveStreamData } from '../types' import type { LivePosition, LiveStreamData } from '../types'
@@ -15,12 +15,15 @@ import type { VisibleSectorState } from '../lib/live'
import type { GapHistoryMap } from '../lib/gapHistory' import type { GapHistoryMap } from '../lib/gapHistory'
import { recordGapSamples } from '../lib/gapHistory' import { recordGapSamples } from '../lib/gapHistory'
import { battleNumbers, detectBattles } from '../lib/battles' import { battleNumbers, detectBattles } from '../lib/battles'
import type { LiveEvent } from '../lib/events'
import { appendEvents, diffSnapshots, sessionSignature } from '../lib/events'
import { SessionBanner } from '../components/live/SessionBanner' import { SessionBanner } from '../components/live/SessionBanner'
import { TrackStatusBanner } from '../components/live/TrackStatusBanner' import { TrackStatusBanner } from '../components/live/TrackStatusBanner'
import { TimingTower } from '../components/live/TimingTower' import { TimingTower } from '../components/live/TimingTower'
import { BattleChips } from '../components/live/BattleChips' import { BattleChips } from '../components/live/BattleChips'
import { PinnedDrivers } from '../components/live/PinnedDrivers' import { PinnedDrivers } from '../components/live/PinnedDrivers'
import { RaceControlFeed } from '../components/live/RaceControlFeed' import { RaceControlFeed } from '../components/live/RaceControlFeed'
import { EventRail } from '../components/live/EventRail'
import { TeamRadioTicker } from '../components/live/TeamRadioTicker' import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
import { TrackMap } from '../components/live/TrackMap' import { TrackMap } from '../components/live/TrackMap'
import { TyreDegPanel } from '../components/live/TyreDegPanel' import { TyreDegPanel } from '../components/live/TyreDegPanel'
@@ -37,6 +40,9 @@ export function LiveTimingPage() {
const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers()) const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers())
const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({}) const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({})
const [positions, setPositions] = useState<Record<string, LivePosition>>({}) const [positions, setPositions] = useState<Record<string, LivePosition>>({})
const [events, setEvents] = useState<LiveEvent[]>([])
const prevSnapshotRef = useRef<LiveStreamData | null>(null)
const sessionSigRef = useRef('')
const { data, isLoading, isError, error } = useQuery({ const { data, isLoading, isError, error } = useQuery({
queryKey: ['live-state'], queryKey: ['live-state'],
@@ -114,6 +120,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]) const rawRows = useMemo(() => sortLiveTimingRows(snapshot), [snapshot])
useEffect(() => { useEffect(() => {
@@ -220,6 +243,7 @@ export function LiveTimingPage() {
session={snapshot.Session} session={snapshot.Session}
/> />
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} /> <RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
</div> </div>
</div> </div>
</> </>

View File

@@ -13,6 +13,7 @@ import { TabBar, type Tab } from '../components/TabBar'
import { DatasetStatusView } from '../components/DatasetStatusView' import { DatasetStatusView } from '../components/DatasetStatusView'
import { StrategyView } from '../components/StrategyView' import { StrategyView } from '../components/StrategyView'
import { LapsView } from '../components/LapsView' import { LapsView } from '../components/LapsView'
import { CompareView } from '../components/CompareView'
import { RaceControlView } from '../components/RaceControlView' import { RaceControlView } from '../components/RaceControlView'
import { WeatherView } from '../components/WeatherView' import { WeatherView } from '../components/WeatherView'
import { OverviewView } from '../components/OverviewView' import { OverviewView } from '../components/OverviewView'
@@ -307,6 +308,19 @@ export function RaceHubPage({ sessionKey }: Props) {
</div> </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' && ( {activeTab === 'lap_data' && (
<div className="data-section"> <div className="data-section">
<div className="sec-header"> <div className="sec-header">

View 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);
}

View 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;
}

View 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;
}

View File

@@ -112,6 +112,7 @@ describe('ChampionshipPage', () => {
expect(screen.getAllByText('VER').length).toBeGreaterThan(0) expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
expect(screen.getByText('Monaco GP', { exact: false })).toBeInTheDocument() expect(screen.getByText('Monaco GP', { exact: false })).toBeInTheDocument()
expect(screen.getByTestId('champ-titlemath')).toHaveTextContent('mathematically win the title') 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 () => { it('switches to constructors and progression views', async () => {

View File

@@ -189,6 +189,26 @@ describe('TimingTower', () => {
expect(screen.getByText(/no driver timing rows/i)).toBeInTheDocument() 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', () => { it('renders the SQ1 cutoff after P17 and marks rows below as at risk', () => {
const sprintRows = Array.from({ length: 22 }, (_, index) => const sprintRows = Array.from({ length: 22 }, (_, index) =>
makeRow(String(index + 1), index + 1, `D${index + 1}`), makeRow(String(index + 1), index + 1, `D${index + 1}`),

View 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')
})
})

View File

@@ -8,6 +8,7 @@ describe('TabBar', () => {
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Race Story' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Race Story' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Strategy' })).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: 'Lap Data' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Conditions' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Conditions' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument()

View File

@@ -52,9 +52,18 @@ describe('TyreDegPanel', () => {
const panel = screen.getByTestId('tyredeg-panel') const panel = screen.getByTestId('tyredeg-panel')
expect(panel).toHaveTextContent('VER') expect(panel).toHaveTextContent('VER')
expect(panel).toHaveTextContent('M +5') expect(panel).toHaveTextContent('M +5')
expect(panel).toHaveTextContent('fresh')
expect(panel).toHaveTextContent('warming up') 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', () => { it('renders slope and rejoin estimate once laps accumulate across snapshots', () => {
const { rerender } = render( const { rerender } = render(
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />, <TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,

View 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()
})
})

View 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('')
})
})

View 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()
})
})

View File

@@ -158,6 +158,37 @@ export interface Lap {
is_pit_out_lap: boolean 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 { export interface WeekendSession {
session: Session session: Session
source: 'local' | 'partial' | 'none' | 'cancelled' source: 'local' | 'partial' | 'none' | 'cancelled'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 129 KiB