diff --git a/frontend/src/components/live/LiveHandoff.tsx b/frontend/src/components/live/LiveHandoff.tsx new file mode 100644 index 0000000..1540948 --- /dev/null +++ b/frontend/src/components/live/LiveHandoff.tsx @@ -0,0 +1,178 @@ +import { Archive, ChevronRight, Flag, Radio } from 'lucide-react' +import type { LiveTimingRow } from '../../lib/live' +import { driverCode } from '../../lib/live' +import type { LiveWeekendContext } from '../../lib/weekendContext' +import type { TransportHealth } from '../../lib/liveState' +import { feedHealthLabel } from '../../lib/liveState' +import { formatSessionScheduleTime } from '../../lib/schedule' + +interface Props { + /** 'settling' immediately after a session; 'inactive' when nothing is retained. */ + phase: 'settling' | 'inactive' + transport: TransportHealth + context: LiveWeekendContext + /** Top rows of the final snapshot (settling only), already position-sorted. */ + rows: LiveTimingRow[] + capturedAt?: string | null + hasArchive: boolean + onViewArchive: () => void +} + +function formatCapturedAt(capturedAt: string | null | undefined): string { + if (!capturedAt) return '' + const date = new Date(capturedAt) + if (Number.isNaN(date.getTime())) return '' + return date.toLocaleString() +} + +export function LiveHandoff({ + phase, + transport, + context, + rows, + capturedAt, + hasArchive, + onViewArchive, +}: Props) { + const isSettling = phase === 'settling' + const testid = isSettling ? 'live-settling' : 'live-inactive' + const capturedLabel = formatCapturedAt(capturedAt) + const title = context.meetingName || 'Live Timing' + const topRows = rows.slice(0, 3) + + const analysisKey = context.analysisSessionKey + const analysisName = context.analysisSessionName || 'session' + const analysisReady = context.analysisReady + + return ( +
+
+ + {isSettling ? ( + <> + SESSION SETTLING + + ) : ( + <> + NO LIVE SESSION + + )} + +

{title}

+ {isSettling && capturedLabel && ( +

+ Final feed snapshot captured {capturedLabel} +

+ )} + {!isSettling && ( +

+ The timing feed is quiet between sessions. Here's where the weekend stands. +

+ )} + + +
+ + {isSettling && topRows.length > 0 && ( +
+ Provisional order at chequered +
    + {topRows.map((row) => ( +
  1. + P{row.Position} + {driverCode(row)} +
  2. + ))} +
+
+ )} + +
+ {analysisKey ? ( + + + + {isSettling ? `Open ${analysisName} analysis` : `Open ${analysisName} in Race Hub`} + + + {analysisReady + ? 'Full timing, strategy & story ready' + : 'Settling — analysis will fill in as data ingests'} + + + + + ) : ( +
+ + Analysis not ready yet + + The completed session will appear in Race Hub once it is ingested. + + +
+ )} + + {context.nextSession && ( +
+ + Up next · {context.nextSession.name} + + {formatSessionScheduleTime(context.nextSession.startsAt)} + + +
+ )} + + {context.previousSession && context.previousSession.sessionKey !== analysisKey && ( + + + Recap · {context.previousSession.name} + Review the last completed session + + + + )} + + {hasArchive && ( + + )} + + {!analysisKey && !context.nextSession && !context.previousSession && !hasArchive && ( +
+ + + Command Center + Weekend schedule & standings + + + +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/live/SessionBanner.tsx b/frontend/src/components/live/SessionBanner.tsx index 54ef76f..36b32f1 100644 --- a/frontend/src/components/live/SessionBanner.tsx +++ b/frontend/src/components/live/SessionBanner.tsx @@ -1,30 +1,60 @@ import type { LiveStreamData } from '../../types' import type { LiveTimingRow } from '../../lib/live' import { extrapolateClock, liveSessionDisplay } from '../../lib/live' +import type { LivePhase, TransportHealth } from '../../lib/liveState' +import { feedHealthLabel } from '../../lib/liveState' import { WeatherStrip } from './WeatherStrip' interface Props { - isLive: boolean - isArchive?: boolean + /** Which of the live phases we are rendering: 'live' | 'disconnected' | 'archive'. */ + phase: LivePhase snapshot: LiveStreamData rows: LiveTimingRow[] - connection: 'connected' | 'connecting' | 'disconnected' | 'error' + transport: TransportHealth now: number + /** ISO capture time — required for archive, shown as the timestamp of record. */ + capturedAt?: string | null } -export function SessionBanner({ isLive, isArchive = false, snapshot, rows, connection, now }: Props) { +function formatCapturedAt(capturedAt: string | null | undefined): string { + if (!capturedAt) return '' + const date = new Date(capturedAt) + if (Number.isNaN(date.getTime())) return '' + return date.toLocaleString() +} + +export function SessionBanner({ phase, snapshot, rows, transport, now, capturedAt }: Props) { const session = snapshot.Session - const clock = extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now) + const isArchive = phase === 'archive' + const isLiveSession = phase === 'live' || phase === 'disconnected' + // Archive is a frozen single frame: never extrapolate a running clock for it. + const clock = isArchive + ? '' + : extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now) const display = liveSessionDisplay(session, rows) const atRiskLabel = display.atRiskStart && display.atRiskEnd ? `P${display.atRiskStart}-P${display.atRiskEnd} at risk` : '' - const stateLabel = isLive ? 'live' : isArchive ? 'archive' : 'stale' + const capturedLabel = formatCapturedAt(capturedAt) return ( -
+
- {connection} + {isArchive ? ( + + ARCHIVE + + ) : ( + + + )}

{session?.MeetingName || 'Live Timing'}

@@ -33,15 +63,40 @@ export function SessionBanner({ isLive, isArchive = false, snapshot, rows, conne

- {display.phaseLabel && {display.phaseLabel}} -
{clock || '--:--:--'}
+ {/* Feed health is strictly secondary and only present for a live session. */} + {isLiveSession && ( + + + )} + {isArchive ? ( +
+ READ-ONLY + {capturedLabel && captured {capturedLabel}} +
+ ) : ( + <> + {display.phaseLabel && {display.phaseLabel}} +
{clock || '--:--:--'}
+ + )}
- {display.advanceCount && {display.advanceCount} advance} - {atRiskLabel && {atRiskLabel}} + {!isArchive && display.advanceCount && {display.advanceCount} advance} + {!isArchive && atRiskLabel && {atRiskLabel}} L{snapshot.CurrentLap || '-'}/{snapshot.TotalLaps || '-'} - {stateLabel} + {!isArchive && ( + + {phase === 'live' ? 'live' : 'stale'} + + )} + {isArchive && archive}
diff --git a/frontend/src/lib/liveState.ts b/frontend/src/lib/liveState.ts new file mode 100644 index 0000000..36ddf5b --- /dev/null +++ b/frontend/src/lib/liveState.ts @@ -0,0 +1,97 @@ +// Live timing state model. +// +// The Live page has to hold four *orthogonal* concepts that used to be +// conflated into a single "connected / offline" flag: +// +// 1. transport health — is the SSE stream up? (connecting/connected/…) +// 2. active session — is a session actually running right now? +// 3. archive availability — do we retain a final snapshot to inspect? +// 4. analysis readiness — is the completed session ingested for Race Hub? +// +// `deriveLivePhase` collapses the first three inputs into a single UI phase so +// the page never, for example, calls a finished session "offline" or lets a +// dropped socket masquerade as "archive mode". + +export type TransportHealth = 'connecting' | 'connected' | 'disconnected' | 'error' + +export type LivePhase = + | 'connecting' // cold start: no snapshot yet, still opening the feed + | 'live' // a session is running and streaming + | 'disconnected' // was live, transport dropped — keep the last snapshot, warn + | 'settling' // session ended; a final snapshot is retained, analysis pending + | 'archive' // user opened the retained snapshot as an explicit read-only view + | 'inactive' // no session and nothing retained — show weekend context instead + +export interface LiveStateInputs { + transport: TransportHealth + /** The feed reports an active session AND carries a snapshot for it. */ + isLive: boolean + hasActiveSnapshot: boolean + /** A final snapshot from the last session is retained. */ + hasArchive: boolean + /** The user explicitly opened the archive as a read-only timing view. */ + archiveMode: boolean +} + +export function transportDown(transport: TransportHealth): boolean { + return transport === 'disconnected' || transport === 'error' +} + +/** + * Map transport + session + archive inputs to one UI phase. Session lifecycle + * and transport health are deliberately orthogonal: a live session with a + * dropped socket is `disconnected` (snapshot retained), never `archive`. + */ +export function deriveLivePhase(input: LiveStateInputs): LivePhase { + const { transport, isLive, hasActiveSnapshot, hasArchive, archiveMode } = input + + // Explicit read-only archive wins — it is a user-chosen mode. + if (archiveMode && hasArchive) return 'archive' + + // An active session: transport health only downgrades the *presentation*, + // it never removes the session. + if (isLive && hasActiveSnapshot) { + return transportDown(transport) ? 'disconnected' : 'live' + } + + // No active session but we still hold the final snapshot -> settling handoff. + if (hasArchive) return 'settling' + + // Nothing yet and the feed is still opening. + if (transport === 'connecting') return 'connecting' + + return 'inactive' +} + +/** Phases that render the live timing tower / snapshot surface. */ +export function rendersSnapshot(phase: LivePhase): boolean { + return phase === 'live' || phase === 'disconnected' || phase === 'archive' +} + +/** + * Whether live-only interpretations (pit-window rejoin, tyre-deg slope, "what + * just happened" deltas) are meaningful. They require a moving session, so an + * archived single frame must never present them as current insight. + */ +export function allowsLiveInterpretations(phase: LivePhase): boolean { + return phase === 'live' || phase === 'disconnected' +} + +/** Read-only, timestamped phases that must not show live/connected chrome. */ +export function isReadOnlyPhase(phase: LivePhase): boolean { + return phase === 'archive' +} + +/** Short, human transport-health label — always secondary to session state. */ +export function feedHealthLabel(transport: TransportHealth): string { + switch (transport) { + case 'connected': + return 'Feed healthy' + case 'connecting': + return 'Connecting' + case 'disconnected': + return 'Reconnecting' + case 'error': + return 'Feed unavailable' + } +} diff --git a/frontend/src/pages/LiveTimingPage.tsx b/frontend/src/pages/LiveTimingPage.tsx index 098cfe7..59fcd00 100644 --- a/frontend/src/pages/LiveTimingPage.tsx +++ b/frontend/src/pages/LiveTimingPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' -import { fetchLiveState } from '../api' +import { fetchLiveState, fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api' import type { LivePosition, LiveStreamData } from '../types' import { loadPinnedDrivers, @@ -17,6 +17,14 @@ 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' import { SessionBanner } from '../components/live/SessionBanner' import { TrackStatusBanner } from '../components/live/TrackStatusBanner' import { TimingTower } from '../components/live/TimingTower' @@ -26,9 +34,8 @@ 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 { Archive, Radio } from 'lucide-react' - -type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error' +import { LiveHandoff } from '../components/live/LiveHandoff' +import '../styles/live-state.css' export function LiveTimingPage() { const [activeSnapshot, setActiveSnapshot] = useState(null) @@ -37,26 +44,67 @@ export function LiveTimingPage() { const [archiveSnapshotAt, setArchiveSnapshotAt] = useState(null) const [archiveMode, setArchiveMode] = useState(false) const [isLive, setIsLive] = useState(false) - const [streamStatus, setStreamStatus] = useState('connecting') + 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 [, setPositions] = useState>({}) const [events, setEvents] = useState([]) const prevSnapshotRef = useRef(null) const sessionSigRef = useRef('') const isLiveRef = useRef(false) const archiveModeRef = useRef(false) - const snapshot = isLive ? activeSnapshot : archiveMode ? archiveSnapshot : null const hasArchive = Boolean(archiveSnapshot) - const { data, isLoading, isError, error } = useQuery({ + // 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({ 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], + ) + useEffect(() => { if (!data) return const nextLive = data.is_live && Boolean(data.data) @@ -167,6 +215,8 @@ export function LiveTimingPage() { }, [snapshot]) const rawRows = useMemo(() => sortLiveTimingRows(snapshot), [snapshot]) + // Final-snapshot rows for the settling handoff (independent of live rows). + const settlingRows = useMemo(() => sortLiveTimingRows(archiveSnapshot), [archiveSnapshot]) useEffect(() => { if (rawRows.length === 0) { @@ -210,68 +260,72 @@ export function LiveTimingPage() { 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) + return ( -
+
{isError && (
{error instanceof Error ? error.message : 'Failed to load live timing state'}
)} - {streamStatus === 'disconnected' && snapshot && !archiveMode && ( -
- Stream disconnected — showing last received snapshot + {phase === 'disconnected' && ( +
+ Connection lost — showing the last live data while we reconnect. This is not an archive.
)} - {archiveMode && snapshot && ( + {phase === 'archive' && (
- {archiveLabel} — live updates are paused for this archive view + {archiveLabel} — read-only, live updates are paused +
)} - {isLoading && !snapshot && ( + {phase === 'connecting' && (
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. -

- {hasArchive && ( - - )} -
+ {(phase === 'settling' || phase === 'inactive') && ( + )} - {snapshot && ( + {snapshot && rendersSnapshot(phase) && ( <> - + {showLiveInterpretations && ( + + )}