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-12 18:50:50 -04:00
|
|
|
import { fetchLiveState, fetchWeekendContext } from '../api'
|
2026-07-03 19:34:09 -04:00
|
|
|
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-07-12 18:09:50 -04:00
|
|
|
import {
|
|
|
|
|
allowsLiveInterpretations,
|
|
|
|
|
deriveLivePhase,
|
2026-07-12 19:40:24 -04:00
|
|
|
effectiveFeedHealth,
|
2026-07-12 18:09:50 -04:00
|
|
|
rendersSnapshot,
|
2026-07-12 18:50:50 -04:00
|
|
|
terminalSessionStatus,
|
2026-07-12 18:09:50 -04:00
|
|
|
} from '../lib/liveState'
|
|
|
|
|
import type { TransportHealth } from '../lib/liveState'
|
2026-07-12 19:40:24 -04:00
|
|
|
import { shouldPollHandoffAnalysis } from '../components/live/LiveHandoff'
|
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-04 00:00:19 -04:00
|
|
|
import { TyreDegPanel } from '../components/live/TyreDegPanel'
|
2026-07-12 18:09:50 -04:00
|
|
|
import { LiveHandoff } from '../components/live/LiveHandoff'
|
2026-07-12 18:00:37 -04:00
|
|
|
import { RouteState } from '../components/RouteState'
|
2026-07-12 18:09:50 -04:00
|
|
|
import '../styles/live-state.css'
|
2026-05-25 02:56:06 -04:00
|
|
|
|
2026-07-12 18:50:50 -04:00
|
|
|
/** How often to re-check weekend-context while analysis is still ingesting. */
|
|
|
|
|
export const WEEKEND_CONTEXT_POLL_MS = 15_000
|
|
|
|
|
const WEEKEND_CONTEXT_STALE_MS = 15_000
|
|
|
|
|
|
2026-05-25 02:56:06 -04:00
|
|
|
export function LiveTimingPage() {
|
2026-07-04 01:40:58 -04:00
|
|
|
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)
|
2026-07-12 18:09:50 -04:00
|
|
|
const [streamStatus, setStreamStatus] = useState<TransportHealth>('connecting')
|
2026-05-25 02:56:06 -04:00
|
|
|
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-12 18:09:50 -04:00
|
|
|
const [, 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-07-04 01:40:58 -04:00
|
|
|
const isLiveRef = useRef(false)
|
|
|
|
|
const archiveModeRef = useRef(false)
|
2026-07-12 18:50:50 -04:00
|
|
|
// Whether we ever observed this session as live — used to reason about a
|
|
|
|
|
// dropped feed vs. a session that was never live in this page session.
|
|
|
|
|
const wasLiveRef = useRef(false)
|
2026-07-04 01:40:58 -04:00
|
|
|
const hasArchive = Boolean(archiveSnapshot)
|
2026-05-25 02:56:06 -04:00
|
|
|
|
2026-07-12 18:50:50 -04:00
|
|
|
const {
|
|
|
|
|
data,
|
2026-07-12 18:00:37 -04:00
|
|
|
isLoading,
|
2026-07-12 18:50:50 -04:00
|
|
|
isError,
|
|
|
|
|
error,
|
2026-07-12 18:00:37 -04:00
|
|
|
refetch,
|
|
|
|
|
isFetching,
|
2026-07-12 18:50:50 -04:00
|
|
|
isFetched: liveStateFetched,
|
|
|
|
|
} = useQuery({
|
|
|
|
|
queryKey: ['live-state'],
|
2026-07-12 18:00:37 -04:00
|
|
|
queryFn: ({ signal }) => fetchLiveState(signal),
|
2026-07-12 18:50:50 -04:00
|
|
|
staleTime: 5_000,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// A retained snapshot is only "settled" (session genuinely finished) if it
|
|
|
|
|
// carries a terminal FIA SessionStatus. Otherwise the upstream feed dropped
|
|
|
|
|
// while the session was still active and we must stay in `disconnected`.
|
|
|
|
|
const sessionEndedCleanly = terminalSessionStatus(archiveSnapshot?.SessionStatus)
|
|
|
|
|
|
|
|
|
|
// Transport health, active-session state, and archive availability are
|
|
|
|
|
// orthogonal inputs; deriveLivePhase collapses them into one UI phase.
|
2026-07-12 18:09:50 -04:00
|
|
|
const phase = deriveLivePhase({
|
|
|
|
|
transport: streamStatus,
|
|
|
|
|
isLive,
|
|
|
|
|
hasActiveSnapshot: Boolean(activeSnapshot),
|
|
|
|
|
hasArchive,
|
|
|
|
|
archiveMode,
|
2026-07-12 18:50:50 -04:00
|
|
|
sessionEndedCleanly,
|
|
|
|
|
wasLive: wasLiveRef.current,
|
|
|
|
|
stateLoaded: liveStateFetched,
|
2026-07-12 18:09:50 -04:00
|
|
|
})
|
2026-07-12 18:50:50 -04:00
|
|
|
const snapshot =
|
|
|
|
|
phase === 'archive'
|
|
|
|
|
? archiveSnapshot
|
|
|
|
|
: isLive
|
|
|
|
|
? activeSnapshot
|
|
|
|
|
: phase === 'disconnected'
|
|
|
|
|
? archiveSnapshot
|
|
|
|
|
: null
|
2026-07-12 18:09:50 -04:00
|
|
|
|
2026-07-12 18:50:50 -04:00
|
|
|
// Canonical weekend context (issue #72) — the single source of truth for
|
|
|
|
|
// previous/next/default-analysis identity and temporal state. Only needed
|
|
|
|
|
// when no session is streaming; keep it idle during a live session. Poll
|
|
|
|
|
// while a completed session is still ingesting so `settling` can flip to
|
|
|
|
|
// analysis-ready without a manual refresh.
|
|
|
|
|
const contextQuery = useQuery({
|
|
|
|
|
queryKey: ['weekend-context'],
|
2026-07-12 18:00:37 -04:00
|
|
|
queryFn: ({ signal }) => fetchWeekendContext(signal),
|
2026-07-12 18:50:50 -04:00
|
|
|
enabled: !isLive,
|
|
|
|
|
staleTime: WEEKEND_CONTEXT_STALE_MS,
|
|
|
|
|
refetchInterval: (query) => {
|
2026-07-12 19:40:24 -04:00
|
|
|
// Poll until the just-finished previous_completed_session is ready — an
|
|
|
|
|
// older already-complete default_analysis_session must not stop us.
|
|
|
|
|
return shouldPollHandoffAnalysis(query.state.data) ? WEEKEND_CONTEXT_POLL_MS : false
|
2026-07-12 18:50:50 -04:00
|
|
|
},
|
|
|
|
|
refetchIntervalInBackground: false,
|
2026-07-12 18:09:50 -04:00
|
|
|
})
|
2026-07-12 18:50:50 -04:00
|
|
|
const weekendContext = contextQuery.data
|
2026-07-12 18:09:50 -04:00
|
|
|
|
2026-05-25 02:56:06 -04:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!data) return
|
2026-07-04 01:40:58 -04:00
|
|
|
const nextLive = data.is_live && Boolean(data.data)
|
|
|
|
|
setIsLive(nextLive)
|
|
|
|
|
isLiveRef.current = nextLive
|
2026-07-12 18:50:50 -04:00
|
|
|
if (nextLive) wasLiveRef.current = true
|
2026-07-04 01:40:58 -04:00
|
|
|
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])
|
|
|
|
|
|
2026-07-04 01:40:58 -04:00
|
|
|
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
|
2026-07-04 01:40:58 -04:00
|
|
|
const nextLive = state.is_live && Boolean(state.data)
|
|
|
|
|
setIsLive(nextLive)
|
|
|
|
|
if (nextLive && state.data) {
|
|
|
|
|
if (!isLiveRef.current || archiveModeRef.current) {
|
|
|
|
|
setPositions({})
|
|
|
|
|
}
|
|
|
|
|
isLiveRef.current = true
|
2026-07-12 18:50:50 -04:00
|
|
|
wasLiveRef.current = true
|
2026-07-04 01:40:58 -04:00
|
|
|
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')
|
|
|
|
|
})
|
|
|
|
|
|
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])
|
2026-07-12 18:09:50 -04:00
|
|
|
// 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])
|
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-07-04 01:40:58 -04:00
|
|
|
const handleViewArchive = () => {
|
|
|
|
|
if (!archiveSnapshot) return
|
|
|
|
|
setIsLive(false)
|
|
|
|
|
setArchiveMode(true)
|
|
|
|
|
setPositions(archivePositions)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 18:09:50 -04:00
|
|
|
const handleExitArchive = () => {
|
|
|
|
|
setArchiveMode(false)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 01:40:58 -04:00
|
|
|
const archiveTimestamp = archiveSnapshotAt ? new Date(archiveSnapshotAt) : null
|
|
|
|
|
const archiveLabel =
|
|
|
|
|
archiveTimestamp && !Number.isNaN(archiveTimestamp.getTime())
|
|
|
|
|
? `Archived snapshot from ${archiveTimestamp.toLocaleString()}`
|
|
|
|
|
: 'Archived live timing snapshot'
|
|
|
|
|
|
2026-07-12 18:09:50 -04:00
|
|
|
const showLiveInterpretations = allowsLiveInterpretations(phase)
|
2026-07-12 19:40:24 -04:00
|
|
|
// Upstream FIA loss can leave the browser SSE open; present one coherent
|
|
|
|
|
// feed-health truth rather than "Connection lost" + "Feed healthy".
|
|
|
|
|
const feedHealth = effectiveFeedHealth(streamStatus, phase)
|
2026-07-12 18:09:50 -04:00
|
|
|
|
2026-05-25 02:56:06 -04:00
|
|
|
return (
|
2026-07-12 18:09:50 -04:00
|
|
|
<div className="page live-page" data-testid="live-page" data-phase={phase}>
|
2026-05-25 02:56:06 -04:00
|
|
|
{isError && (
|
2026-07-12 18:00:37 -04:00
|
|
|
<RouteState
|
|
|
|
|
kind="error"
|
|
|
|
|
title="Live timing unavailable"
|
|
|
|
|
error={error}
|
|
|
|
|
onRetry={() => {
|
|
|
|
|
if (!isFetching) void refetch()
|
|
|
|
|
}}
|
|
|
|
|
retrying={isFetching}
|
|
|
|
|
testId="live-initial-error"
|
|
|
|
|
/>
|
2026-05-25 02:56:06 -04:00
|
|
|
)}
|
|
|
|
|
|
2026-07-12 18:09:50 -04:00
|
|
|
{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
|
|
|
)}
|
|
|
|
|
|
2026-07-12 18:09:50 -04:00
|
|
|
{phase === 'archive' && (
|
2026-07-04 01:40:58 -04:00
|
|
|
<div className="live-status-strip live-status-archive" data-testid="live-archive-strip">
|
2026-07-12 18:09:50 -04:00
|
|
|
<span>{archiveLabel} — read-only, live updates are paused</span>
|
|
|
|
|
<button type="button" className="live-archive-exit" onClick={handleExitArchive}>
|
|
|
|
|
Exit archive
|
|
|
|
|
</button>
|
2026-07-04 01:40:58 -04:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-07-12 18:00:37 -04:00
|
|
|
{phase === 'connecting' && (isLoading || !liveStateFetched) && (
|
|
|
|
|
<RouteState
|
|
|
|
|
kind="loading"
|
|
|
|
|
title="connecting to live timing…"
|
|
|
|
|
testId="live-initial-loading"
|
|
|
|
|
/>
|
2026-05-25 10:45:35 -04:00
|
|
|
)}
|
2026-05-25 02:56:06 -04:00
|
|
|
|
2026-07-12 18:09:50 -04:00
|
|
|
{(phase === 'settling' || phase === 'inactive') && (
|
|
|
|
|
<LiveHandoff
|
|
|
|
|
phase={phase}
|
2026-07-12 19:40:24 -04:00
|
|
|
transport={feedHealth}
|
2026-07-12 18:09:50 -04:00
|
|
|
context={weekendContext}
|
|
|
|
|
rows={settlingRows}
|
|
|
|
|
capturedAt={archiveSnapshotAt}
|
|
|
|
|
hasArchive={hasArchive}
|
|
|
|
|
onViewArchive={handleViewArchive}
|
|
|
|
|
/>
|
2026-05-25 02:56:06 -04:00
|
|
|
)}
|
|
|
|
|
|
2026-07-12 18:09:50 -04:00
|
|
|
{snapshot && rendersSnapshot(phase) && (
|
2026-05-25 02:56:06 -04:00
|
|
|
<>
|
2026-07-04 01:40:58 -04:00
|
|
|
<SessionBanner
|
2026-07-12 18:09:50 -04:00
|
|
|
phase={phase}
|
2026-07-04 01:40:58 -04:00
|
|
|
snapshot={snapshot}
|
|
|
|
|
rows={rows}
|
2026-07-12 19:40:24 -04:00
|
|
|
transport={feedHealth}
|
2026-07-04 01:40:58 -04:00
|
|
|
now={now}
|
2026-07-12 18:09:50 -04:00
|
|
|
capturedAt={phase === 'archive' ? archiveSnapshotAt : null}
|
2026-07-04 01:40:58 -04:00
|
|
|
/>
|
2026-07-03 00:21:23 -04:00
|
|
|
<TrackStatusBanner status={snapshot.TrackStatus} />
|
|
|
|
|
<PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} />
|
2026-07-12 18:09:50 -04:00
|
|
|
{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>
|
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>
|
|
|
|
|
)
|
|
|
|
|
}
|