fix(frontend): restore pit-stop markers on strategy stint timeline (#45)

Wire Race Hub pit_stops into TyreStintTimeline via optional per-row
pitStops laps; render vertical markers at stint boundaries with driver/lap
tooltips. Matches pre-extraction positioning ((lap-1)/totalLaps).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-04 01:04:13 -04:00
parent cc4337be88
commit 9ee14e5c89
5 changed files with 103 additions and 1 deletions

View File

@@ -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 (

View File

@@ -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>
)
})}

View File

@@ -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;

View File

@@ -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', () => {

View File

@@ -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[] = [