diff --git a/frontend/src/components/live/LiveHandoff.tsx b/frontend/src/components/live/LiveHandoff.tsx
index b166dce..dca9d4f 100644
--- a/frontend/src/components/live/LiveHandoff.tsx
+++ b/frontend/src/components/live/LiveHandoff.tsx
@@ -27,6 +27,11 @@ function formatCapturedAt(capturedAt: string | null | undefined): string {
return date.toLocaleString()
}
+function sessionKeyOf(session: ContextSession | undefined): number | undefined {
+ const key = session?.session.session_key
+ return key && key > 0 ? key : undefined
+}
+
/**
* Fully ingested analysis (local_analysis === complete). Stricter than
* hasLocalAnalysis, which also treats partial as link-worthy — Live polling
@@ -36,6 +41,51 @@ export function analysisIsReady(session: ContextSession | undefined): boolean {
return session?.availability?.local_analysis === 'complete'
}
+/**
+ * Analysis target for the Live handoff surface.
+ *
+ * Settling must follow the just-finished session (`previous_completed_session`).
+ * The canonical backend can mark that session archive-complete while leaving
+ * `default_analysis_session` on an older already-ingested practice/qualifying —
+ * preferring default here would link/poll/label the wrong race.
+ *
+ * Inactive keeps the shared default-first preference via analysisSessionKey.
+ */
+export function handoffAnalysisSession(
+ context: WeekendContext | undefined,
+ phase: 'settling' | 'inactive',
+): ContextSession | undefined {
+ if (!context) return undefined
+ if (phase === 'settling') {
+ if (sessionKeyOf(context.previous_completed_session)) {
+ return context.previous_completed_session
+ }
+ return sessionKeyOf(context.default_analysis_session)
+ ? context.default_analysis_session
+ : undefined
+ }
+ const key = analysisSessionKey(context)
+ if (!key) return undefined
+ if (context.default_analysis_session?.session.session_key === key) {
+ return context.default_analysis_session
+ }
+ return context.previous_completed_session
+}
+
+/**
+ * Keep polling weekend-context while the just-finished session (or, absent
+ * that, the default analysis session) is still ingesting. An older ready
+ * default must not stop the settle→ready transition.
+ */
+export function shouldPollHandoffAnalysis(context: WeekendContext | undefined): boolean {
+ if (!context) return true
+ const previous = context.previous_completed_session
+ if (sessionKeyOf(previous) && !analysisIsReady(previous)) return true
+ const fallback = context.default_analysis_session
+ if (sessionKeyOf(fallback) && !analysisIsReady(fallback)) return true
+ return !sessionKeyOf(previous) && !sessionKeyOf(fallback)
+}
+
export function LiveHandoff({
phase,
transport,
@@ -54,14 +104,8 @@ export function LiveHandoff({
const title = focusName || activeName || 'Live Timing'
const topRows = rows.slice(0, 3)
- // 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 analysis = handoffAnalysisSession(context, phase)
+ const analysisKey = sessionKeyOf(analysis)
const analysisName = analysis?.session.session_name || 'session'
const analysisReady = analysisIsReady(analysis)
diff --git a/frontend/src/components/weekend/BetweenSessionsView.tsx b/frontend/src/components/weekend/BetweenSessionsView.tsx
index d92635b..f84fb52 100644
--- a/frontend/src/components/weekend/BetweenSessionsView.tsx
+++ b/frontend/src/components/weekend/BetweenSessionsView.tsx
@@ -32,7 +32,10 @@ export function BetweenSessionsView({
const previous = context.previous_completed_session
const previousName = previous?.session.session_name ?? 'Last session'
const next = context.next_session?.session
- const analysisKey = analysisSessionKey(context)
+ // Settling recap must open the just-finished session, not an older default.
+ const previousKey = previous?.session.session_key
+ const analysisKey =
+ settling && previousKey && previousKey > 0 ? previousKey : analysisSessionKey(context)
const nodes = railNodes(previous, context.active_session, context.next_session)
return (
diff --git a/frontend/src/lib/liveState.ts b/frontend/src/lib/liveState.ts
index 4d9f7d6..54c6804 100644
--- a/frontend/src/lib/liveState.ts
+++ b/frontend/src/lib/liveState.ts
@@ -138,6 +138,21 @@ export function isReadOnlyPhase(phase: LivePhase): boolean {
return phase === 'archive'
}
+/**
+ * Fan-facing feed health. Browser SSE can stay `connected` after an upstream
+ * FIA drop leaves us in `disconnected` with a retained non-terminal snapshot —
+ * present that as reconnecting, never "Feed healthy" beside "Connection lost".
+ */
+export function effectiveFeedHealth(
+ transport: TransportHealth,
+ phase: LivePhase,
+): TransportHealth {
+ if (phase === 'disconnected' && !transportDown(transport)) {
+ return 'disconnected'
+ }
+ return transport
+}
+
/** Short, human transport-health label — always secondary to session state. */
export function feedHealthLabel(transport: TransportHealth): string {
switch (transport) {
diff --git a/frontend/src/pages/LiveTimingPage.tsx b/frontend/src/pages/LiveTimingPage.tsx
index e7f8223..0250d8c 100644
--- a/frontend/src/pages/LiveTimingPage.tsx
+++ b/frontend/src/pages/LiveTimingPage.tsx
@@ -20,11 +20,12 @@ import { appendEvents, diffSnapshots, sessionSignature } from '../lib/events'
import {
allowsLiveInterpretations,
deriveLivePhase,
+ effectiveFeedHealth,
rendersSnapshot,
terminalSessionStatus,
} from '../lib/liveState'
import type { TransportHealth } from '../lib/liveState'
-import { analysisIsReady } from '../components/live/LiveHandoff'
+import { shouldPollHandoffAnalysis } from '../components/live/LiveHandoff'
import { SessionBanner } from '../components/live/SessionBanner'
import { TrackStatusBanner } from '../components/live/TrackStatusBanner'
import { TimingTower } from '../components/live/TimingTower'
@@ -112,11 +113,9 @@ export function LiveTimingPage() {
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
+ // Poll until the just-finished previous_completed_session is ready — an
+ // older already-complete default_analysis_session must not stop us.
+ return shouldPollHandoffAnalysis(query.state.data) ? WEEKEND_CONTEXT_POLL_MS : false
},
refetchIntervalInBackground: false,
})
@@ -290,6 +289,9 @@ export function LiveTimingPage() {
: 'Archived live timing snapshot'
const showLiveInterpretations = allowsLiveInterpretations(phase)
+ // Upstream FIA loss can leave the browser SSE open; present one coherent
+ // feed-health truth rather than "Connection lost" + "Feed healthy".
+ const feedHealth = effectiveFeedHealth(streamStatus, phase)
return (
@@ -321,7 +323,7 @@ export function LiveTimingPage() {
{(phase === 'settling' || phase === 'inactive') && (
diff --git a/frontend/src/test/LiveHandoff.test.tsx b/frontend/src/test/LiveHandoff.test.tsx
index 5639161..ba019c6 100644
--- a/frontend/src/test/LiveHandoff.test.tsx
+++ b/frontend/src/test/LiveHandoff.test.tsx
@@ -1,6 +1,11 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
-import { LiveHandoff, analysisIsReady } from '../components/live/LiveHandoff'
+import {
+ LiveHandoff,
+ analysisIsReady,
+ handoffAnalysisSession,
+ shouldPollHandoffAnalysis,
+} from '../components/live/LiveHandoff'
import type { LiveTimingRow } from '../lib/live'
import type { ContextSession, WeekendContext } from '../types'
@@ -54,6 +59,18 @@ const baseContext: WeekendContext = {
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 },
@@ -68,13 +85,51 @@ describe('analysisIsReady', () => {
})
})
+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-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 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.
+ // Just-finished previous_completed_session drives the primary action target.
await waitFor(() =>
expect(screen.getByTestId('live-handoff-analysis')).toHaveAttribute(
'href',
@@ -202,6 +229,44 @@ describe('LiveTimingPage', () => {
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)
+ })
+
+ 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.
@@ -257,6 +322,11 @@ describe('LiveTimingPage', () => {
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 () => {
diff --git a/frontend/src/test/liveState.test.ts b/frontend/src/test/liveState.test.ts
index 2ae27ae..6d05edd 100644
--- a/frontend/src/test/liveState.test.ts
+++ b/frontend/src/test/liveState.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
allowsLiveInterpretations,
deriveLivePhase,
+ effectiveFeedHealth,
isReadOnlyPhase,
rendersSnapshot,
terminalSessionStatus,
@@ -167,3 +168,16 @@ describe('phase capability helpers', () => {
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 fb46235..2e586a2 100644
--- a/tests/live-timing.spec.ts
+++ b/tests/live-timing.spec.ts
@@ -256,7 +256,8 @@ const weekendContext = (localAnalysis: string) => ({
date_end: '2026-07-05T16:00:00Z',
year: 2026,
},
- default_analysis_session: {
+ // Just-finished session lives in previous_completed_session (canonical contract).
+ previous_completed_session: {
session: {
session_key: 9472,
session_name: 'Race',
@@ -280,6 +281,31 @@ const weekendContext = (localAnalysis: string) => ({
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' })
@@ -353,9 +379,40 @@ test.describe('Live Timing (no session)', () => {
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',
@@ -370,7 +427,21 @@ test.describe('Live Timing (no session)', () => {
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.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')
@@ -378,5 +449,7 @@ test.describe('Live Timing (no session)', () => {
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/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 abdc444..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 f734957..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 251f884..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 ddd0ebc..84b7bae 100644
--- a/tests/visual/helpers.ts
+++ b/tests/visual/helpers.ts
@@ -68,6 +68,156 @@ export async function gotoLiveInactiveReady(page: Page): Promise {
// 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')
+ })
+})