Files
box-box/frontend/src/pages/LiveTimingPage.tsx

361 lines
12 KiB
TypeScript
Raw Normal View History

import { useEffect, useMemo, useRef, useState } from 'react'
2026-05-25 02:56:06 -04:00
import { useQuery } from '@tanstack/react-query'
import { fetchLiveState, fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
import type { LivePosition, LiveStreamData } from '../types'
import {
loadPinnedDrivers,
2026-07-03 12:43:45 -04:00
mergeVisibleSectors,
parseLiveStateEvent,
2026-07-03 12:43:45 -04:00
rowsWithVisibleSectors,
savePinnedDrivers,
sortLiveTimingRows,
togglePin,
} from '../lib/live'
2026-07-03 12:43:45 -04:00
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 {
allowsLiveInterpretations,
deriveLivePhase,
rendersSnapshot,
} from '../lib/liveState'
import type { TransportHealth } from '../lib/liveState'
import { deriveWeekendContext } from '../lib/weekendContext'
import { pickFocusMeeting } from '../lib/schedule'
2026-05-25 02:56:06 -04:00
import { SessionBanner } from '../components/live/SessionBanner'
import { TrackStatusBanner } from '../components/live/TrackStatusBanner'
2026-05-25 02:56:06 -04:00
import { TimingTower } from '../components/live/TimingTower'
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'
import { EventRail } from '../components/live/EventRail'
import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
import { TyreDegPanel } from '../components/live/TyreDegPanel'
import { LiveHandoff } from '../components/live/LiveHandoff'
import '../styles/live-state.css'
2026-05-25 02:56:06 -04:00
export function LiveTimingPage() {
const [activeSnapshot, setActiveSnapshot] = useState<LiveStreamData | null>(null)
const [archiveSnapshot, setArchiveSnapshot] = useState<LiveStreamData | null>(null)
const [archivePositions, setArchivePositions] = useState<Record<string, LivePosition>>({})
const [archiveSnapshotAt, setArchiveSnapshotAt] = useState<string | null>(null)
const [archiveMode, setArchiveMode] = useState(false)
2026-05-25 02:56:06 -04:00
const [isLive, setIsLive] = useState(false)
const [streamStatus, setStreamStatus] = useState<TransportHealth>('connecting')
2026-05-25 02:56:06 -04:00
const [now, setNow] = useState(Date.now())
const [gapHistory, setGapHistory] = useState<GapHistoryMap>({})
const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers())
2026-07-03 12:43:45 -04:00
const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({})
const [, setPositions] = useState<Record<string, LivePosition>>({})
const [events, setEvents] = useState<LiveEvent[]>([])
const prevSnapshotRef = useRef<LiveStreamData | null>(null)
const sessionSigRef = useRef('')
const isLiveRef = useRef(false)
const archiveModeRef = useRef(false)
const hasArchive = Boolean(archiveSnapshot)
2026-05-25 02:56:06 -04:00
// Transport health, active-session state, and archive mode are three
// independent inputs; deriveLivePhase collapses them into one UI phase.
const phase = deriveLivePhase({
transport: streamStatus,
isLive,
hasActiveSnapshot: Boolean(activeSnapshot),
hasArchive,
archiveMode,
})
const snapshot = phase === 'archive' ? archiveSnapshot : isLive ? activeSnapshot : null
const { data, isError, error } = useQuery({
2026-05-25 02:56:06 -04:00
queryKey: ['live-state'],
queryFn: fetchLiveState,
staleTime: 5_000,
})
// Weekend context (previous/next/analysis) is only needed when no session is
// streaming; keep the queries idle during a live session.
const notLive = !isLive
const nowDate = useMemo(() => new Date(now), [now])
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: fetchSeasons,
enabled: notLive,
})
const latestSeason = seasonsQuery.data?.[0] ?? null
const meetingsQuery = useQuery({
queryKey: ['meetings', latestSeason],
queryFn: () => fetchLocalMeetings(latestSeason!),
enabled: notLive && latestSeason != null,
staleTime: 60_000,
})
const focusMeeting = useMemo(
() => pickFocusMeeting(meetingsQuery.data ?? [], nowDate),
[meetingsQuery.data, nowDate],
)
const weekendQuery = useQuery({
queryKey: ['weekend', focusMeeting?.meeting_key],
queryFn: () => fetchWeekend(focusMeeting!.meeting_key),
enabled: notLive && focusMeeting != null,
staleTime: 60_000,
})
const weekendContext = useMemo(
() => deriveWeekendContext(weekendQuery.data, nowDate),
[weekendQuery.data, nowDate],
)
2026-05-25 02:56:06 -04:00
useEffect(() => {
if (!data) return
const nextLive = data.is_live && Boolean(data.data)
setIsLive(nextLive)
isLiveRef.current = nextLive
if (nextLive && data.data) {
setActiveSnapshot(data.data)
setArchiveMode(false)
return
}
setActiveSnapshot(null)
setArchiveSnapshot(data.last_snapshot ?? null)
setArchivePositions(data.last_positions ?? {})
setArchiveSnapshotAt(data.last_snapshot_at ?? null)
setArchiveMode((current) => current && Boolean(data.last_snapshot))
2026-05-25 02:56:06 -04:00
}, [data])
useEffect(() => {
archiveModeRef.current = archiveMode
if (archiveMode) {
setPositions(archivePositions)
}
}, [archiveMode, archivePositions])
2026-05-25 02:56:06 -04:00
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
const nextLive = state.is_live && Boolean(state.data)
setIsLive(nextLive)
if (nextLive && state.data) {
if (!isLiveRef.current || archiveModeRef.current) {
setPositions({})
}
isLiveRef.current = true
setActiveSnapshot(state.data)
setArchiveMode(false)
} else {
isLiveRef.current = false
setActiveSnapshot(null)
setArchiveSnapshot(state.last_snapshot ?? null)
setArchivePositions(state.last_positions ?? {})
setArchiveSnapshotAt(state.last_snapshot_at ?? null)
setArchiveMode((current) => current && Boolean(state.last_snapshot))
}
2026-05-25 02:56:06 -04:00
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<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()
}
}, [])
// 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])
// Final-snapshot rows for the settling handoff (independent of live rows).
const settlingRows = useMemo(() => sortLiveTimingRows(archiveSnapshot), [archiveSnapshot])
2026-07-03 12:43:45 -04:00
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))
}
const handleViewArchive = () => {
if (!archiveSnapshot) return
setIsLive(false)
setArchiveMode(true)
setPositions(archivePositions)
}
const handleExitArchive = () => {
setArchiveMode(false)
}
const archiveTimestamp = archiveSnapshotAt ? new Date(archiveSnapshotAt) : null
const archiveLabel =
archiveTimestamp && !Number.isNaN(archiveTimestamp.getTime())
? `Archived snapshot from ${archiveTimestamp.toLocaleString()}`
: 'Archived live timing snapshot'
const showLiveInterpretations = allowsLiveInterpretations(phase)
2026-05-25 02:56:06 -04:00
return (
<div className="page live-page" data-testid="live-page" data-phase={phase}>
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>
)}
{phase === 'disconnected' && (
<div className="live-status-strip live-status-warn" data-testid="live-disconnected-strip">
Connection lost showing the last live data while we reconnect. This is not an archive.
2026-05-25 10:45:35 -04:00
</div>
2026-05-25 02:56:06 -04:00
)}
{phase === 'archive' && (
<div className="live-status-strip live-status-archive" data-testid="live-archive-strip">
<span>{archiveLabel} read-only, live updates are paused</span>
<button type="button" className="live-archive-exit" onClick={handleExitArchive}>
Exit archive
</button>
</div>
)}
{phase === 'connecting' && (
2026-05-25 10:45:35 -04:00
<div className="loading-state">connecting to live timing</div>
)}
2026-05-25 02:56:06 -04:00
{(phase === 'settling' || phase === 'inactive') && (
<LiveHandoff
phase={phase}
transport={streamStatus}
context={weekendContext}
rows={settlingRows}
capturedAt={archiveSnapshotAt}
hasArchive={hasArchive}
onViewArchive={handleViewArchive}
/>
2026-05-25 02:56:06 -04:00
)}
{snapshot && rendersSnapshot(phase) && (
2026-05-25 02:56:06 -04:00
<>
<SessionBanner
phase={phase}
snapshot={snapshot}
rows={rows}
transport={streamStatus}
now={now}
capturedAt={phase === 'archive' ? archiveSnapshotAt : null}
/>
<TrackStatusBanner status={snapshot.TrackStatus} />
<PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} />
{showLiveInterpretations && (
<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>
{pinned.length > 0 && <span className="sec-meta">{pinned.length}/3 pinned</span>}
2026-05-25 10:45:35 -04:00
</div>
<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-05-25 10:45:35 -04:00
</div>
<div className="live-rc-col">
<TeamRadioTicker
captures={snapshot.TeamRadio ?? []}
driverInfo={snapshot.DriverInfo}
session={snapshot.Session}
/>
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
2026-05-25 02:56:06 -04:00
</div>
</div>
</>
)}
</div>
)
}