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()