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

107
frontend/src/lib/battles.ts Normal file
View File

@@ -0,0 +1,107 @@
// Battle detection for the live timing tower.
// Pure functions only — no React, no side effects — so everything is unit-testable.
import type { LiveTimingRow } from './live'
import { driverCode } from './live'
import { parseIntervalSeconds } from './gapHistory'
export const BATTLE_THRESHOLD_SECONDS = 1.0
export interface BattleDriver {
racingNumber: string
code: string
position: number
/** Interval to the car ahead within the group; null for the group leader. */
gapToAhead: number | null
}
export interface Battle {
drivers: BattleDriver[]
/** Tightest car-to-car interval within the group. */
minGap: number
}
/** Battles are only meaningful in race-type sessions (GP race, sprint). */
export function isRaceSession(sessionType: string | null | undefined): boolean {
if (!sessionType) return false
const type = sessionType.toLowerCase()
return type.includes('race') || type.includes('sprint')
}
function isEligible(row: LiveTimingRow): boolean {
const driver = row.Driver
return Boolean(driver) && !driver.InPit && !driver.Retired && row.Position > 0
}
/**
* Scan position-sorted tower rows and group consecutive cars racing within
* `threshold` seconds of the car ahead. Cars in the pits or retired break
* the chain, as do lapped/unparsable intervals.
*/
export function detectBattles(
rows: ReadonlyArray<LiveTimingRow>,
sessionType: string | null | undefined,
threshold = BATTLE_THRESHOLD_SECONDS,
): Battle[] {
if (!isRaceSession(sessionType) || rows.length < 2) return []
const battles: Battle[] = []
let current: BattleDriver[] | null = null
let minGap = Number.POSITIVE_INFINITY
const flush = () => {
if (current && current.length >= 2) {
battles.push({ drivers: current, minGap })
}
current = null
minGap = Number.POSITIVE_INFINITY
}
for (let i = 1; i < rows.length; i++) {
const ahead = rows[i - 1]
const row = rows[i]
const gap = parseIntervalSeconds(row.Driver?.Interval)
const inBattle =
gap !== null && gap >= 0 && gap <= threshold && isEligible(ahead) && isEligible(row)
if (!inBattle) {
flush()
continue
}
if (!current) {
current = [toBattleDriver(ahead, null)]
}
current.push(toBattleDriver(row, gap))
if (gap < minGap) minGap = gap
}
flush()
return battles
}
function toBattleDriver(row: LiveTimingRow, gapToAhead: number | null): BattleDriver {
return {
racingNumber: row.RacingNumber,
code: driverCode(row),
position: row.Position,
gapToAhead,
}
}
/** Chip label, e.g. "VER ⚔ NOR +0.4" or "VER ⚔ NOR ⚔ PIA +0.3". */
export function battleLabel(battle: Battle): string {
const codes = battle.drivers.map((driver) => driver.code).join(' ⚔ ')
const gap = Number.isFinite(battle.minGap) ? ` +${battle.minGap.toFixed(1)}` : ''
return `${codes}${gap}`
}
/** Racing numbers involved in any battle, for tower row highlighting. */
export function battleNumbers(battles: ReadonlyArray<Battle>): Set<string> {
const numbers = new Set<string>()
for (const battle of battles) {
for (const driver of battle.drivers) numbers.add(driver.racingNumber)
}
return numbers
}

View File

@@ -0,0 +1,108 @@
// Client-side gap/interval history tracking for the live timing tower.
// Pure functions only — no React, no side effects — so everything is unit-testable.
export const MAX_GAP_SAMPLES = 40
/** Per-driver ring buffer of interval samples (seconds), keyed by racing number. */
export type GapHistoryMap = Record<string, number[]>
export type GapTrend = 'closing' | 'opening' | 'steady'
/**
* Parse an F1 live-timing interval/gap string into seconds.
* Handles "+1.234", "1.234", "+1:05.678" (minute form) and rejects
* lapped/leader markers: "", "LAP 12", "1L", "+1 LAP", "2 LAPS", etc.
*/
export function parseIntervalSeconds(raw: string | null | undefined): number | null {
if (!raw) return null
const text = raw.trim()
if (!text) return null
// Lapped / leader markers are never numeric gaps.
if (/lap/i.test(text) || /^\+?\d+\s*L$/i.test(text)) return null
const match = text.match(/^([+-])?(?:(\d+):)?(\d+(?:\.\d+)?)$/)
if (!match) return null
const sign = match[1] === '-' ? -1 : 1
const minutes = match[2] ? Number(match[2]) : 0
const seconds = Number(match[3])
if (!Number.isFinite(minutes) || !Number.isFinite(seconds)) return null
return sign * (minutes * 60 + seconds)
}
export interface GapSampleInput {
racingNumber: string
interval: string
}
/**
* Record one snapshot's worth of interval samples.
* Returns a new map (input is not mutated). Drivers missing from `rows`
* are pruned; unparsable intervals keep the existing buffer untouched.
*/
export function recordGapSamples(
history: GapHistoryMap,
rows: ReadonlyArray<GapSampleInput>,
maxSamples = MAX_GAP_SAMPLES,
): GapHistoryMap {
const next: GapHistoryMap = {}
for (const row of rows) {
if (!row.racingNumber) continue
const existing = history[row.racingNumber] ?? []
const value = parseIntervalSeconds(row.interval)
if (value === null) {
if (existing.length > 0) next[row.racingNumber] = existing
continue
}
const samples = [...existing, value]
next[row.racingNumber] = samples.length > maxSamples ? samples.slice(samples.length - maxSamples) : samples
}
return next
}
/**
* Classify the recent trend of a gap buffer: is the driver closing on the
* car ahead, dropping back, or holding steady? Compares the mean of the
* older half vs the newer half of the most recent samples.
*/
export function gapTrend(samples: ReadonlyArray<number>, window = 10, threshold = 0.1): GapTrend | null {
if (!samples || samples.length < 3) return null
const recent = samples.slice(-window)
const mid = Math.floor(recent.length / 2)
const older = recent.slice(0, mid)
const newer = recent.slice(mid)
if (older.length === 0 || newer.length === 0) return null
const mean = (xs: ReadonlyArray<number>) => xs.reduce((a, b) => a + b, 0) / xs.length
const delta = mean(newer) - mean(older)
if (delta <= -threshold) return 'closing'
if (delta >= threshold) return 'opening'
return 'steady'
}
/**
* Compute SVG polyline points for a sparkline of the samples, fitted to
* width x height with a small vertical inset. Flat data draws a mid line.
*/
export function sparklinePoints(
samples: ReadonlyArray<number>,
width: number,
height: number,
inset = 1.5,
): string {
if (!samples || samples.length < 2) return ''
const min = Math.min(...samples)
const max = Math.max(...samples)
const span = max - min
const usable = height - inset * 2
const step = width / (samples.length - 1)
return samples
.map((value, index) => {
const x = index * step
const y = span === 0 ? height / 2 : inset + (1 - (value - min) / span) * usable
return `${x.toFixed(1)},${y.toFixed(1)}`
})
.join(' ')
}

View File

@@ -21,6 +21,35 @@ const TRACK_STATUS_LABELS: Record<string, string> = {
'4': 'SC',
'5': 'RED',
'6': 'VSC',
'7': 'VSC ENDING',
}
export interface TrackStatusInfo {
key: 'green' | 'yellow' | 'sc' | 'vsc' | 'red' | 'unknown'
label: string
detail: string
}
// Raw F1 SignalR TrackStatus.Status values (see internal/live/types.go):
// "1"=all clear, "2"=yellow, "4"=safety car, "5"=red, "6"=VSC, "7"=VSC ending.
// "3" has not been observed in the feed; unknown values fall through to a
// neutral display so a new encoding never breaks the banner.
const TRACK_STATUS_INFO: Record<string, TrackStatusInfo> = {
'1': { key: 'green', label: 'TRACK CLEAR', detail: 'Green flag — racing' },
'2': { key: 'yellow', label: 'YELLOW FLAG', detail: 'Caution on track' },
'4': { key: 'sc', label: 'SAFETY CAR', detail: 'Safety car deployed' },
'5': { key: 'red', label: 'RED FLAG', detail: 'Session stopped' },
'6': { key: 'vsc', label: 'VIRTUAL SAFETY CAR', detail: 'VSC deployed' },
'7': { key: 'vsc', label: 'VSC ENDING', detail: 'Virtual safety car ending' },
}
export function trackStatusInfo(status: string | null | undefined): TrackStatusInfo {
if (status && TRACK_STATUS_INFO[status]) return TRACK_STATUS_INFO[status]
return {
key: 'unknown',
label: status ? `TRACK STATUS ${status}` : 'TRACK STATUS UNKNOWN',
detail: '',
}
}
export function parseLiveStateEvent(data: string): LiveStateResponse | null {
@@ -90,10 +119,6 @@ export function trackStatusLabel(status: string): string {
return TRACK_STATUS_LABELS[status] || status || 'UNKNOWN'
}
export function trackStatusClass(status: string): string {
return `track-${trackStatusLabel(status).toLowerCase()}`
}
export function positionDelta(driver: LiveDriverData): string {
if (!driver.PrevPosition || !driver.Position || driver.PrevPosition === driver.Position) return ''
return driver.PrevPosition > driver.Position ? '▲' : '▼'
@@ -132,10 +157,68 @@ export function tyreLabel(tyre: LiveTyreData | undefined): string {
return `${compound} +${tyre.Age || 0}`
}
export function compoundClass(compound: string | null | undefined): string {
if (!compound) return 'tyre-unknown'
const normalized = compound.toLowerCase()
return `tyre-${normalized === 'intermediate' ? 'inter' : normalized}`
}
export function compoundLetter(compound: string | null | undefined): string {
return compound?.charAt(0).toUpperCase() || '?'
}
export function tyreClass(tyre: LiveTyreData | undefined): string {
if (!tyre?.Compound) return 'tyre-unknown'
const compound = tyre.Compound.toLowerCase()
return `tyre-${compound === 'intermediate' ? 'inter' : compound}`
return compoundClass(tyre?.Compound)
}
export function windDirectionLabel(degrees: number | null | undefined): string {
if (degrees == null || !Number.isFinite(degrees)) return ''
const points = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
const index = Math.round((((degrees % 360) + 360) % 360) / 45) % 8
return points[index]
}
export const MAX_PINNED_DRIVERS = 3
/**
* Toggle a driver pin. Unpins if already pinned; otherwise appends,
* dropping the oldest pin when at capacity so clicking always works.
*/
export function togglePin(pins: ReadonlyArray<string>, racingNumber: string, max = MAX_PINNED_DRIVERS): string[] {
if (!racingNumber) return [...pins]
if (pins.includes(racingNumber)) return pins.filter((pin) => pin !== racingNumber)
const next = [...pins, racingNumber]
return next.length > max ? next.slice(next.length - max) : next
}
const PINS_STORAGE_KEY = 'box-box.live.pins'
export function loadPinnedDrivers(storage: Pick<Storage, 'getItem'> | null = safeStorage()): string[] {
try {
const raw = storage?.getItem(PINS_STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter((pin): pin is string => typeof pin === 'string').slice(0, MAX_PINNED_DRIVERS)
} catch {
return []
}
}
export function savePinnedDrivers(pins: ReadonlyArray<string>, storage: Pick<Storage, 'setItem'> | null = safeStorage()): void {
try {
storage?.setItem(PINS_STORAGE_KEY, JSON.stringify(pins))
} catch {
// storage unavailable (private mode, SSR) — pins just won't persist
}
}
function safeStorage(): Storage | null {
try {
return typeof window !== 'undefined' ? window.localStorage : null
} catch {
return null
}
}
export function latestRaceControl(messages: LiveRCMessage[], limit = 10): LiveRCMessage[] {