Compare commits
6 Commits
feat/issue
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ee14e5c89 | ||
|
|
cc4337be88 | ||
|
|
addfd6d24d | ||
|
|
b5d070e116 | ||
|
|
233eefaf12 | ||
|
|
51b0238b09 |
BIN
docs/phase-1/screenshots/command-center-hero-between.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
docs/phase-1/screenshots/live-event-tyre-radio-mocked.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/phase-1/screenshots/race-hub-compare-telemetry-delta.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
docs/phase-1/screenshots/race-hub-strategy-timeline.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchLapsComparison, fetchTelemetry } from '../api'
|
||||
import {
|
||||
@@ -57,16 +57,23 @@ export function CompareView({ sessionKey, results, drivers }: Props) {
|
||||
[results, drivers],
|
||||
)
|
||||
|
||||
const previousSessionKey = useRef(sessionKey)
|
||||
const [driverA, setDriverA] = useState<number | null>(initialPair?.[0] ?? null)
|
||||
const [driverB, setDriverB] = useState<number | null>(initialPair?.[1] ?? null)
|
||||
|
||||
useEffect(() => {
|
||||
if (previousSessionKey.current !== sessionKey) {
|
||||
previousSessionKey.current = sessionKey
|
||||
setDriverA(initialPair?.[0] ?? null)
|
||||
setDriverB(initialPair?.[1] ?? null)
|
||||
return
|
||||
}
|
||||
|
||||
if (driverA != null && driverB != null) return
|
||||
const pair = defaultCompareDriverNumbers(results, drivers)
|
||||
if (!pair) return
|
||||
setDriverA(pair[0])
|
||||
setDriverB(pair[1])
|
||||
}, [results, drivers, driverA, driverB])
|
||||
if (!initialPair) return
|
||||
setDriverA(initialPair[0])
|
||||
setDriverB(initialPair[1])
|
||||
}, [sessionKey, initialPair, driverA, driverB])
|
||||
|
||||
const pair = useMemo((): [number, number] | null => {
|
||||
if (driverA == null || driverB == null || driverA === driverB) return null
|
||||
|
||||
@@ -12,7 +12,7 @@ interface Props {
|
||||
hasStints: boolean
|
||||
}
|
||||
|
||||
export function StrategyView({ results, stints, pit_stops: _pitStops, hasStints }: Props) {
|
||||
export function StrategyView({ results, stints, pit_stops, hasStints }: Props) {
|
||||
if (!hasStints) {
|
||||
return (
|
||||
<div>
|
||||
@@ -88,6 +88,10 @@ export function StrategyView({ results, stints, pit_stops: _pitStops, hasStints
|
||||
lapEnd: s.lap_end,
|
||||
isNew: s.tyre_age_at_start === 0,
|
||||
})),
|
||||
pitStops: pit_stops
|
||||
.filter((p) => p.driver_number === driver.driver_number)
|
||||
.map((p) => p.lap_number)
|
||||
.sort((a, b) => a - b),
|
||||
}))
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface StintTimelineRow {
|
||||
label: string
|
||||
color: string
|
||||
stints: StintTimelineStint[]
|
||||
/** Lap numbers where the driver pitted; optional — rows without stops render normally. */
|
||||
pitStops?: number[]
|
||||
}
|
||||
|
||||
interface TyreStintTimelineProps {
|
||||
@@ -57,6 +59,14 @@ function stintBarW(stint: StintTimelineStint, totalLaps: number): number {
|
||||
return Math.max(2, (stintLength(stint) / totalLaps) * BAR_W)
|
||||
}
|
||||
|
||||
function pitMarkerX(lapNumber: number, totalLaps: number): number {
|
||||
return LEFT + ((lapNumber - 1) / totalLaps) * BAR_W
|
||||
}
|
||||
|
||||
function pitMarkerTitle(driverLabel: string, lapNumber: number): string {
|
||||
return `${driverLabel} pit stop · L${lapNumber}`
|
||||
}
|
||||
|
||||
function axisTicks(totalLaps: number): number[] {
|
||||
const ticks: number[] = []
|
||||
for (let lap = 0; lap <= totalLaps; lap += 10) {
|
||||
@@ -130,6 +140,21 @@ export function TyreStintTimeline({ rows, totalLaps }: TyreStintTimelineProps) {
|
||||
<title>{stintTitle(stint)}</title>
|
||||
</rect>
|
||||
))}
|
||||
|
||||
{(row.pitStops ?? []).map((lapNumber, pi) => (
|
||||
<line
|
||||
key={`pit-${pi}`}
|
||||
x1={pitMarkerX(lapNumber, safeTotal)}
|
||||
x2={pitMarkerX(lapNumber, safeTotal)}
|
||||
y1={BAR_Y - 3}
|
||||
y2={BAR_Y + BAR_H + 3}
|
||||
className="stint-timeline__pit-marker"
|
||||
data-testid="pit-marker"
|
||||
data-lap={lapNumber}
|
||||
>
|
||||
<title>{pitMarkerTitle(row.label, lapNumber)}</title>
|
||||
</line>
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -40,6 +40,13 @@
|
||||
stroke-dasharray: 2 1;
|
||||
}
|
||||
|
||||
.stint-timeline__pit-marker {
|
||||
stroke: var(--text);
|
||||
stroke-width: 1.5;
|
||||
opacity: 0.7;
|
||||
pointer-events: stroke;
|
||||
}
|
||||
|
||||
.stint-timeline__axis-tick {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 9px;
|
||||
|
||||
@@ -106,6 +106,17 @@ describe('StrategyView — stints available', () => {
|
||||
)
|
||||
expect(screen.queryByText(/Stints not available/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('maps pit_stops into timeline pit markers for the matching driver', () => {
|
||||
const { container } = render(
|
||||
<StrategyView results={results} stints={stints} pit_stops={pitStops} hasStints={true} />
|
||||
)
|
||||
const markers = container.querySelectorAll('[data-testid="pit-marker"]')
|
||||
expect(markers).toHaveLength(1)
|
||||
expect(markers[0]).toHaveAttribute('data-lap', '19')
|
||||
const titles = [...container.querySelectorAll('title')].map((t) => t.textContent)
|
||||
expect(titles).toContain('HAM pit stop · L19')
|
||||
})
|
||||
})
|
||||
|
||||
describe('StrategyView — stints missing', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { CompareView } from '../components/CompareView'
|
||||
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({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<CompareView sessionKey={9472} results={results} drivers={drivers} />
|
||||
<CompareView
|
||||
sessionKey={props.sessionKey ?? 9472}
|
||||
results={props.results ?? results}
|
||||
drivers={props.drivers ?? drivers}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
@@ -305,6 +381,28 @@ describe('CompareView', () => {
|
||||
expect(screen.getAllByText('HAM').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('resets the selected pair when the mounted session changes', async () => {
|
||||
const { rerender } = renderCompareView()
|
||||
|
||||
fireEvent.change(screen.getByTestId('compare-picker-a'), { target: { value: '44' } })
|
||||
expect(screen.getByTestId('compare-picker-a')).toHaveValue('44')
|
||||
|
||||
rerender(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<CompareView
|
||||
sessionKey={9550}
|
||||
results={nextSessionResults}
|
||||
drivers={nextSessionDrivers}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('compare-picker-a')).toHaveValue('16')
|
||||
expect(screen.getByTestId('compare-picker-b')).toHaveValue('55')
|
||||
})
|
||||
})
|
||||
|
||||
it('renders telemetry and pace sections with mocked queries', async () => {
|
||||
renderCompareView()
|
||||
|
||||
|
||||
@@ -71,6 +71,61 @@ describe('TyreStintTimeline', () => {
|
||||
expect(screen.getByTestId('stint-timeline-empty')).toBeInTheDocument()
|
||||
expect(screen.getByText(/No stint data/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders one pit marker per stop at the correct lap position', () => {
|
||||
const rowsWithPits: StintTimelineRow[] = [
|
||||
{
|
||||
label: 'HAM',
|
||||
color: '#E8002D',
|
||||
stints: [{ compound: 'SOFT', lapStart: 1, lapEnd: 18 }],
|
||||
pitStops: [19],
|
||||
},
|
||||
{
|
||||
label: 'VER',
|
||||
color: '#3671C6',
|
||||
stints: [
|
||||
{ compound: 'MEDIUM', lapStart: 1, lapEnd: 30 },
|
||||
{ compound: 'SOFT', lapStart: 31, lapEnd: 78 },
|
||||
],
|
||||
pitStops: [31, 52],
|
||||
},
|
||||
]
|
||||
const { container } = render(
|
||||
<TyreStintTimeline rows={rowsWithPits} totalLaps={78} />,
|
||||
)
|
||||
const markers = container.querySelectorAll('[data-testid="pit-marker"]')
|
||||
expect(markers).toHaveLength(3)
|
||||
expect(markers[0]).toHaveAttribute('data-lap', '19')
|
||||
expect(markers[1]).toHaveAttribute('data-lap', '31')
|
||||
expect(markers[2]).toHaveAttribute('data-lap', '52')
|
||||
expect(container.querySelectorAll('.stint-timeline__bar')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('positions pit markers using lap_number and includes driver in tooltip', () => {
|
||||
const rows: StintTimelineRow[] = [
|
||||
{
|
||||
label: 'HAM',
|
||||
color: '#E8002D',
|
||||
stints: [{ compound: 'SOFT', lapStart: 1, lapEnd: 18 }],
|
||||
pitStops: [19],
|
||||
},
|
||||
]
|
||||
const { container } = render(<TyreStintTimeline rows={rows} totalLaps={78} />)
|
||||
const marker = container.querySelector('[data-testid="pit-marker"]') as SVGLineElement
|
||||
expect(marker).toBeTruthy()
|
||||
// lap 19 → x = 48 + (18/78) * 580 ≈ 181.85
|
||||
expect(Number(marker.getAttribute('x1'))).toBeCloseTo(181.85, 1)
|
||||
const titles = [...container.querySelectorAll('title')].map((t) => t.textContent)
|
||||
expect(titles).toContain('HAM pit stop · L19')
|
||||
})
|
||||
|
||||
it('leaves rows without pit data unchanged', () => {
|
||||
const { container } = render(
|
||||
<TyreStintTimeline rows={sampleRows} totalLaps={78} />,
|
||||
)
|
||||
expect(container.querySelectorAll('[data-testid="pit-marker"]')).toHaveLength(0)
|
||||
expect(container.querySelectorAll('.stint-timeline__bar')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
const results: EnrichedResult[] = [
|
||||
|
||||
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 129 KiB |