Upgrade live timing page for race weekends

- Track status flag banner (green/yellow/SC/VSC/red, defensive mapping)
- Weather strip: air/track temp, humidity, wind with compass, rain badge
- Gap trend sparklines from a per-driver interval ring buffer
- Battle detection: consecutive cars within 1.0s chained and highlighted
- Stint history column with compound sequence per driver
- Pinnable drivers (max 3) with focus cards, persisted to localStorage

Pure logic lives in lib/gapHistory.ts and lib/battles.ts with unit
tests; 137 frontend tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 00:21:23 -04:00
parent 7a5e0a323d
commit c708b6697d
17 changed files with 1342 additions and 31 deletions

View File

@@ -0,0 +1,221 @@
import { describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { TrackStatusBanner } from '../components/live/TrackStatusBanner'
import { WeatherStrip } from '../components/live/WeatherStrip'
import { StintHistory } from '../components/live/StintHistory'
import { BattleChips } from '../components/live/BattleChips'
import { GapSparkline } from '../components/live/GapSparkline'
import { PinnedDrivers } from '../components/live/PinnedDrivers'
import { TimingTower } from '../components/live/TimingTower'
import { detectBattles } from '../lib/battles'
import type { LiveTimingRow } from '../lib/live'
import type { LiveDriverData, LiveWeatherData } from '../types'
function makeRow(
number: string,
position: number,
tla: string,
driver: Partial<LiveDriverData> = {},
): LiveTimingRow {
return {
RacingNumber: number,
Position: position,
Driver: {
RacingNumber: number,
Position: position,
Interval: '',
GapToLeader: '',
LastLapTime: '1:14.000',
BestLapTime: '1:13.500',
NumberOfLaps: 10,
InPit: false,
PitOut: false,
Retired: false,
...driver,
} as LiveDriverData,
Info: {
RacingNumber: number,
BroadcastName: '',
Tla: tla,
TeamName: '',
TeamColour: '3671c6',
FirstName: '',
LastName: '',
},
}
}
describe('TrackStatusBanner', () => {
it('renders a safety car banner for status 4', () => {
render(<TrackStatusBanner status="4" />)
const banner = screen.getByTestId('track-banner')
expect(banner).toHaveClass('track-banner-sc')
expect(banner).toHaveTextContent('SAFETY CAR')
})
it('renders a neutral banner for unknown statuses', () => {
render(<TrackStatusBanner status="42" />)
const banner = screen.getByTestId('track-banner')
expect(banner).toHaveClass('track-banner-unknown')
expect(banner).toHaveTextContent('TRACK STATUS 42')
})
it('renders nothing without a status', () => {
render(<TrackStatusBanner status="" />)
expect(screen.queryByTestId('track-banner')).not.toBeInTheDocument()
})
})
describe('WeatherStrip', () => {
const weather: LiveWeatherData = {
AirTemp: 21.4,
TrackTemp: 34.8,
Humidity: 58,
WindSpeed: 2.3,
WindDir: 180,
Rainfall: true,
}
it('renders all populated weather fields', () => {
render(<WeatherStrip weather={weather} />)
const strip = screen.getByTestId('weather-strip')
expect(strip).toHaveTextContent('21°C')
expect(strip).toHaveTextContent('35°C')
expect(strip).toHaveTextContent('58%')
expect(strip).toHaveTextContent('2.3 m/s S')
expect(strip).toHaveTextContent('RAIN')
})
it('hides gracefully when the payload is empty', () => {
render(
<WeatherStrip
weather={{ AirTemp: 0, TrackTemp: 0, Humidity: 0, WindSpeed: 0, WindDir: 0, Rainfall: false }}
/>,
)
expect(screen.queryByTestId('weather-strip')).not.toBeInTheDocument()
})
it('hides when weather is missing entirely', () => {
render(<WeatherStrip weather={undefined} />)
expect(screen.queryByTestId('weather-strip')).not.toBeInTheDocument()
})
})
describe('StintHistory', () => {
it('renders the compound sequence with lap counts', () => {
render(
<StintHistory
stints={[
{ Compound: 'MEDIUM', New: true, Laps: 12 },
{ Compound: 'HARD', New: false, Laps: 20 },
]}
/>,
)
const seq = screen.getByTestId('stint-seq')
expect(seq).toHaveTextContent('M')
expect(seq).toHaveTextContent('12')
expect(seq).toHaveTextContent('H')
expect(seq).toHaveTextContent('20')
})
it('renders a dash without stint data', () => {
render(<StintHistory stints={undefined} />)
expect(screen.getByText('-')).toBeInTheDocument()
})
})
describe('GapSparkline', () => {
it('shows a placeholder with too few samples', () => {
render(<GapSparkline samples={[1.0]} />)
expect(screen.queryByTestId('gap-spark')).not.toBeInTheDocument()
})
it('renders a closing indicator when the gap shrinks', () => {
render(<GapSparkline samples={[2.0, 1.8, 1.5, 1.2, 0.9, 0.6]} />)
expect(screen.getByTestId('gap-spark')).toHaveClass('trend-closing')
expect(screen.getByTitle('Gap closing')).toBeInTheDocument()
})
})
describe('BattleChips', () => {
it('renders chip labels for detected battles', () => {
const battles = detectBattles(
[makeRow('1', 1, 'VER'), makeRow('4', 2, 'NOR', { Interval: '+0.4' })],
'Race',
)
render(<BattleChips battles={battles} />)
expect(screen.getByTestId('battle-chips')).toHaveTextContent('VER ⚔ NOR +0.4')
})
it('renders nothing when there are no battles', () => {
render(<BattleChips battles={[]} />)
expect(screen.queryByTestId('battle-chips')).not.toBeInTheDocument()
})
})
describe('TimingTower', () => {
const rows = [
makeRow('1', 1, 'VER'),
makeRow('4', 2, 'NOR', { Interval: '+0.4', GapToLeader: '+0.4' }),
makeRow('16', 3, 'LEC', { Interval: '+3.2', GapToLeader: '+3.6' }),
]
it('highlights battle rows and marks pinned drivers', () => {
render(
<TimingTower
rows={rows}
battleNumbers={new Set(['1', '4'])}
pinned={['16']}
onTogglePin={() => {}}
/>,
)
expect(screen.getByText('VER').closest('tr')).toHaveClass('battle-row')
expect(screen.getByText('NOR').closest('tr')).toHaveClass('battle-row')
const lecRow = screen.getByText('LEC').closest('tr')
expect(lecRow).not.toHaveClass('battle-row')
expect(lecRow).toHaveClass('pinned-row')
})
it('toggles a pin when a row is clicked', () => {
const onTogglePin = vi.fn()
render(<TimingTower rows={rows} onTogglePin={onTogglePin} />)
fireEvent.click(screen.getByText('NOR').closest('tr')!)
expect(onTogglePin).toHaveBeenCalledWith('4')
})
it('shows an empty notice without rows', () => {
render(<TimingTower rows={[]} />)
expect(screen.getByText(/no driver timing rows/i)).toBeInTheDocument()
})
})
describe('PinnedDrivers', () => {
it('renders pinned cards with gap and unpins on click', () => {
const onToggle = vi.fn()
render(
<PinnedDrivers
rows={[makeRow('4', 2, 'NOR', { Interval: '+0.4' })]}
history={{ '4': [1.0, 0.8, 0.6, 0.4] }}
pinned={['4']}
onToggle={onToggle}
/>,
)
const strip = screen.getByTestId('pinned-strip')
expect(strip).toHaveTextContent('NOR')
expect(strip).toHaveTextContent('+0.4')
fireEvent.click(screen.getByText('NOR').closest('button')!)
expect(onToggle).toHaveBeenCalledWith('4')
})
it('renders a fallback card when the driver is missing from the feed', () => {
render(
<PinnedDrivers rows={[]} history={{}} pinned={['44']} onToggle={() => {}} />,
)
expect(screen.getByTestId('pinned-strip')).toHaveTextContent('no data')
})
it('renders nothing without pins', () => {
render(<PinnedDrivers rows={[]} history={{}} pinned={[]} onToggle={() => {}} />)
expect(screen.queryByTestId('pinned-strip')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest'
import { battleLabel, battleNumbers, detectBattles, isRaceSession } from '../lib/battles'
import type { LiveTimingRow } from '../lib/live'
import type { LiveDriverData } from '../types'
function row(
number: string,
position: number,
interval: string,
tla: string,
overrides: Partial<LiveDriverData> = {},
): LiveTimingRow {
return {
RacingNumber: number,
Position: position,
Driver: {
RacingNumber: number,
Position: position,
Interval: interval,
GapToLeader: interval,
InPit: false,
PitOut: false,
Retired: false,
...overrides,
} as LiveDriverData,
Info: {
RacingNumber: number,
BroadcastName: '',
Tla: tla,
TeamName: '',
TeamColour: '',
FirstName: '',
LastName: '',
},
}
}
describe('isRaceSession', () => {
it('only treats race and sprint sessions as races', () => {
expect(isRaceSession('Race')).toBe(true)
expect(isRaceSession('Sprint')).toBe(true)
expect(isRaceSession('Qualifying')).toBe(false)
expect(isRaceSession('Practice')).toBe(false)
expect(isRaceSession('')).toBe(false)
expect(isRaceSession(undefined)).toBe(false)
})
})
describe('detectBattles', () => {
const rows = [
row('1', 1, '', 'VER'),
row('4', 2, '+0.4', 'NOR'),
row('81', 3, '+0.8', 'PIA'),
row('16', 4, '+5.0', 'LEC'),
row('44', 5, '+0.9', 'HAM'),
row('63', 6, '+12.2', 'RUS'),
]
it('groups consecutive cars within 1.0s in race sessions', () => {
const battles = detectBattles(rows, 'Race')
expect(battles).toHaveLength(2)
expect(battles[0].drivers.map((d) => d.code)).toEqual(['VER', 'NOR', 'PIA'])
expect(battles[0].minGap).toBeCloseTo(0.4)
expect(battles[1].drivers.map((d) => d.code)).toEqual(['LEC', 'HAM'])
expect(battles[1].minGap).toBeCloseTo(0.9)
})
it('returns nothing outside race sessions', () => {
expect(detectBattles(rows, 'Qualifying')).toEqual([])
expect(detectBattles(rows, undefined)).toEqual([])
})
it('excludes cars in the pits or retired', () => {
const pitted = [
row('1', 1, '', 'VER'),
row('4', 2, '+0.4', 'NOR', { InPit: true }),
row('81', 3, '+0.6', 'PIA'),
row('16', 4, '+8.0', 'LEC'),
row('44', 5, '+0.5', 'HAM', { Retired: true }),
]
const battles = detectBattles(pitted, 'Race')
expect(battles).toEqual([])
})
it('ignores lapped and missing intervals', () => {
const lapped = [
row('1', 1, '', 'VER'),
row('4', 2, '1L', 'NOR'),
row('81', 3, '', 'PIA'),
]
expect(detectBattles(lapped, 'Race')).toEqual([])
})
it('handles empty and single-row input', () => {
expect(detectBattles([], 'Race')).toEqual([])
expect(detectBattles([row('1', 1, '', 'VER')], 'Race')).toEqual([])
})
})
describe('battleLabel / battleNumbers', () => {
it('formats a two-car chip label', () => {
const battles = detectBattles([row('1', 1, '', 'VER'), row('4', 2, '+0.4', 'NOR')], 'Race')
expect(battleLabel(battles[0])).toBe('VER ⚔ NOR +0.4')
})
it('collects racing numbers across all battles', () => {
const battles = detectBattles(
[
row('1', 1, '', 'VER'),
row('4', 2, '+0.4', 'NOR'),
row('16', 3, '+9.0', 'LEC'),
row('44', 4, '+0.7', 'HAM'),
],
'Race',
)
expect(battleNumbers(battles)).toEqual(new Set(['1', '4', '16', '44']))
})
})

View File

@@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest'
import {
MAX_GAP_SAMPLES,
gapTrend,
parseIntervalSeconds,
recordGapSamples,
sparklinePoints,
} from '../lib/gapHistory'
import type { GapHistoryMap } from '../lib/gapHistory'
describe('parseIntervalSeconds', () => {
it('parses signed and unsigned decimal gaps', () => {
expect(parseIntervalSeconds('+1.234')).toBe(1.234)
expect(parseIntervalSeconds('1.234')).toBe(1.234)
expect(parseIntervalSeconds('+0.4')).toBe(0.4)
expect(parseIntervalSeconds('12')).toBe(12)
expect(parseIntervalSeconds('-0.5')).toBe(-0.5)
})
it('parses minute-form gaps', () => {
expect(parseIntervalSeconds('+1:05.234')).toBeCloseTo(65.234)
})
it('rejects leader, lapped, and garbage markers', () => {
expect(parseIntervalSeconds('')).toBeNull()
expect(parseIntervalSeconds(' ')).toBeNull()
expect(parseIntervalSeconds(undefined)).toBeNull()
expect(parseIntervalSeconds(null)).toBeNull()
expect(parseIntervalSeconds('LAP 12')).toBeNull()
expect(parseIntervalSeconds('1L')).toBeNull()
expect(parseIntervalSeconds('+1 LAP')).toBeNull()
expect(parseIntervalSeconds('2 LAPS')).toBeNull()
expect(parseIntervalSeconds('abc')).toBeNull()
})
})
describe('recordGapSamples', () => {
it('appends one sample per snapshot and does not mutate the input', () => {
const initial = { '1': [1.0] }
const next = recordGapSamples(initial, [
{ racingNumber: '1', interval: '+0.9' },
{ racingNumber: '4', interval: '+1.5' },
])
expect(next['1']).toEqual([1.0, 0.9])
expect(next['4']).toEqual([1.5])
expect(initial['1']).toEqual([1.0])
})
it('keeps existing history when the interval is unparsable', () => {
const next = recordGapSamples({ '1': [1.2, 1.1] }, [{ racingNumber: '1', interval: '1L' }])
expect(next['1']).toEqual([1.2, 1.1])
})
it('prunes drivers missing from the snapshot', () => {
const next = recordGapSamples({ '99': [3.0] }, [{ racingNumber: '1', interval: '+0.5' }])
expect(next['99']).toBeUndefined()
})
it('caps the ring buffer', () => {
let history: GapHistoryMap = {}
for (let i = 0; i < 50; i++) {
history = recordGapSamples(history, [{ racingNumber: '1', interval: `+${i}.0` }])
}
expect(history['1']).toHaveLength(MAX_GAP_SAMPLES)
expect(history['1'][MAX_GAP_SAMPLES - 1]).toBe(49)
expect(history['1'][0]).toBe(50 - MAX_GAP_SAMPLES)
})
it('respects a custom cap', () => {
const next = recordGapSamples({ '1': [1, 2, 3] }, [{ racingNumber: '1', interval: '+4.0' }], 3)
expect(next['1']).toEqual([2, 3, 4])
})
})
describe('gapTrend', () => {
it('detects a closing gap', () => {
expect(gapTrend([2.0, 1.8, 1.6, 1.4, 1.2, 1.0])).toBe('closing')
})
it('detects an opening gap', () => {
expect(gapTrend([1.0, 1.2, 1.4, 1.6, 1.8, 2.0])).toBe('opening')
})
it('reports steady within the threshold', () => {
expect(gapTrend([1.0, 1.02, 0.98, 1.01, 1.0, 0.99])).toBe('steady')
})
it('needs at least three samples', () => {
expect(gapTrend([])).toBeNull()
expect(gapTrend([1.0])).toBeNull()
expect(gapTrend([1.0, 0.5])).toBeNull()
})
})
describe('sparklinePoints', () => {
it('returns empty for insufficient samples', () => {
expect(sparklinePoints([], 56, 14)).toBe('')
expect(sparklinePoints([1.0], 56, 14)).toBe('')
})
it('spans the full width and inverts the y axis (smaller gap = lower)', () => {
const points = sparklinePoints([0, 10], 56, 14).split(' ')
expect(points).toHaveLength(2)
const [x1, y1] = points[0].split(',').map(Number)
const [x2, y2] = points[1].split(',').map(Number)
expect(x1).toBe(0)
expect(x2).toBe(56)
expect(y1).toBeGreaterThan(y2) // larger gap plots higher (smaller y)
})
it('draws a mid line for flat data', () => {
const points = sparklinePoints([1, 1, 1], 56, 14).split(' ')
for (const point of points) {
expect(Number(point.split(',')[1])).toBe(7)
}
})
})

View File

@@ -1,14 +1,21 @@
import { describe, expect, it } from 'vitest'
import {
compoundClass,
compoundLetter,
extrapolateClock,
latestRaceControl,
loadPinnedDrivers,
positionDeltaClass,
parseLiveStateEvent,
rcFlagClass,
savePinnedDrivers,
sortLiveTimingRows,
togglePin,
trackStatusInfo,
trackStatusLabel,
tyreClass,
tyreLabel,
windDirectionLabel,
} from '../lib/live'
import type { LiveStreamData } from '../types'
@@ -137,3 +144,70 @@ describe('live transforms', () => {
expect(extrapolateClock('01:20:00', '2026-05-25T12:00:00Z', true, Date.parse('2026-05-25T12:00:30Z'))).toBe('01:19:30')
})
})
describe('track status mapping', () => {
it('maps all known raw statuses to banner info', () => {
expect(trackStatusInfo('1')).toMatchObject({ key: 'green', label: 'TRACK CLEAR' })
expect(trackStatusInfo('2')).toMatchObject({ key: 'yellow', label: 'YELLOW FLAG' })
expect(trackStatusInfo('4')).toMatchObject({ key: 'sc', label: 'SAFETY CAR' })
expect(trackStatusInfo('5')).toMatchObject({ key: 'red', label: 'RED FLAG' })
expect(trackStatusInfo('6')).toMatchObject({ key: 'vsc', label: 'VIRTUAL SAFETY CAR' })
expect(trackStatusInfo('7')).toMatchObject({ key: 'vsc', label: 'VSC ENDING' })
})
it('falls back to a neutral display for unknown values', () => {
expect(trackStatusInfo('9')).toMatchObject({ key: 'unknown', label: 'TRACK STATUS 9' })
expect(trackStatusInfo('')).toMatchObject({ key: 'unknown', label: 'TRACK STATUS UNKNOWN' })
expect(trackStatusInfo(undefined)).toMatchObject({ key: 'unknown' })
})
it('keeps the compact label helper defensive', () => {
expect(trackStatusLabel('7')).toBe('VSC ENDING')
expect(trackStatusLabel('99')).toBe('99')
expect(trackStatusLabel('')).toBe('UNKNOWN')
})
})
describe('weather and stint helpers', () => {
it('maps wind direction degrees to compass points', () => {
expect(windDirectionLabel(0)).toBe('N')
expect(windDirectionLabel(90)).toBe('E')
expect(windDirectionLabel(180)).toBe('S')
expect(windDirectionLabel(315)).toBe('NW')
expect(windDirectionLabel(359)).toBe('N')
expect(windDirectionLabel(null)).toBe('')
})
it('maps stint compounds to classes and letters', () => {
expect(compoundClass('SOFT')).toBe('tyre-soft')
expect(compoundClass('INTERMEDIATE')).toBe('tyre-inter')
expect(compoundClass('')).toBe('tyre-unknown')
expect(compoundLetter('MEDIUM')).toBe('M')
expect(compoundLetter(undefined)).toBe('?')
})
})
describe('pinned drivers', () => {
it('toggles pins with a max of three, dropping the oldest', () => {
expect(togglePin([], '1')).toEqual(['1'])
expect(togglePin(['1'], '1')).toEqual([])
expect(togglePin(['1', '4'], '16')).toEqual(['1', '4', '16'])
expect(togglePin(['1', '4', '16'], '44')).toEqual(['4', '16', '44'])
expect(togglePin(['1', '4', '16'], '4')).toEqual(['1', '16'])
})
it('round-trips pins through storage and survives corrupt data', () => {
const store = new Map<string, string>()
const storage = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => void store.set(key, value),
}
savePinnedDrivers(['1', '44'], storage)
expect(loadPinnedDrivers(storage)).toEqual(['1', '44'])
store.set('box-box.live.pins', 'not json {{')
expect(loadPinnedDrivers(storage)).toEqual([])
store.set('box-box.live.pins', '{"nope":true}')
expect(loadPinnedDrivers(storage)).toEqual([])
})
})