mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
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:
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user