From c5a7ad51ebef2f44ce830e9533798a8f1a95bf47 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Fri, 3 Jul 2026 23:48:09 -0400 Subject: [PATCH 1/6] feat(frontend): add reusable TyreStintTimeline component (#16) Extract stint timeline SVG from StrategyView into a data-source-agnostic chart primitive with compound legend, lap-axis ticks, and hover titles. StrategyView maps race-hub stint data into the new props contract. Co-authored-by: Cursor --- frontend/src/components/StrategyView.tsx | 177 ++--------------- .../components/charts/TyreStintTimeline.tsx | 182 ++++++++++++++++++ frontend/src/styles/stint-timeline.css | 94 +++++++++ .../src/test/tyre-stint-timeline.test.tsx | 149 ++++++++++++++ 4 files changed, 445 insertions(+), 157 deletions(-) create mode 100644 frontend/src/components/charts/TyreStintTimeline.tsx create mode 100644 frontend/src/styles/stint-timeline.css create mode 100644 frontend/src/test/tyre-stint-timeline.test.tsx diff --git a/frontend/src/components/StrategyView.tsx b/frontend/src/components/StrategyView.tsx index 3dee6a4..4904fd8 100644 --- a/frontend/src/components/StrategyView.tsx +++ b/frontend/src/components/StrategyView.tsx @@ -1,28 +1,9 @@ import type { EnrichedResult, Stint, PitStop } from '../types' import { compareFinishPosition } from '../utils' - -const COMPOUND_COLORS: Record = { - SOFT: '#e8002d', - MEDIUM: '#ffd600', - HARD: '#e8e8e4', - INTERMEDIATE: '#39b54a', - WET: '#0067ff', -} - -function compoundColor(c: string): string { - return COMPOUND_COLORS[c.toUpperCase()] ?? '#666' -} - -function compoundInitial(c: string): string { - const abbr: Record = { - SOFT: 'S', - MEDIUM: 'M', - HARD: 'H', - INTERMEDIATE: 'I', - WET: 'W', - } - return abbr[c.toUpperCase()] ?? c[0] ?? '?' -} +import { + TyreStintTimeline, + type StintTimelineRow, +} from './charts/TyreStintTimeline' interface Props { results: EnrichedResult[] @@ -31,7 +12,7 @@ interface Props { hasStints: boolean } -export function StrategyView({ results, stints, pit_stops, hasStints }: Props) { +export function StrategyView({ results, stints, pit_stops: _pitStops, hasStints }: Props) { if (!hasStints) { return (
@@ -89,147 +70,29 @@ export function StrategyView({ results, stints, pit_stops, hasStints }: Props) { const cmp = compareFinishPosition(a.position, b.position) return cmp !== 0 ? cmp : a.driver_number - b.driver_number }) + const totalLaps = Math.max( ...stints.map((s) => s.lap_end), ...results.map((r) => r.number_of_laps), - 1 + 1, ) - const SVG_W = 640 - const LEFT = 48 - const RIGHT = 12 - const ROW_H = 28 - const BAR_H = 14 - const BAR_Y = 7 - const BAR_W = SVG_W - LEFT - RIGHT - const SVG_H = sortedDrivers.length * ROW_H + 8 - - const lapX = (lap: number) => LEFT + ((lap - 1) / totalLaps) * BAR_W - const stintW = (s: Stint) => - Math.max(2, ((s.lap_end - s.lap_start + 1) / totalLaps) * BAR_W) - - const usedCompounds = [...new Set(stints.map((s) => s.compound.toUpperCase()))].filter( - (c) => c in COMPOUND_COLORS - ) + const timelineRows: StintTimelineRow[] = sortedDrivers.map((driver) => ({ + label: driver.name_acronym || String(driver.driver_number), + color: driver.team_colour ? `#${driver.team_colour}` : '#888', + stints: stints + .filter((s) => s.driver_number === driver.driver_number) + .map((s) => ({ + compound: s.compound, + lapStart: s.lap_start, + lapEnd: s.lap_end, + isNew: s.tyre_age_at_start === 0, + })), + })) return (
-
- - {sortedDrivers.map((driver, i) => { - const rowY = i * ROW_H - const color = driver.team_colour ? `#${driver.team_colour}` : '#888' - const dStints = stints.filter((s) => s.driver_number === driver.driver_number) - const dPits = pit_stops.filter((p) => p.driver_number === driver.driver_number) - - return ( - - - {driver.name_acronym} - - - {dStints.map((stint, si) => { - const x = lapX(stint.lap_start) - const w = stintW(stint) - const fill = compoundColor(stint.compound) - return ( - - - {w > 18 && ( - - {compoundInitial(stint.compound)} - - )} - - ) - })} - - {dPits.map((pit, pi) => { - const x = lapX(pit.lap_number) - return ( - - ) - })} - - ) - })} - -
- -
- {usedCompounds.map((c) => ( - - - {c.charAt(0) + c.slice(1).toLowerCase()} - - ))} - {pit_stops.length > 0 && ( - - - Pit stop - - )} - - {totalLaps} laps - -
+
) } diff --git a/frontend/src/components/charts/TyreStintTimeline.tsx b/frontend/src/components/charts/TyreStintTimeline.tsx new file mode 100644 index 0000000..731574c --- /dev/null +++ b/frontend/src/components/charts/TyreStintTimeline.tsx @@ -0,0 +1,182 @@ +import { compoundClass } from '../../lib/live' +import '../../styles/stint-timeline.css' + +export interface StintTimelineStint { + compound: string + lapStart: number + lapEnd: number + isNew?: boolean +} + +export interface StintTimelineRow { + label: string + color: string + stints: StintTimelineStint[] +} + +interface TyreStintTimelineProps { + rows: StintTimelineRow[] + totalLaps: number +} + +const SVG_W = 640 +const LEFT = 48 +const RIGHT = 12 +const ROW_H = 28 +const BAR_H = 14 +const BAR_Y = 7 +const AXIS_H = 20 +const BAR_W = SVG_W - LEFT - RIGHT + +const COMPOUND_ORDER = ['SOFT', 'MEDIUM', 'HARD', 'INTERMEDIATE', 'WET'] as const + +function compoundLabel(compound: string): string { + const upper = compound.toUpperCase() + if (upper === 'INTERMEDIATE') return 'Intermediate' + return upper.charAt(0) + upper.slice(1).toLowerCase() +} + +function stintLength(stint: StintTimelineStint): number { + return stint.lapEnd - stint.lapStart + 1 +} + +function stintTitle(stint: StintTimelineStint): string { + const length = stintLength(stint) + return `${compoundLabel(stint.compound)} · L${stint.lapStart}–${stint.lapEnd} · ${length} lap${length === 1 ? '' : 's'}` +} + +function lapX(lap: number, totalLaps: number): number { + return LEFT + (lap / totalLaps) * BAR_W +} + +function stintBarX(stint: StintTimelineStint, totalLaps: number): number { + return LEFT + ((stint.lapStart - 1) / totalLaps) * BAR_W +} + +function stintBarW(stint: StintTimelineStint, totalLaps: number): number { + return Math.max(2, (stintLength(stint) / totalLaps) * BAR_W) +} + +function axisTicks(totalLaps: number): number[] { + const ticks: number[] = [] + for (let lap = 0; lap <= totalLaps; lap += 10) { + ticks.push(lap) + } + return ticks +} + +function collectUsedCompounds(rows: StintTimelineRow[]): string[] { + const seen = new Set() + for (const row of rows) { + for (const stint of row.stints) { + seen.add(stint.compound.toUpperCase()) + } + } + const ordered = COMPOUND_ORDER.filter((c) => seen.has(c)) + const extras = [...seen] + .filter((c) => !COMPOUND_ORDER.includes(c as (typeof COMPOUND_ORDER)[number])) + .sort() + return [...ordered, ...extras] +} + +export function TyreStintTimeline({ rows, totalLaps }: TyreStintTimelineProps) { + const safeTotal = Math.max(totalLaps, 1) + const usedCompounds = collectUsedCompounds(rows) + const ticks = axisTicks(safeTotal) + const chartH = rows.length * ROW_H + const svgH = chartH + AXIS_H + 4 + + if (rows.length === 0) { + return ( +
+
No stint data to display.
+
+ ) + } + + return ( +
+
+ + {rows.map((row, i) => { + const rowY = i * ROW_H + return ( + + + {row.label} + + + {row.stints.map((stint, si) => ( + + {stintTitle(stint)} + + ))} + + ) + })} + + + + {ticks.map((lap) => ( + + + + {lap} + + + ))} + + +
+ +
+ {usedCompounds.map((compound) => ( + + + {compoundLabel(compound)} + + ))} + {safeTotal} laps +
+
+ ) +} diff --git a/frontend/src/styles/stint-timeline.css b/frontend/src/styles/stint-timeline.css new file mode 100644 index 0000000..38e9219 --- /dev/null +++ b/frontend/src/styles/stint-timeline.css @@ -0,0 +1,94 @@ +.stint-timeline { + --stint-left: 48px; + --stint-right: 12px; + --stint-row-h: 28px; + --stint-bar-h: 14px; + --stint-axis-h: 20px; +} + +.stint-timeline__scroll { + overflow-x: auto; +} + +.stint-timeline__svg { + width: 100%; + min-width: 280px; + max-width: 640px; + display: block; +} + +.stint-timeline__label { + font-family: var(--f-mono); + font-weight: 700; + font-size: 10px; +} + +.stint-timeline__bar { + stroke: none; +} + +.stint-timeline__bar.tyre-soft { fill: var(--tyre-soft); } +.stint-timeline__bar.tyre-medium { fill: var(--tyre-medium); } +.stint-timeline__bar.tyre-hard { fill: var(--tyre-hard); stroke: #555; stroke-width: 0.5; } +.stint-timeline__bar.tyre-inter { fill: var(--tyre-inter); } +.stint-timeline__bar.tyre-wet { fill: var(--tyre-wet); } +.stint-timeline__bar.tyre-unknown { fill: var(--surface-2); stroke: var(--border-2); stroke-width: 0.5; } + +.stint-timeline__bar--new { + stroke: var(--text); + stroke-width: 1; + stroke-dasharray: 2 1; +} + +.stint-timeline__axis-tick { + font-family: var(--f-mono); + font-size: 9px; + fill: var(--text-3); +} + +.stint-timeline__axis-line { + stroke: var(--border-2); + stroke-width: 1; +} + +.stint-timeline__legend { + display: flex; + gap: var(--s4); + flex-wrap: wrap; + margin-top: var(--s4); + font-size: 11px; + color: var(--text-3); + align-items: center; +} + +.stint-timeline__legend-swatch { + width: 10px; + height: 10px; + border-radius: 2px; + display: inline-block; +} + +.stint-timeline__legend-swatch.tyre-hard { + border: 1px solid #555; +} + +.stint-timeline__legend-item { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.stint-timeline__meta { + margin-left: auto; + font-family: var(--f-mono); + font-size: 10px; +} + +.stint-timeline__empty { + padding: var(--s5); + color: var(--text-3); + font-size: 13px; + text-align: center; + border: 1px dashed var(--border-2); + border-radius: var(--r2); +} diff --git a/frontend/src/test/tyre-stint-timeline.test.tsx b/frontend/src/test/tyre-stint-timeline.test.tsx new file mode 100644 index 0000000..b090c2e --- /dev/null +++ b/frontend/src/test/tyre-stint-timeline.test.tsx @@ -0,0 +1,149 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { TyreStintTimeline, type StintTimelineRow } from '../components/charts/TyreStintTimeline' +import { StrategyView } from '../components/StrategyView' +import type { EnrichedResult, Stint, PitStop } from '../types' + +const sampleRows: StintTimelineRow[] = [ + { + label: 'VER', + color: '#3671C6', + stints: [ + { compound: 'MEDIUM', lapStart: 1, lapEnd: 30 }, + { compound: 'SOFT', lapStart: 31, lapEnd: 78 }, + ], + }, + { + label: 'HAM', + color: '#E8002D', + stints: [{ compound: 'SOFT', lapStart: 1, lapEnd: 18 }], + }, +] + +describe('TyreStintTimeline', () => { + it('renders one rect per stint', () => { + const { container } = render( + , + ) + expect(container.querySelectorAll('.stint-timeline__bar')).toHaveLength(3) + }) + + it('maps compounds to color classes', () => { + const { container } = render( + , + ) + const bars = container.querySelectorAll('.stint-timeline__bar') + expect(bars[0]).toHaveClass('tyre-medium') + expect(bars[1]).toHaveClass('tyre-soft') + expect(bars[2]).toHaveClass('tyre-soft') + }) + + it('shows native title with compound, lap range, and stint length', () => { + const { container } = render( + , + ) + const titles = [...container.querySelectorAll('title')].map((t) => t.textContent) + expect(titles).toContain('Medium · L1–30 · 30 laps') + expect(titles).toContain('Soft · L31–78 · 48 laps') + expect(titles).toContain('Soft · L1–18 · 18 laps') + }) + + it('renders lap-axis ticks every 10 laps', () => { + render() + expect(screen.getByText('0')).toBeInTheDocument() + expect(screen.getByText('10')).toBeInTheDocument() + expect(screen.getByText('20')).toBeInTheDocument() + expect(screen.getByText('70')).toBeInTheDocument() + }) + + it('shows legend only for used compounds', () => { + render() + expect(screen.getByTestId('legend-soft')).toBeInTheDocument() + expect(screen.getByTestId('legend-medium')).toBeInTheDocument() + expect(screen.queryByTestId('legend-hard')).not.toBeInTheDocument() + expect(screen.queryByTestId('legend-wet')).not.toBeInTheDocument() + expect(screen.getByText('Soft')).toBeInTheDocument() + expect(screen.getByText('Medium')).toBeInTheDocument() + }) + + it('renders empty state when rows are empty', () => { + render() + expect(screen.getByTestId('stint-timeline-empty')).toBeInTheDocument() + expect(screen.getByText(/No stint data/i)).toBeInTheDocument() + }) +}) + +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 stints: Stint[] = [ + { + session_key: 9472, + driver_number: 1, + meeting_key: 1229, + stint_number: 1, + compound: 'MEDIUM', + lap_start: 1, + lap_end: 30, + tyre_age_at_start: 0, + }, + { + session_key: 9472, + driver_number: 44, + meeting_key: 1229, + stint_number: 1, + compound: 'SOFT', + lap_start: 1, + lap_end: 18, + tyre_age_at_start: 0, + }, +] + +const pitStops: PitStop[] = [] + +describe('StrategyView integration', () => { + it('renders timeline when stints exist', () => { + const { container } = render( + , + ) + expect(container.querySelector('[data-testid="strategy-chart"]')).toBeInTheDocument() + expect(screen.getByTestId('stint-timeline')).toBeInTheDocument() + expect(screen.getByText('VER')).toBeInTheDocument() + expect(screen.getByText('HAM')).toBeInTheDocument() + expect(container.querySelectorAll('.stint-timeline__bar')).toHaveLength(2) + }) +}) From 09778618069b552348152397f69f099ce4d56fc1 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Fri, 3 Jul 2026 23:48:50 -0400 Subject: [PATCH 2/6] feat(frontend): add DeltaTimeGraph cumulative delta chart (#17) Introduce a reusable SVG chart primitive for lap-by-lap cumulative time delta vs a reference driver, with pure math in lib/delta.ts, dedicated styles, null-lap gap handling, and Vitest coverage for math and rendering. Co-authored-by: Cursor --- .../src/components/charts/DeltaTimeGraph.tsx | 282 ++++++++++++++++++ frontend/src/lib/delta.ts | 112 +++++++ frontend/src/styles/delta-graph.css | 76 +++++ frontend/src/test/delta-time-graph.test.tsx | 121 ++++++++ 4 files changed, 591 insertions(+) create mode 100644 frontend/src/components/charts/DeltaTimeGraph.tsx create mode 100644 frontend/src/lib/delta.ts create mode 100644 frontend/src/styles/delta-graph.css create mode 100644 frontend/src/test/delta-time-graph.test.tsx diff --git a/frontend/src/components/charts/DeltaTimeGraph.tsx b/frontend/src/components/charts/DeltaTimeGraph.tsx new file mode 100644 index 0000000..c4e840f --- /dev/null +++ b/frontend/src/components/charts/DeltaTimeGraph.tsx @@ -0,0 +1,282 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +import { + computeCumulativeDeltas, + deltaPolylineSegments, + formatDeltaSeconds, + type DeltaSeries, + type DriverDeltaResult, +} from '../../lib/delta' +import '../../styles/delta-graph.css' + +export type { DeltaSeries } + +export interface DeltaTimeGraphProps { + series: DeltaSeries[] + referenceLabel?: string +} + +const W = 640 +const H = 220 +const PL = 44 +const PR = 16 +const PT = 12 +const PB = 28 + +function lapCount(series: ReadonlyArray): number { + return series.reduce((max, s) => Math.max(max, s.lapTimes.length), 0) +} + +function yExtent(drivers: ReadonlyArray): { min: number; max: number } { + let min = 0 + let max = 0 + for (const driver of drivers) { + for (const delta of driver.deltas) { + if (delta === null) continue + min = Math.min(min, delta) + max = Math.max(max, delta) + } + } + if (min === max) { + const pad = 1 + return { min: min - pad, max: max + pad } + } + const span = max - min + const pad = span * 0.08 + return { min: min - pad, max: max + pad } +} + +function niceYTicks(min: number, max: number): number[] { + const span = max - min + if (span <= 0) return [0] + const rough = span / 4 + const magnitude = Math.pow(10, Math.floor(Math.log10(rough))) + const step = Math.ceil(rough / magnitude) * magnitude + const ticks: number[] = [] + const start = Math.ceil(min / step) * step + for (let v = start; v <= max + step * 0.01; v += step) { + ticks.push(Number(v.toFixed(6))) + } + if (!ticks.some((t) => Math.abs(t) < step * 0.01)) { + ticks.push(0) + ticks.sort((a, b) => a - b) + } + return ticks +} + +export function DeltaTimeGraph({ series, referenceLabel }: DeltaTimeGraphProps) { + const svgRef = useRef(null) + const [hoverLap, setHoverLap] = useState(null) + + const drivers = useMemo( + () => computeCumulativeDeltas(series, referenceLabel), + [series, referenceLabel], + ) + + const laps = useMemo(() => lapCount(series), [series]) + const plotW = W - PL - PR + const plotH = H - PT - PB + const { min: yMin, max: yMax } = useMemo(() => yExtent(drivers), [drivers]) + const yTicks = useMemo(() => niceYTicks(yMin, yMax), [yMin, yMax]) + + const toX = useCallback( + (lapIndex: number) => { + if (laps <= 1) return PL + plotW / 2 + return PL + (lapIndex / (laps - 1)) * plotW + }, + [laps, plotW], + ) + + const toY = useCallback( + (delta: number) => { + const span = yMax - yMin || 1 + return PT + ((delta - yMin) / span) * plotH + }, + [yMin, yMax, plotH], + ) + + const lapTickNumbers = useMemo(() => { + const ticks: number[] = [] + for (let lap = 5; lap <= laps; lap += 5) { + ticks.push(lap) + } + return ticks + }, [laps]) + + const handlePointerMove = useCallback( + (e: React.PointerEvent | React.MouseEvent) => { + if (!svgRef.current || laps === 0) return + const rect = svgRef.current.getBoundingClientRect() + if (rect.width <= 0) return + const x = ((e.clientX - rect.left) / rect.width) * W + const frac = Math.max(0, Math.min(1, (x - PL) / plotW)) + const lapIndex = laps <= 1 ? 0 : Math.round(frac * (laps - 1)) + if (!Number.isFinite(lapIndex)) return + setHoverLap(Math.max(0, Math.min(laps - 1, lapIndex))) + }, + [laps, plotW], + ) + + const handlePointerLeave = useCallback(() => setHoverLap(null), []) + + if (series.length === 0 || laps === 0) { + return ( +
+

No lap data to compare.

+
+ ) + } + + if (drivers.length === 0) { + return ( +
+

Select at least two drivers to compare.

+
+ ) + } + + const hoverX = hoverLap !== null ? toX(hoverLap) : null + const tooltipRows = hoverLap !== null + ? drivers + .map((d) => { + const delta = d.deltas[hoverLap] + if (delta == null) return null + return { label: d.label, color: d.color, delta } + }) + .filter((row): row is { label: string; color: string; delta: number } => row !== null) + : [] + + const tooltipH = 18 + tooltipRows.length * 14 + const tooltipW = 120 + const tooltipX = hoverX !== null ? Math.min(Math.max(hoverX + 8, PL), W - PR - tooltipW) : 0 + const tooltipY = PT + + return ( +
+ + {yTicks.map((tick) => ( + + + + {formatDeltaSeconds(tick)} + + + ))} + + {lapTickNumbers.map((lap) => { + const x = toX(lap - 1) + return ( + + + + {lap} + + + ) + })} + + {drivers.map((driver) => { + const segments = deltaPolylineSegments(driver.deltas, (lapIndex, delta) => + `${toX(lapIndex).toFixed(1)},${toY(delta).toFixed(1)}`, + ) + return ( + + {segments.map((points, i) => ( + + ))} + + ) + })} + + {hoverX !== null && ( + <> + + {tooltipRows.length > 0 && ( + + + + Lap {hoverLap! + 1} + + {tooltipRows.map((row, i) => ( + + {row.label} {formatDeltaSeconds(row.delta)} + + ))} + + )} + + )} + + + +
+ ) +} diff --git a/frontend/src/lib/delta.ts b/frontend/src/lib/delta.ts new file mode 100644 index 0000000..eb8fc64 --- /dev/null +++ b/frontend/src/lib/delta.ts @@ -0,0 +1,112 @@ +// Cumulative lap-time delta math for driver comparison charts. +// Pure functions only — no React — so everything is unit-testable. + +export interface DeltaSeries { + label: string + color: string + /** Lap duration in seconds; null = no time (pit/out lap). */ + lapTimes: (number | null)[] +} + +export interface DriverDeltaResult { + label: string + color: string + /** Cumulative delta vs reference (seconds); null when that lap has no time. */ + deltas: (number | null)[] +} + +/** Format a delta value for axis labels and tooltips (e.g. "+2.5s", "-1.2s"). */ +export function formatDeltaSeconds(delta: number): string { + const sign = delta >= 0 ? '+' : '' + return `${sign}${delta.toFixed(1)}s` +} + +function buildCumulative(lapTimes: ReadonlyArray): number[] { + const cumulative: number[] = [] + let running = 0 + for (const lap of lapTimes) { + if (lap !== null) { + running += lap + } + cumulative.push(running) + } + return cumulative +} + +function resolveReference( + series: ReadonlyArray, + referenceLabel?: string, +): DeltaSeries | null { + if (series.length === 0) return null + if (referenceLabel) { + return series.find((s) => s.label === referenceLabel) ?? series[0] + } + return series[0] +} + +/** + * 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). + */ +export function computeCumulativeDeltas( + series: ReadonlyArray, + referenceLabel?: string, +): DriverDeltaResult[] { + const reference = resolveReference(series, referenceLabel) + if (!reference) return [] + + const refCumulative = buildCumulative(reference.lapTimes) + + return series + .filter((s) => s.label !== reference.label) + .map((driver) => { + const driverCumulative = buildCumulative(driver.lapTimes) + 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) { + 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) + } + + return { + label: driver.label, + color: driver.color, + deltas, + } + }) +} + +/** Split delta samples into contiguous SVG polyline point strings (gaps at null laps). */ +export function deltaPolylineSegments( + deltas: ReadonlyArray, + toPoint: (lapIndex: number, delta: number) => string, +): string[] { + const segments: string[] = [] + let current: string[] = [] + + for (let i = 0; i < deltas.length; i++) { + const value = deltas[i] + if (value === null) { + if (current.length > 0) { + segments.push(current.join(' ')) + current = [] + } + continue + } + current.push(toPoint(i, value)) + } + + if (current.length > 0) { + segments.push(current.join(' ')) + } + + return segments +} diff --git a/frontend/src/styles/delta-graph.css b/frontend/src/styles/delta-graph.css new file mode 100644 index 0000000..e3fafd5 --- /dev/null +++ b/frontend/src/styles/delta-graph.css @@ -0,0 +1,76 @@ +.delta-graph { + width: 100%; + min-width: 280px; + max-width: 640px; +} + +.delta-graph-svg { + display: block; + width: 100%; +} + +.delta-graph-empty { + padding: 1.5rem 1rem; + text-align: center; + color: var(--text-3); + font-size: 0.875rem; +} + +.delta-graph-zero-line { + stroke: var(--text-3); + stroke-width: 1; + stroke-dasharray: 4 3; +} + +.delta-graph-grid-line { + stroke: var(--border); + stroke-width: 0.5; +} + +.delta-graph-axis-label { + fill: var(--text-3); + font-family: var(--f-mono); + font-size: 9px; +} + +.delta-graph-driver-line { + fill: none; + stroke-width: 2; + stroke-linejoin: round; + stroke-linecap: round; +} + +.delta-graph-crosshair { + stroke: var(--text-2); + stroke-width: 1; + stroke-dasharray: 3 3; + pointer-events: none; +} + +.delta-graph-hover-layer { + cursor: crosshair; + touch-action: none; +} + +.delta-graph-tooltip { + pointer-events: none; +} + +.delta-graph-tooltip-bg { + fill: var(--bg-elevated, var(--bg)); + stroke: var(--border); + stroke-width: 1; +} + +.delta-graph-tooltip-title { + fill: var(--text-1); + font-family: var(--f-mono); + font-size: 10px; + font-weight: 600; +} + +.delta-graph-tooltip-row { + fill: var(--text-2); + font-family: var(--f-mono); + font-size: 9px; +} diff --git a/frontend/src/test/delta-time-graph.test.tsx b/frontend/src/test/delta-time-graph.test.tsx new file mode 100644 index 0000000..291d136 --- /dev/null +++ b/frontend/src/test/delta-time-graph.test.tsx @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { DeltaTimeGraph } from '../components/charts/DeltaTimeGraph' +import { + computeCumulativeDeltas, + deltaPolylineSegments, + formatDeltaSeconds, + type DeltaSeries, +} from '../lib/delta' + +const reference: DeltaSeries = { + label: 'VER', + color: '#3671C6', + lapTimes: [90, 91, 92], +} + +const challenger: DeltaSeries = { + label: 'HAM', + color: '#E8002D', + lapTimes: [89, 92, 90], +} + +describe('formatDeltaSeconds', () => { + it('formats signed deltas with one decimal and s suffix', () => { + expect(formatDeltaSeconds(2.5)).toBe('+2.5s') + expect(formatDeltaSeconds(-1.2)).toBe('-1.2s') + expect(formatDeltaSeconds(0)).toBe('+0.0s') + }) +}) + +describe('computeCumulativeDeltas', () => { + it('computes cumulative delta vs the first series by default', () => { + const result = computeCumulativeDeltas([reference, challenger]) + expect(result).toHaveLength(1) + expect(result[0].label).toBe('HAM') + // Lap 1: 89-90=-1, Lap 2: (89+92)-(90+91)=0, Lap 3: (89+92+90)-(90+91+92)=-2 + expect(result[0].deltas[0]).toBeCloseTo(-1) + expect(result[0].deltas[1]).toBeCloseTo(0) + expect(result[0].deltas[2]).toBeCloseTo(-2) + }) + + it('respects an explicit reference label', () => { + const result = computeCumulativeDeltas([reference, challenger], 'HAM') + expect(result).toHaveLength(1) + expect(result[0].label).toBe('VER') + expect(result[0].deltas[0]).toBeCloseTo(1) + expect(result[0].deltas[2]).toBeCloseTo(2) + }) + + it('emits null for missing lap times while carrying cumulative forward', () => { + const withNull: DeltaSeries = { + label: 'NOR', + color: '#FF8000', + lapTimes: [88, null, 93], + } + const result = computeCumulativeDeltas([reference, withNull]) + expect(result[0].deltas[0]).toBeCloseTo(-2) + expect(result[0].deltas[1]).toBeNull() + // After null: NOR cum=181, VER cum=273 → delta -92 + expect(result[0].deltas[2]).toBeCloseTo(-92) + }) + + it('returns an empty array when only one series is provided', () => { + expect(computeCumulativeDeltas([reference])).toEqual([]) + }) +}) + +describe('deltaPolylineSegments', () => { + it('splits polylines at null laps', () => { + const segments = deltaPolylineSegments( + [1, null, 2], + (lap, delta) => `${lap},${delta}`, + ) + expect(segments).toEqual(['0,1', '2,2']) + }) +}) + +describe('DeltaTimeGraph', () => { + it('renders an empty state with no series', () => { + render() + expect(screen.getByTestId('delta-time-graph-empty')).toBeInTheDocument() + expect(screen.getByText(/No lap data/i)).toBeInTheDocument() + }) + + it('renders an empty state with only the reference driver', () => { + render() + expect(screen.getByTestId('delta-time-graph-empty')).toBeInTheDocument() + expect(screen.getByText(/at least two drivers/i)).toBeInTheDocument() + }) + + it('renders a zero line and one polyline per non-reference driver', () => { + const { container } = render() + expect(screen.getByTestId('delta-time-graph')).toBeInTheDocument() + expect(container.querySelector('[data-testid="delta-zero-line"]')).toBeInTheDocument() + expect(screen.getByTestId('delta-line-HAM')).toBeInTheDocument() + expect(container.querySelectorAll('.delta-graph-driver-line')).toHaveLength(1) + expect(screen.queryByTestId('delta-line-VER')).not.toBeInTheDocument() + }) + + it('shows a crosshair tooltip on hover', () => { + vi.spyOn(SVGSVGElement.prototype, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + width: 640, + height: 220, + right: 640, + bottom: 220, + toJSON: () => ({}), + }) + const { container } = render() + const hoverLayer = container.querySelector('.delta-graph-hover-layer') + expect(hoverLayer).toBeTruthy() + fireEvent.mouseMove(hoverLayer!, { clientX: 44, clientY: 100 }) + expect(screen.getByTestId('delta-crosshair')).toBeInTheDocument() + expect(screen.getByTestId('delta-tooltip')).toBeInTheDocument() + expect(screen.getByText(/Lap 1/)).toBeInTheDocument() + vi.restoreAllMocks() + }) +}) From 98126a06f90c0dcc5b82b7ca25222502cacc7755 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Fri, 3 Jul 2026 23:53:13 -0400 Subject: [PATCH 3/6] docs(agents): mark agy adapter unreliable for headless dispatch Co-Authored-By: Claude Fable 5 --- .agents/harnesses.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.agents/harnesses.sh b/.agents/harnesses.sh index cef91c5..0142a2a 100644 --- a/.agents/harnesses.sh +++ b/.agents/harnesses.sh @@ -31,11 +31,13 @@ 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 — Gemini 3.1 Pro, headless full-auto - # --new-project is required for --model to take effect (otherwise agy resumes the - # previous conversation and silently keeps its old model). +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 --new-project --print-timeout 60m \ + ( cd "$dir" && agy --print --print-timeout 60m \ --model="Gemini 3.1 Pro (High)" --dangerously-skip-permissions "$(cat "$prompt")" ) } From 6c72c128217f6ea593af52e082362146793ca022 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Fri, 3 Jul 2026 23:56:19 -0400 Subject: [PATCH 4/6] feat(live): add team radio ticker Subscribe to TeamRadio on the F1 live feed, parse defensive capture snapshots and patches, and relay capped captures through live snapshots with the SessionInfo static path. Add the frontend CDN URL helper and ticker using one shared audio element. --- .../src/components/live/TeamRadioTicker.tsx | 108 ++++++++++++++++++ frontend/src/lib/radio.ts | 22 ++++ frontend/src/pages/LiveTimingPage.tsx | 6 + frontend/src/styles/team-radio.css | 82 +++++++++++++ frontend/src/test/LiveComponents.test.tsx | 1 + frontend/src/test/TeamRadioTicker.test.tsx | 87 ++++++++++++++ frontend/src/test/live.test.ts | 13 ++- frontend/src/types.ts | 8 ++ internal/live/parser_test.go | 69 ++++++++++- internal/live/signalr.go | 3 +- internal/live/state.go | 72 ++++++++++++ internal/live/types.go | 9 ++ 12 files changed, 472 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/live/TeamRadioTicker.tsx create mode 100644 frontend/src/lib/radio.ts create mode 100644 frontend/src/styles/team-radio.css create mode 100644 frontend/src/test/TeamRadioTicker.test.tsx diff --git a/frontend/src/components/live/TeamRadioTicker.tsx b/frontend/src/components/live/TeamRadioTicker.tsx new file mode 100644 index 0000000..459e299 --- /dev/null +++ b/frontend/src/components/live/TeamRadioTicker.tsx @@ -0,0 +1,108 @@ +import { useMemo, useRef, useState } from 'react' +import { Pause, Play } from 'lucide-react' +import type { LiveDriverInfo, LiveRadioCapture, LiveSessionMeta } from '../../types' +import { radioCaptureKey, radioClipUrl } from '../../lib/radio' +import { teamColor } from '../../utils' +import '../../styles/team-radio.css' + +interface Props { + captures?: LiveRadioCapture[] + driverInfo?: Record + session?: LiveSessionMeta +} + +function relativeTime(utc: string): string { + const timestamp = new Date(utc).getTime() + if (!utc || Number.isNaN(timestamp)) return '--' + + const diffSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000)) + if (diffSeconds < 60) return `${diffSeconds}s ago` + const minutes = Math.floor(diffSeconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + return new Date(utc).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) +} + +function driverLabel(info: LiveDriverInfo | undefined, racingNumber: string): string { + return info?.Tla || racingNumber +} + +export function TeamRadioTicker({ captures = [], driverInfo = {}, session }: Props) { + const audioRef = useRef(null) + const [playingKey, setPlayingKey] = useState(null) + + const newestFirst = useMemo(() => [...captures].reverse(), [captures]) + + const toggleCapture = (capture: LiveRadioCapture) => { + const key = radioCaptureKey(capture) + const audio = audioRef.current + const url = radioClipUrl(session, capture) + if (!audio || !url) return + + if (playingKey === key) { + audio.pause() + setPlayingKey(null) + return + } + + audio.pause() + audio.src = url + setPlayingKey(key) + const play = audio.play() + if (play) { + play.catch(() => setPlayingKey(null)) + } + } + + return ( +
+
+ Team Radio + {captures.length > 0 && {captures.length} clips} +
+ +
+ ) +} diff --git a/frontend/src/lib/radio.ts b/frontend/src/lib/radio.ts new file mode 100644 index 0000000..6ff21d9 --- /dev/null +++ b/frontend/src/lib/radio.ts @@ -0,0 +1,22 @@ +import type { LiveRadioCapture, LiveSessionMeta } from '../types' + +export const LIVE_TIMING_STATIC_BASE = 'https://livetiming.formula1.com/static/' + +export function radioClipUrl( + session: Pick | null | undefined, + capture: Pick | null | undefined, +): string { + const sessionPath = session?.Path?.trim() + const capturePath = capture?.Path?.trim() + if (!sessionPath || !capturePath) return '' + if (/^https?:\/\//i.test(capturePath)) return capturePath + + const base = LIVE_TIMING_STATIC_BASE.replace(/\/+$/, '') + const normalizedSession = sessionPath.replace(/^\/+|\/+$/g, '') + const normalizedCapture = capturePath.replace(/^\/+/g, '') + return `${base}/${normalizedSession}/${normalizedCapture}` +} + +export function radioCaptureKey(capture: LiveRadioCapture): string { + return `${capture.Utc}-${capture.RacingNumber}-${capture.Path}` +} diff --git a/frontend/src/pages/LiveTimingPage.tsx b/frontend/src/pages/LiveTimingPage.tsx index fd478eb..a824453 100644 --- a/frontend/src/pages/LiveTimingPage.tsx +++ b/frontend/src/pages/LiveTimingPage.tsx @@ -21,6 +21,7 @@ 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 { TeamRadioTicker } from '../components/live/TeamRadioTicker' import { TrackMap } from '../components/live/TrackMap' import { Radio } from 'lucide-react' @@ -211,6 +212,11 @@ export function LiveTimingPage() { />
+
diff --git a/frontend/src/styles/team-radio.css b/frontend/src/styles/team-radio.css new file mode 100644 index 0000000..152a405 --- /dev/null +++ b/frontend/src/styles/team-radio.css @@ -0,0 +1,82 @@ +.team-radio { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.team-radio-audio { + display: none; +} + +.team-radio-list { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 260px; + overflow-y: auto; + padding-right: 2px; +} + +.team-radio-row { + display: grid; + grid-template-columns: 28px minmax(44px, 56px) minmax(68px, 1fr); + align-items: center; + gap: 8px; + min-height: 34px; + padding: 6px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.team-radio-row:last-child { + border-bottom: 0; +} + +.team-radio-play { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 50%; + color: var(--text-1); + background: rgba(255, 255, 255, 0.06); + cursor: pointer; +} + +.team-radio-play:hover:not(:disabled), +.team-radio-play:focus-visible:not(:disabled) { + border-color: rgba(255, 255, 255, 0.36); + background: rgba(255, 255, 255, 0.12); +} + +.team-radio-play:disabled { + color: var(--text-3); + cursor: not-allowed; + opacity: 0.55; +} + +.team-radio-driver { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 44px; + height: 24px; + border-left: 3px solid var(--text-3); + color: var(--text-1); + font-family: var(--font-mono); + font-size: 12px; + font-weight: 700; + letter-spacing: 0; +} + +.team-radio-time { + min-width: 0; + color: var(--text-2); + font-family: var(--font-mono); + font-size: 12px; + text-align: right; + white-space: nowrap; +} diff --git a/frontend/src/test/LiveComponents.test.tsx b/frontend/src/test/LiveComponents.test.tsx index c3df461..7af18bc 100644 --- a/frontend/src/test/LiveComponents.test.tsx +++ b/frontend/src/test/LiveComponents.test.tsx @@ -201,6 +201,7 @@ describe('TimingTower', () => { CircuitName: 'Silverstone', SessionType: 'Sprint Qualifying', SessionName: 'Sprint Qualifying', + Path: '', }} />, ) diff --git a/frontend/src/test/TeamRadioTicker.test.tsx b/frontend/src/test/TeamRadioTicker.test.tsx new file mode 100644 index 0000000..02569a1 --- /dev/null +++ b/frontend/src/test/TeamRadioTicker.test.tsx @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import { TeamRadioTicker } from '../components/live/TeamRadioTicker' +import { radioClipUrl } from '../lib/radio' +import type { LiveDriverInfo, LiveRadioCapture, LiveSessionMeta } from '../types' + +const session: LiveSessionMeta = { + MeetingName: 'British Grand Prix', + CircuitName: 'Silverstone', + SessionType: 'Race', + SessionName: 'Race', + Path: '/2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/', +} + +const driverInfo: Record = { + '4': { + RacingNumber: '4', + BroadcastName: 'L NORRIS', + Tla: 'NOR', + TeamName: 'McLaren', + TeamColour: 'ff8000', + FirstName: 'Lando', + LastName: 'Norris', + }, + '16': { + RacingNumber: '16', + BroadcastName: 'C LECLERC', + Tla: 'LEC', + TeamName: 'Ferrari', + TeamColour: 'e8002d', + FirstName: 'Charles', + LastName: 'Leclerc', + }, +} + +const captures: LiveRadioCapture[] = [ + { Utc: '2026-07-05T14:05:00Z', RacingNumber: '16', Path: '/TeamRadio/LEC-1.mp3' }, + { Utc: '2026-07-05T14:07:00Z', RacingNumber: '4', Path: 'TeamRadio/NOR-1.mp3' }, +] + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('radioClipUrl', () => { + it('builds static CDN URLs from the session path and capture path', () => { + expect(radioClipUrl(session, captures[0])).toBe( + 'https://livetiming.formula1.com/static/2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/TeamRadio/LEC-1.mp3', + ) + }) + + it('returns an empty URL without path data', () => { + expect(radioClipUrl({ ...session, Path: '' }, captures[0])).toBe('') + expect(radioClipUrl(session, { ...captures[0], Path: '' })).toBe('') + }) +}) + +describe('TeamRadioTicker', () => { + it('renders captures newest first with driver labels', () => { + vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-05T14:10:00Z')) + + render() + + const rows = screen.getAllByRole('button') + expect(rows).toHaveLength(2) + expect(screen.getByText('NOR')).toBeInTheDocument() + expect(screen.getByText('LEC')).toBeInTheDocument() + expect(screen.getAllByText(/m ago/).map((node) => node.textContent)).toEqual(['3m ago', '5m ago']) + }) + + it('uses one audio element and toggles play/pause per clip', () => { + const play = vi.spyOn(window.HTMLMediaElement.prototype, 'play').mockResolvedValue() + const pause = vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => {}) + + render() + + fireEvent.click(screen.getByLabelText('Play NOR radio')) + expect(play).toHaveBeenCalledTimes(1) + expect(document.querySelectorAll('audio')).toHaveLength(1) + expect(document.querySelector('audio')?.getAttribute('src')).toBe( + 'https://livetiming.formula1.com/static/2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/TeamRadio/NOR-1.mp3', + ) + + fireEvent.click(screen.getByLabelText('Pause NOR radio')) + expect(pause).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/test/live.test.ts b/frontend/src/test/live.test.ts index 28b90ef..68b331b 100644 --- a/frontend/src/test/live.test.ts +++ b/frontend/src/test/live.test.ts @@ -100,7 +100,8 @@ const snapshot: LiveStreamData = { { Time: '14:08', Category: 'Drs', Flag: '', Message: 'DRS ENABLED', Lap: 3 }, ], Weather: { AirTemp: 20, TrackTemp: 31, Humidity: 55, WindSpeed: 2, WindDir: 180, Rainfall: false }, - Session: { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race' }, + Session: { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race', Path: '2026/Monaco/Race' }, + TeamRadio: [], TrackStatus: '1', CurrentLap: 21, TotalLaps: 78, @@ -220,7 +221,7 @@ describe('track status mapping', () => { describe('live qualifying display', () => { it('puts the SQ1 cutoff after P17 for a 22-car sprint qualifying session', () => { const display = liveSessionDisplay( - { MeetingName: 'British Grand Prix', CircuitName: 'Silverstone', SessionType: 'Sprint Qualifying', SessionName: 'Sprint Qualifying' }, + { MeetingName: 'British Grand Prix', CircuitName: 'Silverstone', SessionType: 'Sprint Qualifying', SessionName: 'Sprint Qualifying', Path: '' }, rows(22), ) expect(display.phaseLabel).toBe('SQ1') @@ -232,7 +233,7 @@ describe('live qualifying display', () => { it('keeps the normal Q1 cutoff after P15 for a 20-car qualifying session', () => { const display = liveSessionDisplay( - { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying' }, + { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying', Path: '' }, rows(20), ) expect(display.phaseLabel).toBe('Q1') @@ -241,7 +242,7 @@ describe('live qualifying display', () => { it('moves phase 2 cutoff after P10 once five cars are knocked out', () => { const display = liveSessionDisplay( - { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying' }, + { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying', Path: '' }, rows(20, 5), ) expect(display.phaseLabel).toBe('Q2') @@ -251,13 +252,13 @@ describe('live qualifying display', () => { it('shows no cutoff for race sessions or Q3', () => { expect( liveSessionDisplay( - { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race' }, + { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race', Path: '' }, rows(20), ).cutoffPosition, ).toBeNull() expect( liveSessionDisplay( - { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Q3' }, + { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Q3', Path: '' }, rows(10), ).cutoffPosition, ).toBeNull() diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 68e4929..790c7e8 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -261,6 +261,7 @@ export interface LiveSessionMeta { CircuitName: string SessionType: string SessionName: string + Path: string } export interface LiveStintData { @@ -269,6 +270,12 @@ export interface LiveStintData { Laps: number } +export interface LiveRadioCapture { + Utc: string + RacingNumber: string + Path: string +} + export interface LiveStreamData { Drivers: Record DriverInfo: Record @@ -277,6 +284,7 @@ export interface LiveStreamData { RCMessages: LiveRCMessage[] Weather: LiveWeatherData Session: LiveSessionMeta + TeamRadio: LiveRadioCapture[] TrackStatus: string CurrentLap: number TotalLaps: number diff --git a/internal/live/parser_test.go b/internal/live/parser_test.go index 5667a45..4f60166 100644 --- a/internal/live/parser_test.go +++ b/internal/live/parser_test.go @@ -5,6 +5,7 @@ import ( "compress/flate" "encoding/base64" "encoding/json" + "fmt" "testing" "time" @@ -211,12 +212,16 @@ func TestProcessTopicSessionInfoCircuitName(t *testing.T) { state.ProcessTopic("SessionInfo", json.RawMessage(`{ "Meeting": {"Name": "British Grand Prix", "Circuit": {"ShortName": "Silverstone"}}, "Name": "Race", - "Type": "Race" + "Type": "Race", + "Path": "2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/" }`)) s := state.Snapshot().Session if s.MeetingName != "British Grand Prix" || s.CircuitName != "Silverstone" { t.Fatalf("session = %+v", s) } + if s.Path != "2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/" { + t.Fatalf("session path = %q", s.Path) + } } func TestProcessTopicDriverList(t *testing.T) { @@ -285,6 +290,68 @@ func TestProcessTopicRaceControlMessages(t *testing.T) { } } +func TestProcessTopicTeamRadioSnapshotAndPatch(t *testing.T) { + state := live.NewState() + snapshot := json.RawMessage(`{ + "Captures": [ + {"Utc": "2026-07-05T14:05:30Z", "RacingNumber": "4", "Path": "TeamRadio/NOR-1.mp3"}, + {"Utc": "2026-07-05T14:04:10Z", "RacingNumber": "16", "Path": "TeamRadio/LEC-1.mp3"} + ] + }`) + + if !state.ProcessTopic("TeamRadio", snapshot) { + t.Fatal("TeamRadio snapshot should update state") + } + + patch := json.RawMessage(`{ + "Captures": { + "2": {"Utc": "2026-07-05T14:06:00Z", "RacingNumber": "44", "Path": "TeamRadio/HAM-1.mp3"} + } + }`) + if !state.ProcessTopic("TeamRadio", patch) { + t.Fatal("TeamRadio keyed patch should update state") + } + + radio := state.Snapshot().TeamRadio + if len(radio) != 3 { + t.Fatalf("radio captures = %d, want 3", len(radio)) + } + if radio[0].RacingNumber != "16" || radio[1].RacingNumber != "4" || radio[2].RacingNumber != "44" { + t.Fatalf("radio order = %+v", radio) + } +} + +func TestProcessTopicTeamRadioMalformed(t *testing.T) { + state := live.NewState() + if state.ProcessTopic("TeamRadio", json.RawMessage(`{"Captures": {"1": {"Utc": "2026-07-05T14:06:00Z", "Path": "missing-driver.mp3"}}}`)) { + t.Fatal("incomplete TeamRadio capture should not update state") + } + if state.ProcessTopic("TeamRadio", json.RawMessage(`{"Captures": "not-a-list"}`)) { + t.Fatal("unexpected TeamRadio captures shape should not update state") + } + if len(state.Snapshot().TeamRadio) != 0 { + t.Fatalf("malformed captures mutated state: %+v", state.Snapshot().TeamRadio) + } +} + +func TestProcessTopicTeamRadioCapsAtTwenty(t *testing.T) { + state := live.NewState() + for i := 0; i < 25; i++ { + payload := json.RawMessage([]byte(fmt.Sprintf(`{ + "Captures": [{"Utc": "2026-07-05T14:%02d:00Z", "RacingNumber": "%d", "Path": "TeamRadio/%02d.mp3"}] + }`, i, i, i))) + state.ProcessTopic("TeamRadio", payload) + } + + radio := state.Snapshot().TeamRadio + if len(radio) != 20 { + t.Fatalf("radio captures = %d, want 20", len(radio)) + } + if radio[0].Utc != "2026-07-05T14:05:00Z" || radio[19].Utc != "2026-07-05T14:24:00Z" { + t.Fatalf("radio cap kept wrong captures: first=%+v last=%+v", radio[0], radio[19]) + } +} + func TestProcessTopicWeatherData(t *testing.T) { state := live.NewState() state.ProcessTopic("WeatherData", json.RawMessage(`{ diff --git a/internal/live/signalr.go b/internal/live/signalr.go index e9b8342..9265d70 100644 --- a/internal/live/signalr.go +++ b/internal/live/signalr.go @@ -105,6 +105,7 @@ func connectToF1SignalRCore(dataChan chan LiveStreamData) error { "RaceControlMessages", "WeatherData", "SessionInfo", + "TeamRadio", "CurrentTyres", "TimingAppData", "TimingStats", @@ -196,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","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"]],"I":1}`) err = c.WriteMessage(websocket.TextMessage, subscribeMsg) if err != nil { return err diff --git a/internal/live/state.go b/internal/live/state.go index bd7d8d4..08d1488 100644 --- a/internal/live/state.go +++ b/internal/live/state.go @@ -8,6 +8,8 @@ import ( "encoding/json" "fmt" "io" + "log" + "sort" "strings" "time" ) @@ -23,6 +25,7 @@ type State struct { RCMessages []LiveRCMessage Weather LiveWeatherData Session LiveSessionMeta + TeamRadio []LiveRadioCapture TrackStatus string CurrentLap int TotalLaps int @@ -34,6 +37,7 @@ type State struct { } const signalRRecordSeparator = byte(0x1e) +const maxTeamRadioCaptures = 20 // NewState returns an empty live timing accumulator. func NewState() *State { @@ -71,6 +75,8 @@ func (s *State) Snapshot() LiveStreamData { } cpyRC := make([]LiveRCMessage, len(s.RCMessages)) copy(cpyRC, s.RCMessages) + cpyRadio := make([]LiveRadioCapture, len(s.TeamRadio)) + copy(cpyRadio, s.TeamRadio) cpyStints := make(map[string][]LiveStintData, len(s.Stints)) for k, v := range s.Stints { st := make([]LiveStintData, len(v)) @@ -86,6 +92,7 @@ func (s *State) Snapshot() LiveStreamData { RCMessages: cpyRC, Weather: s.Weather, Session: s.Session, + TeamRadio: cpyRadio, TrackStatus: s.TrackStatus, CurrentLap: s.CurrentLap, TotalLaps: s.TotalLaps, @@ -335,6 +342,7 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool { } `json:"Meeting"` Name string `json:"Name"` Type string `json:"Type"` + Path string `json:"Path"` } if json.Unmarshal(data, &si) == nil { if si.Meeting.Name != "" { @@ -349,8 +357,13 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool { if si.Type != "" { s.Session.SessionType = si.Type } + if si.Path != "" { + s.Session.Path = si.Path + } updated = true } + case "TeamRadio": + updated = s.updateTeamRadio(data) case "CurrentTyres": var ct map[string]json.RawMessage if json.Unmarshal(data, &ct) == nil { @@ -483,6 +496,62 @@ func readCompressed(r io.ReadCloser, err error) ([]byte, bool) { return out, err == nil } +func (s *State) updateTeamRadio(data json.RawMessage) bool { + var payload struct { + Captures json.RawMessage `json:"Captures"` + } + if err := json.Unmarshal(data, &payload); err != nil { + log.Printf("live: skipping malformed TeamRadio payload: %v", err) + return false + } + if len(payload.Captures) == 0 || string(payload.Captures) == "null" { + log.Printf("live: skipping TeamRadio payload without Captures") + return false + } + + captures := indexedRawValues(payload.Captures) + if len(captures) == 0 { + log.Printf("live: skipping TeamRadio payload with unexpected Captures shape") + return false + } + + updated := false + for _, captureRaw := range captures { + var capture LiveRadioCapture + if err := json.Unmarshal(captureRaw.Raw, &capture); err != nil { + log.Printf("live: skipping malformed TeamRadio capture: %v", err) + continue + } + if capture.Utc == "" || capture.RacingNumber == "" || capture.Path == "" { + log.Printf("live: skipping incomplete TeamRadio capture: utc=%q racing_number=%q path=%q", capture.Utc, capture.RacingNumber, capture.Path) + continue + } + if s.hasTeamRadioCapture(capture) { + continue + } + s.TeamRadio = append(s.TeamRadio, capture) + updated = true + } + if updated { + sort.SliceStable(s.TeamRadio, func(i, j int) bool { + return s.TeamRadio[i].Utc < s.TeamRadio[j].Utc + }) + if len(s.TeamRadio) > maxTeamRadioCaptures { + s.TeamRadio = append([]LiveRadioCapture(nil), s.TeamRadio[len(s.TeamRadio)-maxTeamRadioCaptures:]...) + } + } + return updated +} + +func (s *State) hasTeamRadioCapture(capture LiveRadioCapture) bool { + for _, existing := range s.TeamRadio { + if existing.Utc == capture.Utc && existing.RacingNumber == capture.RacingNumber && existing.Path == capture.Path { + return true + } + } + return false +} + func (s *State) updatePositions(data json.RawMessage) bool { var payload struct { Position json.RawMessage `json:"Position"` @@ -799,6 +868,9 @@ func indexedRawValues(raw json.RawMessage) []indexedRaw { fmt.Sscanf(k, "%d", &i) values = append(values, indexedRaw{Index: i, Raw: v}) } + sort.Slice(values, func(i, j int) bool { + return values[i].Index < values[j].Index + }) return values } diff --git a/internal/live/types.go b/internal/live/types.go index 72e047c..4d62f6d 100644 --- a/internal/live/types.go +++ b/internal/live/types.go @@ -85,6 +85,7 @@ type LiveSessionMeta struct { CircuitName string SessionType string SessionName string + Path string } // LivePositionData is the latest raw F1 GPS position for one driver. @@ -144,6 +145,13 @@ type LiveStintData struct { Laps int } +// LiveRadioCapture is one team radio audio clip from the live timing feed. +type LiveRadioCapture struct { + Utc string + RacingNumber string + Path string +} + // LiveStreamData is an immutable snapshot of all live timing state. type LiveStreamData struct { Drivers map[string]LiveDriverData @@ -153,6 +161,7 @@ type LiveStreamData struct { RCMessages []LiveRCMessage Weather LiveWeatherData Session LiveSessionMeta + TeamRadio []LiveRadioCapture TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC CurrentLap int TotalLaps int From ba7f653e3c0834b0248c73da860d14981cf71b2f Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Fri, 3 Jul 2026 23:59:38 -0400 Subject: [PATCH 5/6] feat(frontend): add session-aware Command Center hero (#14) Replace the static weekend band with a three-state hero (live, upcoming, between weekends) driven by heroState() and existing page queries. Between-weekend podium uses the existing race-hub endpoint for the last ingested race session since championship hub does not expose finish positions. Co-authored-by: Cursor --- frontend/src/components/CommandCenterHero.tsx | 415 ++++++++++++++++++ frontend/src/lib/hero.ts | 27 ++ frontend/src/pages/CommandCenterPage.tsx | 228 +++------- frontend/src/styles/hero.css | 373 ++++++++++++++++ frontend/src/test/CommandCenterPage.test.tsx | 64 ++- frontend/src/test/hero.test.ts | 123 ++++++ frontend/src/test/hero.test.tsx | 257 +++++++++++ tests/command-center.spec.ts | 6 +- 8 files changed, 1323 insertions(+), 170 deletions(-) create mode 100644 frontend/src/components/CommandCenterHero.tsx create mode 100644 frontend/src/lib/hero.ts create mode 100644 frontend/src/styles/hero.css create mode 100644 frontend/src/test/hero.test.ts create mode 100644 frontend/src/test/hero.test.tsx diff --git a/frontend/src/components/CommandCenterHero.tsx b/frontend/src/components/CommandCenterHero.tsx new file mode 100644 index 0000000..a67391f --- /dev/null +++ b/frontend/src/components/CommandCenterHero.tsx @@ -0,0 +1,415 @@ +import { Link } from '@tanstack/react-router' +import { Activity, ArrowRight, Play } from 'lucide-react' +import { sessionTypeAbbrev } from '../lib/coverage' +import { countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity' +import { classifySessionStatus, type HeroStateKind } from '../lib/hero' +import { sortLiveTimingRows, trackStatusInfo } from '../lib/live' +import { + formatCountdown, + formatSessionScheduleTime, + meetingStartTime, + sessionStartTime, + type FocusMeetingKind, +} from '../lib/schedule' +import type { EnrichedResult, LiveStreamData, Meeting, Session, WeekendSession } from '../types' +import '../styles/hero.css' + +export interface CommandCenterHeroProps { + state: HeroStateKind + now: Date + accent: string + liveActive: boolean + liveData: LiveStreamData | null | undefined + focusMeeting: Meeting + focusKind: FocusMeetingKind + sessions: WeekendSession[] + currentSession: Session | null + nextSession: Session | null + analysisSessionKey?: number + analysisSessionName?: string + lastRaceName: string + lastRacePodium: EnrichedResult[] + lastRaceSessionKey?: number + nextMeeting: Meeting | null +} + +export function CommandCenterHero({ + state, + now, + accent, + liveActive, + liveData, + focusMeeting, + focusKind, + sessions, + currentSession, + nextSession, + analysisSessionKey, + analysisSessionName, + lastRaceName, + lastRacePodium, + lastRaceSessionKey, + nextMeeting, +}: CommandCenterHeroProps) { + const accentStyle = { + '--gp-accent': accent, + '--hero-accent': accent, + } as React.CSSProperties + + return ( +
+
+
+
+ ) +} + +function LiveHero({ + liveActive, + liveData, + currentSession, + focusMeeting, +}: { + liveActive: boolean + liveData: LiveStreamData | null | undefined + currentSession: Session | null + focusMeeting: Meeting +}) { + const sessionName = + liveData?.Session?.SessionName ?? currentSession?.session_name ?? 'Live session' + const trackStatus = trackStatusInfo(liveData?.TrackStatus) + const topThree = sortLiveTimingRows(liveData).filter((r) => r.Position > 0).slice(0, 3) + const decal = countryDecal(focusMeeting) + + return ( + <> +
+ {decal} +
+
● Live now
+

{sessionName}

+
+ {[focusMeeting.meeting_name, focusMeeting.circuit_short_name].filter(Boolean).join(' · ')} +
+
+ {trackStatus.label} +
+
+ {topThree.length > 0 && ( +
+ {topThree.map((row) => ( +
+ + P{row.Position} + + + + {row.Info?.Tla ?? row.RacingNumber} + + + {row.Position === 1 ? 'LEAD' : row.Driver.Interval || row.Driver.GapToLeader || '—'} + +
+ ))} +
+ )} +
+
{liveActive ? 'SignalR' : 'On track'}
+
LIVE
+
{sessionName}
+
+
+
+ + Open Live Timing + +
+ + ) +} + +function UpcomingHero({ + now, + focusMeeting, + focusKind, + sessions, + currentSession, + nextSession, + liveActive, + analysisSessionKey, + analysisSessionName, +}: { + now: Date + focusMeeting: Meeting + focusKind: FocusMeetingKind + sessions: WeekendSession[] + currentSession: Session | null + nextSession: Session | null + liveActive: boolean + analysisSessionKey?: number + analysisSessionName?: string +}) { + const decal = countryDecal(focusMeeting) + const countdownTarget = + nextSession && sessionStartTime(nextSession) + ? sessionStartTime(nextSession)! + : meetingStartTime(focusMeeting) + + const kindLabel = + focusKind === 'current' + ? 'Current weekend' + : focusKind === 'next' + ? 'Next weekend' + : 'Weekend' + + const sortedSessions = [...sessions].sort((a, b) => { + const left = sessionStartTime(a.session)?.getTime() ?? 0 + const right = sessionStartTime(b.session)?.getTime() ?? 0 + return left - right + }) + + return ( + <> +
+ {decal} +
+
{kindLabel}
+

{focusMeeting.meeting_name}

+
+ {[focusMeeting.location, focusMeeting.circuit_short_name].filter(Boolean).join(' · ')} +
+
{formatGpDateRange(focusMeeting)}
+
+
+ {nextSession ? ( + <> +
Next · {nextSession.session_name}
+ {countdownTarget && ( +
{formatCountdown(countdownTarget, now)}
+ )} +
{formatSessionScheduleTime(nextSession.date_start)}
+ + ) : ( + <> +
Status
+
Complete
+
Weekend finished
+ + )} +
+
+ + {sortedSessions.length > 0 && ( +
+ {sortedSessions.map(({ session }) => { + const status = classifySessionStatus(session, now) + const isNext = nextSession?.session_key === session.session_key + const isCurrent = currentSession?.session_key === session.session_key + const isLive = isCurrent && liveActive + return ( + +
+ {sessionTypeAbbrev(session.session_type, session.session_name)} +
+
{session.session_name}
+
+ {formatSessionScheduleTime(session.date_start)} +
+
+ {isCurrent ? 'On track' : status === 'done' ? 'Done' : isNext ? 'Next' : 'Upcoming'} +
+ + ) + })} +
+ )} + +
+ + Watch Live + + {analysisSessionKey != null && ( + + Open Analysis + {analysisSessionName ? ` · ${analysisSessionName}` : ''} + + )} +
+ + ) +} + +function BetweenHero({ + now, + lastRaceName, + lastRacePodium, + lastRaceSessionKey, + nextMeeting, + analysisSessionKey, + analysisSessionName, +}: { + now: Date + lastRaceName: string + lastRacePodium: EnrichedResult[] + lastRaceSessionKey?: number + nextMeeting: Meeting | null + analysisSessionKey?: number + analysisSessionName?: string +}) { + const nextStart = nextMeeting ? meetingStartTime(nextMeeting) : null + const podium = lastRacePodium + .filter((r) => r.position >= 1 && r.position <= 3) + .sort((a, b) => a.position - b.position) + + return ( + <> +
+
+
Between race weekends
+ {lastRaceName && ( +

+ After {lastRaceName} +

+ )} + {!lastRaceName &&

Season pause

} +
+ + {podium.length > 0 && ( +
+ {podium.map((r) => ( +
+ + P{r.position} + + + + {r.name_acronym} + +
+ ))} +
+ )} + + {nextMeeting && ( +
+
+ {countryFlag(nextMeeting) && ( + + )} +
+
Next GP
+
+ {nextMeeting.meeting_name} +
+
+
+ {nextStart && ( + <> +
+ {formatCountdown(nextStart, now)} +
+
{formatGpDateRange(nextMeeting)}
+ + )} +
+ )} +
+ +
+ {lastRaceSessionKey != null && ( + + View {lastRaceName || 'last race'} results → + + )} + {analysisSessionKey != null && analysisSessionKey !== lastRaceSessionKey && ( + + Open Analysis{analysisSessionName ? ` · ${analysisSessionName}` : ''} + + )} + + Live Timing → + +
+ + ) +} diff --git a/frontend/src/lib/hero.ts b/frontend/src/lib/hero.ts new file mode 100644 index 0000000..3c12636 --- /dev/null +++ b/frontend/src/lib/hero.ts @@ -0,0 +1,27 @@ +import type { Session } from '../types' +import { sessionEndTime, sessionStartTime, type FocusMeetingKind } from './schedule' + +export type HeroStateKind = 'live' | 'upcoming' | 'between' + +export interface HeroStateInputs { + now: Date + liveActive: boolean + currentSession: Session | null + focusKind: FocusMeetingKind | null +} + +export function classifySessionStatus(session: Session, now: Date): 'live' | 'done' | 'upcoming' { + const start = sessionStartTime(session) + const end = sessionEndTime(session) + if (start && end && now >= start && now < end) return 'live' + if (start && now >= start) return 'done' + return 'upcoming' +} + +export function heroState(inputs: HeroStateInputs): HeroStateKind { + const { liveActive, currentSession, focusKind } = inputs + + if (liveActive || currentSession) return 'live' + if (focusKind === 'current') return 'upcoming' + return 'between' +} diff --git a/frontend/src/pages/CommandCenterPage.tsx b/frontend/src/pages/CommandCenterPage.tsx index 7f54907..29c7493 100644 --- a/frontend/src/pages/CommandCenterPage.tsx +++ b/frontend/src/pages/CommandCenterPage.tsx @@ -1,38 +1,38 @@ import { useEffect, useMemo, useState } from 'react' import { useQueries, useQuery } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' -import { fetchLiveState, fetchLocalMeetings, fetchSeasonMeetings, fetchSeasons, fetchSessions, fetchWeekend, fetchChampionshipHub } from '../api' +import { + fetchChampionshipHub, + fetchLiveState, + fetchLocalMeetings, + fetchRaceHub, + fetchSeasonMeetings, + fetchSeasons, + fetchSessions, + fetchWeekend, +} from '../api' +import { CommandCenterHero } from '../components/CommandCenterHero' +import { PaddockBriefing } from '../components/PaddockBriefing' import { RACE_HUB_DATASETS, countWeekendStats, formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage' +import { countryAccent, countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity' +import { classifySessionStatus, heroState } from '../lib/hero' import { currentAndNextSession, focusMeetingKind, - formatCountdown, formatSessionScheduleTime, meetingHasStarted, + mostRecentPastMeeting, + nextUpcomingMeeting, pickFocusMeeting, - sessionEndTime, - sessionStartTime, sortSessionsByStart, } from '../lib/schedule' -import { countryAccent, countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity' import type { Meeting, Session, Weekend, WeekendSession } from '../types' -import { PaddockBriefing } from '../components/PaddockBriefing' -import { Play, Activity, Calendar, Trophy } from 'lucide-react' - -type WeekendStatusKind = 'live' | 'current' | 'next' | 'recent' | 'fallback' +import { Trophy } from 'lucide-react' const missingDatasets = Object.fromEntries( RACE_HUB_DATASETS.map((dataset) => [dataset, { status: 'missing', source: 'none', count: 0 }]), ) as WeekendSession['datasets'] -function classifySessionStatus(session: Session, now: Date): 'live' | 'done' | 'upcoming' { - const start = sessionStartTime(session) - const end = sessionEndTime(session) - if (start && end && now >= start && now < end) return 'live' - if (start && now >= start) return 'done' - return 'upcoming' -} - function meetingStatus(meeting: Meeting, focusKey: number | undefined, now: Date) { if (meeting.meeting_key === focusKey) return 'focus' if (meetingHasStarted(meeting, now)) return 'past' @@ -148,7 +148,36 @@ export function CommandCenterPage() { focusWeekendSessions[0]?.session const analysisSessionKey = actionSession?.session_key - const weekendsLoading = weekendQueries.some((q) => q.isLoading) + const liveActive = liveQuery.data?.is_live === true + const heroStateKind = heroState({ + now: nowDate, + liveActive, + currentSession, + focusKind, + }) + + const lastPastMeeting = useMemo( + () => mostRecentPastMeeting(focusMeetings, nowDate), + [focusMeetings, nowDate], + ) + const lastPastWeekend = lastPastMeeting ? weekendsByKey.get(lastPastMeeting.meeting_key) : undefined + const lastRaceAnalysis = pickAnalysisSession(lastPastWeekend) + const lastRaceSessionKey = lastRaceAnalysis?.session.session_key + + const lastRaceHubQuery = useQuery({ + queryKey: ['race-hub', lastRaceSessionKey, 'hero-podium'], + queryFn: () => fetchRaceHub(lastRaceSessionKey!), + enabled: lastRaceSessionKey != null && heroStateKind === 'between', + staleTime: 60_000, + }) + + const nextMeetingForHero = useMemo(() => { + if (heroStateKind !== 'between') return null + return nextUpcomingMeeting(focusMeetings, nowDate) + }, [heroStateKind, focusMeetings, nowDate]) + + const lastRacePodium = lastRaceHubQuery.data?.results ?? [] + const lastRaceName = champHub?.last_race ?? lastPastMeeting?.meeting_name ?? '' if (seasonsQuery.isLoading) { return
loading command center…
@@ -189,19 +218,7 @@ export function CommandCenterPage() { ) } - const liveActive = liveQuery.data?.is_live === true - const statusKind: WeekendStatusKind = liveActive - ? 'live' - : focusKind === 'current' - ? 'current' - : focusKind === 'next' - ? 'next' - : focusKind === 'recent' - ? 'recent' - : 'fallback' - const accent = countryAccent(focusMeeting ?? null) - const decal = countryDecal(focusMeeting ?? null) const accentStyle = { '--gp-accent': accent } as React.CSSProperties return ( @@ -224,76 +241,25 @@ export function CommandCenterPage() { )} - {focusMeeting && ( -
-
-
- -
- - Watch Live - {liveActive ? 'Feed active' : 'Standby'} - - - Open Analysis - - {actionSession - ? `${actionSession.session_name} · session ${actionSession.session_key}` - : 'Pick a session'} - - - {nextSession && sessionStartTime(nextSession) && ( - { - e.preventDefault() - document.getElementById('cc-schedule')?.scrollIntoView({ behavior: 'smooth' }) - }} - > - Schedule - - next: {nextSession.session_name} - - - )} -
-
+ {focusMeeting && focusKind && ( + )}
@@ -483,65 +449,3 @@ function FormSparkline({ form, color }: { form: number[], color: string }) { ) } -function WeekendKindLabel({ kind }: { kind: WeekendStatusKind }) { - switch (kind) { - case 'live': - return ● Live now - case 'current': - return Current weekend - case 'next': - return Next weekend - case 'recent': - return Recent weekend - default: - return Weekend - } -} - -interface CountdownBlockProps { - liveActive: boolean - currentSession: Session | null - nextSession: Session | null - meeting: Meeting - now: Date -} - -function CountdownBlock({ liveActive, currentSession, nextSession, meeting, now }: CountdownBlockProps) { - if (liveActive) { - return ( -
-
SignalR
-
LIVE
-
{currentSession?.session_name ?? 'Feed connected'}
-
- ) - } - if (currentSession) { - return ( -
-
On Track
-
{currentSession.session_name}
-
In session
-
- ) - } - if (nextSession && sessionStartTime(nextSession)) { - return ( -
-
Next · {nextSession.session_name}
-
{formatCountdown(sessionStartTime(nextSession)!, now)}
-
{formatSessionScheduleTime(nextSession.date_start)}
-
- ) - } - if (meetingHasStarted(meeting, now)) { - return ( -
-
Status
-
Complete
-
Weekend finished
-
- ) - } - return null -} diff --git a/frontend/src/styles/hero.css b/frontend/src/styles/hero.css new file mode 100644 index 0000000..7f1a8c1 --- /dev/null +++ b/frontend/src/styles/hero.css @@ -0,0 +1,373 @@ +.hero-panel { + display: flex; + flex-direction: column; + gap: var(--s4); +} + +.hero-card { + display: flex; + background: var(--surface); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + overflow: hidden; + min-height: 124px; +} + +.hero-accent { + width: 4px; + flex-shrink: 0; + box-shadow: 0 0 12px var(--hero-accent, var(--gp-accent)); + background: var(--hero-accent, var(--gp-accent)); +} + +.hero-accent--live { + animation: hero-pulse 1.4s ease-in-out infinite; +} + +@keyframes hero-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } +} + +.hero-body { + flex: 1; + padding: var(--s5); + min-width: 0; + position: relative; + overflow: hidden; + background: + radial-gradient(ellipse at top right, color-mix(in srgb, var(--gp-accent) 15%, transparent), transparent 60%), + linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px), + linear-gradient(to bottom, rgba(255, 255, 255, 0.03) 1px, transparent 1px), + var(--surface); + background-size: 100% 100%, 20px 20px, 20px 20px, 100% 100%; +} + +.hero-body::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent 0%, rgba(0, 0, 0, 0.3) 100%); + pointer-events: none; +} + +.hero-inner { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + gap: var(--s5); +} + +.hero-row { + display: flex; + align-items: stretch; + gap: var(--s6); + min-width: 0; + flex-wrap: wrap; +} + +.hero-identity { + flex: 1; + min-width: 0; +} + +.hero-decal { + font-size: 56px; + font-weight: 800; + line-height: 0.85; + letter-spacing: -0.04em; + color: color-mix(in srgb, var(--gp-accent) 35%, var(--text-3)); + opacity: 0.9; + flex-shrink: 0; +} + +.hero-eyebrow { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-3); + margin-bottom: var(--s2); +} + +.hero-eyebrow--live { + color: var(--red); +} + +.hero-title { + font-size: 28px; + font-weight: 800; + letter-spacing: -0.02em; + line-height: 1.1; + margin: 0; +} + +.hero-sub { + font-size: 12px; + color: var(--text-2); + margin-top: var(--s2); +} + +.hero-countdown { + text-align: right; + flex-shrink: 0; +} + +.hero-cd-label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-3); + margin-bottom: var(--s2); +} + +.hero-cd-value { + font-size: 22px; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--text); +} + +.hero-cd-value--live { + color: var(--red); + font-size: 28px; + letter-spacing: 0.08em; +} + +.hero-cd-sub { + font-size: 11px; + color: var(--text-3); + margin-top: var(--s2); +} + +.hero-track-status { + display: inline-flex; + align-items: center; + gap: var(--s3); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + padding: var(--s2) var(--s3); + border-radius: 4px; + margin-top: var(--s3); +} + +.hero-track-status--green { color: #3dd68c; background: rgba(61, 214, 140, 0.1); } +.hero-track-status--yellow { color: #f5c842; background: rgba(245, 200, 66, 0.1); } +.hero-track-status--sc, +.hero-track-status--vsc { color: #f5a623; background: rgba(245, 166, 35, 0.12); } +.hero-track-status--red { color: var(--red); background: rgba(225, 6, 0, 0.12); } +.hero-track-status--unknown { color: var(--text-3); background: rgba(255, 255, 255, 0.05); } + +.hero-timing { + display: flex; + flex-direction: column; + gap: var(--s2); + min-width: 200px; +} + +.hero-timing-row { + display: grid; + grid-template-columns: 28px 1fr auto; + align-items: center; + gap: var(--s3); + padding: var(--s2) var(--s3); + background: rgba(255, 255, 255, 0.03); + border-radius: 6px; + font-size: 13px; +} + +.hero-timing-pos { + font-weight: 700; + color: var(--text-3); + font-variant-numeric: tabular-nums; +} + +.hero-timing-pos--p1 { color: #ffd700; } +.hero-timing-pos--p2 { color: #c0c0c0; } +.hero-timing-pos--p3 { color: #cd7f32; } + +.hero-timing-bar { + width: 3px; + height: 14px; + border-radius: 2px; + flex-shrink: 0; +} + +.hero-timing-driver { + display: flex; + align-items: center; + gap: var(--s3); + font-weight: 600; + font-family: var(--f-mono); +} + +.hero-timing-gap { + font-size: 11px; + color: var(--text-3); + font-family: var(--f-mono); + font-variant-numeric: tabular-nums; +} + +.hero-cta { + display: inline-flex; + align-items: center; + gap: var(--s3); + font-size: 14px; + font-weight: 700; + color: var(--red); + text-decoration: none; + transition: opacity 0.1s; +} + +.hero-cta:hover { + opacity: 0.85; +} + +.hero-cta--primary { + padding: var(--s3) var(--s5); + background: rgba(225, 6, 0, 0.12); + border: 1px solid rgba(225, 6, 0, 0.35); + border-radius: 6px; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: var(--s3); + align-items: center; +} + +.hero-secondary-link { + font-size: 12px; + font-weight: 600; + color: var(--text-2); + text-decoration: none; +} + +.hero-secondary-link:hover { + color: var(--text); +} + +.hero-schedule-strip { + display: flex; + gap: var(--s3); + overflow-x: auto; + padding-bottom: var(--s2); +} + +.hero-schedule-card { + flex: 0 0 auto; + min-width: 120px; + padding: var(--s3) var(--s4); + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.02); + text-decoration: none; + color: inherit; + transition: background 0.1s, border-color 0.1s; +} + +.hero-schedule-card:hover { + background: rgba(255, 255, 255, 0.05); +} + +.hero-schedule-card.is-next { + border-color: color-mix(in srgb, var(--gp-accent) 50%, transparent); + background: color-mix(in srgb, var(--gp-accent) 8%, transparent); +} + +.hero-schedule-card.is-done { + opacity: 0.55; +} + +.hero-schedule-card.is-live { + border-color: rgba(225, 6, 0, 0.4); + background: rgba(225, 6, 0, 0.08); +} + +.hero-schedule-abbrev { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + color: var(--text-3); +} + +.hero-schedule-name { + font-size: 12px; + font-weight: 600; + margin: var(--s2) 0; +} + +.hero-schedule-time { + font-size: 10px; + color: var(--text-3); +} + +.hero-schedule-marker { + font-size: 9px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + margin-top: var(--s2); + color: var(--text-3); +} + +.hero-schedule-marker--next { color: var(--gp-accent); } +.hero-schedule-marker--live { color: var(--red); } + +.hero-podium { + display: flex; + flex-direction: column; + gap: var(--s2); + min-width: 200px; +} + +.hero-podium-row { + display: grid; + grid-template-columns: 32px 1fr; + align-items: center; + gap: var(--s3); + padding: var(--s2) var(--s3); + background: rgba(255, 255, 255, 0.03); + border-radius: 6px; + font-size: 13px; +} + +.hero-podium-pos { + font-weight: 700; + font-family: var(--f-mono); + color: var(--text-3); +} + +.hero-podium-pos--p1 { color: #ffd700; } +.hero-podium-pos--p2 { color: #c0c0c0; } +.hero-podium-pos--p3 { color: #cd7f32; } + +.hero-podium-driver { + display: flex; + align-items: center; + gap: var(--s3); + font-weight: 600; + font-family: var(--f-mono); +} + +.hero-between-next { + display: flex; + align-items: center; + gap: var(--s4); +} + +.hero-flag { + font-size: 32px; + line-height: 1; +} + +@media (min-width: 700px) { + .hero-row { + flex-wrap: nowrap; + } +} diff --git a/frontend/src/test/CommandCenterPage.test.tsx b/frontend/src/test/CommandCenterPage.test.tsx index 4ec99f8..1519c59 100644 --- a/frontend/src/test/CommandCenterPage.test.tsx +++ b/frontend/src/test/CommandCenterPage.test.tsx @@ -12,9 +12,20 @@ vi.mock('../api', () => ({ fetchSessions: vi.fn(), fetchWeekend: vi.fn(), fetchLiveState: vi.fn(), + fetchChampionshipHub: vi.fn(), + fetchRaceHub: vi.fn(), })) -import { fetchSeasons, fetchLocalMeetings, fetchSeasonMeetings, fetchSessions, fetchWeekend, fetchLiveState } from '../api' +import { + fetchSeasons, + fetchLocalMeetings, + fetchSeasonMeetings, + fetchSessions, + fetchWeekend, + fetchLiveState, + fetchChampionshipHub, + fetchRaceHub, +} from '../api' const mockFetchSeasons = vi.mocked(fetchSeasons) const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings) @@ -22,6 +33,8 @@ const mockFetchSeasonMeetings = vi.mocked(fetchSeasonMeetings) const mockFetchSessions = vi.mocked(fetchSessions) const mockFetchWeekend = vi.mocked(fetchWeekend) const mockFetchLiveState = vi.mocked(fetchLiveState) +const mockFetchChampionshipHub = vi.mocked(fetchChampionshipHub) +const mockFetchRaceHub = vi.mocked(fetchRaceHub) const meeting: Meeting = { meeting_key: 1229, @@ -102,6 +115,48 @@ describe('CommandCenterPage', () => { vi.clearAllMocks() mockFetchLiveState.mockResolvedValue({ is_live: false, data: null }) mockFetchSessions.mockResolvedValue([]) + mockFetchChampionshipHub.mockResolvedValue({ + season: 2025, + round: 1, + total_rounds: 1, + rounds_left: 0, + last_race: 'Monaco', + round_labels: ['R1'], + drivers: [], + teams: [], + }) + mockFetchRaceHub.mockResolvedValue({ + source: 'local', + session_key: 9472, + datasets: fullDatasets, + results: [ + { + 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: 7200, + gap_to_leader: null, + number_of_laps: 78, + points: 25, + session_key: 9472, + meeting_key: 1229, + }, + ], + starting_grid: [], + drivers: [], + stints: [], + pit_stops: [], + positions: [], + race_control: [], + weather: [], + laps: [], + }) }) it('shows empty state when no seasons are ingested', async () => { @@ -131,11 +186,10 @@ describe('CommandCenterPage', () => { expect(screen.getByTestId('cc-session-9472')).toBeInTheDocument() }) expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco') - expect(screen.getByTestId('cc-focus')).toHaveTextContent('MON') expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar') expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01') expect(screen.getByText('No live session')).toBeInTheDocument() - expect(screen.getByTestId('cc-action-race-hub')).toHaveTextContent('Race') + expect(screen.getByTestId('hero-last-race-link')).toHaveTextContent('Monaco') }) it('uses OpenF1 calendar metadata to focus the current weekend when local ingest is behind', async () => { @@ -181,9 +235,9 @@ describe('CommandCenterPage', () => { await waitFor(() => { expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco') }) - expect(screen.getByTestId('cc-focus')).toHaveTextContent('Current weekend') + expect(screen.getByTestId('cc-focus')).toHaveTextContent('Live now') expect(screen.getByTestId('cc-session-9602')).toHaveTextContent('On track') - expect(screen.getByTestId('cc-action-race-hub')).toHaveTextContent('Qualifying') + expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live') vi.useRealTimers() }) diff --git a/frontend/src/test/hero.test.ts b/frontend/src/test/hero.test.ts new file mode 100644 index 0000000..21af353 --- /dev/null +++ b/frontend/src/test/hero.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest' +import { heroState, classifySessionStatus } from '../lib/hero' +import { currentAndNextSession } from '../lib/schedule' +import type { Session } from '../types' + +const session = (overrides: Partial = {}): Session => ({ + session_key: 9472, + session_name: 'Race', + session_type: 'Race', + meeting_key: 1, + date_start: '2025-05-25T13:00:00+00:00', + date_end: '2025-05-25T15:00:00+00:00', + gmt_offset: '02:00:00', + ...overrides, +}) + +describe('heroState', () => { + it('returns live when the live snapshot is active', () => { + const now = new Date('2025-05-24T12:00:00Z') + expect( + heroState({ + now, + liveActive: true, + currentSession: null, + focusKind: 'current', + }), + ).toBe('live') + }) + + it('returns live when a session is in progress', () => { + const now = new Date('2025-05-25T14:00:00Z') + const sessions = [session()] + const { current } = currentAndNextSession(sessions, now) + expect( + heroState({ + now, + liveActive: false, + currentSession: current, + focusKind: 'current', + }), + ).toBe('live') + }) + + it('returns upcoming during a focus weekend gap before the next session', () => { + const now = new Date('2025-05-24T12:00:00Z') + const sessions = [ + session({ + session_key: 1, + session_name: 'FP1', + date_start: '2025-05-23T10:00:00+00:00', + date_end: '2025-05-23T11:00:00+00:00', + }), + session({ + session_key: 2, + session_name: 'Race', + date_start: '2025-05-25T13:00:00+00:00', + date_end: '2025-05-25T15:00:00+00:00', + }), + ] + const { current, next } = currentAndNextSession(sessions, now) + expect(current).toBeNull() + expect(next?.session_key).toBe(2) + expect( + heroState({ + now, + liveActive: false, + currentSession: current, + focusKind: 'current', + }), + ).toBe('upcoming') + }) + + it('returns upcoming when the weekend just ended but is still the focus weekend', () => { + const now = new Date('2025-05-25T16:00:00Z') + const sessions = [session()] + const { current } = currentAndNextSession(sessions, now) + expect(current).toBeNull() + expect( + heroState({ + now, + liveActive: false, + currentSession: current, + focusKind: 'current', + }), + ).toBe('upcoming') + }) + + it('returns between before the next grand prix weekend', () => { + const now = new Date('2025-06-01T12:00:00Z') + expect( + heroState({ + now, + liveActive: false, + currentSession: null, + focusKind: 'next', + }), + ).toBe('between') + }) + + it('returns between after the season when only a recent meeting remains', () => { + const now = new Date('2026-01-01T00:00:00Z') + expect( + heroState({ + now, + liveActive: false, + currentSession: null, + focusKind: 'recent', + }), + ).toBe('between') + }) +}) + +describe('classifySessionStatus', () => { + it('marks a session as done immediately after it ends', () => { + const now = new Date('2025-05-25T15:00:00Z') + expect(classifySessionStatus(session(), now)).toBe('done') + }) + + it('marks a session as live during its window', () => { + const now = new Date('2025-05-25T14:00:00Z') + expect(classifySessionStatus(session(), now)).toBe('live') + }) +}) diff --git a/frontend/src/test/hero.test.tsx b/frontend/src/test/hero.test.tsx new file mode 100644 index 0000000..3db06d1 --- /dev/null +++ b/frontend/src/test/hero.test.tsx @@ -0,0 +1,257 @@ +import { describe, it, expect, vi } from 'vitest' +import type { ComponentProps, ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import type { EnrichedResult, LiveStreamData, Meeting, WeekendSession } from '../types' + +vi.mock('@tanstack/react-router', () => ({ + Link: ({ + to, + children, + ...rest + }: { + to: string + children: ReactNode + className?: string + 'data-testid'?: string + }) => ( + + {children} + + ), +})) + +import { CommandCenterHero } from '../components/CommandCenterHero' + +const focusMeeting: Meeting = { + meeting_key: 1229, + meeting_name: 'Monaco', + meeting_official_name: 'FORMULA 1 GRAND PRIX DE MONACO 2025', + location: 'Monaco', + country_name: 'Monaco', + country_code: 'MON', + country_flag: '', + circuit_short_name: 'Monaco', + date_start: '2025-05-23T00:00:00+00:00', + date_end: '2025-05-25T00:00:00+00:00', + year: 2025, +} + +const weekendSessions: WeekendSession[] = [ + { + session: { + session_key: 9472, + session_name: 'Race', + session_type: 'Race', + meeting_key: 1229, + date_start: '2025-05-25T13:00:00+00:00', + date_end: '2025-05-25T15:00:00+00:00', + gmt_offset: '02:00:00', + }, + source: 'local', + datasets: {} as WeekendSession['datasets'], + }, +] + +const liveData: LiveStreamData = { + Drivers: { + '1': { + RacingNumber: '1', + Position: 1, + PrevPosition: 1, + GapToLeader: '', + Interval: '', + LastLapTime: '1:14.000', + LastLapPB: false, + LastLapOB: false, + BestLapTime: '1:13.500', + BestLapPB: false, + BestLapOB: false, + BestLapNum: 10, + InPit: false, + PitOut: false, + Retired: false, + KnockedOut: false, + Cutoff: false, + OnFlyingLap: false, + NumberOfLaps: 10, + SpeedTrap: '', + Sectors: [], + }, + '44': { + RacingNumber: '44', + Position: 2, + PrevPosition: 2, + GapToLeader: '+1.2', + Interval: '+1.2', + LastLapTime: '1:14.200', + LastLapPB: false, + LastLapOB: false, + BestLapTime: '1:13.700', + BestLapPB: false, + BestLapOB: false, + BestLapNum: 10, + InPit: false, + PitOut: false, + Retired: false, + KnockedOut: false, + Cutoff: false, + OnFlyingLap: false, + NumberOfLaps: 10, + SpeedTrap: '', + Sectors: [], + }, + }, + DriverInfo: { + '1': { + RacingNumber: '1', + BroadcastName: 'M VERSTAPPEN', + Tla: 'VER', + TeamName: 'Red Bull Racing', + TeamColour: '3671c6', + FirstName: 'Max', + LastName: 'Verstappen', + }, + '44': { + RacingNumber: '44', + BroadcastName: 'L HAMILTON', + Tla: 'HAM', + TeamName: 'Ferrari', + TeamColour: 'e8002d', + FirstName: 'Lewis', + LastName: 'Hamilton', + }, + }, + Tyres: {}, + RCMessages: [], + Weather: { + AirTemp: 20, + TrackTemp: 30, + Humidity: 50, + WindSpeed: 1, + WindDir: 0, + Rainfall: false, + }, + Session: { + MeetingName: 'Monaco', + CircuitName: 'Monaco', + SessionType: 'Race', + SessionName: 'Race', + }, + TrackStatus: '1', + CurrentLap: 10, + TotalLaps: 78, + Clock: '', + ClockRefTime: '', + ClockExtrapolating: false, + Stints: {}, +} + +const podium: 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: 7200, + 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: 7205, + gap_to_leader: 3.2, + number_of_laps: 78, + points: 18, + session_key: 9472, + meeting_key: 1229, + }, +] + +function renderHero(overrides: Partial> = {}) { + const props: ComponentProps = { + state: 'upcoming', + now: new Date('2025-05-24T12:00:00Z'), + accent: '#d61a3e', + liveActive: false, + liveData: null, + focusMeeting, + focusKind: 'current', + sessions: weekendSessions, + currentSession: null, + nextSession: weekendSessions[0].session, + lastRaceName: '', + lastRacePodium: [], + nextMeeting: null, + ...overrides, + } + + return render() +} + +describe('CommandCenterHero', () => { + it('renders live timing link and top three in live state', () => { + renderHero({ + state: 'live', + liveActive: true, + liveData, + currentSession: weekendSessions[0].session, + }) + + expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live') + expect(screen.getByTestId('hero-live-timing')).toHaveTextContent('VER') + expect(screen.getByTestId('hero-live-timing')).toHaveTextContent('HAM') + expect(screen.getByText('TRACK CLEAR')).toBeInTheDocument() + }) + + it('renders countdown and schedule strip in upcoming state', () => { + renderHero({ + state: 'upcoming', + nextSession: weekendSessions[0].session, + }) + + expect(screen.getByTestId('hero-countdown')).toHaveTextContent('1d 01h 00m 00s') + expect(screen.getByTestId('hero-schedule-strip')).toHaveTextContent('Race') + expect(screen.getByTestId('hero-schedule-strip')).toHaveTextContent('Next') + }) + + it('renders last race podium and next GP countdown in between state', () => { + const nextMeeting: Meeting = { + ...focusMeeting, + meeting_key: 1301, + meeting_name: 'Canada', + country_code: 'CAN', + date_start: '2025-06-06T00:00:00+00:00', + date_end: '2025-06-08T00:00:00+00:00', + } + + renderHero({ + state: 'between', + lastRaceName: 'Monaco', + lastRacePodium: podium, + nextMeeting, + now: new Date('2025-06-01T12:00:00Z'), + }) + + expect(screen.getByTestId('hero-last-race')).toHaveTextContent('After Monaco') + expect(screen.getByTestId('hero-podium')).toHaveTextContent('VER') + expect(screen.getByTestId('hero-podium')).toHaveTextContent('HAM') + expect(screen.getByTestId('hero-next-gp-countdown')).toHaveTextContent('Canada') + expect(screen.getByTestId('hero-next-gp-countdown')).toHaveTextContent('4d') + }) +}) diff --git a/tests/command-center.spec.ts b/tests/command-center.spec.ts index a744e23..a63e327 100644 --- a/tests/command-center.spec.ts +++ b/tests/command-center.spec.ts @@ -9,7 +9,7 @@ test.describe('Command Center', () => { await expect(page.getByTestId('command-center')).toBeVisible() await expect(page.getByTestId('cc-focus')).toBeVisible() await expect(page.getByTestId('cc-session-9472')).toBeVisible() - await expect(page.getByTestId('cc-actions')).toBeVisible() + await expect(page.getByTestId('hero-last-race-link')).toBeVisible() }) test('nav link reaches command center from race hub', async ({ page }) => { @@ -21,8 +21,8 @@ test.describe('Command Center', () => { test('open analysis action opens race hub for the focus session', async ({ page }) => { await page.goto('/') - await expect(page.getByTestId('cc-action-race-hub')).toContainText(String(FULL_SESSION)) - await page.getByTestId('cc-action-race-hub').click() + await expect(page.getByTestId('hero-last-race-link')).toBeVisible() + await page.getByTestId('hero-last-race-link').click() await expect(page).toHaveURL(new RegExp(`/race-hub\\?session_key=${FULL_SESSION}`)) await expect(page.getByTestId('rh-identity')).toBeVisible() await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible() From eacd5f26e96d97cee4eea916c732d4735751df21 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Fri, 3 Jul 2026 23:59:39 -0400 Subject: [PATCH 6/6] feat(#15): Telemetry trace chart component Implemented by claude via .agents/dev dispatch. --- .../components/charts/TelemetryTraceChart.tsx | 194 ++++++++++++++++++ frontend/src/styles/telemetry-trace.css | 101 +++++++++ .../src/test/telemetry-trace-chart.test.tsx | 118 +++++++++++ 3 files changed, 413 insertions(+) create mode 100644 frontend/src/components/charts/TelemetryTraceChart.tsx create mode 100644 frontend/src/styles/telemetry-trace.css create mode 100644 frontend/src/test/telemetry-trace-chart.test.tsx diff --git a/frontend/src/components/charts/TelemetryTraceChart.tsx b/frontend/src/components/charts/TelemetryTraceChart.tsx new file mode 100644 index 0000000..90d446f --- /dev/null +++ b/frontend/src/components/charts/TelemetryTraceChart.tsx @@ -0,0 +1,194 @@ +import { useMemo, useRef, useState } from 'react' +import '../../styles/telemetry-trace.css' + +export interface TelemetryTraceSample { + speed: number + throttle: number + brake: number +} + +export interface TelemetryTraceSeries { + label: string + color: string + samples: TelemetryTraceSample[] +} + +export type TelemetryTraceChannel = 'speed' | 'throttle' | 'brake' + +export interface TelemetryTraceChartProps { + series: TelemetryTraceSeries[] + channels?: TelemetryTraceChannel[] + /** Height of each channel panel in SVG units (viewBox space). */ + height?: number +} + +const VIEW_WIDTH = 800 +const PANEL_GAP = 18 +const PAD_TOP = 6 +const PAD_BOTTOM = 6 + +const CHANNEL_LABELS: Record = { + speed: 'Speed', + throttle: 'Throttle', + brake: 'Brake', +} + +const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v)) + +function channelValue(sample: TelemetryTraceSample, channel: TelemetryTraceChannel): number { + const raw = sample[channel] + // Throttle/brake are percentages; clamp so out-of-range API values can't + // draw outside the panel. Speed is clamped to >= 0. + if (channel === 'speed') return Math.max(0, raw) + return clamp(raw, 0, 100) +} + +function formatValue(value: number, channel: TelemetryTraceChannel): string { + if (channel === 'speed') return `${Math.round(value)} km/h` + return `${Math.round(value)}%` +} + +export function TelemetryTraceChart({ + series, + channels = ['speed', 'throttle', 'brake'], + height = 110, +}: TelemetryTraceChartProps) { + const svgRef = useRef(null) + const [hoverIndex, setHoverIndex] = useState(null) + + // Series are index-aligned; mismatched lengths are clamped to the shortest + // series so every drawn x has a value for every driver. + const sampleCount = useMemo( + () => (series.length === 0 ? 0 : Math.min(...series.map((s) => s.samples.length))), + [series], + ) + + const speedMax = useMemo(() => { + let max = 0 + for (const s of series) { + for (let i = 0; i < sampleCount; i++) { + max = Math.max(max, channelValue(s.samples[i], 'speed')) + } + } + return max > 0 ? max : 1 + }, [series, sampleCount]) + + if (series.length === 0 || sampleCount === 0 || channels.length === 0) { + return
No telemetry data
+ } + + // Clamp a stale hover index in case the series prop shrank between renders. + const hover = hoverIndex === null ? null : Math.min(hoverIndex, sampleCount - 1) + + const panelHeight = height + const totalHeight = channels.length * panelHeight + (channels.length - 1) * PANEL_GAP + const xAt = (i: number) => (i / Math.max(1, sampleCount - 1)) * VIEW_WIDTH + + const channelMax = (channel: TelemetryTraceChannel) => (channel === 'speed' ? speedMax : 100) + + const yAt = (value: number, channel: TelemetryTraceChannel, panelTop: number) => { + const usable = panelHeight - PAD_TOP - PAD_BOTTOM + const frac = channelValue({ speed: value, throttle: value, brake: value }, channel) / channelMax(channel) + return panelTop + PAD_TOP + (1 - frac) * usable + } + + const handleMouseMove = (e: React.MouseEvent) => { + const rect = svgRef.current?.getBoundingClientRect() + if (!rect || rect.width === 0) { + setHoverIndex(0) + return + } + const frac = clamp((e.clientX - rect.left) / rect.width, 0, 1) + setHoverIndex(clamp(Math.round(frac * (sampleCount - 1)), 0, sampleCount - 1)) + } + + return ( +
+
+ {series.map((s) => ( + + + ))} +
+ setHoverIndex(null)} + > + {channels.map((channel, panelIdx) => { + const panelTop = panelIdx * (panelHeight + PANEL_GAP) + return ( + + + + {CHANNEL_LABELS[channel]} + + + {channel === 'speed' ? `${Math.round(speedMax)} km/h` : '100%'} + + {series.map((s) => { + const points = Array.from({ length: sampleCount }, (_, i) => { + const x = xAt(i) + const y = yAt(s.samples[i][channel], channel, panelTop) + return `${x.toFixed(2)},${y.toFixed(2)}` + }).join(' ') + return ( + + ) + })} + + ) + })} + {hover !== null && ( + + )} + + {hover !== null && ( +
+ Sample {hover} + {series.map((s) => ( + + + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/styles/telemetry-trace.css b/frontend/src/styles/telemetry-trace.css new file mode 100644 index 0000000..87b316f --- /dev/null +++ b/frontend/src/styles/telemetry-trace.css @@ -0,0 +1,101 @@ +.telemetry-trace { + display: flex; + flex-direction: column; + gap: 8px; +} + +.telemetry-trace-empty { + padding: 24px; + text-align: center; + color: var(--text-3); + font-size: 13px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; +} + +.telemetry-trace-legend { + display: flex; + flex-wrap: wrap; + gap: 14px; + font-size: 12px; + color: var(--text-2); +} + +.telemetry-trace-legend-item { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.telemetry-trace-swatch { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 2px; + flex: none; +} + +.telemetry-trace-svg { + display: block; + width: 100%; + height: auto; +} + +.telemetry-trace-panel-bg { + fill: var(--surface); + stroke: var(--border); + stroke-width: 1; + rx: 4; +} + +.telemetry-trace-panel-title { + fill: var(--text-2); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.telemetry-trace-axis-max { + fill: var(--text-3); + font-size: 10px; +} + +.telemetry-trace-crosshair { + stroke: var(--text-2); + stroke-width: 1; + stroke-dasharray: 3 3; + pointer-events: none; +} + +.telemetry-trace-readout { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + padding: 6px 10px; + font-size: 12px; + color: var(--text-2); + background: var(--surface-h); + border: 1px solid var(--border); + border-radius: 6px; +} + +.telemetry-trace-readout-index { + color: var(--text-3); +} + +.telemetry-trace-readout-driver { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.telemetry-trace-readout-label { + color: var(--text); + font-weight: 600; +} + +.telemetry-trace-readout-value { + font-variant-numeric: tabular-nums; +} diff --git a/frontend/src/test/telemetry-trace-chart.test.tsx b/frontend/src/test/telemetry-trace-chart.test.tsx new file mode 100644 index 0000000..1281cb7 --- /dev/null +++ b/frontend/src/test/telemetry-trace-chart.test.tsx @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import { + TelemetryTraceChart, + type TelemetryTraceSeries, +} from '../components/charts/TelemetryTraceChart' + +function makeSeries(label: string, color: string, speeds: number[]): TelemetryTraceSeries { + return { + label, + color, + samples: speeds.map((speed, i) => ({ + speed, + throttle: (i * 25) % 101, + brake: i % 2 === 0 ? 100 : 0, + })), + } +} + +const VER = makeSeries('VER', '#2563eb', [280, 310, 190, 90, 240]) +const LEC = makeSeries('LEC', '#dc2626', [275, 305, 200, 95, 235]) + +describe('TelemetryTraceChart', () => { + it('renders one polyline per series per channel', () => { + const { container } = render() + const lines = container.querySelectorAll('polyline.telemetry-trace-line') + expect(lines).toHaveLength(2 * 3) + for (const channel of ['speed', 'throttle', 'brake']) { + expect( + container.querySelectorAll(`polyline.telemetry-trace-line[data-channel="${channel}"]`), + ).toHaveLength(2) + } + expect( + container.querySelectorAll('polyline.telemetry-trace-line[data-series="VER"]'), + ).toHaveLength(3) + }) + + it('respects the channels prop', () => { + const { container } = render() + expect(container.querySelectorAll('polyline.telemetry-trace-line')).toHaveLength(1) + expect(container.querySelectorAll('.telemetry-trace-panel')).toHaveLength(1) + }) + + it('renders an empty state for no series', () => { + render() + expect(screen.getByText('No telemetry data')).toBeInTheDocument() + }) + + it('renders an empty state when a series has no samples', () => { + render() + expect(screen.getByText('No telemetry data')).toBeInTheDocument() + }) + + it('survives a single sample and mismatched series lengths', () => { + const short = makeSeries('VER', '#2563eb', [300]) + const long = makeSeries('LEC', '#dc2626', [280, 290, 300]) + const { container } = render() + // Clamped to the shortest series: every polyline has exactly one point. + const lines = container.querySelectorAll('polyline.telemetry-trace-line') + expect(lines).toHaveLength(6) + for (const line of lines) { + expect(line.getAttribute('points')!.trim().split(' ')).toHaveLength(1) + } + }) + + it('clamps throttle values to 0-100 within the panel', () => { + const wild: TelemetryTraceSeries = { + label: 'VER', + color: '#2563eb', + samples: [ + { speed: 100, throttle: 150, brake: 0 }, + { speed: 100, throttle: -20, brake: 0 }, + { speed: 100, throttle: 50, brake: 0 }, + ], + } + const { container } = render( + , + ) + const line = container.querySelector('polyline.telemetry-trace-line[data-channel="throttle"]')! + const ys = line + .getAttribute('points')! + .split(' ') + .map((p) => Number(p.split(',')[1])) + // Panel occupies y 0..110 with 6px padding; clamped values pin to the edges. + expect(ys[0]).toBeCloseTo(6, 1) // 150% -> 100% -> panel top + expect(ys[1]).toBeCloseTo(104, 1) // -20% -> 0% -> panel bottom + expect(ys[2]).toBeCloseTo(55, 1) // 50% -> middle + }) + + it('shows the speed axis max in km/h', () => { + render() + expect(screen.getByText('310 km/h')).toBeInTheDocument() + }) + + it('lists every series in the legend with a color swatch', () => { + const { container } = render() + const legend = container.querySelector('.telemetry-trace-legend')! + expect(legend.textContent).toContain('VER') + expect(legend.textContent).toContain('LEC') + expect(legend.querySelectorAll('.telemetry-trace-swatch')).toHaveLength(2) + }) + + it('shows a crosshair and per-driver readout on hover, hides on leave', () => { + const { container } = render() + const svg = container.querySelector('svg.telemetry-trace-svg')! + expect(screen.queryByTestId('telemetry-trace-crosshair')).not.toBeInTheDocument() + + fireEvent.mouseMove(svg, { clientX: 0, clientY: 10 }) + expect(screen.getByTestId('telemetry-trace-crosshair')).toBeInTheDocument() + const readout = screen.getByTestId('telemetry-trace-readout') + expect(readout.textContent).toContain('VER') + expect(readout.textContent).toContain('LEC') + expect(readout.textContent).toContain('km/h') + + fireEvent.mouseLeave(svg) + expect(screen.queryByTestId('telemetry-trace-crosshair')).not.toBeInTheDocument() + }) +})