diff --git a/frontend/src/components/live/LiveHandoff.tsx b/frontend/src/components/live/LiveHandoff.tsx new file mode 100644 index 0000000..dca9d4f --- /dev/null +++ b/frontend/src/components/live/LiveHandoff.tsx @@ -0,0 +1,263 @@ +import { Archive, ChevronRight, Flag, Radio } from 'lucide-react' +import type { LiveTimingRow } from '../../lib/live' +import { driverCode } from '../../lib/live' +import type { TransportHealth } from '../../lib/liveState' +import { feedHealthLabel } from '../../lib/liveState' +import type { ContextSession, WeekendContext } from '../../types' +import { analysisSessionKey } from '../../lib/weekendContext' +import { formatSessionScheduleTime } from '../../lib/schedule' + +interface Props { + /** 'settling' immediately after a session; 'inactive' when nothing is live. */ + phase: 'settling' | 'inactive' + transport: TransportHealth + /** Canonical weekend context (issue #72). May be undefined before it loads. */ + context: WeekendContext | undefined + /** 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() +} + +function sessionKeyOf(session: ContextSession | undefined): number | undefined { + const key = session?.session.session_key + return key && key > 0 ? key : undefined +} + +/** + * Fully ingested analysis (local_analysis === complete). Stricter than + * hasLocalAnalysis, which also treats partial as link-worthy — Live polling + * and "ready" chrome wait for the complete state. + */ +export function analysisIsReady(session: ContextSession | undefined): boolean { + return session?.availability?.local_analysis === 'complete' +} + +/** + * Analysis target for the Live handoff surface. + * + * Settling must follow the just-finished session (`previous_completed_session`). + * The canonical backend can mark that session archive-complete while leaving + * `default_analysis_session` on an older already-ingested practice/qualifying — + * preferring default here would link/poll/label the wrong race. + * + * Inactive keeps the shared default-first preference via analysisSessionKey. + */ +export function handoffAnalysisSession( + context: WeekendContext | undefined, + phase: 'settling' | 'inactive', +): ContextSession | undefined { + if (!context) return undefined + if (phase === 'settling') { + if (sessionKeyOf(context.previous_completed_session)) { + return context.previous_completed_session + } + return sessionKeyOf(context.default_analysis_session) + ? context.default_analysis_session + : undefined + } + const key = analysisSessionKey(context) + if (!key) return undefined + if (context.default_analysis_session?.session.session_key === key) { + return context.default_analysis_session + } + return context.previous_completed_session +} + +/** + * Keep polling weekend-context while the just-finished session (or, absent + * that, the default analysis session) is still ingesting. An older ready + * default must not stop the settle→ready transition. + */ +export function shouldPollHandoffAnalysis(context: WeekendContext | undefined): boolean { + if (!context) return true + const previous = context.previous_completed_session + if (sessionKeyOf(previous) && !analysisIsReady(previous)) return true + const fallback = context.default_analysis_session + if (sessionKeyOf(fallback) && !analysisIsReady(fallback)) return true + return !sessionKeyOf(previous) && !sessionKeyOf(fallback) +} + +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 focusName = context?.focus_meeting?.meeting_name + const activeName = context?.active_session?.meeting?.meeting_name + const title = focusName || activeName || 'Live Timing' + const topRows = rows.slice(0, 3) + + const analysis = handoffAnalysisSession(context, phase) + const analysisKey = sessionKeyOf(analysis) + const analysisName = analysis?.session.session_name || 'session' + const analysisReady = analysisIsReady(analysis) + + const next = context?.next_session + const previous = context?.previous_completed_session + // Avoid a duplicate recap card when previous == the analysis target. + const showRecap = + previous && previous.session.session_key !== analysisKey && previous.session.session_key !== 0 + + 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' + : isSettling + ? 'Settling — analysis will fill in as data ingests' + : 'Analysis will fill in as data ingests'} + + + + + ) : ( +
+ + Analysis not ready yet + + The completed session will appear in Race Hub once it is ingested. + + +
+ )} + + {next && ( +
+ + + Up next · {next.session.session_name} + + + {formatSessionScheduleTime(next.session.date_start)} + + +
+ )} + + {showRecap && ( + + + + Recap · {previous!.session.session_name} + + + Review the last completed session + + + + + )} + + {hasArchive && ( + + )} + + {!analysisKey && !next && !showRecap && !hasArchive && ( +
+ + + Weekend + 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/components/weekend/BetweenSessionsView.tsx b/frontend/src/components/weekend/BetweenSessionsView.tsx index d92635b..f84fb52 100644 --- a/frontend/src/components/weekend/BetweenSessionsView.tsx +++ b/frontend/src/components/weekend/BetweenSessionsView.tsx @@ -32,7 +32,10 @@ export function BetweenSessionsView({ const previous = context.previous_completed_session const previousName = previous?.session.session_name ?? 'Last session' const next = context.next_session?.session - const analysisKey = analysisSessionKey(context) + // Settling recap must open the just-finished session, not an older default. + const previousKey = previous?.session.session_key + const analysisKey = + settling && previousKey && previousKey > 0 ? previousKey : analysisSessionKey(context) const nodes = railNodes(previous, context.active_session, context.next_session) return ( diff --git a/frontend/src/lib/liveState.ts b/frontend/src/lib/liveState.ts new file mode 100644 index 0000000..54c6804 --- /dev/null +++ b/frontend/src/lib/liveState.ts @@ -0,0 +1,168 @@ +// 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". +// +// Critical distinction (issue #74 P0): the server's `deactivate()` path moves a +// STILL-ACTIVE snapshot into `last_snapshot` whenever the upstream FIA SignalR +// connection ends or idles — WITHOUT changing SessionStatus. So `is_live=false` +// alone is ambiguous. We rely on the retained snapshot's terminal SessionStatus +// (Finished/Ended/…) as the only trustworthy "the session really ended" signal. +// A non-terminal retained snapshot means the feed dropped, not that the session +// finished, and must render as `disconnected` (retain snapshot, warn, recover), +// never `settling`/archive. + +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/feed dropped — keep the last snapshot, warn + | 'settling' // session truly 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 retained snapshot from the last stream is held (may be a dropped feed). */ + hasArchive: boolean + /** The user explicitly opened the archive as a read-only timing view. */ + archiveMode: boolean + /** + * The retained snapshot ended on a *terminal* FIA SessionStatus. Only true + * here means the session genuinely finished (vs. the transport dropping). + */ + sessionEndedCleanly: boolean + /** We have observed this session live at least once in this page session. */ + wasLive: boolean + /** + * The initial `/api/v1/live/state` query has resolved. `connecting` is a + * cold-start-only phase: once we know the current state (even if the SSE + * transport handshake is still pending), an idle feed is `inactive`, not a + * perpetual "connecting…" spinner. + */ + stateLoaded: boolean +} + +// Raw FIA SessionStatus values that mean the session is genuinely over. Mirrors +// the backend `terminalSessionStatus` used by the weekend-context resolver so +// the client and server agree on "finished". +const TERMINAL_STATUSES = new Set(['finished', 'finalised', 'finalized', 'ended', 'aborted']) + +function normalizeStatus(status: string | undefined | null): string { + if (!status) return '' + return status.replace(/[^a-zA-Z]/g, '').toLowerCase() +} + +/** Whether a raw FIA SessionStatus represents a genuinely completed session. */ +export function terminalSessionStatus(status: string | undefined | null): boolean { + return TERMINAL_STATUSES.has(normalizeStatus(status)) +} + +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` or + * `settling`. + */ +export function deriveLivePhase(input: LiveStateInputs): LivePhase { + const { + transport, + isLive, + hasActiveSnapshot, + hasArchive, + archiveMode, + sessionEndedCleanly, + stateLoaded, + } = 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' + } + + // Feed reports no active session but a snapshot is retained. Distinguish a + // genuine session end from an upstream feed drop using the *only* trustworthy + // signal: whether the retained snapshot carries a terminal SessionStatus. + if (hasArchive) { + if (sessionEndedCleanly) return 'settling' + // Non-terminal retained snapshot means the upstream feed dropped, not that + // the session finished. Hold the last frame and warn — never settle/archive. + return 'disconnected' + } + + // Cold start only: still opening the feed and we have not yet learned the + // current state. Once the initial state query resolves, an idle feed is + // `inactive`, not a perpetual "connecting…". + if (transport === 'connecting' && !stateLoaded) 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' +} + +/** + * Fan-facing feed health. Browser SSE can stay `connected` after an upstream + * FIA drop leaves us in `disconnected` with a retained non-terminal snapshot — + * present that as reconnecting, never "Feed healthy" beside "Connection lost". + */ +export function effectiveFeedHealth( + transport: TransportHealth, + phase: LivePhase, +): TransportHealth { + if (phase === 'disconnected' && !transportDown(transport)) { + return 'disconnected' + } + return transport +} + +/** 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..0250d8c 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, fetchWeekendContext } from '../api' import type { LivePosition, LiveStreamData } from '../types' import { loadPinnedDrivers, @@ -17,6 +17,15 @@ 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, + effectiveFeedHealth, + rendersSnapshot, + terminalSessionStatus, +} from '../lib/liveState' +import type { TransportHealth } from '../lib/liveState' +import { shouldPollHandoffAnalysis } from '../components/live/LiveHandoff' import { SessionBanner } from '../components/live/SessionBanner' import { TrackStatusBanner } from '../components/live/TrackStatusBanner' import { TimingTower } from '../components/live/TimingTower' @@ -26,9 +35,12 @@ 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' +import { LiveHandoff } from '../components/live/LiveHandoff' +import '../styles/live-state.css' -type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error' +/** 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 export function LiveTimingPage() { const [activeSnapshot, setActiveSnapshot] = useState(null) @@ -37,31 +49,84 @@ 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 + // 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) const hasArchive = Boolean(archiveSnapshot) - const { data, isLoading, isError, error } = useQuery({ + const { + data, + isError, + error, + isFetched: liveStateFetched, + } = useQuery({ queryKey: ['live-state'], queryFn: fetchLiveState, 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. + const phase = deriveLivePhase({ + transport: streamStatus, + isLive, + hasActiveSnapshot: Boolean(activeSnapshot), + hasArchive, + archiveMode, + sessionEndedCleanly, + wasLive: wasLiveRef.current, + stateLoaded: liveStateFetched, + }) + const snapshot = + phase === 'archive' + ? archiveSnapshot + : isLive + ? activeSnapshot + : phase === 'disconnected' + ? archiveSnapshot + : null + + // 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'], + queryFn: fetchWeekendContext, + enabled: !isLive, + staleTime: WEEKEND_CONTEXT_STALE_MS, + refetchInterval: (query) => { + // 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 + }, + refetchIntervalInBackground: false, + }) + const weekendContext = contextQuery.data + useEffect(() => { if (!data) return const nextLive = data.is_live && Boolean(data.data) setIsLive(nextLive) isLiveRef.current = nextLive + if (nextLive) wasLiveRef.current = true if (nextLive && data.data) { setActiveSnapshot(data.data) setArchiveMode(false) @@ -111,6 +176,7 @@ export function LiveTimingPage() { setPositions({}) } isLiveRef.current = true + wasLiveRef.current = true setActiveSnapshot(state.data) setArchiveMode(false) } else { @@ -167,6 +233,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 +278,75 @@ 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) + // 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) + 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 && ( + + )}
diff --git a/frontend/src/styles/live-state.css b/frontend/src/styles/live-state.css new file mode 100644 index 0000000..378e62e --- /dev/null +++ b/frontend/src/styles/live-state.css @@ -0,0 +1,414 @@ +/* ── Live → settling → analysis handoff (issue #74) ── + * + * State-transition presentation for Live: session/flag chrome that replaces + * the old connection-as-session conflation, the connection-loss warning, the + * archive read-only strip, and the settling/inactive handoff surface. + */ + +/* Session flag: primary session state. Feed health is secondary. */ +.live-session-flag { + display: inline-flex; + align-items: center; + gap: var(--s2); + padding: 5px 9px; + border-radius: 3px; + font-family: var(--f-mono); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; +} + +.live-session-flag-live { + background: rgba(225, 6, 0, 0.14); + border: 1px solid rgba(225, 6, 0, 0.35); + color: #ff6b6b; +} + +.live-session-flag-live.is-stale { + background: rgba(255, 214, 0, 0.1); + border-color: rgba(255, 214, 0, 0.35); + color: var(--yellow); +} + +.live-session-flag-archive { + background: rgba(70, 140, 255, 0.12); + border: 1px solid rgba(70, 140, 255, 0.35); + color: #8bb7ff; +} + +.live-session-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 0 0 currentColor; + animation: live-pulse 1.6s ease-in-out infinite; +} + +.live-session-flag-live.is-stale .live-session-dot { + animation: none; + opacity: 0.55; +} + +.live-feed-health { + display: inline-flex; + align-items: center; + gap: var(--s2); + padding: 4px 7px; + border-radius: 2px; + font-family: var(--f-mono); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-3); + border: 1px solid var(--border-2); + background: rgba(255, 255, 255, 0.03); +} + +.live-feed-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +.live-feed-connected { + color: var(--green); + border-color: rgba(57, 199, 58, 0.25); + background: rgba(57, 199, 58, 0.08); +} + +.live-feed-connecting { + color: var(--yellow); + border-color: rgba(255, 214, 0, 0.25); + background: rgba(255, 214, 0, 0.08); +} + +.live-feed-disconnected, +.live-feed-error { + color: #ff6b6b; + border-color: rgba(225, 6, 0, 0.24); + background: rgba(225, 6, 0, 0.08); +} + +.live-banner-archive { + border-color: rgba(70, 140, 255, 0.28); +} + +.live-archive-stamp { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; +} + +.live-archive-readonly { + font-size: 10px; + font-weight: 800; + letter-spacing: 0.08em; + color: #8bb7ff; +} + +.live-archive-captured { + font-size: 10px; + color: var(--text-3); +} + +.live-state-archive { + background: rgba(70, 140, 255, 0.12); + color: #8bb7ff; + border: 1px solid rgba(70, 140, 255, 0.28); +} + +/* Disconnected warning: a live session whose feed dropped. Distinct from the + * blue archive strip so a dropped feed is never mistaken for a read-only + * archive. The retained tower stays visible beneath it. */ +.live-status-strip.live-status-warn { + display: flex; + align-items: center; + gap: var(--s3); +} + +.live-status-strip.live-status-warn::before { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--yellow); + animation: live-pulse 1.6s ease-in-out infinite; +} + +@keyframes live-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.35; + } +} + +.live-status-strip.live-status-archive { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s4); + flex-wrap: wrap; +} + +.live-archive-exit { + min-height: 28px; + padding: 0 var(--s4); + border: 1px solid rgba(70, 140, 255, 0.4); + border-radius: 4px; + background: rgba(70, 140, 255, 0.12); + color: #8bb7ff; + font-family: var(--f-mono); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + cursor: pointer; +} + +.live-archive-exit:hover { + border-color: rgba(70, 140, 255, 0.7); + background: rgba(70, 140, 255, 0.2); +} + +/* ── Handoff surface (settling + inactive) ── */ +.live-handoff { + border: 1px solid var(--border-2); + border-radius: 10px; + padding: var(--s6) var(--s5); + background: + radial-gradient(120% 120% at 0% 0%, rgba(255, 255, 255, 0.04), transparent 55%), + var(--surface); +} + +.live-handoff-settling { + border-left: 3px solid var(--yellow); +} + +.live-handoff-inactive { + border-left: 3px solid var(--border-2); +} + +.live-handoff-head { + display: flex; + flex-direction: column; + gap: var(--s3); + margin-bottom: var(--s5); +} + +.live-handoff-eyebrow { + display: inline-flex; + align-items: center; + gap: var(--s2); + align-self: flex-start; + padding: 4px var(--s3); + border-radius: 3px; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.live-handoff-settling .live-handoff-eyebrow { + color: var(--yellow); + background: rgba(245, 158, 11, 0.14); + border: 1px solid rgba(245, 158, 11, 0.3); +} + +.live-handoff-inactive .live-handoff-eyebrow { + color: var(--text-2); + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--border-2); +} + +.live-handoff-title { + font-size: 26px; + line-height: 1.15; + font-weight: 800; +} + +.live-handoff-captured { + font-size: 11px; + color: var(--text-3); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.live-handoff-sub { + font-size: 13px; + color: var(--text-2); + max-width: 62ch; +} + +.live-handoff-feed { + align-self: flex-start; + margin-top: var(--s2); +} + +/* Provisional final-order snapshot chips (settling only). */ +.live-handoff-snapshot { + margin-bottom: var(--s5); + padding: var(--s4); + border: 1px dashed var(--border-2); + border-radius: 8px; + background: rgba(255, 255, 255, 0.02); +} + +.live-handoff-snapshot-label { + display: block; + margin-bottom: var(--s3); + font-size: 10px; + color: var(--text-3); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.live-handoff-order { + display: flex; + flex-wrap: wrap; + gap: var(--s3); + list-style: none; +} + +.live-handoff-order li { + display: inline-flex; + align-items: center; + gap: var(--s2); + padding: var(--s2) var(--s3); + border: 1px solid var(--border-2); + border-radius: 4px; + background: rgba(255, 255, 255, 0.03); + font-size: 12px; +} + +.live-handoff-order li span:first-child { + color: var(--text-3); + font-size: 11px; +} + +.live-handoff-code { + font-weight: 800; + color: var(--text); +} + +/* ── Action cards ── */ +.live-handoff-actions { + display: grid; + gap: var(--s3); +} + +.live-handoff-action { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s4); + width: 100%; + padding: var(--s4) var(--s5); + border: 1px solid var(--border-2); + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + color: var(--text); + text-align: left; + cursor: pointer; + transition: + border-color 0.15s ease, + background 0.15s ease, + transform 0.15s ease; +} + +button.live-handoff-action { + font: inherit; +} + +.live-handoff-action:hover { + border-color: var(--border-glow); + background: rgba(255, 255, 255, 0.06); + transform: translateX(2px); +} + +.live-handoff-action-body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.live-handoff-action-label { + display: inline-flex; + align-items: center; + gap: var(--s2); + font-size: 14px; + font-weight: 700; +} + +.live-handoff-action-meta { + font-size: 11px; + color: var(--text-3); +} + +/* The primary "open analysis" action is the strongest affordance. */ +.live-handoff-primary { + border-color: rgba(230, 36, 41, 0.4); + background: linear-gradient(90deg, rgba(230, 36, 41, 0.14), transparent 60%); +} + +.live-handoff-primary:hover { + border-color: rgba(230, 36, 41, 0.7); + background: linear-gradient(90deg, rgba(230, 36, 41, 0.22), transparent 60%); +} + +/* Pending-analysis states read as "waiting", not actionable links. */ +.live-handoff-primary[data-ready='false'] { + border-color: rgba(245, 158, 11, 0.4); + background: linear-gradient(90deg, rgba(245, 158, 11, 0.1), transparent 60%); +} + +.live-handoff-pending { + cursor: default; + opacity: 0.75; +} + +.live-handoff-pending:hover { + transform: none; + background: rgba(255, 255, 255, 0.03); + border-color: var(--border-2); +} + +.live-handoff-next { + cursor: default; +} + +.live-handoff-next:hover { + transform: none; +} + +/* ── Responsive: keep the handoff usable at tablet / mobile widths ── */ +@media (max-width: 640px) { + .live-handoff { + padding: var(--s5) var(--s4); + } + + .live-handoff-title { + font-size: 21px; + } + + .live-handoff-action { + padding: var(--s4); + } + + .live-handoff-action-label { + font-size: 13px; + } + + .live-status-strip.live-status-archive { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/frontend/src/test/LiveHandoff.test.tsx b/frontend/src/test/LiveHandoff.test.tsx new file mode 100644 index 0000000..ba019c6 --- /dev/null +++ b/frontend/src/test/LiveHandoff.test.tsx @@ -0,0 +1,289 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { + LiveHandoff, + analysisIsReady, + handoffAnalysisSession, + shouldPollHandoffAnalysis, +} from '../components/live/LiveHandoff' +import type { LiveTimingRow } from '../lib/live' +import type { ContextSession, WeekendContext } from '../types' + +function session(key: number, name: string, type = name): ContextSession['session'] { + return { + session_key: key, + session_name: name, + session_type: type, + meeting_key: 1, + date_start: '2026-07-05T14:00:00Z', + date_end: '2026-07-05T16:00:00Z', + gmt_offset: '', + } +} + +function contextSession( + key: number, + name: string, + localAnalysis: string, +): ContextSession { + return { + session: session(key, name), + availability: { + schedule: 'available', + live_transport: 'unknown', + live_session: 'inactive', + archive: 'unavailable', + local_analysis: localAnalysis, + freshness: 'fresh', + limitations: [], + }, + } +} + +const baseContext: WeekendContext = { + temporal_state: 'session_settling', + focus_meeting: { + meeting_key: 1, + meeting_name: 'British Grand Prix', + meeting_official_name: 'British Grand Prix', + location: 'Silverstone', + country_name: 'UK', + country_code: 'GB', + country_flag: '', + circuit_short_name: 'Silverstone', + date_start: '2026-07-03T09:00:00Z', + date_end: '2026-07-05T16:00:00Z', + year: 2026, + }, + championship_round: 1, + total_championship_rounds: 1, +} + +/** Canonical contract: archive-only just-finished Race vs older ready Practice. */ +function archiveOnlySettlingContext(previousAnalysis: string): WeekendContext { + return { + ...baseContext, + // Older already-ingested session remains the default analysis target. + default_analysis_session: contextSession(10, 'Practice 1', 'complete'), + // Just-finished Race is archive-complete but not yet analysis-ready. + previous_completed_session: contextSession(99, 'Race', previousAnalysis), + next_session: contextSession(12, 'Qualifying', 'not_applicable'), + } +} + +const rows: LiveTimingRow[] = [ + { RacingNumber: '1', Position: 1, Driver: { RacingNumber: '1', Position: 1 } as never, Info: { Tla: 'VER' } as never }, + { RacingNumber: '4', Position: 2, Driver: { RacingNumber: '4', Position: 2 } as never, Info: { Tla: 'NOR' } as never }, +] + +describe('analysisIsReady', () => { + it('is true only when local analysis is complete', () => { + expect(analysisIsReady(contextSession(11, 'Race', 'complete'))).toBe(true) + expect(analysisIsReady(contextSession(11, 'Race', 'pending'))).toBe(false) + expect(analysisIsReady(contextSession(11, 'Race', 'partial'))).toBe(false) + expect(analysisIsReady(undefined)).toBe(false) + }) +}) + +describe('handoffAnalysisSession', () => { + it('settling prefers previous_completed_session over an older default', () => { + const ctx = archiveOnlySettlingContext('pending') + const analysis = handoffAnalysisSession(ctx, 'settling') + expect(analysis?.session.session_key).toBe(99) + expect(analysis?.session.session_name).toBe('Race') + expect(analysisIsReady(analysis)).toBe(false) + }) + + it('inactive prefers default_analysis_session (shared analysisSessionKey)', () => { + const ctx = archiveOnlySettlingContext('pending') + const analysis = handoffAnalysisSession(ctx, 'inactive') + expect(analysis?.session.session_key).toBe(10) + expect(analysis?.session.session_name).toBe('Practice 1') + }) + + it('settling falls back to default when previous is absent', () => { + const ctx = { + ...baseContext, + default_analysis_session: contextSession(11, 'Race', 'pending'), + } + expect(handoffAnalysisSession(ctx, 'settling')?.session.session_key).toBe(11) + }) +}) + +describe('shouldPollHandoffAnalysis', () => { + it('keeps polling when previous is pending even if default is already complete', () => { + expect(shouldPollHandoffAnalysis(archiveOnlySettlingContext('pending'))).toBe(true) + }) + + it('stops polling once the just-finished previous session is ready', () => { + expect(shouldPollHandoffAnalysis(archiveOnlySettlingContext('complete'))).toBe(false) + }) +}) + +describe('LiveHandoff settling', () => { + it('shows SESSION SETTLING with a pending analysis action while ingesting', () => { + render( + , + ) + + expect(screen.getByTestId('live-settling')).toBeInTheDocument() + expect(screen.getByText('SESSION SETTLING')).toBeInTheDocument() + expect(screen.getByTestId('live-handoff-captured')).toHaveTextContent('Final feed snapshot captured') + const action = screen.getByTestId('live-handoff-analysis') + expect(action).toHaveAttribute('href', '/race-hub?session_key=11') + expect(action).toHaveAttribute('data-ready', 'false') + expect(action).toHaveTextContent('Open Race analysis') + expect(action).toHaveTextContent(/analysis will fill in as data ingests/i) + // Provisional final order from the retained snapshot. + expect(screen.getByTestId('live-handoff-snapshot')).toHaveTextContent('VER') + }) + + it('links/labels/readiness follow archive-only previous, not an older ready default', () => { + render( + , + ) + + const action = screen.getByTestId('live-handoff-analysis') + expect(action).toHaveAttribute('href', '/race-hub?session_key=99') + expect(action).toHaveAttribute('data-ready', 'false') + expect(action).toHaveTextContent('Open Race analysis') + expect(action).toHaveTextContent(/Settling — analysis will fill in as data ingests/i) + expect(action).not.toHaveTextContent('Practice 1') + expect(action).not.toHaveTextContent(/full timing, strategy & story ready/i) + }) + + it('flips to analysis-ready once local ingestion completes', () => { + render( + , + ) + const action = screen.getByTestId('live-handoff-analysis') + expect(action).toHaveAttribute('data-ready', 'true') + expect(action).toHaveTextContent(/full timing, strategy & story ready/i) + }) + + it('never renders live/connected chrome or a countdown in settling', () => { + const { container } = render( + , + ) + expect(screen.queryByText('LIVE SESSION')).not.toBeInTheDocument() + expect(screen.queryByTestId('live-clock')).not.toBeInTheDocument() + expect(container.querySelector('.live-session-flag-live')).toBeNull() + }) + + it('uses non-settling readiness copy when inactive and analysis is still ingesting', () => { + render( + , + ) + const action = screen.getByTestId('live-handoff-analysis') + expect(action).toHaveAttribute('data-ready', 'false') + expect(action).toHaveTextContent(/analysis will fill in as data ingests/i) + expect(action).not.toHaveTextContent(/^Settling/i) + }) +}) + +describe('LiveHandoff inactive', () => { + it('uses shared next + recap context instead of a telemetry-offline dead end', () => { + render( + , + ) + expect(screen.getByTestId('live-inactive')).toBeInTheDocument() + expect(screen.getByText('NO LIVE SESSION')).toBeInTheDocument() + expect(screen.getByTestId('live-handoff-next')).toHaveTextContent('Qualifying') + expect(screen.getByTestId('live-handoff-recap')).toHaveTextContent('Practice 2') + // No provisional-order snapshot when inactive. + expect(screen.queryByTestId('live-handoff-snapshot')).not.toBeInTheDocument() + }) + + it('does not duplicate the recap card when previous equals the analysis target', () => { + render( + , + ) + expect(screen.queryByTestId('live-handoff-recap')).not.toBeInTheDocument() + }) + + it('falls back to Weekend when the context has nothing to offer', () => { + render( + , + ) + expect(screen.getByTestId('live-handoff-fallback')).toHaveTextContent('Weekend') + }) +}) diff --git a/frontend/src/test/LiveTimingPage.test.tsx b/frontend/src/test/LiveTimingPage.test.tsx index cb831e6..be0a8ac 100644 --- a/frontend/src/test/LiveTimingPage.test.tsx +++ b/frontend/src/test/LiveTimingPage.test.tsx @@ -1,32 +1,34 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { LiveTimingPage } from '../pages/LiveTimingPage' -import type { LiveStateResponse, LiveStreamData } from '../types' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { LiveTimingPage, WEEKEND_CONTEXT_POLL_MS } from '../pages/LiveTimingPage' +import type { LiveStateResponse, LiveStreamData, WeekendContext } from '../types' vi.mock('../api', () => ({ fetchLiveState: vi.fn(), fetchLiveTrackOutline: vi.fn(), + fetchWeekendContext: vi.fn(), })) -import { fetchLiveState, fetchLiveTrackOutline } from '../api' +import { fetchLiveState, fetchLiveTrackOutline, fetchWeekendContext } from '../api' const mockFetchLiveState = vi.mocked(fetchLiveState) const mockFetchLiveTrackOutline = vi.mocked(fetchLiveTrackOutline) +const mockFetchWeekendContext = vi.mocked(fetchWeekendContext) +// A MockEventSource that opens on construct but never emits a snapshot, so the +// initial /api/v1/live/state query drives the rendered phase. class MockEventSource { onopen: (() => void) | null = null onerror: (() => void) | null = null - constructor() { setTimeout(() => this.onopen?.(), 0) } - addEventListener() {} close() {} } -const archivedSnapshot: LiveStreamData = { +const raceSnapshot: LiveStreamData = { Drivers: { '1': { RacingNumber: '1', @@ -63,19 +65,10 @@ const archivedSnapshot: LiveStreamData = { LastName: 'Verstappen', }, }, - Tyres: { - '1': { Compound: 'HARD', New: false, Age: 12 }, - }, + Tyres: { '1': { Compound: 'HARD', New: false, Age: 12 } }, Telemetry: {}, RCMessages: [], - Weather: { - AirTemp: 22, - TrackTemp: 41, - Humidity: 58, - WindSpeed: 3, - WindDir: 180, - Rainfall: false, - }, + Weather: { AirTemp: 22, TrackTemp: 41, Humidity: 58, WindSpeed: 3, WindDir: 180, Rainfall: false }, Session: { MeetingName: 'Testonia Grand Prix', CircuitName: 'Testring', @@ -84,9 +77,9 @@ const archivedSnapshot: LiveStreamData = { Path: '', }, TeamRadio: [], - SessionStatus: 'Finished', + SessionStatus: 'Started', TrackStatus: '1', - CurrentLap: 57, + CurrentLap: 30, TotalLaps: 57, Clock: '', ClockRefTime: '', @@ -94,17 +87,93 @@ const archivedSnapshot: LiveStreamData = { Stints: {}, } -function renderPage(response: LiveStateResponse) { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }) +function weekendContext(localAnalysis: string): WeekendContext { + return { + temporal_state: 'session_settling', + focus_meeting: { + meeting_key: 1, + meeting_name: 'Testonia Grand Prix', + meeting_official_name: 'Testonia Grand Prix', + location: 'Testring', + country_name: 'Testonia', + country_code: 'TS', + country_flag: '', + circuit_short_name: 'Testring', + date_start: '2026-07-03T09:00:00Z', + date_end: '2026-07-05T16:00:00Z', + year: 2026, + }, + previous_completed_session: { + session: { + session_key: 99, + session_name: 'Race', + session_type: 'Race', + meeting_key: 1, + date_start: '2026-07-05T14:00:00Z', + date_end: '2026-07-05T16:00:00Z', + gmt_offset: '', + }, + availability: { + schedule: 'available', + live_transport: 'unknown', + live_session: 'inactive', + archive: 'available', + local_analysis: localAnalysis, + freshness: 'fresh', + limitations: [], + }, + }, + championship_round: 1, + total_championship_rounds: 1, + } +} + +/** Just-finished archive-only Race + older already-ready Practice default. */ +function archiveOnlySettlingContext(previousAnalysis: string): WeekendContext { + return { + ...weekendContext(previousAnalysis), + default_analysis_session: { + session: { + session_key: 10, + session_name: 'Practice 1', + session_type: 'Practice', + meeting_key: 1, + date_start: '2026-07-04T12:00:00Z', + date_end: '2026-07-04T13:00:00Z', + gmt_offset: '', + }, + availability: { + schedule: 'available', + live_transport: 'unknown', + live_session: 'inactive', + archive: 'available', + local_analysis: 'complete', + freshness: 'fresh', + limitations: [], + }, + }, + } +} + +function renderPage( + response: LiveStateResponse, + context?: WeekendContext, + options: { setWeekendContext?: boolean } = {}, +) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) mockFetchLiveState.mockResolvedValue(response) mockFetchLiveTrackOutline.mockResolvedValue({ circuit_key: 1, points: [], bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 }, }) - + // Allow callers to pre-configure the weekend-context mock (e.g. pending→complete + // polling) without this helper overwriting the chain. + if (options.setWeekendContext !== false) { + mockFetchWeekendContext.mockResolvedValue( + context ?? { temporal_state: 'no_season', championship_round: 0, total_championship_rounds: 0 }, + ) + } return render( @@ -112,7 +181,7 @@ function renderPage(response: LiveStateResponse) { ) } -describe('LiveTimingPage archive mode', () => { +describe('LiveTimingPage', () => { beforeEach(() => { vi.clearAllMocks() Object.defineProperty(window, 'EventSource', { @@ -122,45 +191,205 @@ describe('LiveTimingPage archive mode', () => { }) }) - it('keeps archived snapshots behind the View Last Session action', async () => { - renderPage({ - is_live: false, - data: null, - last_snapshot: archivedSnapshot, - last_positions: { - '1': { x: 10, y: 20, z: 0, status: 'OnTrack' }, + afterEach(() => { + vi.useRealTimers() + }) + + it('renders the live timing tower for an active session', async () => { + renderPage({ is_live: true, data: raceSnapshot }) + expect(await screen.findByText('Timing Tower')).toBeInTheDocument() + expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'live') + expect(screen.getByTestId('live-session-flag')).toHaveTextContent('LIVE SESSION') + expect(screen.getByTestId('live-feed-health')).toBeInTheDocument() + expect(screen.getAllByText('VER').length).toBeGreaterThan(0) + }) + + it('enters the settling handoff (not archive) when the session ends cleanly', async () => { + renderPage( + { + is_live: false, + data: null, + last_snapshot: { ...raceSnapshot, SessionStatus: 'Finished' }, + last_snapshot_at: '2026-07-05T16:02:00Z', }, - last_snapshot_at: '2026-07-04T14:00:00Z', + weekendContext('pending'), + ) + + const settling = await screen.findByTestId('live-settling') + expect(settling).toBeInTheDocument() + expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'settling') + // Just-finished previous_completed_session drives the primary action target. + await waitFor(() => + expect(screen.getByTestId('live-handoff-analysis')).toHaveAttribute( + 'href', + '/race-hub?session_key=99', + ), + ) + // The timing tower is not shown while settling — it is a handoff surface. + expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument() + }) + + it('settles against archive-only previous, not an older ready default_analysis_session', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + mockFetchWeekendContext + .mockResolvedValueOnce(archiveOnlySettlingContext('pending')) + .mockResolvedValue(archiveOnlySettlingContext('complete')) + + renderPage( + { + is_live: false, + data: null, + last_snapshot: { ...raceSnapshot, SessionStatus: 'Finished' }, + last_snapshot_at: '2026-07-05T16:02:00Z', + }, + undefined, + { setWeekendContext: false }, + ) + + const action = await screen.findByTestId('live-handoff-analysis') + expect(action).toHaveAttribute('href', '/race-hub?session_key=99') + expect(action).toHaveAttribute('data-ready', 'false') + expect(action).toHaveTextContent('Open Race analysis') + expect(action).not.toHaveTextContent('Practice 1') + + // Older default is already complete — polling must continue for previous. + await act(async () => { + await vi.advanceTimersByTimeAsync(WEEKEND_CONTEXT_POLL_MS + 500) }) - expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active') + await waitFor(() => + expect(screen.getByTestId('live-handoff-analysis')).toHaveAttribute('data-ready', 'true'), + ) + expect(screen.getByTestId('live-handoff-analysis')).toHaveAttribute( + 'href', + '/race-hub?session_key=99', + ) + expect(mockFetchWeekendContext.mock.calls.length).toBeGreaterThan(1) + }) + + it('flips settling → analysis-ready when polling sees ingestion complete', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + // First fetch: pending. Subsequent polls: complete. + mockFetchWeekendContext + .mockResolvedValueOnce(weekendContext('pending')) + .mockResolvedValue(weekendContext('complete')) + + renderPage( + { + is_live: false, + data: null, + last_snapshot: { ...raceSnapshot, SessionStatus: 'Finished' }, + last_snapshot_at: '2026-07-05T16:02:00Z', + }, + undefined, + { setWeekendContext: false }, + ) + + const action = await screen.findByTestId('live-handoff-analysis') + expect(action).toHaveAttribute('data-ready', 'false') + expect(action).toHaveTextContent(/analysis will fill in as data ingests/i) + + await act(async () => { + await vi.advanceTimersByTimeAsync(WEEKEND_CONTEXT_POLL_MS + 500) + }) + + await waitFor(() => + expect(screen.getByTestId('live-handoff-analysis')).toHaveAttribute('data-ready', 'true'), + ) + expect(screen.getByTestId('live-handoff-analysis')).toHaveTextContent( + /full timing, strategy & story ready/i, + ) + }) + + it('retains the last snapshot with a disconnected warning (not settling) on a feed drop', async () => { + // is_live=false but the retained snapshot has a NON-terminal SessionStatus: + // the FIA feed dropped while the session was still active. + renderPage( + { + is_live: false, + data: null, + last_snapshot: { ...raceSnapshot, SessionStatus: 'Started' }, + last_snapshot_at: '2026-07-05T15:30:00Z', + }, + weekendContext('pending'), + ) + + expect(await screen.findByTestId('live-disconnected-strip')).toHaveTextContent( + /showing the last live data/i, + ) + expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'disconnected') + // The retained live tower stays visible; we never fell into settling/archive. + expect(screen.getByText('Timing Tower')).toBeInTheDocument() + expect(screen.queryByTestId('live-settling')).not.toBeInTheDocument() + expect(screen.queryByTestId('live-archive-strip')).not.toBeInTheDocument() + // SSE may still be open (MockEventSource opens) — health must not say healthy. + await waitFor(() => + expect(screen.getByTestId('live-feed-health')).toHaveTextContent(/reconnecting/i), + ) + expect(screen.getByTestId('live-feed-health')).not.toHaveTextContent(/feed healthy/i) + }) + + it('keeps the settled snapshot behind an explicit read-only archive action', async () => { + renderPage( + { + is_live: false, + data: null, + last_snapshot: { ...raceSnapshot, SessionStatus: 'Finished' }, + last_positions: { '1': { x: 10, y: 20, z: 0, status: 'OnTrack' } }, + last_snapshot_at: '2026-07-05T16:02:00Z', + }, + weekendContext('complete'), + ) + + // Settling first — the tower is hidden until the user opens the archive. + await screen.findByTestId('live-settling') expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: /view last session/i })) + fireEvent.click(screen.getByTestId('live-handoff-archive')) - await waitFor(() => { - expect(screen.getByTestId('live-archive-strip')).toHaveTextContent('Archived snapshot') - }) + await waitFor(() => + expect(screen.getByTestId('live-archive-strip')).toHaveTextContent(/read-only/i), + ) expect(screen.getByText('Timing Tower')).toBeInTheDocument() - expect(screen.getAllByText('VER').length).toBeGreaterThan(0) + // Archive presents its read-only, timestamped chrome — never LIVE. expect(screen.getByText('archive')).toBeInTheDocument() + expect(screen.queryByText('LIVE SESSION')).not.toBeInTheDocument() }) - it('does not show the archive action when no snapshot is retained', async () => { - renderPage({ is_live: false, data: null }) + it('shows the inactive weekend context when nothing is live or retained', async () => { + renderPage( + { is_live: false, data: null }, + { + temporal_state: 'between_weekends', + next_session: { + session: { + session_key: 21, + session_name: 'Practice 1', + session_type: 'Practice 1', + meeting_key: 2, + date_start: '2026-07-17T09:00:00Z', + date_end: '2026-07-17T10:00:00Z', + gmt_offset: '', + }, + availability: { + schedule: 'available', + live_transport: 'unknown', + live_session: 'inactive', + archive: 'unavailable', + local_analysis: 'not_applicable', + freshness: 'fresh', + limitations: [], + }, + }, + championship_round: 2, + total_championship_rounds: 24, + }, + ) - expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active') - expect(screen.queryByRole('button', { name: /view last session/i })).not.toBeInTheDocument() - }) - - it('temporarily omits the track map while live GPS is unavailable', async () => { - renderPage({ - is_live: true, - data: { ...archivedSnapshot, SessionStatus: 'Started' }, - }) - - expect(await screen.findByText('Timing Tower')).toBeInTheDocument() - expect(screen.queryByText('Track Map')).not.toBeInTheDocument() - expect(mockFetchLiveTrackOutline).not.toHaveBeenCalled() + expect(await screen.findByTestId('live-inactive')).toBeInTheDocument() + expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'inactive') + await waitFor(() => + expect(screen.getByTestId('live-handoff-next')).toHaveTextContent('Practice 1'), + ) }) }) diff --git a/frontend/src/test/liveState.test.ts b/frontend/src/test/liveState.test.ts new file mode 100644 index 0000000..6d05edd --- /dev/null +++ b/frontend/src/test/liveState.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from 'vitest' +import { + allowsLiveInterpretations, + deriveLivePhase, + effectiveFeedHealth, + isReadOnlyPhase, + rendersSnapshot, + terminalSessionStatus, + type LiveStateInputs, +} from '../lib/liveState' + +const base: LiveStateInputs = { + transport: 'connecting', + isLive: false, + hasActiveSnapshot: false, + hasArchive: false, + archiveMode: false, + sessionEndedCleanly: false, + wasLive: false, + stateLoaded: false, +} + +describe('terminalSessionStatus', () => { + it('recognizes genuine session-end statuses', () => { + for (const s of ['Finished', 'Finalised', 'Finalized', 'ENDED', 'Aborted']) { + expect(terminalSessionStatus(s)).toBe(true) + } + }) + + it('treats still-running / unknown statuses as non-terminal', () => { + for (const s of ['Started', 'Resumed', 'Inactive', '', undefined, null]) { + expect(terminalSessionStatus(s)).toBe(false) + } + }) +}) + +describe('deriveLivePhase transitions', () => { + it('connecting: cold start with the feed still opening', () => { + expect(deriveLivePhase({ ...base, transport: 'connecting' })).toBe('connecting') + }) + + it('connecting → live once a session snapshot arrives', () => { + expect( + deriveLivePhase({ + ...base, + transport: 'connected', + isLive: true, + hasActiveSnapshot: true, + }), + ).toBe('live') + }) + + it('live → disconnected when the SSE transport drops mid-session', () => { + expect( + deriveLivePhase({ + ...base, + transport: 'disconnected', + isLive: true, + hasActiveSnapshot: true, + wasLive: true, + }), + ).toBe('disconnected') + }) + + it('live → settling when the session ends with a terminal status', () => { + // Feed reports is_live=false, transport healthy, retained snapshot carries a + // terminal SessionStatus → the session genuinely finished. + expect( + deriveLivePhase({ + ...base, + transport: 'connected', + isLive: false, + hasArchive: true, + sessionEndedCleanly: true, + wasLive: true, + }), + ).toBe('settling') + }) + + it('live → disconnected (NOT settling) when the FIA feed drops but status is non-terminal', () => { + // This is the P0 case: server deactivate() moved a still-active snapshot to + // last_snapshot with is_live=false but a non-terminal SessionStatus. The + // browser transport is healthy. We must warn + retain, never settle/archive. + expect( + deriveLivePhase({ + ...base, + transport: 'connected', + isLive: false, + hasArchive: true, + sessionEndedCleanly: false, + wasLive: true, + }), + ).toBe('disconnected') + }) + + it('a retained non-terminal snapshot on cold load is disconnected, not settling', () => { + expect( + deriveLivePhase({ + ...base, + transport: 'connected', + isLive: false, + hasArchive: true, + sessionEndedCleanly: false, + wasLive: false, + }), + ).toBe('disconnected') + }) + + it('settling → archive when the user explicitly opens the read-only snapshot', () => { + expect( + deriveLivePhase({ + ...base, + transport: 'connected', + isLive: false, + hasArchive: true, + sessionEndedCleanly: true, + archiveMode: true, + }), + ).toBe('archive') + }) + + it('archive mode wins over an active session (user-chosen read-only view)', () => { + expect( + deriveLivePhase({ + ...base, + transport: 'connected', + isLive: true, + hasActiveSnapshot: true, + hasArchive: true, + archiveMode: true, + }), + ).toBe('archive') + }) + + it('inactive when there is no session and nothing retained', () => { + expect(deriveLivePhase({ ...base, transport: 'connected', stateLoaded: true })).toBe('inactive') + }) + + it('leaves connecting for inactive once the initial state resolves, even mid-handshake', () => { + // connecting → inactive: the SSE transport handshake is still pending + // (transport === "connecting") but the initial live-state query resolved as + // idle, so we must not spin on "connecting…" forever. + expect(deriveLivePhase({ ...base, transport: 'connecting', stateLoaded: true })).toBe('inactive') + }) +}) + +describe('phase capability helpers', () => { + it('renders the snapshot surface only for live/disconnected/archive', () => { + expect(rendersSnapshot('live')).toBe(true) + expect(rendersSnapshot('disconnected')).toBe(true) + expect(rendersSnapshot('archive')).toBe(true) + expect(rendersSnapshot('settling')).toBe(false) + expect(rendersSnapshot('inactive')).toBe(false) + expect(rendersSnapshot('connecting')).toBe(false) + }) + + it('allows live-only interpretations only while the session is moving', () => { + expect(allowsLiveInterpretations('live')).toBe(true) + expect(allowsLiveInterpretations('disconnected')).toBe(true) + // A frozen archive frame must never present live-only interpretations. + expect(allowsLiveInterpretations('archive')).toBe(false) + expect(allowsLiveInterpretations('settling')).toBe(false) + }) + + it('marks archive as the read-only phase', () => { + expect(isReadOnlyPhase('archive')).toBe(true) + expect(isReadOnlyPhase('live')).toBe(false) + expect(isReadOnlyPhase('disconnected')).toBe(false) + }) +}) + +describe('effectiveFeedHealth', () => { + it('downgrades a still-open SSE to reconnecting while phase is disconnected', () => { + expect(effectiveFeedHealth('connected', 'disconnected')).toBe('disconnected') + expect(effectiveFeedHealth('connecting', 'disconnected')).toBe('disconnected') + }) + + it('preserves transport when the session phase is live or settling', () => { + expect(effectiveFeedHealth('connected', 'live')).toBe('connected') + expect(effectiveFeedHealth('connected', 'settling')).toBe('connected') + expect(effectiveFeedHealth('error', 'disconnected')).toBe('error') + }) +}) diff --git a/tests/live-timing.spec.ts b/tests/live-timing.spec.ts index d392372..2e586a2 100644 --- a/tests/live-timing.spec.ts +++ b/tests/live-timing.spec.ts @@ -93,6 +93,7 @@ const raceSnapshot = { SessionType: 'Race', SessionName: 'Race', }, + SessionStatus: 'Started', TrackStatus: '2', CurrentLap: 30, TotalLaps: 57, @@ -138,6 +139,7 @@ const sprintQualifyingSnapshot = { SessionType: 'Sprint Qualifying', SessionName: 'Sprint Qualifying', }, + SessionStatus: 'Started', TrackStatus: '1', CurrentLap: 0, TotalLaps: 0, @@ -164,6 +166,12 @@ test.describe('Live Timing (mocked snapshot)', () => { test('renders timing tower with drivers from the snapshot', async ({ page }) => { await expect(page.getByTestId('live-page')).toBeVisible() + // The hermetic SSE mock ends after a heartbeat, so transport may drop to + // disconnected while the session snapshot remains active — both phases keep + // the LIVE SESSION chrome and timing tower. + await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', /^(live|disconnected)$/) + await expect(page.getByTestId('live-session-flag')).toContainText('LIVE SESSION') + await expect(page.getByTestId('live-feed-health')).toBeVisible() const tower = page.locator('.live-tower') await expect(tower).toBeVisible() await expect(tower).toContainText('VER') @@ -233,45 +241,215 @@ test.describe('Live Timing (mocked Sprint Qualifying)', () => { }) }) +const weekendContext = (localAnalysis: string) => ({ + temporal_state: 'session_settling', + focus_meeting: { + meeting_key: 1, + meeting_name: 'Testonia Grand Prix', + meeting_official_name: 'Testonia Grand Prix', + location: 'Testring', + country_name: 'Testonia', + country_code: 'TS', + country_flag: '', + circuit_short_name: 'Testring', + date_start: '2026-07-03T09:00:00Z', + date_end: '2026-07-05T16:00:00Z', + year: 2026, + }, + // Just-finished session lives in previous_completed_session (canonical contract). + previous_completed_session: { + session: { + session_key: 9472, + session_name: 'Race', + session_type: 'Race', + meeting_key: 1, + date_start: '2026-07-05T14:00:00Z', + date_end: '2026-07-05T16:00:00Z', + gmt_offset: '', + }, + availability: { + schedule: 'available', + live_transport: 'unknown', + live_session: 'inactive', + archive: 'available', + local_analysis: localAnalysis, + freshness: 'fresh', + limitations: [], + }, + }, + championship_round: 1, + total_championship_rounds: 1, +}) + +/** Archive-only just-finished Race + older already-ready Practice default. */ +const archiveOnlySettlingContext = (previousAnalysis: string) => ({ + ...weekendContext(previousAnalysis), + default_analysis_session: { + session: { + session_key: 9001, + session_name: 'Practice 1', + session_type: 'Practice', + meeting_key: 1, + date_start: '2026-07-04T12:00:00Z', + date_end: '2026-07-04T13:00:00Z', + gmt_offset: '', + }, + availability: { + schedule: 'available', + live_transport: 'unknown', + live_session: 'inactive', + archive: 'available', + local_analysis: 'complete', + freshness: 'fresh', + limitations: [], + }, + }, +}) + +const heartbeatStream = (route: import('@playwright/test').Route) => + route.fulfill({ contentType: 'text/event-stream', body: 'event: heartbeat\ndata: {}\n\n' }) + test.describe('Live Timing (no session)', () => { - test('shows the empty state when the feed has no snapshot', async ({ page }) => { + test('shows the inactive weekend-context handoff when the feed has no snapshot', async ({ page }) => { + await page.route('**/api/v1/live/stream', heartbeatStream) await page.goto('/live') - await expect(page.getByTestId('live-empty')).toBeVisible() - await expect(page.getByTestId('live-page')).toContainText('No live session active') + // The e2e server (BOXBOX_DISABLE_LIVE=1) exposes a real /api/v1/weekend-context. + await expect(page.getByTestId('live-inactive')).toBeVisible() + await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', 'inactive') + await expect(page.getByText('NO LIVE SESSION')).toBeVisible() }) - test('renders an archived snapshot only after View Last Session', async ({ page }) => { + test('enters settling (not archive) when a finished session is retained', async ({ page }) => { await page.route('**/api/v1/live/state', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ is_live: false, data: null, - last_snapshot: { - ...raceSnapshot.data, - SessionStatus: 'Finished', - }, - last_positions: { - '1': { x: 100, y: -50, z: 2, status: 'OnTrack' }, - }, + last_snapshot: { ...raceSnapshot.data, SessionStatus: 'Finished' }, + last_positions: { '1': { x: 100, y: -50, z: 2, status: 'OnTrack' } }, last_snapshot_at: '2026-07-04T14:00:00Z', }), }), ) - await page.route('**/api/v1/live/stream', (route) => - route.fulfill({ - contentType: 'text/event-stream', - body: 'event: heartbeat\ndata: {}\n\n', - }), + await page.route('**/api/v1/weekend-context', (route) => + route.fulfill({ contentType: 'application/json', body: JSON.stringify(weekendContext('complete')) }), ) + await page.route('**/api/v1/live/stream', heartbeatStream) await page.goto('/live') - await expect(page.getByTestId('live-empty')).toContainText('No live session active') + await expect(page.getByTestId('live-settling')).toBeVisible() + await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', 'settling') + await expect(page.getByText('SESSION SETTLING')).toBeVisible() await expect(page.getByText('Timing Tower')).toHaveCount(0) + // Canonical weekend-context drives the analysis target + readiness. + const analysis = page.getByTestId('live-handoff-analysis') + await expect(analysis).toHaveAttribute('href', '/race-hub?session_key=9472') + await expect(analysis).toHaveAttribute('data-ready', 'true') - await page.getByRole('button', { name: 'View Last Session' }).click() - await expect(page.getByTestId('live-archive-strip')).toContainText('Archived snapshot') + // Opening the read-only archive reveals the frozen tower, never LIVE chrome. + await page.getByTestId('live-handoff-archive').click() + await expect(page.getByTestId('live-archive-strip')).toContainText('read-only') await expect(page.getByText('Timing Tower')).toBeVisible() await expect(page.locator('.live-state')).toContainText('archive') + await expect(page.getByText('LIVE SESSION')).toHaveCount(0) + }) + + test('settling shows pending analysis until weekend-context reports complete', async ({ page }) => { + await page.route('**/api/v1/live/state', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + is_live: false, + data: null, + last_snapshot: { ...raceSnapshot.data, SessionStatus: 'Finished' }, + last_snapshot_at: '2026-07-04T14:00:00Z', + }), + }), + ) + await page.route('**/api/v1/weekend-context', (route) => + route.fulfill({ contentType: 'application/json', body: JSON.stringify(weekendContext('pending')) }), + ) + await page.route('**/api/v1/live/stream', heartbeatStream) + + await page.goto('/live') + await expect(page.getByTestId('live-settling')).toBeVisible() + const analysis = page.getByTestId('live-handoff-analysis') + await expect(analysis).toHaveAttribute('data-ready', 'false') + await expect(analysis).toContainText(/analysis will fill in as data ingests/i) + }) + + test('settling targets archive-only previous_completed_session over older default', async ({ page }) => { + await page.route('**/api/v1/live/state', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + is_live: false, + data: null, + last_snapshot: { ...raceSnapshot.data, SessionStatus: 'Finished' }, + last_snapshot_at: '2026-07-04T14:00:00Z', + }), + }), + ) + await page.route('**/api/v1/weekend-context', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify(archiveOnlySettlingContext('pending')), + }), + ) + await page.route('**/api/v1/live/stream', heartbeatStream) + + await page.goto('/live') + await expect(page.getByTestId('live-settling')).toBeVisible() + const analysis = page.getByTestId('live-handoff-analysis') + await expect(analysis).toHaveAttribute('href', '/race-hub?session_key=9472') + await expect(analysis).toHaveAttribute('data-ready', 'false') + await expect(analysis).toContainText('Open Race analysis') + await expect(analysis).not.toContainText('Practice 1') + }) + + test('retains the last live snapshot with a disconnected warning on a feed drop', async ({ page }) => { + // is_live=false but SessionStatus is still "Started": the FIA feed dropped + // mid-session. The page must warn + retain the live tower, never settle. + // Keep the browser SSE open via a long-lived stream so transport stays connected + // while phase is disconnected — health must still say Reconnecting. + await page.route('**/api/v1/live/state', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + is_live: false, + data: null, + last_snapshot: { ...raceSnapshot.data, SessionStatus: 'Started' }, + last_snapshot_at: '2026-07-04T13:30:00Z', + }), + }), + ) + await page.route('**/api/v1/weekend-context', (route) => + route.fulfill({ contentType: 'application/json', body: JSON.stringify(weekendContext('pending')) }), + ) + await page.route('**/api/v1/live/stream', async (route) => { + const body = [ + 'event: heartbeat', + 'data: {}', + '', + 'event: heartbeat', + 'data: {}', + '', + ].join('\n') + await route.fulfill({ + contentType: 'text/event-stream', + headers: { 'Cache-Control': 'no-cache' }, + body, + }) + }) + + await page.goto('/live') + await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', 'disconnected') + await expect(page.getByTestId('live-disconnected-strip')).toContainText('last live data') + await expect(page.getByText('Timing Tower')).toBeVisible() + await expect(page.getByTestId('live-settling')).toHaveCount(0) + await expect(page.getByTestId('live-archive-strip')).toHaveCount(0) + await expect(page.getByTestId('live-feed-health')).toContainText(/reconnecting/i) + await expect(page.getByTestId('live-feed-health')).not.toContainText(/feed healthy/i) }) }) diff --git a/tests/production-smoke.spec.ts b/tests/production-smoke.spec.ts index 731c2ac..bdfa9be 100644 --- a/tests/production-smoke.spec.ts +++ b/tests/production-smoke.spec.ts @@ -37,11 +37,11 @@ test.describe('Production serving (Go + built React)', () => { await expect(page.getByTestId('dl-meeting-1229')).toBeVisible() }) - test('serves live route with empty state when live is disabled', async ({ page }) => { + test('serves live route with the inactive handoff when live is disabled', async ({ page }) => { await page.goto('/live') - await expect(page.getByTestId('live-empty')).toBeVisible() - await expect(page.getByText('No live session active')).toBeVisible() + await expect(page.getByTestId('live-inactive')).toBeVisible() + await expect(page.getByText('NO LIVE SESSION')).toBeVisible() }) test('primary nav links and Admin utility work from built SPA', async ({ page }) => { diff --git a/tests/visual/__snapshots__/desktop/live-active.png b/tests/visual/__snapshots__/desktop/live-active.png new file mode 100644 index 0000000..d8cf6e3 Binary files /dev/null and b/tests/visual/__snapshots__/desktop/live-active.png differ diff --git a/tests/visual/__snapshots__/desktop/live.png b/tests/visual/__snapshots__/desktop/live.png index 50a353b..92be1d7 100644 Binary files a/tests/visual/__snapshots__/desktop/live.png and b/tests/visual/__snapshots__/desktop/live.png differ diff --git a/tests/visual/__snapshots__/mobile/live-active.png b/tests/visual/__snapshots__/mobile/live-active.png new file mode 100644 index 0000000..1bcd051 Binary files /dev/null and b/tests/visual/__snapshots__/mobile/live-active.png differ diff --git a/tests/visual/__snapshots__/mobile/live.png b/tests/visual/__snapshots__/mobile/live.png index 151b816..846a5a2 100644 Binary files a/tests/visual/__snapshots__/mobile/live.png and b/tests/visual/__snapshots__/mobile/live.png differ diff --git a/tests/visual/__snapshots__/tablet/live-active.png b/tests/visual/__snapshots__/tablet/live-active.png new file mode 100644 index 0000000..5994f7a Binary files /dev/null and b/tests/visual/__snapshots__/tablet/live-active.png differ diff --git a/tests/visual/__snapshots__/tablet/live.png b/tests/visual/__snapshots__/tablet/live.png index 8858f37..c4887dd 100644 Binary files a/tests/visual/__snapshots__/tablet/live.png and b/tests/visual/__snapshots__/tablet/live.png differ diff --git a/tests/visual/helpers.ts b/tests/visual/helpers.ts index f01d5ea..84b7bae 100644 --- a/tests/visual/helpers.ts +++ b/tests/visual/helpers.ts @@ -62,10 +62,162 @@ export async function gotoDataLibraryReady(page: Page): Promise { await waitForScreenshotReady(page) } -export async function gotoLiveEmptyReady(page: Page): Promise { +export async function gotoLiveInactiveReady(page: Page): Promise { await page.goto('/live') await expect(page.locator('.loading-state')).toHaveCount(0) - await expect(page.getByTestId('live-empty')).toBeVisible() + // With BOXBOX_DISABLE_LIVE=1 the feed is silent, so the page settles into the + // inactive weekend-context handoff sourced from /api/v1/weekend-context. + await expect(page.getByTestId('live-inactive')).toBeVisible() + // Integrated #73 shell — stale Command/Live/Race Hub baselines must not pass. + await expect(page.getByRole('navigation')).toContainText('Weekend') + await expect(page.getByRole('navigation')).not.toContainText('Command') + await waitForScreenshotReady(page) +} + +/** Deterministic active Live hierarchy (mocked snapshot + sticky SSE). */ +export async function gotoLiveActiveReady(page: Page): Promise { + await page.addInitScript(() => { + window.localStorage.clear() + class StickyEventSource { + onopen: ((ev: Event) => void) | null = null + onerror: ((ev: Event) => void) | null = null + constructor(_url: string | URL) { + queueMicrotask(() => this.onopen?.(new Event('open'))) + } + addEventListener(_type: string, _listener: EventListenerOrEventListenerObject) {} + close() {} + } + Object.defineProperty(window, 'EventSource', { + configurable: true, + writable: true, + value: StickyEventSource, + }) + }) + + const driver = ( + num: string, + pos: number, + interval: string, + gap: string, + overrides: Record = {}, + ) => ({ + RacingNumber: num, + Position: pos, + PrevPosition: pos, + GapToLeader: gap, + Interval: interval, + LastLapTime: '1:21.345', + LastLapPB: false, + LastLapOB: false, + BestLapTime: '1:20.987', + BestLapPB: true, + BestLapOB: false, + BestLapNum: 22, + InPit: false, + PitOut: false, + Retired: false, + KnockedOut: false, + Cutoff: false, + OnFlyingLap: false, + NumberOfLaps: 30, + SpeedTrap: '312', + Sectors: [], + ...overrides, + }) + const info = (num: string, tla: string, first: string, last: string, team: string, colour: string) => ({ + RacingNumber: num, + BroadcastName: `${first[0]} ${last.toUpperCase()}`, + Tla: tla, + TeamName: team, + TeamColour: colour, + FirstName: first, + LastName: last, + }) + + const liveState = { + is_live: true, + data: { + Drivers: { + '1': driver('1', 1, '', ''), + '4': driver('4', 2, '+0.523', '+0.523'), + '44': driver('44', 3, '+3.214', '+3.737'), + '63': driver('63', 4, '+12.001', '+15.738', { InPit: true }), + }, + DriverInfo: { + '1': info('1', 'VER', 'Max', 'Verstappen', 'Red Bull Racing', '3671C6'), + '4': info('4', 'NOR', 'Lando', 'Norris', 'McLaren', 'FF8000'), + '44': info('44', 'HAM', 'Lewis', 'Hamilton', 'Ferrari', 'E80020'), + '63': info('63', 'RUS', 'George', 'Russell', 'Mercedes', '27F4D2'), + }, + Tyres: { + '1': { Compound: 'HARD', New: false, Age: 12 }, + '4': { Compound: 'MEDIUM', New: false, Age: 8 }, + '44': { Compound: 'MEDIUM', New: true, Age: 3 }, + '63': { Compound: 'HARD', New: true, Age: 0 }, + }, + Stints: { + '1': [ + { Compound: 'MEDIUM', New: true, Laps: 18 }, + { Compound: 'HARD', New: false, Laps: 12 }, + ], + '4': [ + { Compound: 'SOFT', New: true, Laps: 14 }, + { Compound: 'MEDIUM', New: true, Laps: 16 }, + ], + }, + RCMessages: [ + { + Time: '2026-07-03T14:05:00Z', + Category: 'Flag', + Flag: 'YELLOW', + Message: 'YELLOW IN SECTOR 2', + Lap: 29, + }, + ], + Weather: { + AirTemp: 22.5, + TrackTemp: 41.3, + Humidity: 58, + WindSpeed: 3.4, + WindDir: 180, + Rainfall: false, + }, + Session: { + MeetingName: 'Testonia Grand Prix', + CircuitName: 'Testring', + SessionType: 'Race', + SessionName: 'Race', + Path: '', + }, + TeamRadio: [], + SessionStatus: 'Started', + TrackStatus: '2', + CurrentLap: 30, + TotalLaps: 57, + // Fixed empty clock → "--:--:--" (no live extrapolation drift). + Clock: '', + ClockRefTime: '', + ClockExtrapolating: false, + Telemetry: {}, + }, + } + + await page.route('**/api/v1/live/state', (route) => + route.fulfill({ contentType: 'application/json', body: JSON.stringify(liveState) }), + ) + await page.route('**/api/v1/live/stream', (route) => + route.fulfill({ + contentType: 'text/event-stream', + body: 'event: heartbeat\ndata: {}\n\n', + }), + ) + + await page.goto('/live') + await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', 'live') + await expect(page.getByTestId('live-session-flag')).toContainText('LIVE SESSION') + await expect(page.getByText('Timing Tower')).toBeVisible() + await expect(page.getByRole('navigation')).toContainText('Weekend') + await expect(page.getByRole('navigation')).not.toContainText('Command') await waitForScreenshotReady(page) } diff --git a/tests/visual/live-active.spec.ts b/tests/visual/live-active.spec.ts new file mode 100644 index 0000000..3ba2422 --- /dev/null +++ b/tests/visual/live-active.spec.ts @@ -0,0 +1,9 @@ +import { test } from '@playwright/test' +import { gotoLiveActiveReady, screenshotPage } from './helpers' + +test.describe('Live active visual regression', () => { + test('live-active', async ({ page }) => { + await gotoLiveActiveReady(page) + await screenshotPage(page, 'live-active') + }) +}) diff --git a/tests/visual/mvp-screens.spec.ts b/tests/visual/mvp-screens.spec.ts index e6f063a..0d30711 100644 --- a/tests/visual/mvp-screens.spec.ts +++ b/tests/visual/mvp-screens.spec.ts @@ -2,7 +2,7 @@ import { test } from '@playwright/test' import { gotoWeekendReady, gotoDataLibraryReady, - gotoLiveEmptyReady, + gotoLiveInactiveReady, gotoRaceHubFutureReady, gotoRaceHubReady, screenshotPage, @@ -32,8 +32,8 @@ test.describe('MVP visual regression', () => { await screenshotPage(page, 'data-library') }) - test('live-empty', async ({ page }) => { - await gotoLiveEmptyReady(page) + test('live-inactive', async ({ page }) => { + await gotoLiveInactiveReady(page) await screenshotPage(page, 'live') }) }) diff --git a/tests/weekend.spec.ts b/tests/weekend.spec.ts index 565d015..e00f2d0 100644 --- a/tests/weekend.spec.ts +++ b/tests/weekend.spec.ts @@ -48,7 +48,7 @@ test.describe('Weekend home (seeded canonical context)', () => { await expect(page.getByTestId('data-library')).toBeVisible() await page.goto('/live') - await expect(page.getByTestId('live-empty')).toBeVisible() + await expect(page.getByTestId('live-inactive')).toBeVisible() await page.goto('/explore') await expect(page.getByTestId('explore-page')).toBeVisible()