diff --git a/frontend/src/api.ts b/frontend/src/api.ts index a42330a..1b7d972 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,6 +1,8 @@ import type { ArticleContent, + CarDataSample, ChampionshipHub, + LapsComparisonResponse, LiveStateResponse, LiveSessionMeta, Meeting, @@ -126,3 +128,32 @@ export async function markNewsRead(articleUrl: string): Promise { body: JSON.stringify({ url: articleUrl }), }) } + +export async function fetchTelemetry( + sessionKey: number, + driverNumber: number, +): Promise { + 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 { + 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() +} diff --git a/frontend/src/components/CompareView.tsx b/frontend/src/components/CompareView.tsx new file mode 100644 index 0000000..257b2e9 --- /dev/null +++ b/frontend/src/components/CompareView.tsx @@ -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
loading…
+ } + if (error) { + return
{error.message}
+ } + if (empty) { + return
{emptyMessage}
+ } + 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(initialPair?.[0] ?? null) + const [driverB, setDriverB] = useState(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 ( +
+ Need at least two drivers in this session to compare. +
+ ) + } + + const driverAInfo = driverOptions.find((d) => d.driver_number === driverA) + const driverBInfo = driverOptions.find((d) => d.driver_number === driverB) + + return ( +
+
+
+ Reference + + {driverAInfo && ( + + )} +
+ +
+ Challenger + + {driverBInfo && ( + + )} +
+
+ + {driverA === driverB && ( +
+ Pick two different drivers to run a comparison. +
+ )} + +
+
+
Best lap telemetry
+
+ Speed, throttle, and brake traces for each driver's fastest lap +
+
+ + + +
+ +
+
+
Race pace
+
+ Cumulative lap-time delta vs {referenceLabel ?? 'reference'} +
+
+ + + {pitCaption && ( +

+ {pitCaption} +

+ )} +
+
+
+ ) +} diff --git a/frontend/src/components/TabBar.tsx b/frontend/src/components/TabBar.tsx index 41707f3..5d1678c 100644 --- a/frontend/src/components/TabBar.tsx +++ b/frontend/src/components/TabBar.tsx @@ -2,6 +2,7 @@ export type Tab = | 'overview' | 'race_story' | 'strategy' + | 'compare' | 'lap_data' | 'conditions' | 'race_control' @@ -11,6 +12,7 @@ const TABS: { id: Tab; label: string }[] = [ { id: 'overview', label: 'Overview' }, { id: 'race_story', label: 'Race Story' }, { id: 'strategy', label: 'Strategy' }, + { id: 'compare', label: 'Compare' }, { id: 'lap_data', label: 'Lap Data' }, { id: 'conditions', label: 'Conditions' }, { id: 'race_control', label: 'Race Control' }, diff --git a/frontend/src/lib/compare.ts b/frontend/src/lib/compare.ts new file mode 100644 index 0000000..9209b7e --- /dev/null +++ b/frontend/src/lib/compare.ts @@ -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, + 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, + })) +} diff --git a/frontend/src/pages/RaceHubPage.tsx b/frontend/src/pages/RaceHubPage.tsx index 5c98f81..e680e9d 100644 --- a/frontend/src/pages/RaceHubPage.tsx +++ b/frontend/src/pages/RaceHubPage.tsx @@ -13,6 +13,7 @@ import { TabBar, type Tab } from '../components/TabBar' import { DatasetStatusView } from '../components/DatasetStatusView' import { StrategyView } from '../components/StrategyView' import { LapsView } from '../components/LapsView' +import { CompareView } from '../components/CompareView' import { RaceControlView } from '../components/RaceControlView' import { WeatherView } from '../components/WeatherView' import { OverviewView } from '../components/OverviewView' @@ -307,6 +308,19 @@ export function RaceHubPage({ sessionKey }: Props) { )} + {activeTab === 'compare' && ( +
+
+ Driver Compare +
+ +
+ )} + {activeTab === 'lap_data' && (
diff --git a/frontend/src/styles/compare-view.css b/frontend/src/styles/compare-view.css new file mode 100644 index 0000000..cbe5685 --- /dev/null +++ b/frontend/src/styles/compare-view.css @@ -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); +} diff --git a/frontend/src/test/TabBar.test.tsx b/frontend/src/test/TabBar.test.tsx index de9a1b0..da6dd41 100644 --- a/frontend/src/test/TabBar.test.tsx +++ b/frontend/src/test/TabBar.test.tsx @@ -8,6 +8,7 @@ describe('TabBar', () => { expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Race Story' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Strategy' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Compare' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Conditions' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument() diff --git a/frontend/src/test/compare-view.test.tsx b/frontend/src/test/compare-view.test.tsx new file mode 100644 index 0000000..f23d0a4 --- /dev/null +++ b/frontend/src/test/compare-view.test.tsx @@ -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( + + + , + ) +} + +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( + + + , + ) + expect(screen.getByTestId('compare-view-empty')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 790c7e8..0e6c326 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -158,6 +158,37 @@ export interface Lap { is_pit_out_lap: boolean } +export interface CarDataSample { + brake: number + date: string + driver_number: number + drs: number + meeting_key: number + n_gear: number + rpm: number + session_key: number + speed: number + throttle: number +} + +export interface ComparisonLap extends Lap { + compound?: string +} + +export interface ComparisonDriver { + driver_number: number + name_acronym: string + team_colour: string + laps: ComparisonLap[] +} + +export interface LapsComparisonResponse { + session_key: number + sc_periods: unknown[] + pit_laps: Record + drivers: ComparisonDriver[] +} + export interface WeekendSession { session: Session source: 'local' | 'partial' | 'none' | 'cancelled'