import { useEffect, useMemo, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { fetchLiveState, fetchLiveTrackOutline } from '../api' import type { LivePosition, LiveStreamData } from '../types' import { loadPinnedDrivers, mergeVisibleSectors, parseLiveStateEvent, rowsWithVisibleSectors, savePinnedDrivers, sortLiveTimingRows, togglePin, } from '../lib/live' 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 { TeamRadioTicker } from '../components/live/TeamRadioTicker' import { TrackMap } from '../components/live/TrackMap' import { TyreDegPanel } from '../components/live/TyreDegPanel' import { Radio } from 'lucide-react' type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error' export function LiveTimingPage() { const [snapshot, setSnapshot] = useState(null) 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 [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'], queryFn: fetchLiveState, staleTime: 5_000, }) const trackOutlineQuery = useQuery({ queryKey: [ 'live-track-outline', snapshot?.Session?.MeetingName ?? '', snapshot?.Session?.CircuitName ?? '', ], queryFn: () => fetchLiveTrackOutline(snapshot!.Session), enabled: Boolean(snapshot?.Session?.MeetingName || snapshot?.Session?.CircuitName), staleTime: Infinity, retry: false, }) useEffect(() => { if (!data) return setIsLive(data.is_live) setSnapshot(data.data) }, [data]) useEffect(() => { const timer = window.setInterval(() => setNow(Date.now()), 1000) return () => window.clearInterval(timer) }, []) useEffect(() => { if (!('EventSource' in window)) { setStreamStatus('error') return } let cancelled = false const events = new EventSource('/api/v1/live/stream') setStreamStatus('connecting') events.onopen = () => { if (!cancelled) setStreamStatus('connected') } events.addEventListener('snapshot', (event) => { const state = parseLiveStateEvent(event.data) if (!state || cancelled) return setIsLive(state.is_live) setSnapshot(state.data) setStreamStatus('connected') }) events.addEventListener('heartbeat', () => { if (!cancelled) setStreamStatus('connected') }) events.addEventListener('positions', (event) => { if (cancelled) return try { const parsed = JSON.parse(event.data) as Record setPositions(parsed && typeof parsed === 'object' ? parsed : {}) setStreamStatus('connected') } catch { // Ignore malformed transient frames; the next 4Hz update will replace it. } }) events.onerror = () => { if (!cancelled) setStreamStatus('disconnected') } return () => { cancelled = true events.close() } }, []) // 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(() => { if (rawRows.length === 0) { setVisibleSectors({}) return } setVisibleSectors((prev) => mergeVisibleSectors(prev, rawRows)) }, [rawRows]) const rows = useMemo(() => rowsWithVisibleSectors(rawRows, visibleSectors), [rawRows, visibleSectors]) // 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 && (
{error instanceof Error ? error.message : 'Failed to load live timing state'}
)} {streamStatus === 'disconnected' && snapshot && (
Stream disconnected — showing last received snapshot
)} {isLoading && !snapshot && (
connecting to live timing…
)} {!isLoading && !snapshot && (
{streamStatus}

No live session active

The telemetry feed is currently offline.

Check the Command Center for the weekend schedule or explore historical data in the Race Hub.

)} {snapshot && ( <>
Timing Tower {pinned.length > 0 && {pinned.length}/3 pinned}
)}
) }