Compare commits

...

7 Commits

Author SHA1 Message Date
AmanTahiliani
2d6fa531f2 fix(compare): gap deltas beyond reference laps 2026-07-04 01:16:01 -04:00
AmanTahiliani
ad379e0f07 fix(compare): gap delta graph when reference lap is null
Reference-null laps now emit null deltas for all drivers and exclude that
lap window from aligned cumulative totals so later comparisons stay sane.
Challenger-null behavior is unchanged. Compare tab meta documents the policy.

Closes #46

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 01:04:55 -04:00
Aman Tahiliani
cc4337be88 Merge pull request #43 from AmanTahiliani/phase-1
Phase 1 delivery
2026-07-04 00:37:54 -04:00
AmanTahiliani
addfd6d24d fix(compare): reset driver pair on session change 2026-07-04 00:37:12 -04:00
AmanTahiliani
b5d070e116 docs: add phase 1 PR screenshots 2026-07-04 00:32:20 -04:00
AmanTahiliani
233eefaf12 test: update phase 1 visual snapshots 2026-07-04 00:28:09 -04:00
Aman Tahiliani
51b0238b09 Merge pull request #42 from AmanTahiliani/feat/issue-18-annotate-every-number-with-meaning-ux-p
"Annotate every number with meaning" UX pass (#18)
2026-07-04 00:25:39 -04:00
12 changed files with 219 additions and 22 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { fetchLapsComparison, fetchTelemetry } from '../api' import { fetchLapsComparison, fetchTelemetry } from '../api'
import { import {
@@ -57,16 +57,23 @@ export function CompareView({ sessionKey, results, drivers }: Props) {
[results, drivers], [results, drivers],
) )
const previousSessionKey = useRef(sessionKey)
const [driverA, setDriverA] = useState<number | null>(initialPair?.[0] ?? null) const [driverA, setDriverA] = useState<number | null>(initialPair?.[0] ?? null)
const [driverB, setDriverB] = useState<number | null>(initialPair?.[1] ?? null) const [driverB, setDriverB] = useState<number | null>(initialPair?.[1] ?? null)
useEffect(() => { useEffect(() => {
if (previousSessionKey.current !== sessionKey) {
previousSessionKey.current = sessionKey
setDriverA(initialPair?.[0] ?? null)
setDriverB(initialPair?.[1] ?? null)
return
}
if (driverA != null && driverB != null) return if (driverA != null && driverB != null) return
const pair = defaultCompareDriverNumbers(results, drivers) if (!initialPair) return
if (!pair) return setDriverA(initialPair[0])
setDriverA(pair[0]) setDriverB(initialPair[1])
setDriverB(pair[1]) }, [sessionKey, initialPair, driverA, driverB])
}, [results, drivers, driverA, driverB])
const pair = useMemo((): [number, number] | null => { const pair = useMemo((): [number, number] | null => {
if (driverA == null || driverB == null || driverA === driverB) return null if (driverA == null || driverB == null || driverA === driverB) return null
@@ -222,7 +229,9 @@ export function CompareView({ sessionKey, results, drivers }: Props) {
<div> <div>
<div className="compare-section-title">Race pace</div> <div className="compare-section-title">Race pace</div>
<div className="compare-section-meta"> <div className="compare-section-meta">
Cumulative lap-time delta vs {referenceLabel ?? 'reference'} Cumulative lap-time delta vs {referenceLabel ?? 'reference'}. Deltas are plotted
only where the reference lap is valid; gaps appear when the reference has no lap
time.
</div> </div>
</div> </div>
<SectionState <SectionState

View File

@@ -21,15 +21,31 @@ export function formatDeltaSeconds(delta: number): string {
return `${sign}${delta.toFixed(1)}s` return `${sign}${delta.toFixed(1)}s`
} }
function buildCumulative(lapTimes: ReadonlyArray<number | null>): number[] { /**
* Cumulative lap time aligned to reference-valid laps only.
* Laps where the reference is null are skipped for every series so later deltas
* do not compare against a frozen baseline while challengers keep accumulating.
*/
function buildAlignedCumulative(
lapTimes: ReadonlyArray<number | null>,
referenceLapTimes: ReadonlyArray<number | null>,
): number[] {
const cumulative: number[] = [] const cumulative: number[] = []
let running = 0 let running = 0
for (const lap of lapTimes) { const length = Math.max(lapTimes.length, referenceLapTimes.length)
if (lap !== null) {
for (let i = 0; i < length; i++) {
if (referenceLapTimes[i] == null) {
cumulative.push(running)
continue
}
const lap = lapTimes[i]
if (lap != null) {
running += lap running += lap
} }
cumulative.push(running) cumulative.push(running)
} }
return cumulative return cumulative
} }
@@ -47,7 +63,8 @@ function resolveReference(
/** /**
* Compute per-lap cumulative time delta for each non-reference driver. * Compute per-lap cumulative time delta for each non-reference driver.
* Positive = behind reference; negative = ahead. * Positive = behind reference; negative = ahead.
* Null laps carry cumulative forward but emit null in deltas (skip when plotting). * Deltas are only emitted where the reference lap is valid; reference-null laps
* gap every series. Challenger-null laps gap only that driver's line.
*/ */
export function computeCumulativeDeltas( export function computeCumulativeDeltas(
series: ReadonlyArray<DeltaSeries>, series: ReadonlyArray<DeltaSeries>,
@@ -56,24 +73,22 @@ export function computeCumulativeDeltas(
const reference = resolveReference(series, referenceLabel) const reference = resolveReference(series, referenceLabel)
if (!reference) return [] if (!reference) return []
const refCumulative = buildCumulative(reference.lapTimes) const refLapTimes = reference.lapTimes
const refCumulative = buildAlignedCumulative(refLapTimes, refLapTimes)
return series return series
.filter((s) => s.label !== reference.label) .filter((s) => s.label !== reference.label)
.map((driver) => { .map((driver) => {
const driverCumulative = buildCumulative(driver.lapTimes) const driverCumulative = buildAlignedCumulative(driver.lapTimes, refLapTimes)
const lapCount = Math.max(driver.lapTimes.length, refCumulative.length) const lapCount = Math.max(driver.lapTimes.length, refCumulative.length)
const deltas: (number | null)[] = [] const deltas: (number | null)[] = []
for (let i = 0; i < lapCount; i++) { for (let i = 0; i < lapCount; i++) {
if (driver.lapTimes[i] === null) { if (refLapTimes[i] == null || driver.lapTimes[i] == null) {
deltas.push(null) deltas.push(null)
continue continue
} }
const refValue = refCumulative[i] ?? refCumulative[refCumulative.length - 1] ?? 0 deltas.push(driverCumulative[i] - refCumulative[i])
const driverValue =
driverCumulative[i] ?? driverCumulative[driverCumulative.length - 1] ?? 0
deltas.push(driverValue - refValue)
} }
return { return {

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { CompareView } from '../components/CompareView' import { CompareView } from '../components/CompareView'
import type { Driver, EnrichedResult, LapsComparisonResponse } from '../types' import type { Driver, EnrichedResult, LapsComparisonResponse } from '../types'
@@ -158,13 +158,89 @@ const comparison: LapsComparisonResponse = {
], ],
} }
function renderCompareView() { const nextSessionResults: EnrichedResult[] = [
{
driver_number: 16,
position: 1,
name_acronym: 'LEC',
full_name: 'Charles Leclerc',
team_name: 'Ferrari',
team_colour: 'E8002D',
dnf: false,
dns: false,
dsq: false,
duration: null,
gap_to_leader: null,
number_of_laps: 57,
points: 25,
session_key: 9550,
meeting_key: 1234,
},
{
driver_number: 55,
position: 2,
name_acronym: 'SAI',
full_name: 'Carlos Sainz',
team_name: 'Williams',
team_colour: '64C4FF',
dnf: false,
dns: false,
dsq: false,
duration: null,
gap_to_leader: 3.2,
number_of_laps: 57,
points: 18,
session_key: 9550,
meeting_key: 1234,
},
]
const nextSessionDrivers: Driver[] = [
{
driver_number: 16,
name_acronym: 'LEC',
full_name: 'Charles Leclerc',
first_name: 'Charles',
last_name: 'Leclerc',
team_name: 'Ferrari',
team_colour: 'E8002D',
headshot_url: '',
broadcast_name: 'C LECLERC',
session_key: 9550,
meeting_key: 1234,
},
{
driver_number: 55,
name_acronym: 'SAI',
full_name: 'Carlos Sainz',
first_name: 'Carlos',
last_name: 'Sainz',
team_name: 'Williams',
team_colour: '64C4FF',
headshot_url: '',
broadcast_name: 'C SAINZ',
session_key: 9550,
meeting_key: 1234,
},
]
function renderCompareView(
props: {
sessionKey?: number
results?: EnrichedResult[]
drivers?: Driver[]
} = {},
) {
const client = new QueryClient({ const client = new QueryClient({
defaultOptions: { queries: { retry: false } }, defaultOptions: { queries: { retry: false } },
}) })
return render( return render(
<QueryClientProvider client={client}> <QueryClientProvider client={client}>
<CompareView sessionKey={9472} results={results} drivers={drivers} /> <CompareView
sessionKey={props.sessionKey ?? 9472}
results={props.results ?? results}
drivers={props.drivers ?? drivers}
/>
</QueryClientProvider>, </QueryClientProvider>,
) )
} }
@@ -305,6 +381,28 @@ describe('CompareView', () => {
expect(screen.getAllByText('HAM').length).toBeGreaterThan(0) expect(screen.getAllByText('HAM').length).toBeGreaterThan(0)
}) })
it('resets the selected pair when the mounted session changes', async () => {
const { rerender } = renderCompareView()
fireEvent.change(screen.getByTestId('compare-picker-a'), { target: { value: '44' } })
expect(screen.getByTestId('compare-picker-a')).toHaveValue('44')
rerender(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<CompareView
sessionKey={9550}
results={nextSessionResults}
drivers={nextSessionDrivers}
/>
</QueryClientProvider>,
)
await waitFor(() => {
expect(screen.getByTestId('compare-picker-a')).toHaveValue('16')
expect(screen.getByTestId('compare-picker-b')).toHaveValue('55')
})
})
it('renders telemetry and pace sections with mocked queries', async () => { it('renders telemetry and pace sections with mocked queries', async () => {
renderCompareView() renderCompareView()

View File

@@ -47,7 +47,7 @@ describe('computeCumulativeDeltas', () => {
expect(result[0].deltas[2]).toBeCloseTo(2) expect(result[0].deltas[2]).toBeCloseTo(2)
}) })
it('emits null for missing lap times while carrying cumulative forward', () => { it('emits null for challenger missing lap times while carrying cumulative forward', () => {
const withNull: DeltaSeries = { const withNull: DeltaSeries = {
label: 'NOR', label: 'NOR',
color: '#FF8000', color: '#FF8000',
@@ -60,6 +60,41 @@ describe('computeCumulativeDeltas', () => {
expect(result[0].deltas[2]).toBeCloseTo(-92) expect(result[0].deltas[2]).toBeCloseTo(-92)
}) })
it('gaps all drivers when the reference lap is null and resumes without that window', () => {
const refWithNull: DeltaSeries = {
label: 'VER',
color: '#3671C6',
lapTimes: [90, null, 92],
}
const validChallenger: DeltaSeries = {
label: 'HAM',
color: '#E8002D',
lapTimes: [89, 91, 90],
}
const result = computeCumulativeDeltas([refWithNull, validChallenger])
expect(result[0].deltas[0]).toBeCloseTo(-1)
expect(result[0].deltas[1]).toBeNull()
// Lap 3 excludes the reference-null window for both: (89+90) - (90+92) = -3
expect(result[0].deltas[2]).toBeCloseTo(-3)
})
it('gaps challenger laps beyond a shorter reference series', () => {
const shortReference: DeltaSeries = {
label: 'VER',
color: '#3671C6',
lapTimes: [90, 91],
}
const longerChallenger: DeltaSeries = {
label: 'HAM',
color: '#E8002D',
lapTimes: [89, 92, 90],
}
const result = computeCumulativeDeltas([shortReference, longerChallenger])
expect(result[0].deltas[0]).toBeCloseTo(-1)
expect(result[0].deltas[1]).toBeCloseTo(0)
expect(result[0].deltas[2]).toBeNull()
})
it('returns an empty array when only one series is provided', () => { it('returns an empty array when only one series is provided', () => {
expect(computeCumulativeDeltas([reference])).toEqual([]) expect(computeCumulativeDeltas([reference])).toEqual([])
}) })
@@ -97,6 +132,19 @@ describe('DeltaTimeGraph', () => {
expect(screen.queryByTestId('delta-line-VER')).not.toBeInTheDocument() expect(screen.queryByTestId('delta-line-VER')).not.toBeInTheDocument()
}) })
it('splits polylines at reference-null laps', () => {
const refWithNull: DeltaSeries = {
label: 'VER',
color: '#3671C6',
lapTimes: [90, null, 92],
}
const { container } = render(
<DeltaTimeGraph series={[refWithNull, challenger]} />,
)
const lines = container.querySelectorAll('.delta-graph-driver-line')
expect(lines.length).toBeGreaterThan(1)
})
it('shows a crosshair tooltip on hover', () => { it('shows a crosshair tooltip on hover', () => {
vi.spyOn(SVGSVGElement.prototype, 'getBoundingClientRect').mockReturnValue({ vi.spyOn(SVGSVGElement.prototype, 'getBoundingClientRect').mockReturnValue({
x: 0, x: 0,
@@ -118,4 +166,31 @@ describe('DeltaTimeGraph', () => {
expect(screen.getByText(/Lap 1/)).toBeInTheDocument() expect(screen.getByText(/Lap 1/)).toBeInTheDocument()
vi.restoreAllMocks() vi.restoreAllMocks()
}) })
it('omits tooltip rows on reference-null laps', () => {
vi.spyOn(SVGSVGElement.prototype, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
width: 640,
height: 220,
right: 640,
bottom: 220,
toJSON: () => ({}),
})
const refWithNull: DeltaSeries = {
label: 'VER',
color: '#3671C6',
lapTimes: [90, null, 92],
}
const { container } = render(
<DeltaTimeGraph series={[refWithNull, challenger]} />,
)
const hoverLayer = container.querySelector('.delta-graph-hover-layer')
fireEvent.mouseMove(hoverLayer!, { clientX: 352, clientY: 100 })
expect(screen.getByTestId('delta-crosshair')).toBeInTheDocument()
expect(screen.queryByTestId('delta-tooltip')).not.toBeInTheDocument()
vi.restoreAllMocks()
})
}) })

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 129 KiB