mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
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.
This commit is contained in:
@@ -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 (
|
||||
<section className="live-banner">
|
||||
@@ -39,7 +41,7 @@ export function SessionBanner({ isLive, snapshot, rows, connection, now }: Props
|
||||
<span>
|
||||
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
|
||||
</span>
|
||||
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{isLive ? 'live' : 'stale'}</span>
|
||||
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{stateLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<LiveStreamData | null>(null)
|
||||
const [activeSnapshot, setActiveSnapshot] = useState<LiveStreamData | null>(null)
|
||||
const [archiveSnapshot, setArchiveSnapshot] = useState<LiveStreamData | null>(null)
|
||||
const [archivePositions, setArchivePositions] = useState<Record<string, LivePosition>>({})
|
||||
const [archiveSnapshotAt, setArchiveSnapshotAt] = useState<string | null>(null)
|
||||
const [archiveMode, setArchiveMode] = useState(false)
|
||||
const [isLive, setIsLive] = useState(false)
|
||||
const [streamStatus, setStreamStatus] = useState<StreamStatus>('connecting')
|
||||
const [now, setNow] = useState(Date.now())
|
||||
@@ -43,6 +47,10 @@ export function LiveTimingPage() {
|
||||
const [events, setEvents] = useState<LiveEvent[]>([])
|
||||
const prevSnapshotRef = useRef<LiveStreamData | null>(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 (
|
||||
<div className="page live-page" data-testid="live-page">
|
||||
{isError && (
|
||||
@@ -182,12 +237,18 @@ export function LiveTimingPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{streamStatus === 'disconnected' && snapshot && (
|
||||
{streamStatus === 'disconnected' && snapshot && !archiveMode && (
|
||||
<div className="live-status-strip live-status-warn">
|
||||
Stream disconnected — showing last received snapshot
|
||||
</div>
|
||||
)}
|
||||
|
||||
{archiveMode && snapshot && (
|
||||
<div className="live-status-strip live-status-archive" data-testid="live-archive-strip">
|
||||
{archiveLabel} — live updates are paused for this archive view
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && !snapshot && (
|
||||
<div className="loading-state">connecting to live timing…</div>
|
||||
)}
|
||||
@@ -202,12 +263,25 @@ export function LiveTimingPage() {
|
||||
<p className="empty-state-desc" style={{ color: 'var(--text-2)' }}>
|
||||
The telemetry feed is currently offline. <br /><br /> Check the <a href="/" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Command Center</a> for the weekend schedule or explore historical data in the <a href="/race-hub" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Race Hub</a>.
|
||||
</p>
|
||||
{hasArchive && (
|
||||
<button type="button" className="live-archive-btn" onClick={handleViewArchive}>
|
||||
<Archive size={15} />
|
||||
View Last Session
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{snapshot && (
|
||||
<>
|
||||
<SessionBanner isLive={isLive} snapshot={snapshot} rows={rows} connection={streamStatus} now={now} />
|
||||
<SessionBanner
|
||||
isLive={isLive}
|
||||
isArchive={archiveMode}
|
||||
snapshot={snapshot}
|
||||
rows={rows}
|
||||
connection={streamStatus}
|
||||
now={now}
|
||||
/>
|
||||
<TrackStatusBanner status={snapshot.TrackStatus} />
|
||||
<PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} />
|
||||
<TrackMap
|
||||
|
||||
@@ -808,6 +808,12 @@ a { color: inherit; text-decoration: none; }
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.live-status-archive {
|
||||
background: rgba(70, 140, 255, 0.08);
|
||||
border: 1px solid rgba(70, 140, 255, 0.24);
|
||||
color: #8bb7ff;
|
||||
}
|
||||
|
||||
/* ── Live empty state ── */
|
||||
.live-empty-status {
|
||||
display: flex;
|
||||
@@ -815,6 +821,29 @@ a { color: inherit; text-decoration: none; }
|
||||
margin-bottom: var(--s4);
|
||||
}
|
||||
|
||||
.live-archive-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s2);
|
||||
margin-top: var(--s5);
|
||||
min-height: 36px;
|
||||
padding: 0 var(--s4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text);
|
||||
font-family: var(--f-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.live-archive-btn:hover {
|
||||
border-color: rgba(255, 255, 255, 0.28);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.mono { font-family: var(--f-mono); }
|
||||
|
||||
.live-conn,
|
||||
|
||||
155
frontend/src/test/LiveTimingPage.test.tsx
Normal file
155
frontend/src/test/LiveTimingPage.test.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
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'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LiveTimingPage />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
|
||||
@@ -206,6 +206,9 @@ export interface Weekend {
|
||||
export interface LiveStateResponse {
|
||||
is_live: boolean
|
||||
data: LiveStreamData | null
|
||||
last_snapshot?: LiveStreamData | null
|
||||
last_positions?: Record<string, LivePosition> | 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
|
||||
|
||||
@@ -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(`{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
90
internal/web/live_archive_test.go
Normal file
90
internal/web/live_archive_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user