From 5a6323d3b4c2abd349253ad2fa6e2ec6ae53e0f8 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Fri, 17 Jul 2026 12:04:13 -0400 Subject: [PATCH] fix(live): keep red-flagged sessions active --- frontend/src/test/LiveTimingPage.test.tsx | 23 ++++++ internal/live/parser_test.go | 22 ++++++ internal/live/types.go | 13 ++++ internal/web/live.go | 36 ++++++++- internal/web/live_archive_test.go | 95 +++++++++++++++++++++++ 5 files changed, 188 insertions(+), 1 deletion(-) diff --git a/frontend/src/test/LiveTimingPage.test.tsx b/frontend/src/test/LiveTimingPage.test.tsx index cb831e6..1bf277f 100644 --- a/frontend/src/test/LiveTimingPage.test.tsx +++ b/frontend/src/test/LiveTimingPage.test.tsx @@ -153,6 +153,29 @@ describe('LiveTimingPage archive mode', () => { expect(screen.queryByRole('button', { name: /view last session/i })).not.toBeInTheDocument() }) + it('renders the live session for a red-flag paused snapshot instead of the inactive empty state', async () => { + renderPage({ + is_live: true, + data: { + ...archivedSnapshot, + SessionStatus: 'Inactive', + TrackStatus: '2', + Clock: '00:03:27', + Session: { + MeetingName: 'Belgian Grand Prix', + CircuitName: 'Spa-Francorchamps', + SessionType: 'Practice', + SessionName: 'Practice 2', + Path: '', + }, + }, + }) + + expect(await screen.findByText('Timing Tower')).toBeInTheDocument() + expect(screen.queryByTestId('live-empty')).not.toBeInTheDocument() + expect(screen.queryByTestId('live-archive-strip')).not.toBeInTheDocument() + }) + it('temporarily omits the track map while live GPS is unavailable', async () => { renderPage({ is_live: true, diff --git a/internal/live/parser_test.go b/internal/live/parser_test.go index ca6850b..e483e8a 100644 --- a/internal/live/parser_test.go +++ b/internal/live/parser_test.go @@ -307,6 +307,28 @@ func TestSessionStatusIsActive(t *testing.T) { } } +func TestSessionStatusIsTerminal(t *testing.T) { + tests := []struct { + status string + want bool + }{ + {"Finished", true}, + {"Finalised", true}, + {"Finalized", true}, + {"Ends", true}, + {"Aborted", true}, + {"Started", false}, + {"Resumed", false}, + {"Inactive", false}, + {"", false}, + } + for _, tt := range tests { + if got := live.SessionStatusIsTerminal(tt.status); got != tt.want { + t.Errorf("SessionStatusIsTerminal(%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/types.go b/internal/live/types.go index f2ded4f..0255932 100644 --- a/internal/live/types.go +++ b/internal/live/types.go @@ -186,6 +186,19 @@ func SessionStatusIsActive(status string) bool { } } +// SessionStatusIsTerminal reports whether a raw F1 live timing SessionStatus +// value represents a session that has ended and will not resume. A temporarily +// inactive session (e.g. a red-flag pause reported as "Inactive") is neither +// active nor terminal. +func SessionStatusIsTerminal(status string) bool { + switch normalizeSessionStatus(status) { + case "finished", "finalised", "finalized", "ends", "aborted": + return true + default: + return false + } +} + func normalizeSessionStatus(status string) string { out := make([]rune, 0, len(status)) for _, r := range status { diff --git a/internal/web/live.go b/internal/web/live.go index 168ac7b..a390010 100644 --- a/internal/web/live.go +++ b/internal/web/live.go @@ -134,7 +134,7 @@ func (h *SSEHub) applySnapshot(data live.LiveStreamData, now time.Time) liveStat h.mu.Lock() defer h.mu.Unlock() - if live.SessionStatusIsActive(data.SessionStatus) { + if snapshotIsLive(data) { h.isLive = true h.activeSnapshot = &data if data.PositionUpdated && len(data.Positions) > 0 { @@ -208,6 +208,40 @@ func (h *SSEHub) stateLocked() liveStatePayload { return payload } +// snapshotIsLive reports whether the current live-timing snapshot represents an +// ongoing session that should be surfaced as live. +// +// An actively running session (Started/Resumed) is always live. A non-terminal +// but temporarily inactive session — e.g. a red-flag pause where SessionStatus +// drops to "Inactive" while the session is still in progress — is also live when +// the snapshot itself carries live evidence: a session clock with time remaining +// plus session metadata. This keeps the paused track state visible instead of +// collapsing to the inactive empty state. +// +// Terminal sessions (Finished/Finalised/Ends/Aborted) are never live. A generic +// inactive/no-session stale snapshot is not live either: the predicate keys off +// the current snapshot's clock and session, so old metadata alone is not enough. +func snapshotIsLive(data live.LiveStreamData) bool { + if live.SessionStatusIsActive(data.SessionStatus) { + return true + } + if live.SessionStatusIsTerminal(data.SessionStatus) { + return false + } + return clockHasTimeRemaining(data.Clock) && data.Session.SessionName != "" +} + +// clockHasTimeRemaining reports whether an "HH:MM:SS" session clock has any time +// left. Empty or all-zero clocks (a spent or absent session) return false. +func clockHasTimeRemaining(clock string) bool { + for _, r := range clock { + if r >= '1' && r <= '9' { + return true + } + } + return false +} + func hasLiveSnapshotData(data live.LiveStreamData) bool { return len(data.Drivers) > 0 || len(data.DriverInfo) > 0 || diff --git a/internal/web/live_archive_test.go b/internal/web/live_archive_test.go index 6be5e4d..f3e4f1a 100644 --- a/internal/web/live_archive_test.go +++ b/internal/web/live_archive_test.go @@ -51,6 +51,101 @@ func TestSSEHubArchivesTerminalSessionSnapshot(t *testing.T) { } } +func TestSnapshotIsLive(t *testing.T) { + redFlag := live.LiveStreamData{ + SessionStatus: "Inactive", + TrackStatus: "2", + Clock: "00:03:27", + Session: live.LiveSessionMeta{MeetingName: "Belgian Grand Prix", SessionName: "Practice 2", SessionType: "Practice"}, + } + tests := []struct { + name string + data live.LiveStreamData + want bool + }{ + {"started", live.LiveStreamData{SessionStatus: "Started"}, true}, + {"resumed", live.LiveStreamData{SessionStatus: "Resumed"}, true}, + {"red flag inactive with clock and session", redFlag, true}, + {"terminal finished with clock", func() live.LiveStreamData { + d := redFlag + d.SessionStatus = "Finished" + return d + }(), false}, + {"inactive spent clock", func() live.LiveStreamData { + d := redFlag + d.Clock = "00:00:00" + return d + }(), false}, + {"inactive no clock", func() live.LiveStreamData { + d := redFlag + d.Clock = "" + return d + }(), false}, + {"inactive no session metadata", func() live.LiveStreamData { + d := redFlag + d.Session = live.LiveSessionMeta{} + return d + }(), false}, + } + for _, tt := range tests { + if got := snapshotIsLive(tt.data); got != tt.want { + t.Errorf("%s: snapshotIsLive = %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestSSEHubKeepsRedFlagSessionLive(t *testing.T) { + hub := newSSEHub() + now := time.Date(2026, 7, 26, 14, 0, 0, 0, time.UTC) + + redFlag := live.LiveStreamData{ + SessionStatus: "Inactive", + TrackStatus: "2", + Clock: "00:03:27", + Session: live.LiveSessionMeta{MeetingName: "Belgian Grand Prix", SessionName: "Practice 2", SessionType: "Practice"}, + SnapshotUpdated: true, + } + state := hub.applySnapshot(redFlag, now) + + if !state.IsLive { + t.Fatal("red-flag paused session should report is_live=true") + } + if state.Data == nil { + t.Fatal("red-flag paused session must still send the active snapshot") + } + if state.Data.TrackStatus != "2" { + t.Fatalf("track status = %q, want the paused state \"2\"", state.Data.TrackStatus) + } + if state.LastSnapshot != nil { + t.Fatalf("red-flag session should not be archived: %+v", state.LastSnapshot) + } +} + +func TestSSEHubStaleInactiveSnapshotNotLive(t *testing.T) { + hub := newSSEHub() + now := time.Date(2026, 7, 26, 14, 0, 0, 0, time.UTC) + + // Inactive snapshot with session metadata but no remaining clock: this is a + // stale/no-live-evidence snapshot and must not be surfaced as live. + stale := live.LiveStreamData{ + SessionStatus: "Inactive", + Clock: "00:00:00", + Session: live.LiveSessionMeta{MeetingName: "Belgian Grand Prix", SessionName: "Practice 2"}, + SnapshotUpdated: true, + } + state := hub.applySnapshot(stale, now) + + if state.IsLive { + t.Fatal("stale inactive snapshot should report is_live=false") + } + if state.Data != nil { + t.Fatalf("stale inactive snapshot leaked into active data: %+v", state.Data) + } + if state.LastSnapshot == nil { + t.Fatal("stale inactive snapshot with metadata should be archived") + } +} + func TestHandleLiveStateKeepsArchiveOutOfActiveData(t *testing.T) { hub := newSSEHub() now := time.Date(2026, 7, 4, 14, 0, 0, 0, time.UTC)