From 2d49a8bd006ad2f23a6cdc606a5304b7f5d4fec2 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Sat, 4 Jul 2026 00:00:34 -0400 Subject: [PATCH] feat(#11): "What just happened" synthesized event rail Implemented by claude via .agents/dev dispatch. --- frontend/src/components/live/EventRail.tsx | 72 +++++ frontend/src/lib/events.ts | 318 +++++++++++++++++++++ frontend/src/pages/LiveTimingPage.tsx | 26 +- frontend/src/styles/event-rail.css | 67 +++++ frontend/src/test/events.test.ts | 266 +++++++++++++++++ 5 files changed, 748 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/live/EventRail.tsx create mode 100644 frontend/src/lib/events.ts create mode 100644 frontend/src/styles/event-rail.css create mode 100644 frontend/src/test/events.test.ts diff --git a/frontend/src/components/live/EventRail.tsx b/frontend/src/components/live/EventRail.tsx new file mode 100644 index 0000000..c8fa6c1 --- /dev/null +++ b/frontend/src/components/live/EventRail.tsx @@ -0,0 +1,72 @@ +import { useAutoAnimate } from '@formkit/auto-animate/react' +import { + Flag, + LogOut, + Megaphone, + OctagonX, + Swords, + Timer, + TrendingDown, + TrendingUp, + Wrench, + Zap, +} from 'lucide-react' +import type { LucideIcon } from 'lucide-react' +import type { LiveDriverInfo } from '../../types' +import type { LiveEvent, LiveEventKind } from '../../lib/events' +import { teamColor } from '../../utils' +import '../../styles/event-rail.css' + +interface Props { + events: LiveEvent[] + driverInfo?: Record +} + +const KIND_ICONS: Record = { + overtake: Swords, + 'position-gain': TrendingUp, + 'position-loss': TrendingDown, + 'pit-in': Wrench, + 'pit-out': LogOut, + 'personal-best': Timer, + 'fastest-lap': Zap, + retirement: OctagonX, + 'track-status': Flag, + 'race-control': Megaphone, +} + +export function EventRail({ events, driverInfo }: Props) { + const [listRef] = useAutoAnimate() + + return ( +
+
+ What Just Happened + {events.length > 0 && {events.length} events} +
+ {events.length === 0 ? ( +
Events will appear here as the session unfolds.
+ ) : ( +
+ {events.map((event) => { + const Icon = KIND_ICONS[event.kind] ?? Megaphone + const colour = driverInfo?.[event.racingNumbers[0]]?.TeamColour + const accent = event.racingNumbers.length > 0 ? teamColor(colour) : 'var(--border-2)' + return ( +
+ +
+ {event.headline} + {event.detail && {event.detail}} +
+ {event.lap > 0 && L{event.lap}} +
+ ) + })} +
+ )} +
+ ) +} diff --git a/frontend/src/lib/events.ts b/frontend/src/lib/events.ts new file mode 100644 index 0000000..3f1db43 --- /dev/null +++ b/frontend/src/lib/events.ts @@ -0,0 +1,318 @@ +// "What just happened" event synthesis for the live page. +// Pure functions only — no React, no side effects — so everything is unit-testable. +// Mirrors the gapHistory/battles precedent: successive LiveStreamData snapshots +// in, typed events out; the page owns the buffer, this module owns the diffing. + +import type { + LiveDriverData, + LiveRCMessage, + LiveSessionMeta, + LiveStreamData, +} from '../types' + +export const MAX_EVENTS = 30 + +export type LiveEventKind = + | 'overtake' + | 'position-gain' + | 'position-loss' + | 'pit-in' + | 'pit-out' + | 'personal-best' + | 'fastest-lap' + | 'retirement' + | 'track-status' + | 'race-control' + +export interface LiveEvent { + id: string + kind: LiveEventKind + lap: number + timestamp: string + headline: string + detail?: string + racingNumbers: string[] +} + +/** + * Identity of the running session, used to reset the event buffer when a new + * session starts. LiveSessionMeta carries no numeric session key, so the + * meeting + session name/type triple stands in for one. + */ +export function sessionSignature(session: LiveSessionMeta | null | undefined): string { + if (!session) return '' + return [session.MeetingName, session.SessionName, session.SessionType].join('|') +} + +/** + * Prepend freshly diffed events onto the newest-first buffer, dropping any + * whose id is already present (re-renders and replayed diffs must not + * duplicate) and evicting the oldest events beyond `cap`. + */ +export function appendEvents( + buffer: ReadonlyArray, + incoming: ReadonlyArray, + cap = MAX_EVENTS, +): LiveEvent[] { + const seen = new Set(buffer.map((event) => event.id)) + const fresh = incoming.filter((event) => { + if (seen.has(event.id)) return false + seen.add(event.id) + return true + }) + if (fresh.length === 0) return [...buffer] + // Within one diff the array is oldest-first; reverse so the last-produced + // event ends up at the top of the newest-first rail. + return [...fresh.reverse(), ...buffer].slice(0, cap) +} + +const TRACK_STATUS_HEADLINES: Record = { + '1': 'Green flag — track clear', + '2': 'Yellow flag', + '4': 'Safety Car deployed', + '5': 'Red flag — session stopped', + '6': 'Virtual Safety Car deployed', + '7': 'Virtual Safety Car ending', +} + +const RC_CATEGORIES = new Set(['flag', 'safetycar', 'drs']) + +/** + * Diff two successive live snapshots into a chronological list of synthesized + * events. Callers are expected to feed genuinely consecutive snapshots of the + * same session; diffing across a session boundary is the caller's job to + * prevent (see sessionSignature). + */ +export function diffSnapshots(prev: LiveStreamData, next: LiveStreamData): LiveEvent[] { + if (!prev || !next || prev === next) return [] + + const events: LiveEvent[] = [] + const lap = next.CurrentLap || 0 + const timestamp = next.Clock || '' + const code = (racingNumber: string): string => + next.DriverInfo?.[racingNumber]?.Tla || + prev.DriverInfo?.[racingNumber]?.Tla || + `#${racingNumber}` + + diffTrackStatus(prev, next, lap, timestamp, events) + diffRaceControl(prev, next, events) + + const prevDrivers = prev.Drivers ?? {} + const nextDrivers = next.Drivers ?? {} + const numbers = Object.keys(nextDrivers).filter((number) => prevDrivers[number]) + + // Racing numbers whose position loss is already explained by an overtake + // event, so we don't also report a generic "drops to Pn". + const overtakenNumbers = new Set() + + for (const number of numbers) { + const before = prevDrivers[number] + const after = nextDrivers[number] + + if (!before.Retired && after.Retired) { + events.push({ + id: `ret:${number}`, + kind: 'retirement', + lap, + timestamp, + headline: `${code(number)} retires`, + detail: before.Position > 0 ? `Was running P${before.Position}` : undefined, + racingNumbers: [number], + }) + } + + if (!before.InPit && after.InPit && !after.Retired) { + const fromPosition = before.Position > 0 ? ` from P${before.Position}` : '' + events.push({ + id: `pit-in:${number}:l${lap}`, + kind: 'pit-in', + lap, + timestamp, + headline: `${code(number)} pits${fromPosition}`, + racingNumbers: [number], + }) + } + + if (before.InPit && !after.InPit && !after.Retired) { + const compound = next.Tyres?.[number]?.Compound || '' + const stops = Math.max(0, (next.Stints?.[number]?.length ?? 0) - 1) + const parts = [compound, stops > 0 ? `${ordinal(stops)} stop` : ''].filter(Boolean) + const rejoined = after.Position > 0 ? ` P${after.Position}` : '' + events.push({ + id: `pit-out:${number}:l${lap}`, + kind: 'pit-out', + lap, + timestamp, + headline: `${code(number)} rejoins${rejoined}${parts.length > 0 ? ` (${parts.join(', ')})` : ''}`, + racingNumbers: [number], + }) + } + + diffBestLap(number, before, after, code, lap, timestamp, events) + } + + // Position gains, with overtake attribution via position swaps. + for (const number of numbers) { + const before = prevDrivers[number] + const after = nextDrivers[number] + if (before.Position <= 0 || after.Position <= 0) continue + if (after.Position >= before.Position) continue + if (after.InPit || after.Retired) continue + + const overtaken = numbers.find((other) => { + if (other === number) return false + const otherBefore = prevDrivers[other] + const otherAfter = nextDrivers[other] + return ( + otherBefore.Position === after.Position && + otherAfter.Position > otherBefore.Position && + !otherAfter.InPit && + !otherAfter.Retired + ) + }) + + if (overtaken) { + overtakenNumbers.add(overtaken) + events.push({ + id: `overtake:${number}:${overtaken}:p${after.Position}:l${lap}`, + kind: 'overtake', + lap, + timestamp, + headline: `${code(number)} overtakes ${code(overtaken)} for P${after.Position}`, + detail: before.Position - after.Position > 1 ? `Up from P${before.Position}` : undefined, + racingNumbers: [number, overtaken], + }) + } else { + events.push({ + id: `pos:${number}:${before.Position}-${after.Position}:l${lap}`, + kind: 'position-gain', + lap, + timestamp, + headline: `${code(number)} moves up to P${after.Position} (from P${before.Position})`, + racingNumbers: [number], + }) + } + } + + // Position losses not already explained by an overtake or a pit visit. + for (const number of numbers) { + const before = prevDrivers[number] + const after = nextDrivers[number] + if (before.Position <= 0 || after.Position <= 0) continue + if (after.Position <= before.Position) continue + if (overtakenNumbers.has(number)) continue + if (after.InPit || after.PitOut || after.Retired || before.InPit) continue + + events.push({ + id: `pos:${number}:${before.Position}-${after.Position}:l${lap}`, + kind: 'position-loss', + lap, + timestamp, + headline: `${code(number)} drops to P${after.Position} (from P${before.Position})`, + racingNumbers: [number], + }) + } + + return events +} + +function diffTrackStatus( + prev: LiveStreamData, + next: LiveStreamData, + lap: number, + timestamp: string, + events: LiveEvent[], +): void { + if (!prev.TrackStatus || !next.TrackStatus) return + if (prev.TrackStatus === next.TrackStatus) return + events.push({ + id: `track:${next.TrackStatus}:l${lap}`, + kind: 'track-status', + lap, + timestamp, + headline: TRACK_STATUS_HEADLINES[next.TrackStatus] ?? `Track status ${next.TrackStatus}`, + racingNumbers: [], + }) +} + +function diffRaceControl(prev: LiveStreamData, next: LiveStreamData, events: LiveEvent[]): void { + const prevMessages = prev.RCMessages ?? [] + const seen = new Set(prevMessages.map(rcMessageKey)) + for (const message of next.RCMessages ?? []) { + if (!message.Message || seen.has(rcMessageKey(message))) continue + if (!RC_CATEGORIES.has((message.Category || '').toLowerCase())) continue + events.push({ + id: `rc:${rcMessageKey(message)}`, + kind: 'race-control', + lap: message.Lap || 0, + timestamp: message.Time || '', + headline: humanizeRaceControl(message.Message), + racingNumbers: carNumbersIn(message.Message), + }) + } +} + +function diffBestLap( + number: string, + before: LiveDriverData, + after: LiveDriverData, + code: (racingNumber: string) => string, + lap: number, + timestamp: string, + events: LiveEvent[], +): void { + if (!after.LastLapTime) return + const isNewLap = after.LastLapTime !== before.LastLapTime + + if (after.LastLapOB && (isNewLap || !before.LastLapOB)) { + events.push({ + id: `ob:${number}:${after.LastLapTime}`, + kind: 'fastest-lap', + lap, + timestamp, + headline: `${code(number)} sets the fastest lap — ${after.LastLapTime}`, + racingNumbers: [number], + }) + return + } + + if (after.LastLapPB && !after.LastLapOB && (isNewLap || !before.LastLapPB)) { + events.push({ + id: `pb:${number}:${after.LastLapTime}`, + kind: 'personal-best', + lap, + timestamp, + headline: `${code(number)} sets a personal best — ${after.LastLapTime}`, + racingNumbers: [number], + }) + } +} + +function rcMessageKey(message: LiveRCMessage): string { + return `${message.Time}:${message.Message}` +} + +/** "SAFETY CAR DEPLOYED" -> "Safety car deployed", keeping known acronyms upper-cased. */ +export function humanizeRaceControl(message: string): string { + const lower = message.trim().toLowerCase() + if (!lower) return '' + const sentence = lower.charAt(0).toUpperCase() + lower.slice(1) + return sentence.replace(/\b(drs|vsc|sc|fia)\b/gi, (acronym) => acronym.toUpperCase()) +} + +function carNumbersIn(message: string): string[] { + const numbers: string[] = [] + for (const match of message.matchAll(/CAR (\d+)/gi)) { + if (!numbers.includes(match[1])) numbers.push(match[1]) + } + return numbers +} + +function ordinal(n: number): string { + const rem10 = n % 10 + const rem100 = n % 100 + if (rem10 === 1 && rem100 !== 11) return `${n}st` + if (rem10 === 2 && rem100 !== 12) return `${n}nd` + if (rem10 === 3 && rem100 !== 13) return `${n}rd` + return `${n}th` +} diff --git a/frontend/src/pages/LiveTimingPage.tsx b/frontend/src/pages/LiveTimingPage.tsx index fd478eb..4d4aa20 100644 --- a/frontend/src/pages/LiveTimingPage.tsx +++ b/frontend/src/pages/LiveTimingPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { fetchLiveState, fetchLiveTrackOutline } from '../api' import type { LivePosition, LiveStreamData } from '../types' @@ -15,12 +15,15 @@ import type { VisibleSectorState } from '../lib/live' import type { GapHistoryMap } from '../lib/gapHistory' import { recordGapSamples } from '../lib/gapHistory' import { battleNumbers, detectBattles } from '../lib/battles' +import type { LiveEvent } from '../lib/events' +import { appendEvents, diffSnapshots, sessionSignature } from '../lib/events' 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' +import { EventRail } from '../components/live/EventRail' import { TrackMap } from '../components/live/TrackMap' import { Radio } from 'lucide-react' @@ -35,6 +38,9 @@ export function LiveTimingPage() { const [pinned, setPinned] = useState(() => loadPinnedDrivers()) const [visibleSectors, setVisibleSectors] = useState({}) const [positions, setPositions] = useState>({}) + const [events, setEvents] = useState([]) + const prevSnapshotRef = useRef(null) + const sessionSigRef = useRef('') const { data, isLoading, isError, error } = useQuery({ queryKey: ['live-state'], @@ -112,6 +118,23 @@ export function LiveTimingPage() { } }, []) + // Synthesize "what just happened" events from successive snapshots; the + // buffer resets whenever a different session starts streaming. + useEffect(() => { + if (!snapshot) return + const signature = sessionSignature(snapshot.Session) + const prev = prevSnapshotRef.current + prevSnapshotRef.current = snapshot + if (signature !== sessionSigRef.current) { + sessionSigRef.current = signature + setEvents([]) + return + } + if (!prev || prev === snapshot) return + const fresh = diffSnapshots(prev, snapshot) + if (fresh.length > 0) setEvents((current) => appendEvents(current, fresh)) + }, [snapshot]) + const rawRows = useMemo(() => sortLiveTimingRows(snapshot), [snapshot]) useEffect(() => { @@ -212,6 +235,7 @@ export function LiveTimingPage() {
+
diff --git a/frontend/src/styles/event-rail.css b/frontend/src/styles/event-rail.css new file mode 100644 index 0000000..1ed33ae --- /dev/null +++ b/frontend/src/styles/event-rail.css @@ -0,0 +1,67 @@ +/* ── "What just happened" event rail (live page) ── */ + +.event-rail { + margin-top: var(--s5); +} + +.event-rail-list { + display: flex; + flex-direction: column; + gap: var(--s3); + padding-top: var(--s4); +} + +.event-rail-row { + display: flex; + align-items: flex-start; + gap: var(--s4); + padding: var(--s3) var(--s4); + border-left: 3px solid var(--border-2); + background: rgba(255, 255, 255, 0.03); + border-radius: 0 4px 4px 0; +} + +.event-rail-icon { + display: inline-flex; + align-items: center; + color: var(--text-2); + padding-top: 1px; + flex-shrink: 0; +} + +.event-kind-fastest-lap .event-rail-icon { color: var(--purple); } +.event-kind-personal-best .event-rail-icon { color: var(--green); } +.event-kind-overtake .event-rail-icon, +.event-kind-position-gain .event-rail-icon { color: var(--green); } +.event-kind-position-loss .event-rail-icon { color: var(--yellow); } +.event-kind-retirement .event-rail-icon, +.event-kind-track-status .event-rail-icon { color: var(--red); } +.event-kind-pit-in .event-rail-icon, +.event-kind-pit-out .event-rail-icon { color: var(--blue); } + +.event-rail-body { + display: flex; + flex-direction: column; + gap: var(--s1); + min-width: 0; + flex: 1; +} + +.event-rail-headline { + font-size: 12px; + color: var(--text); + line-height: 1.4; +} + +.event-rail-detail { + font-size: 11px; + color: var(--text-2); +} + +.event-rail-lap { + font-family: var(--f-mono); + font-size: 10px; + color: var(--text-3); + flex-shrink: 0; + padding-top: 2px; +} diff --git a/frontend/src/test/events.test.ts b/frontend/src/test/events.test.ts new file mode 100644 index 0000000..4bcdbe1 --- /dev/null +++ b/frontend/src/test/events.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_EVENTS, + appendEvents, + diffSnapshots, + humanizeRaceControl, + sessionSignature, +} from '../lib/events' +import type { LiveEvent } from '../lib/events' +import type { + LiveDriverData, + LiveDriverInfo, + LiveRCMessage, + LiveStreamData, +} from '../types' + +function driver(number: string, position: number, overrides: Partial = {}): LiveDriverData { + return { + RacingNumber: number, + Position: position, + PrevPosition: position, + GapToLeader: '', + Interval: '', + LastLapTime: '', + LastLapPB: false, + LastLapOB: false, + BestLapTime: '', + BestLapPB: false, + BestLapOB: false, + BestLapNum: 0, + InPit: false, + PitOut: false, + Retired: false, + KnockedOut: false, + Cutoff: false, + OnFlyingLap: false, + NumberOfLaps: 10, + SpeedTrap: '', + Sectors: [], + ...overrides, + } +} + +function info(number: string, tla: string): LiveDriverInfo { + return { + RacingNumber: number, + BroadcastName: tla, + Tla: tla, + TeamName: '', + TeamColour: '3671C6', + FirstName: '', + LastName: '', + } +} + +function snapshot( + drivers: Record, + overrides: Partial = {}, +): LiveStreamData { + return { + Drivers: drivers, + DriverInfo: { + '1': info('1', 'VER'), + '4': info('4', 'NOR'), + '44': info('44', 'HAM'), + }, + Tyres: {}, + RCMessages: [], + Weather: {} as LiveStreamData['Weather'], + Session: { + MeetingName: 'Test Grand Prix', + CircuitName: 'Test Circuit', + SessionType: 'Race', + SessionName: 'Race', + }, + TrackStatus: '1', + CurrentLap: 12, + TotalLaps: 57, + Clock: '01:23:45', + ClockRefTime: '', + ClockExtrapolating: false, + Stints: {}, + ...overrides, + } +} + +function rc(message: string, category: string, overrides: Partial = {}): LiveRCMessage { + return { Time: '2026-07-03T14:00:00Z', Category: category, Flag: '', Message: message, Lap: 12, ...overrides } +} + +describe('diffSnapshots', () => { + it('returns no events for identical snapshots', () => { + const prev = snapshot({ '1': driver('1', 1), '44': driver('44', 2) }) + const next = snapshot({ '1': driver('1', 1), '44': driver('44', 2) }) + expect(diffSnapshots(prev, next)).toEqual([]) + }) + + it('reports an overtake when two drivers swap positions', () => { + const prev = snapshot({ '1': driver('1', 3), '44': driver('44', 2) }) + const next = snapshot({ '1': driver('1', 2), '44': driver('44', 3) }) + const events = diffSnapshots(prev, next) + expect(events).toHaveLength(1) + expect(events[0].kind).toBe('overtake') + expect(events[0].headline).toBe('VER overtakes HAM for P2') + expect(events[0].racingNumbers).toEqual(['1', '44']) + expect(events[0].lap).toBe(12) + }) + + it('reports a generic gain when the displaced driver pitted instead', () => { + const prev = snapshot({ '1': driver('1', 3), '44': driver('44', 2) }) + const next = snapshot({ '1': driver('1', 2), '44': driver('44', 5, { InPit: true }) }) + const events = diffSnapshots(prev, next) + const gain = events.find((event) => event.kind === 'position-gain') + expect(gain?.headline).toBe('VER moves up to P2 (from P3)') + // HAM's drop is covered by the pit-in event, not a position-loss one. + expect(events.some((event) => event.kind === 'position-loss')).toBe(false) + expect(events.some((event) => event.kind === 'pit-in')).toBe(true) + }) + + it('reports a position loss not explained by an overtake or pit stop', () => { + const prev = snapshot({ '44': driver('44', 3) }) + const next = snapshot({ '44': driver('44', 6) }) + const events = diffSnapshots(prev, next) + expect(events).toHaveLength(1) + expect(events[0].kind).toBe('position-loss') + expect(events[0].headline).toBe('HAM drops to P6 (from P3)') + }) + + it('reports pit entry with the position pitted from', () => { + const prev = snapshot({ '4': driver('4', 3) }) + const next = snapshot({ '4': driver('4', 3, { InPit: true }) }) + const events = diffSnapshots(prev, next) + expect(events).toHaveLength(1) + expect(events[0].kind).toBe('pit-in') + expect(events[0].headline).toBe('NOR pits from P3') + expect(events[0].racingNumbers).toEqual(['4']) + }) + + it('reports pit exit with rejoin position, compound, and stop count', () => { + const prev = snapshot({ '4': driver('4', 3, { InPit: true }) }) + const next = snapshot( + { '4': driver('4', 6, { PitOut: true }) }, + { + Tyres: { '4': { Compound: 'MEDIUM', New: true, Age: 0 } }, + Stints: { + '4': [ + { Compound: 'SOFT', New: true, Laps: 12 }, + { Compound: 'HARD', New: true, Laps: 20 }, + { Compound: 'MEDIUM', New: true, Laps: 0 }, + ], + }, + }, + ) + const events = diffSnapshots(prev, next) + expect(events).toHaveLength(1) + expect(events[0].kind).toBe('pit-out') + expect(events[0].headline).toBe('NOR rejoins P6 (MEDIUM, 2nd stop)') + }) + + it('reports personal and overall best laps once per lap time', () => { + const prev = snapshot({ + '1': driver('1', 1, { LastLapTime: '1:24.500' }), + '4': driver('4', 2, { LastLapTime: '1:25.100' }), + }) + const next = snapshot({ + '1': driver('1', 1, { LastLapTime: '1:23.456', LastLapPB: true, LastLapOB: true }), + '4': driver('4', 2, { LastLapTime: '1:24.900', LastLapPB: true }), + }) + const events = diffSnapshots(prev, next) + const fastest = events.find((event) => event.kind === 'fastest-lap') + const personal = events.find((event) => event.kind === 'personal-best') + expect(fastest?.headline).toBe('VER sets the fastest lap — 1:23.456') + expect(personal?.headline).toBe('NOR sets a personal best — 1:24.900') + // A repeat diff of the same state produces nothing new. + expect(diffSnapshots(next, snapshot(next.Drivers))).toEqual([]) + }) + + it('reports a retirement with the last running position', () => { + const prev = snapshot({ '44': driver('44', 5) }) + const next = snapshot({ '44': driver('44', 5, { Retired: true }) }) + const events = diffSnapshots(prev, next) + expect(events).toHaveLength(1) + expect(events[0].kind).toBe('retirement') + expect(events[0].headline).toBe('HAM retires') + expect(events[0].detail).toBe('Was running P5') + }) + + it('reports track status transitions as human sentences', () => { + const prev = snapshot({ '1': driver('1', 1) }) + const next = snapshot({ '1': driver('1', 1) }, { TrackStatus: '4' }) + const events = diffSnapshots(prev, next) + expect(events).toHaveLength(1) + expect(events[0].kind).toBe('track-status') + expect(events[0].headline).toBe('Safety Car deployed') + }) + + it('reports new Flag/SafetyCar/Drs race-control messages and skips other categories', () => { + const existing = rc('YELLOW IN TRACK SECTOR 7', 'Flag', { Time: '2026-07-03T13:00:00Z' }) + const prev = snapshot({ '1': driver('1', 1) }, { RCMessages: [existing] }) + const next = snapshot({ '1': driver('1', 1) }, { + RCMessages: [ + existing, + rc('DRS ENABLED', 'Drs'), + rc('FIA STEWARDS: CAR 4 (NOR) UNDER INVESTIGATION', 'Other'), + ], + }) + const events = diffSnapshots(prev, next) + expect(events).toHaveLength(1) + expect(events[0].kind).toBe('race-control') + expect(events[0].headline).toBe('DRS enabled') + }) + + it('produces stable ids across repeated diffs of the same snapshots', () => { + const prev = snapshot({ '1': driver('1', 3), '44': driver('44', 2) }) + const next = snapshot({ '1': driver('1', 2), '44': driver('44', 3) }) + const first = diffSnapshots(prev, next) + const second = diffSnapshots(prev, next) + expect(first.map((event) => event.id)).toEqual(second.map((event) => event.id)) + }) +}) + +describe('humanizeRaceControl', () => { + it('sentence-cases while preserving acronyms', () => { + expect(humanizeRaceControl('SAFETY CAR DEPLOYED')).toBe('Safety car deployed') + expect(humanizeRaceControl('DRS ENABLED')).toBe('DRS enabled') + expect(humanizeRaceControl('VSC DEPLOYED')).toBe('VSC deployed') + }) +}) + +describe('appendEvents', () => { + const event = (id: string): LiveEvent => ({ + id, + kind: 'race-control', + lap: 1, + timestamp: '', + headline: id, + racingNumbers: [], + }) + + it('prepends new events newest-first and dedups by id', () => { + const buffer = appendEvents([event('a')], [event('a'), event('b'), event('c')]) + expect(buffer.map((entry) => entry.id)).toEqual(['c', 'b', 'a']) + // Re-appending the same diff output changes nothing. + const again = appendEvents(buffer, [event('b'), event('c')]) + expect(again.map((entry) => entry.id)).toEqual(['c', 'b', 'a']) + }) + + it('evicts the oldest events beyond the cap', () => { + let buffer: LiveEvent[] = [] + for (let index = 0; index < MAX_EVENTS + 5; index += 1) { + buffer = appendEvents(buffer, [event(`event-${index}`)]) + } + expect(buffer).toHaveLength(MAX_EVENTS) + expect(buffer[0].id).toBe(`event-${MAX_EVENTS + 4}`) + expect(buffer[buffer.length - 1].id).toBe('event-5') + }) +}) + +describe('sessionSignature', () => { + it('changes when the session changes, enabling buffer resets', () => { + const race = snapshot({}).Session + expect(sessionSignature(race)).toBe('Test Grand Prix|Race|Race') + expect(sessionSignature({ ...race, SessionName: 'Qualifying' })).not.toBe(sessionSignature(race)) + expect(sessionSignature(null)).toBe('') + }) +})