mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
feat(#74): Live → settling → analysis handoff
Implemented by claude via .agents/dev dispatch.
This commit is contained in:
178
frontend/src/components/live/LiveHandoff.tsx
Normal file
178
frontend/src/components/live/LiveHandoff.tsx
Normal file
@@ -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 (
|
||||
<section className={`live-handoff live-handoff-${phase}`} data-testid={testid}>
|
||||
<header className="live-handoff-head">
|
||||
<span className="live-handoff-eyebrow mono">
|
||||
{isSettling ? (
|
||||
<>
|
||||
<Flag size={13} /> SESSION SETTLING
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Radio size={13} /> NO LIVE SESSION
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<h1 className="live-handoff-title">{title}</h1>
|
||||
{isSettling && capturedLabel && (
|
||||
<p className="live-handoff-captured mono" data-testid="live-handoff-captured">
|
||||
Final feed snapshot captured {capturedLabel}
|
||||
</p>
|
||||
)}
|
||||
{!isSettling && (
|
||||
<p className="live-handoff-sub">
|
||||
The timing feed is quiet between sessions. Here's where the weekend stands.
|
||||
</p>
|
||||
)}
|
||||
<span className={`live-feed-health live-feed-${transport} live-handoff-feed`}>
|
||||
<span className="live-feed-dot" aria-hidden="true" />
|
||||
{feedHealthLabel(transport)}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{isSettling && topRows.length > 0 && (
|
||||
<div className="live-handoff-snapshot" data-testid="live-handoff-snapshot">
|
||||
<span className="live-handoff-snapshot-label mono">Provisional order at chequered</span>
|
||||
<ol className="live-handoff-order">
|
||||
{topRows.map((row) => (
|
||||
<li key={row.RacingNumber}>
|
||||
<span className="mono">P{row.Position}</span>
|
||||
<span className="live-handoff-code">{driverCode(row)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="live-handoff-actions">
|
||||
{analysisKey ? (
|
||||
<a
|
||||
className="live-handoff-action live-handoff-primary"
|
||||
href={`/race-hub?session_key=${analysisKey}`}
|
||||
data-testid="live-handoff-analysis"
|
||||
>
|
||||
<span className="live-handoff-action-body">
|
||||
<span className="live-handoff-action-label">
|
||||
{isSettling ? `Open ${analysisName} analysis` : `Open ${analysisName} in Race Hub`}
|
||||
</span>
|
||||
<span className="live-handoff-action-meta mono">
|
||||
{analysisReady
|
||||
? 'Full timing, strategy & story ready'
|
||||
: 'Settling — analysis will fill in as data ingests'}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight size={18} />
|
||||
</a>
|
||||
) : (
|
||||
<div className="live-handoff-action live-handoff-pending" data-testid="live-handoff-analysis-pending">
|
||||
<span className="live-handoff-action-body">
|
||||
<span className="live-handoff-action-label">Analysis not ready yet</span>
|
||||
<span className="live-handoff-action-meta mono">
|
||||
The completed session will appear in Race Hub once it is ingested.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{context.nextSession && (
|
||||
<div className="live-handoff-action live-handoff-next" data-testid="live-handoff-next">
|
||||
<span className="live-handoff-action-body">
|
||||
<span className="live-handoff-action-label">Up next · {context.nextSession.name}</span>
|
||||
<span className="live-handoff-action-meta mono">
|
||||
{formatSessionScheduleTime(context.nextSession.startsAt)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{context.previousSession && context.previousSession.sessionKey !== analysisKey && (
|
||||
<a
|
||||
className="live-handoff-action"
|
||||
href={`/race-hub?session_key=${context.previousSession.sessionKey}`}
|
||||
data-testid="live-handoff-recap"
|
||||
>
|
||||
<span className="live-handoff-action-body">
|
||||
<span className="live-handoff-action-label">Recap · {context.previousSession.name}</span>
|
||||
<span className="live-handoff-action-meta mono">Review the last completed session</span>
|
||||
</span>
|
||||
<ChevronRight size={18} />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{hasArchive && (
|
||||
<button
|
||||
type="button"
|
||||
className="live-handoff-action live-handoff-archive-btn"
|
||||
onClick={onViewArchive}
|
||||
data-testid="live-handoff-archive"
|
||||
>
|
||||
<span className="live-handoff-action-body">
|
||||
<span className="live-handoff-action-label">
|
||||
<Archive size={14} /> View full timing (read-only)
|
||||
</span>
|
||||
<span className="live-handoff-action-meta mono">Frozen final snapshot — no live updates</span>
|
||||
</span>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!analysisKey && !context.nextSession && !context.previousSession && !hasArchive && (
|
||||
<div className="live-handoff-fallback" data-testid="live-handoff-fallback">
|
||||
<a href="/" className="live-handoff-action">
|
||||
<span className="live-handoff-action-body">
|
||||
<span className="live-handoff-action-label">Command Center</span>
|
||||
<span className="live-handoff-action-meta mono">Weekend schedule & standings</span>
|
||||
</span>
|
||||
<ChevronRight size={18} />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<section className="live-banner">
|
||||
<section className={`live-banner${isArchive ? ' live-banner-archive' : ''}`} data-testid="live-banner">
|
||||
<div className="live-banner-row">
|
||||
<div className="live-banner-main">
|
||||
<span className={`live-conn live-conn-${connection}`}>{connection}</span>
|
||||
{isArchive ? (
|
||||
<span className="live-session-flag live-session-flag-archive" data-testid="live-archive-flag">
|
||||
ARCHIVE
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={`live-session-flag live-session-flag-live${
|
||||
phase === 'disconnected' ? ' is-stale' : ''
|
||||
}`}
|
||||
data-testid="live-session-flag"
|
||||
>
|
||||
<span className="live-session-dot" aria-hidden="true" />
|
||||
LIVE SESSION
|
||||
</span>
|
||||
)}
|
||||
<div>
|
||||
<h1>{session?.MeetingName || 'Live Timing'}</h1>
|
||||
<p>
|
||||
@@ -33,15 +63,40 @@ export function SessionBanner({ isLive, isArchive = false, snapshot, rows, conne
|
||||
</div>
|
||||
</div>
|
||||
<div className="live-session-board">
|
||||
{display.phaseLabel && <span className="live-phase-pill mono">{display.phaseLabel}</span>}
|
||||
<div className="live-clock mono" data-testid="live-clock">{clock || '--:--:--'}</div>
|
||||
{/* Feed health is strictly secondary and only present for a live session. */}
|
||||
{isLiveSession && (
|
||||
<span
|
||||
className={`live-feed-health live-feed-${transport}`}
|
||||
data-testid="live-feed-health"
|
||||
title="Transport health — independent of session state"
|
||||
>
|
||||
<span className="live-feed-dot" aria-hidden="true" />
|
||||
{feedHealthLabel(transport)}
|
||||
</span>
|
||||
)}
|
||||
{isArchive ? (
|
||||
<div className="live-archive-stamp" data-testid="live-archive-stamp">
|
||||
<span className="live-archive-readonly mono">READ-ONLY</span>
|
||||
{capturedLabel && <span className="live-archive-captured mono">captured {capturedLabel}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{display.phaseLabel && <span className="live-phase-pill mono">{display.phaseLabel}</span>}
|
||||
<div className="live-clock mono" data-testid="live-clock">{clock || '--:--:--'}</div>
|
||||
</>
|
||||
)}
|
||||
<div className="live-banner-meta">
|
||||
{display.advanceCount && <span>{display.advanceCount} advance</span>}
|
||||
{atRiskLabel && <span>{atRiskLabel}</span>}
|
||||
{!isArchive && display.advanceCount && <span>{display.advanceCount} advance</span>}
|
||||
{!isArchive && atRiskLabel && <span>{atRiskLabel}</span>}
|
||||
<span>
|
||||
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
|
||||
</span>
|
||||
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{stateLabel}</span>
|
||||
{!isArchive && (
|
||||
<span className={phase === 'live' ? 'live-state live-state-on' : 'live-state'}>
|
||||
{phase === 'live' ? 'live' : 'stale'}
|
||||
</span>
|
||||
)}
|
||||
{isArchive && <span className="live-state live-state-archive">archive</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
97
frontend/src/lib/liveState.ts
Normal file
97
frontend/src/lib/liveState.ts
Normal file
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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<LiveStreamData | null>(null)
|
||||
@@ -37,26 +44,67 @@ export function LiveTimingPage() {
|
||||
const [archiveSnapshotAt, setArchiveSnapshotAt] = useState<string | null>(null)
|
||||
const [archiveMode, setArchiveMode] = useState(false)
|
||||
const [isLive, setIsLive] = useState(false)
|
||||
const [streamStatus, setStreamStatus] = useState<StreamStatus>('connecting')
|
||||
const [streamStatus, setStreamStatus] = useState<TransportHealth>('connecting')
|
||||
const [now, setNow] = useState(Date.now())
|
||||
const [gapHistory, setGapHistory] = useState<GapHistoryMap>({})
|
||||
const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers())
|
||||
const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({})
|
||||
const [positions, setPositions] = useState<Record<string, LivePosition>>({})
|
||||
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 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 (
|
||||
<div className="page live-page" data-testid="live-page">
|
||||
<div className="page live-page" data-testid="live-page" data-phase={phase}>
|
||||
{isError && (
|
||||
<div className="error-box">
|
||||
{error instanceof Error ? error.message : 'Failed to load live timing state'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{streamStatus === 'disconnected' && snapshot && !archiveMode && (
|
||||
<div className="live-status-strip live-status-warn">
|
||||
Stream disconnected — showing last received snapshot
|
||||
{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.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{archiveMode && snapshot && (
|
||||
{phase === 'archive' && (
|
||||
<div className="live-status-strip live-status-archive" data-testid="live-archive-strip">
|
||||
{archiveLabel} — live updates are paused for this archive view
|
||||
<span>{archiveLabel} — read-only, live updates are paused</span>
|
||||
<button type="button" className="live-archive-exit" onClick={handleExitArchive}>
|
||||
Exit archive
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && !snapshot && (
|
||||
{phase === 'connecting' && (
|
||||
<div className="loading-state">connecting to live timing…</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !snapshot && (
|
||||
<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' }}>
|
||||
<span className={`live-conn live-conn-${streamStatus}`}>{streamStatus}</span>
|
||||
</div>
|
||||
<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>
|
||||
{hasArchive && (
|
||||
<button type="button" className="live-archive-btn" onClick={handleViewArchive}>
|
||||
<Archive size={15} />
|
||||
View Last Session
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{(phase === 'settling' || phase === 'inactive') && (
|
||||
<LiveHandoff
|
||||
phase={phase}
|
||||
transport={streamStatus}
|
||||
context={weekendContext}
|
||||
rows={settlingRows}
|
||||
capturedAt={archiveSnapshotAt}
|
||||
hasArchive={hasArchive}
|
||||
onViewArchive={handleViewArchive}
|
||||
/>
|
||||
)}
|
||||
|
||||
{snapshot && (
|
||||
{snapshot && rendersSnapshot(phase) && (
|
||||
<>
|
||||
<SessionBanner
|
||||
isLive={isLive}
|
||||
isArchive={archiveMode}
|
||||
phase={phase}
|
||||
snapshot={snapshot}
|
||||
rows={rows}
|
||||
connection={streamStatus}
|
||||
transport={streamStatus}
|
||||
now={now}
|
||||
capturedAt={phase === 'archive' ? archiveSnapshotAt : null}
|
||||
/>
|
||||
<TrackStatusBanner status={snapshot.TrackStatus} />
|
||||
<PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} />
|
||||
<TyreDegPanel rows={rows} sessionType={snapshot.Session?.SessionType} pinned={pinned} />
|
||||
{showLiveInterpretations && (
|
||||
<TyreDegPanel rows={rows} sessionType={snapshot.Session?.SessionType} pinned={pinned} />
|
||||
)}
|
||||
<div className="live-columns">
|
||||
<div className="live-tower-col">
|
||||
<div className="sec-header">
|
||||
|
||||
Reference in New Issue
Block a user