diff --git a/frontend/src/components/live/BattleChips.tsx b/frontend/src/components/live/BattleChips.tsx new file mode 100644 index 0000000..bbc927c --- /dev/null +++ b/frontend/src/components/live/BattleChips.tsx @@ -0,0 +1,21 @@ +import type { Battle } from '../../lib/battles' +import { battleLabel } from '../../lib/battles' + +interface Props { + battles: Battle[] +} + +export function BattleChips({ battles }: Props) { + if (battles.length === 0) return null + + return ( +
+ Battles + {battles.map((battle) => ( + d.racingNumber).join('-')}> + {battleLabel(battle)} + + ))} +
+ ) +} diff --git a/frontend/src/components/live/GapSparkline.tsx b/frontend/src/components/live/GapSparkline.tsx new file mode 100644 index 0000000..7eb8c99 --- /dev/null +++ b/frontend/src/components/live/GapSparkline.tsx @@ -0,0 +1,44 @@ +import { gapTrend, sparklinePoints } from '../../lib/gapHistory' + +interface Props { + samples: number[] | undefined + width?: number + height?: number +} + +export function GapSparkline({ samples, width = 56, height = 14 }: Props) { + if (!samples || samples.length < 2) { + return · + } + + const trend = gapTrend(samples) + const points = sparklinePoints(samples, width, height) + + return ( + + + {trend === 'closing' && ( + + )} + {trend === 'opening' && ( + + )} + + ) +} diff --git a/frontend/src/components/live/PinnedDrivers.tsx b/frontend/src/components/live/PinnedDrivers.tsx new file mode 100644 index 0000000..3fcf735 --- /dev/null +++ b/frontend/src/components/live/PinnedDrivers.tsx @@ -0,0 +1,63 @@ +import { teamColor } from '../../utils' +import type { LiveTimingRow } from '../../lib/live' +import { driverCode, tyreClass, tyreLabel } from '../../lib/live' +import type { GapHistoryMap } from '../../lib/gapHistory' +import { GapSparkline } from './GapSparkline' + +interface Props { + rows: LiveTimingRow[] + history: GapHistoryMap + pinned: string[] + onToggle: (racingNumber: string) => void +} + +export function PinnedDrivers({ rows, history, pinned, onToggle }: Props) { + if (pinned.length === 0) return null + + const rowsByNumber = new Map(rows.map((row) => [row.RacingNumber, row])) + + return ( +
+ {pinned.map((number) => { + const row = rowsByNumber.get(number) + if (!row) { + return ( + + ) + } + + const driver = row.Driver + const gap = driver.Interval || driver.GapToLeader || '-' + return ( + + ) + })} +
+ ) +} diff --git a/frontend/src/components/live/SessionBanner.tsx b/frontend/src/components/live/SessionBanner.tsx index 559644c..7426b61 100644 --- a/frontend/src/components/live/SessionBanner.tsx +++ b/frontend/src/components/live/SessionBanner.tsx @@ -1,5 +1,6 @@ import type { LiveStreamData } from '../../types' -import { extrapolateClock, trackStatusClass, trackStatusLabel } from '../../lib/live' +import { extrapolateClock } from '../../lib/live' +import { WeatherStrip } from './WeatherStrip' interface Props { isLive: boolean @@ -11,9 +12,6 @@ interface Props { export function SessionBanner({ isLive, snapshot, connection, now }: Props) { const session = snapshot.Session const clock = extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now) - const status = snapshot.TrackStatus ? trackStatusLabel(snapshot.TrackStatus) : '' - const weather = snapshot.Weather - const hasWeather = weather && (weather.AirTemp > 0 || weather.TrackTemp > 0) return (
@@ -28,7 +26,6 @@ export function SessionBanner({ isLive, snapshot, connection, now }: Props) {
- {status && {status}} L{snapshot.CurrentLap || '-'}/{snapshot.TotalLaps || '-'} @@ -36,15 +33,7 @@ export function SessionBanner({ isLive, snapshot, connection, now }: Props) { {isLive ? 'live' : 'stale'}
- {hasWeather && ( -
- {weather.AirTemp.toFixed(0)}° air - {weather.TrackTemp.toFixed(0)}° track - {weather.Humidity > 0 && {weather.Humidity.toFixed(0)}% humidity} - {weather.WindSpeed > 0 && {weather.WindSpeed.toFixed(1)} m/s} - {weather.Rainfall && WET} -
- )} +
) } diff --git a/frontend/src/components/live/StintHistory.tsx b/frontend/src/components/live/StintHistory.tsx new file mode 100644 index 0000000..cbc97d5 --- /dev/null +++ b/frontend/src/components/live/StintHistory.tsx @@ -0,0 +1,29 @@ +import type { LiveStintData } from '../../types' +import { compoundClass, compoundLetter } from '../../lib/live' + +interface Props { + stints: LiveStintData[] | undefined +} + +export function StintHistory({ stints }: Props) { + if (!stints || stints.length === 0) { + return - + } + + return ( + + {stints.map((stint, index) => ( + + {index > 0 && } + + {compoundLetter(stint.Compound)} + + {stint.Laps > 0 && {stint.Laps}} + + ))} + + ) +} diff --git a/frontend/src/components/live/TimingTower.tsx b/frontend/src/components/live/TimingTower.tsx index 164203c..039ff13 100644 --- a/frontend/src/components/live/TimingTower.tsx +++ b/frontend/src/components/live/TimingTower.tsx @@ -1,16 +1,24 @@ import { teamColor } from '../../utils' -import type { LiveStreamData } from '../../types' +import type { LiveStintData } from '../../types' +import type { LiveTimingRow } from '../../lib/live' import { driverCode, positionDelta, positionDeltaClass, - sortLiveTimingRows, tyreClass, tyreLabel, } from '../../lib/live' +import type { GapHistoryMap } from '../../lib/gapHistory' +import { GapSparkline } from './GapSparkline' +import { StintHistory } from './StintHistory' interface Props { - snapshot: LiveStreamData + rows: LiveTimingRow[] + stints?: Record + history?: GapHistoryMap + battleNumbers?: Set + pinned?: string[] + onTogglePin?: (racingNumber: string) => void } function posClass(pos: number): string { @@ -20,9 +28,14 @@ function posClass(pos: number): string { return 'pos-n' } -export function TimingTower({ snapshot }: Props) { - const rows = sortLiveTimingRows(snapshot) - +export function TimingTower({ + rows, + stints, + history, + battleNumbers, + pinned, + onTogglePin, +}: Props) { if (rows.length === 0) { return (
@@ -33,7 +46,7 @@ export function TimingTower({ snapshot }: Props) { return (
- +
@@ -42,7 +55,9 @@ export function TimingTower({ snapshot }: Props) { + + @@ -51,6 +66,8 @@ export function TimingTower({ snapshot }: Props) { const driver = row.Driver const delta = positionDelta(driver) const deltaClass = positionDeltaClass(driver) + const isPinned = pinned?.includes(row.RacingNumber) ?? false + const inBattle = battleNumbers?.has(row.RacingNumber) ?? false return ( onTogglePin(row.RacingNumber) : undefined} + title={onTogglePin ? (isPinned ? 'Click to unpin' : 'Click to pin (max 3)') : undefined} > @@ -67,6 +89,7 @@ export function TimingTower({ snapshot }: Props) {
{driverCode(row)} {row.RacingNumber} + {isPinned && } {driver.InPit && PIT} {driver.PitOut && !driver.InPit && OUT} {driver.Retired && RET} @@ -82,9 +105,15 @@ export function TimingTower({ snapshot }: Props) { {driver.LastLapTime || '-'}
+ + ) diff --git a/frontend/src/components/live/TrackStatusBanner.tsx b/frontend/src/components/live/TrackStatusBanner.tsx new file mode 100644 index 0000000..20c41da --- /dev/null +++ b/frontend/src/components/live/TrackStatusBanner.tsx @@ -0,0 +1,17 @@ +import { trackStatusInfo } from '../../lib/live' + +interface Props { + status: string | null | undefined +} + +export function TrackStatusBanner({ status }: Props) { + if (!status) return null + const info = trackStatusInfo(status) + + return ( +
+ {info.label} + {info.detail && {info.detail}} +
+ ) +} diff --git a/frontend/src/components/live/WeatherStrip.tsx b/frontend/src/components/live/WeatherStrip.tsx new file mode 100644 index 0000000..a3baa4a --- /dev/null +++ b/frontend/src/components/live/WeatherStrip.tsx @@ -0,0 +1,51 @@ +import type { LiveWeatherData } from '../../types' +import { windDirectionLabel } from '../../lib/live' + +interface Props { + weather: LiveWeatherData | null | undefined +} + +export function WeatherStrip({ weather }: Props) { + if (!weather) return null + const hasData = + weather.AirTemp > 0 || + weather.TrackTemp > 0 || + weather.Humidity > 0 || + weather.WindSpeed > 0 || + weather.Rainfall + if (!hasData) return null + + const windDir = windDirectionLabel(weather.WindDir) + + return ( +
+ {weather.AirTemp > 0 && ( + + air + {weather.AirTemp.toFixed(0)}°C + + )} + {weather.TrackTemp > 0 && ( + + track + {weather.TrackTemp.toFixed(0)}°C + + )} + {weather.Humidity > 0 && ( + + hum + {weather.Humidity.toFixed(0)}% + + )} + {weather.WindSpeed > 0 && ( + + wind + + {weather.WindSpeed.toFixed(1)} m/s{windDir ? ` ${windDir}` : ''} + + + )} + {weather.Rainfall && RAIN} +
+ ) +} diff --git a/frontend/src/lib/battles.ts b/frontend/src/lib/battles.ts new file mode 100644 index 0000000..2096f3b --- /dev/null +++ b/frontend/src/lib/battles.ts @@ -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, + 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): Set { + const numbers = new Set() + for (const battle of battles) { + for (const driver of battle.drivers) numbers.add(driver.racingNumber) + } + return numbers +} diff --git a/frontend/src/lib/gapHistory.ts b/frontend/src/lib/gapHistory.ts new file mode 100644 index 0000000..bdc34f9 --- /dev/null +++ b/frontend/src/lib/gapHistory.ts @@ -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 + +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, + 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, 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) => 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, + 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(' ') +} diff --git a/frontend/src/lib/live.ts b/frontend/src/lib/live.ts index a7f38ea..c76251b 100644 --- a/frontend/src/lib/live.ts +++ b/frontend/src/lib/live.ts @@ -21,6 +21,35 @@ const TRACK_STATUS_LABELS: Record = { '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 { @@ -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, 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[] { diff --git a/frontend/src/pages/LiveTimingPage.tsx b/frontend/src/pages/LiveTimingPage.tsx index d0c17e5..d261738 100644 --- a/frontend/src/pages/LiveTimingPage.tsx +++ b/frontend/src/pages/LiveTimingPage.tsx @@ -1,10 +1,22 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { fetchLiveState } from '../api' import type { LiveStreamData } from '../types' -import { parseLiveStateEvent } from '../lib/live' +import { + loadPinnedDrivers, + parseLiveStateEvent, + savePinnedDrivers, + sortLiveTimingRows, + togglePin, +} from '../lib/live' +import type { GapHistoryMap } from '../lib/gapHistory' +import { recordGapSamples } from '../lib/gapHistory' +import { battleNumbers, detectBattles } from '../lib/battles' import { SessionBanner } from '../components/live/SessionBanner' +import { TrackStatusBanner } from '../components/live/TrackStatusBanner' import { TimingTower } from '../components/live/TimingTower' +import { BattleChips } from '../components/live/BattleChips' +import { PinnedDrivers } from '../components/live/PinnedDrivers' import { RaceControlFeed } from '../components/live/RaceControlFeed' type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error' @@ -14,6 +26,8 @@ export function LiveTimingPage() { const [isLive, setIsLive] = useState(false) const [streamStatus, setStreamStatus] = useState('connecting') const [now, setNow] = useState(Date.now()) + const [gapHistory, setGapHistory] = useState({}) + const [pinned, setPinned] = useState(() => loadPinnedDrivers()) const { data, isLoading, isError, error } = useQuery({ queryKey: ['live-state'], @@ -68,6 +82,33 @@ export function LiveTimingPage() { } }, []) + const rows = useMemo(() => sortLiveTimingRows(snapshot), [snapshot]) + + // One interval sample per received snapshot, ring-buffered per driver. + useEffect(() => { + if (rows.length === 0) return + setGapHistory((prev) => + recordGapSamples( + prev, + rows.map((row) => ({ racingNumber: row.RacingNumber, interval: row.Driver.Interval || '' })), + ), + ) + }, [rows]) + + useEffect(() => { + savePinnedDrivers(pinned) + }, [pinned]) + + const battles = useMemo( + () => detectBattles(rows, snapshot?.Session?.SessionType), + [rows, snapshot?.Session?.SessionType], + ) + const inBattle = useMemo(() => battleNumbers(battles), [battles]) + + const handleTogglePin = (racingNumber: string) => { + setPinned((prev) => togglePin(prev, racingNumber)) + } + return (
{isError && ( @@ -101,12 +142,23 @@ export function LiveTimingPage() { {snapshot && ( <> + +
Timing Tower + {pinned.length > 0 && {pinned.length}/3 pinned}
- + +
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 71e67ed..be335e0 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -661,6 +661,178 @@ a { color: inherit; text-decoration: none; } .tyre-wet { background: var(--tyre-wet); color: #fff; } .tyre-unknown { background: var(--surface-2); color: var(--text-2); border: 1px solid var(--border-2); } +/* ── Track status banner ── */ +.track-banner { + display: flex; + align-items: baseline; + gap: var(--s4); + width: 100%; + padding: var(--s3) var(--s5); + margin-bottom: var(--s5); + border: 1px solid var(--border-2); + border-left-width: 3px; + border-radius: 2px; + font-family: var(--f-mono); +} + +.track-banner-label { + font-size: 13px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; + white-space: nowrap; +} + +.track-banner-detail { + font-size: 11px; + opacity: 0.75; + min-width: 0; +} + +.track-banner-green { background: rgba(57,199,58,.08); border-color: rgba(57,199,58,.3); color: var(--green); } +.track-banner-yellow { background: rgba(255,214,0,.12); border-color: rgba(255,214,0,.4); color: var(--yellow); } +.track-banner-sc { background: rgba(255,152,0,.12); border-color: rgba(255,152,0,.4); color: #ffb84d; } +.track-banner-vsc { background: rgba(194,120,255,.12); border-color: rgba(194,120,255,.4); color: var(--purple); } +.track-banner-red { background: rgba(225,6,0,.14); border-color: rgba(225,6,0,.5); color: #ff6b6b; animation: track-banner-pulse 1.6s ease-in-out infinite; } +.track-banner-unknown { background: var(--surface-2); border-color: var(--border-2); color: var(--text-2); } + +@keyframes track-banner-pulse { + 0%, 100% { background: rgba(225,6,0,.14); } + 50% { background: rgba(225,6,0,.26); } +} + +/* ── Weather strip items ── */ +.weather-item { + display: inline-flex; + align-items: baseline; + gap: var(--s2); + white-space: nowrap; +} +.weather-k { color: var(--text-3); text-transform: uppercase; letter-spacing: 0.08em; font-size: 9px; } +.weather-v { color: var(--text-2); font-family: var(--f-mono); font-size: 11px; } + +/* ── Gap trend sparkline ── */ +.gap-spark { + display: inline-flex; + align-items: center; + gap: var(--s2); + color: var(--text-3); + line-height: 1; +} +.gap-spark-empty { color: var(--text-3); } +.gap-spark-svg { display: block; } +.gap-spark.trend-closing { color: var(--green); } +.gap-spark.trend-opening { color: #ff6b6b; } +.gap-spark.trend-steady { color: var(--text-3); } + +.trend-arrow { font-size: 8px; line-height: 1; } +.trend-arrow-closing { color: var(--green); } +.trend-arrow-opening { color: #ff6b6b; } + +.spark-cell { line-height: 0; } + +/* ── Battle highlighting ── */ +.live-tower tr.battle-row td { background: rgba(255,214,0,.045); } +.live-tower tr.battle-row td:first-child { box-shadow: inset 2px 0 0 rgba(255,214,0,.55); } + +.battle-chips { + display: flex; + align-items: center; + gap: var(--s3); + flex-wrap: wrap; + margin-bottom: var(--s3); +} + +.battle-chips-label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-3); +} + +.battle-chip { + padding: 2px 7px; + font-size: 11px; + border-radius: 2px; + background: rgba(255,214,0,.08); + border: 1px solid rgba(255,214,0,.25); + color: var(--yellow); + white-space: nowrap; +} + +/* ── Pinned drivers ── */ +.live-tower tr.pinnable { cursor: pointer; } +.live-tower tr.pinned-row td { background: rgba(0,128,255,.08); } +.live-tower tr.pinned-row td:first-child { box-shadow: inset 2px 0 0 rgba(0,128,255,.55); } +.live-tower tr.battle-row.pinned-row td:first-child { + box-shadow: inset 2px 0 0 rgba(0,128,255,.55), inset 4px 0 0 rgba(255,214,0,.55); +} + +.pin-mark { color: #66aaff; font-size: 10px; line-height: 1; } + +.pinned-strip { + display: flex; + gap: var(--s3); + flex-wrap: wrap; + margin-bottom: var(--s5); +} + +.pinned-card { + display: inline-flex; + align-items: center; + gap: var(--s3); + padding: var(--s2) var(--s4); + background: var(--surface); + border: 1px solid rgba(0,128,255,.26); + border-radius: 2px; + color: var(--text); + font: inherit; + cursor: pointer; + transition: background 0.1s, border-color 0.1s; +} +.pinned-card:hover { background: var(--surface-h); border-color: rgba(0,128,255,.45); } +.pinned-card .drv-bar { height: 16px; } + +.pinned-pos { font-size: 11px; color: var(--text-2); } +.pinned-gap { font-size: 11px; color: var(--text-2); min-width: 44px; text-align: right; } +.pinned-nodata { font-size: 10px; color: var(--text-3); font-family: var(--f-mono); } +.pinned-unpin { color: var(--text-3); font-size: 12px; line-height: 1; } +.pinned-card:hover .pinned-unpin { color: #ff6b6b; } +.pinned-card-missing { border-color: var(--border-2); opacity: 0.75; } + +/* ── Stint history ── */ +.stint-seq { + display: inline-flex; + align-items: center; + gap: 3px; + white-space: nowrap; +} + +.stint-item { display: inline-flex; align-items: center; gap: 2px; } +.stint-arrow { color: var(--text-3); font-size: 9px; margin-right: 2px; } + +.stint-dot { + display: inline-flex; + align-items: center; + justify-content: center; + width: 13px; + height: 13px; + border-radius: 50%; + font-family: var(--f-mono); + font-size: 8px; + font-weight: 700; + line-height: 1; +} + +.stint-laps { + font-family: var(--f-mono); + font-size: 9px; + color: var(--text-3); +} + +.stint-empty { color: var(--text-3); font-family: var(--f-mono); } + .live-rc { margin-bottom: var(--s7); } .live-rc-list { @@ -2470,6 +2642,22 @@ a { color: inherit; text-decoration: none; } .live-weather-strip { gap: var(--s3); } .live-rc-scroll { max-height: 220px; } + .track-banner { padding: var(--s2) var(--s4); } + .track-banner-label { font-size: 12px; } + .track-banner-detail { display: none; } + + .battle-chips, + .pinned-strip { + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + padding-bottom: var(--s2); + } + .battle-chips::-webkit-scrollbar, + .pinned-strip::-webkit-scrollbar { display: none; } + .pinned-card { flex-shrink: 0; } + .dataset-strip { flex-wrap: nowrap; overflow-x: auto; diff --git a/frontend/src/test/LiveComponents.test.tsx b/frontend/src/test/LiveComponents.test.tsx new file mode 100644 index 0000000..b8425d4 --- /dev/null +++ b/frontend/src/test/LiveComponents.test.tsx @@ -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 = {}, +): 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() + 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() + 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() + 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() + 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( + , + ) + expect(screen.queryByTestId('weather-strip')).not.toBeInTheDocument() + }) + + it('hides when weather is missing entirely', () => { + render() + expect(screen.queryByTestId('weather-strip')).not.toBeInTheDocument() + }) +}) + +describe('StintHistory', () => { + it('renders the compound sequence with lap counts', () => { + render( + , + ) + 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() + expect(screen.getByText('-')).toBeInTheDocument() + }) +}) + +describe('GapSparkline', () => { + it('shows a placeholder with too few samples', () => { + render() + expect(screen.queryByTestId('gap-spark')).not.toBeInTheDocument() + }) + + it('renders a closing indicator when the gap shrinks', () => { + render() + 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() + expect(screen.getByTestId('battle-chips')).toHaveTextContent('VER ⚔ NOR +0.4') + }) + + it('renders nothing when there are no battles', () => { + render() + 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( + {}} + />, + ) + 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() + fireEvent.click(screen.getByText('NOR').closest('tr')!) + expect(onTogglePin).toHaveBeenCalledWith('4') + }) + + it('shows an empty notice without rows', () => { + render() + 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( + , + ) + 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( + {}} />, + ) + expect(screen.getByTestId('pinned-strip')).toHaveTextContent('no data') + }) + + it('renders nothing without pins', () => { + render( {}} />) + expect(screen.queryByTestId('pinned-strip')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/test/battles.test.ts b/frontend/src/test/battles.test.ts new file mode 100644 index 0000000..b956d87 --- /dev/null +++ b/frontend/src/test/battles.test.ts @@ -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 = {}, +): 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'])) + }) +}) diff --git a/frontend/src/test/gapHistory.test.ts b/frontend/src/test/gapHistory.test.ts new file mode 100644 index 0000000..b66e927 --- /dev/null +++ b/frontend/src/test/gapHistory.test.ts @@ -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) + } + }) +}) diff --git a/frontend/src/test/live.test.ts b/frontend/src/test/live.test.ts index 78c7a52..e3a94bf 100644 --- a/frontend/src/test/live.test.ts +++ b/frontend/src/test/live.test.ts @@ -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() + 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([]) + }) +})
PosTyre Last Lap GapTrend BestStints Laps
{row.Position} {delta}{driver.GapToLeader || driver.Interval || '-'} + + {driver.BestLapTime || '-'} + + {driver.NumberOfLaps || '-'}