Compare commits

..

12 Commits

Author SHA1 Message Date
AmanTahiliani
91199ff5c0 chore: add F1 live socket probe 2026-07-04 07:22:25 -04:00
AmanTahiliani
144e93ee44 fix: hide live track map while GPS unavailable 2026-07-04 07:17:28 -04:00
Aman Tahiliani
f4f5071e26 Merge pull request #53 from AmanTahiliani/feat/issue-52-fix-stale-live-tab-sessions-and-add-last
Fix stale Live tab sessions and add last-session archive view (#52)
2026-07-04 01:44:21 -04:00
AmanTahiliani
a1d71900d1 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.
2026-07-04 01:40:58 -04:00
Aman Tahiliani
34b060238a Merge pull request #51 from AmanTahiliani/feat/issue-47-disable-or-repair-agy-headless-harness
Disable or repair agy headless harness path (#47)
2026-07-04 01:17:56 -04:00
Aman Tahiliani
a9920f0dd4 Merge pull request #49 from AmanTahiliani/feat/issue-46-handle-null-reference-laps-in-delta-time
Handle null reference laps in delta-time graph (#46)
2026-07-04 01:17:53 -04:00
Aman Tahiliani
7c8a75f7ca Merge pull request #48 from AmanTahiliani/feat/issue-45-restore-pit-stop-markers-on-strategy-st
Restore pit-stop markers on strategy stint timeline (#45)
2026-07-04 01:17:51 -04:00
Aman Tahiliani
e0174f7bf1 Merge pull request #50 from AmanTahiliani/feat/issue-44-web-track-outline-cache-warming-for-web-o
Web track-outline cache warming for web-only deployments (#44)
2026-07-04 01:17:49 -04:00
AmanTahiliani
2d6fa531f2 fix(compare): gap deltas beyond reference laps 2026-07-04 01:16:01 -04:00
AmanTahiliani
b0fd252096 feat(cli): add track outline cache warmer
Spike: existing TUI prefetch stored outlines under time.Now().Year(), so the CLI uses a new explicit year-aware prefetch path and the TUI wrapper now derives the year from meetings when available.
2026-07-04 01:06:19 -04:00
AmanTahiliani
ad379e0f07 fix(compare): gap delta graph when reference lap is null
Reference-null laps now emit null deltas for all drivers and exclude that
lap window from aligned cumulative totals so later comparisons stay sane.
Challenger-null behavior is unchanged. Compare tab meta documents the policy.

Closes #46

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 01:04:55 -04:00
AmanTahiliani
9ee14e5c89 fix(frontend): restore pit-stop markers on strategy stint timeline (#45)
Wire Race Hub pit_stops into TyreStintTimeline via optional per-row
pitStops laps; render vertical markers at stint boundaries with driver/lap
tooltips. Matches pre-extraction positioning ((lap-1)/totalLaps).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 01:04:13 -04:00
27 changed files with 1376 additions and 99 deletions

View File

@@ -27,6 +27,7 @@ func main() {
ingestMeeting := flag.Int("ingest-meeting", 0, "Ingest meeting metadata and Race Hub datasets for all sessions")
ingestSession := flag.Int("ingest-session", 0, "Ingest Race Hub datasets for a session key")
ingestNews := flag.Bool("ingest-news", false, "Refresh RSS/Atom paddock briefing feeds")
prefetchTrackOutlines := flag.Int("prefetch-track-outlines", 0, "Warm the web track-outline cache for a season year (for web-only hosts, run before --web so /api/v1/track-outline can serve live maps)")
dryRun := flag.Bool("dry-run", false, "Preview ingestion without writing domain rows")
force := flag.Bool("force", false, "Re-ingest datasets even if already tracked in the session_coverage table as completed")
coverageYear := flag.Int("coverage", 0, "Show season coverage report for the given year")
@@ -76,11 +77,21 @@ func main() {
if *ingestNews {
ingestFlags++
}
if *prefetchTrackOutlines != 0 {
ingestFlags++
}
if ingestFlags > 0 {
if ingestFlags > 1 {
fmt.Fprintln(os.Stderr, "box-box: only one of --ingest-year, --backfill-season, --ingest-meeting, --ingest-session, or --ingest-news may be set")
fmt.Fprintln(os.Stderr, "box-box: only one of --ingest-year, --backfill-season, --ingest-meeting, --ingest-session, --ingest-news, or --prefetch-track-outlines may be set")
os.Exit(1)
}
if *prefetchTrackOutlines != 0 {
if err := runTrackOutlinePrefetch(client, *prefetchTrackOutlines); err != nil {
fmt.Fprintf(os.Stderr, "box-box track outline prefetch error: %v\n", err)
os.Exit(1)
}
return
}
if *ingestNews {
if err := runNewsIngestion(*dryRun, *dbPath); err != nil {
fmt.Fprintf(os.Stderr, "box-box ingest error: %v\n", err)
@@ -174,6 +185,35 @@ func runIngestion(client *api.OpenF1Client, year, meetingKey, sessionKey int, fo
return err
}
func runTrackOutlinePrefetch(client *api.OpenF1Client, year int) error {
log.SetOutput(os.Stderr)
fmt.Fprintf(os.Stderr, "track outlines: warming HTTP cache %s for %d\n", api.DefaultCacheDBPath(), year)
meetings, err := client.GetMeetingsForYear(year)
if err != nil {
return fmt.Errorf("fetch meetings for %d: %w", year, err)
}
result := client.PrefetchTrackOutlinesForYear(year, meetings)
fmt.Printf(
"track outlines %d: cached %d/%d unique circuit(s) before, %d/%d after; %d skipped, %d fetched, %d failed\n",
result.Year,
result.CachedBefore,
result.UniqueCircuits,
result.CachedAfter,
result.UniqueCircuits,
result.Skipped,
result.Fetched,
result.Failed,
)
if result.CachedAfter == 0 {
return fmt.Errorf("cached zero track outlines for %d", year)
}
return nil
}
func runNewsIngestion(dryRun bool, dbPath string) error {
log.SetOutput(os.Stderr)

View File

@@ -229,7 +229,9 @@ export function CompareView({ sessionKey, results, drivers }: Props) {
<div>
<div className="compare-section-title">Race pace</div>
<div className="compare-section-meta">
Cumulative lap-time delta vs {referenceLabel ?? 'reference'}
Cumulative lap-time delta vs {referenceLabel ?? 'reference'}. Deltas are plotted
only where the reference lap is valid; gaps appear when the reference has no lap
time.
</div>
</div>
<SectionState

View File

@@ -12,7 +12,7 @@ interface Props {
hasStints: boolean
}
export function StrategyView({ results, stints, pit_stops: _pitStops, hasStints }: Props) {
export function StrategyView({ results, stints, pit_stops, hasStints }: Props) {
if (!hasStints) {
return (
<div>
@@ -88,6 +88,10 @@ export function StrategyView({ results, stints, pit_stops: _pitStops, hasStints
lapEnd: s.lap_end,
isNew: s.tyre_age_at_start === 0,
})),
pitStops: pit_stops
.filter((p) => p.driver_number === driver.driver_number)
.map((p) => p.lap_number)
.sort((a, b) => a - b),
}))
return (

View File

@@ -12,6 +12,8 @@ export interface StintTimelineRow {
label: string
color: string
stints: StintTimelineStint[]
/** Lap numbers where the driver pitted; optional — rows without stops render normally. */
pitStops?: number[]
}
interface TyreStintTimelineProps {
@@ -57,6 +59,14 @@ function stintBarW(stint: StintTimelineStint, totalLaps: number): number {
return Math.max(2, (stintLength(stint) / totalLaps) * BAR_W)
}
function pitMarkerX(lapNumber: number, totalLaps: number): number {
return LEFT + ((lapNumber - 1) / totalLaps) * BAR_W
}
function pitMarkerTitle(driverLabel: string, lapNumber: number): string {
return `${driverLabel} pit stop · L${lapNumber}`
}
function axisTicks(totalLaps: number): number[] {
const ticks: number[] = []
for (let lap = 0; lap <= totalLaps; lap += 10) {
@@ -130,6 +140,21 @@ export function TyreStintTimeline({ rows, totalLaps }: TyreStintTimelineProps) {
<title>{stintTitle(stint)}</title>
</rect>
))}
{(row.pitStops ?? []).map((lapNumber, pi) => (
<line
key={`pit-${pi}`}
x1={pitMarkerX(lapNumber, safeTotal)}
x2={pitMarkerX(lapNumber, safeTotal)}
y1={BAR_Y - 3}
y2={BAR_Y + BAR_H + 3}
className="stint-timeline__pit-marker"
data-testid="pit-marker"
data-lap={lapNumber}
>
<title>{pitMarkerTitle(row.label, lapNumber)}</title>
</line>
))}
</g>
)
})}

View File

@@ -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>

View File

@@ -21,15 +21,31 @@ export function formatDeltaSeconds(delta: number): string {
return `${sign}${delta.toFixed(1)}s`
}
function buildCumulative(lapTimes: ReadonlyArray<number | null>): number[] {
/**
* Cumulative lap time aligned to reference-valid laps only.
* Laps where the reference is null are skipped for every series so later deltas
* do not compare against a frozen baseline while challengers keep accumulating.
*/
function buildAlignedCumulative(
lapTimes: ReadonlyArray<number | null>,
referenceLapTimes: ReadonlyArray<number | null>,
): number[] {
const cumulative: number[] = []
let running = 0
for (const lap of lapTimes) {
if (lap !== null) {
const length = Math.max(lapTimes.length, referenceLapTimes.length)
for (let i = 0; i < length; i++) {
if (referenceLapTimes[i] == null) {
cumulative.push(running)
continue
}
const lap = lapTimes[i]
if (lap != null) {
running += lap
}
cumulative.push(running)
}
return cumulative
}
@@ -47,7 +63,8 @@ function resolveReference(
/**
* Compute per-lap cumulative time delta for each non-reference driver.
* Positive = behind reference; negative = ahead.
* Null laps carry cumulative forward but emit null in deltas (skip when plotting).
* Deltas are only emitted where the reference lap is valid; reference-null laps
* gap every series. Challenger-null laps gap only that driver's line.
*/
export function computeCumulativeDeltas(
series: ReadonlyArray<DeltaSeries>,
@@ -56,24 +73,22 @@ export function computeCumulativeDeltas(
const reference = resolveReference(series, referenceLabel)
if (!reference) return []
const refCumulative = buildCumulative(reference.lapTimes)
const refLapTimes = reference.lapTimes
const refCumulative = buildAlignedCumulative(refLapTimes, refLapTimes)
return series
.filter((s) => s.label !== reference.label)
.map((driver) => {
const driverCumulative = buildCumulative(driver.lapTimes)
const driverCumulative = buildAlignedCumulative(driver.lapTimes, refLapTimes)
const lapCount = Math.max(driver.lapTimes.length, refCumulative.length)
const deltas: (number | null)[] = []
for (let i = 0; i < lapCount; i++) {
if (driver.lapTimes[i] === null) {
if (refLapTimes[i] == null || driver.lapTimes[i] == null) {
deltas.push(null)
continue
}
const refValue = refCumulative[i] ?? refCumulative[refCumulative.length - 1] ?? 0
const driverValue =
driverCumulative[i] ?? driverCumulative[driverCumulative.length - 1] ?? 0
deltas.push(driverValue - refValue)
deltas.push(driverCumulative[i] - refCumulative[i])
}
return {

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchLiveState, fetchLiveTrackOutline } from '../api'
import { fetchLiveState } from '../api'
import type { LivePosition, LiveStreamData } from '../types'
import {
loadPinnedDrivers,
@@ -25,14 +25,17 @@ import { PinnedDrivers } from '../components/live/PinnedDrivers'
import { RaceControlFeed } from '../components/live/RaceControlFeed'
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 +46,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'],
@@ -50,24 +57,31 @@ export function LiveTimingPage() {
staleTime: 5_000,
})
const trackOutlineQuery = useQuery({
queryKey: [
'live-track-outline',
snapshot?.Session?.MeetingName ?? '',
snapshot?.Session?.CircuitName ?? '',
],
queryFn: () => fetchLiveTrackOutline(snapshot!.Session),
enabled: Boolean(snapshot?.Session?.MeetingName || snapshot?.Session?.CircuitName),
staleTime: Infinity,
retry: false,
})
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 +104,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 +203,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 +224,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,22 +250,27 @@ 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
outline={trackOutlineQuery.data}
positions={positions}
telemetry={snapshot.Telemetry}
drivers={snapshot.Drivers}
driverInfo={snapshot.DriverInfo}
loading={trackOutlineQuery.isLoading}
/>
<TyreDegPanel rows={rows} sessionType={snapshot.Session?.SessionType} pinned={pinned} />
<div className="live-columns">
<div className="live-tower-col">

View File

@@ -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,

View File

@@ -40,6 +40,13 @@
stroke-dasharray: 2 1;
}
.stint-timeline__pit-marker {
stroke: var(--text);
stroke-width: 1.5;
opacity: 0.7;
pointer-events: stroke;
}
.stint-timeline__axis-tick {
font-family: var(--f-mono);
font-size: 9px;

View File

@@ -0,0 +1,166 @@
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()
})
it('temporarily omits the track map while live GPS is unavailable', async () => {
renderPage({
is_live: true,
data: { ...archivedSnapshot, SessionStatus: 'Started' },
})
expect(await screen.findByText('Timing Tower')).toBeInTheDocument()
expect(screen.queryByText('Track Map')).not.toBeInTheDocument()
expect(mockFetchLiveTrackOutline).not.toHaveBeenCalled()
})
})

View File

@@ -106,6 +106,17 @@ describe('StrategyView — stints available', () => {
)
expect(screen.queryByText(/Stints not available/i)).not.toBeInTheDocument()
})
it('maps pit_stops into timeline pit markers for the matching driver', () => {
const { container } = render(
<StrategyView results={results} stints={stints} pit_stops={pitStops} hasStints={true} />
)
const markers = container.querySelectorAll('[data-testid="pit-marker"]')
expect(markers).toHaveLength(1)
expect(markers[0]).toHaveAttribute('data-lap', '19')
const titles = [...container.querySelectorAll('title')].map((t) => t.textContent)
expect(titles).toContain('HAM pit stop · L19')
})
})
describe('StrategyView — stints missing', () => {

View File

@@ -47,7 +47,7 @@ describe('computeCumulativeDeltas', () => {
expect(result[0].deltas[2]).toBeCloseTo(2)
})
it('emits null for missing lap times while carrying cumulative forward', () => {
it('emits null for challenger missing lap times while carrying cumulative forward', () => {
const withNull: DeltaSeries = {
label: 'NOR',
color: '#FF8000',
@@ -60,6 +60,41 @@ describe('computeCumulativeDeltas', () => {
expect(result[0].deltas[2]).toBeCloseTo(-92)
})
it('gaps all drivers when the reference lap is null and resumes without that window', () => {
const refWithNull: DeltaSeries = {
label: 'VER',
color: '#3671C6',
lapTimes: [90, null, 92],
}
const validChallenger: DeltaSeries = {
label: 'HAM',
color: '#E8002D',
lapTimes: [89, 91, 90],
}
const result = computeCumulativeDeltas([refWithNull, validChallenger])
expect(result[0].deltas[0]).toBeCloseTo(-1)
expect(result[0].deltas[1]).toBeNull()
// Lap 3 excludes the reference-null window for both: (89+90) - (90+92) = -3
expect(result[0].deltas[2]).toBeCloseTo(-3)
})
it('gaps challenger laps beyond a shorter reference series', () => {
const shortReference: DeltaSeries = {
label: 'VER',
color: '#3671C6',
lapTimes: [90, 91],
}
const longerChallenger: DeltaSeries = {
label: 'HAM',
color: '#E8002D',
lapTimes: [89, 92, 90],
}
const result = computeCumulativeDeltas([shortReference, longerChallenger])
expect(result[0].deltas[0]).toBeCloseTo(-1)
expect(result[0].deltas[1]).toBeCloseTo(0)
expect(result[0].deltas[2]).toBeNull()
})
it('returns an empty array when only one series is provided', () => {
expect(computeCumulativeDeltas([reference])).toEqual([])
})
@@ -97,6 +132,19 @@ describe('DeltaTimeGraph', () => {
expect(screen.queryByTestId('delta-line-VER')).not.toBeInTheDocument()
})
it('splits polylines at reference-null laps', () => {
const refWithNull: DeltaSeries = {
label: 'VER',
color: '#3671C6',
lapTimes: [90, null, 92],
}
const { container } = render(
<DeltaTimeGraph series={[refWithNull, challenger]} />,
)
const lines = container.querySelectorAll('.delta-graph-driver-line')
expect(lines.length).toBeGreaterThan(1)
})
it('shows a crosshair tooltip on hover', () => {
vi.spyOn(SVGSVGElement.prototype, 'getBoundingClientRect').mockReturnValue({
x: 0,
@@ -118,4 +166,31 @@ describe('DeltaTimeGraph', () => {
expect(screen.getByText(/Lap 1/)).toBeInTheDocument()
vi.restoreAllMocks()
})
it('omits tooltip rows on reference-null laps', () => {
vi.spyOn(SVGSVGElement.prototype, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
width: 640,
height: 220,
right: 640,
bottom: 220,
toJSON: () => ({}),
})
const refWithNull: DeltaSeries = {
label: 'VER',
color: '#3671C6',
lapTimes: [90, null, 92],
}
const { container } = render(
<DeltaTimeGraph series={[refWithNull, challenger]} />,
)
const hoverLayer = container.querySelector('.delta-graph-hover-layer')
fireEvent.mouseMove(hoverLayer!, { clientX: 352, clientY: 100 })
expect(screen.getByTestId('delta-crosshair')).toBeInTheDocument()
expect(screen.queryByTestId('delta-tooltip')).not.toBeInTheDocument()
vi.restoreAllMocks()
})
})

View File

@@ -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'])

View File

@@ -71,6 +71,61 @@ describe('TyreStintTimeline', () => {
expect(screen.getByTestId('stint-timeline-empty')).toBeInTheDocument()
expect(screen.getByText(/No stint data/i)).toBeInTheDocument()
})
it('renders one pit marker per stop at the correct lap position', () => {
const rowsWithPits: StintTimelineRow[] = [
{
label: 'HAM',
color: '#E8002D',
stints: [{ compound: 'SOFT', lapStart: 1, lapEnd: 18 }],
pitStops: [19],
},
{
label: 'VER',
color: '#3671C6',
stints: [
{ compound: 'MEDIUM', lapStart: 1, lapEnd: 30 },
{ compound: 'SOFT', lapStart: 31, lapEnd: 78 },
],
pitStops: [31, 52],
},
]
const { container } = render(
<TyreStintTimeline rows={rowsWithPits} totalLaps={78} />,
)
const markers = container.querySelectorAll('[data-testid="pit-marker"]')
expect(markers).toHaveLength(3)
expect(markers[0]).toHaveAttribute('data-lap', '19')
expect(markers[1]).toHaveAttribute('data-lap', '31')
expect(markers[2]).toHaveAttribute('data-lap', '52')
expect(container.querySelectorAll('.stint-timeline__bar')).toHaveLength(3)
})
it('positions pit markers using lap_number and includes driver in tooltip', () => {
const rows: StintTimelineRow[] = [
{
label: 'HAM',
color: '#E8002D',
stints: [{ compound: 'SOFT', lapStart: 1, lapEnd: 18 }],
pitStops: [19],
},
]
const { container } = render(<TyreStintTimeline rows={rows} totalLaps={78} />)
const marker = container.querySelector('[data-testid="pit-marker"]') as SVGLineElement
expect(marker).toBeTruthy()
// lap 19 → x = 48 + (18/78) * 580 ≈ 181.85
expect(Number(marker.getAttribute('x1'))).toBeCloseTo(181.85, 1)
const titles = [...container.querySelectorAll('title')].map((t) => t.textContent)
expect(titles).toContain('HAM pit stop · L19')
})
it('leaves rows without pit data unchanged', () => {
const { container } = render(
<TyreStintTimeline rows={sampleRows} totalLaps={78} />,
)
expect(container.querySelectorAll('[data-testid="pit-marker"]')).toHaveLength(0)
expect(container.querySelectorAll('.stint-timeline__bar')).toHaveLength(3)
})
})
const results: EnrichedResult[] = [

View File

@@ -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

View File

@@ -94,6 +94,12 @@ func cacheDBPath() string {
return filepath.Join(".cache", "box-box", "cache.db")
}
// DefaultCacheDBPath returns the HTTP cache database path used by the OpenF1
// client in both TUI and web modes.
func DefaultCacheDBPath() string {
return cacheDBPath()
}
// ttlForURL determines the appropriate TTL based on the URL pattern.
// Returns 0 (CacheTTLForever) for historical data that will never change.
func ttlForURL(url string) time.Duration {

View File

@@ -623,6 +623,19 @@ func (c *OpenF1Client) GetTeamRadio(sessionKey, driverNumber int) ([]models.Team
// to maximise the chance of finding data quickly.
var candidateDrivers = []int{1, 11, 44, 16, 55, 4, 14, 63, 81, 24}
// TrackOutlinePrefetchResult summarizes a season track-outline cache warming
// run. Counts are scoped to the unique non-zero circuit keys in the provided
// meeting list.
type TrackOutlinePrefetchResult struct {
Year int
UniqueCircuits int
CachedBefore int
CachedAfter int
Skipped int
Fetched int
Failed int
}
// PrefetchTrackOutlines fetches GPS location data for every circuit in the
// provided meeting list and stores it in the cache so the track map tab can
// render during live sessions when the free-tier API is locked.
@@ -632,28 +645,58 @@ var candidateDrivers = []int{1, 11, 44, 16, 55, 4, 14, 63, 81, 24}
// Errors per-circuit are silently ignored — this is a best-effort operation
// and must never block or crash the main UI.
func (c *OpenF1Client) PrefetchTrackOutlines(meetings []models.Meeting) {
year := time.Now().Year()
for _, m := range meetings {
if m.Year != 0 {
year = m.Year
break
}
}
_ = c.PrefetchTrackOutlinesForYear(year, meetings)
}
// PrefetchTrackOutlinesForYear fetches and caches track outlines for unique
// circuits in the provided meeting list, storing them under the explicit season
// year. Unlike PrefetchTrackOutlines, it returns accounting suitable for CLI
// cache-warming workflows.
func (c *OpenF1Client) PrefetchTrackOutlinesForYear(year int, meetings []models.Meeting) TrackOutlinePrefetchResult {
const maxWorkers = 3
year := time.Now().Year()
// Filter to meetings that need fetching.
var pending []models.Meeting
result := TrackOutlinePrefetchResult{Year: year}
uniqueByCircuit := make(map[int]models.Meeting)
var unique []models.Meeting
for _, m := range meetings {
if m.CircuitKey == 0 {
continue
}
if _, exists := uniqueByCircuit[m.CircuitKey]; exists {
continue
}
uniqueByCircuit[m.CircuitKey] = m
unique = append(unique, m)
}
result.UniqueCircuits = len(unique)
// Filter to meetings that need fetching.
var pending []models.Meeting
for _, m := range unique {
if _, ok := c.cache.GetTrackOutline(m.CircuitKey, year); ok {
result.CachedBefore++
result.Skipped++
continue // already cached for this season
}
pending = append(pending, m)
}
if len(pending) == 0 {
return
result.CachedAfter = result.CachedBefore
return result
}
sem := make(chan struct{}, maxWorkers)
var wg sync.WaitGroup
var mu sync.Mutex
for _, mtg := range pending {
mtg := mtg // capture
@@ -662,19 +705,34 @@ func (c *OpenF1Client) PrefetchTrackOutlines(meetings []models.Meeting) {
go func() {
defer wg.Done()
defer func() { <-sem }()
c.prefetchCircuit(mtg, year)
ok := c.prefetchCircuit(mtg, year)
mu.Lock()
if ok {
result.Fetched++
} else {
result.Failed++
}
mu.Unlock()
}()
}
wg.Wait()
for _, m := range unique {
if _, ok := c.cache.GetTrackOutline(m.CircuitKey, year); ok {
result.CachedAfter++
}
}
return result
}
// prefetchCircuit fetches the track outline for a single meeting and stores it.
// It prefers completed sessions (past date_end) so the data is full and stable.
func (c *OpenF1Client) prefetchCircuit(mtg models.Meeting, year int) {
func (c *OpenF1Client) prefetchCircuit(mtg models.Meeting, year int) bool {
sessions, err := c.GetSessionsForMeeting(int(mtg.MeetingKey))
if err != nil || len(sessions) == 0 {
return
return false
}
// Pick the best session: prefer a completed race, then any session with
@@ -696,7 +754,7 @@ func (c *OpenF1Client) prefetchCircuit(mtg models.Meeting, year int) {
}
}
if bestSession == nil {
return
return false
}
// Try candidate drivers in order until we find one with enough points.
@@ -706,7 +764,7 @@ func (c *OpenF1Client) prefetchCircuit(mtg models.Meeting, year int) {
continue
}
// Store under the circuit key for this year and stop.
_ = c.cache.SetTrackOutline(mtg.CircuitKey, year, locs)
return
return c.cache.SetTrackOutline(mtg.CircuitKey, year, locs) == nil
}
return false
}

View File

@@ -0,0 +1,109 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/AmanTahiliani/box-box/internal/models"
)
func newTrackOutlineTestClient(t *testing.T, srvURL string) *OpenF1Client {
t.Helper()
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
c := NewOpenF1Client(srvURL, 5*time.Second)
c.pacer = &requestPacer{}
t.Cleanup(func() { _ = c.Close() })
return c
}
func TestPrefetchTrackOutlinesForYearSkipsCachedAndWritesLocations(t *testing.T) {
var sessionsByMeeting = map[string][]models.Session{
"202": {
{
SessionKey: 9002,
SessionName: "Race",
MeetingKey: 202,
CircuitKey: 2,
DateEnd: "2026-01-01T12:00:00+00:00",
},
},
}
var sessionsRequested []string
var locationsRequested []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/sessions":
meetingKey := r.URL.Query().Get("meeting_key")
sessionsRequested = append(sessionsRequested, meetingKey)
_ = json.NewEncoder(w).Encode(sessionsByMeeting[meetingKey])
case "/v1/location":
sessionKey := r.URL.Query().Get("session_key")
driverNumber := r.URL.Query().Get("driver_number")
locationsRequested = append(locationsRequested, sessionKey+"/"+driverNumber)
_ = json.NewEncoder(w).Encode(testLocations(9002, 1, 51))
default:
t.Fatalf("unexpected request path %s", r.URL.Path)
}
}))
defer srv.Close()
client := newTrackOutlineTestClient(t, srv.URL)
if err := client.Cache().SetTrackOutline(1, 2026, testLocations(9001, 1, 51)); err != nil {
t.Fatalf("SetTrackOutline() error = %v", err)
}
result := client.PrefetchTrackOutlinesForYear(2026, []models.Meeting{
{MeetingKey: 101, Year: 2026, Circuit: models.Circuit{CircuitKey: 1}},
{MeetingKey: 202, Year: 2026, Circuit: models.Circuit{CircuitKey: 2}},
{MeetingKey: 303, Year: 2026, Circuit: models.Circuit{CircuitKey: 2}},
})
if result.UniqueCircuits != 2 {
t.Fatalf("UniqueCircuits = %d, want 2", result.UniqueCircuits)
}
if result.CachedBefore != 1 || result.Skipped != 1 || result.Fetched != 1 || result.Failed != 0 || result.CachedAfter != 2 {
t.Fatalf("unexpected result: %+v", result)
}
if got, want := len(sessionsRequested), 1; got != want {
t.Fatalf("sessions requested %d time(s), want %d: %v", got, want, sessionsRequested)
}
if sessionsRequested[0] != "202" {
t.Fatalf("requested meeting %s, want 202", sessionsRequested[0])
}
if got, want := len(locationsRequested), 1; got != want {
t.Fatalf("locations requested %d time(s), want %d: %v", got, want, locationsRequested)
}
if locationsRequested[0] != "9002/1" {
t.Fatalf("requested location %s, want 9002/1", locationsRequested[0])
}
locs, ok := client.Cache().GetTrackOutline(2, 2026)
if !ok {
t.Fatal("expected circuit 2 outline to be cached")
}
if len(locs) != 51 {
t.Fatalf("cached %d locations, want 51", len(locs))
}
}
func testLocations(sessionKey, driverNumber, count int) []models.Location {
locs := make([]models.Location, count)
for i := range locs {
locs[i] = models.Location{
Date: "2026-01-01T12:00:" + strconv.Itoa(i%60) + "+00:00",
DriverNumber: driverNumber,
MeetingKey: 202,
SessionKey: sessionKey,
X: float64(i),
Y: float64(i * 2),
}
}
return locs
}

View File

@@ -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(`{

View File

@@ -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

View File

@@ -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"`

View File

@@ -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)
}

View File

@@ -30,11 +30,22 @@ type SSEHub struct {
broadcast chan sseEvent
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 {
return &SSEHub{
register: make(chan *sseClient, 16),
@@ -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.

View 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")
}
}

View File

@@ -0,0 +1,264 @@
#!/usr/bin/env python3
"""Probe the official F1 live timing sockets for GPS position topics.
This intentionally does not call OpenF1 REST. It connects directly to the F1
SignalR endpoints used by the app and reports which topics arrive.
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import random
import socket
import ssl
import struct
import sys
import time
import urllib.parse
import urllib.request
RS = b"\x1e"
CORE_NEGOTIATE = "https://livetiming.formula1.com/signalrcore/negotiate?negotiateVersion=1"
CORE_WS_HOST = "livetiming.formula1.com"
LEGACY_HUB = '[{"name":"Streaming"}]'
TOPICS = [
"Heartbeat",
"TimingData",
"Position",
"Position.z",
"CarData",
"CarData.z",
"DriverList",
"LapCount",
"ExtrapolatedClock",
"TrackStatus",
"RaceControlMessages",
"WeatherData",
"SessionInfo",
"TeamRadio",
"CurrentTyres",
"TimingAppData",
"TimingStats",
"SessionStatus",
"TopThree",
]
class WebSocket:
def __init__(self, host: str, path: str, headers: dict[str, str] | None = None):
self.host = host
self.sock = ssl.create_default_context().wrap_socket(
socket.create_connection((host, 443), timeout=10),
server_hostname=host,
)
key = base64.b64encode(os.urandom(16)).decode("ascii")
request_headers = {
"Host": host,
"Upgrade": "websocket",
"Connection": "Upgrade",
"Sec-WebSocket-Key": key,
"Sec-WebSocket-Version": "13",
"Origin": "https://www.formula1.com",
"User-Agent": "Mozilla/5.0",
}
request_headers.update(headers or {})
lines = [f"GET {path} HTTP/1.1", *[f"{k}: {v}" for k, v in request_headers.items()], "", ""]
self.sock.sendall("\r\n".join(lines).encode("utf-8"))
response = self._read_http_response()
if b" 101 " not in response.split(b"\r\n", 1)[0]:
raise RuntimeError(response.decode("utf-8", "replace"))
def _read_http_response(self) -> bytes:
data = b""
while b"\r\n\r\n" not in data:
data += self.sock.recv(4096)
return data
def send_text(self, text: str) -> None:
payload = text.encode("utf-8")
header = bytearray([0x81])
if len(payload) < 126:
header.append(0x80 | len(payload))
elif len(payload) < 65536:
header.append(0x80 | 126)
header.extend(struct.pack("!H", len(payload)))
else:
header.append(0x80 | 127)
header.extend(struct.pack("!Q", len(payload)))
mask = random.randbytes(4) if hasattr(random, "randbytes") else os.urandom(4)
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
self.sock.sendall(bytes(header) + mask + masked)
def recv_text(self, timeout: float = 5.0) -> str | None:
self.sock.settimeout(timeout)
while True:
first = self.sock.recv(2)
if not first:
return None
opcode = first[0] & 0x0F
masked = bool(first[1] & 0x80)
length = first[1] & 0x7F
if length == 126:
length = struct.unpack("!H", self._read_exact(2))[0]
elif length == 127:
length = struct.unpack("!Q", self._read_exact(8))[0]
mask = self._read_exact(4) if masked else b""
payload = self._read_exact(length)
if masked:
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
if opcode == 0x8:
return None
if opcode == 0x9:
self._send_pong(payload)
continue
if opcode == 0x1:
return payload.decode("utf-8", "replace")
def _send_pong(self, payload: bytes) -> None:
self.sock.sendall(bytes([0x8A, len(payload)]) + payload)
def _read_exact(self, n: int) -> bytes:
chunks = []
remaining = n
while remaining:
chunk = self.sock.recv(remaining)
if not chunk:
raise EOFError("socket closed")
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def close(self) -> None:
self.sock.close()
def http_json(url: str, method: str = "GET", headers: dict[str, str] | None = None) -> tuple[dict, list[str]]:
req = urllib.request.Request(url, method=method, headers=headers or {})
with urllib.request.urlopen(req, timeout=15) as resp:
cookies = resp.headers.get_all("Set-Cookie") or []
return json.loads(resp.read()), cookies
def core_probe(seconds: int) -> None:
print("== SignalR Core ==")
neg, cookies = http_json(
CORE_NEGOTIATE,
method="POST",
headers={"Origin": "https://www.formula1.com", "User-Agent": "Mozilla/5.0", "Content-Length": "0"},
)
token = urllib.parse.quote(neg["connectionToken"], safe="")
ws = WebSocket(
CORE_WS_HOST,
f"/signalrcore?id={token}",
{"Cookie": "; ".join(cookies)} if cookies else None,
)
try:
ws.send_text(json.dumps({"protocol": "json", "version": 1}, separators=(",", ":")) + RS.decode())
print("handshake:", repr(ws.recv_text()))
ws.send_text(
json.dumps(
{"type": 1, "target": "subscribe", "arguments": [TOPICS], "invocationId": "1"},
separators=(",", ":"),
)
+ RS.decode()
)
collect(ws, seconds)
finally:
ws.close()
def legacy_probe(seconds: int, bearer: str | None) -> None:
print("\n== Legacy SignalR ==")
query = urllib.parse.quote(LEGACY_HUB, safe="")
headers = {"User-Agent": "BestHTTP"}
if bearer:
headers["Authorization"] = f"Bearer {bearer}"
neg_url = f"https://livetiming.formula1.com/signalr/negotiate?clientProtocol=1.5&connectionData={query}"
try:
neg, cookies = http_json(neg_url, headers=headers)
except Exception as exc:
print("negotiate failed:", exc)
return
token = urllib.parse.quote(neg["ConnectionToken"], safe="")
path = f"/signalr/connect?clientProtocol=1.5&transport=webSockets&connectionToken={token}&connectionData={query}"
ws_headers = {"User-Agent": "BestHTTP"}
if cookies:
ws_headers["Cookie"] = "; ".join(cookies)
if bearer:
ws_headers["Authorization"] = f"Bearer {bearer}"
ws = WebSocket(CORE_WS_HOST, path, ws_headers)
try:
ws.send_text(json.dumps({"H": "Streaming", "M": "Subscribe", "A": [TOPICS], "I": 1}, separators=(",", ":")))
collect(ws, seconds)
finally:
ws.close()
def collect(ws: WebSocket, seconds: int) -> None:
deadline = time.time() + seconds
seen: dict[str, int] = {}
while time.time() < deadline:
try:
message = ws.recv_text(timeout=min(5, max(1, deadline - time.time())))
except TimeoutError:
continue
if not message:
break
for frame in message.split(RS.decode()):
if not frame:
continue
try:
payload = json.loads(frame)
except json.JSONDecodeError:
continue
record_topics(payload, seen)
print("seen topics:")
for topic in sorted(seen):
print(f" {topic}: {seen[topic]}")
if not any(topic.startswith("Position") for topic in seen):
print(" (no Position / Position.z topics observed)")
def record_topics(payload: dict, seen: dict[str, int]) -> None:
result = payload.get("result") or payload.get("R")
if isinstance(result, dict):
for topic, data in result.items():
seen[topic] = seen.get(topic, 0) + 1
maybe_print_position(topic, data)
for message in payload.get("M") or []:
args = message.get("A") or []
if len(args) >= 2:
topic = args[0]
seen[topic] = seen.get(topic, 0) + 1
maybe_print_position(topic, args[1])
if payload.get("target") == "feed":
args = payload.get("arguments") or []
if len(args) >= 2:
topic = args[0]
seen[topic] = seen.get(topic, 0) + 1
maybe_print_position(topic, args[1])
def maybe_print_position(topic: str, data) -> None:
if topic in {"Position", "Position.z"}:
text = json.dumps(data)[:600] if not isinstance(data, str) else data[:600]
print(f"POSITION TOPIC {topic}: {text}")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--seconds", type=int, default=20)
parser.add_argument("--legacy", action="store_true", help="also probe legacy SignalR")
args = parser.parse_args()
core_probe(args.seconds)
if args.legacy:
legacy_probe(args.seconds, os.environ.get("BOXBOX_F1_LIVE_BEARER_TOKEN") or os.environ.get("F1_LIVE_BEARER_TOKEN"))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

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

View File

@@ -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')
})
})