2026-07-04 00:00:34 -04:00
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
2026-05-25 02:56:06 -04:00
|
|
|
import { useQuery } from '@tanstack/react-query'
|
2026-07-03 19:34:09 -04:00
|
|
|
import { fetchLiveState, fetchLiveTrackOutline } from '../api'
|
|
|
|
|
import type { LivePosition, LiveStreamData } from '../types'
|
2026-07-03 00:21:23 -04:00
|
|
|
import {
|
|
|
|
|
loadPinnedDrivers,
|
2026-07-03 12:43:45 -04:00
|
|
|
mergeVisibleSectors,
|
2026-07-03 00:21:23 -04:00
|
|
|
parseLiveStateEvent,
|
2026-07-03 12:43:45 -04:00
|
|
|
rowsWithVisibleSectors,
|
2026-07-03 00:21:23 -04:00
|
|
|
savePinnedDrivers,
|
|
|
|
|
sortLiveTimingRows,
|
|
|
|
|
togglePin,
|
|
|
|
|
} from '../lib/live'
|
2026-07-03 12:43:45 -04:00
|
|
|
import type { VisibleSectorState } from '../lib/live'
|
2026-07-03 00:21:23 -04:00
|
|
|
import type { GapHistoryMap } from '../lib/gapHistory'
|
|
|
|
|
import { recordGapSamples } from '../lib/gapHistory'
|
|
|
|
|
import { battleNumbers, detectBattles } from '../lib/battles'
|
2026-07-04 00:00:34 -04:00
|
|
|
import type { LiveEvent } from '../lib/events'
|
|
|
|
|
import { appendEvents, diffSnapshots, sessionSignature } from '../lib/events'
|
2026-05-25 02:56:06 -04:00
|
|
|
import { SessionBanner } from '../components/live/SessionBanner'
|
2026-07-03 00:21:23 -04:00
|
|
|
import { TrackStatusBanner } from '../components/live/TrackStatusBanner'
|
2026-05-25 02:56:06 -04:00
|
|
|
import { TimingTower } from '../components/live/TimingTower'
|
2026-07-03 00:21:23 -04:00
|
|
|
import { BattleChips } from '../components/live/BattleChips'
|
|
|
|
|
import { PinnedDrivers } from '../components/live/PinnedDrivers'
|
2026-05-25 02:56:06 -04:00
|
|
|
import { RaceControlFeed } from '../components/live/RaceControlFeed'
|
2026-07-04 00:00:34 -04:00
|
|
|
import { EventRail } from '../components/live/EventRail'
|
2026-07-03 23:56:19 -04:00
|
|
|
import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
|
2026-07-03 19:34:09 -04:00
|
|
|
import { TrackMap } from '../components/live/TrackMap'
|
2026-07-04 00:00:19 -04:00
|
|
|
import { TyreDegPanel } from '../components/live/TyreDegPanel'
|
2026-07-03 01:46:35 -04:00
|
|
|
import { Radio } from 'lucide-react'
|
2026-05-25 02:56:06 -04:00
|
|
|
|
|
|
|
|
type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
|
|
|
|
|
|
|
|
|
|
export function LiveTimingPage() {
|
|
|
|
|
const [snapshot, setSnapshot] = useState<LiveStreamData | null>(null)
|
|
|
|
|
const [isLive, setIsLive] = useState(false)
|
|
|
|
|
const [streamStatus, setStreamStatus] = useState<StreamStatus>('connecting')
|
|
|
|
|
const [now, setNow] = useState(Date.now())
|
2026-07-03 00:21:23 -04:00
|
|
|
const [gapHistory, setGapHistory] = useState<GapHistoryMap>({})
|
|
|
|
|
const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers())
|
2026-07-03 12:43:45 -04:00
|
|
|
const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({})
|
2026-07-03 19:34:09 -04:00
|
|
|
const [positions, setPositions] = useState<Record<string, LivePosition>>({})
|
2026-07-04 00:00:34 -04:00
|
|
|
const [events, setEvents] = useState<LiveEvent[]>([])
|
|
|
|
|
const prevSnapshotRef = useRef<LiveStreamData | null>(null)
|
|
|
|
|
const sessionSigRef = useRef('')
|
2026-05-25 02:56:06 -04:00
|
|
|
|
|
|
|
|
const { data, isLoading, isError, error } = useQuery({
|
|
|
|
|
queryKey: ['live-state'],
|
|
|
|
|
queryFn: fetchLiveState,
|
|
|
|
|
staleTime: 5_000,
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-03 19:34:09 -04:00
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-25 02:56:06 -04:00
|
|
|
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')
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-03 19:34:09 -04:00
|
|
|
events.addEventListener('positions', (event) => {
|
|
|
|
|
if (cancelled) return
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(event.data) as Record<string, LivePosition>
|
|
|
|
|
setPositions(parsed && typeof parsed === 'object' ? parsed : {})
|
|
|
|
|
setStreamStatus('connected')
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore malformed transient frames; the next 4Hz update will replace it.
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-25 02:56:06 -04:00
|
|
|
events.onerror = () => {
|
|
|
|
|
if (!cancelled) setStreamStatus('disconnected')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true
|
|
|
|
|
events.close()
|
|
|
|
|
}
|
|
|
|
|
}, [])
|
|
|
|
|
|
2026-07-04 00:00:34 -04:00
|
|
|
// 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])
|
|
|
|
|
|
2026-07-03 12:43:45 -04:00
|
|
|
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])
|
2026-07-03 00:21:23 -04:00
|
|
|
|
|
|
|
|
// 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))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 02:56:06 -04:00
|
|
|
return (
|
2026-05-25 10:04:11 -04:00
|
|
|
<div className="page live-page" data-testid="live-page">
|
2026-05-25 02:56:06 -04:00
|
|
|
{isError && (
|
|
|
|
|
<div className="error-box">
|
|
|
|
|
{error instanceof Error ? error.message : 'Failed to load live timing state'}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-05-25 10:45:35 -04:00
|
|
|
{streamStatus === 'disconnected' && snapshot && (
|
|
|
|
|
<div className="live-status-strip live-status-warn">
|
|
|
|
|
Stream disconnected — showing last received snapshot
|
|
|
|
|
</div>
|
2026-05-25 02:56:06 -04:00
|
|
|
)}
|
|
|
|
|
|
2026-05-25 10:45:35 -04:00
|
|
|
{isLoading && !snapshot && (
|
|
|
|
|
<div className="loading-state">connecting to live timing…</div>
|
|
|
|
|
)}
|
2026-05-25 02:56:06 -04:00
|
|
|
|
|
|
|
|
{!isLoading && !snapshot && (
|
2026-07-03 01:46:35 -04:00
|
|
|
<div className="empty-state ui-card glass-panel" style={{ padding: '40px', textAlign: 'center', marginTop: '20vh', maxWidth: '400px', marginLeft: 'auto', marginRight: 'auto' }} data-testid="live-empty">
|
|
|
|
|
<div className="live-empty-status" style={{ marginBottom: '16px' }}>
|
2026-05-25 10:45:35 -04:00
|
|
|
<span className={`live-conn live-conn-${streamStatus}`}>{streamStatus}</span>
|
|
|
|
|
</div>
|
2026-07-03 01:46:35 -04:00
|
|
|
<Radio size={48} style={{ color: 'var(--text-3)', margin: '0 auto 16px auto', display: 'block' }} />
|
|
|
|
|
<h2 className="empty-state-title" style={{ fontSize: '20px', marginBottom: '8px' }}>No live session active</h2>
|
|
|
|
|
<p className="empty-state-desc" style={{ color: 'var(--text-2)' }}>
|
|
|
|
|
The telemetry feed is currently offline. <br /><br /> Check the <a href="/" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Command Center</a> for the weekend schedule or explore historical data in the <a href="/race-hub" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Race Hub</a>.
|
|
|
|
|
</p>
|
2026-05-25 02:56:06 -04:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{snapshot && (
|
|
|
|
|
<>
|
2026-07-03 12:43:45 -04:00
|
|
|
<SessionBanner isLive={isLive} snapshot={snapshot} rows={rows} connection={streamStatus} now={now} />
|
2026-07-03 00:21:23 -04:00
|
|
|
<TrackStatusBanner status={snapshot.TrackStatus} />
|
|
|
|
|
<PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} />
|
2026-07-03 19:34:09 -04:00
|
|
|
<TrackMap
|
|
|
|
|
outline={trackOutlineQuery.data}
|
|
|
|
|
positions={positions}
|
|
|
|
|
telemetry={snapshot.Telemetry}
|
|
|
|
|
drivers={snapshot.Drivers}
|
|
|
|
|
driverInfo={snapshot.DriverInfo}
|
|
|
|
|
loading={trackOutlineQuery.isLoading}
|
|
|
|
|
/>
|
2026-07-04 00:00:19 -04:00
|
|
|
<TyreDegPanel rows={rows} sessionType={snapshot.Session?.SessionType} pinned={pinned} />
|
2026-05-25 10:45:35 -04:00
|
|
|
<div className="live-columns">
|
|
|
|
|
<div className="live-tower-col">
|
|
|
|
|
<div className="sec-header">
|
|
|
|
|
<span className="sec-title">Timing Tower</span>
|
2026-07-03 00:21:23 -04:00
|
|
|
{pinned.length > 0 && <span className="sec-meta">{pinned.length}/3 pinned</span>}
|
2026-05-25 10:45:35 -04:00
|
|
|
</div>
|
2026-07-03 00:21:23 -04:00
|
|
|
<BattleChips battles={battles} />
|
|
|
|
|
<TimingTower
|
|
|
|
|
rows={rows}
|
|
|
|
|
stints={snapshot.Stints}
|
|
|
|
|
history={gapHistory}
|
|
|
|
|
battleNumbers={inBattle}
|
|
|
|
|
pinned={pinned}
|
|
|
|
|
onTogglePin={handleTogglePin}
|
2026-07-03 12:43:45 -04:00
|
|
|
session={snapshot.Session}
|
2026-07-03 00:21:23 -04:00
|
|
|
/>
|
2026-05-25 10:45:35 -04:00
|
|
|
</div>
|
|
|
|
|
<div className="live-rc-col">
|
2026-07-03 23:56:19 -04:00
|
|
|
<TeamRadioTicker
|
|
|
|
|
captures={snapshot.TeamRadio ?? []}
|
|
|
|
|
driverInfo={snapshot.DriverInfo}
|
|
|
|
|
session={snapshot.Session}
|
|
|
|
|
/>
|
2026-07-03 02:47:04 -04:00
|
|
|
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
|
2026-07-04 00:00:34 -04:00
|
|
|
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
|
2026-05-25 02:56:06 -04:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|