+ )}
+
+ {/* Embedded Weekend already shows the canonical countdown header — skip the duplicate. */}
+ {!embedded && (
+
+ )}
+
+ {/* Embedded: still surface recovered schedule once sessions load. */}
+ {embedded && sessions.length > 0 && (
+
@@ -419,9 +539,9 @@ export function RacePreviewPage() {
loading={priorMeetingsQuery.isLoading || priorSessionsQuery.isLoading || priorResultsQuery.isLoading}
error={
priorMeetingsQuery.isError
- ? (priorMeetingsQuery.error as Error)
+ ? priorMeetingsQuery.error
: priorResultsQuery.isError
- ? (priorResultsQuery.error as Error)
+ ? priorResultsQuery.error
: null
}
podium={podium}
@@ -432,7 +552,7 @@ export function RacePreviewPage() {
+ return
}
switch (view) {
@@ -54,12 +58,50 @@ function renderState(view: WeekendViewState, args: RenderArgs) {
case 'session_live':
return
case 'pre_session':
- return
+ return
default:
return
}
}
+function WeekendAvailabilityNotices({
+ availabilityNotice,
+ supplementsLimited,
+ onRetry,
+ retrying,
+}: {
+ availabilityNotice: DataAvailability | null
+ supplementsLimited: boolean
+ onRetry: () => void
+ retrying: boolean
+}) {
+ // Prefer a single reported freshness notice; Limited for failed supplements
+ // only when freshness itself is not already disclosing a stronger state.
+ if (availabilityNotice) {
+ return (
+
+ )
+ }
+ if (supplementsLimited) {
+ return (
+
+ )
+ }
+ return null
+}
+
export function WeekendPage({
preview = false,
focusMeetingKey,
@@ -71,8 +113,18 @@ export function WeekendPage({
/** Restored from `/?session_key=` when returning from Race Hub analysis. */
focusSessionKey?: number
}) {
- const { context, loadState, error, championship, briefing, now, refetch, isFetching } =
- useWeekendContext()
+ const {
+ context,
+ loadState,
+ error,
+ championship,
+ briefing,
+ availabilityNotice,
+ supplementsLimited,
+ now,
+ refetch,
+ isFetching,
+ } = useWeekendContext()
const hasFocus =
(focusMeetingKey != null && focusMeetingKey > 0) ||
(focusSessionKey != null && focusSessionKey > 0)
@@ -94,6 +146,7 @@ export function WeekendPage({
}
const view = resolveViewState(context)
+ const shellAvailability = availabilityNotice ?? (supplementsLimited ? 'limited' : null)
return (
)}
- {renderState(view, { context, now, championship, briefing, preview })}
+
+ {renderState(view, {
+ context,
+ now,
+ championship,
+ briefing,
+ preview,
+ shellAvailability,
+ })}
)
}
diff --git a/frontend/src/test/BriefingPage.test.tsx b/frontend/src/test/BriefingPage.test.tsx
index dac7e94..89291a8 100644
--- a/frontend/src/test/BriefingPage.test.tsx
+++ b/frontend/src/test/BriefingPage.test.tsx
@@ -1,4 +1,4 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BriefingPage } from '../pages/BriefingPage'
@@ -21,6 +21,7 @@ import {
fetchChampionshipHub,
markNewsRead,
} from '../api'
+import { rememberResponseAvailability } from '../lib/fetch'
const mockFetchNews = vi.mocked(fetchNews)
const mockFetchNewsArticle = vi.mocked(fetchNewsArticle)
@@ -208,4 +209,62 @@ describe('BriefingPage digest layout', () => {
})
expect(screen.getByRole('tab', { name: /News/i })).toBeInTheDocument()
})
+
+ it('keeps articles usable and shows Limited when grouping supplements fail', async () => {
+ mockFetchSeasonMeetings.mockRejectedValue(new Error('API 503: meetings failed'))
+ mockFetchHub.mockRejectedValue(new Error('API 503: hub failed'))
+ renderPage()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('briefing-data-notice')).toHaveTextContent(/Limited/i)
+ })
+ expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
+ expect(screen.queryByTestId('briefing-error')).not.toBeInTheDocument()
+
+ let meetingsCalls = 1
+ let hubCalls = 1
+ let resolveMeetings!: (value: Meeting[]) => void
+ let resolveHub!: (value: ChampionshipHub) => void
+ mockFetchSeasonMeetings.mockImplementation(
+ () =>
+ new Promise
((resolve) => {
+ meetingsCalls += 1
+ resolveMeetings = resolve
+ }),
+ )
+ mockFetchHub.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ hubCalls += 1
+ resolveHub = resolve
+ }),
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Retrying…' })).toBeDisabled())
+ expect(screen.getByRole('button', { name: 'Retrying…' })).toHaveAttribute('aria-busy', 'true')
+ fireEvent.click(screen.getByRole('button', { name: 'Retrying…' }))
+ expect(meetingsCalls).toBe(2)
+ expect(hubCalls).toBe(2)
+
+ resolveMeetings([bahrain, monaco])
+ resolveHub(hub)
+ await waitFor(() => expect(screen.queryByTestId('briefing-data-notice')).not.toBeInTheDocument())
+ expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
+ })
+
+ it('preserves Stale hub over routine local News when sources conflict', async () => {
+ const localNews = [...newsItems]
+ rememberResponseAvailability(localNews, { source: 'local', freshness: 'local' })
+ const staleHub = { ...hub }
+ rememberResponseAvailability(staleHub, { source: 'openf1', freshness: 'stale' })
+ mockFetchNews.mockResolvedValue(localNews)
+ mockFetchHub.mockResolvedValue(staleHub)
+
+ renderPage()
+
+ await waitFor(() => expect(screen.getByTestId('briefing-data-notice')).toHaveTextContent(/Stale/i))
+ expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
+ expect(screen.queryByTestId('briefing-error')).not.toBeInTheDocument()
+ })
})
diff --git a/frontend/src/test/ChampionshipPage.test.tsx b/frontend/src/test/ChampionshipPage.test.tsx
index 199e26a..bb29f06 100644
--- a/frontend/src/test/ChampionshipPage.test.tsx
+++ b/frontend/src/test/ChampionshipPage.test.tsx
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
import { ChampionshipPage } from '../pages/ChampionshipPage'
import type { ChampHubDriver, ChampHubTeam, ChampionshipHub } from '../types'
+import { rememberResponseAvailability } from '../lib/fetch'
vi.mock('../api', () => ({
fetchSeasons: vi.fn(),
@@ -226,4 +227,36 @@ describe('ChampionshipPage', () => {
expect(rows[1]).toHaveTextContent('Red Bull')
expect(rows[1]).toHaveTextContent('9–1')
})
+
+ it('shows a non-blocking stale notice above usable standings', async () => {
+ const staleHub = { ...hub }
+ rememberResponseAvailability(staleHub, { source: 'openf1', freshness: 'stale' })
+ mockFetchHub.mockResolvedValue(staleHub)
+ renderPage()
+
+ await waitFor(() => expect(screen.getByTestId('championship')).toBeInTheDocument())
+ expect(screen.getByTestId('championship-data-notice')).toHaveTextContent(/Stale/i)
+ expect(screen.getByTestId('champ-view-drivers')).toBeInTheDocument()
+ expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
+ })
+
+ it('shows a non-blocking partial notice when reported', async () => {
+ const partialHub = { ...hub }
+ rememberResponseAvailability(partialHub, { source: 'openf1', freshness: 'partial' })
+ mockFetchHub.mockResolvedValue(partialHub)
+ renderPage()
+
+ await waitFor(() => expect(screen.getByTestId('championship-data-notice')).toBeInTheDocument())
+ expect(screen.getByTestId('championship-data-notice')).toHaveTextContent(/Partial/i)
+ })
+
+ it('shows a non-blocking local notice when reported', async () => {
+ const localHub = { ...hub }
+ rememberResponseAvailability(localHub, { source: 'local', freshness: 'local' })
+ mockFetchHub.mockResolvedValue(localHub)
+ renderPage()
+
+ await waitFor(() => expect(screen.getByTestId('championship-data-notice')).toBeInTheDocument())
+ expect(screen.getByTestId('championship-data-notice')).toHaveTextContent(/Local/i)
+ })
})
diff --git a/frontend/src/test/LiveTimingPage.test.tsx b/frontend/src/test/LiveTimingPage.test.tsx
index be0a8ac..88883eb 100644
--- a/frontend/src/test/LiveTimingPage.test.tsx
+++ b/frontend/src/test/LiveTimingPage.test.tsx
@@ -356,7 +356,7 @@ describe('LiveTimingPage', () => {
expect(screen.queryByText('LIVE SESSION')).not.toBeInTheDocument()
})
- it('shows the inactive weekend context when nothing is live or retained', async () => {
+ it('shows inactive handoff when nothing is live or retained', async () => {
renderPage(
{ is_live: false, data: null },
{
@@ -392,4 +392,164 @@ describe('LiveTimingPage', () => {
expect(screen.getByTestId('live-handoff-next')).toHaveTextContent('Practice 1'),
)
})
+
+ it('clears fatal initial error once SSE supplies a live snapshot', async () => {
+ type Listener = (event: { data: string }) => void
+ class RecoveringEventSource {
+ onopen: (() => void) | null = null
+ onerror: (() => void) | null = null
+ private listeners = new Map()
+ static latest: RecoveringEventSource | null = null
+ constructor() {
+ RecoveringEventSource.latest = this
+ setTimeout(() => this.onopen?.(), 0)
+ }
+ addEventListener(type: string, listener: Listener) {
+ const list = this.listeners.get(type) ?? []
+ list.push(listener)
+ this.listeners.set(type, list)
+ }
+ emit(type: string, data: unknown) {
+ for (const listener of this.listeners.get(type) ?? []) {
+ listener({ data: JSON.stringify(data) })
+ }
+ }
+ close() {}
+ }
+
+ Object.defineProperty(window, 'EventSource', {
+ value: RecoveringEventSource,
+ writable: true,
+ configurable: true,
+ })
+
+ mockFetchLiveState.mockRejectedValue(new Error('API 503: live state unavailable'))
+ mockFetchLiveTrackOutline.mockResolvedValue({
+ circuit_key: 1,
+ points: [],
+ bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
+ })
+ mockFetchWeekendContext.mockResolvedValue({
+ temporal_state: 'no_season',
+ championship_round: 0,
+ total_championship_rounds: 0,
+ })
+
+ render(
+
+
+ ,
+ )
+
+ expect(await screen.findByTestId('live-initial-error')).toBeInTheDocument()
+ expect(screen.getByText(/Live timing unavailable/i)).toBeInTheDocument()
+
+ await act(async () => {
+ RecoveringEventSource.latest?.emit('snapshot', {
+ is_live: true,
+ data: raceSnapshot,
+ })
+ })
+
+ await waitFor(() => expect(screen.getByText('Timing Tower')).toBeInTheDocument())
+ expect(screen.queryByTestId('live-initial-error')).not.toBeInTheDocument()
+ expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'live')
+ expect(screen.queryByText(/Live timing unavailable/i)).not.toBeInTheDocument()
+ })
+
+ it('clears fatal initial error when SSE reports authoritative inactive null state', async () => {
+ type Listener = (event: { data: string }) => void
+ class InactiveEventSource {
+ onopen: (() => void) | null = null
+ onerror: (() => void) | null = null
+ private listeners = new Map()
+ static latest: InactiveEventSource | null = null
+ constructor() {
+ InactiveEventSource.latest = this
+ setTimeout(() => this.onopen?.(), 0)
+ }
+ addEventListener(type: string, listener: Listener) {
+ const list = this.listeners.get(type) ?? []
+ list.push(listener)
+ this.listeners.set(type, list)
+ }
+ emit(type: string, data: unknown) {
+ for (const listener of this.listeners.get(type) ?? []) {
+ listener({ data: JSON.stringify(data) })
+ }
+ }
+ close() {}
+ }
+
+ Object.defineProperty(window, 'EventSource', {
+ value: InactiveEventSource,
+ writable: true,
+ configurable: true,
+ })
+
+ mockFetchLiveState.mockRejectedValue(new Error('API 503: live state unavailable'))
+ mockFetchLiveTrackOutline.mockResolvedValue({
+ circuit_key: 1,
+ points: [],
+ bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
+ })
+ mockFetchWeekendContext.mockResolvedValue({
+ temporal_state: 'between_weekends',
+ championship_round: 5,
+ total_championship_rounds: 24,
+ season: 2026,
+ })
+
+ render(
+
+
+ ,
+ )
+
+ expect(await screen.findByTestId('live-initial-error')).toBeInTheDocument()
+
+ await act(async () => {
+ InactiveEventSource.latest?.emit('snapshot', {
+ is_live: false,
+ data: null,
+ last_snapshot: null,
+ })
+ })
+
+ await waitFor(() => expect(screen.getByTestId('live-inactive')).toBeInTheDocument())
+ expect(screen.queryByTestId('live-initial-error')).not.toBeInTheDocument()
+ expect(screen.queryByText(/Live timing unavailable/i)).not.toBeInTheDocument()
+ expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'inactive')
+ expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument()
+ })
+
+ it('keeps bounded initial error + Retry when no stream data arrives', async () => {
+ mockFetchLiveState.mockRejectedValue(new Error('API 503: live state unavailable'))
+ mockFetchLiveTrackOutline.mockResolvedValue({
+ circuit_key: 1,
+ points: [],
+ bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
+ })
+ mockFetchWeekendContext.mockResolvedValue({
+ temporal_state: 'no_season',
+ championship_round: 0,
+ total_championship_rounds: 0,
+ })
+
+ render(
+
+
+ ,
+ )
+
+ expect(await screen.findByTestId('live-initial-error')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled()
+ expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument()
+ })
})
diff --git a/frontend/src/test/RacePreviewPage.test.tsx b/frontend/src/test/RacePreviewPage.test.tsx
index 8fc0fc0..31e90d8 100644
--- a/frontend/src/test/RacePreviewPage.test.tsx
+++ b/frontend/src/test/RacePreviewPage.test.tsx
@@ -1,8 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { render, screen, waitFor } from '@testing-library/react'
+import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
+import { rememberResponseAvailability } from '../lib/fetch'
import { RacePreviewPage } from '../pages/RacePreviewPage'
+import { ApiError } from '../lib/fetch'
import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session, TrackOutline } from '../types'
vi.mock('../api', () => ({
@@ -174,16 +176,22 @@ const outline: TrackOutline = {
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
}
-function renderPage() {
+function renderPage(props?: {
+ meeting?: Meeting
+ season?: number
+ embedded?: boolean
+ shellAvailability?: 'local' | 'partial' | 'stale' | 'archive' | 'limited' | 'missing' | null
+}) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ const Page = () =>
const rootRoute = createRootRoute({
component: () => (
-
+
),
})
- const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: RacePreviewPage })
+ const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: Page })
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
return render()
}
@@ -274,4 +282,145 @@ describe('RacePreviewPage', () => {
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('First time on the calendar')
})
})
+
+ it('uses canonical meeting identity when embedded and skips seasons/meetings selection', async () => {
+ mockFetchSessions.mockImplementation(async (meetingKey: number) => {
+ if (meetingKey === 100) return sessions
+ if (meetingKey === 90) return [priorRaceSession]
+ return []
+ })
+ mockFetchMeetings.mockImplementation(async (year: number) => {
+ if (year === 2098) return [priorMeeting]
+ return []
+ })
+
+ renderPage({ meeting: upcomingMeeting, season: 2099, embedded: true, shellAvailability: 'partial' })
+
+ await waitFor(() => expect(screen.getByTestId('preview-page')).toBeInTheDocument())
+ expect(screen.getByTestId('preview-page')).toHaveAttribute('data-meeting-key', '100')
+ expect(screen.getByTestId('preview-page')).toHaveAttribute('data-embedded', 'true')
+ expect(mockFetchSeasons).not.toHaveBeenCalled()
+ // Current-year meetings selection is skipped; prior-year lookup may still run.
+ expect(mockFetchMeetings).not.toHaveBeenCalledWith(2099, expect.anything(), expect.anything())
+ expect(mockFetchSessions).toHaveBeenCalledWith(100, 'auto', expect.anything())
+ expect(screen.queryByTestId('preview-header')).not.toBeInTheDocument()
+ })
+
+ it('dedupes an equivalent shell notice but shows distinct Preview freshness', async () => {
+ mockFetchSessions.mockImplementation(async (meetingKey: number) => {
+ if (meetingKey === 100) return sessions
+ if (meetingKey === 90) return [priorRaceSession]
+ return []
+ })
+ mockFetchMeetings.mockImplementation(async (year: number) => {
+ if (year === 2098) return [priorMeeting]
+ return []
+ })
+
+ const staleHub = { ...hub }
+ rememberResponseAvailability(staleHub, { source: 'openf1', freshness: 'stale' })
+ mockFetchChampionshipHub.mockResolvedValue(staleHub)
+
+ const distinct = renderPage({
+ meeting: upcomingMeeting,
+ season: 2099,
+ embedded: true,
+ shellAvailability: 'partial',
+ })
+ await waitFor(() => expect(screen.getByTestId('preview-data-notice')).toHaveTextContent(/Stale/i))
+ distinct.unmount()
+
+ const partialHub = { ...hub }
+ rememberResponseAvailability(partialHub, { source: 'openf1', freshness: 'partial' })
+ mockFetchChampionshipHub.mockResolvedValue(partialHub)
+
+ renderPage({
+ meeting: upcomingMeeting,
+ season: 2099,
+ embedded: true,
+ shellAvailability: 'partial',
+ })
+ await waitFor(() => expect(screen.getByTestId('preview-page')).toBeInTheDocument())
+ expect(screen.queryByTestId('preview-data-notice')).not.toBeInTheDocument()
+ })
+
+ it('preserves Stale/Partial over routine Championship Local from conflicting sources', async () => {
+ mockFetchSessions.mockImplementation(async (meetingKey: number) => {
+ if (meetingKey === 100) {
+ const payload = [...sessions]
+ rememberResponseAvailability(payload, { source: 'openf1', freshness: 'stale' })
+ return payload
+ }
+ if (meetingKey === 90) return [priorRaceSession]
+ return []
+ })
+ mockFetchMeetings.mockImplementation(async (year: number) => {
+ if (year === 2098) return [priorMeeting]
+ return []
+ })
+
+ const localHub = { ...hub }
+ rememberResponseAvailability(localHub, { source: 'local', freshness: 'local' })
+ mockFetchChampionshipHub.mockResolvedValue(localHub)
+
+ renderPage({ meeting: upcomingMeeting, season: 2099 })
+
+ await waitFor(() => expect(screen.getByTestId('preview-data-notice')).toHaveTextContent(/Stale/i))
+ expect(screen.getByTestId('preview-title-fight-card')).toBeInTheDocument()
+ expect(screen.getByTestId('preview-schedule')).toHaveTextContent('FP1')
+ })
+
+ it('shows sanitized sessions failure with Retry that refetches once', async () => {
+ let sessionCalls = 0
+ mockFetchSessions.mockImplementation(async (meetingKey: number) => {
+ if (meetingKey !== 100) return [priorRaceSession]
+ sessionCalls += 1
+ if (sessionCalls === 1) {
+ throw new ApiError('http', 'API 500: sessions boom', { status: 500 })
+ }
+ return sessions
+ })
+ mockFetchMeetings.mockImplementation(async (year: number) => {
+ if (year === 2098) return [priorMeeting]
+ return []
+ })
+
+ renderPage({ meeting: upcomingMeeting, season: 2099, embedded: true })
+
+ await waitFor(() => expect(screen.getByTestId('preview-sessions-error')).toBeInTheDocument())
+ expect(screen.getByTestId('preview-sessions-error')).not.toHaveTextContent(/API 500|sessions boom/i)
+ expect(screen.getByTestId('preview-page')).toBeInTheDocument()
+
+ const retry = screen.getByTestId('preview-sessions-retry')
+ fireEvent.click(retry)
+ fireEvent.click(retry)
+
+ await waitFor(() => expect(screen.getByTestId('preview-schedule')).toHaveTextContent('FP1'))
+ expect(sessionCalls).toBe(2)
+ expect(screen.queryByTestId('preview-sessions-error')).not.toBeInTheDocument()
+ })
+
+ it('sanitizes raw HTTP errors and offers a guarded keyboard Retry', async () => {
+ mockFetchSeasons.mockRejectedValue(
+ new ApiError('http', 'API 500: Internal Server Error', { status: 500 }),
+ )
+
+ renderPage()
+
+ await waitFor(() => expect(screen.getByTestId('preview-error')).toBeInTheDocument())
+ expect(screen.getByTestId('preview-error')).not.toHaveTextContent(/API 500|Internal Server Error/i)
+ const retry = screen.getByRole('button', { name: 'Retry' })
+ expect(retry).toBeEnabled()
+
+ mockFetchSeasons.mockResolvedValue([2099])
+ mockFetchMeetings.mockResolvedValue([upcomingMeeting])
+ mockFetchSessions.mockResolvedValue(sessions)
+
+ fireEvent.click(retry)
+ fireEvent.click(retry)
+
+ await waitFor(() => expect(screen.getByTestId('preview-page')).toBeInTheDocument())
+ // Guarded: one in-flight refetch despite double click while fetching.
+ expect(mockFetchSeasons.mock.calls.length).toBeGreaterThanOrEqual(2)
+ })
})
diff --git a/frontend/src/test/WeekendPage.test.tsx b/frontend/src/test/WeekendPage.test.tsx
index c9178e6..182cb2d 100644
--- a/frontend/src/test/WeekendPage.test.tsx
+++ b/frontend/src/test/WeekendPage.test.tsx
@@ -50,6 +50,7 @@ import {
fetchStartingGrid,
fetchTrackOutline,
} from '../api'
+import { rememberResponseAvailability } from '../lib/fetch'
const mockContext = vi.mocked(fetchWeekendContext)
const mockHub = vi.mocked(fetchChampionshipHub)
@@ -364,6 +365,267 @@ describe('WeekendPage canonical contract rendering', () => {
expect(screen.queryByTestId('weekend-between-races')).not.toBeInTheDocument()
})
+ it('pre_session embeds Preview with canonical meeting identity (no seasons/meetings re-resolve)', async () => {
+ const next = meeting({
+ meeting_key: 2,
+ meeting_name: 'Hungarian Grand Prix',
+ date_start: '2026-07-24T09:00:00Z',
+ })
+ mockContext.mockResolvedValue(
+ context({
+ temporal_state: 'pre_session',
+ next_meeting: next,
+ focus_meeting: next,
+ next_session: ctxSession({
+ session: session({
+ session_key: 21,
+ session_name: 'Practice 1',
+ meeting_key: 2,
+ date_start: '2026-07-24T09:00:00Z',
+ }),
+ meeting: next,
+ availability: availability({ freshness: 'partial', local_analysis: 'partial' }),
+ }),
+ }),
+ )
+ mockSessions.mockResolvedValue([
+ session({
+ session_key: 21,
+ session_name: 'Practice 1',
+ meeting_key: 2,
+ date_start: '2026-07-24T09:00:00Z',
+ }),
+ ])
+
+ renderAt('/')
+ await waitFor(() => expect(screen.getByTestId('weekend-pre-session')).toBeInTheDocument())
+ expect(screen.getByTestId('wk-pre-head')).toHaveTextContent('Hungarian Grand Prix')
+ expect(screen.getByTestId('weekend-data-notice')).toHaveTextContent(/Partial/i)
+
+ await waitFor(() => expect(screen.getByTestId('preview-page')).toBeInTheDocument())
+ expect(screen.getByTestId('preview-page')).toHaveAttribute('data-meeting-key', '2')
+ expect(screen.getByTestId('preview-page')).toHaveAttribute('data-embedded', 'true')
+ // Single shell notice — Preview must not stack a second freshness banner.
+ expect(screen.getByTestId('weekend-data-notice')).toHaveTextContent(/Partial/i)
+ expect(screen.queryByTestId('preview-data-notice')).not.toBeInTheDocument()
+ // Canonical identity was passed — Preview must not fan out to seasons /
+ // current-season meetings selection. Prior-year lookup for "Last year here"
+ // remains an intentional supplement.
+ expect(mockSeasons).not.toHaveBeenCalled()
+ expect(mockMeetings).not.toHaveBeenCalledWith(2026, expect.anything(), expect.anything())
+ expect(mockSessions).toHaveBeenCalledWith(2, 'auto', expect.anything())
+ })
+
+ it('preview supplement failure keeps the Weekend shell usable with sanitized Retry', async () => {
+ const next = meeting({
+ meeting_key: 2,
+ meeting_name: 'Hungarian Grand Prix',
+ date_start: '2026-07-24T09:00:00Z',
+ })
+ mockContext.mockResolvedValue(
+ context({
+ temporal_state: 'pre_session',
+ next_meeting: next,
+ focus_meeting: next,
+ next_session: ctxSession({
+ session: session({ session_key: 21, session_name: 'Practice 1', meeting_key: 2 }),
+ meeting: next,
+ }),
+ }),
+ )
+ let sessionCalls = 0
+ mockSessions.mockImplementation(async (meetingKey: number) => {
+ if (meetingKey !== 2) return []
+ sessionCalls += 1
+ if (sessionCalls === 1) throw new Error('API 500: sessions boom')
+ return [
+ session({
+ session_key: 21,
+ session_name: 'Practice 1',
+ meeting_key: 2,
+ date_start: '2026-07-24T09:00:00Z',
+ }),
+ ]
+ })
+
+ renderAt('/')
+ await waitFor(() => expect(screen.getByTestId('wk-pre-head')).toBeInTheDocument())
+ expect(screen.getByTestId('wk-pre-head')).toHaveTextContent('Hungarian Grand Prix')
+ await waitFor(() => expect(screen.getByTestId('preview-sessions-error')).toBeInTheDocument())
+ expect(screen.queryByText(/API 500|sessions boom/i)).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByTestId('preview-sessions-retry'))
+ await waitFor(() => expect(screen.getByTestId('preview-schedule')).toHaveTextContent('Practice 1'))
+ expect(sessionCalls).toBe(2)
+ expect(screen.queryByTestId('preview-sessions-error')).not.toBeInTheDocument()
+ })
+
+ it('Limited Retry refetches failed championship/news supplements once', async () => {
+ mockContext.mockResolvedValue(context({ temporal_state: 'between_weekends' }))
+ let hubCalls = 0
+ let newsCalls = 0
+ mockHub.mockImplementation(async () => {
+ hubCalls += 1
+ if (hubCalls === 1) throw new Error('API 503: hub')
+ return hub
+ })
+ mockNews.mockImplementation(async () => {
+ newsCalls += 1
+ if (newsCalls === 1) throw new Error('API 503: news')
+ return []
+ })
+
+ renderAt('/')
+ await waitFor(() => expect(screen.getByTestId('weekend-data-notice')).toHaveTextContent(/Limited/i))
+ const retry = screen.getByTestId('weekend-data-notice').querySelector('button')!
+ expect(retry).toBeEnabled()
+ fireEvent.click(retry)
+ fireEvent.click(retry)
+
+ await waitFor(() => expect(screen.queryByTestId('weekend-data-notice')).not.toBeInTheDocument())
+ expect(hubCalls).toBe(2)
+ expect(newsCalls).toBe(2)
+ // Canonical context is not the failed resource — only one initial fetch.
+ expect(mockContext).toHaveBeenCalledTimes(1)
+ })
+
+ it('Limited Retry exposes disabled Retrying… aria-busy while deferred supplements recover', async () => {
+ mockContext.mockResolvedValue(context({ temporal_state: 'between_weekends' }))
+ let hubCalls = 0
+ let newsCalls = 0
+ let resolveHub!: (value: ChampionshipHub) => void
+ let resolveNews!: (value: []) => void
+ mockHub.mockImplementation(
+ () =>
+ new Promise((resolve, reject) => {
+ hubCalls += 1
+ if (hubCalls === 1) {
+ reject(new Error('API 503: hub'))
+ return
+ }
+ resolveHub = resolve
+ }),
+ )
+ mockNews.mockImplementation(
+ () =>
+ new Promise<[]>((resolve, reject) => {
+ newsCalls += 1
+ if (newsCalls === 1) {
+ reject(new Error('API 503: news'))
+ return
+ }
+ resolveNews = resolve
+ }),
+ )
+
+ renderAt('/')
+ await waitFor(() => expect(screen.getByTestId('weekend-data-notice')).toHaveTextContent(/Limited/i))
+ expect(mockContext).toHaveBeenCalledTimes(1)
+
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Retrying…' })).toBeDisabled())
+ expect(screen.getByRole('button', { name: 'Retrying…' })).toHaveAttribute('aria-busy', 'true')
+ // Double-click while pending must not issue extra supplement or context requests.
+ fireEvent.click(screen.getByRole('button', { name: 'Retrying…' }))
+ expect(hubCalls).toBe(2)
+ expect(newsCalls).toBe(2)
+ expect(mockContext).toHaveBeenCalledTimes(1)
+
+ resolveHub(hub)
+ resolveNews([])
+ await waitFor(() => expect(screen.queryByTestId('weekend-data-notice')).not.toBeInTheDocument())
+ expect(hubCalls).toBe(2)
+ expect(newsCalls).toBe(2)
+ expect(mockContext).toHaveBeenCalledTimes(1)
+ })
+
+ it('between-sessions header local/local suppresses Archive from terminal previous', async () => {
+ const focus = meeting({
+ meeting_key: 2,
+ meeting_name: 'Belgian Grand Prix',
+ date_start: '2026-07-10T09:00:00Z',
+ })
+ const previous = meeting({
+ meeting_key: 1,
+ meeting_name: 'British Grand Prix',
+ circuit_short_name: 'Silverstone',
+ date_start: '2026-07-05T09:00:00Z',
+ })
+ const payload = context({
+ temporal_state: 'between_sessions',
+ focus_meeting: focus,
+ next_meeting: focus,
+ previous_meeting: previous,
+ next_session: ctxSession({
+ session: session({
+ session_key: 21,
+ session_name: 'Practice 1',
+ meeting_key: 2,
+ date_start: '2026-07-10T09:00:00Z',
+ }),
+ meeting: focus,
+ availability: availability({ source: 'local', freshness: 'local' }),
+ }),
+ previous_completed_session: ctxSession({
+ session: session({
+ session_key: 99,
+ session_name: 'Race',
+ session_type: 'Race',
+ meeting_key: 1,
+ date_start: '2026-07-05T14:00:00Z',
+ }),
+ meeting: previous,
+ availability: availability({ source: 'fia', freshness: 'archive', archive: 'available' }),
+ }),
+ })
+ rememberResponseAvailability(payload, { source: 'local', freshness: 'local' })
+ mockContext.mockResolvedValue(payload)
+
+ renderAt('/')
+ await waitFor(() => expect(screen.getByTestId('weekend-between-sessions')).toBeInTheDocument())
+ expect(screen.queryByTestId('weekend-data-notice')).not.toBeInTheDocument()
+ expect(screen.queryByText(/archived snapshot/i)).not.toBeInTheDocument()
+ })
+
+ it('pre_session keeps shell Partial while disclosing distinct Preview stale', async () => {
+ const next = meeting({
+ meeting_key: 2,
+ meeting_name: 'Hungarian Grand Prix',
+ date_start: '2026-07-24T09:00:00Z',
+ })
+ mockContext.mockResolvedValue(
+ context({
+ temporal_state: 'pre_session',
+ next_meeting: next,
+ focus_meeting: next,
+ next_session: ctxSession({
+ session: session({
+ session_key: 21,
+ session_name: 'Practice 1',
+ meeting_key: 2,
+ date_start: '2026-07-24T09:00:00Z',
+ }),
+ meeting: next,
+ availability: availability({ freshness: 'partial', local_analysis: 'partial' }),
+ }),
+ }),
+ )
+ mockSessions.mockResolvedValue([
+ session({
+ session_key: 21,
+ session_name: 'Practice 1',
+ meeting_key: 2,
+ date_start: '2026-07-24T09:00:00Z',
+ }),
+ ])
+ rememberResponseAvailability(hub, { source: 'openf1', freshness: 'stale' })
+ mockHub.mockResolvedValue(hub)
+
+ renderAt('/')
+ await waitFor(() => expect(screen.getByTestId('weekend-data-notice')).toHaveTextContent(/Partial/i))
+ await waitFor(() => expect(screen.getByTestId('preview-data-notice')).toHaveTextContent(/Stale/i))
+ })
+
it('restores Race Hub meeting/session focus from the Weekend URL search contract', async () => {
mockContext.mockResolvedValue(
context({
diff --git a/frontend/src/test/availability.test.ts b/frontend/src/test/availability.test.ts
new file mode 100644
index 0000000..20858fc
--- /dev/null
+++ b/frontend/src/test/availability.test.ts
@@ -0,0 +1,344 @@
+import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
+import { QueryClient, QueryObserver } from '@tanstack/react-query'
+import {
+ apiFetch,
+ availabilityAwareStructuralSharing,
+ clearApiFetchInflight,
+ DATA_FRESHNESS_HEADER,
+ DATA_SOURCE_HEADER,
+ getResponseAvailability,
+ rememberResponseAvailability,
+} from '../lib/fetch'
+import {
+ aggregateNotices,
+ noticeFromFreshness,
+ noticeFromResponse,
+ shouldShowEmbeddedNotice,
+ weekendContextNotice,
+} from '../lib/availability'
+import type { WeekendContext } from '../types'
+
+describe('response availability metadata', () => {
+ beforeEach(() => {
+ clearApiFetchInflight()
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ clearApiFetchInflight()
+ })
+
+ it('captures CORS-readable source/freshness headers without changing the payload shape', async () => {
+ const body = { season: 2026, drivers: [{ points: 1 }] }
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () =>
+ new Response(JSON.stringify(body), {
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json',
+ [DATA_SOURCE_HEADER]: 'openf1',
+ [DATA_FRESHNESS_HEADER]: 'stale',
+ },
+ }),
+ ),
+ )
+
+ const data = await apiFetch('/api/v1/championship/hub')
+ expect(data).toEqual(body)
+ expect(getResponseAvailability(data)).toEqual({ source: 'openf1', freshness: 'stale' })
+ })
+
+ it('preserves metadata through deduped in-flight subscribers', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () =>
+ new Response(JSON.stringify({ ok: true }), {
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json',
+ [DATA_SOURCE_HEADER]: 'local',
+ [DATA_FRESHNESS_HEADER]: 'partial',
+ },
+ }),
+ ),
+ )
+
+ const a = apiFetch<{ ok: boolean }>('/api/v1/shared', { dedupeKey: 'meta-shared' })
+ const b = apiFetch<{ ok: boolean }>('/api/v1/shared', { dedupeKey: 'meta-shared' })
+ const [ra, rb] = await Promise.all([a, b])
+ expect(ra).toBe(rb)
+ expect(getResponseAvailability(ra)).toEqual({ source: 'local', freshness: 'partial' })
+ })
+
+ it('maps only reported freshness values and never invents stale', () => {
+ expect(noticeFromFreshness('stale')).toBe('stale')
+ expect(noticeFromFreshness('partial')).toBe('partial')
+ expect(noticeFromFreshness('local')).toBe('local')
+ expect(noticeFromFreshness('limited')).toBe('limited')
+ expect(noticeFromFreshness('archive')).toBe('archive')
+ expect(noticeFromFreshness('fresh')).toBeNull()
+ expect(noticeFromFreshness('live')).toBeNull()
+ expect(noticeFromFreshness(undefined)).toBeNull()
+ expect(noticeFromFreshness('local', { includeLocal: false })).toBeNull()
+ })
+
+ it('reads notice from WeakMap metadata attached to a successful payload', () => {
+ const hub = { season: 2025, drivers: [] as unknown[] }
+ rememberResponseAvailability(hub, { source: 'openf1', freshness: 'stale' })
+ expect(noticeFromResponse(hub)).toBe('stale')
+ })
+
+ it('dedupes only equivalent embedded Preview notices', () => {
+ expect(shouldShowEmbeddedNotice('stale', 'partial')).toBe(true)
+ expect(shouldShowEmbeddedNotice('partial', 'partial')).toBe(false)
+ expect(shouldShowEmbeddedNotice('stale', null)).toBe(true)
+ expect(shouldShowEmbeddedNotice(null, 'partial')).toBe(false)
+ })
+
+ it('aggregates conflicting sources by severity so Local cannot mask worse truth', () => {
+ expect(aggregateNotices(['local', 'stale'])).toBe('stale')
+ expect(aggregateNotices(['local', 'partial'])).toBe('partial')
+ expect(aggregateNotices(['local', 'limited'])).toBe('limited')
+ expect(aggregateNotices(['local', 'archive'])).toBe('archive')
+ expect(aggregateNotices(['stale', 'partial', 'local'])).toBe('partial')
+ expect(aggregateNotices(['stale', 'stale', null])).toBe('stale')
+ expect(aggregateNotices([null, undefined])).toBeNull()
+ })
+
+ it('derives Weekend Context notices from typed session freshness, skipping routine local', () => {
+ const context: WeekendContext = {
+ season: 2026,
+ temporal_state: 'pre_session',
+ championship_round: 1,
+ total_championship_rounds: 24,
+ focus_meeting: {
+ meeting_key: 2,
+ meeting_name: 'Belgian Grand Prix',
+ meeting_official_name: '',
+ location: 'Spa',
+ country_name: 'Belgium',
+ country_code: 'BEL',
+ country_flag: '',
+ circuit_key: 7,
+ circuit_short_name: 'Spa',
+ date_start: '2026-07-24T09:00:00Z',
+ date_end: '2026-07-26T16:00:00Z',
+ year: 2026,
+ },
+ next_session: {
+ session: {
+ session_key: 21,
+ session_name: 'FP1',
+ session_type: 'Practice',
+ meeting_key: 2,
+ date_start: '2026-07-24T09:00:00Z',
+ date_end: '2026-07-24T10:00:00Z',
+ gmt_offset: '',
+ },
+ meeting: {
+ meeting_key: 2,
+ meeting_name: 'Belgian Grand Prix',
+ meeting_official_name: '',
+ location: 'Spa',
+ country_name: 'Belgium',
+ country_code: 'BEL',
+ country_flag: '',
+ circuit_key: 7,
+ circuit_short_name: 'Spa',
+ date_start: '2026-07-24T09:00:00Z',
+ date_end: '2026-07-26T16:00:00Z',
+ year: 2026,
+ },
+ availability: {
+ source: 'local',
+ schedule: 'available',
+ live_transport: 'unknown',
+ live_session: 'inactive',
+ archive: 'unavailable',
+ local_analysis: 'partial',
+ freshness: 'partial',
+ limitations: [],
+ },
+ },
+ }
+ expect(weekendContextNotice(context)).toBe('partial')
+
+ const localOnly: WeekendContext = {
+ ...context,
+ next_session: {
+ ...context.next_session!,
+ availability: {
+ ...context.next_session!.availability,
+ local_analysis: 'pending',
+ freshness: 'local',
+ },
+ },
+ }
+ expect(weekendContextNotice(localOnly)).toBeNull()
+ })
+
+ it('authoritative Weekend header local/local does not fall through to previous archive', () => {
+ const focusMeeting = {
+ meeting_key: 2,
+ meeting_name: 'Belgian Grand Prix',
+ meeting_official_name: '',
+ location: 'Spa',
+ country_name: 'Belgium',
+ country_code: 'BEL',
+ country_flag: '',
+ circuit_key: 7,
+ circuit_short_name: 'Spa',
+ date_start: '2026-07-10T09:00:00Z',
+ date_end: '2026-07-12T16:00:00Z',
+ year: 2026,
+ }
+ const previousMeeting = {
+ ...focusMeeting,
+ meeting_key: 1,
+ meeting_name: 'British Grand Prix',
+ circuit_short_name: 'Silverstone',
+ location: 'Silverstone',
+ country_name: 'United Kingdom',
+ country_code: 'GBR',
+ }
+ const context: WeekendContext = {
+ season: 2026,
+ temporal_state: 'between_sessions',
+ championship_round: 12,
+ total_championship_rounds: 24,
+ focus_meeting: focusMeeting,
+ next_session: {
+ session: {
+ session_key: 21,
+ session_name: 'Practice 1',
+ session_type: 'Practice',
+ meeting_key: 2,
+ date_start: '2026-07-10T09:00:00Z',
+ date_end: '2026-07-10T10:00:00Z',
+ gmt_offset: '',
+ },
+ meeting: focusMeeting,
+ availability: {
+ source: 'local',
+ schedule: 'available',
+ live_transport: 'unknown',
+ live_session: 'inactive',
+ archive: 'unavailable',
+ local_analysis: 'pending',
+ freshness: 'local',
+ limitations: [],
+ },
+ },
+ previous_completed_session: {
+ session: {
+ session_key: 99,
+ session_name: 'Race',
+ session_type: 'Race',
+ meeting_key: 1,
+ date_start: '2026-07-05T14:00:00Z',
+ date_end: '2026-07-05T16:00:00Z',
+ gmt_offset: '',
+ },
+ meeting: previousMeeting,
+ availability: {
+ source: 'fia',
+ schedule: 'available',
+ live_transport: 'unknown',
+ live_session: 'inactive',
+ archive: 'available',
+ local_analysis: 'complete',
+ freshness: 'archive',
+ limitations: [],
+ },
+ },
+ }
+
+ rememberResponseAvailability(context, { source: 'local', freshness: 'local' })
+ expect(weekendContextNotice(context)).toBeNull()
+
+ // Without an authoritative header, typed focus (upcoming local) still suppresses Local.
+ const noHeader: WeekendContext = { ...context }
+ expect(weekendContextNotice(noHeader)).toBeNull()
+ })
+
+ it('updates metadata when structural sharing reuses an equal JSON object', () => {
+ const body = { season: 2026, drivers: [{ points: 1 }] }
+ const first = structuredClone(body)
+ const second = structuredClone(body)
+ rememberResponseAvailability(first, { source: 'openf1', freshness: 'stale' })
+ rememberResponseAvailability(second, { source: 'openf1', freshness: 'fresh' })
+
+ const shared = availabilityAwareStructuralSharing(first, second)
+ expect(shared).toBe(first)
+ expect(getResponseAvailability(shared)).toEqual({ source: 'openf1', freshness: 'fresh' })
+ expect(noticeFromResponse(shared)).toBeNull()
+
+ const third = structuredClone(body)
+ rememberResponseAvailability(third, { source: 'openf1', freshness: 'partial' })
+ const sharedAgain = availabilityAwareStructuralSharing(shared, third)
+ expect(sharedAgain).toBe(first)
+ expect(noticeFromResponse(sharedAgain)).toBe('partial')
+ })
+
+ it('QueryObserver stale→fresh and fresh→partial refetches update metadata and notices', async () => {
+ const body = { season: 2026, drivers: [{ points: 42 }] }
+ const sequence: Array<'stale' | 'fresh' | 'partial'> = ['stale', 'fresh', 'partial']
+ let call = 0
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => {
+ const freshness = sequence[Math.min(call, sequence.length - 1)]
+ call += 1
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json',
+ [DATA_SOURCE_HEADER]: 'openf1',
+ [DATA_FRESHNESS_HEADER]: freshness,
+ },
+ })
+ }),
+ )
+
+ const client = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ structuralSharing: availabilityAwareStructuralSharing,
+ },
+ },
+ })
+ const observer = new QueryObserver(client, {
+ queryKey: ['availability-observer'],
+ queryFn: () => apiFetch('/api/v1/championship/hub'),
+ staleTime: 0,
+ })
+
+ const notices: Array = []
+ const unsub = observer.subscribe((result) => {
+ if (result.data) notices.push(noticeFromResponse(result.data))
+ })
+
+ await observer.refetch()
+ expect(noticeFromResponse(observer.getCurrentResult().data)).toBe('stale')
+ expect(notices[notices.length - 1]).toBe('stale')
+
+ await observer.refetch()
+ expect(observer.getCurrentResult().data).toEqual(body)
+ expect(getResponseAvailability(observer.getCurrentResult().data!)).toEqual({
+ source: 'openf1',
+ freshness: 'fresh',
+ })
+ expect(noticeFromResponse(observer.getCurrentResult().data)).toBeNull()
+ expect(notices[notices.length - 1]).toBeNull()
+
+ await observer.refetch()
+ expect(noticeFromResponse(observer.getCurrentResult().data)).toBe('partial')
+ expect(notices[notices.length - 1]).toBe('partial')
+ expect(call).toBe(3)
+
+ unsub()
+ client.clear()
+ })
+})
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 02105ed..2c1c3bd 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -472,6 +472,8 @@ export type TemporalState =
// ContextAvailability mirrors query.ContextAvailability. Every field is present
// in a canonical payload except the optional `observed_at`.
export interface ContextAvailability {
+ /** Domain / FIA provenance: local | mixed | fia (additive; may be absent on older payloads). */
+ source?: string
schedule: string
live_transport: string
live_session: string
diff --git a/internal/api/cache.go b/internal/api/cache.go
index 7057e0e..5ae2d5a 100644
--- a/internal/api/cache.go
+++ b/internal/api/cache.go
@@ -164,8 +164,8 @@ func (c *Cache) Get(key string) ([]byte, bool) {
if ttl > 0 {
age := time.Since(time.Unix(createdAt, 0))
if age > ttl {
- // Expired — delete and return miss.
- _, _ = c.db.Exec(`DELETE FROM cache WHERE key = ?`, key)
+ // Expired entries remain stored so get() can use them as a stale
+ // fallback if the live request fails. Prune() owns physical cleanup.
atomic.AddInt64(&c.stats.Misses, 1)
return nil, false
}
diff --git a/internal/api/client.go b/internal/api/client.go
index fe04625..49d93b9 100644
--- a/internal/api/client.go
+++ b/internal/api/client.go
@@ -1,6 +1,7 @@
package api
import (
+ "context"
"net/http"
"sync"
"sync/atomic"
@@ -26,8 +27,12 @@ type requestPacer struct {
// wait blocks until this caller's reserved slot arrives.
func (p *requestPacer) wait() {
+ _ = p.waitContext(context.Background())
+}
+
+func (p *requestPacer) waitContext(ctx context.Context) error {
if p == nil || p.interval <= 0 {
- return
+ return nil
}
p.mu.Lock()
now := time.Now()
@@ -38,8 +43,18 @@ func (p *requestPacer) wait() {
p.next = p.next.Add(p.interval)
p.mu.Unlock()
if sleep > 0 {
- time.Sleep(sleep)
+ timer := time.NewTimer(sleep)
+ defer timer.Stop()
+ select {
+ case <-timer.C:
+ case <-ctx.Done():
+ // Keep the unused reservation in the schedule. Blindly reclaiming an
+ // interval can collide with later callers that already reserved their
+ // wake times, releasing two requests simultaneously.
+ return ctx.Err()
+ }
}
+ return nil
}
type OpenF1Client struct {
@@ -56,6 +71,26 @@ type OpenF1Client struct {
staleFlag int32
}
+// Scoped returns a lightweight request-scoped view of the client. Network,
+// pacing and cache resources are shared, while the stale fallback indicator is
+// deliberately not shared. Web handlers use this view so a stale fallback in
+// one concurrent HTTP request can never mark an unrelated response as stale.
+//
+// The legacy client-wide stale flag remains available for the TUI, whose loads
+// are intentionally aggregated into one navigation-level notice.
+func (c *OpenF1Client) Scoped() *OpenF1Client {
+ if c == nil {
+ return nil
+ }
+ return &OpenF1Client{
+ url: c.url,
+ apiKey: c.apiKey,
+ httpClient: c.httpClient,
+ cache: c.cache,
+ pacer: c.pacer,
+ }
+}
+
func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client {
return &OpenF1Client{
url: url,
diff --git a/internal/api/freshness_test.go b/internal/api/freshness_test.go
new file mode 100644
index 0000000..16c4fa5
--- /dev/null
+++ b/internal/api/freshness_test.go
@@ -0,0 +1,123 @@
+package api
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+func expireCacheEntry(t *testing.T, c *OpenF1Client, key string) {
+ t.Helper()
+ if _, err := c.cache.db.Exec(`UPDATE cache SET created_at = ? WHERE key = ?`, time.Now().Add(-48*time.Hour).Unix(), key); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestScopedClientReportsStaleFallbackWithoutMutatingParent(t *testing.T) {
+ year := time.Now().Year()
+ var fail atomic.Bool
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if fail.Load() {
+ http.Error(w, "unavailable", http.StatusServiceUnavailable)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = fmt.Fprintf(w, `[{
+ "meeting_key": 1,
+ "meeting_name": "British Grand Prix",
+ "year": %d
+ }]`, year)
+ }))
+ defer upstream.Close()
+
+ client := NewOpenF1Client(upstream.URL, time.Second)
+ defer client.Close()
+ client.pacer.interval = 0
+ key := fmt.Sprintf("%s/v1/meetings?year=%d", upstream.URL, year)
+ _, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key = ?`, key)
+ defer func() { _, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key = ?`, key) }()
+
+ if _, err := client.GetMeetingsForYear(year); err != nil {
+ t.Fatalf("prime cache: %v", err)
+ }
+ expireCacheEntry(t, client, key)
+ fail.Store(true)
+
+ scoped := client.Scoped()
+ meetings, err := scoped.GetMeetingsForYear(year)
+ if err != nil || len(meetings) != 1 {
+ t.Fatalf("stale fallback = (%+v, %v)", meetings, err)
+ }
+ if !scoped.LastResponseWasStale() {
+ t.Fatal("scoped request did not report its stale fallback")
+ }
+ if client.LastResponseWasStale() {
+ t.Fatal("request-scoped fallback leaked into the parent client")
+ }
+}
+
+func TestScopedClientsDoNotLeakFreshnessAcrossConcurrentRequests(t *testing.T) {
+ year := time.Now().Year()
+ staleStarted := make(chan struct{})
+ releaseStale := make(chan struct{})
+ var failMeetings atomic.Bool
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/v1/meetings":
+ if failMeetings.Load() {
+ close(staleStarted)
+ <-releaseStale
+ http.Error(w, "unavailable", http.StatusServiceUnavailable)
+ return
+ }
+ _, _ = fmt.Fprintf(w, `[{"meeting_key":1,"meeting_name":"British Grand Prix","year":%d}]`, year)
+ case "/v1/sessions":
+ _, _ = w.Write([]byte(`[{"session_key":11,"meeting_key":2,"session_name":"Race"}]`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer upstream.Close()
+
+ client := NewOpenF1Client(upstream.URL, 2*time.Second)
+ defer client.Close()
+ client.pacer.interval = 0
+ meetingKey := fmt.Sprintf("%s/v1/meetings?year=%d", upstream.URL, year)
+ sessionKey := upstream.URL + "/v1/sessions?meeting_key=2"
+ _, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key IN (?, ?)`, meetingKey, sessionKey)
+ defer func() { _, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key IN (?, ?)`, meetingKey, sessionKey) }()
+ if _, err := client.GetMeetingsForYear(year); err != nil {
+ t.Fatal(err)
+ }
+ expireCacheEntry(t, client, meetingKey)
+ failMeetings.Store(true)
+
+ staleClient := client.Scoped()
+ staleDone := make(chan error, 1)
+ go func() {
+ _, err := staleClient.GetMeetingsForYear(year)
+ staleDone <- err
+ }()
+ <-staleStarted
+
+ freshClient := client.Scoped()
+ if _, err := freshClient.GetSessionsForMeeting(2); err != nil {
+ t.Fatalf("fresh concurrent request: %v", err)
+ }
+ if freshClient.LastResponseWasStale() {
+ t.Fatal("fresh request inherited concurrent request's stale state")
+ }
+ close(releaseStale)
+ if err := <-staleDone; err != nil {
+ t.Fatalf("stale request: %v", err)
+ }
+ if !staleClient.LastResponseWasStale() {
+ t.Fatal("stale request lost its own freshness state")
+ }
+ if freshClient.LastResponseWasStale() || client.LastResponseWasStale() {
+ t.Fatal("stale state leaked after concurrent requests completed")
+ }
+}
diff --git a/internal/api/openf1.go b/internal/api/openf1.go
index d61874e..b0be5d4 100644
--- a/internal/api/openf1.go
+++ b/internal/api/openf1.go
@@ -2,6 +2,7 @@ package api
import (
"bytes"
+ "context"
"encoding/json"
"errors"
"fmt"
@@ -59,8 +60,14 @@ func retryAfter429(resp *http.Response) time.Duration {
// Without this, concurrent fan-outs (championship hub, track prefetch) burst
// past the free-tier limit and callers silently treat 429s as missing data.
func (c *OpenF1Client) doPaced(req *http.Request) (*http.Response, error) {
+ return c.doPacedContext(req.Context(), req)
+}
+
+func (c *OpenF1Client) doPacedContext(ctx context.Context, req *http.Request) (*http.Response, error) {
for attempt := 0; ; attempt++ {
- c.pacer.wait()
+ if err := c.pacer.waitContext(ctx); err != nil {
+ return nil, err
+ }
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
@@ -70,7 +77,14 @@ func (c *OpenF1Client) doPaced(req *http.Request) (*http.Response, error) {
}
delay := retryAfter429(resp)
resp.Body.Close()
- time.Sleep(delay)
+ timer := time.NewTimer(delay)
+ select {
+ case <-timer.C:
+ case <-ctx.Done():
+ timer.Stop()
+ return nil, ctx.Err()
+ }
+ timer.Stop()
}
}
@@ -83,13 +97,23 @@ func (c *OpenF1Client) doPaced(req *http.Request) (*http.Response, error) {
// entry for this URL, that stale entry is returned instead of propagating the
// error. The client's staleFlag is set so the UI can show a disclaimer.
func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
+ return c.getContext(context.Background(), url)
+}
+
+// getContext is the cancellable form used by bounded optional web enrichment.
+// A caller cancellation never falls back to stale data: the work is no longer
+// relevant to that response and must stop instead of continuing in background.
+func (c *OpenF1Client) getContext(ctx context.Context, url string) (io.ReadCloser, error) {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
// 1. Check the cache for a fresh (non-expired) entry.
if cachedData, ok := c.cache.Get(url); ok {
return io.NopCloser(bytes.NewReader(cachedData)), nil
}
// 2. Attempt a live network request.
- req, err := http.NewRequest("GET", url, nil)
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
// Even a request-construction failure warrants a stale fallback.
return c.tryStale(url, err)
@@ -98,8 +122,11 @@ func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
- resp, err := c.doPaced(req)
+ resp, err := c.doPacedContext(ctx, req)
if err != nil {
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
return c.tryStale(url, err)
}
defer resp.Body.Close()
@@ -128,6 +155,9 @@ func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
// 3. Success — read the body, store in cache, return.
data, err := io.ReadAll(resp.Body)
if err != nil {
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
return c.tryStale(url, err)
}
@@ -245,7 +275,13 @@ func (c *OpenF1Client) GetDriversForSession(sessionKey int) ([]models.Driver, er
}
func (c *OpenF1Client) GetDriver(sessionKey, driverNumber int) (*models.Driver, error) {
- body, err := c.get(fmt.Sprintf("%s/v1/drivers?session_key=%d&driver_number=%d", c.url, sessionKey, driverNumber))
+ return c.GetDriverContext(context.Background(), sessionKey, driverNumber)
+}
+
+// GetDriverContext is a cancellable single-driver lookup for optional bounded
+// enrichment. Other public methods retain their existing background semantics.
+func (c *OpenF1Client) GetDriverContext(ctx context.Context, sessionKey, driverNumber int) (*models.Driver, error) {
+ body, err := c.getContext(ctx, fmt.Sprintf("%s/v1/drivers?session_key=%d&driver_number=%d", c.url, sessionKey, driverNumber))
if err != nil {
return nil, err
}
diff --git a/internal/api/pacing_test.go b/internal/api/pacing_test.go
index 284f819..689ea13 100644
--- a/internal/api/pacing_test.go
+++ b/internal/api/pacing_test.go
@@ -1,7 +1,9 @@
package api
import (
+ "context"
"encoding/json"
+ "errors"
"io"
"net/http"
"net/http/httptest"
@@ -49,6 +51,70 @@ func TestRequestPacerNilSafe(t *testing.T) {
p.wait() // must not panic
}
+func TestRequestPacerCancellationDoesNotCollideReservedWaiters(t *testing.T) {
+ const interval = 80 * time.Millisecond
+ p := &requestPacer{interval: interval}
+ if err := p.waitContext(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ p.mu.Lock()
+ initialNext := p.next
+ p.mu.Unlock()
+
+ waitForReservation := func(want time.Time) {
+ t.Helper()
+ deadline := time.Now().Add(250 * time.Millisecond)
+ for time.Now().Before(deadline) {
+ p.mu.Lock()
+ got := p.next
+ p.mu.Unlock()
+ if got.Equal(want) {
+ return
+ }
+ time.Sleep(time.Millisecond)
+ }
+ t.Fatalf("reservation did not reach %v", want)
+ }
+
+ ctxB, cancelB := context.WithCancel(context.Background())
+ bDone := make(chan error, 1)
+ go func() { bDone <- p.waitContext(ctxB) }()
+ waitForReservation(initialNext.Add(interval))
+
+ cDone := make(chan time.Time, 1)
+ go func() {
+ _ = p.waitContext(context.Background())
+ cDone <- time.Now()
+ }()
+ waitForReservation(initialNext.Add(2 * interval))
+
+ cancelStarted := time.Now()
+ cancelB()
+ select {
+ case err := <-bDone:
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("B error = %v, want context.Canceled", err)
+ }
+ if elapsed := time.Since(cancelStarted); elapsed > 30*time.Millisecond {
+ t.Fatalf("B cancellation took %v", elapsed)
+ }
+ case <-time.After(50 * time.Millisecond):
+ t.Fatal("B did not return promptly after cancellation")
+ }
+
+ dDone := make(chan time.Time, 1)
+ go func() {
+ _ = p.waitContext(context.Background())
+ dDone <- time.Now()
+ }()
+ waitForReservation(initialNext.Add(3 * interval))
+
+ cAt, dAt := <-cDone, <-dDone
+ if separation := dAt.Sub(cAt); separation < interval/2 {
+ t.Fatalf("C and D collided: wake separation %v, want at least %v", separation, interval/2)
+ }
+}
+
func TestGetRetriesOn429(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/query/context.go b/internal/query/context.go
index 7c61e51..b876f61 100644
--- a/internal/query/context.go
+++ b/internal/query/context.go
@@ -40,6 +40,7 @@ type LiveEvidence struct {
// ContextAvailability is structured source state for a referenced session.
type ContextAvailability struct {
+ Source string `json:"source"`
Schedule string `json:"schedule"`
LiveTransport string `json:"live_transport"`
LiveSession string `json:"live_session"`
@@ -298,21 +299,28 @@ func meetingModelByKey(meetings []store.Meeting, key int) *models.Meeting {
func sessionRef(c contextCandidate, evidence LiveEvidence, now time.Time) *ContextSession {
session := sessionToModel(c.session)
meeting := meetingToModel(c.meeting)
- availability := ContextAvailability{Schedule: "available", LiveSession: "inactive", Archive: "unavailable", Freshness: "fresh", Limitations: []string{}}
+ // Schedule and analysis are domain-store facts. Without an ingestion
+ // timestamp the resolver cannot honestly call them network-fresh, so local
+ // is the baseline freshness vocabulary exposed to clients.
+ availability := ContextAvailability{Source: "local", Schedule: "available", LiveSession: "inactive", Archive: "unavailable", Freshness: "local", Limitations: []string{}}
availability.LiveTransport = "unknown"
if c.session.SessionKey == 0 {
availability.Schedule = "unavailable"
availability.Limitations = append(availability.Limitations, "schedule_identity_unmatched")
}
if evidence.Active && liveMatches(evidence, c.meeting, c.session) {
+ availability.Source = "mixed"
availability.LiveTransport = "connected"
availability.LiveSession = "active"
+ availability.Freshness = "live"
if !evidence.ObservedAt.IsZero() {
availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339)
}
}
if c.archived {
+ availability.Source = "mixed"
availability.Archive = "available"
+ availability.Freshness = "archive"
if !evidence.ObservedAt.IsZero() {
availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339)
}
@@ -326,6 +334,12 @@ func sessionRef(c contextCandidate, evidence LiveEvidence, now time.Time) *Conte
} else {
availability.LocalAnalysis = "pending"
}
+ if availability.LocalAnalysis == "partial" && availability.Freshness == "local" {
+ availability.Freshness = "partial"
+ }
+ if c.session.SessionKey == 0 && availability.LiveSession == "active" {
+ availability.Source = "fia"
+ }
return &ContextSession{Session: session, Meeting: &meeting, Availability: availability}
}
diff --git a/internal/query/context_test.go b/internal/query/context_test.go
index 8dac292..f85ce14 100644
--- a/internal/query/context_test.go
+++ b/internal/query/context_test.go
@@ -160,6 +160,54 @@ func TestResolveWeekendContextPassedTimeDoesNotCompleteSession(t *testing.T) {
}
}
+func TestResolveWeekendContextAvailabilityUsesTruthfulSources(t *testing.T) {
+ now, _ := time.Parse(time.RFC3339, "2026-07-05T18:00:00Z")
+ svc := contextService(t, now)
+ addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T00:00:00Z", "2026-07-05T16:00:00Z", false)
+ addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
+ // Results without laps/stints/positions are meaningful but incomplete local
+ // analysis, so they must not be labelled universally fresh.
+ completeContextSession(t, svc, 11, 1)
+ addContextMeeting(t, svc, 2, "Belgian Grand Prix", "2026-07-17T00:00:00Z", "2026-07-19T16:00:00Z", false)
+ addContextSession(t, svc, 21, 2, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
+ addContextSession(t, svc, 22, 2, "Race", "2026-07-19T14:00:00Z", "2026-07-19T16:00:00Z", false)
+
+ local, err := svc.ResolveWeekendContext(LiveEvidence{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := local.PreviousCompletedSession.Availability; got.Source != "local" || got.Freshness != "partial" || got.LocalAnalysis != "partial" {
+ t.Fatalf("partial local availability = %+v", got)
+ }
+ if got := local.NextSession.Availability; got.Source != "local" || got.Freshness != "local" {
+ t.Fatalf("future local availability = %+v", got)
+ }
+
+ liveContext, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "Belgian Grand Prix", CircuitName: "Belgian Grand Prix", SessionName: "Practice 1", SessionType: "Practice 1", ObservedAt: now})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := liveContext.ActiveSession.Availability; got.Source != "mixed" || got.Freshness != "live" || got.LiveSession != "active" {
+ t.Fatalf("FIA + local availability = %+v", got)
+ }
+
+ archiveContext, err := svc.ResolveWeekendContext(LiveEvidence{Final: true, MeetingName: "British Grand Prix", CircuitName: "British Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := archiveContext.PreviousCompletedSession.Availability; got.Source != "mixed" || got.Freshness != "archive" || got.Archive != "available" {
+ t.Fatalf("FIA archive + local availability = %+v", got)
+ }
+
+ synthetic, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "Unscheduled Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := synthetic.ActiveSession.Availability; got.Source != "fia" || got.Freshness != "live" || got.Schedule != "unavailable" {
+ t.Fatalf("synthetic FIA availability = %+v", got)
+ }
+}
+
func TestResolveWeekendContextNeverUsesFutureAnalysis(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z")
svc := contextService(t, now)
diff --git a/internal/web/api.go b/internal/web/api.go
index fca561e..de21b7a 100644
--- a/internal/web/api.go
+++ b/internal/web/api.go
@@ -16,6 +16,7 @@ import (
readability "codeberg.org/readeck/go-readability/v2"
+ "github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/query"
)
@@ -43,6 +44,7 @@ func (s *Server) handleMeetings(w http.ResponseWriter, r *http.Request) {
switch parseSourceMode(r) {
case sourceLocal:
+ markLocalResponse(w, false)
if !s.hasLocalQuery() {
writeJSON(w, []models.Meeting{})
return
@@ -62,17 +64,20 @@ func (s *Server) handleMeetings(w http.ResponseWriter, r *http.Request) {
return
}
if len(meetings) > 0 {
+ markLocalResponse(w, false)
writeJSON(w, meetings)
return
}
}
}
- meetings, err := s.client.GetMeetingsForYear(year)
+ client := s.client.Scoped()
+ meetings, err := client.GetMeetingsForYear(year)
if err != nil {
- writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
+ markOpenF1Response(w, client)
writeJSON(w, meetings)
}
@@ -87,6 +92,7 @@ func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
switch parseSourceMode(r) {
case sourceLocal:
+ markLocalResponse(w, false)
if !s.hasLocalQuery() {
writeJSON(w, []models.Session{})
return
@@ -106,23 +112,27 @@ func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
return
}
if len(sessions) > 0 {
+ markLocalResponse(w, false)
writeJSON(w, sessions)
return
}
}
}
- sessions, err := s.client.GetSessionsForMeeting(meetingKey)
+ client := s.client.Scoped()
+ sessions, err := client.GetSessionsForMeeting(meetingKey)
if err != nil {
- writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
+ markOpenF1Response(w, client)
writeJSON(w, sessions)
}
// --- /api/v1/news ---
func (s *Server) handleNews(w http.ResponseWriter, r *http.Request) {
+ markLocalResponse(w, false)
if !s.hasLocalQuery() {
writeJSON(w, []query.NewsItem{})
return
@@ -223,6 +233,7 @@ func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) {
switch parseSourceMode(r) {
case sourceLocal:
+ markLocalResponse(w, false)
if !s.hasLocalQuery() {
writeJSON(w, []models.Driver{})
return
@@ -242,6 +253,7 @@ func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) {
if s.hasLocalQuery() {
drivers, err := s.query.ListDrivers(sessionKey)
if err == nil && len(drivers) > 0 {
+ markLocalResponse(w, false)
writeJSON(w, drivers)
return
}
@@ -252,11 +264,13 @@ func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) {
}
}
- drivers, err := s.client.GetDriversForSession(sessionKey)
+ client := s.client.Scoped()
+ drivers, err := client.GetDriversForSession(sessionKey)
if err != nil {
- writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
+ markOpenF1Response(w, client)
writeJSON(w, drivers)
}
@@ -279,6 +293,7 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
switch parseSourceMode(r) {
case sourceLocal:
+ markLocalResponse(w, false)
if !s.hasLocalQuery() {
writeJSON(w, []resultWithDriver{})
return
@@ -294,6 +309,7 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
if s.hasLocalQuery() {
results, err := s.query.ListResults(sessionKey)
if err == nil && len(results) > 0 {
+ markLocalResponse(w, false)
writeJSON(w, enrichedResultsToAPI(results))
return
}
@@ -308,30 +324,42 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
results []models.SessionResult
drivers []models.Driver
resultsErr error
+ driversErr error
wg sync.WaitGroup
)
+ client := s.client.Scoped()
wg.Add(2)
- go func() { defer wg.Done(); results, resultsErr = s.client.GetSessionResult(sessionKey) }()
- go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }()
+ go func() { defer wg.Done(); results, resultsErr = client.GetSessionResult(sessionKey) }()
+ go func() { defer wg.Done(); drivers, driversErr = client.GetDriversForSession(sessionKey) }()
wg.Wait()
if resultsErr != nil {
- writeError(w, resultsErr, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ writeError(w, resultsErr, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
driverMap := buildDriverMap(drivers)
+ incomplete := driversErr != nil
enriched := make([]resultWithDriver, 0, len(results))
for _, res := range results {
e := resultWithDriver{SessionResult: res}
- if d, ok := driverMap[res.DriverNumber]; ok {
+ if d, ok := driverMap[res.DriverNumber]; ok && hasDriverPresentation(d) {
e.NameAcronym = d.NameAcronym
e.FullName = d.FullName
e.TeamName = d.TeamName
e.TeamColour = d.TeamColour
+ } else {
+ incomplete = true
}
enriched = append(enriched, e)
}
+ resultsFreshness := "fresh"
+ if len(results) == 0 {
+ resultsFreshness = "limited"
+ } else if incomplete {
+ resultsFreshness = "partial"
+ }
+ markOpenF1Availability(w, client, resultsFreshness)
writeJSON(w, enriched)
}
@@ -354,6 +382,7 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
switch parseSourceMode(r) {
case sourceLocal:
+ markLocalResponse(w, false)
if !s.hasLocalQuery() {
writeJSON(w, []gridWithDriver{})
return
@@ -369,6 +398,7 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
if s.hasLocalQuery() {
grid, err := s.query.ListStartingGrid(sessionKey)
if err == nil && len(grid) > 0 {
+ markLocalResponse(w, false)
writeJSON(w, enrichedGridToAPI(grid))
return
}
@@ -380,33 +410,45 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
}
var (
- grid []models.StartingGrid
- drivers []models.Driver
- gridErr error
- wg sync.WaitGroup
+ grid []models.StartingGrid
+ drivers []models.Driver
+ gridErr error
+ driversErr error
+ wg sync.WaitGroup
)
+ client := s.client.Scoped()
wg.Add(2)
- go func() { defer wg.Done(); grid, gridErr = s.client.GetStartingGrid(sessionKey) }()
- go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }()
+ go func() { defer wg.Done(); grid, gridErr = client.GetStartingGrid(sessionKey) }()
+ go func() { defer wg.Done(); drivers, driversErr = client.GetDriversForSession(sessionKey) }()
wg.Wait()
if gridErr != nil {
- writeError(w, gridErr, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ writeError(w, gridErr, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
driverMap := buildDriverMap(drivers)
+ incomplete := driversErr != nil
enriched := make([]gridWithDriver, 0, len(grid))
for _, g := range grid {
e := gridWithDriver{StartingGrid: g}
- if d, ok := driverMap[g.DriverNumber]; ok {
+ if d, ok := driverMap[g.DriverNumber]; ok && hasDriverPresentation(d) {
e.NameAcronym = d.NameAcronym
e.FullName = d.FullName
e.TeamName = d.TeamName
e.TeamColour = d.TeamColour
+ } else {
+ incomplete = true
}
enriched = append(enriched, e)
}
+ gridFreshness := "fresh"
+ if len(grid) == 0 {
+ gridFreshness = "limited"
+ } else if incomplete {
+ gridFreshness = "partial"
+ }
+ markOpenF1Availability(w, client, gridFreshness)
writeJSON(w, enriched)
}
@@ -419,26 +461,29 @@ func (s *Server) handleLaps(w http.ResponseWriter, r *http.Request) {
return
}
+ client := s.client.Scoped()
if dnStr := r.URL.Query().Get("driver_number"); dnStr != "" {
driverNumber, err := strconv.Atoi(dnStr)
if err != nil || driverNumber == 0 {
http.Error(w, "invalid driver_number", http.StatusBadRequest)
return
}
- laps, err := s.client.GetLapsForDriver(sessionKey, driverNumber)
+ laps, err := client.GetLapsForDriver(sessionKey, driverNumber)
if err != nil {
- writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
+ markOpenF1Response(w, client)
writeJSON(w, laps)
return
}
- laps, err := s.client.GetLapsForSession(sessionKey)
+ laps, err := client.GetLapsForSession(sessionKey)
if err != nil {
- writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
+ markOpenF1Response(w, client)
writeJSON(w, laps)
}
@@ -450,11 +495,13 @@ func (s *Server) handleWeather(w http.ResponseWriter, r *http.Request) {
http.Error(w, "session_key required", http.StatusBadRequest)
return
}
- weather, err := s.client.GetWeather(sessionKey)
+ client := s.client.Scoped()
+ weather, err := client.GetWeather(sessionKey)
if err != nil {
- writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
+ markOpenF1Response(w, client)
writeJSON(w, weather)
}
@@ -466,11 +513,13 @@ func (s *Server) handleRaceControl(w http.ResponseWriter, r *http.Request) {
http.Error(w, "session_key required", http.StatusBadRequest)
return
}
- rc, err := s.client.GetRaceControl(sessionKey)
+ client := s.client.Scoped()
+ rc, err := client.GetRaceControl(sessionKey)
if err != nil {
- writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
+ markOpenF1Response(w, client)
writeJSON(w, rc)
}
@@ -487,11 +536,13 @@ func (s *Server) handleTelemetry(w http.ResponseWriter, r *http.Request) {
http.Error(w, "driver_number required", http.StatusBadRequest)
return
}
- carData, err := s.client.GetCarData(sessionKey, driverNumber)
+ client := s.client.Scoped()
+ carData, err := client.GetCarData(sessionKey, driverNumber)
if err != nil {
- writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
+ markOpenF1Response(w, client)
writeJSON(w, carData)
}
@@ -503,11 +554,13 @@ func (s *Server) handleOvertakes(w http.ResponseWriter, r *http.Request) {
http.Error(w, "session_key required", http.StatusBadRequest)
return
}
- overtakes, err := s.client.GetOvertakesForSession(sessionKey)
+ client := s.client.Scoped()
+ overtakes, err := client.GetOvertakesForSession(sessionKey)
if err != nil {
- writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
+ markOpenF1Response(w, client)
writeJSON(w, overtakes)
}
@@ -524,11 +577,13 @@ func (s *Server) handleTeamRadio(w http.ResponseWriter, r *http.Request) {
http.Error(w, "driver_number required", http.StatusBadRequest)
return
}
- radios, err := s.client.GetTeamRadio(sessionKey, driverNumber)
+ client := s.client.Scoped()
+ radios, err := client.GetTeamRadio(sessionKey, driverNumber)
if err != nil {
- writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
+ markOpenF1Response(w, client)
writeJSON(w, radios)
}
@@ -547,36 +602,42 @@ func (s *Server) handleChampionshipDrivers(w http.ResponseWriter, r *http.Reques
if year == 0 {
year = time.Now().Year()
}
- champ, err := s.client.GetDriverChampionshipForYear(year)
+ client := s.client.Scoped()
+ champ, err := client.GetDriverChampionshipForYear(year)
if err != nil {
- writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
if len(champ) == 0 {
+ markOpenF1Availability(w, client, "limited")
writeJSON(w, []any{})
return
}
- drivers, _ := s.client.GetDriversForSession(champ[0].SessionKey)
+ drivers, driversErr := client.GetDriversForSession(champ[0].SessionKey)
driverMap := buildDriverMapFirst(drivers)
+ incomplete := driversErr != nil
enriched := make([]champDriverWithInfo, 0, len(champ))
for _, c := range champ {
e := champDriverWithInfo{ChampionshipDriver: c}
- d, ok := s.championshipDriverInfo(c.SessionKey, c.DriverNumber, driverMap)
- if ok {
+ d, ok := championshipDriverInfo(client, c.SessionKey, c.DriverNumber, driverMap)
+ if ok && hasDriverPresentation(d) {
e.NameAcronym = d.NameAcronym
e.FullName = d.FullName
e.TeamName = d.TeamName
e.TeamColour = d.TeamColour
+ } else {
+ incomplete = true
}
enriched = append(enriched, e)
}
+ markOpenF1AggregateResponse(w, client, incomplete)
writeJSON(w, enriched)
}
-func (s *Server) championshipDriverInfo(sessionKey, driverNumber int, fallback map[int]models.Driver) (models.Driver, bool) {
- if d, err := s.client.GetDriver(sessionKey, driverNumber); err == nil && d != nil {
+func championshipDriverInfo(client *api.OpenF1Client, sessionKey, driverNumber int, fallback map[int]models.Driver) (models.Driver, bool) {
+ if d, err := client.GetDriver(sessionKey, driverNumber); err == nil && d != nil {
return *d, true
}
d, ok := fallback[driverNumber]
@@ -590,11 +651,17 @@ func (s *Server) handleChampionshipTeams(w http.ResponseWriter, r *http.Request)
if year == 0 {
year = time.Now().Year()
}
- teams, err := s.client.GetTeamChampionshipForYear(year)
+ client := s.client.Scoped()
+ teams, err := client.GetTeamChampionshipForYear(year)
if err != nil {
- writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
+ if len(teams) == 0 {
+ markOpenF1Availability(w, client, "limited")
+ } else {
+ markOpenF1Response(w, client)
+ }
writeJSON(w, teams)
}
@@ -671,8 +738,10 @@ func champHubTTL(year int, now time.Time) time.Duration {
}
type champHubEntry struct {
- resp champHubResponse
- expires time.Time
+ resp champHubResponse
+ source string
+ freshness string
+ expires time.Time
}
// champHubCache is an in-memory cache of aggregated hub responses keyed by
@@ -693,12 +762,26 @@ func (c *champHubCache) get(year int, now time.Time) (champHubResponse, bool) {
}
func (c *champHubCache) put(year int, resp champHubResponse, now time.Time, ttl time.Duration) {
+ c.putWithMetadata(year, resp, "local", "local", now, ttl)
+}
+
+func (c *champHubCache) getWithMetadata(year int, now time.Time) (champHubResponse, string, string, bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ e, ok := c.entries[year]
+ if !ok || now.After(e.expires) {
+ return champHubResponse{}, "", "", false
+ }
+ return e.resp, e.source, e.freshness, true
+}
+
+func (c *champHubCache) putWithMetadata(year int, resp champHubResponse, source, freshness string, now time.Time, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
if c.entries == nil {
c.entries = map[int]champHubEntry{}
}
- c.entries[year] = champHubEntry{resp: resp, expires: now.Add(ttl)}
+ c.entries[year] = champHubEntry{resp: resp, source: source, freshness: freshness, expires: now.Add(ttl)}
}
// fetchMeetingRaces fans fetch out across meetings with bounded concurrency.
@@ -740,13 +823,6 @@ func (s *Server) handleChampionshipHub(w http.ResponseWriter, r *http.Request) {
}
mode := parseSourceMode(r)
- if mode != sourceLocal {
- if resp, ok := s.hubCache.get(year, time.Now()); ok {
- writeJSON(w, resp)
- return
- }
- }
-
if mode == sourceLocal || mode == sourceAuto {
resp, ok, err := s.localChampionshipHub(year)
if err != nil {
@@ -754,21 +830,33 @@ func (s *Server) handleChampionshipHub(w http.ResponseWriter, r *http.Request) {
return
}
if ok {
- s.hubCache.put(year, resp, time.Now(), champHubTTL(year, time.Now()))
+ s.hubCache.putWithMetadata(year, resp, "local", "local", time.Now(), champHubTTL(year, time.Now()))
+ markLocalResponse(w, false)
writeJSON(w, resp)
return
}
if mode == sourceLocal {
+ markDataResponse(w, "none", "limited")
writeJSON(w, resp)
return
}
}
- resp, err := s.openF1ChampionshipHub(year)
- if err != nil {
- writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ // At this point an auto request has no usable domain aggregate and an
+ // explicit OpenF1 request must not be satisfied by a local cache entry.
+ if resp, source, freshness, ok := s.hubCache.getWithMetadata(year, time.Now()); ok && source == "openf1" {
+ markDataResponse(w, source, freshness)
+ writeJSON(w, resp)
return
}
+
+ client := s.client.Scoped()
+ resp, incomplete, err := s.openF1ChampionshipHub(client, year)
+ if err != nil {
+ writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
+ return
+ }
+ markOpenF1AggregateResponse(w, client, incomplete)
writeJSON(w, resp)
}
@@ -797,25 +885,35 @@ func (s *Server) localChampionshipHub(year int) (champHubResponse, bool, error)
return aggregateChampionshipHub(year, races, inputs.Champ, inputs.Teams, inputs.DriverMap), true, nil
}
-func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
- champ, err := s.client.GetDriverChampionshipForYear(year)
+func (s *Server) openF1ChampionshipHub(client *api.OpenF1Client, year int) (champHubResponse, bool, error) {
+ champ, err := client.GetDriverChampionshipForYear(year)
if err != nil {
- return champHubResponse{}, err
+ return champHubResponse{}, false, err
}
if len(champ) == 0 {
- return champHubResponse{Season: year, RoundLabels: []string{}, Drivers: []champHubDriver{}, Teams: []champHubTeam{}}, nil
+ return champHubResponse{Season: year, RoundLabels: []string{}, Drivers: []champHubDriver{}, Teams: []champHubTeam{}}, true, nil
}
- teams, _ := s.client.GetTeamChampionshipForYear(year)
+ teams, teamsErr := client.GetTeamChampionshipForYear(year)
driverInfo := map[int]models.Driver{}
- if ds, derr := s.client.GetDriversForSession(champ[0].SessionKey); derr == nil {
+ driversIncomplete := false
+ if ds, derr := client.GetDriversForSession(champ[0].SessionKey); derr == nil {
driverInfo = buildDriverMapFirst(ds)
+ } else {
+ driversIncomplete = true
+ }
+ for _, standing := range champ {
+ if !hasDriverPresentation(driverInfo[standing.DriverNumber]) {
+ driversIncomplete = true
+ break
+ }
}
- races, incomplete, err := s.fetchSeasonRaces(year)
+ races, incomplete, err := fetchSeasonRaces(client, year)
if err != nil {
- return champHubResponse{}, err
+ return champHubResponse{}, false, err
}
+ incomplete = incomplete || teamsErr != nil || driversIncomplete
resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo)
ttl := champHubTTL(year, time.Now())
@@ -825,16 +923,22 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
// so a partial view of the season doesn't stick around for the full TTL.
ttl = champHubIncompleteTTL
}
- s.hubCache.put(year, resp, time.Now(), ttl)
- return resp, nil
+ freshness := "fresh"
+ if client.LastResponseWasStale() {
+ freshness = "stale"
+ } else if incomplete {
+ freshness = "partial"
+ }
+ s.hubCache.putWithMetadata(year, resp, "openf1", freshness, time.Now(), ttl)
+ return resp, incomplete, nil
}
// fetchSeasonRaces returns a season's GP meetings in date order, each bundled
// with its race results and starting grid fetched from OpenF1. incomplete
// reports whether any per-meeting fetch failed, so callers can avoid caching a
// partial view of the season for long.
-func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete bool, err error) {
- meetings, err := s.client.GetMeetingsForYear(year)
+func fetchSeasonRaces(client *api.OpenF1Client, year int) (races []meetingRace, incomplete bool, err error) {
+ meetings, err := client.GetMeetingsForYear(year)
if err != nil {
return nil, false, err
}
@@ -842,7 +946,7 @@ func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete boo
var failed atomic.Bool
races = fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) {
- sessions, serr := s.client.GetSessionsForMeeting(int(m.MeetingKey))
+ sessions, serr := client.GetSessionsForMeeting(int(m.MeetingKey))
if serr != nil {
failed.Store(true)
return meetingRace{}, false
@@ -855,10 +959,17 @@ func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete boo
}
}
if raceKey == 0 {
- return meetingRace{}, false // not a GP meeting (e.g. pre-season testing)
+ if isKnownNonChampionshipMeeting(m, sessions) {
+ return meetingRace{}, false
+ }
+ failed.Store(true)
+ // The meeting list does not identify non-championship events. Skipping
+ // a meeting without a Race may be expected (testing), but the aggregate
+ // is not proven complete and must be labelled partial.
+ return meetingRace{}, false
}
- results, rerr := s.client.GetSessionResult(raceKey)
- grid, gerr := s.client.GetStartingGrid(raceKey)
+ results, rerr := client.GetSessionResult(raceKey)
+ grid, gerr := client.GetStartingGrid(raceKey)
if rerr != nil || gerr != nil {
failed.Store(true)
}
@@ -867,6 +978,29 @@ func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete boo
return races, failed.Load(), nil
}
+func isKnownNonChampionshipMeeting(meeting models.Meeting, sessions []models.Session) bool {
+ if hasTestingToken(meeting.MeetingName + " " + meeting.MeetingOfficialName) {
+ return true
+ }
+ for _, session := range sessions {
+ if hasTestingToken(session.SessionName + " " + session.SessionType) {
+ return true
+ }
+ }
+ return false
+}
+
+func hasTestingToken(value string) bool {
+ for _, token := range strings.FieldsFunc(strings.ToLower(value), func(r rune) bool {
+ return (r < 'a' || r > 'z') && (r < '0' || r > '9')
+ }) {
+ if token == "test" || token == "tests" || token == "testing" {
+ return true
+ }
+ }
+ return false
+}
+
// aggregateChampionshipHub is the pure aggregation core (no network) so it can be
// unit-tested with synthetic data. races must be ordered ascending by date and
// contain only GP meetings (those with a Race session).
@@ -1077,6 +1211,7 @@ type trackOutlineResponse struct {
}
func (s *Server) handleTrackOutline(w http.ResponseWriter, r *http.Request) {
+ markLocalResponse(w, false)
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
if year == 0 {
year = time.Now().Year()
@@ -1273,22 +1408,25 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
}
var (
- stints []models.Stint
- pits []models.Pit
- results []models.SessionResult
- drivers []models.Driver
- rc []models.RaceControl
- stintsErr error
- pitsErr error
- resErr error
- wg sync.WaitGroup
+ stints []models.Stint
+ pits []models.Pit
+ results []models.SessionResult
+ drivers []models.Driver
+ rc []models.RaceControl
+ stintsErr error
+ pitsErr error
+ resErr error
+ driversErr error
+ rcErr error
+ wg sync.WaitGroup
)
+ client := s.client.Scoped()
wg.Add(5)
- go func() { defer wg.Done(); stints, stintsErr = s.client.GetStintsForSession(sessionKey) }()
- go func() { defer wg.Done(); pits, pitsErr = s.client.GetPitStopsForSession(sessionKey) }()
- go func() { defer wg.Done(); results, resErr = s.client.GetSessionResult(sessionKey) }()
- go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }()
- go func() { defer wg.Done(); rc, _ = s.client.GetRaceControl(sessionKey) }()
+ go func() { defer wg.Done(); stints, stintsErr = client.GetStintsForSession(sessionKey) }()
+ go func() { defer wg.Done(); pits, pitsErr = client.GetPitStopsForSession(sessionKey) }()
+ go func() { defer wg.Done(); results, resErr = client.GetSessionResult(sessionKey) }()
+ go func() { defer wg.Done(); drivers, driversErr = client.GetDriversForSession(sessionKey) }()
+ go func() { defer wg.Done(); rc, rcErr = client.GetRaceControl(sessionKey) }()
wg.Wait()
if stintsErr != nil || pitsErr != nil || resErr != nil {
@@ -1299,17 +1437,21 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
if e == nil {
e = resErr
}
- writeError(w, e, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ writeError(w, e, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
- // Non-race sessions have no stints.
+ // Empty strategy data may mean a non-race session or a race still settling.
if len(stints) == 0 {
+ // Without session-type evidence, an empty primary strategy dataset is
+ // not enough to prove "not applicable" (it may still be settling).
+ markOpenF1Availability(w, client, "limited")
writeJSON(w, map[string]any{"note": "Not applicable", "drivers": []any{}})
return
}
driverMap := buildDriverMap(drivers)
+ incomplete := driversErr != nil || rcErr != nil
resultMap := make(map[int]models.SessionResult, len(results))
totalLaps := 0
@@ -1342,6 +1484,9 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
stratDrivers := make([]strategyDriver, 0, len(seenDrivers))
for dn := range seenDrivers {
d := driverMap[dn]
+ if !hasDriverPresentation(d) {
+ incomplete = true
+ }
res := resultMap[dn]
sd := strategyDriver{
@@ -1396,6 +1541,7 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
return pi < pj
})
+ markOpenF1AggregateResponse(w, client, incomplete)
writeJSON(w, strategyResponse{
SessionKey: sessionKey,
TotalLaps: totalLaps,
@@ -1491,21 +1637,31 @@ func (s *Server) handleLapsComparison(w http.ResponseWriter, r *http.Request) {
}
var (
- allLaps []models.Lap
- stints []models.Stint
- pits []models.Pit
- rc []models.RaceControl
- wg sync.WaitGroup
+ allLaps []models.Lap
+ stints []models.Stint
+ pits []models.Pit
+ rc []models.RaceControl
+ lapsErr error
+ stintsErr error
+ pitsErr error
+ rcErr error
+ wg sync.WaitGroup
)
+ client := s.client.Scoped()
wg.Add(4)
- go func() { defer wg.Done(); allLaps, _ = s.client.GetLapsForSession(sessionKey) }()
- go func() { defer wg.Done(); stints, _ = s.client.GetStintsForSession(sessionKey) }()
- go func() { defer wg.Done(); pits, _ = s.client.GetPitStopsForSession(sessionKey) }()
- go func() { defer wg.Done(); rc, _ = s.client.GetRaceControl(sessionKey) }()
+ go func() { defer wg.Done(); allLaps, lapsErr = client.GetLapsForSession(sessionKey) }()
+ go func() { defer wg.Done(); stints, stintsErr = client.GetStintsForSession(sessionKey) }()
+ go func() { defer wg.Done(); pits, pitsErr = client.GetPitStopsForSession(sessionKey) }()
+ go func() { defer wg.Done(); rc, rcErr = client.GetRaceControl(sessionKey) }()
wg.Wait()
+ if lapsErr != nil {
+ writeError(w, lapsErr, http.StatusInternalServerError, client.LastResponseWasStale())
+ return
+ }
- allDrivers, _ := s.client.GetDriversForSession(sessionKey)
+ allDrivers, driversErr := client.GetDriversForSession(sessionKey)
driverMap := buildDriverMap(allDrivers)
+ incomplete := stintsErr != nil || pitsErr != nil || rcErr != nil || driversErr != nil
// If no filter, default to first 3 unique driver numbers from lap data.
if len(requestedDrivers) == 0 {
@@ -1543,6 +1699,9 @@ func (s *Server) handleLapsComparison(w http.ResponseWriter, r *http.Request) {
compDrivers := make([]comparisonDriver, 0, len(requestedDrivers))
for _, dn := range requestedDrivers {
d := driverMap[dn]
+ if !hasDriverPresentation(d) {
+ incomplete = true
+ }
cd := comparisonDriver{
DriverNumber: dn,
NameAcronym: d.NameAcronym,
@@ -1558,6 +1717,13 @@ func (s *Server) handleLapsComparison(w http.ResponseWriter, r *http.Request) {
compDrivers = append(compDrivers, cd)
}
+ freshness := "fresh"
+ if len(allLaps) == 0 {
+ freshness = "limited"
+ } else if incomplete {
+ freshness = "partial"
+ }
+ markOpenF1Availability(w, client, freshness)
writeJSON(w, lapsComparisonResponse{
SessionKey: sessionKey,
SCPeriods: extractSCPeriods(rc),
diff --git a/internal/web/championship_hub_test.go b/internal/web/championship_hub_test.go
index dc005b0..05d63fd 100644
--- a/internal/web/championship_hub_test.go
+++ b/internal/web/championship_hub_test.go
@@ -1,13 +1,136 @@
package web
import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
"sync/atomic"
"testing"
"time"
+ "github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models"
)
+func championshipTestUpstream(t *testing.T, driversOK, meetingHasRace bool) *httptest.Server {
+ t.Helper()
+ completed := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/v1/sessions":
+ if r.URL.Query().Get("session_name") == "Race" {
+ _, _ = fmt.Fprintf(w, `[{"session_key":99,"session_name":"Race","date_end":%q}]`, completed)
+ return
+ }
+ if meetingHasRace {
+ _, _ = w.Write([]byte(`[{"session_key":101,"meeting_key":1,"session_name":"Race"}]`))
+ } else {
+ _, _ = w.Write([]byte(`[{"session_key":100,"meeting_key":1,"session_name":"Practice 1"}]`))
+ }
+ case "/v1/championship_drivers":
+ _, _ = w.Write([]byte(`[{"driver_number":1,"session_key":99,"position_current":1,"points_current":25}]`))
+ case "/v1/championship_teams":
+ _, _ = w.Write([]byte(`[{"team_name":"Red Bull","position_current":1,"points_current":25}]`))
+ case "/v1/drivers":
+ if !driversOK {
+ http.Error(w, "identity unavailable", http.StatusBadGateway)
+ return
+ }
+ _, _ = w.Write([]byte(`[{"driver_number":1,"name_acronym":"VER","full_name":"Max Verstappen","team_name":"Red Bull","team_colour":"3671c6"}]`))
+ case "/v1/meetings":
+ _, _ = w.Write([]byte(`[{"meeting_key":1,"meeting_name":"Mystery Grand Prix"}]`))
+ case "/v1/session_result":
+ _, _ = w.Write([]byte(`[{"driver_number":1,"position":1,"points":25}]`))
+ case "/v1/starting_grid":
+ _, _ = w.Write([]byte(`[{"driver_number":1,"position":1}]`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+}
+
+func TestOpenF1ChampionshipHubIdentityFailureIsPartialAndCached(t *testing.T) {
+ upstream := championshipTestUpstream(t, false, true)
+ defer upstream.Close()
+ client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
+ defer client.Close()
+ server := NewServer(client, 0, nil)
+ year := time.Now().Year()
+
+ _, incomplete, err := server.openF1ChampionshipHub(client.Scoped(), year)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !incomplete {
+ t.Fatal("missing championship driver identity was labelled complete")
+ }
+ _, source, freshness, ok := server.hubCache.getWithMetadata(year, time.Now())
+ if !ok || source != "openf1" || freshness != "partial" {
+ t.Fatalf("cached metadata = hit %v, %q/%q", ok, source, freshness)
+ }
+}
+
+func TestFetchSeasonRacesMeetingWithoutRaceIsIncomplete(t *testing.T) {
+ upstream := championshipTestUpstream(t, true, false)
+ defer upstream.Close()
+ client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
+ defer client.Close()
+
+ races, incomplete, err := fetchSeasonRaces(client.Scoped(), time.Now().Year())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !incomplete || len(races) != 0 {
+ t.Fatalf("no-Race meeting = races %d, incomplete %v", len(races), incomplete)
+ }
+}
+
+func TestFetchSeasonRacesRecognizedTestingMeetingIsNotIncomplete(t *testing.T) {
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/v1/meetings":
+ _, _ = w.Write([]byte(`[{"meeting_key":1253,"meeting_name":"Pre-Season Testing"}]`))
+ case "/v1/sessions":
+ _, _ = w.Write([]byte(`[{"session_key":1,"meeting_key":1253,"session_name":"Day 1","session_type":"Testing"}]`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer upstream.Close()
+ client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
+ defer client.Close()
+
+ races, incomplete, err := fetchSeasonRaces(client.Scoped(), time.Now().Year())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if incomplete || len(races) != 0 {
+ t.Fatalf("recognized testing meeting = races %d, incomplete %v", len(races), incomplete)
+ }
+}
+
+func TestKnownNonChampionshipMeetingRequiresTestingToken(t *testing.T) {
+ if !isKnownNonChampionshipMeeting(models.Meeting{MeetingName: "Pre-Season Testing"}, nil) {
+ t.Fatal("pre-season testing was not recognized")
+ }
+ if isKnownNonChampionshipMeeting(models.Meeting{MeetingName: "Fastest Grand Prix"}, nil) {
+ t.Fatal("substring inside a normal word was treated as testing")
+ }
+ if !isKnownNonChampionshipMeeting(models.Meeting{MeetingName: "Winter Event"}, []models.Session{{SessionType: "Test"}}) {
+ t.Fatal("explicit Test session was not recognized")
+ }
+}
+
+func TestHandleChampionshipHubSourceLocalWithoutAggregateIsLimited(t *testing.T) {
+ server := NewServer(nil, 0, nil)
+ recorder := httptest.NewRecorder()
+ server.handleChampionshipHub(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/championship/hub?year=2026&source=local", nil))
+ if recorder.Code != http.StatusOK || recorder.Header().Get(dataSourceHeader) != "none" || recorder.Header().Get(dataFreshnessHeader) != "limited" {
+ t.Fatalf("empty local championship = %d %q/%q body=%s", recorder.Code, recorder.Header().Get(dataSourceHeader), recorder.Header().Get(dataFreshnessHeader), recorder.Body.String())
+ }
+}
+
func raceResult(num, pos int, pts float64) models.SessionResult {
return models.SessionResult{DriverNumber: num, Position: pos, Points: pts}
}
diff --git a/internal/web/component_freshness_test.go b/internal/web/component_freshness_test.go
new file mode 100644
index 0000000..f6818b3
--- /dev/null
+++ b/internal/web/component_freshness_test.go
@@ -0,0 +1,120 @@
+package web
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/AmanTahiliani/box-box/internal/api"
+)
+
+func componentTestServer(t *testing.T, responses map[string]string, failures map[string]bool) *Server {
+ t.Helper()
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if failures[r.URL.Path] {
+ http.Error(w, "component unavailable", http.StatusBadGateway)
+ return
+ }
+ body, ok := responses[r.URL.Path]
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(body))
+ }))
+ t.Cleanup(upstream.Close)
+ client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
+ t.Cleanup(func() { _ = client.Close() })
+ return NewServer(client, 0, nil)
+}
+
+func assertAvailabilityHeaders(t *testing.T, recorder *httptest.ResponseRecorder, source, freshness string) {
+ t.Helper()
+ if recorder.Code != http.StatusOK || recorder.Header().Get(dataSourceHeader) != source || recorder.Header().Get(dataFreshnessHeader) != freshness {
+ t.Fatalf("response = status %d, metadata %q/%q, body=%s", recorder.Code, recorder.Header().Get(dataSourceHeader), recorder.Header().Get(dataFreshnessHeader), recorder.Body.String())
+ }
+}
+
+func TestResultsAndGridIdentityFailuresReportPartial(t *testing.T) {
+ tests := []struct {
+ name string
+ path string
+ body string
+ run func(*Server, http.ResponseWriter, *http.Request)
+ }{
+ {name: "results", path: "/v1/session_result", body: `[{"driver_number":1,"position":1}]`, run: func(s *Server, w http.ResponseWriter, r *http.Request) { s.handleResults(w, r) }},
+ {name: "grid", path: "/v1/starting_grid", body: `[{"driver_number":1,"position":1}]`, run: func(s *Server, w http.ResponseWriter, r *http.Request) { s.handleGrid(w, r) }},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := componentTestServer(t, map[string]string{tt.path: tt.body}, map[string]bool{"/v1/drivers": true})
+ recorder := httptest.NewRecorder()
+ tt.run(server, recorder, httptest.NewRequest(http.MethodGet, "/api/v1/"+tt.name+"?session_key=99&source=openf1", nil))
+ assertAvailabilityHeaders(t, recorder, "openf1", "partial")
+ })
+ }
+}
+
+func TestStrategyOptionalComponentFailureReportsPartial(t *testing.T) {
+ server := componentTestServer(t, map[string]string{
+ "/v1/stints": `[{"driver_number":1,"stint_number":1,"lap_start":1,"lap_end":10,"compound":"MEDIUM"}]`,
+ "/v1/pit": `[]`,
+ "/v1/session_result": `[{"driver_number":1,"position":1,"number_of_laps":10}]`,
+ "/v1/race_control": `[]`,
+ }, map[string]bool{"/v1/drivers": true})
+ recorder := httptest.NewRecorder()
+ server.handleStrategy(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/strategy?session_key=99", nil))
+ assertAvailabilityHeaders(t, recorder, "openf1", "partial")
+}
+
+func TestStrategyEmptyPrimaryDataReportsLimited(t *testing.T) {
+ server := componentTestServer(t, map[string]string{
+ "/v1/stints": `[]`,
+ "/v1/pit": `[]`,
+ "/v1/session_result": `[{"driver_number":1,"position":1,"number_of_laps":10}]`,
+ "/v1/drivers": `[{"driver_number":1,"full_name":"Max Verstappen","team_name":"Red Bull","team_colour":"3671c6"}]`,
+ "/v1/race_control": `[]`,
+ }, nil)
+ recorder := httptest.NewRecorder()
+ server.handleStrategy(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/strategy?session_key=99", nil))
+ assertAvailabilityHeaders(t, recorder, "openf1", "limited")
+}
+
+func TestLapsComparisonDoesNotLabelMissingComponentsFresh(t *testing.T) {
+ tests := []struct {
+ name string
+ laps string
+ freshness string
+ }{
+ {name: "empty primary data", laps: `[]`, freshness: "limited"},
+ {name: "optional components failed", laps: `[{"driver_number":1,"lap_number":1}]`, freshness: "partial"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := componentTestServer(t, map[string]string{"/v1/laps": tt.laps}, map[string]bool{
+ "/v1/stints": true,
+ "/v1/pit": true,
+ "/v1/race_control": true,
+ "/v1/drivers": true,
+ })
+ recorder := httptest.NewRecorder()
+ server.handleLapsComparison(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/laps/comparison?session_key=99", nil))
+ assertAvailabilityHeaders(t, recorder, "openf1", tt.freshness)
+ })
+ }
+ t.Run("primary laps failure is an error", func(t *testing.T) {
+ server := componentTestServer(t, nil, map[string]bool{
+ "/v1/laps": true,
+ "/v1/stints": true,
+ "/v1/pit": true,
+ "/v1/race_control": true,
+ })
+ recorder := httptest.NewRecorder()
+ server.handleLapsComparison(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/laps/comparison?session_key=99", nil))
+ if recorder.Code != http.StatusInternalServerError {
+ t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String())
+ }
+ })
+}
diff --git a/internal/web/context.go b/internal/web/context.go
index ee63deb..d13aa65 100644
--- a/internal/web/context.go
+++ b/internal/web/context.go
@@ -10,6 +10,7 @@ import (
func (s *Server) handleWeekendContext(w http.ResponseWriter, _ *http.Request) {
if !s.hasLocalQuery() {
+ markDataResponse(w, "none", "limited")
writeJSON(w, query.WeekendContext{TemporalState: query.TemporalNoSeason})
return
}
@@ -29,9 +30,33 @@ func (s *Server) handleWeekendContext(w http.ResponseWriter, _ *http.Request) {
writeError(w, err, http.StatusInternalServerError, false)
return
}
+ if focus := focusedContextSession(context); focus != nil {
+ markDataResponse(w, focus.Availability.Source, focus.Availability.Freshness)
+ } else {
+ markDataResponse(w, "none", "limited")
+ }
writeJSON(w, context)
}
+// focusedContextSession selects the session whose state the Weekend shell is
+// presenting. An older terminal/default session must never override an
+// upcoming focus session's metadata.
+func focusedContextSession(context query.WeekendContext) *query.ContextSession {
+ if context.ActiveSession != nil {
+ return context.ActiveSession
+ }
+ if context.FocusMeeting == nil {
+ return nil
+ }
+ focusKey := context.FocusMeeting.MeetingKey
+ for _, ref := range []*query.ContextSession{context.NextSession, context.PreviousCompletedSession, context.DefaultAnalysisSession} {
+ if ref != nil && ref.Meeting != nil && ref.Meeting.MeetingKey == focusKey {
+ return ref
+ }
+ }
+ return nil
+}
+
func liveEvidence(data *live.LiveStreamData, active, final bool) query.LiveEvidence {
return query.LiveEvidence{Active: active, Final: final, MeetingName: data.Session.MeetingName, CircuitName: data.Session.CircuitName, SessionName: data.Session.SessionName, SessionType: data.Session.SessionType}
}
diff --git a/internal/web/context_test.go b/internal/web/context_test.go
index f5afd0c..18dcd79 100644
--- a/internal/web/context_test.go
+++ b/internal/web/context_test.go
@@ -47,6 +47,19 @@ func TestWeekendContextHandlerWithoutStoreReturnsNoSeason(t *testing.T) {
if got.TemporalState != query.TemporalNoSeason {
t.Fatalf("state = %s", got.TemporalState)
}
+ if rr.Header().Get(dataSourceHeader) != "none" || rr.Header().Get(dataFreshnessHeader) != "limited" {
+ t.Fatalf("missing context metadata = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
+ }
+}
+
+func TestWeekendContextHandlerEmptyStoreReportsLimited(t *testing.T) {
+ st := openContextStore(t)
+ s := NewServer(nil, 0, st)
+ rr := httptest.NewRecorder()
+ s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
+ if rr.Code != http.StatusOK || rr.Header().Get(dataSourceHeader) != "none" || rr.Header().Get(dataFreshnessHeader) != "limited" {
+ t.Fatalf("empty context = %d %q/%q body=%s", rr.Code, rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader), rr.Body.String())
+ }
}
func TestWeekendContextHandlerUsesLiveHubIdentityWithoutOpenF1(t *testing.T) {
@@ -76,6 +89,12 @@ func TestWeekendContextHandlerUsesLiveHubIdentityWithoutOpenF1(t *testing.T) {
if got.ActiveSession.Availability.LiveTransport != "connected" || got.ActiveSession.Availability.LiveSession != "active" {
t.Fatalf("availability = %+v", got.ActiveSession.Availability)
}
+ if got.ActiveSession.Availability.Source != "mixed" || got.ActiveSession.Availability.Freshness != "live" {
+ t.Fatalf("live source/freshness = %+v", got.ActiveSession.Availability)
+ }
+ if rr.Header().Get(dataSourceHeader) != "mixed" || rr.Header().Get(dataFreshnessHeader) != "live" {
+ t.Fatalf("response source/freshness = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
+ }
}
func TestWeekendContextHandlerUsesTerminalArchiveAsCompletionEvidence(t *testing.T) {
@@ -94,11 +113,48 @@ func TestWeekendContextHandlerUsesTerminalArchiveAsCompletionEvidence(t *testing
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Availability.Archive != "available" {
t.Fatalf("archive context = %+v", got)
}
+ if got.PreviousCompletedSession.Availability.Source != "mixed" || got.PreviousCompletedSession.Availability.Freshness != "archive" {
+ t.Fatalf("archive source/freshness = %+v", got.PreviousCompletedSession.Availability)
+ }
+ if rr.Header().Get(dataSourceHeader) != "mixed" || rr.Header().Get(dataFreshnessHeader) != "archive" {
+ t.Fatalf("archive response source/freshness = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
+ }
if got.DefaultAnalysisSession != nil {
t.Fatal("archive-only session must not become local default analysis")
}
}
+func TestWeekendContextMetadataFollowsUpcomingFocusNotTerminalPrevious(t *testing.T) {
+ st := openContextStore(t)
+ seedContextHandler(t, st)
+ if err := st.UpsertMeeting(store.Meeting{MeetingKey: 2, MeetingName: "Belgian Grand Prix", CircuitShortName: "Spa", Year: 2026, DateStart: "2026-07-10T09:00:00Z", DateEnd: "2026-07-12T16:00:00Z"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.UpsertSession(store.Session{SessionKey: 21, MeetingKey: 2, SessionName: "Practice 1", SessionType: "Practice", DateStart: "2026-07-10T09:00:00Z", DateEnd: "2026-07-10T10:00:00Z"}); err != nil {
+ t.Fatal(err)
+ }
+ now, _ := time.Parse(time.RFC3339, "2026-07-05T16:05:00Z")
+ s := NewServer(nil, 0, st)
+ s.query = query.NewServiceWithClock(st, func() time.Time { return now })
+ s.hub.applySnapshot(live.LiveStreamData{SessionStatus: "Finished", Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", CircuitName: "Silverstone", SessionName: "Race", SessionType: "Race"}}, now)
+
+ rr := httptest.NewRecorder()
+ s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
+ var got query.WeekendContext
+ if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.FocusMeeting == nil || got.FocusMeeting.MeetingKey != 2 || got.NextSession == nil {
+ t.Fatalf("focus context = %+v", got)
+ }
+ if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Availability.Freshness != "archive" {
+ t.Fatalf("terminal previous missing = %+v", got.PreviousCompletedSession)
+ }
+ if rr.Header().Get(dataSourceHeader) != "local" || rr.Header().Get(dataFreshnessHeader) != "local" {
+ t.Fatalf("focus metadata was overridden by archive = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
+ }
+}
+
func TestTerminalSessionStatus(t *testing.T) {
for _, status := range []string{"Finished", "Finalised", "ENDED", "Aborted"} {
if !terminalSessionStatus(status) {
diff --git a/internal/web/driversummary.go b/internal/web/driversummary.go
index 633e563..1b25c67 100644
--- a/internal/web/driversummary.go
+++ b/internal/web/driversummary.go
@@ -1,11 +1,13 @@
package web
import (
+ "context"
"fmt"
"net/http"
"strconv"
"time"
+ "github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models"
)
@@ -78,6 +80,7 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("source") == "" {
mode = sourceAuto
}
+ client := s.client.Scoped()
if mode == sourceLocal || mode == sourceAuto {
resp, sessionKey, ok, lerr := s.localDriverSummary(year, driverNumber)
@@ -87,7 +90,15 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
}
if ok {
if mode != sourceLocal {
- s.tryEnrichDriverSummary(&resp, sessionKey)
+ tryEnrichDriverSummary(r.Context(), client, &resp, sessionKey)
+ }
+ switch resp.Enrichment {
+ case "full":
+ markMixedResponse(w, client, false)
+ case "limited":
+ markDataResponse(w, "local", "limited")
+ default:
+ markLocalResponse(w, false)
}
writeJSON(w, resp)
return
@@ -98,15 +109,16 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
}
}
- resp, err := s.openF1DriverSummary(year, driverNumber)
+ resp, incomplete, err := openF1DriverSummary(client, year, driverNumber)
if err != nil {
- writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
if resp == nil {
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
return
}
+ markOpenF1AggregateResponse(w, client, incomplete)
writeJSON(w, resp)
}
@@ -152,10 +164,10 @@ func (s *Server) localDriverSummary(year, driverNumber int) (driverSummaryRespon
return resp, sessionKey, true, nil
}
-func (s *Server) openF1DriverSummary(year, driverNumber int) (*driverSummaryResponse, error) {
- champ, err := s.client.GetDriverChampionshipForYear(year)
+func openF1DriverSummary(client *api.OpenF1Client, year, driverNumber int) (*driverSummaryResponse, bool, error) {
+ champ, err := client.GetDriverChampionshipForYear(year)
if err != nil {
- return nil, err
+ return nil, false, err
}
var entry *models.ChampionshipDriver
for i := range champ {
@@ -165,65 +177,65 @@ func (s *Server) openF1DriverSummary(year, driverNumber int) (*driverSummaryResp
}
}
if entry == nil {
- return nil, nil
+ return nil, false, nil
}
driverInfo := map[int]models.Driver{}
sessionKey := champ[0].SessionKey
- if ds, derr := s.client.GetDriversForSession(sessionKey); derr == nil {
+ if ds, derr := client.GetDriversForSession(sessionKey); derr == nil {
driverInfo = buildDriverMapFirst(ds)
}
- if d, ok := s.championshipDriverInfo(entry.SessionKey, driverNumber, driverInfo); ok {
- driverInfo[driverNumber] = d
+ d, directErr := client.GetDriver(entry.SessionKey, driverNumber)
+ if directErr == nil && d != nil {
+ driverInfo[driverNumber] = *d
+ } else if fallback, ok := driverInfo[driverNumber]; ok {
+ driverInfo[driverNumber] = fallback
}
-
- races, _, err := s.fetchSeasonRaces(year)
+ identityIncomplete := !hasDriverPresentation(driverInfo[driverNumber])
+ races, racesIncomplete, err := fetchSeasonRaces(client, year)
if err != nil {
- return nil, err
+ return nil, false, err
}
resp, ok := aggregateDriverSummary(year, driverNumber, races, champ, driverInfo)
if !ok {
- return nil, nil
+ return nil, false, nil
}
+ incomplete := identityIncomplete || racesIncomplete
resp.Source = "openf1"
- resp.Enrichment = "full"
- return &resp, nil
+ if identityIncomplete {
+ resp.Enrichment = "limited"
+ } else {
+ resp.Enrichment = "full"
+ }
+ return &resp, incomplete, nil
+}
+
+func hasDriverPresentation(driver models.Driver) bool {
+ hasName := driver.FullName != "" || driver.NameAcronym != "" || driver.BroadcastName != ""
+ return hasName && driver.TeamName != "" && driver.TeamColour != ""
}
// tryEnrichDriverSummary optionally fills headshot / polished identity from
// OpenF1. It never blocks longer than driverEnrichmentTimeout — on timeout or
// failure the local profile remains intact with enrichment=limited.
-func (s *Server) tryEnrichDriverSummary(resp *driverSummaryResponse, sessionKey int) {
- if resp == nil || s.client == nil || sessionKey <= 0 {
+func tryEnrichDriverSummary(parent context.Context, client *api.OpenF1Client, resp *driverSummaryResponse, sessionKey int) {
+ if resp == nil || client == nil || sessionKey <= 0 {
if resp != nil && resp.Enrichment == "none" {
// No session to enrich from — leave as none (local identity only).
}
return
}
- type enrichResult struct {
- driver models.Driver
- ok bool
- }
-
- done := make(chan enrichResult, 1)
- go func() {
- d, ok := s.championshipDriverInfo(sessionKey, resp.DriverNumber, nil)
- done <- enrichResult{driver: d, ok: ok}
- }()
-
- select {
- case result := <-done:
- if !result.ok {
- resp.Enrichment = "limited"
- return
- }
- applyDriverEnrichment(resp, result.driver)
- resp.Enrichment = "full"
- case <-time.After(driverEnrichmentTimeout):
+ ctx, cancel := context.WithTimeout(parent, driverEnrichmentTimeout)
+ defer cancel()
+ driver, err := client.GetDriverContext(ctx, sessionKey, resp.DriverNumber)
+ if err != nil || driver == nil {
resp.Enrichment = "limited"
+ return
}
+ applyDriverEnrichment(resp, *driver)
+ resp.Enrichment = "full"
}
func applyDriverEnrichment(resp *driverSummaryResponse, d models.Driver) {
diff --git a/internal/web/driversummary_test.go b/internal/web/driversummary_test.go
index 55020b9..97a7037 100644
--- a/internal/web/driversummary_test.go
+++ b/internal/web/driversummary_test.go
@@ -2,6 +2,7 @@ package web
import (
"encoding/json"
+ "fmt"
"net/http"
"net/http/httptest"
"testing"
@@ -212,10 +213,12 @@ func TestHandleDriverSummaryLocalFirstIgnoresHangingEnrichment(t *testing.T) {
// Enrichment seam: OpenF1 hangs until released. Local summary must still return.
release := make(chan struct{})
+ cancelObserved := make(chan struct{})
hang := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-release:
case <-r.Context().Done():
+ close(cancelObserved)
}
}))
t.Cleanup(func() {
@@ -239,6 +242,11 @@ func TestHandleDriverSummaryLocalFirstIgnoresHangingEnrichment(t *testing.T) {
if elapsed > 500*time.Millisecond {
t.Fatalf("handler blocked on enrichment for %v", elapsed)
}
+ select {
+ case <-cancelObserved:
+ case <-time.After(250 * time.Millisecond):
+ t.Fatal("timed-out enrichment did not cancel its upstream request")
+ }
var resp driverSummaryResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
@@ -250,6 +258,9 @@ func TestHandleDriverSummaryLocalFirstIgnoresHangingEnrichment(t *testing.T) {
if resp.Enrichment != "limited" {
t.Errorf("enrichment = %q, want limited", resp.Enrichment)
}
+ if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "limited" {
+ t.Errorf("limited metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
+ }
if resp.DriverNumber != 1 || resp.NameAcronym != "VER" || resp.Points != 25 {
t.Errorf("local identity/results missing: %+v", resp)
}
@@ -289,6 +300,9 @@ func TestHandleDriverSummaryLocalFirstWithFailingEnrichment(t *testing.T) {
if resp.Enrichment != "limited" {
t.Errorf("enrichment = %q, want limited", resp.Enrichment)
}
+ if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "limited" {
+ t.Errorf("limited metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
+ }
if resp.FullName != "Max Verstappen" {
t.Errorf("full_name = %q, want local identity", resp.FullName)
}
@@ -313,4 +327,46 @@ func TestHandleDriverSummarySourceLocalOnly(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
+ if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "local" {
+ t.Fatalf("local metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
+ }
+}
+
+func TestHandleRemoteDriverSummaryReportsIdentityAndRoundLimitations(t *testing.T) {
+ tests := []struct {
+ name string
+ driversOK bool
+ meetingHasRace bool
+ wantEnrichment string
+ }{
+ {name: "missing identity", driversOK: false, meetingHasRace: true, wantEnrichment: "limited"},
+ {name: "missing race round", driversOK: true, meetingHasRace: false, wantEnrichment: "full"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ upstream := championshipTestUpstream(t, tt.driversOK, tt.meetingHasRace)
+ defer upstream.Close()
+ client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
+ defer client.Close()
+ server := NewServer(client, 0, nil)
+ year := time.Now().Year()
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/driver/summary?year=%d&driver_number=1&source=openf1", year), nil)
+
+ server.handleDriverSummary(recorder, request)
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String())
+ }
+ var response driverSummaryResponse
+ if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
+ t.Fatal(err)
+ }
+ if response.Enrichment != tt.wantEnrichment {
+ t.Fatalf("enrichment = %q, want %q", response.Enrichment, tt.wantEnrichment)
+ }
+ if recorder.Header().Get(dataSourceHeader) != "openf1" || recorder.Header().Get(dataFreshnessHeader) != "partial" {
+ t.Fatalf("remote limitation metadata = %q/%q", recorder.Header().Get(dataSourceHeader), recorder.Header().Get(dataFreshnessHeader))
+ }
+ })
+ }
}
diff --git a/internal/web/freshness.go b/internal/web/freshness.go
new file mode 100644
index 0000000..f6d9530
--- /dev/null
+++ b/internal/web/freshness.go
@@ -0,0 +1,65 @@
+package web
+
+import (
+ "net/http"
+)
+
+const (
+ dataSourceHeader = "X-BoxBox-Data-Source"
+ dataFreshnessHeader = "X-BoxBox-Data-Freshness"
+)
+
+type staleResponseReporter interface {
+ LastResponseWasStale() bool
+}
+
+// markOpenF1Response publishes request-scoped success provenance. Callers must
+// pass the scoped client used for this response, never Server.client.
+func markOpenF1Response(w http.ResponseWriter, client staleResponseReporter) {
+ markOpenF1AggregateResponse(w, client, false)
+}
+
+func markOpenF1AggregateResponse(w http.ResponseWriter, client staleResponseReporter, partial bool) {
+ freshness := "fresh"
+ if partial {
+ freshness = "partial"
+ }
+ markOpenF1Availability(w, client, freshness)
+}
+
+func markOpenF1Availability(w http.ResponseWriter, client staleResponseReporter, freshness string) {
+ w.Header().Set(dataSourceHeader, "openf1")
+ if client != nil && client.LastResponseWasStale() {
+ w.Header().Set(dataFreshnessHeader, "stale")
+ return
+ }
+ if freshness == "" {
+ freshness = "fresh"
+ }
+ w.Header().Set(dataFreshnessHeader, freshness)
+}
+
+func markDataResponse(w http.ResponseWriter, source, freshness string) {
+ w.Header().Set(dataSourceHeader, source)
+ w.Header().Set(dataFreshnessHeader, freshness)
+}
+
+func markLocalResponse(w http.ResponseWriter, partial bool) {
+ w.Header().Set(dataSourceHeader, "local")
+ if partial {
+ w.Header().Set(dataFreshnessHeader, "partial")
+ return
+ }
+ w.Header().Set(dataFreshnessHeader, "local")
+}
+
+func markMixedResponse(w http.ResponseWriter, client staleResponseReporter, partial bool) {
+ w.Header().Set(dataSourceHeader, "mixed")
+ if client != nil && client.LastResponseWasStale() {
+ w.Header().Set(dataFreshnessHeader, "stale")
+ } else if partial {
+ w.Header().Set(dataFreshnessHeader, "partial")
+ } else {
+ w.Header().Set(dataFreshnessHeader, "local")
+ }
+}
diff --git a/internal/web/freshness_test.go b/internal/web/freshness_test.go
new file mode 100644
index 0000000..bb63870
--- /dev/null
+++ b/internal/web/freshness_test.go
@@ -0,0 +1,86 @@
+package web
+
+import (
+ "database/sql"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/AmanTahiliani/box-box/internal/api"
+ _ "modernc.org/sqlite"
+)
+
+type fakeStaleReporter bool
+
+func (f fakeStaleReporter) LastResponseWasStale() bool { return bool(f) }
+
+func TestStaleFreshnessTakesPrecedenceOverPartialAndLimited(t *testing.T) {
+ for _, fallback := range []string{"partial", "limited"} {
+ recorder := httptest.NewRecorder()
+ markOpenF1Availability(recorder, fakeStaleReporter(true), fallback)
+ if recorder.Header().Get(dataFreshnessHeader) != "stale" {
+ t.Fatalf("fallback %q overrode stale: %q", fallback, recorder.Header().Get(dataFreshnessHeader))
+ }
+ }
+}
+
+func TestOpenF1HandlerReportsFreshThenStaleSuccess(t *testing.T) {
+ year := time.Now().Year()
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = fmt.Fprintf(w, `[{"meeting_key":1,"meeting_name":"British Grand Prix","year":%d}]`, year)
+ }))
+ client := api.NewOpenF1Client(upstream.URL, time.Second)
+ t.Cleanup(func() { _ = client.Close() })
+ server := NewServer(client, 0, nil)
+ cacheKey := fmt.Sprintf("%s/v1/meetings?year=%d", upstream.URL, year)
+ requestURL := fmt.Sprintf("/api/v1/meetings?year=%d&source=openf1", year)
+ defer func() {
+ // Keep the shared application cache clean even if this test fails.
+ db, err := sql.Open("sqlite", api.DefaultCacheDBPath()+"?_busy_timeout=5000")
+ if err == nil {
+ _, _ = db.Exec(`DELETE FROM cache WHERE key = ?`, cacheKey)
+ _ = db.Close()
+ }
+ }()
+
+ fresh := httptest.NewRecorder()
+ server.handleMeetings(fresh, httptest.NewRequest(http.MethodGet, requestURL, nil))
+ if fresh.Code != http.StatusOK || fresh.Header().Get(dataSourceHeader) != "openf1" || fresh.Header().Get(dataFreshnessHeader) != "fresh" {
+ t.Fatalf("fresh response status/metadata = %d %q/%q body=%s", fresh.Code, fresh.Header().Get(dataSourceHeader), fresh.Header().Get(dataFreshnessHeader), fresh.Body.String())
+ }
+
+ db, err := sql.Open("sqlite", api.DefaultCacheDBPath()+"?_busy_timeout=5000")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`UPDATE cache SET created_at = ? WHERE key = ?`, time.Now().Add(-48*time.Hour).Unix(), cacheKey); err != nil {
+ _ = db.Close()
+ t.Fatal(err)
+ }
+ _ = db.Close()
+ upstream.Close()
+
+ stale := httptest.NewRecorder()
+ server.handleMeetings(stale, httptest.NewRequest(http.MethodGet, requestURL, nil))
+ if stale.Code != http.StatusOK || stale.Header().Get(dataSourceHeader) != "openf1" || stale.Header().Get(dataFreshnessHeader) != "stale" {
+ t.Fatalf("stale response status/metadata = %d %q/%q body=%s", stale.Code, stale.Header().Get(dataSourceHeader), stale.Header().Get(dataFreshnessHeader), stale.Body.String())
+ }
+}
+
+func TestFreshnessHeadersAreExposedToBrowserClients(t *testing.T) {
+ handler := withCORS(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ markDataResponse(w, "local", "partial")
+ writeJSON(w, map[string]bool{"ok": true})
+ }))
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/test", nil))
+
+ if got := recorder.Header().Get("Access-Control-Expose-Headers"); got != dataSourceHeader+", "+dataFreshnessHeader {
+ t.Fatalf("exposed headers = %q", got)
+ }
+ if recorder.Header().Get(dataSourceHeader) != "local" || recorder.Header().Get(dataFreshnessHeader) != "partial" {
+ t.Fatalf("data metadata = %q/%q", recorder.Header().Get(dataSourceHeader), recorder.Header().Get(dataFreshnessHeader))
+ }
+}
diff --git a/internal/web/live.go b/internal/web/live.go
index 168ac7b..ef4598e 100644
--- a/internal/web/live.go
+++ b/internal/web/live.go
@@ -330,7 +330,15 @@ 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) {
- writeJSON(w, s.hub.State())
+ state := s.hub.State()
+ freshness := "limited"
+ if state.IsLive {
+ freshness = "live"
+ } else if state.LastSnapshot != nil {
+ freshness = "archive"
+ }
+ markDataResponse(w, "fia", freshness)
+ writeJSON(w, state)
}
// handleSSEStream is the persistent SSE endpoint for live data.
diff --git a/internal/web/navigation.go b/internal/web/navigation.go
index d6b9830..d9ffd8c 100644
--- a/internal/web/navigation.go
+++ b/internal/web/navigation.go
@@ -9,6 +9,7 @@ import (
)
func (s *Server) handleSeasons(w http.ResponseWriter, r *http.Request) {
+ markLocalResponse(w, false)
if !s.hasLocalQuery() {
writeJSON(w, []int{})
return
@@ -26,6 +27,7 @@ func (s *Server) handleSeasons(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleWeekend(w http.ResponseWriter, r *http.Request) {
+ markLocalResponse(w, false)
meetingKey, err := strconv.Atoi(r.URL.Query().Get("meeting_key"))
if err != nil || meetingKey == 0 {
http.Error(w, "meeting_key required", http.StatusBadRequest)
@@ -46,5 +48,6 @@ func (s *Server) handleWeekend(w http.ResponseWriter, r *http.Request) {
writeError(w, err, http.StatusInternalServerError, false)
return
}
+ markLocalResponse(w, weekend.Source == query.ResponseSourcePartial)
writeJSON(w, weekend)
}
diff --git a/internal/web/racehub.go b/internal/web/racehub.go
index db8725e..b00647a 100644
--- a/internal/web/racehub.go
+++ b/internal/web/racehub.go
@@ -17,6 +17,7 @@ func (s *Server) handleRaceHub(w http.ResponseWriter, r *http.Request) {
}
if !s.hasLocalQuery() {
+ markDataResponse(w, "none", "limited")
writeJSON(w, emptyRaceHub(sessionKey))
return
}
@@ -26,6 +27,14 @@ func (s *Server) handleRaceHub(w http.ResponseWriter, r *http.Request) {
writeError(w, err, http.StatusInternalServerError, false)
return
}
+ switch hub.Source {
+ case query.ResponseSourceNone:
+ markDataResponse(w, "none", "limited")
+ case query.ResponseSourcePartial:
+ markLocalResponse(w, true)
+ default:
+ markLocalResponse(w, false)
+ }
writeJSON(w, hub)
}
diff --git a/internal/web/racehub_test.go b/internal/web/racehub_test.go
index 50755d5..58babde 100644
--- a/internal/web/racehub_test.go
+++ b/internal/web/racehub_test.go
@@ -86,11 +86,29 @@ func TestHandleRaceHubWithoutStore(t *testing.T) {
if hub.Source != query.ResponseSourceNone {
t.Fatalf("source = %q, want %q", hub.Source, query.ResponseSourceNone)
}
+ if rec.Header().Get(dataSourceHeader) != "none" || rec.Header().Get(dataFreshnessHeader) != "limited" {
+ t.Fatalf("missing hub metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
+ }
if hub.Datasets["session"].Status != query.DatasetStatusMissing {
t.Fatalf("session dataset = %+v, want missing", hub.Datasets["session"])
}
}
+func TestHandleRaceHubUnknownSessionWithStoreReportsLimited(t *testing.T) {
+ st := openTestStore(t)
+ srv := testServer(t, st)
+ rec := httptest.NewRecorder()
+ srv.handleRaceHub(rec, httptest.NewRequest(http.MethodGet, "/api/v1/race-hub?session_key=999999", nil))
+
+ var hub query.RaceHub
+ if err := json.Unmarshal(rec.Body.Bytes(), &hub); err != nil {
+ t.Fatal(err)
+ }
+ if hub.Source != query.ResponseSourceNone || rec.Header().Get(dataSourceHeader) != "none" || rec.Header().Get(dataFreshnessHeader) != "limited" {
+ t.Fatalf("unknown session = source %q, metadata %q/%q", hub.Source, rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
+ }
+}
+
func TestHandleRaceHubWithLocalData(t *testing.T) {
st := openTestStore(t)
seedRaceHubStore(t, st)
@@ -117,6 +135,12 @@ func TestHandleRaceHubWithLocalData(t *testing.T) {
if hub.Datasets["results"].Status != query.DatasetStatusMissing {
t.Fatalf("results dataset = %+v, want missing", hub.Datasets["results"])
}
+ if hub.Source != query.ResponseSourcePartial {
+ t.Fatalf("source = %q, want partial", hub.Source)
+ }
+ if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "partial" {
+ t.Fatalf("partial hub metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
+ }
}
func TestHandleRaceHubIncludesChapters(t *testing.T) {
diff --git a/internal/web/replay.go b/internal/web/replay.go
index 9caa7bc..7277013 100644
--- a/internal/web/replay.go
+++ b/internal/web/replay.go
@@ -61,15 +61,27 @@ func (s *Server) handleReplayFrames(w http.ResponseWriter, r *http.Request) {
}
}
- resp, err := assembleReplayFrames(r.Context(), s.client, sessionKey, intervalMS)
+ client := s.client.Scoped()
+ resp, incomplete, err := assembleReplayFrames(r.Context(), client, sessionKey, intervalMS)
if err != nil {
- writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
+ markOpenF1Availability(w, client, replayResponseFreshness(resp, incomplete))
writeJSON(w, resp)
}
-func assembleReplayFrames(ctx context.Context, client replayDataClient, sessionKey, intervalMS int) (replayFramesResponse, error) {
+func replayResponseFreshness(resp replayFramesResponse, incomplete bool) string {
+ if !incomplete {
+ return "fresh"
+ }
+ if len(resp.Frames) == 0 {
+ return "limited"
+ }
+ return "partial"
+}
+
+func assembleReplayFrames(ctx context.Context, client replayDataClient, sessionKey, intervalMS int) (replayFramesResponse, bool, error) {
if intervalMS < defaultReplayIntervalMS {
intervalMS = defaultReplayIntervalMS
}
@@ -82,26 +94,26 @@ func assembleReplayFrames(ctx context.Context, client replayDataClient, sessionK
drivers, err := client.GetDriversForSession(sessionKey)
if err != nil {
- return resp, err
+ return resp, false, err
}
driverNumbers := uniqueDriverNumbers(drivers)
if len(driverNumbers) == 0 {
- return resp, nil
+ return resp, true, nil
}
series, err := fetchReplayLocationSeries(ctx, client, sessionKey, driverNumbers)
if err != nil && len(series) == 0 {
- return resp, err
+ return resp, false, err
}
start, ok := earliestReplayLocationTime(series)
if !ok {
- return resp, nil
+ return resp, true, nil
}
resp.StartTime = start.Format(time.RFC3339Nano)
resp.Frames = snapReplayFrames(series, start, intervalMS)
- return resp, nil
+ return resp, err != nil || len(series) < len(driverNumbers) || len(resp.Frames) == 0, nil
}
func uniqueDriverNumbers(drivers []models.Driver) []int {
diff --git a/internal/web/replay_test.go b/internal/web/replay_test.go
index 25e7437..db24b86 100644
--- a/internal/web/replay_test.go
+++ b/internal/web/replay_test.go
@@ -3,6 +3,7 @@ package web
import (
"context"
"encoding/json"
+ "errors"
"net/http"
"net/http/httptest"
"sync"
@@ -16,6 +17,7 @@ type fakeReplayClient struct {
drivers []models.Driver
locs map[int][]models.Location
err error
+ locErrs map[int]error
mu sync.Mutex
inFlight int
@@ -46,7 +48,38 @@ func (f *fakeReplayClient) GetLocation(sessionKey, driverNumber int) ([]models.L
f.inFlight--
f.mu.Unlock()
- return f.locs[driverNumber], nil
+ return f.locs[driverNumber], f.locErrs[driverNumber]
+}
+
+func TestAssembleReplayFramesReportsPartialDriverSeries(t *testing.T) {
+ start := time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC)
+ client := &fakeReplayClient{
+ drivers: []models.Driver{{DriverNumber: 1}, {DriverNumber: 4}},
+ locs: map[int][]models.Location{
+ 1: {{Date: start.Format(time.RFC3339Nano), X: 1, Y: 2}},
+ },
+ locErrs: map[int]error{4: errors.New("location unavailable")},
+ }
+ resp, incomplete, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS)
+ if err != nil {
+ t.Fatalf("partial replay should remain usable: %v", err)
+ }
+ if !incomplete || len(resp.Frames) != 1 {
+ t.Fatalf("partial replay = incomplete %v, frames %+v", incomplete, resp.Frames)
+ }
+ if got := replayResponseFreshness(resp, incomplete); got != "partial" {
+ t.Fatalf("partial replay freshness = %q", got)
+ }
+}
+
+func TestAssembleReplayFramesEmptyDriverSetIsLimited(t *testing.T) {
+ resp, incomplete, err := assembleReplayFrames(context.Background(), &fakeReplayClient{}, 99, defaultReplayIntervalMS)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !incomplete || replayResponseFreshness(resp, incomplete) != "limited" {
+ t.Fatalf("empty replay = incomplete %v, freshness %q", incomplete, replayResponseFreshness(resp, incomplete))
+ }
}
func TestAssembleReplayFramesSnapsNearestSamplesAndOmitsEmptyDrivers(t *testing.T) {
@@ -70,10 +103,16 @@ func TestAssembleReplayFramesSnapsNearestSamplesAndOmitsEmptyDrivers(t *testing.
},
}
- resp, err := assembleReplayFrames(context.Background(), client, 99, 5000)
+ resp, incomplete, err := assembleReplayFrames(context.Background(), client, 99, 5000)
if err != nil {
t.Fatalf("assembleReplayFrames() error = %v", err)
}
+ if !incomplete {
+ t.Fatal("empty entrant location series was labelled complete")
+ }
+ if got := replayResponseFreshness(resp, incomplete); got != "partial" {
+ t.Fatalf("empty entrant freshness = %q", got)
+ }
if resp.SessionKey != 99 || resp.Interval != 5000 {
t.Fatalf("response metadata = %+v", resp)
}
@@ -112,7 +151,7 @@ func TestAssembleReplayFramesCapsFrameCount(t *testing.T) {
locs: map[int][]models.Location{1: locs},
}
- resp, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS)
+ resp, _, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS)
if err != nil {
t.Fatalf("assembleReplayFrames() error = %v", err)
}
@@ -139,7 +178,7 @@ func TestAssembleReplayFramesBoundsLocationFanOut(t *testing.T) {
delay: 5 * time.Millisecond,
}
- if _, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS); err != nil {
+ if _, _, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS); err != nil {
t.Fatalf("assembleReplayFrames() error = %v", err)
}
if client.maxInFlight > replayFetchConcurrency {
@@ -167,7 +206,7 @@ func TestHandleReplayFramesValidatesParamsAndFloorsInterval(t *testing.T) {
client := &fakeReplayClient{drivers: []models.Driver{{DriverNumber: 1}}, locs: map[int][]models.Location{
1: {{Date: time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC).Format(time.RFC3339Nano), X: 1, Y: 2}},
}}
- resp, err := assembleReplayFrames(context.Background(), client, 99, 1000)
+ resp, _, err := assembleReplayFrames(context.Background(), client, 99, 1000)
if err != nil {
t.Fatalf("assembleReplayFrames() error = %v", err)
}
diff --git a/internal/web/server.go b/internal/web/server.go
index 1612966..8aae981 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -159,6 +159,7 @@ func withCORS(next http.Handler) http.Handler {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
+ w.Header().Set("Access-Control-Expose-Headers", dataSourceHeader+", "+dataFreshnessHeader)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
diff --git a/tests/issue-76-availability.spec.ts b/tests/issue-76-availability.spec.ts
new file mode 100644
index 0000000..ae58da3
--- /dev/null
+++ b/tests/issue-76-availability.spec.ts
@@ -0,0 +1,385 @@
+import { test, expect, type Page } from '@playwright/test'
+import path from 'node:path'
+
+const evidenceDir = path.join('tests', 'evidence', 'issue-76-availability')
+
+const availability = {
+ source: 'local',
+ schedule: 'available',
+ live_transport: 'unknown',
+ live_session: 'inactive',
+ archive: 'unavailable',
+ local_analysis: 'partial',
+ freshness: 'partial',
+ limitations: [],
+}
+
+function meeting(key: number, name: string, start: string) {
+ return {
+ meeting_key: key,
+ meeting_name: name,
+ meeting_official_name: name,
+ location: name,
+ country_code: 'HUN',
+ country_name: 'Hungary',
+ country_flag: '',
+ circuit_key: key,
+ circuit_short_name: name,
+ date_start: start,
+ date_end: start,
+ year: 2026,
+ }
+}
+
+function ctxSession(key: number, name: string, start: string, meetingKey: number) {
+ return {
+ session: {
+ session_key: key,
+ session_name: name,
+ session_type: name,
+ meeting_key: meetingKey,
+ date_start: start,
+ date_end: start,
+ gmt_offset: '',
+ },
+ meeting: meeting(meetingKey, 'Hungarian Grand Prix', start),
+ availability,
+ }
+}
+
+async function stubPreSession(page: Page, opts?: { failSessions?: boolean; recover?: boolean }) {
+ const next = meeting(2, 'Hungarian Grand Prix', '2026-07-24T09:00:00Z')
+ await page.route('**/api/v1/weekend-context', (route) =>
+ route.fulfill({
+ contentType: 'application/json',
+ headers: {
+ 'X-BoxBox-Data-Source': 'local',
+ 'X-BoxBox-Data-Freshness': 'partial',
+ },
+ body: JSON.stringify({
+ season: 2026,
+ temporal_state: 'pre_session',
+ focus_meeting: next,
+ next_meeting: next,
+ next_session: ctxSession(21, 'Practice 1', '2026-07-24T09:00:00Z', 2),
+ championship_round: 13,
+ total_championship_rounds: 24,
+ }),
+ }),
+ )
+ await page.route('**/api/v1/news**', (route) =>
+ route.fulfill({ contentType: 'application/json', body: '[]' }),
+ )
+ await page.route('**/api/v1/championship/hub**', (route) =>
+ route.fulfill({
+ contentType: 'application/json',
+ headers: {
+ 'X-BoxBox-Data-Source': 'openf1',
+ 'X-BoxBox-Data-Freshness': 'stale',
+ },
+ body: JSON.stringify({
+ season: 2026,
+ round: 12,
+ total_rounds: 24,
+ rounds_left: 12,
+ last_race: 'British GP',
+ round_labels: [],
+ drivers: [
+ {
+ driver_number: 1,
+ name_acronym: 'VER',
+ full_name: 'Max Verstappen',
+ team_name: 'Red Bull',
+ team_colour: '3671c6',
+ points: 200,
+ position: 1,
+ wins: 5,
+ podiums: 8,
+ poles: 4,
+ form: [25, 18, 25, 15, 25],
+ cumulative: [25, 43, 68, 83, 108, 200],
+ round_positions: [1, 2, 1, 3, 1, 1],
+ teammate_wins: 9,
+ teammate_losses: 1,
+ },
+ {
+ driver_number: 4,
+ name_acronym: 'NOR',
+ full_name: 'Lando Norris',
+ team_name: 'McLaren',
+ team_colour: 'ff8000',
+ points: 160,
+ position: 2,
+ wins: 3,
+ podiums: 5,
+ poles: 2,
+ form: [18, 25, 18, 25, 18],
+ cumulative: [18, 43, 61, 86, 104, 160],
+ round_positions: [2, 1, 2, 2, 2, 2],
+ teammate_wins: 6,
+ teammate_losses: 4,
+ },
+ {
+ driver_number: 81,
+ name_acronym: 'PIA',
+ full_name: 'Oscar Piastri',
+ team_name: 'McLaren',
+ team_colour: 'ff8000',
+ points: 140,
+ position: 3,
+ wins: 2,
+ podiums: 4,
+ poles: 1,
+ form: [15, 12, 18, 10, 15],
+ cumulative: [15, 27, 45, 55, 70, 140],
+ round_positions: [3, 3, 2, 4, 3, 3],
+ teammate_wins: 4,
+ teammate_losses: 6,
+ },
+ ],
+ teams: [
+ { team_name: 'Red Bull', team_colour: '3671c6', points: 260, position: 1, wins: 6 },
+ { team_name: 'McLaren', team_colour: 'ff8000', points: 300, position: 2, wins: 5 },
+ ],
+ }),
+ }),
+ )
+
+ let sessionCalls = 0
+ await page.route('**/api/v1/sessions**', async (route) => {
+ sessionCalls += 1
+ if (opts?.failSessions && !(opts.recover && sessionCalls > 1)) {
+ await route.fulfill({
+ status: 500,
+ contentType: 'application/json',
+ body: JSON.stringify({ error: 'API 500: forced sessions failure' }),
+ })
+ return
+ }
+ await route.fulfill({
+ contentType: 'application/json',
+ body: JSON.stringify([
+ {
+ session_key: 21,
+ session_name: 'Practice 1',
+ session_type: 'Practice',
+ meeting_key: 2,
+ date_start: '2026-07-24T09:00:00Z',
+ date_end: '2026-07-24T10:00:00Z',
+ gmt_offset: '',
+ },
+ ]),
+ })
+ })
+
+ await page.route('**/api/v1/meetings**', (route) =>
+ route.fulfill({ contentType: 'application/json', body: '[]' }),
+ )
+ await page.route('**/api/v1/track-outline**', (route) =>
+ route.fulfill({
+ contentType: 'application/json',
+ body: JSON.stringify({ error: 'unavailable' }),
+ }),
+ )
+}
+
+test.describe('Issue #76 availability / Preview resilience evidence', () => {
+ test('Weekend pre-session recovers Preview sessions via Retry and keeps distinct notices', async ({
+ page,
+ }, testInfo) => {
+ let sessionCalls = 0
+ let allowSessions = false
+ await stubPreSession(page)
+ await page.unroute('**/api/v1/sessions**')
+ await page.route('**/api/v1/sessions**', async (route) => {
+ const url = new URL(route.request().url())
+ if (url.searchParams.get('meeting_key') === '2') {
+ sessionCalls += 1
+ }
+ if (!allowSessions) {
+ await route.fulfill({
+ status: 500,
+ contentType: 'application/json',
+ body: JSON.stringify({ error: 'API 500: forced sessions failure' }),
+ })
+ return
+ }
+ await route.fulfill({
+ contentType: 'application/json',
+ body: JSON.stringify([
+ {
+ session_key: 21,
+ session_name: 'Practice 1',
+ session_type: 'Practice',
+ meeting_key: 2,
+ date_start: '2026-07-24T09:00:00Z',
+ date_end: '2026-07-24T10:00:00Z',
+ gmt_offset: '',
+ },
+ ]),
+ })
+ })
+
+ await page.setViewportSize({ width: 390, height: 844 })
+ await page.goto('/')
+
+ await expect(page.getByTestId('weekend-pre-session')).toBeVisible()
+ await expect(page.getByTestId('wk-pre-head')).toContainText('Hungarian Grand Prix')
+ await expect(page.getByTestId('weekend-data-notice')).toContainText(/Partial/i)
+ // Shell Partial + Preview championship Stale are distinct — both may show.
+ await expect(page.getByTestId('preview-data-notice')).toContainText(/Stale/i)
+ await expect(page.getByTestId('preview-page')).toBeVisible()
+ await expect(page.getByTestId('preview-page')).toHaveAttribute('data-meeting-key', '2')
+ await expect(page.getByTestId('preview-sessions-error')).toBeVisible()
+ await expect(page.locator('body')).not.toContainText(/API 500|forced sessions failure/i)
+ await expect(page.getByTestId('preview-title-fight-card')).toContainText('VER')
+
+ const failedCalls = sessionCalls
+ expect(failedCalls).toBeGreaterThanOrEqual(1)
+
+ allowSessions = true
+ const retry = page.getByTestId('preview-sessions-retry')
+ await expect(retry).toBeEnabled()
+ await retry.click()
+ await expect(page.getByTestId('preview-schedule')).toContainText('Practice 1')
+ await expect(page.getByTestId('preview-sessions-error')).toHaveCount(0)
+ expect(sessionCalls).toBe(failedCalls + 1)
+
+ await page.screenshot({
+ path: path.join(evidenceDir, `weekend-presession-failure-mobile-${testInfo.project.name}.png`),
+ fullPage: true,
+ })
+
+ await page.setViewportSize({ width: 1440, height: 900 })
+ await page.screenshot({
+ path: path.join(evidenceDir, `weekend-presession-partial-desktop-${testInfo.project.name}.png`),
+ fullPage: true,
+ })
+ await expect(page.getByTestId('weekend-data-notice')).toContainText(/Partial/i)
+ await expect(page.getByTestId('preview-data-notice')).toContainText(/Stale/i)
+ })
+
+ test('Championship discloses stale metadata above usable standings', async ({ page }, testInfo) => {
+ await page.route('**/api/v1/seasons', (route) =>
+ route.fulfill({ contentType: 'application/json', body: JSON.stringify([2026]) }),
+ )
+ await page.route('**/api/v1/championship/hub**', (route) =>
+ route.fulfill({
+ contentType: 'application/json',
+ headers: {
+ 'X-BoxBox-Data-Source': 'openf1',
+ 'X-BoxBox-Data-Freshness': 'stale',
+ },
+ body: JSON.stringify({
+ season: 2026,
+ round: 6,
+ total_rounds: 24,
+ rounds_left: 18,
+ last_race: 'Monaco GP',
+ round_labels: ['R1', 'R2', 'R3', 'R4', 'R5', 'R6'],
+ drivers: [
+ {
+ driver_number: 1,
+ name_acronym: 'VER',
+ full_name: 'Max Verstappen',
+ team_name: 'Red Bull',
+ team_colour: '3671c6',
+ points: 200,
+ position: 1,
+ wins: 5,
+ podiums: 8,
+ poles: 4,
+ form: [25, 18, 25, 15, 25],
+ cumulative: [25, 43, 68, 83, 108, 200],
+ round_positions: [1, 2, 1, 3, 1, 1],
+ teammate_wins: 9,
+ teammate_losses: 1,
+ },
+ {
+ driver_number: 4,
+ name_acronym: 'NOR',
+ full_name: 'Lando Norris',
+ team_name: 'McLaren',
+ team_colour: 'ff8000',
+ points: 160,
+ position: 2,
+ wins: 3,
+ podiums: 5,
+ poles: 2,
+ form: [18, 25, 18, 25, 18],
+ cumulative: [18, 43, 61, 86, 104, 160],
+ round_positions: [2, 1, 2, 2, 2, 2],
+ teammate_wins: 6,
+ teammate_losses: 4,
+ },
+ ],
+ teams: [
+ { team_name: 'Red Bull', team_colour: '3671c6', points: 260, position: 1, wins: 6 },
+ { team_name: 'McLaren', team_colour: 'ff8000', points: 220, position: 2, wins: 3 },
+ ],
+ }),
+ }),
+ )
+
+ await page.setViewportSize({ width: 768, height: 1024 })
+ await page.goto('/championship')
+ await expect(page.getByTestId('championship-data-notice')).toContainText(/Stale/i)
+ await expect(page.getByTestId('champ-view-drivers')).toBeVisible()
+ await page.screenshot({
+ path: path.join(evidenceDir, `championship-stale-tablet-${testInfo.project.name}.png`),
+ fullPage: true,
+ })
+ })
+
+ test('Briefing Limited notice keeps articles usable', async ({ page }, testInfo) => {
+ await page.route('**/api/v1/news**', (route) =>
+ route.fulfill({
+ contentType: 'application/json',
+ headers: {
+ 'X-BoxBox-Data-Source': 'local',
+ 'X-BoxBox-Data-Freshness': 'local',
+ },
+ body: JSON.stringify([
+ {
+ id: 1,
+ title: 'Paddock briefing sample',
+ url: 'https://example.com/a',
+ source: 'Autosport',
+ published_at: '2025-04-01T12:00:00Z',
+ summary: 'Sample article remains usable.',
+ category: 'news',
+ },
+ ]),
+ }),
+ )
+ await page.route('**/api/v1/seasons', (route) =>
+ route.fulfill({ contentType: 'application/json', body: JSON.stringify([2025]) }),
+ )
+ await page.route('**/api/v1/meetings**', (route) =>
+ route.fulfill({
+ status: 503,
+ contentType: 'application/json',
+ body: JSON.stringify({ error: 'API 503: meetings unavailable' }),
+ }),
+ )
+ await page.route('**/api/v1/championship/hub**', (route) =>
+ route.fulfill({
+ status: 503,
+ contentType: 'application/json',
+ body: JSON.stringify({ error: 'API 503: hub unavailable' }),
+ }),
+ )
+
+ await page.setViewportSize({ width: 390, height: 844 })
+ await page.goto('/briefing')
+ await expect(page.getByTestId('briefing-data-notice')).toContainText(/Limited/i)
+ await expect(page.getByText('Paddock briefing sample')).toBeVisible()
+ await expect(page.locator('body')).not.toContainText(/API 503/i)
+ const retry = page.getByTestId('briefing-data-notice').getByRole('button', { name: 'Retry' })
+ await expect(retry).toBeEnabled()
+ await page.screenshot({
+ path: path.join(evidenceDir, `briefing-limited-mobile-${testInfo.project.name}.png`),
+ fullPage: true,
+ })
+ })
+})
diff --git a/tests/live-timing.spec.ts b/tests/live-timing.spec.ts
index 2e586a2..12a3173 100644
--- a/tests/live-timing.spec.ts
+++ b/tests/live-timing.spec.ts
@@ -452,4 +452,55 @@ test.describe('Live Timing (no session)', () => {
await expect(page.getByTestId('live-feed-health')).toContainText(/reconnecting/i)
await expect(page.getByTestId('live-feed-health')).not.toContainText(/feed healthy/i)
})
+
+ test('clears fatal initial error once SSE supplies a live snapshot', async ({ page }) => {
+ await page.route('**/api/v1/live/state', (route) =>
+ route.fulfill({
+ status: 503,
+ contentType: 'application/json',
+ body: JSON.stringify({ error: 'API 503: live state unavailable' }),
+ }),
+ )
+ await page.route('**/api/v1/live/stream', (route) =>
+ route.fulfill({
+ contentType: 'text/event-stream',
+ headers: { 'Cache-Control': 'no-cache' },
+ body: `event: snapshot\ndata: ${JSON.stringify(raceSnapshot)}\n\n`,
+ }),
+ )
+
+ await page.goto('/live')
+ await expect(page.getByText('Timing Tower')).toBeVisible({ timeout: 15_000 })
+ await expect(page.getByTestId('live-initial-error')).toHaveCount(0)
+ await expect(page.getByText(/Live timing unavailable/i)).toHaveCount(0)
+ await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', /^(live|disconnected)$/)
+ await expect(page.locator('.live-tower')).toContainText('VER')
+ })
+
+ test('clears fatal initial error once SSE supplies an inactive null state', async ({ page }) => {
+ await page.route('**/api/v1/live/state', (route) =>
+ route.fulfill({
+ status: 503,
+ contentType: 'application/json',
+ body: JSON.stringify({ error: 'API 503: live state unavailable' }),
+ }),
+ )
+ await page.route('**/api/v1/live/stream', (route) =>
+ route.fulfill({
+ contentType: 'text/event-stream',
+ headers: { 'Cache-Control': 'no-cache' },
+ body: `event: snapshot\ndata: ${JSON.stringify({
+ is_live: false,
+ data: null,
+ last_snapshot: null,
+ })}\n\n`,
+ }),
+ )
+
+ await page.goto('/live')
+ await expect(page.getByTestId('live-inactive')).toBeVisible({ timeout: 15_000 })
+ await expect(page.getByTestId('live-initial-error')).toHaveCount(0)
+ await expect(page.getByText(/Live timing unavailable/i)).toHaveCount(0)
+ await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', 'inactive')
+ })
})