import type { LiveDriverData, LiveDriverInfo, LiveRCMessage, LiveSectorData, LiveSessionMeta, LiveStateResponse, LiveStreamData, LiveTyreData, } from '../types' export interface LiveTimingRow { RacingNumber: string Position: number Driver: LiveDriverData Info?: LiveDriverInfo Tyre?: LiveTyreData } export interface LiveSessionDisplay { isRace: boolean isQualifying: boolean isSprintQualifying: boolean phase: 1 | 2 | 3 | null phaseLabel: string cutoffPosition: number | null advanceCount: number | null atRiskStart: number | null atRiskEnd: number | null } export interface VisibleSectorEntry { lap: number lastLapTime: string completed: boolean sectors: LiveSectorData[] } export type VisibleSectorState = Record const TRACK_STATUS_LABELS: Record = { '1': 'GREEN', '2': 'YELLOW', '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 = { '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 { try { const parsed = JSON.parse(data) as LiveStateResponse return typeof parsed === 'object' && parsed !== null ? parsed : null } catch { return null } } export function sortLiveTimingRows(snapshot: LiveStreamData | null | undefined): LiveTimingRow[] { if (!snapshot) return [] const rowsByNumber = new Map() for (const [number, driver] of Object.entries(snapshot.Drivers ?? {})) { rowsByNumber.set(number, { RacingNumber: driver.RacingNumber || number, Position: driver.Position || 0, Driver: { ...driver, RacingNumber: driver.RacingNumber || number }, Info: snapshot.DriverInfo?.[number], Tyre: snapshot.Tyres?.[number], }) } for (const [number, info] of Object.entries(snapshot.DriverInfo ?? {})) { if (!rowsByNumber.has(number)) { rowsByNumber.set(number, { RacingNumber: info.RacingNumber || number, Position: 0, Driver: { RacingNumber: info.RacingNumber || number, Position: 0, } as LiveDriverData, Info: info, Tyre: snapshot.Tyres?.[number], }) } } const rows = [...rowsByNumber.values()] rows.sort((a, b) => { if (a.Position > 0 && b.Position > 0) return a.Position - b.Position if (a.Position > 0) return -1 if (b.Position > 0) return 1 const aBest = a.Driver.BestLapTime || '' const bBest = b.Driver.BestLapTime || '' if (aBest && bBest) return aBest.localeCompare(bBest) if (aBest) return -1 if (bBest) return 1 return Number(a.RacingNumber) - Number(b.RacingNumber) }) return rows.map((row, index) => ({ ...row, Position: row.Position || index + 1, })) } export function liveSessionDisplay( session: LiveSessionMeta | null | undefined, rows: ReadonlyArray, ): LiveSessionDisplay { const text = [session?.SessionName, session?.SessionType].filter(Boolean).join(' ').toLowerCase() const isQualifying = /\bs?q\s*[123]\b/i.test(text) || text.includes('qualifying') || text.includes('shootout') const isSprintQualifying = /\bsq\s*[123]\b/i.test(text) || text.includes('sprint qualifying') || text.includes('sprint shootout') const isRace = !isQualifying && (text.includes('race') || /\bsprint\b/i.test(text)) const phase = isQualifying ? explicitQualifyingPhase(text) ?? inferredQualifyingPhase(rows) : null const prefix = isSprintQualifying ? 'SQ' : 'Q' const cutoffPosition = qualifyingCutoffPosition(rows.length, phase) const atRiskStart = cutoffPosition === null ? null : cutoffPosition + 1 return { isRace, isQualifying, isSprintQualifying, phase, phaseLabel: phase ? `${prefix}${phase}` : isQualifying ? prefix : '', cutoffPosition, advanceCount: cutoffPosition, atRiskStart, atRiskEnd: cutoffPosition === null ? null : rows.length, } } function explicitQualifyingPhase(text: string): 1 | 2 | 3 | null { const match = text.match(/\b(?:s?q|qualifying)\s*([123])\b/i) if (!match) return null const phase = Number(match[1]) return phase === 1 || phase === 2 || phase === 3 ? phase : null } function inferredQualifyingPhase(rows: ReadonlyArray): 1 | 2 | 3 { if (rows.length === 0) return 1 const knockedOut = rows.filter((row) => row.Driver.KnockedOut).length if (knockedOut >= Math.max(0, rows.length - 10)) return 3 if (knockedOut >= 5) return 2 if (rows.length <= 10) return 3 if (rows.length <= 18) return 2 return 1 } export function qualifyingCutoffPosition(totalRows: number, phase: 1 | 2 | 3 | null): number | null { if (phase === 1 && totalRows > 5) return totalRows - 5 if (phase === 2 && totalRows > 10) return 10 return null } function emptySector(): LiveSectorData { return { Value: '', PersonalFastest: false, OverallFastest: false } } function sectorHasValue(sector: LiveSectorData | undefined): boolean { return Boolean(sector?.Value) } function normalizeSectors(sectors: ReadonlyArray | undefined): LiveSectorData[] { return [0, 1, 2].map((index) => sectors?.[index] ?? emptySector()) } export function mergeVisibleSectors( previous: VisibleSectorState, rows: ReadonlyArray, ): VisibleSectorState { const next: VisibleSectorState = {} for (const row of rows) { const driver = row.Driver const prior = previous[row.RacingNumber] const incoming = normalizeSectors(driver.Sectors) const hasIncoming = incoming.some(sectorHasValue) const lap = driver.NumberOfLaps || prior?.lap || 0 const lapAdvanced = Boolean(prior && driver.NumberOfLaps > prior.lap) const lastLapChanged = Boolean( prior && driver.LastLapTime && driver.LastLapTime !== prior.lastLapTime, ) if ((lapAdvanced || lastLapChanged || prior?.completed) && !hasIncoming) { continue } if (!prior && !hasIncoming) continue const merged = lapAdvanced || lastLapChanged ? normalizeSectors(undefined) : normalizeSectors(prior?.sectors) for (let index = 0; index < 3; index += 1) { if (sectorHasValue(incoming[index])) { merged[index] = incoming[index] } } const hasMerged = merged.some(sectorHasValue) if (!hasMerged) continue next[row.RacingNumber] = { lap, lastLapTime: driver.LastLapTime || prior?.lastLapTime || '', completed: sectorHasValue(merged[2]) || (!driver.OnFlyingLap && lastLapChanged), sectors: merged, } } return next } export function rowsWithVisibleSectors( rows: ReadonlyArray, visibleSectors: VisibleSectorState, ): LiveTimingRow[] { return rows.map((row) => { const sectors = visibleSectors[row.RacingNumber]?.sectors if (!sectors) return row return { ...row, Driver: { ...row.Driver, Sectors: sectors, }, } }) } export function driverCode(row: LiveTimingRow): string { return row.Info?.Tla || row.RacingNumber } /** * Parse an F1 lap-time string ("1:45.944", "45.944") into total seconds. * Returns null for empty/invalid values so callers never invent a gap. */ export function parseLapTimeSeconds(value: string | null | undefined): number | null { if (!value) return null const match = value.trim().match(/^(?:(\d+):)?([0-5]?\d(?:\.\d+)?)$/) if (!match) return null const minutes = match[1] ? Number(match[1]) : 0 const seconds = Number(match[2]) if (!Number.isFinite(minutes) || !Number.isFinite(seconds)) return null return minutes * 60 + seconds } export interface BestLapGap { isLeader: boolean gap: string } /** * Display-only gap-to-P1 for practice/qualifying, derived from each driver's * valid best lap. Only drivers with a parseable best lap get an entry, and the * fastest is flagged as the leader. Never fabricates a gap from a missing or * invalid lap — the upstream interval remains the source of truth for races. */ export function bestLapGaps(rows: ReadonlyArray): Record { let leaderNumber = '' let best = Infinity for (const row of rows) { const seconds = parseLapTimeSeconds(row.Driver.BestLapTime) if (seconds === null) continue if (seconds < best) { best = seconds leaderNumber = row.RacingNumber } } const out: Record = {} if (!Number.isFinite(best)) return out for (const row of rows) { const seconds = parseLapTimeSeconds(row.Driver.BestLapTime) if (seconds === null) continue out[row.RacingNumber] = row.RacingNumber === leaderNumber ? { isLeader: true, gap: '' } : { isLeader: false, gap: `+${(seconds - best).toFixed(3)}` } } return out } /** True when the live feed reports an in-progress session with timing data. */ export function isLiveSessionActive( state: { is_live?: boolean; data?: unknown } | null | undefined, ): boolean { return Boolean(state?.is_live && state.data) } export function trackStatusLabel(status: string): string { return TRACK_STATUS_LABELS[status] || status || 'UNKNOWN' } export function positionDelta(driver: LiveDriverData): string { if (!driver.PrevPosition || !driver.Position || driver.PrevPosition === driver.Position) return '' return driver.PrevPosition > driver.Position ? '▲' : '▼' } export function positionDeltaClass(driver: LiveDriverData): string { if (!driver.PrevPosition || !driver.Position || driver.PrevPosition === driver.Position) return '' return driver.PrevPosition > driver.Position ? 'pos-gain' : 'pos-loss' } const RC_FLAG_CSS: Record = { GREEN: 'rc-flag-green', YELLOW: 'rc-flag-yellow', 'DOUBLE YELLOW': 'rc-flag-yellow', RED: 'rc-flag-red', BLUE: 'rc-flag-blue', BLACK: 'rc-flag-black', 'BLACK AND ORANGE': 'rc-flag-black', 'BLACK AND WHITE': 'rc-flag-black', SC: 'rc-flag-sc', 'SAFETY CAR': 'rc-flag-sc', VSC: 'rc-flag-vsc', 'VIRTUAL SAFETY CAR': 'rc-flag-vsc', CHEQUERED: 'rc-flag-chequered', CHECKERED: 'rc-flag-chequered', } export function rcFlagClass(flag: string): string { if (!flag) return '' return RC_FLAG_CSS[flag.toUpperCase()] ?? '' } export function tyreLabel(tyre: LiveTyreData | undefined): string { if (!tyre) return '?' const compound = tyre.Compound?.charAt(0) || '?' 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 { 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, 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 | 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, storage: Pick | 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[] { return [...(messages ?? [])].reverse().slice(0, limit) } export function extrapolateClock(clock: string, refTime: string, extrapolating: boolean, now = Date.now()): string { if (!clock || !extrapolating || !refTime) return clock || '' const parts = clock.split(':').map(Number) if (parts.length !== 3 || parts.some((part) => !Number.isFinite(part))) return clock const refMs = new Date(refTime).getTime() if (!Number.isFinite(refMs)) return clock const totalSeconds = parts[0] * 3600 + parts[1] * 60 + parts[2] const elapsed = Math.max(0, (now - refMs) / 1000) const remaining = Math.max(0, totalSeconds - elapsed) const hours = Math.floor(remaining / 3600) const minutes = Math.floor((remaining % 3600) / 60) const seconds = Math.floor(remaining % 60) return [hours, minutes, seconds].map((part) => String(part).padStart(2, '0')).join(':') }