Compare commits

...

2 Commits

Author SHA1 Message Date
AmanTahiliani
66bf649729 feat(race-hub): add Compare tab with telemetry traces and lap deltas (#10)
Ship driver comparison in the race hub: two pickers (default top finishers),
best-lap telemetry overlays via TelemetryTraceChart, and cumulative pace
delta via DeltaTimeGraph with pit-lap captions. Car data is filtered to each
driver's fastest lap client-side (OpenF1 returns full-session samples).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 00:18:10 -04:00
Aman Tahiliani
2ddc930efe Merge pull request #40 from AmanTahiliani/feat/issue-11-what-just-happened-synthesized-event-rai
"What just happened" synthesized event rail (#11)
2026-07-04 00:10:06 -04:00
9 changed files with 944 additions and 0 deletions

View File

@@ -1,6 +1,8 @@
import type { import type {
ArticleContent, ArticleContent,
CarDataSample,
ChampionshipHub, ChampionshipHub,
LapsComparisonResponse,
LiveStateResponse, LiveStateResponse,
LiveSessionMeta, LiveSessionMeta,
Meeting, Meeting,
@@ -126,3 +128,32 @@ export async function markNewsRead(articleUrl: string): Promise<void> {
body: JSON.stringify({ url: articleUrl }), body: JSON.stringify({ url: articleUrl }),
}) })
} }
export async function fetchTelemetry(
sessionKey: number,
driverNumber: number,
): Promise<CarDataSample[]> {
const res = await fetch(
`/api/v1/telemetry?session_key=${sessionKey}&driver_number=${driverNumber}`,
)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
const data = await res.json()
return Array.isArray(data) ? data : []
}
export async function fetchLapsComparison(
sessionKey: number,
drivers?: number[],
): Promise<LapsComparisonResponse> {
const params = new URLSearchParams({ session_key: String(sessionKey) })
if (drivers?.length) {
params.set('drivers', drivers.join(','))
}
const res = await fetch(`/api/v1/laps/comparison?${params}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}

View File

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

View File

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

202
frontend/src/lib/compare.ts Normal file
View File

@@ -0,0 +1,202 @@
// Driver comparison mapping — pure functions for telemetry traces and lap deltas.
import type { TelemetryTraceSeries } from '../components/charts/TelemetryTraceChart'
import type { DeltaSeries } from '../lib/delta'
import type {
CarDataSample,
ComparisonLap,
Driver,
EnrichedResult,
LapsComparisonResponse,
} from '../types'
import { compareFinishPosition, teamColor } from '../utils'
function sortDriversByResults(drivers: Driver[], results: EnrichedResult[]): Driver[] {
const order = new Map(
[...results]
.filter((r) => r.position > 0)
.sort((a, b) => compareFinishPosition(a.position, b.position))
.map((r, i) => [r.driver_number, i]),
)
return [...drivers].sort((a, b) => {
const ao = order.get(a.driver_number) ?? 999
const bo = order.get(b.driver_number) ?? 999
if (ao !== bo) return ao - bo
return a.driver_number - b.driver_number
})
}
/** Default compare pair: top two classified finishers, else first two session drivers. */
export function defaultCompareDriverNumbers(
results: EnrichedResult[],
drivers: Driver[],
): [number, number] | null {
const classified = [...results]
.filter((r) => r.position > 0)
.sort((a, b) => compareFinishPosition(a.position, b.position))
if (classified.length >= 2) {
return [classified[0].driver_number, classified[1].driver_number]
}
if (drivers.length >= 2) {
const sorted = sortDriversByResults(drivers, results)
return [sorted[0].driver_number, sorted[1].driver_number]
}
if (classified.length === 1 && drivers.length >= 1) {
const other = drivers.find((d) => d.driver_number !== classified[0].driver_number)
if (other) return [classified[0].driver_number, other.driver_number]
}
return null
}
/** Lap records → per-lap duration array (index 0 = lap 1); null for pit/out laps. */
export function lapsToLapTimes(laps: ComparisonLap[]): (number | null)[] {
if (laps.length === 0) return []
const maxLap = laps.reduce((max, lap) => Math.max(max, lap.lap_number), 0)
const times: (number | null)[] = Array.from({ length: maxLap }, () => null)
for (const lap of laps) {
const idx = lap.lap_number - 1
if (idx < 0) continue
if (lap.is_pit_out_lap || lap.lap_duration == null || lap.lap_duration <= 0) {
times[idx] = null
} else {
times[idx] = lap.lap_duration
}
}
return times
}
export function findBestLap(laps: ComparisonLap[]): ComparisonLap | null {
let best: ComparisonLap | null = null
for (const lap of laps) {
if (lap.lap_duration == null || lap.lap_duration <= 0) continue
if (!best || (best.lap_duration ?? Infinity) > lap.lap_duration) {
best = lap
}
}
return best
}
/** Keep car-data samples whose timestamps fall within a lap window. */
export function filterCarDataToLap(
samples: CarDataSample[],
lap: ComparisonLap,
): CarDataSample[] {
if (!lap.date_start || samples.length === 0) return samples
const start = new Date(lap.date_start).getTime()
if (!Number.isFinite(start)) return samples
const end = start + (lap.lap_duration ?? 0) * 1000 + 500
return samples.filter((s) => {
const t = new Date(s.date).getTime()
return Number.isFinite(t) && t >= start && t <= end
})
}
export function carDataToTraceSeries(
samples: CarDataSample[],
label: string,
color: string,
): TelemetryTraceSeries {
return {
label,
color,
samples: samples.map((s) => ({
speed: s.speed,
throttle: s.throttle,
brake: s.brake,
})),
}
}
export function buildBestLapTraceSeries(
carData: CarDataSample[],
laps: ComparisonLap[],
label: string,
color: string,
): TelemetryTraceSeries | null {
const best = findBestLap(laps)
if (!best) return null
const filtered = filterCarDataToLap(carData, best)
if (filtered.length === 0) return null
return carDataToTraceSeries(filtered, label, color)
}
function driverMeta(
driverNumber: number,
comparison: LapsComparisonResponse | undefined,
drivers: Driver[],
): { label: string; color: string; laps: ComparisonLap[] } {
const comp = comparison?.drivers.find((d) => d.driver_number === driverNumber)
const session = drivers.find((d) => d.driver_number === driverNumber)
return {
label: comp?.name_acronym || session?.name_acronym || `#${driverNumber}`,
color: teamColor(comp?.team_colour || session?.team_colour),
laps: comp?.laps ?? [],
}
}
export function comparisonToDeltaSeries(
comparison: LapsComparisonResponse,
driverNumbers: [number, number],
drivers: Driver[],
): DeltaSeries[] {
return driverNumbers.map((dn) => {
const meta = driverMeta(dn, comparison, drivers)
return {
label: meta.label,
color: meta.color,
lapTimes: lapsToLapTimes(meta.laps),
}
})
}
export function formatPitLapsCaption(
pitLaps: Record<string, number[]>,
driverNumbers: [number, number],
comparison: LapsComparisonResponse | undefined,
drivers: Driver[],
): string | null {
const parts: string[] = []
for (const dn of driverNumbers) {
const laps = pitLaps[String(dn)]
if (!laps?.length) continue
const meta = driverMeta(dn, comparison, drivers)
parts.push(`${meta.label}: L${laps.join(', L')}`)
}
return parts.length > 0 ? `Pit stops — ${parts.join(' · ')}` : null
}
export function compareDriverOptions(
drivers: Driver[],
results: EnrichedResult[],
): Driver[] {
if (drivers.length > 0) {
return sortDriversByResults(drivers, results)
}
return results.map((r) => ({
driver_number: r.driver_number,
name_acronym: r.name_acronym,
full_name: r.full_name,
first_name: '',
last_name: '',
team_name: r.team_name,
team_colour: r.team_colour,
headshot_url: '',
broadcast_name: r.full_name,
session_key: r.session_key,
meeting_key: r.meeting_key,
}))
}

View File

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

View File

@@ -0,0 +1,78 @@
.compare-view {
display: flex;
flex-direction: column;
gap: var(--s5);
}
.compare-pickers {
display: flex;
flex-wrap: wrap;
gap: var(--s4);
align-items: flex-end;
}
.compare-picker {
display: flex;
flex-direction: column;
gap: var(--s3);
min-width: 180px;
}
.compare-picker-label {
font-family: var(--f-mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
}
.compare-picker-select {
font-family: var(--f-mono);
font-size: 13px;
padding: var(--s2) var(--s3);
border: 1px solid var(--border);
border-radius: var(--r1);
background: var(--surface-2);
color: var(--text);
}
.compare-picker-select:focus {
outline: 2px solid var(--gp-accent, var(--red));
outline-offset: 1px;
}
.compare-section {
display: flex;
flex-direction: column;
gap: var(--s3);
}
.compare-section-title {
font-family: var(--f-mono);
font-size: 12px;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-2);
}
.compare-section-meta {
font-family: var(--f-mono);
font-size: 11px;
color: var(--text-3);
}
.compare-pit-caption {
font-family: var(--f-mono);
font-size: 11px;
color: var(--text-3);
margin: 0;
}
.compare-stale-notice {
font-size: 12px;
color: var(--amber, #f59e0b);
background: rgba(245, 158, 11, 0.08);
border: 1px solid rgba(245, 158, 11, 0.25);
border-radius: var(--r1);
padding: var(--s2) var(--s3);
}

View File

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

View File

@@ -0,0 +1,341 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { CompareView } from '../components/CompareView'
import type { Driver, EnrichedResult, LapsComparisonResponse } from '../types'
import {
buildBestLapTraceSeries,
carDataToTraceSeries,
comparisonToDeltaSeries,
defaultCompareDriverNumbers,
filterCarDataToLap,
findBestLap,
formatPitLapsCaption,
lapsToLapTimes,
} from '../lib/compare'
import { computeCumulativeDeltas } from '../lib/delta'
vi.mock('../api', () => ({
fetchTelemetry: vi.fn(),
fetchLapsComparison: vi.fn(),
}))
import { fetchTelemetry, fetchLapsComparison } from '../api'
const mockFetchTelemetry = vi.mocked(fetchTelemetry)
const mockFetchLapsComparison = vi.mocked(fetchLapsComparison)
const results: EnrichedResult[] = [
{
driver_number: 1,
position: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull Racing',
team_colour: '3671C6',
dnf: false,
dns: false,
dsq: false,
duration: null,
gap_to_leader: null,
number_of_laps: 78,
points: 25,
session_key: 9472,
meeting_key: 1229,
},
{
driver_number: 44,
position: 2,
name_acronym: 'HAM',
full_name: 'Lewis Hamilton',
team_name: 'Ferrari',
team_colour: 'E8002D',
dnf: false,
dns: false,
dsq: false,
duration: null,
gap_to_leader: 5.1,
number_of_laps: 78,
points: 18,
session_key: 9472,
meeting_key: 1229,
},
]
const drivers: Driver[] = [
{
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
first_name: 'Max',
last_name: 'Verstappen',
team_name: 'Red Bull Racing',
team_colour: '3671C6',
headshot_url: '',
broadcast_name: 'M VERSTAPPEN',
session_key: 9472,
meeting_key: 1229,
},
{
driver_number: 44,
name_acronym: 'HAM',
full_name: 'Lewis Hamilton',
first_name: 'Lewis',
last_name: 'Hamilton',
team_name: 'Ferrari',
team_colour: 'E8002D',
headshot_url: '',
broadcast_name: 'L HAMILTON',
session_key: 9472,
meeting_key: 1229,
},
]
const comparison: LapsComparisonResponse = {
session_key: 9472,
sc_periods: [],
pit_laps: { '44': [19, 40] },
drivers: [
{
driver_number: 1,
name_acronym: 'VER',
team_colour: '3671C6',
laps: [
{
session_key: 9472,
driver_number: 1,
meeting_key: 1229,
lap_number: 1,
date_start: '2025-05-25T13:00:00.000Z',
lap_duration: 92.1,
is_pit_out_lap: false,
},
{
session_key: 9472,
driver_number: 1,
meeting_key: 1229,
lap_number: 2,
date_start: '2025-05-25T13:01:32.100Z',
lap_duration: 90.5,
is_pit_out_lap: false,
},
],
},
{
driver_number: 44,
name_acronym: 'HAM',
team_colour: 'E8002D',
laps: [
{
session_key: 9472,
driver_number: 44,
meeting_key: 1229,
lap_number: 1,
date_start: '2025-05-25T13:00:01.000Z',
lap_duration: 93.0,
is_pit_out_lap: false,
},
{
session_key: 9472,
driver_number: 44,
meeting_key: 1229,
lap_number: 2,
date_start: '2025-05-25T13:01:34.000Z',
lap_duration: null,
is_pit_out_lap: true,
},
{
session_key: 9472,
driver_number: 44,
meeting_key: 1229,
lap_number: 3,
date_start: '2025-05-25T13:03:10.000Z',
lap_duration: 91.2,
is_pit_out_lap: false,
},
],
},
],
}
function renderCompareView() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
return render(
<QueryClientProvider client={client}>
<CompareView sessionKey={9472} results={results} drivers={drivers} />
</QueryClientProvider>,
)
}
describe('compare mapping helpers', () => {
it('defaults to top two finishers', () => {
expect(defaultCompareDriverNumbers(results, drivers)).toEqual([1, 44])
})
it('maps comparison laps to delta series with null pit laps', () => {
const series = comparisonToDeltaSeries(comparison, [1, 44], drivers)
expect(series).toHaveLength(2)
expect(series[0].label).toBe('VER')
expect(series[1].lapTimes[1]).toBeNull()
const deltas = computeCumulativeDeltas(series, 'VER')
expect(deltas[0].deltas[0]).toBeCloseTo(0.9)
})
it('maps car data to trace series', () => {
const trace = carDataToTraceSeries(
[
{
speed: 300,
throttle: 100,
brake: 0,
date: '',
driver_number: 1,
drs: 0,
meeting_key: 1,
n_gear: 8,
rpm: 12000,
session_key: 1,
},
],
'VER',
'#3671C6',
)
expect(trace.samples[0]).toEqual({ speed: 300, throttle: 100, brake: 0 })
})
it('filters car data to best lap window', () => {
const best = findBestLap(comparison.drivers[0].laps)
expect(best?.lap_number).toBe(2)
const samples = [
{
date: '2025-05-25T13:01:32.100Z',
speed: 280,
throttle: 90,
brake: 0,
driver_number: 1,
drs: 0,
meeting_key: 1229,
n_gear: 7,
rpm: 11000,
session_key: 9472,
},
{
date: '2025-05-25T13:00:00.000Z',
speed: 200,
throttle: 50,
brake: 10,
driver_number: 1,
drs: 0,
meeting_key: 1229,
n_gear: 4,
rpm: 9000,
session_key: 9472,
},
]
const filtered = filterCarDataToLap(samples, best!)
expect(filtered).toHaveLength(1)
expect(filtered[0].speed).toBe(280)
})
it('builds best-lap trace series from car data and laps', () => {
const series = buildBestLapTraceSeries(
[
{
date: '2025-05-25T13:01:33.000Z',
speed: 310,
throttle: 100,
brake: 0,
driver_number: 1,
drs: 10,
meeting_key: 1229,
n_gear: 8,
rpm: 12000,
session_key: 9472,
},
],
comparison.drivers[0].laps,
'VER',
'#3671C6',
)
expect(series?.samples).toHaveLength(1)
expect(series?.samples[0].speed).toBe(310)
})
it('maps laps to lap time arrays with nulls', () => {
expect(lapsToLapTimes(comparison.drivers[1].laps)).toEqual([93.0, null, 91.2])
})
it('formats pit lap captions', () => {
expect(formatPitLapsCaption(comparison.pit_laps, [1, 44], comparison, drivers)).toBe(
'Pit stops — HAM: L19, L40',
)
})
})
describe('CompareView', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchLapsComparison.mockResolvedValue(comparison)
mockFetchTelemetry.mockResolvedValue([
{
date: '2025-05-25T13:01:33.000Z',
speed: 310,
throttle: 100,
brake: 0,
driver_number: 1,
drs: 10,
meeting_key: 1229,
n_gear: 8,
rpm: 12000,
session_key: 9472,
},
])
})
it('renders pickers defaulting to top two finishers', async () => {
renderCompareView()
expect(screen.getByTestId('compare-picker-a')).toHaveValue('1')
expect(screen.getByTestId('compare-picker-b')).toHaveValue('44')
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
expect(screen.getAllByText('HAM').length).toBeGreaterThan(0)
})
it('renders telemetry and pace sections with mocked queries', async () => {
renderCompareView()
await waitFor(() => {
expect(mockFetchLapsComparison).toHaveBeenCalledWith(9472, [1, 44])
})
await waitFor(() => {
expect(screen.getByTestId('telemetry-trace')).toBeInTheDocument()
})
expect(screen.getByTestId('delta-time-graph')).toBeInTheDocument()
expect(screen.getByTestId('compare-pit-caption')).toHaveTextContent('HAM: L19, L40')
})
it('shows error state when comparison fetch fails', async () => {
mockFetchLapsComparison.mockRejectedValue(new Error('comparison unavailable'))
renderCompareView()
await waitFor(() => {
expect(screen.getByText('comparison unavailable')).toBeInTheDocument()
})
})
it('shows empty state when fewer than two drivers', () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={client}>
<CompareView sessionKey={9472} results={[]} drivers={[drivers[0]]} />
</QueryClientProvider>,
)
expect(screen.getByTestId('compare-view-empty')).toBeInTheDocument()
})
})

View File

@@ -158,6 +158,37 @@ export interface Lap {
is_pit_out_lap: boolean is_pit_out_lap: boolean
} }
export interface CarDataSample {
brake: number
date: string
driver_number: number
drs: number
meeting_key: number
n_gear: number
rpm: number
session_key: number
speed: number
throttle: number
}
export interface ComparisonLap extends Lap {
compound?: string
}
export interface ComparisonDriver {
driver_number: number
name_acronym: string
team_colour: string
laps: ComparisonLap[]
}
export interface LapsComparisonResponse {
session_key: number
sc_periods: unknown[]
pit_laps: Record<string, number[]>
drivers: ComparisonDriver[]
}
export interface WeekendSession { export interface WeekendSession {
session: Session session: Session
source: 'local' | 'partial' | 'none' | 'cancelled' source: 'local' | 'partial' | 'none' | 'cancelled'