From a1d71900d166533fe2833b3cf1889c964d352a8c Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Sat, 4 Jul 2026 01:40:58 -0400 Subject: [PATCH] fix: separate live archive snapshots Parse and expose SessionStatus from the official live feed, treating Started/Resumed as active and terminal or missing statuses as inactive archive candidates. Keep /api/v1/live/state and SSE snapshot data reserved for active sessions while exposing memory-only last_snapshot, last_positions, and last_snapshot_at for the explicit Live tab archive view. --- .../src/components/live/SessionBanner.tsx | 6 +- frontend/src/pages/LiveTimingPage.tsx | 90 ++++++++- frontend/src/styles/app.css | 29 +++ frontend/src/test/LiveTimingPage.test.tsx | 155 +++++++++++++++ frontend/src/test/live.test.ts | 14 ++ frontend/src/types.ts | 4 + internal/live/parser_test.go | 34 ++++ internal/live/signalr.go | 2 +- internal/live/state.go | 10 + internal/live/types.go | 25 +++ internal/web/live.go | 185 ++++++++++++++---- internal/web/live_archive_test.go | 90 +++++++++ tests/command-center.spec.ts | 35 ++++ tests/live-timing.spec.ts | 35 ++++ 14 files changed, 665 insertions(+), 49 deletions(-) create mode 100644 frontend/src/test/LiveTimingPage.test.tsx create mode 100644 internal/web/live_archive_test.go diff --git a/frontend/src/components/live/SessionBanner.tsx b/frontend/src/components/live/SessionBanner.tsx index dbb58d5..54ef76f 100644 --- a/frontend/src/components/live/SessionBanner.tsx +++ b/frontend/src/components/live/SessionBanner.tsx @@ -5,18 +5,20 @@ import { WeatherStrip } from './WeatherStrip' interface Props { isLive: boolean + isArchive?: boolean snapshot: LiveStreamData rows: LiveTimingRow[] connection: 'connected' | 'connecting' | 'disconnected' | 'error' now: number } -export function SessionBanner({ isLive, snapshot, rows, connection, now }: Props) { +export function SessionBanner({ isLive, isArchive = false, snapshot, rows, connection, now }: Props) { const session = snapshot.Session const clock = extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now) const display = liveSessionDisplay(session, rows) const atRiskLabel = display.atRiskStart && display.atRiskEnd ? `P${display.atRiskStart}-P${display.atRiskEnd} at risk` : '' + const stateLabel = isLive ? 'live' : isArchive ? 'archive' : 'stale' return (
@@ -39,7 +41,7 @@ export function SessionBanner({ isLive, snapshot, rows, connection, now }: Props L{snapshot.CurrentLap || '-'}/{snapshot.TotalLaps || '-'} - {isLive ? 'live' : 'stale'} + {stateLabel} diff --git a/frontend/src/pages/LiveTimingPage.tsx b/frontend/src/pages/LiveTimingPage.tsx index 6440a12..89a92ae 100644 --- a/frontend/src/pages/LiveTimingPage.tsx +++ b/frontend/src/pages/LiveTimingPage.tsx @@ -27,12 +27,16 @@ import { EventRail } from '../components/live/EventRail' import { TeamRadioTicker } from '../components/live/TeamRadioTicker' import { TrackMap } from '../components/live/TrackMap' import { TyreDegPanel } from '../components/live/TyreDegPanel' -import { Radio } from 'lucide-react' +import { Archive, Radio } from 'lucide-react' type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error' export function LiveTimingPage() { - const [snapshot, setSnapshot] = useState(null) + const [activeSnapshot, setActiveSnapshot] = useState(null) + const [archiveSnapshot, setArchiveSnapshot] = useState(null) + const [archivePositions, setArchivePositions] = useState>({}) + const [archiveSnapshotAt, setArchiveSnapshotAt] = useState(null) + const [archiveMode, setArchiveMode] = useState(false) const [isLive, setIsLive] = useState(false) const [streamStatus, setStreamStatus] = useState('connecting') const [now, setNow] = useState(Date.now()) @@ -43,6 +47,10 @@ export function LiveTimingPage() { const [events, setEvents] = useState([]) const prevSnapshotRef = useRef(null) const sessionSigRef = useRef('') + const isLiveRef = useRef(false) + const archiveModeRef = useRef(false) + const snapshot = isLive ? activeSnapshot : archiveMode ? archiveSnapshot : null + const hasArchive = Boolean(archiveSnapshot) const { data, isLoading, isError, error } = useQuery({ queryKey: ['live-state'], @@ -64,10 +72,29 @@ export function LiveTimingPage() { useEffect(() => { if (!data) return - setIsLive(data.is_live) - setSnapshot(data.data) + const nextLive = data.is_live && Boolean(data.data) + setIsLive(nextLive) + isLiveRef.current = nextLive + if (nextLive && data.data) { + setActiveSnapshot(data.data) + setArchiveMode(false) + return + } + + setActiveSnapshot(null) + setArchiveSnapshot(data.last_snapshot ?? null) + setArchivePositions(data.last_positions ?? {}) + setArchiveSnapshotAt(data.last_snapshot_at ?? null) + setArchiveMode((current) => current && Boolean(data.last_snapshot)) }, [data]) + useEffect(() => { + archiveModeRef.current = archiveMode + if (archiveMode) { + setPositions(archivePositions) + } + }, [archiveMode, archivePositions]) + useEffect(() => { const timer = window.setInterval(() => setNow(Date.now()), 1000) return () => window.clearInterval(timer) @@ -90,8 +117,23 @@ export function LiveTimingPage() { events.addEventListener('snapshot', (event) => { const state = parseLiveStateEvent(event.data) if (!state || cancelled) return - setIsLive(state.is_live) - setSnapshot(state.data) + const nextLive = state.is_live && Boolean(state.data) + setIsLive(nextLive) + if (nextLive && state.data) { + if (!isLiveRef.current || archiveModeRef.current) { + setPositions({}) + } + isLiveRef.current = true + setActiveSnapshot(state.data) + setArchiveMode(false) + } else { + isLiveRef.current = false + setActiveSnapshot(null) + setArchiveSnapshot(state.last_snapshot ?? null) + setArchivePositions(state.last_positions ?? {}) + setArchiveSnapshotAt(state.last_snapshot_at ?? null) + setArchiveMode((current) => current && Boolean(state.last_snapshot)) + } setStreamStatus('connected') }) @@ -174,6 +216,19 @@ export function LiveTimingPage() { setPinned((prev) => togglePin(prev, racingNumber)) } + const handleViewArchive = () => { + if (!archiveSnapshot) return + setIsLive(false) + setArchiveMode(true) + setPositions(archivePositions) + } + + const archiveTimestamp = archiveSnapshotAt ? new Date(archiveSnapshotAt) : null + const archiveLabel = + archiveTimestamp && !Number.isNaN(archiveTimestamp.getTime()) + ? `Archived snapshot from ${archiveTimestamp.toLocaleString()}` + : 'Archived live timing snapshot' + return (
{isError && ( @@ -182,12 +237,18 @@ export function LiveTimingPage() {
)} - {streamStatus === 'disconnected' && snapshot && ( + {streamStatus === 'disconnected' && snapshot && !archiveMode && (
Stream disconnected — showing last received snapshot
)} + {archiveMode && snapshot && ( +
+ {archiveLabel} — live updates are paused for this archive view +
+ )} + {isLoading && !snapshot && (
connecting to live timing…
)} @@ -202,12 +263,25 @@ export function LiveTimingPage() {

The telemetry feed is currently offline.

Check the Command Center for the weekend schedule or explore historical data in the Race Hub.

+ {hasArchive && ( + + )} )} {snapshot && ( <> - + ({ + fetchLiveState: vi.fn(), + fetchLiveTrackOutline: vi.fn(), +})) + +import { fetchLiveState, fetchLiveTrackOutline } from '../api' + +const mockFetchLiveState = vi.mocked(fetchLiveState) +const mockFetchLiveTrackOutline = vi.mocked(fetchLiveTrackOutline) + +class MockEventSource { + onopen: (() => void) | null = null + onerror: (() => void) | null = null + + constructor() { + setTimeout(() => this.onopen?.(), 0) + } + + addEventListener() {} + close() {} +} + +const archivedSnapshot: LiveStreamData = { + Drivers: { + '1': { + RacingNumber: '1', + Position: 1, + PrevPosition: 1, + GapToLeader: '', + Interval: '', + LastLapTime: '1:21.345', + LastLapPB: false, + LastLapOB: false, + BestLapTime: '1:20.987', + BestLapPB: false, + BestLapOB: false, + BestLapNum: 22, + InPit: false, + PitOut: false, + Retired: false, + KnockedOut: false, + Cutoff: false, + OnFlyingLap: false, + NumberOfLaps: 30, + SpeedTrap: '', + Sectors: [], + }, + }, + DriverInfo: { + '1': { + RacingNumber: '1', + BroadcastName: 'M VERSTAPPEN', + Tla: 'VER', + TeamName: 'Red Bull Racing', + TeamColour: '3671C6', + FirstName: 'Max', + LastName: 'Verstappen', + }, + }, + Tyres: { + '1': { Compound: 'HARD', New: false, Age: 12 }, + }, + Telemetry: {}, + RCMessages: [], + Weather: { + AirTemp: 22, + TrackTemp: 41, + Humidity: 58, + WindSpeed: 3, + WindDir: 180, + Rainfall: false, + }, + Session: { + MeetingName: 'Testonia Grand Prix', + CircuitName: 'Testring', + SessionType: 'Race', + SessionName: 'Race', + Path: '', + }, + TeamRadio: [], + SessionStatus: 'Finished', + TrackStatus: '1', + CurrentLap: 57, + TotalLaps: 57, + Clock: '', + ClockRefTime: '', + ClockExtrapolating: false, + Stints: {}, +} + +function renderPage(response: LiveStateResponse) { + 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 }, + }) + + return render( + + + , + ) +} + +describe('LiveTimingPage archive mode', () => { + beforeEach(() => { + vi.clearAllMocks() + Object.defineProperty(window, 'EventSource', { + value: MockEventSource, + writable: true, + configurable: true, + }) + }) + + 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' }, + }, + last_snapshot_at: '2026-07-04T14:00:00Z', + }) + + expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active') + expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /view last session/i })) + + await waitFor(() => { + expect(screen.getByTestId('live-archive-strip')).toHaveTextContent('Archived snapshot') + }) + expect(screen.getByText('Timing Tower')).toBeInTheDocument() + expect(screen.getAllByText('VER').length).toBeGreaterThan(0) + expect(screen.getByText('archive')).toBeInTheDocument() + }) + + it('does not show the archive action when no snapshot is retained', async () => { + renderPage({ is_live: false, data: null }) + + expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active') + expect(screen.queryByRole('button', { name: /view last session/i })).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/test/live.test.ts b/frontend/src/test/live.test.ts index 68b331b..176be30 100644 --- a/frontend/src/test/live.test.ts +++ b/frontend/src/test/live.test.ts @@ -163,6 +163,20 @@ describe('live transforms', () => { expect(parsed?.data?.Drivers['16'].RacingNumber).toBe('16') }) + it('parses archived EventSource snapshots separately from active data', () => { + const parsed = parseLiveStateEvent(JSON.stringify({ + is_live: false, + data: null, + last_snapshot: snapshot, + last_positions: { '16': { x: 1, y: 2, z: 3, status: 'OnTrack' } }, + last_snapshot_at: '2026-07-04T14:00:00Z', + })) + expect(parsed?.is_live).toBe(false) + expect(parsed?.data).toBeNull() + expect(parsed?.last_snapshot?.Drivers['16'].RacingNumber).toBe('16') + expect(parsed?.last_positions?.['16'].status).toBe('OnTrack') + }) + it('sorts timing rows by live position and includes drivers with metadata only', () => { const rows = sortLiveTimingRows(snapshot) expect(rows.map((row) => row.RacingNumber)).toEqual(['16', '1', '44']) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 0e6c326..c0b2fcf 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -206,6 +206,9 @@ export interface Weekend { export interface LiveStateResponse { is_live: boolean data: LiveStreamData | null + last_snapshot?: LiveStreamData | null + last_positions?: Record | null + last_snapshot_at?: string } export interface LivePosition { @@ -316,6 +319,7 @@ export interface LiveStreamData { Weather: LiveWeatherData Session: LiveSessionMeta TeamRadio: LiveRadioCapture[] + SessionStatus?: string TrackStatus: string CurrentLap: number TotalLaps: number diff --git a/internal/live/parser_test.go b/internal/live/parser_test.go index 4f60166..ca6850b 100644 --- a/internal/live/parser_test.go +++ b/internal/live/parser_test.go @@ -273,6 +273,40 @@ func TestProcessTopicTrackStatus(t *testing.T) { } } +func TestProcessTopicSessionStatus(t *testing.T) { + state := live.NewState() + if !state.ProcessTopic("SessionStatus", json.RawMessage(`{"Status": "Finished"}`)) { + t.Fatal("SessionStatus should update state") + } + snap := state.Snapshot() + if snap.SessionStatus != "Finished" { + t.Fatalf("session status = %q, want Finished", snap.SessionStatus) + } + if !snap.SnapshotUpdated { + t.Fatal("SessionStatus should mark snapshot updated") + } +} + +func TestSessionStatusIsActive(t *testing.T) { + tests := []struct { + status string + want bool + }{ + {"Started", true}, + {"Resumed", true}, + {"Finished", false}, + {"Finalised", false}, + {"Ends", false}, + {"Aborted", false}, + {"", false}, + } + for _, tt := range tests { + if got := live.SessionStatusIsActive(tt.status); got != tt.want { + t.Errorf("SessionStatusIsActive(%q) = %v, want %v", tt.status, got, tt.want) + } + } +} + func TestProcessTopicRaceControlMessages(t *testing.T) { state := live.NewState() data := json.RawMessage(`{ diff --git a/internal/live/signalr.go b/internal/live/signalr.go index 9265d70..31e960e 100644 --- a/internal/live/signalr.go +++ b/internal/live/signalr.go @@ -197,7 +197,7 @@ func connectToF1LegacySignalR(dataChan chan LiveStreamData) error { return err } - subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","Position.z","CarData.z","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","TeamRadio","CurrentTyres","TimingAppData","TimingStats"]],"I":1}`) + subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","Position.z","CarData.z","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","TeamRadio","CurrentTyres","TimingAppData","TimingStats","SessionStatus"]],"I":1}`) err = c.WriteMessage(websocket.TextMessage, subscribeMsg) if err != nil { return err diff --git a/internal/live/state.go b/internal/live/state.go index 08d1488..ab7ba49 100644 --- a/internal/live/state.go +++ b/internal/live/state.go @@ -26,6 +26,7 @@ type State struct { Weather LiveWeatherData Session LiveSessionMeta TeamRadio []LiveRadioCapture + SessionStatus string TrackStatus string CurrentLap int TotalLaps int @@ -93,6 +94,7 @@ func (s *State) Snapshot() LiveStreamData { Weather: s.Weather, Session: s.Session, TeamRadio: cpyRadio, + SessionStatus: s.SessionStatus, TrackStatus: s.TrackStatus, CurrentLap: s.CurrentLap, TotalLaps: s.TotalLaps, @@ -273,6 +275,14 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool { s.TrackStatus = ts.Status updated = true } + case "SessionStatus": + var ss struct { + Status string `json:"Status"` + } + if json.Unmarshal(data, &ss) == nil && ss.Status != "" { + s.SessionStatus = ss.Status + updated = true + } case "RaceControlMessages": var rcm struct { Messages json.RawMessage `json:"Messages"` diff --git a/internal/live/types.go b/internal/live/types.go index 4d62f6d..f2ded4f 100644 --- a/internal/live/types.go +++ b/internal/live/types.go @@ -162,6 +162,7 @@ type LiveStreamData struct { Weather LiveWeatherData Session LiveSessionMeta TeamRadio []LiveRadioCapture + SessionStatus string TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC CurrentLap int TotalLaps int @@ -173,3 +174,27 @@ type LiveStreamData struct { PositionUpdated bool `json:"-"` SnapshotUpdated bool `json:"-"` } + +// SessionStatusIsActive reports whether a raw F1 live timing SessionStatus +// value represents an actively running session. +func SessionStatusIsActive(status string) bool { + switch normalizeSessionStatus(status) { + case "started", "resumed": + return true + default: + return false + } +} + +func normalizeSessionStatus(status string) string { + out := make([]rune, 0, len(status)) + for _, r := range status { + switch { + case r >= 'A' && r <= 'Z': + out = append(out, r+'a'-'A') + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + out = append(out, r) + } + } + return string(out) +} diff --git a/internal/web/live.go b/internal/web/live.go index 36dae78..168ac7b 100644 --- a/internal/web/live.go +++ b/internal/web/live.go @@ -29,10 +29,21 @@ type SSEHub struct { deregister chan *sseClient broadcast chan sseEvent - mu sync.RWMutex - lastSnapshot *live.LiveStreamData - lastPositions map[string]live.LivePositionData - isLive bool + mu sync.RWMutex + activeSnapshot *live.LiveStreamData + activePositions map[string]live.LivePositionData + lastSnapshot *live.LiveStreamData + lastPositions map[string]live.LivePositionData + lastSnapshotAt time.Time + isLive bool +} + +type liveStatePayload struct { + IsLive bool `json:"is_live"` + Data *live.LiveStreamData `json:"data"` + LastSnapshot *live.LiveStreamData `json:"last_snapshot,omitempty"` + LastPositions map[string]live.LivePositionData `json:"last_positions,omitempty"` + LastSnapshotAt *time.Time `json:"last_snapshot_at,omitempty"` } func newSSEHub() *SSEHub { @@ -51,19 +62,16 @@ func (h *SSEHub) run() { case c := <-h.register: clients[c] = true // Send catch-up snapshot so new clients see current state immediately. - h.mu.RLock() - snap := h.lastSnapshot - positions := cloneLivePositions(h.lastPositions) - live := h.isLive - h.mu.RUnlock() - if snap != nil { - if data, err := json.Marshal(map[string]any{"data": snap, "is_live": live}); err == nil { + state := h.State() + if state.Data != nil || state.LastSnapshot != nil { + if data, err := json.Marshal(state); err == nil { select { case c.ch <- formatSSEFrame("snapshot", data): default: } } } + positions := h.ActivePositions() if len(positions) > 0 { if data, err := json.Marshal(positions); err == nil { select { @@ -96,11 +104,125 @@ func formatSSEFrame(event string, data []byte) []byte { return []byte(fmt.Sprintf("event: %s\ndata: %s\n\n", event, data)) } -// Snapshot returns the latest live data snapshot and whether a session is active. -func (h *SSEHub) Snapshot() (*live.LiveStreamData, bool) { +// State returns the active live snapshot and the retained in-memory archive. +func (h *SSEHub) State() liveStatePayload { h.mu.RLock() defer h.mu.RUnlock() - return h.lastSnapshot, h.isLive + payload := liveStatePayload{ + IsLive: h.isLive, + } + if h.isLive { + payload.Data = h.activeSnapshot + } else if h.lastSnapshot != nil { + payload.LastSnapshot = h.lastSnapshot + payload.LastPositions = cloneLivePositions(h.lastPositions) + if !h.lastSnapshotAt.IsZero() { + at := h.lastSnapshotAt + payload.LastSnapshotAt = &at + } + } + return payload +} + +func (h *SSEHub) ActivePositions() map[string]live.LivePositionData { + h.mu.RLock() + defer h.mu.RUnlock() + return cloneLivePositions(h.activePositions) +} + +func (h *SSEHub) applySnapshot(data live.LiveStreamData, now time.Time) liveStatePayload { + h.mu.Lock() + defer h.mu.Unlock() + + if live.SessionStatusIsActive(data.SessionStatus) { + h.isLive = true + h.activeSnapshot = &data + if data.PositionUpdated && len(data.Positions) > 0 { + h.activePositions = cloneLivePositions(data.Positions) + } + return liveStatePayload{IsLive: true, Data: h.activeSnapshot} + } + + archivePositions := cloneLivePositions(h.activePositions) + if data.PositionUpdated && len(data.Positions) > 0 { + archivePositions = cloneLivePositions(data.Positions) + } + h.isLive = false + h.activeSnapshot = nil + h.activePositions = nil + if hasLiveSnapshotData(data) { + h.lastSnapshot = &data + h.lastSnapshotAt = now + h.lastPositions = archivePositions + } + return h.stateLocked() +} + +func (h *SSEHub) applyPositions(data live.LiveStreamData) map[string]live.LivePositionData { + h.mu.Lock() + defer h.mu.Unlock() + + if len(data.Positions) == 0 { + return nil + } + positions := cloneLivePositions(data.Positions) + if h.isLive { + h.activePositions = positions + return positions + } + if h.lastSnapshot != nil { + h.lastPositions = positions + } + return nil +} + +func (h *SSEHub) deactivate(now time.Time) liveStatePayload { + h.mu.Lock() + defer h.mu.Unlock() + + if h.activeSnapshot != nil { + h.lastSnapshot = h.activeSnapshot + h.lastPositions = cloneLivePositions(h.activePositions) + h.lastSnapshotAt = now + } + h.isLive = false + h.activeSnapshot = nil + h.activePositions = nil + return h.stateLocked() +} + +func (h *SSEHub) stateLocked() liveStatePayload { + payload := liveStatePayload{IsLive: h.isLive} + if h.isLive { + payload.Data = h.activeSnapshot + return payload + } + if h.lastSnapshot != nil { + payload.LastSnapshot = h.lastSnapshot + payload.LastPositions = cloneLivePositions(h.lastPositions) + if !h.lastSnapshotAt.IsZero() { + at := h.lastSnapshotAt + payload.LastSnapshotAt = &at + } + } + return payload +} + +func hasLiveSnapshotData(data live.LiveStreamData) bool { + return len(data.Drivers) > 0 || + len(data.DriverInfo) > 0 || + len(data.Tyres) > 0 || + len(data.Telemetry) > 0 || + len(data.RCMessages) > 0 || + len(data.TeamRadio) > 0 || + len(data.Stints) > 0 || + data.Session.MeetingName != "" || + data.Session.SessionName != "" || + data.Session.SessionType != "" || + data.TrackStatus != "" || + data.CurrentLap != 0 || + data.TotalLaps != 0 || + data.Clock != "" } // runLiveFeeds launches background goroutines for the F1 SignalR feed and keepalive. @@ -129,13 +251,8 @@ func (s *Server) signalRLoop() { log.Printf("web: live feed ended: %v", err) } - s.hub.mu.Lock() - s.hub.isLive = false - s.hub.lastSnapshot = nil - s.hub.lastPositions = nil - s.hub.mu.Unlock() - - if payload, err := json.Marshal(map[string]any{"data": nil, "is_live": false}); err == nil { + state := s.hub.deactivate(time.Now()) + if payload, err := json.Marshal(state); err == nil { s.hub.broadcast <- sseEvent{name: "snapshot", data: payload} } @@ -167,24 +284,20 @@ func (s *Server) connectAndDrain() error { select { case data := <-dataChan: now := time.Now() - s.hub.mu.Lock() if data.SnapshotUpdated { - s.hub.lastSnapshot = &data - } - if data.PositionUpdated && len(data.Positions) > 0 { - s.hub.lastPositions = cloneLivePositions(data.Positions) - } - s.hub.isLive = true - s.hub.mu.Unlock() - - if data.SnapshotUpdated { - if payload, err := json.Marshal(map[string]any{"data": data, "is_live": true}); err == nil { + state := s.hub.applySnapshot(data, now) + if payload, err := json.Marshal(state); err == nil { s.hub.broadcast <- sseEvent{name: "snapshot", data: payload} } } if data.PositionUpdated && len(data.Positions) > 0 && now.Sub(lastPositionBroadcast) >= 250*time.Millisecond { - if payload, err := json.Marshal(data.Positions); err == nil { + positions := s.hub.applyPositions(data) + if len(positions) > 0 { + payload, err := json.Marshal(positions) + if err != nil { + continue + } s.hub.broadcast <- sseEvent{name: "positions", data: payload} lastPositionBroadcast = now } @@ -217,11 +330,7 @@ func cloneLivePositions(in map[string]live.LivePositionData) map[string]live.Liv // handleLiveState returns the current live data snapshot as JSON. func (s *Server) handleLiveState(w http.ResponseWriter, r *http.Request) { - snap, isLive := s.hub.Snapshot() - writeJSON(w, map[string]any{ - "is_live": isLive, - "data": snap, - }) + writeJSON(w, s.hub.State()) } // handleSSEStream is the persistent SSE endpoint for live data. diff --git a/internal/web/live_archive_test.go b/internal/web/live_archive_test.go new file mode 100644 index 0000000..6be5e4d --- /dev/null +++ b/internal/web/live_archive_test.go @@ -0,0 +1,90 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/AmanTahiliani/box-box/internal/live" +) + +func TestSSEHubArchivesTerminalSessionSnapshot(t *testing.T) { + hub := newSSEHub() + now := time.Date(2026, 7, 4, 14, 0, 0, 0, time.UTC) + active := live.LiveStreamData{ + SessionStatus: "Started", + Drivers: map[string]live.LiveDriverData{ + "1": {RacingNumber: "1", Position: 1}, + }, + Positions: map[string]live.LivePositionData{ + "1": {X: 100, Y: -50, Z: 2, Status: "OnTrack"}, + }, + PositionUpdated: true, + SnapshotUpdated: true, + } + if state := hub.applySnapshot(active, now); !state.IsLive || state.Data == nil { + t.Fatalf("active state = %+v, want live data", state) + } + + terminal := active + terminal.SessionStatus = "Finished" + terminal.Positions = nil + terminal.PositionUpdated = false + state := hub.applySnapshot(terminal, now.Add(time.Minute)) + + if state.IsLive { + t.Fatal("terminal SessionStatus should not be live") + } + if state.Data != nil { + t.Fatalf("inactive state data = %+v, want nil", state.Data) + } + if state.LastSnapshot == nil || state.LastSnapshot.SessionStatus != "Finished" { + t.Fatalf("last snapshot = %+v, want terminal snapshot", state.LastSnapshot) + } + if got := state.LastPositions["1"]; got.X != 100 || got.Status != "OnTrack" { + t.Fatalf("last positions = %+v, want carried active positions", state.LastPositions) + } + if state.LastSnapshotAt == nil || !state.LastSnapshotAt.Equal(now.Add(time.Minute)) { + t.Fatalf("last snapshot time = %v, want %v", state.LastSnapshotAt, now.Add(time.Minute)) + } +} + +func TestHandleLiveStateKeepsArchiveOutOfActiveData(t *testing.T) { + hub := newSSEHub() + now := time.Date(2026, 7, 4, 14, 0, 0, 0, time.UTC) + hub.applySnapshot(live.LiveStreamData{ + SessionStatus: "Finished", + Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", SessionName: "Race"}, + Drivers: map[string]live.LiveDriverData{ + "44": {RacingNumber: "44", Position: 1}, + }, + SnapshotUpdated: true, + }, now) + + srv := &Server{hub: hub} + req := httptest.NewRequest(http.MethodGet, "/api/v1/live/state", nil) + rec := httptest.NewRecorder() + srv.handleLiveState(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var resp liveStatePayload + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.IsLive { + t.Fatal("archived snapshot should report is_live=false") + } + if resp.Data != nil { + t.Fatalf("archived snapshot leaked into data: %+v", resp.Data) + } + if resp.LastSnapshot == nil || resp.LastSnapshot.Session.MeetingName != "British Grand Prix" { + t.Fatalf("last snapshot = %+v, want archived race", resp.LastSnapshot) + } + if resp.LastSnapshotAt == nil { + t.Fatal("last_snapshot_at should be present for archived snapshots") + } +} diff --git a/tests/command-center.spec.ts b/tests/command-center.spec.ts index a63e327..ae8e4c8 100644 --- a/tests/command-center.spec.ts +++ b/tests/command-center.spec.ts @@ -28,6 +28,41 @@ test.describe('Command Center', () => { await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible() }) + test('archived live snapshot does not mark command center live', 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: { + Drivers: { '1': { RacingNumber: '1', Position: 1 } }, + DriverInfo: { '1': { RacingNumber: '1', Tla: 'VER', TeamColour: '3671C6' } }, + Tyres: {}, + RCMessages: [], + Weather: {}, + Session: { MeetingName: 'Archived GP', SessionName: 'Race', SessionType: 'Race' }, + TeamRadio: [], + SessionStatus: 'Finished', + TrackStatus: '1', + CurrentLap: 57, + TotalLaps: 57, + Clock: '', + ClockRefTime: '', + ClockExtrapolating: false, + Stints: {}, + }, + last_snapshot_at: '2026-07-04T14:00:00Z', + }), + }), + ) + + await page.goto('/') + await expect(page.getByTestId('command-center')).toBeVisible() + await expect(page.getByTestId('cc-live-status')).toContainText('No live session') + await expect(page.getByTestId('cc-live-status')).not.toContainText('Live session active') + }) + test('existing routes continue to work', async ({ page }) => { await page.goto(`/race-hub?session_key=${FULL_SESSION}`) await expect(page.getByTestId('race-hub')).toBeVisible() diff --git a/tests/live-timing.spec.ts b/tests/live-timing.spec.ts index 05d54cc..d392372 100644 --- a/tests/live-timing.spec.ts +++ b/tests/live-timing.spec.ts @@ -239,4 +239,39 @@ test.describe('Live Timing (no session)', () => { await expect(page.getByTestId('live-empty')).toBeVisible() await expect(page.getByTestId('live-page')).toContainText('No live session active') }) + + test('renders an archived snapshot only after View Last Session', 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_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.goto('/live') + await expect(page.getByTestId('live-empty')).toContainText('No live session active') + await expect(page.getByText('Timing Tower')).toHaveCount(0) + + await page.getByRole('button', { name: 'View Last Session' }).click() + await expect(page.getByTestId('live-archive-strip')).toContainText('Archived snapshot') + await expect(page.getByText('Timing Tower')).toBeVisible() + await expect(page.locator('.live-state')).toContainText('archive') + }) })