diff --git a/frontend/src/components/live/LiveHandoff.tsx b/frontend/src/components/live/LiveHandoff.tsx index 1540948..b166dce 100644 --- a/frontend/src/components/live/LiveHandoff.tsx +++ b/frontend/src/components/live/LiveHandoff.tsx @@ -1,16 +1,18 @@ 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 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 retained. */ + /** 'settling' immediately after a session; 'inactive' when nothing is live. */ phase: 'settling' | 'inactive' transport: TransportHealth - context: LiveWeekendContext + /** 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 @@ -25,6 +27,15 @@ function formatCapturedAt(capturedAt: string | null | undefined): string { return date.toLocaleString() } +/** + * 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' +} + export function LiveHandoff({ phase, transport, @@ -37,12 +48,28 @@ export function LiveHandoff({ const isSettling = phase === 'settling' const testid = isSettling ? 'live-settling' : 'live-inactive' const capturedLabel = formatCapturedAt(capturedAt) - const title = context.meetingName || 'Live Timing' + + 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 analysisKey = context.analysisSessionKey - const analysisName = context.analysisSessionName || 'session' - const analysisReady = context.analysisReady + // Shared analysisSessionKey prefers default_analysis_session, then previous. + const analysisKey = context ? analysisSessionKey(context) : undefined + const analysis = + !context || !analysisKey + ? undefined + : context.default_analysis_session?.session.session_key === analysisKey + ? context.default_analysis_session + : context.previous_completed_session + 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 (
@@ -95,6 +122,7 @@ export function LiveHandoff({ className="live-handoff-action live-handoff-primary" href={`/race-hub?session_key=${analysisKey}`} data-testid="live-handoff-analysis" + data-ready={analysisReady ? 'true' : 'false'} > @@ -103,13 +131,18 @@ export function LiveHandoff({ {analysisReady ? 'Full timing, strategy & story ready' - : 'Settling — analysis will fill in as data ingests'} + : isSettling + ? 'Settling — analysis will fill in as data ingests' + : 'Analysis will fill in as data ingests'} ) : ( -
+
Analysis not ready yet @@ -119,26 +152,32 @@ export function LiveHandoff({
)} - {context.nextSession && ( + {next && (
- Up next · {context.nextSession.name} + + Up next · {next.session.session_name} + - {formatSessionScheduleTime(context.nextSession.startsAt)} + {formatSessionScheduleTime(next.session.date_start)}
)} - {context.previousSession && context.previousSession.sessionKey !== analysisKey && ( + {showRecap && ( - Recap · {context.previousSession.name} - Review the last completed session + + Recap · {previous!.session.session_name} + + + Review the last completed session + @@ -155,17 +194,19 @@ export function LiveHandoff({ View full timing (read-only) - Frozen final snapshot — no live updates + + Frozen final snapshot — no live updates + )} - {!analysisKey && !context.nextSession && !context.previousSession && !hasArchive && ( + {!analysisKey && !next && !showRecap && !hasArchive && (
- Command Center + Weekend Weekend schedule & standings diff --git a/frontend/src/lib/liveState.ts b/frontend/src/lib/liveState.ts index 36ddf5b..4d9f7d6 100644 --- a/frontend/src/lib/liveState.ts +++ b/frontend/src/lib/liveState.ts @@ -11,14 +11,23 @@ // `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 dropped — keep the last snapshot, warn - | 'settling' // session ended; a final snapshot is retained, analysis pending + | '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 @@ -27,10 +36,39 @@ export interface LiveStateInputs { /** 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. */ + /** 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 { @@ -40,10 +78,19 @@ export function transportDown(transport: TransportHealth): boolean { /** * 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`. + * dropped socket is `disconnected` (snapshot retained), never `archive` or + * `settling`. */ export function deriveLivePhase(input: LiveStateInputs): LivePhase { - const { transport, isLive, hasActiveSnapshot, hasArchive, archiveMode } = input + 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' @@ -54,11 +101,20 @@ export function deriveLivePhase(input: LiveStateInputs): LivePhase { return transportDown(transport) ? 'disconnected' : 'live' } - // No active session but we still hold the final snapshot -> settling handoff. - if (hasArchive) return 'settling' + // 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' + } - // Nothing yet and the feed is still opening. - if (transport === 'connecting') return 'connecting' + // 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' } diff --git a/frontend/src/pages/LiveTimingPage.tsx b/frontend/src/pages/LiveTimingPage.tsx index 59fcd00..e7f8223 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, fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api' +import { fetchLiveState, fetchWeekendContext } from '../api' import type { LivePosition, LiveStreamData } from '../types' import { loadPinnedDrivers, @@ -21,10 +21,10 @@ import { allowsLiveInterpretations, deriveLivePhase, rendersSnapshot, + terminalSessionStatus, } from '../lib/liveState' import type { TransportHealth } from '../lib/liveState' -import { deriveWeekendContext } from '../lib/weekendContext' -import { pickFocusMeeting } from '../lib/schedule' +import { analysisIsReady } from '../components/live/LiveHandoff' import { SessionBanner } from '../components/live/SessionBanner' import { TrackStatusBanner } from '../components/live/TrackStatusBanner' import { TimingTower } from '../components/live/TimingTower' @@ -37,6 +37,10 @@ import { TyreDegPanel } from '../components/live/TyreDegPanel' import { LiveHandoff } from '../components/live/LiveHandoff' import '../styles/live-state.css' +/** 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) const [archiveSnapshot, setArchiveSnapshot] = useState(null) @@ -55,61 +59,75 @@ export function LiveTimingPage() { const sessionSigRef = useRef('') const isLiveRef = useRef(false) const archiveModeRef = useRef(false) + // 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) - // Transport health, active-session state, and archive mode are three - // independent inputs; deriveLivePhase collapses them into one UI phase. + 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 : null + const snapshot = + phase === 'archive' + ? archiveSnapshot + : isLive + ? activeSnapshot + : phase === 'disconnected' + ? archiveSnapshot + : null - const { data, isError, error } = useQuery({ - queryKey: ['live-state'], - queryFn: fetchLiveState, - staleTime: 5_000, + // 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) => { + const ctx = query.state.data + if (!ctx) return WEEKEND_CONTEXT_POLL_MS + // Once the default-analysis session is fully ingested there is nothing + // left to wait for; stop polling. + return analysisIsReady(ctx.default_analysis_session) ? false : WEEKEND_CONTEXT_POLL_MS + }, + refetchIntervalInBackground: false, }) - - // 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], - ) + 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) @@ -159,6 +177,7 @@ export function LiveTimingPage() { setPositions({}) } isLiveRef.current = true + wasLiveRef.current = true setActiveSnapshot(state.data) setArchiveMode(false) } else { 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..5639161 --- /dev/null +++ b/frontend/src/test/LiveHandoff.test.tsx @@ -0,0 +1,206 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { LiveHandoff, analysisIsReady } 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, +} + +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('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('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..925b6eb 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,66 @@ 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, + }, + default_analysis_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, + } +} + +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 +154,7 @@ function renderPage(response: LiveStateResponse) { ) } -describe('LiveTimingPage archive mode', () => { +describe('LiveTimingPage', () => { beforeEach(() => { vi.clearAllMocks() Object.defineProperty(window, 'EventSource', { @@ -122,45 +164,162 @@ 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') + // Canonical default-analysis 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('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) }) - 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')).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() + }) + + 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..2ae27ae --- /dev/null +++ b/frontend/src/test/liveState.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest' +import { + allowsLiveInterpretations, + deriveLivePhase, + 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) + }) +}) diff --git a/tests/live-timing.spec.ts b/tests/live-timing.spec.ts index d392372..fb46235 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,142 @@ 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, + }, + default_analysis_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, +}) + +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('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. + 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', heartbeatStream) + + 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) }) }) 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.png b/tests/visual/__snapshots__/desktop/live.png index 50a353b..abdc444 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.png b/tests/visual/__snapshots__/mobile/live.png index 151b816..f734957 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.png b/tests/visual/__snapshots__/tablet/live.png index 8858f37..251f884 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..ddd0ebc 100644 --- a/tests/visual/helpers.ts +++ b/tests/visual/helpers.ts @@ -62,10 +62,12 @@ 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() await waitForScreenshotReady(page) } 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') }) })