mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 12:07:23 -04:00
Compare commits
16 Commits
feat/issue
...
sprint/v0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3583a80d42 | ||
|
|
a0ff8fa5a7 | ||
|
|
c0d1cf2892 | ||
|
|
0bba213652 | ||
|
|
01d291507d | ||
|
|
eabf87a2b8 | ||
|
|
5534ebf82c | ||
|
|
dafb9b6dc5 | ||
|
|
1a9e08c0ab | ||
|
|
14d9bd3dd4 | ||
|
|
15d81c8eab | ||
|
|
2651569453 | ||
|
|
ad0eb26e05 | ||
|
|
5ea8a4a9c4 | ||
|
|
b3d1730324 | ||
|
|
e8a1f15f8b |
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from './lib/fetch'
|
||||
import type {
|
||||
ArticleContent,
|
||||
CarDataSample,
|
||||
@@ -18,101 +19,118 @@ import type {
|
||||
WeekendContext,
|
||||
} from './types'
|
||||
|
||||
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
|
||||
const res = await fetch(`/api/v1/race-hub?session_key=${sessionKey}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
return res.json()
|
||||
export async function fetchRaceHub(sessionKey: number, signal?: AbortSignal): Promise<RaceHub> {
|
||||
return apiFetch<RaceHub>(`/api/v1/race-hub?session_key=${sessionKey}`, {
|
||||
signal,
|
||||
dedupeKey: `race-hub:${sessionKey}`,
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchSeasons(): Promise<number[]> {
|
||||
const res = await fetch('/api/v1/seasons')
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
const years = await res.json()
|
||||
export async function fetchSeasons(signal?: AbortSignal): Promise<number[]> {
|
||||
const years = await apiFetch<number[]>('/api/v1/seasons', {
|
||||
signal,
|
||||
dedupeKey: 'seasons',
|
||||
})
|
||||
return Array.isArray(years) ? years : []
|
||||
}
|
||||
|
||||
export async function fetchLocalMeetings(year: number): Promise<Meeting[]> {
|
||||
const res = await fetch(`/api/v1/meetings?year=${year}&source=local`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
const meetings = await res.json()
|
||||
export async function fetchLocalMeetings(year: number, signal?: AbortSignal): Promise<Meeting[]> {
|
||||
const meetings = await apiFetch<Meeting[]>(`/api/v1/meetings?year=${year}&source=local`, {
|
||||
signal,
|
||||
dedupeKey: `meetings:local:${year}`,
|
||||
})
|
||||
return Array.isArray(meetings) ? meetings : []
|
||||
}
|
||||
|
||||
export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
|
||||
return fetchMeetings(year, 'openf1')
|
||||
export async function fetchSeasonMeetings(year: number, signal?: AbortSignal): Promise<Meeting[]> {
|
||||
return fetchMeetings(year, 'openf1', signal)
|
||||
}
|
||||
|
||||
export async function fetchMeetings(year: number, source = 'auto'): Promise<Meeting[]> {
|
||||
const res = await fetch(`/api/v1/meetings?year=${year}&source=${source}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
const meetings = await res.json()
|
||||
export async function fetchMeetings(
|
||||
year: number,
|
||||
source = 'auto',
|
||||
signal?: AbortSignal,
|
||||
): Promise<Meeting[]> {
|
||||
const meetings = await apiFetch<Meeting[]>(`/api/v1/meetings?year=${year}&source=${source}`, {
|
||||
signal,
|
||||
dedupeKey: `meetings:${source}:${year}`,
|
||||
})
|
||||
return Array.isArray(meetings) ? meetings : []
|
||||
}
|
||||
|
||||
export async function fetchResults(sessionKey: number, source = 'auto'): Promise<EnrichedResult[]> {
|
||||
const res = await fetch(`/api/v1/results?session_key=${sessionKey}&source=${source}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
const results = await res.json()
|
||||
export async function fetchResults(
|
||||
sessionKey: number,
|
||||
source = 'auto',
|
||||
signal?: AbortSignal,
|
||||
): Promise<EnrichedResult[]> {
|
||||
const results = await apiFetch<EnrichedResult[]>(
|
||||
`/api/v1/results?session_key=${sessionKey}&source=${source}`,
|
||||
{ signal, dedupeKey: `results:${source}:${sessionKey}` },
|
||||
)
|
||||
return Array.isArray(results) ? results : []
|
||||
}
|
||||
|
||||
export async function fetchStartingGrid(sessionKey: number, source = 'auto'): Promise<EnrichedGrid[]> {
|
||||
const res = await fetch(`/api/v1/grid?session_key=${sessionKey}&source=${source}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
const grid = await res.json()
|
||||
export async function fetchStartingGrid(
|
||||
sessionKey: number,
|
||||
source = 'auto',
|
||||
signal?: AbortSignal,
|
||||
): Promise<EnrichedGrid[]> {
|
||||
const grid = await apiFetch<EnrichedGrid[]>(
|
||||
`/api/v1/grid?session_key=${sessionKey}&source=${source}`,
|
||||
{ signal, dedupeKey: `grid:${source}:${sessionKey}` },
|
||||
)
|
||||
return Array.isArray(grid) ? grid : []
|
||||
}
|
||||
|
||||
export async function fetchTrackOutline(circuitKey: number, year: number): Promise<TrackOutline | null> {
|
||||
const res = await fetch(`/api/v1/track-outline?circuit_key=${circuitKey}&year=${year}`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
export async function fetchTrackOutline(
|
||||
circuitKey: number,
|
||||
year: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TrackOutline | null> {
|
||||
try {
|
||||
const data = await apiFetch<TrackOutline & { error?: string }>(
|
||||
`/api/v1/track-outline?circuit_key=${circuitKey}&year=${year}`,
|
||||
{ signal, dedupeKey: `track-outline:${circuitKey}:${year}` },
|
||||
)
|
||||
if (data?.error || !Array.isArray(data?.points) || data.points.length < 2) return null
|
||||
return data as TrackOutline
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchReplayFrames(
|
||||
sessionKey: number,
|
||||
intervalMs = 5000,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ReplayFramesResponse> {
|
||||
const params = new URLSearchParams({
|
||||
session_key: String(sessionKey),
|
||||
interval_ms: String(intervalMs),
|
||||
})
|
||||
const res = await fetch(`/api/v1/replay/frames?${params}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
return res.json()
|
||||
return apiFetch<ReplayFramesResponse>(`/api/v1/replay/frames?${params}`, {
|
||||
signal,
|
||||
dedupeKey: `replay-frames:${sessionKey}:${intervalMs}`,
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise<Session[]> {
|
||||
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
const sessions = await res.json()
|
||||
export async function fetchSessions(
|
||||
meetingKey: number,
|
||||
source = 'openf1',
|
||||
signal?: AbortSignal,
|
||||
): Promise<Session[]> {
|
||||
const sessions = await apiFetch<Session[]>(
|
||||
`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`,
|
||||
{ signal, dedupeKey: `sessions:${source}:${meetingKey}` },
|
||||
)
|
||||
return Array.isArray(sessions) ? sessions : []
|
||||
}
|
||||
|
||||
export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
|
||||
const res = await fetch(`/api/v1/weekend?meeting_key=${meetingKey}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
return res.json()
|
||||
export async function fetchWeekend(meetingKey: number, signal?: AbortSignal): Promise<Weekend> {
|
||||
return apiFetch<Weekend>(`/api/v1/weekend?meeting_key=${meetingKey}`, {
|
||||
signal,
|
||||
dedupeKey: `weekend:${meetingKey}`,
|
||||
})
|
||||
}
|
||||
|
||||
// fetchWeekendContext consumes the canonical /api/v1/weekend-context endpoint
|
||||
@@ -121,61 +139,64 @@ export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
|
||||
// so the hook can surface an explicit error state; there is no client-side
|
||||
// re-derivation of the contract. Race Hub bare-default landing also reads this
|
||||
// for `default_analysis_session` (#75).
|
||||
export async function fetchWeekendContext(): Promise<WeekendContext> {
|
||||
const res = await fetch('/api/v1/weekend-context')
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
return res.json()
|
||||
export async function fetchWeekendContext(signal?: AbortSignal): Promise<WeekendContext> {
|
||||
return apiFetch<WeekendContext>('/api/v1/weekend-context', {
|
||||
signal,
|
||||
dedupeKey: 'weekend-context',
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchChampionshipHub(year?: number): Promise<ChampionshipHub> {
|
||||
export async function fetchChampionshipHub(
|
||||
year?: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ChampionshipHub> {
|
||||
const params = new URLSearchParams({ source: 'auto' })
|
||||
if (year) params.set('year', year.toString())
|
||||
const url = `/api/v1/championship/hub?${params.toString()}`
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
return res.json()
|
||||
return apiFetch<ChampionshipHub>(`/api/v1/championship/hub?${params.toString()}`, {
|
||||
signal,
|
||||
dedupeKey: `championship-hub:${year ?? 'latest'}`,
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchDriverSummary(
|
||||
driverNumber: number,
|
||||
year?: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DriverSummary> {
|
||||
const params = new URLSearchParams({ driver_number: String(driverNumber) })
|
||||
const params = new URLSearchParams({ driver_number: String(driverNumber), source: 'auto' })
|
||||
if (year) params.set('year', String(year))
|
||||
const res = await fetch(`/api/v1/driver/summary?${params.toString()}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
return res.json()
|
||||
return apiFetch<DriverSummary>(`/api/v1/driver/summary?${params.toString()}`, {
|
||||
signal,
|
||||
dedupeKey: `driver-summary:${driverNumber}:${year ?? 'latest'}`,
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchLiveState(): Promise<LiveStateResponse> {
|
||||
const res = await fetch('/api/v1/live/state')
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
return res.json()
|
||||
export async function fetchLiveState(signal?: AbortSignal): Promise<LiveStateResponse> {
|
||||
return apiFetch<LiveStateResponse>('/api/v1/live/state', {
|
||||
signal,
|
||||
dedupeKey: 'live-state',
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchLiveTrackOutline(
|
||||
session: LiveSessionMeta,
|
||||
year = new Date().getFullYear(),
|
||||
signal?: AbortSignal,
|
||||
): Promise<TrackOutline> {
|
||||
const params = new URLSearchParams({ year: year.toString() })
|
||||
if (session.MeetingName) params.set('meeting_name', session.MeetingName)
|
||||
if (session.CircuitName) params.set('circuit_name', session.CircuitName)
|
||||
const res = await fetch(`/api/v1/track-outline?${params.toString()}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
return res.json()
|
||||
return apiFetch<TrackOutline>(`/api/v1/track-outline?${params.toString()}`, {
|
||||
signal,
|
||||
dedupeKey: `live-track-outline:${year}:${session.MeetingName ?? ''}:${session.CircuitName ?? ''}`,
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchNews(limit?: number, source?: string): Promise<NewsItem[]> {
|
||||
export async function fetchNews(
|
||||
limit?: number,
|
||||
source?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NewsItem[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (limit) params.set('limit', limit.toString())
|
||||
if (source) params.set('source', source)
|
||||
@@ -183,19 +204,20 @@ export async function fetchNews(limit?: number, source?: string): Promise<NewsIt
|
||||
const query = params.toString()
|
||||
const url = query ? `/api/v1/news?${query}` : '/api/v1/news'
|
||||
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
return res.json()
|
||||
return apiFetch<NewsItem[]>(url, {
|
||||
signal,
|
||||
dedupeKey: `news:${limit ?? 'all'}:${source ?? 'all'}`,
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchNewsArticle(articleUrl: string): Promise<ArticleContent> {
|
||||
const res = await fetch(`/api/v1/news/article?url=${encodeURIComponent(articleUrl)}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
return res.json()
|
||||
export async function fetchNewsArticle(
|
||||
articleUrl: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ArticleContent> {
|
||||
return apiFetch<ArticleContent>(
|
||||
`/api/v1/news/article?url=${encodeURIComponent(articleUrl)}`,
|
||||
{ signal },
|
||||
)
|
||||
}
|
||||
|
||||
export async function markNewsRead(articleUrl: string): Promise<void> {
|
||||
@@ -209,28 +231,26 @@ export async function markNewsRead(articleUrl: string): Promise<void> {
|
||||
export async function fetchTelemetry(
|
||||
sessionKey: number,
|
||||
driverNumber: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CarDataSample[]> {
|
||||
const res = await fetch(
|
||||
const data = await apiFetch<CarDataSample[]>(
|
||||
`/api/v1/telemetry?session_key=${sessionKey}&driver_number=${driverNumber}`,
|
||||
{ signal, dedupeKey: `telemetry:${sessionKey}:${driverNumber}` },
|
||||
)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
return Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
export async function fetchLapsComparison(
|
||||
sessionKey: number,
|
||||
drivers?: number[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<LapsComparisonResponse> {
|
||||
const params = new URLSearchParams({ session_key: String(sessionKey) })
|
||||
if (drivers?.length) {
|
||||
params.set('drivers', drivers.join(','))
|
||||
}
|
||||
const res = await fetch(`/api/v1/laps/comparison?${params}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
return res.json()
|
||||
return apiFetch<LapsComparisonResponse>(`/api/v1/laps/comparison?${params}`, {
|
||||
signal,
|
||||
dedupeKey: `laps-comparison:${sessionKey}:${drivers?.join(',') ?? 'all'}`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
|
||||
import { formatCoverageHint } from '../lib/coverage'
|
||||
import { SourceBadge } from './SourceBadge'
|
||||
import { SessionCoverageDots } from './SessionCoverageDots'
|
||||
import { RouteState } from './RouteState'
|
||||
import type { Meeting, WeekendSession } from '../types'
|
||||
|
||||
function formatMeetingDates(meeting: Meeting): string {
|
||||
@@ -29,18 +30,18 @@ export function LocalDataNavigator({ onSelectSession }: Props) {
|
||||
|
||||
const seasonsQuery = useQuery({
|
||||
queryKey: ['seasons'],
|
||||
queryFn: fetchSeasons,
|
||||
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||
})
|
||||
|
||||
const meetingsQuery = useQuery({
|
||||
queryKey: ['meetings', selectedYear],
|
||||
queryFn: () => fetchLocalMeetings(selectedYear!),
|
||||
queryFn: ({ signal }) => fetchLocalMeetings(selectedYear!, signal),
|
||||
enabled: selectedYear != null,
|
||||
})
|
||||
|
||||
const weekendQuery = useQuery({
|
||||
queryKey: ['weekend', selectedMeetingKey],
|
||||
queryFn: () => fetchWeekend(selectedMeetingKey!),
|
||||
queryFn: ({ signal }) => fetchWeekend(selectedMeetingKey!, signal),
|
||||
enabled: selectedMeetingKey != null,
|
||||
})
|
||||
|
||||
@@ -68,14 +69,22 @@ export function LocalDataNavigator({ onSelectSession }: Props) {
|
||||
}
|
||||
|
||||
if (seasonsQuery.isLoading) {
|
||||
return <div className="nav-panel loading-state">loading local seasons…</div>
|
||||
return <RouteState kind="loading" title="loading local seasons…" className="nav-panel" />
|
||||
}
|
||||
|
||||
if (seasonsQuery.isError) {
|
||||
return (
|
||||
<div className="nav-panel error-box">
|
||||
{seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'}
|
||||
</div>
|
||||
<RouteState
|
||||
kind="error"
|
||||
className="nav-panel"
|
||||
title="Local seasons unavailable"
|
||||
error={seasonsQuery.error}
|
||||
onRetry={() => {
|
||||
if (!seasonsQuery.isFetching) void seasonsQuery.refetch()
|
||||
}}
|
||||
retrying={seasonsQuery.isFetching}
|
||||
testId="weekend-error"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -124,9 +133,16 @@ export function LocalDataNavigator({ onSelectSession }: Props) {
|
||||
)}
|
||||
|
||||
{meetingsQuery.isError && (
|
||||
<div className="error-box" style={{ marginTop: 'var(--s4)' }}>
|
||||
{meetingsQuery.error instanceof Error ? meetingsQuery.error.message : 'Failed to load meetings'}
|
||||
</div>
|
||||
<RouteState
|
||||
kind="error"
|
||||
title="Meetings unavailable"
|
||||
error={meetingsQuery.error}
|
||||
onRetry={() => {
|
||||
if (!meetingsQuery.isFetching) void meetingsQuery.refetch()
|
||||
}}
|
||||
retrying={meetingsQuery.isFetching}
|
||||
testId="weekend-meetings-error"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && (
|
||||
@@ -194,9 +210,16 @@ export function LocalDataNavigator({ onSelectSession }: Props) {
|
||||
)}
|
||||
|
||||
{selectedMeetingKey != null && weekendQuery.isError && (
|
||||
<div className="error-box" style={{ marginTop: 'var(--s4)' }}>
|
||||
{weekendQuery.error instanceof Error ? weekendQuery.error.message : 'Failed to load weekend'}
|
||||
</div>
|
||||
<RouteState
|
||||
kind="error"
|
||||
title="Weekend unavailable"
|
||||
error={weekendQuery.error}
|
||||
onRetry={() => {
|
||||
if (!weekendQuery.isFetching) void weekendQuery.refetch()
|
||||
}}
|
||||
retrying={weekendQuery.isFetching}
|
||||
testId="weekend-error"
|
||||
/>
|
||||
)}
|
||||
|
||||
{weekend && (
|
||||
|
||||
@@ -34,25 +34,25 @@ export function PaddockBriefing() {
|
||||
|
||||
const { data: news, isLoading, isError } = useQuery({
|
||||
queryKey: ['news'],
|
||||
queryFn: () => fetchNews(100),
|
||||
queryFn: ({ signal }) => fetchNews(100, undefined, signal),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const seasonsQuery = useQuery({
|
||||
queryKey: ['seasons'],
|
||||
queryFn: fetchSeasons,
|
||||
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||
})
|
||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||
|
||||
const meetingsQuery = useQuery({
|
||||
queryKey: ['season-meetings', latestSeason],
|
||||
queryFn: () => fetchSeasonMeetings(latestSeason!),
|
||||
queryFn: ({ signal }) => fetchSeasonMeetings(latestSeason!, signal),
|
||||
enabled: latestSeason != null,
|
||||
})
|
||||
|
||||
const hubQuery = useQuery({
|
||||
queryKey: ['championship-hub', latestSeason],
|
||||
queryFn: () => fetchChampionshipHub(latestSeason!),
|
||||
queryFn: ({ signal }) => fetchChampionshipHub(latestSeason!, signal),
|
||||
enabled: latestSeason != null,
|
||||
})
|
||||
|
||||
|
||||
@@ -704,7 +704,12 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
</div>
|
||||
|
||||
{displayResults.length > 0 && (
|
||||
<div className="rs-field-list">
|
||||
<div
|
||||
className="rs-field-list"
|
||||
role="region"
|
||||
aria-label="Final running order"
|
||||
data-testid="race-story-outcome"
|
||||
>
|
||||
{displayResults.map((r, i) => {
|
||||
const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
|
||||
const currentPos = scrubTime !== null ? i + 1 : r.position
|
||||
|
||||
163
frontend/src/components/RouteState.tsx
Normal file
163
frontend/src/components/RouteState.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { isTimeoutError, userFacingError } from '../lib/fetch'
|
||||
import { noticeMessage, type DataAvailability } from '../lib/availability'
|
||||
|
||||
export type { DataAvailability }
|
||||
|
||||
export function availabilityLabel(kind: DataAvailability): string {
|
||||
switch (kind) {
|
||||
case 'local':
|
||||
return 'Local'
|
||||
case 'partial':
|
||||
return 'Partial'
|
||||
case 'stale':
|
||||
return 'Stale'
|
||||
case 'archive':
|
||||
return 'Archive'
|
||||
case 'limited':
|
||||
return 'Limited'
|
||||
case 'missing':
|
||||
return 'Missing'
|
||||
}
|
||||
}
|
||||
|
||||
export function AvailabilityBadge({ kind, label }: { kind: DataAvailability; label?: string }) {
|
||||
return (
|
||||
<span className={`badge badge-${kind === 'archive' ? 'none' : kind}`} data-testid={`availability-${kind}`}>
|
||||
{label ?? availabilityLabel(kind)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export type RouteStateKind = 'loading' | 'empty' | 'error' | 'timeout'
|
||||
|
||||
interface RouteStateProps {
|
||||
kind: RouteStateKind
|
||||
title?: string
|
||||
message?: ReactNode
|
||||
error?: unknown
|
||||
onRetry?: () => void
|
||||
retrying?: boolean
|
||||
testId?: string
|
||||
/** Optional override for the Retry button's data-testid (defaults to none). */
|
||||
retryTestId?: string
|
||||
className?: string
|
||||
/** Optional availability strip (stale/limited/partial) above the state body. */
|
||||
availability?: DataAvailability
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const DEFAULT_TITLES: Record<RouteStateKind, string> = {
|
||||
loading: 'Loading…',
|
||||
empty: 'Nothing here yet',
|
||||
error: 'Could not load this view',
|
||||
timeout: 'Request timed out',
|
||||
}
|
||||
|
||||
const DEFAULT_MESSAGES: Record<RouteStateKind, string> = {
|
||||
loading: 'Fetching the latest local data.',
|
||||
empty: 'No data is available for this view yet.',
|
||||
error: 'Something went wrong. Retry to try again.',
|
||||
timeout: 'This request took too long. Check your connection, then retry.',
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared primary-route state surface: loading, empty, timeout/error + retry.
|
||||
* Retry is a real <button> (keyboard accessible) and callers should gate
|
||||
* concurrent refetches via React Query / deduped apiFetch.
|
||||
*/
|
||||
export function RouteState({
|
||||
kind,
|
||||
title,
|
||||
message,
|
||||
error,
|
||||
onRetry,
|
||||
retrying = false,
|
||||
testId,
|
||||
retryTestId,
|
||||
className = '',
|
||||
availability,
|
||||
children,
|
||||
}: RouteStateProps) {
|
||||
const resolvedKind: RouteStateKind =
|
||||
kind === 'error' && isTimeoutError(error) ? 'timeout' : kind
|
||||
|
||||
const resolvedMessage =
|
||||
message ??
|
||||
(error != null && (resolvedKind === 'error' || resolvedKind === 'timeout')
|
||||
? userFacingError(error)
|
||||
: DEFAULT_MESSAGES[resolvedKind])
|
||||
|
||||
const showRetry =
|
||||
(resolvedKind === 'error' || resolvedKind === 'timeout') && typeof onRetry === 'function'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`route-state route-state-${resolvedKind} ${className}`.trim()}
|
||||
data-testid={testId ?? `route-state-${resolvedKind}`}
|
||||
role={resolvedKind === 'error' || resolvedKind === 'timeout' ? 'alert' : undefined}
|
||||
>
|
||||
{availability && (
|
||||
<div className="route-state-availability">
|
||||
<AvailabilityBadge kind={availability} />
|
||||
</div>
|
||||
)}
|
||||
{resolvedKind === 'loading' ? (
|
||||
<div className="loading-state">{title ?? 'loading…'}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="route-state-title">{title ?? DEFAULT_TITLES[resolvedKind]}</div>
|
||||
<div className="route-state-message">{resolvedMessage}</div>
|
||||
{children}
|
||||
{showRetry && (
|
||||
<button
|
||||
type="button"
|
||||
className="route-state-retry"
|
||||
onClick={onRetry}
|
||||
disabled={retrying}
|
||||
aria-busy={retrying || undefined}
|
||||
data-testid={retryTestId}
|
||||
>
|
||||
{retrying ? 'Retrying…' : 'Retry'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface StaleNoticeProps {
|
||||
availability?: DataAvailability
|
||||
message?: string
|
||||
onRetry?: () => void
|
||||
retrying?: boolean
|
||||
testId?: string
|
||||
}
|
||||
|
||||
/** Inline notice when a successful payload is limited/stale/partial. */
|
||||
export function DataNotice({
|
||||
availability = 'stale',
|
||||
message,
|
||||
onRetry,
|
||||
retrying = false,
|
||||
testId = 'data-notice',
|
||||
}: StaleNoticeProps) {
|
||||
return (
|
||||
<div className="data-notice" data-testid={testId} role="status">
|
||||
<AvailabilityBadge kind={availability} />
|
||||
<span className="data-notice-text">{message ?? noticeMessage(availability)}</span>
|
||||
{onRetry && (
|
||||
<button
|
||||
type="button"
|
||||
className="route-state-retry data-notice-retry"
|
||||
onClick={onRetry}
|
||||
disabled={retrying}
|
||||
aria-busy={retrying || undefined}
|
||||
>
|
||||
{retrying ? 'Retrying…' : 'Retry'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export function WeekendSwitcher({
|
||||
onClose,
|
||||
}: Props) {
|
||||
const navigate = useNavigate()
|
||||
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons })
|
||||
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: ({ signal }) => fetchSeasons(signal) })
|
||||
const [year, setYear] = useState<number | null>(null)
|
||||
const [openMeetingKey, setOpenMeetingKey] = useState<number | null>(
|
||||
currentMeetingKey ?? null,
|
||||
@@ -36,7 +36,7 @@ export function WeekendSwitcher({
|
||||
|
||||
const weekendQuery = useQuery({
|
||||
queryKey: ['weekend', openMeetingKey],
|
||||
queryFn: () => fetchWeekend(openMeetingKey!),
|
||||
queryFn: ({ signal }) => fetchWeekend(openMeetingKey!, signal),
|
||||
enabled: openMeetingKey != null,
|
||||
})
|
||||
|
||||
@@ -52,7 +52,7 @@ export function WeekendSwitcher({
|
||||
|
||||
const meetingsQuery = useQuery({
|
||||
queryKey: ['meetings', year],
|
||||
queryFn: () => fetchLocalMeetings(year!),
|
||||
queryFn: ({ signal }) => fetchLocalMeetings(year!, signal),
|
||||
enabled: year != null,
|
||||
})
|
||||
|
||||
|
||||
@@ -4,15 +4,29 @@ import type { WeekendContext } from '../../types'
|
||||
import { RacePreviewPage } from '../../pages/RacePreviewPage'
|
||||
import { ChampionshipRoundStrip, CountdownDisplay, Flag, SessionRail, railNodes } from './shared'
|
||||
import { meetingIdentity } from '../../lib/weekendContext'
|
||||
import type { DataAvailability } from '../../lib/availability'
|
||||
|
||||
/**
|
||||
* PreSessionView folds the race preview surface into the Weekend home so there is
|
||||
* no separate Preview destination. It reuses the existing preview page content and
|
||||
* frames it with the next-session countdown and compact season navigation. It also
|
||||
* backs the /preview alias, so saved preview links resolve here instead of looping.
|
||||
*
|
||||
* Canonical meeting/session identity is passed into Preview so the nested surface
|
||||
* cannot independently re-resolve a different current weekend.
|
||||
*/
|
||||
export function PreSessionView({ context, now }: { context: WeekendContext; now: Date }) {
|
||||
const meeting = meetingIdentity(context.next_meeting ?? context.focus_meeting)
|
||||
export function PreSessionView({
|
||||
context,
|
||||
now,
|
||||
shellAvailability = null,
|
||||
}: {
|
||||
context: WeekendContext
|
||||
now: Date
|
||||
/** Weekend shell notice already shown — Preview dedupes only an equivalent kind. */
|
||||
shellAvailability?: DataAvailability | null
|
||||
}) {
|
||||
const meeting = context.next_meeting ?? context.focus_meeting
|
||||
const identity = meetingIdentity(meeting)
|
||||
const next = context.next_session?.session
|
||||
const nextStart = next?.date_start ?? meeting?.date_start
|
||||
const nodes = railNodes(
|
||||
@@ -25,11 +39,11 @@ export function PreSessionView({ context, now }: { context: WeekendContext; now:
|
||||
<div className="wk-pre" data-testid="weekend-pre-session" data-state="pre_session">
|
||||
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">Pre-session</div>
|
||||
|
||||
{meeting && (
|
||||
{identity && (
|
||||
<header className="wk-pre-head" data-testid="wk-pre-head">
|
||||
<div className="wk-event-id">
|
||||
<Flag code={meeting.country_code} flag={meeting.country_flag} />
|
||||
<h1 className="wk-sessions-title">{meeting.meeting_name}</h1>
|
||||
<Flag code={identity.country_code} flag={identity.country_flag} />
|
||||
<h1 className="wk-sessions-title">{identity.meeting_name}</h1>
|
||||
</div>
|
||||
<div className="wk-pre-countdown">
|
||||
<span className="wk-next-label mono">{next?.session_name ?? 'Next session'}</span>
|
||||
@@ -44,7 +58,12 @@ export function PreSessionView({ context, now }: { context: WeekendContext; now:
|
||||
{nodes.length > 0 && <SessionRail nodes={nodes} />}
|
||||
|
||||
<div className="wk-pre-preview" data-testid="wk-pre-preview">
|
||||
<RacePreviewPage />
|
||||
<RacePreviewPage
|
||||
embedded
|
||||
meeting={meeting}
|
||||
season={context.season ?? meeting?.year}
|
||||
shellAvailability={shellAvailability}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChampionshipRoundStrip
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { userFacingError } from '../../lib/fetch'
|
||||
|
||||
export function WeekendLoading() {
|
||||
return (
|
||||
@@ -9,12 +10,40 @@ export function WeekendLoading() {
|
||||
)
|
||||
}
|
||||
|
||||
export function WeekendError({ message }: { message?: string }) {
|
||||
export function WeekendError({
|
||||
error,
|
||||
message,
|
||||
onRetry,
|
||||
retrying = false,
|
||||
}: {
|
||||
error?: unknown
|
||||
message?: string
|
||||
onRetry?: () => void
|
||||
retrying?: boolean
|
||||
}) {
|
||||
const resolved =
|
||||
message ??
|
||||
(error != null ? userFacingError(error) : 'Something went wrong loading the weekend context.')
|
||||
|
||||
return (
|
||||
<div className="wk-status wk-status-error" data-testid="weekend-error" role="alert">
|
||||
<span className="wk-status-eyebrow mono">box-box · weekend</span>
|
||||
<p className="wk-status-title">Weekend unavailable</p>
|
||||
<p className="wk-status-sub">{message ?? 'Something went wrong loading the weekend context.'}</p>
|
||||
<p className="wk-status-sub">{resolved}</p>
|
||||
{typeof onRetry === 'function' && (
|
||||
<div className="wk-status-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="wk-cta wk-cta-primary"
|
||||
onClick={onRetry}
|
||||
disabled={retrying}
|
||||
aria-busy={retrying || undefined}
|
||||
data-testid="weekend-retry"
|
||||
>
|
||||
{retrying ? 'Retrying…' : 'Retry'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchChampionshipHub, fetchNews, fetchWeekendContext } from '../api'
|
||||
import { weekendContextNotice, type DataAvailability } from '../lib/availability'
|
||||
import { championshipImpact, briefingItems } from '../lib/weekendContext'
|
||||
import type {
|
||||
WeekendChampionshipImpact,
|
||||
@@ -19,7 +20,15 @@ export interface UseWeekendContextResult {
|
||||
championship?: WeekendChampionshipImpact
|
||||
/** Supplementary briefing items (not part of the #72 contract). */
|
||||
briefing: WeekendBriefingItem[]
|
||||
/** Non-blocking availability notice from reported context freshness/headers. */
|
||||
availabilityNotice: DataAvailability | null
|
||||
/** True when championship or news supplements failed while context succeeded. */
|
||||
supplementsLimited: boolean
|
||||
now: Date
|
||||
/** Refetch canonical context and/or failed supplements as appropriate. */
|
||||
refetch: () => void
|
||||
/** True while a relevant refetch is in flight. */
|
||||
isFetching: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,7 +53,7 @@ export function useWeekendContext(): UseWeekendContextResult {
|
||||
|
||||
const contextQuery = useQuery({
|
||||
queryKey: ['weekend-context'],
|
||||
queryFn: fetchWeekendContext,
|
||||
queryFn: ({ signal }) => fetchWeekendContext(signal),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
@@ -56,14 +65,14 @@ export function useWeekendContext(): UseWeekendContextResult {
|
||||
// so a pending/failed canonical read never triggers a request fan-out.
|
||||
const championshipQuery = useQuery({
|
||||
queryKey: ['championship-hub', season ?? 'current'],
|
||||
queryFn: () => fetchChampionshipHub(season),
|
||||
queryFn: ({ signal }) => fetchChampionshipHub(season, signal),
|
||||
enabled: canonicalReady,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const newsQuery = useQuery({
|
||||
queryKey: ['news', 6],
|
||||
queryFn: () => fetchNews(6),
|
||||
queryFn: ({ signal }) => fetchNews(6, undefined, signal),
|
||||
enabled: canonicalReady,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
@@ -74,16 +83,58 @@ export function useWeekendContext(): UseWeekendContextResult {
|
||||
)
|
||||
const briefing = useMemo(() => briefingItems(newsQuery.data ?? []), [newsQuery.data])
|
||||
|
||||
const availabilityNotice = useMemo(
|
||||
() => (canonical ? weekendContextNotice(canonical) : null),
|
||||
[canonical],
|
||||
)
|
||||
|
||||
// Keep Limited visible while a failed supplement is refetching so Retry can
|
||||
// expose disabled/aria-busy. React Query v5 clears isError during that refetch.
|
||||
const supplementDegraded = (q: {
|
||||
isError: boolean
|
||||
isFetching: boolean
|
||||
isFetched: boolean
|
||||
data: unknown
|
||||
}) => q.isError || (q.isFetching && q.isFetched && q.data == null)
|
||||
|
||||
const supplementsLimited =
|
||||
canonicalReady &&
|
||||
(supplementDegraded(championshipQuery) || supplementDegraded(newsQuery))
|
||||
|
||||
let loadState: WeekendLoadState = 'loading'
|
||||
if (contextQuery.isError) loadState = 'error'
|
||||
else if (canonicalReady) loadState = 'ready'
|
||||
|
||||
const refetch = () => {
|
||||
if (!canonicalReady) {
|
||||
if (!contextQuery.isFetching) void contextQuery.refetch()
|
||||
return
|
||||
}
|
||||
// Freshness notice Retry refreshes the canonical context.
|
||||
if (availabilityNotice && !contextQuery.isFetching) {
|
||||
void contextQuery.refetch()
|
||||
}
|
||||
// Limited Retry must hit the failed supplements — not merely context.
|
||||
// Gate on degraded (error or in-flight post-error refetch), not isError alone.
|
||||
if (championshipQuery.isError && !championshipQuery.isFetching) {
|
||||
void championshipQuery.refetch()
|
||||
}
|
||||
if (newsQuery.isError && !newsQuery.isFetching) {
|
||||
void newsQuery.refetch()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
context: canonical,
|
||||
loadState,
|
||||
error: contextQuery.error instanceof Error ? contextQuery.error : undefined,
|
||||
championship,
|
||||
briefing,
|
||||
availabilityNotice,
|
||||
supplementsLimited,
|
||||
now: nowDate,
|
||||
refetch,
|
||||
isFetching:
|
||||
contextQuery.isFetching || championshipQuery.isFetching || newsQuery.isFetching,
|
||||
}
|
||||
}
|
||||
|
||||
135
frontend/src/lib/availability.ts
Normal file
135
frontend/src/lib/availability.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { getResponseAvailability, type ResponseAvailability } from './fetch'
|
||||
import type { ContextSession, WeekendContext } from '../types'
|
||||
|
||||
/** Shared Weekend Context terminology for coverage / availability indicators. */
|
||||
export type DataAvailability = 'local' | 'partial' | 'stale' | 'archive' | 'limited' | 'missing'
|
||||
|
||||
/** Freshness values that warrant a non-blocking DataNotice. */
|
||||
const NOTICE_FRESHNESS = new Set<string>(['stale', 'partial', 'local', 'limited', 'archive'])
|
||||
|
||||
/**
|
||||
* Product-impact severity for aggregating conflicting reported freshness.
|
||||
* Higher wins so routine Local cannot mask Limited/Partial/Stale/Archive.
|
||||
*/
|
||||
const NOTICE_SEVERITY: Record<DataAvailability, number> = {
|
||||
local: 1,
|
||||
archive: 2,
|
||||
stale: 3,
|
||||
partial: 4,
|
||||
limited: 5,
|
||||
missing: 6,
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a reported freshness string onto shared DataNotice vocabulary.
|
||||
* Returns null for unreported / routine-success values (`fresh`, `live`, …).
|
||||
* Never invents stale from React Query staleTime.
|
||||
*/
|
||||
export function noticeFromFreshness(
|
||||
freshness: string | undefined | null,
|
||||
opts?: { includeLocal?: boolean },
|
||||
): DataAvailability | null {
|
||||
if (!freshness) return null
|
||||
const includeLocal = opts?.includeLocal ?? true
|
||||
if (freshness === 'local' && !includeLocal) return null
|
||||
if (!NOTICE_FRESHNESS.has(freshness)) return null
|
||||
return freshness as DataAvailability
|
||||
}
|
||||
|
||||
/** Prefer header freshness, then optional typed payload freshness. */
|
||||
export function noticeFromResponse(
|
||||
data: unknown,
|
||||
opts?: { includeLocal?: boolean; fallbackFreshness?: string },
|
||||
): DataAvailability | null {
|
||||
const meta = getResponseAvailability(data)
|
||||
return (
|
||||
noticeFromFreshness(meta?.freshness, opts) ??
|
||||
noticeFromFreshness(opts?.fallbackFreshness, opts)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the worst (highest product-impact) reported notice among sources.
|
||||
* Equivalent kinds collapse to one; Local never masks a stronger disclosure.
|
||||
*/
|
||||
export function aggregateNotices(
|
||||
notices: Array<DataAvailability | null | undefined>,
|
||||
): DataAvailability | null {
|
||||
let worst: DataAvailability | null = null
|
||||
for (const notice of notices) {
|
||||
if (!notice) continue
|
||||
if (!worst || NOTICE_SEVERITY[notice] > NOTICE_SEVERITY[worst]) {
|
||||
worst = notice
|
||||
}
|
||||
}
|
||||
return worst
|
||||
}
|
||||
|
||||
export function noticeMessage(kind: DataAvailability): string {
|
||||
switch (kind) {
|
||||
case 'stale':
|
||||
return 'Showing stale cached data. Retry to refresh.'
|
||||
case 'partial':
|
||||
return 'Coverage is partial for this view.'
|
||||
case 'local':
|
||||
return 'Showing local season data.'
|
||||
case 'limited':
|
||||
return 'Some optional details are unavailable. Core data is still shown.'
|
||||
case 'archive':
|
||||
return 'Showing an archived snapshot.'
|
||||
case 'missing':
|
||||
return 'Some expected data is missing.'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Session whose state the Weekend shell is presenting — mirrors backend
|
||||
* focusedContextSession so an older terminal session never overrides focus.
|
||||
*/
|
||||
export function focusedContextSession(context: WeekendContext): ContextSession | undefined {
|
||||
if (context.active_session) return context.active_session
|
||||
if (!context.focus_meeting) return undefined
|
||||
const focusKey = context.focus_meeting.meeting_key
|
||||
for (const ref of [
|
||||
context.next_session,
|
||||
context.previous_completed_session,
|
||||
context.default_analysis_session,
|
||||
]) {
|
||||
if (ref?.meeting?.meeting_key === focusKey) return ref
|
||||
// Session.meeting_key is always present even when meeting identity is sparse.
|
||||
if (ref && ref.session.meeting_key === focusKey) return ref
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Weekend shell notice from authoritative response headers when present.
|
||||
* A header that intentionally maps to no notice (e.g. focused local/local with
|
||||
* Local suppressed) must not fall through to older previous-session archive/partial.
|
||||
* When headers are absent, typed focus-session freshness may be used.
|
||||
*/
|
||||
export function weekendContextNotice(
|
||||
context: WeekendContext,
|
||||
responseMeta?: ResponseAvailability,
|
||||
): DataAvailability | null {
|
||||
const headerMeta = responseMeta ?? getResponseAvailability(context)
|
||||
if (headerMeta) {
|
||||
return noticeFromFreshness(headerMeta.freshness, { includeLocal: false })
|
||||
}
|
||||
|
||||
const focus = focusedContextSession(context)
|
||||
return noticeFromFreshness(focus?.availability.freshness, { includeLocal: false })
|
||||
}
|
||||
|
||||
/**
|
||||
* Embedded Preview should disclose its own freshness unless the Weekend shell
|
||||
* already shows an equivalent notice (same DataAvailability kind).
|
||||
*/
|
||||
export function shouldShowEmbeddedNotice(
|
||||
notice: DataAvailability | null | undefined,
|
||||
shellNotice: DataAvailability | null | undefined,
|
||||
): boolean {
|
||||
if (!notice) return false
|
||||
if (!shellNotice) return true
|
||||
return notice !== shellNotice
|
||||
}
|
||||
353
frontend/src/lib/fetch.ts
Normal file
353
frontend/src/lib/fetch.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
/** Bounded fetch helpers for primary-route resilience. */
|
||||
|
||||
import { replaceEqualDeep } from '@tanstack/react-query'
|
||||
|
||||
export const DEFAULT_FETCH_TIMEOUT_MS = 15_000
|
||||
|
||||
/** CORS-readable success provenance headers from the Go API. */
|
||||
export const DATA_SOURCE_HEADER = 'X-BoxBox-Data-Source'
|
||||
export const DATA_FRESHNESS_HEADER = 'X-BoxBox-Data-Freshness'
|
||||
|
||||
/** Reported response source values (`openf1|local|mixed`, plus `fia` for live). */
|
||||
export type DataSourceHeader = 'openf1' | 'local' | 'mixed' | 'fia' | string
|
||||
|
||||
/** Reported freshness (`fresh|stale|local|partial`, plus live/archive/limited). */
|
||||
export type DataFreshnessHeader =
|
||||
| 'fresh'
|
||||
| 'stale'
|
||||
| 'local'
|
||||
| 'partial'
|
||||
| 'live'
|
||||
| 'archive'
|
||||
| 'limited'
|
||||
| string
|
||||
|
||||
/** Additive success-response availability metadata (never inferred from React Query). */
|
||||
export interface ResponseAvailability {
|
||||
source?: DataSourceHeader
|
||||
freshness?: DataFreshnessHeader
|
||||
}
|
||||
|
||||
const responseAvailability = new WeakMap<object, ResponseAvailability>()
|
||||
|
||||
function readHeader(headers: Headers, name: string): string | undefined {
|
||||
const value = headers.get(name)?.trim()
|
||||
return value || undefined
|
||||
}
|
||||
|
||||
function captureResponseAvailability(data: unknown, headers: Headers): void {
|
||||
if (data === null || (typeof data !== 'object' && typeof data !== 'function')) return
|
||||
const source = readHeader(headers, DATA_SOURCE_HEADER)
|
||||
const freshness = readHeader(headers, DATA_FRESHNESS_HEADER)
|
||||
if (!source && !freshness) return
|
||||
responseAvailability.set(data as object, { source, freshness })
|
||||
}
|
||||
|
||||
/** Read availability metadata captured from the last successful fetch of this payload. */
|
||||
export function getResponseAvailability(data: unknown): ResponseAvailability | undefined {
|
||||
if (data === null || (typeof data !== 'object' && typeof data !== 'function')) return undefined
|
||||
return responseAvailability.get(data as object)
|
||||
}
|
||||
|
||||
/** Test / manual helper — attach reported availability to a payload object. */
|
||||
export function rememberResponseAvailability(
|
||||
data: object,
|
||||
meta: ResponseAvailability,
|
||||
): void {
|
||||
responseAvailability.set(data, meta)
|
||||
}
|
||||
|
||||
/** Drop availability metadata from a payload (e.g. after a header-less refetch). */
|
||||
export function clearResponseAvailability(data: object): void {
|
||||
responseAvailability.delete(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* React Query structural sharing that keeps header availability in sync.
|
||||
*
|
||||
* Default `replaceEqualDeep` reuses the previous object when JSON is equal, which
|
||||
* would leave stale WeakMap metadata attached after a stale→fresh (or fresh→partial)
|
||||
* refetch of an identical body. Always re-bind the latest fetch's metadata onto the
|
||||
* object that lands in the query cache.
|
||||
*/
|
||||
export function availabilityAwareStructuralSharing<T>(
|
||||
oldData: T | undefined,
|
||||
newData: T,
|
||||
): T {
|
||||
const meta = getResponseAvailability(newData)
|
||||
const shared = replaceEqualDeep(oldData, newData) as T
|
||||
if (shared !== null && typeof shared === 'object') {
|
||||
if (meta) {
|
||||
rememberResponseAvailability(shared as object, meta)
|
||||
} else {
|
||||
clearResponseAvailability(shared as object)
|
||||
}
|
||||
}
|
||||
return shared
|
||||
}
|
||||
|
||||
export type ApiErrorKind = 'http' | 'timeout' | 'abort' | 'network'
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly kind: ApiErrorKind
|
||||
readonly status?: number
|
||||
readonly stale: boolean
|
||||
readonly userMessage: string
|
||||
|
||||
constructor(
|
||||
kind: ApiErrorKind,
|
||||
message: string,
|
||||
opts?: { status?: number; stale?: boolean; userMessage?: string },
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.kind = kind
|
||||
this.status = opts?.status
|
||||
this.stale = opts?.stale ?? false
|
||||
this.userMessage = opts?.userMessage ?? defaultUserMessage(kind, opts?.stale)
|
||||
}
|
||||
}
|
||||
|
||||
function defaultUserMessage(kind: ApiErrorKind, stale?: boolean): string {
|
||||
if (stale) {
|
||||
return 'Showing cached data because a fresh update did not arrive. Retry to refresh.'
|
||||
}
|
||||
switch (kind) {
|
||||
case 'timeout':
|
||||
return 'This request took too long. Check your connection, then retry.'
|
||||
case 'abort':
|
||||
return 'This request was cancelled. Retry when you are ready.'
|
||||
case 'network':
|
||||
return 'Could not reach box-box. Check your connection, then retry.'
|
||||
case 'http':
|
||||
default:
|
||||
return 'Something went wrong loading this view. Retry to try again.'
|
||||
}
|
||||
}
|
||||
|
||||
export function isApiError(err: unknown): err is ApiError {
|
||||
return err instanceof ApiError
|
||||
}
|
||||
|
||||
export function isTimeoutError(err: unknown): boolean {
|
||||
return isApiError(err) && err.kind === 'timeout'
|
||||
}
|
||||
|
||||
export function userFacingError(err: unknown): string {
|
||||
if (isApiError(err)) return err.userMessage
|
||||
if (err instanceof Error && err.message) {
|
||||
// Never surface raw "API 500: Internal Server Error" style strings.
|
||||
if (/^API\s+\d+/i.test(err.message) || /status\s+\d+/i.test(err.message)) {
|
||||
return defaultUserMessage('http')
|
||||
}
|
||||
return err.message
|
||||
}
|
||||
return defaultUserMessage('http')
|
||||
}
|
||||
|
||||
export interface ApiFetchOptions {
|
||||
timeoutMs?: number
|
||||
signal?: AbortSignal
|
||||
/** When set, concurrent identical in-flight requests share one promise. */
|
||||
dedupeKey?: string
|
||||
method?: string
|
||||
headers?: HeadersInit
|
||||
body?: BodyInit | null
|
||||
}
|
||||
|
||||
interface InflightEntry {
|
||||
/** Shared underlying fetch — not tied to any single caller's AbortSignal. */
|
||||
promise: Promise<unknown>
|
||||
/** Aborts the shared request only when the last subscriber cancels. */
|
||||
controller: AbortController
|
||||
subscribers: number
|
||||
}
|
||||
|
||||
const inflight = new Map<string, InflightEntry>()
|
||||
|
||||
function abortError(): ApiError {
|
||||
return new ApiError('abort', 'Request aborted', {
|
||||
userMessage: defaultUserMessage('abort'),
|
||||
})
|
||||
}
|
||||
|
||||
function combineAbortSignals(signals: AbortSignal[]): AbortSignal {
|
||||
const controller = new AbortController()
|
||||
const onAbort = () => {
|
||||
const reason = signals.find((s) => s.aborted)?.reason
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort(reason)
|
||||
}
|
||||
}
|
||||
for (const signal of signals) {
|
||||
if (signal.aborted) {
|
||||
onAbort()
|
||||
break
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
}
|
||||
return controller.signal
|
||||
}
|
||||
|
||||
async function parseErrorBody(res: Response): Promise<{ error?: string; stale?: boolean }> {
|
||||
try {
|
||||
const data = (await res.json()) as { error?: string; stale?: boolean }
|
||||
return data ?? {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
async function rawApiFetch<T>(url: string, options: ApiFetchOptions = {}): Promise<T> {
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS
|
||||
const timeoutController = new AbortController()
|
||||
const timer = setTimeout(() => {
|
||||
timeoutController.abort(new DOMException('Request timed out', 'TimeoutError'))
|
||||
}, timeoutMs)
|
||||
|
||||
const signals = [timeoutController.signal]
|
||||
if (options.signal) signals.push(options.signal)
|
||||
const signal = combineAbortSignals(signals)
|
||||
|
||||
try {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: options.method,
|
||||
headers: options.headers,
|
||||
body: options.body,
|
||||
signal,
|
||||
})
|
||||
} catch (err) {
|
||||
if (timeoutController.signal.aborted && !options.signal?.aborted) {
|
||||
throw new ApiError('timeout', 'Request timed out', {
|
||||
userMessage: defaultUserMessage('timeout'),
|
||||
})
|
||||
}
|
||||
if (options.signal?.aborted || (err instanceof DOMException && err.name === 'AbortError')) {
|
||||
throw abortError()
|
||||
}
|
||||
throw new ApiError('network', err instanceof Error ? err.message : 'Network error', {
|
||||
userMessage: defaultUserMessage('network'),
|
||||
})
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await parseErrorBody(res)
|
||||
throw new ApiError('http', body.error || `HTTP ${res.status}`, {
|
||||
status: res.status,
|
||||
stale: Boolean(body.stale),
|
||||
userMessage: body.stale
|
||||
? defaultUserMessage('http', true)
|
||||
: defaultUserMessage('http'),
|
||||
})
|
||||
}
|
||||
|
||||
const data = (await res.json()) as T
|
||||
captureResponseAvailability(data, res.headers)
|
||||
return data
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a caller to a shared in-flight request.
|
||||
* One caller's abort rejects only that caller; the shared fetch continues
|
||||
* while other subscribers remain, and is cancelled only when the last one leaves.
|
||||
*/
|
||||
function subscribeToInflight<T>(
|
||||
key: string,
|
||||
entry: InflightEntry,
|
||||
callerSignal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
if (callerSignal?.aborted) {
|
||||
return Promise.reject(abortError())
|
||||
}
|
||||
|
||||
entry.subscribers += 1
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false
|
||||
|
||||
const leave = () => {
|
||||
entry.subscribers -= 1
|
||||
if (entry.subscribers <= 0) {
|
||||
if (!entry.controller.signal.aborted) {
|
||||
entry.controller.abort()
|
||||
}
|
||||
if (inflight.get(key) === entry) {
|
||||
inflight.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onCallerAbort = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
leave()
|
||||
reject(abortError())
|
||||
}
|
||||
|
||||
if (callerSignal) {
|
||||
callerSignal.addEventListener('abort', onCallerAbort, { once: true })
|
||||
}
|
||||
|
||||
entry.promise.then(
|
||||
(value) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
callerSignal?.removeEventListener('abort', onCallerAbort)
|
||||
resolve(value as T)
|
||||
},
|
||||
(err) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
callerSignal?.removeEventListener('abort', onCallerAbort)
|
||||
reject(err)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch JSON with a bounded timeout, abort propagation, typed errors,
|
||||
* and optional in-flight deduplication (same key shares one underlying request).
|
||||
*
|
||||
* Deduped requests are subscriber-safe: aborting one consumer does not cancel
|
||||
* or poison other consumers that still hold the same key (React Strict Mode remounts).
|
||||
*/
|
||||
export function apiFetch<T>(url: string, options: ApiFetchOptions = {}): Promise<T> {
|
||||
const key = options.dedupeKey
|
||||
if (!key) {
|
||||
return rawApiFetch<T>(url, options)
|
||||
}
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
return Promise.reject(abortError())
|
||||
}
|
||||
|
||||
let entry = inflight.get(key)
|
||||
if (!entry) {
|
||||
const controller = new AbortController()
|
||||
// Shared fetch only listens to the ref-counted controller + timeout —
|
||||
// never to any single caller's AbortSignal.
|
||||
const promise = rawApiFetch<T>(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
}).finally(() => {
|
||||
if (inflight.get(key)?.promise === promise) {
|
||||
inflight.delete(key)
|
||||
}
|
||||
})
|
||||
entry = { promise, controller, subscribers: 0 }
|
||||
inflight.set(key, entry)
|
||||
}
|
||||
|
||||
return subscribeToInflight<T>(key, entry, options.signal)
|
||||
}
|
||||
|
||||
/** Test helper — clears the in-flight dedupe map. */
|
||||
export function clearApiFetchInflight(): void {
|
||||
inflight.clear()
|
||||
}
|
||||
16
frontend/src/lib/queryClient.ts
Normal file
16
frontend/src/lib/queryClient.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { availabilityAwareStructuralSharing } from './fetch'
|
||||
|
||||
/** Shared QueryClient defaults — availability-aware structural sharing for header metadata. */
|
||||
export function createAppQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
// Explicit Retry on RouteState — avoid automatic retry storms.
|
||||
retry: false,
|
||||
structuralSharing: availabilityAwareStructuralSharing,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,18 +1,12 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { RouterProvider } from '@tanstack/react-router'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { createAppQueryClient } from './lib/queryClient'
|
||||
import { router } from './router'
|
||||
import './styles/app.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
const queryClient = createAppQueryClient()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
} from '../lib/digest'
|
||||
import { stripHtml, timeAgo } from '../utils'
|
||||
import type { ArticleContent, NewsItem } from '../types'
|
||||
import { DataNotice, RouteState } from '../components/RouteState'
|
||||
import { aggregateNotices, noticeFromResponse, noticeMessage } from '../lib/availability'
|
||||
import '../styles/digest.css'
|
||||
|
||||
type Category = 'all' | 'official' | 'news' | 'video'
|
||||
@@ -406,27 +408,27 @@ export function BriefingPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const now = useMemo(() => new Date(), [])
|
||||
|
||||
const { data: allNews = [], isLoading, isError } = useQuery({
|
||||
const { data: allNews = [], isLoading, isError, error, refetch, isFetching } = useQuery({
|
||||
queryKey: ['news'],
|
||||
queryFn: () => fetchNews(100),
|
||||
queryFn: ({ signal }) => fetchNews(100, undefined, signal),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const seasonsQuery = useQuery({
|
||||
queryKey: ['seasons'],
|
||||
queryFn: fetchSeasons,
|
||||
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||
})
|
||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||
|
||||
const meetingsQuery = useQuery({
|
||||
queryKey: ['season-meetings', latestSeason],
|
||||
queryFn: () => fetchSeasonMeetings(latestSeason!),
|
||||
queryFn: ({ signal }) => fetchSeasonMeetings(latestSeason!, signal),
|
||||
enabled: latestSeason != null,
|
||||
})
|
||||
|
||||
const hubQuery = useQuery({
|
||||
queryKey: ['championship-hub', latestSeason],
|
||||
queryFn: () => fetchChampionshipHub(latestSeason!),
|
||||
queryFn: ({ signal }) => fetchChampionshipHub(latestSeason!, signal),
|
||||
enabled: latestSeason != null,
|
||||
})
|
||||
|
||||
@@ -515,6 +517,43 @@ export function BriefingPage() {
|
||||
const hasDigest = meetings.length > 0
|
||||
const showEmpty = tagFiltered.length === 0 && grouped.recent.length === 0
|
||||
|
||||
const supplementsFetching =
|
||||
meetingsQuery.isFetching || hubQuery.isFetching || seasonsQuery.isFetching
|
||||
// React Query v5 clears isError while a post-error refetch is in flight
|
||||
// (status → pending). Keep Limited mounted so Retry can show aria-busy.
|
||||
const supplementDegraded = (q: {
|
||||
isError: boolean
|
||||
isFetching: boolean
|
||||
isFetched: boolean
|
||||
data: unknown
|
||||
}) => q.isError || (q.isFetching && q.isFetched && q.data == null)
|
||||
const supplementsLimited =
|
||||
!isLoading &&
|
||||
!isError &&
|
||||
(supplementDegraded(meetingsQuery) ||
|
||||
supplementDegraded(hubQuery) ||
|
||||
supplementDegraded(seasonsQuery))
|
||||
const supplementsRetrying = supplementsLimited && supplementsFetching
|
||||
|
||||
const retrySupplements = () => {
|
||||
if (supplementDegraded(meetingsQuery) && !meetingsQuery.isFetching) {
|
||||
void meetingsQuery.refetch()
|
||||
}
|
||||
if (supplementDegraded(hubQuery) && !hubQuery.isFetching) {
|
||||
void hubQuery.refetch()
|
||||
}
|
||||
if (supplementDegraded(seasonsQuery) && !seasonsQuery.isFetching) {
|
||||
void seasonsQuery.refetch()
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate by severity so routine local News cannot mask stale hub/meetings.
|
||||
const availability = aggregateNotices([
|
||||
noticeFromResponse(allNews, { includeLocal: true }),
|
||||
noticeFromResponse(hub, { includeLocal: false }),
|
||||
noticeFromResponse(meetings, { includeLocal: false }),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="bp-page" data-testid="briefing-page">
|
||||
<div className="bp-topbar">
|
||||
@@ -524,11 +563,45 @@ export function BriefingPage() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isLoading && <div className="loading-state">loading briefing…</div>}
|
||||
{isError && <div className="error-box">Failed to load paddock briefing.</div>}
|
||||
{isLoading && (
|
||||
<RouteState kind="loading" title="loading briefing…" testId="briefing-loading" />
|
||||
)}
|
||||
{isError && (
|
||||
<RouteState
|
||||
kind="error"
|
||||
title="Briefing unavailable"
|
||||
error={error}
|
||||
onRetry={() => {
|
||||
if (!isFetching) void refetch()
|
||||
}}
|
||||
retrying={isFetching}
|
||||
testId="briefing-error"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && (
|
||||
<>
|
||||
{supplementsLimited && (
|
||||
<DataNotice
|
||||
availability="limited"
|
||||
message="Weekend grouping or driver tags are limited. Articles are still available."
|
||||
onRetry={retrySupplements}
|
||||
retrying={supplementsRetrying}
|
||||
testId="briefing-data-notice"
|
||||
/>
|
||||
)}
|
||||
{!supplementsLimited && availability && (
|
||||
<DataNotice
|
||||
availability={availability}
|
||||
message={noticeMessage(availability)}
|
||||
onRetry={() => {
|
||||
if (!isFetching) void refetch()
|
||||
}}
|
||||
retrying={isFetching}
|
||||
testId="briefing-data-notice"
|
||||
/>
|
||||
)}
|
||||
|
||||
<CategoryTabs
|
||||
active={activeCategory}
|
||||
counts={counts}
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { ChampHubDriver, ChampionshipHub } from '../types'
|
||||
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
|
||||
import { RivalryCompare } from '../components/RivalryCompare'
|
||||
import { Meaning } from '../components/Meaning'
|
||||
import { DataNotice, RouteState } from '../components/RouteState'
|
||||
import { TeammateH2H } from '../components/TeammateH2H'
|
||||
import { noticeFromResponse, noticeMessage } from '../lib/availability'
|
||||
import { teammatePairs } from '../lib/h2h'
|
||||
import { pointsGapMeaning } from '../lib/meaning'
|
||||
|
||||
@@ -81,23 +83,55 @@ function teamSplit(teamName: string, drivers: ChampHubDriver[]): TeamSplit {
|
||||
export function ChampionshipPage() {
|
||||
const [view, setView] = useState<View>('drivers')
|
||||
|
||||
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons })
|
||||
const seasonsQuery = useQuery({
|
||||
queryKey: ['seasons'],
|
||||
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||
})
|
||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||
|
||||
const hubQuery = useQuery({
|
||||
queryKey: ['championship-hub', latestSeason],
|
||||
queryFn: () => fetchChampionshipHub(latestSeason ?? undefined),
|
||||
queryFn: ({ signal }) => fetchChampionshipHub(latestSeason ?? undefined, signal),
|
||||
enabled: latestSeason != null,
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
|
||||
if (seasonsQuery.isLoading || hubQuery.isLoading) {
|
||||
return <div className="page loading-state">loading championship…</div>
|
||||
return (
|
||||
<div className="page">
|
||||
<RouteState kind="loading" title="loading championship…" testId="championship-loading" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (seasonsQuery.isError) {
|
||||
return (
|
||||
<div className="page">
|
||||
<RouteState
|
||||
kind="error"
|
||||
title="Championship unavailable"
|
||||
error={seasonsQuery.error}
|
||||
onRetry={() => {
|
||||
if (!seasonsQuery.isFetching) void seasonsQuery.refetch()
|
||||
}}
|
||||
retrying={seasonsQuery.isFetching}
|
||||
testId="championship-error"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (hubQuery.isError) {
|
||||
return (
|
||||
<div className="page error-box">
|
||||
{hubQuery.error instanceof Error ? hubQuery.error.message : 'Failed to load championship'}
|
||||
<div className="page">
|
||||
<RouteState
|
||||
kind="error"
|
||||
title="Championship unavailable"
|
||||
error={hubQuery.error}
|
||||
onRetry={() => {
|
||||
if (!hubQuery.isFetching) void hubQuery.refetch()
|
||||
}}
|
||||
retrying={hubQuery.isFetching}
|
||||
testId="championship-error"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -118,16 +152,32 @@ export function ChampionshipPage() {
|
||||
)
|
||||
}
|
||||
|
||||
return <ChampionshipBody hub={hub} view={view} setView={setView} />
|
||||
const availability = noticeFromResponse(hub, { includeLocal: true })
|
||||
|
||||
return (
|
||||
<ChampionshipBody
|
||||
hub={hub}
|
||||
view={view}
|
||||
setView={setView}
|
||||
availability={availability}
|
||||
onRetry={() => {
|
||||
if (!hubQuery.isFetching) void hubQuery.refetch()
|
||||
}}
|
||||
retrying={hubQuery.isFetching}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface BodyProps {
|
||||
hub: ChampionshipHub
|
||||
view: View
|
||||
setView: (v: View) => void
|
||||
availability?: ReturnType<typeof noticeFromResponse>
|
||||
onRetry?: () => void
|
||||
retrying?: boolean
|
||||
}
|
||||
|
||||
function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
||||
function ChampionshipBody({ hub, view, setView, availability, onRetry, retrying }: BodyProps) {
|
||||
const { drivers, teams } = hub
|
||||
const leader = drivers[0]
|
||||
const remaining = hub.rounds_left * 25
|
||||
@@ -183,6 +233,15 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
||||
|
||||
return (
|
||||
<div className="champ-page" data-testid="championship">
|
||||
{availability && (
|
||||
<DataNotice
|
||||
availability={availability}
|
||||
message={noticeMessage(availability)}
|
||||
onRetry={onRetry}
|
||||
retrying={retrying}
|
||||
testId="championship-data-notice"
|
||||
/>
|
||||
)}
|
||||
<div className="champ-header">
|
||||
<div className="champ-title-row">
|
||||
<span className="champ-accent" aria-hidden="true" />
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { SourceBadge, weekendStatusLabel } from '../components/SourceBadge'
|
||||
import { CliCommands, ingestYearCommands } from '../components/CliCommands'
|
||||
import { MeetingDetailPanel } from '../components/MeetingDetailPanel'
|
||||
import { RouteState } from '../components/RouteState'
|
||||
import type { Meeting, Weekend } from '../types'
|
||||
|
||||
function formatMeetingDate(meeting: Meeting): string {
|
||||
@@ -27,12 +28,12 @@ export function DataLibraryPage() {
|
||||
|
||||
const seasonsQuery = useQuery({
|
||||
queryKey: ['seasons'],
|
||||
queryFn: fetchSeasons,
|
||||
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||
})
|
||||
|
||||
const meetingsQuery = useQuery({
|
||||
queryKey: ['meetings', selectedYear],
|
||||
queryFn: () => fetchLocalMeetings(selectedYear!),
|
||||
queryFn: ({ signal }) => fetchLocalMeetings(selectedYear!, signal),
|
||||
enabled: selectedYear != null,
|
||||
})
|
||||
|
||||
@@ -41,7 +42,7 @@ export function DataLibraryPage() {
|
||||
const weekendQueries = useQueries({
|
||||
queries: meetings.map((meeting) => ({
|
||||
queryKey: ['weekend', meeting.meeting_key],
|
||||
queryFn: () => fetchWeekend(meeting.meeting_key),
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) => fetchWeekend(meeting.meeting_key, signal),
|
||||
enabled: meetings.length > 0,
|
||||
staleTime: 60_000,
|
||||
})),
|
||||
@@ -78,13 +79,26 @@ export function DataLibraryPage() {
|
||||
const weekendsLoading = weekendQueries.some((q) => q.isLoading)
|
||||
|
||||
if (seasonsQuery.isLoading) {
|
||||
return <div className="page loading-state">loading local data library…</div>
|
||||
return (
|
||||
<div className="page">
|
||||
<RouteState kind="loading" title="loading local data library…" testId="weekend-loading" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (seasonsQuery.isError) {
|
||||
return (
|
||||
<div className="page error-box">
|
||||
{seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'}
|
||||
<div className="page">
|
||||
<RouteState
|
||||
kind="error"
|
||||
title="Weekend data unavailable"
|
||||
error={seasonsQuery.error}
|
||||
onRetry={() => {
|
||||
if (!seasonsQuery.isFetching) void seasonsQuery.refetch()
|
||||
}}
|
||||
retrying={seasonsQuery.isFetching}
|
||||
testId="weekend-error"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -222,15 +236,20 @@ export function DataLibraryPage() {
|
||||
</div>
|
||||
|
||||
{meetingsQuery.isLoading && (
|
||||
<div className="loading-state">loading meetings…</div>
|
||||
<RouteState kind="loading" title="loading meetings…" testId="weekend-meetings-loading" />
|
||||
)}
|
||||
|
||||
{meetingsQuery.isError && (
|
||||
<div className="error-box">
|
||||
{meetingsQuery.error instanceof Error
|
||||
? meetingsQuery.error.message
|
||||
: 'Failed to load meetings'}
|
||||
</div>
|
||||
<RouteState
|
||||
kind="error"
|
||||
title="Weekend meetings unavailable"
|
||||
error={meetingsQuery.error}
|
||||
onRetry={() => {
|
||||
if (!meetingsQuery.isFetching) void meetingsQuery.refetch()
|
||||
}}
|
||||
retrying={meetingsQuery.isFetching}
|
||||
testId="weekend-error"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && (
|
||||
@@ -316,11 +335,16 @@ export function DataLibraryPage() {
|
||||
|
||||
<div className="dl-detail-wrap">
|
||||
{weekendsLoading && selectedWeekend == null && (
|
||||
<div className="loading-state">loading weekend details…</div>
|
||||
<RouteState kind="loading" title="loading weekend details…" testId="weekend-detail-loading" />
|
||||
)}
|
||||
{selectedWeekend && <MeetingDetailPanel weekend={selectedWeekend} />}
|
||||
{selectedMeetingKey != null && !weekendsLoading && selectedWeekend == null && (
|
||||
<div className="missing-notice">Could not load weekend details.</div>
|
||||
<RouteState
|
||||
kind="empty"
|
||||
title="Weekend details missing"
|
||||
message="Could not load weekend details for this meeting."
|
||||
testId="weekend-detail-empty"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { fetchDriverSummary, fetchSeasons } from '../api'
|
||||
import { DataNotice, RouteState } from '../components/RouteState'
|
||||
import { noticeFromResponse, noticeMessage } from '../lib/availability'
|
||||
import { teamColor } from '../utils'
|
||||
import { countryFlag } from '../lib/gpIdentity'
|
||||
import {
|
||||
@@ -26,7 +28,7 @@ export function DriverProfilePage({ driverNumber, year }: Props) {
|
||||
// seasons list is empty/unavailable the backend falls back to the current year.
|
||||
const seasonsQuery = useQuery({
|
||||
queryKey: ['seasons'],
|
||||
queryFn: fetchSeasons,
|
||||
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||
enabled: year == null,
|
||||
})
|
||||
const resolvedYear = year ?? seasonsQuery.data?.[0]
|
||||
@@ -34,39 +36,102 @@ export function DriverProfilePage({ driverNumber, year }: Props) {
|
||||
|
||||
const summaryQuery = useQuery({
|
||||
queryKey: ['driver-summary', driverNumber, resolvedYear ?? 'latest'],
|
||||
queryFn: () => fetchDriverSummary(driverNumber, resolvedYear),
|
||||
queryFn: ({ signal }) => fetchDriverSummary(driverNumber, resolvedYear, signal),
|
||||
enabled: seasonsSettled && driverNumber > 0,
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
|
||||
if (driverNumber <= 0) {
|
||||
return <div className="page error-box">Invalid driver number</div>
|
||||
return (
|
||||
<div className="page">
|
||||
<RouteState kind="empty" title="Invalid driver number" message="Pick a driver from the championship standings." />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!seasonsSettled || summaryQuery.isLoading) {
|
||||
return <div className="page loading-state">loading driver profile…</div>
|
||||
return (
|
||||
<div className="page">
|
||||
<RouteState kind="loading" title="loading driver profile…" testId="driver-profile-loading" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (summaryQuery.isError) {
|
||||
return (
|
||||
<div className="page error-box">
|
||||
{summaryQuery.error instanceof Error
|
||||
? summaryQuery.error.message
|
||||
: 'Failed to load driver profile'}
|
||||
<div className="page">
|
||||
<RouteState
|
||||
kind="error"
|
||||
title="Driver profile unavailable"
|
||||
error={summaryQuery.error}
|
||||
onRetry={() => {
|
||||
if (!summaryQuery.isFetching) void summaryQuery.refetch()
|
||||
}}
|
||||
retrying={summaryQuery.isFetching}
|
||||
testId="driver-profile-error"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const summary = summaryQuery.data
|
||||
if (!summary) {
|
||||
return <div className="page error-box">No driver data</div>
|
||||
return (
|
||||
<div className="page">
|
||||
<RouteState
|
||||
kind="empty"
|
||||
title="No driver data"
|
||||
message="This driver is not in the local season standings yet."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <DriverProfileBody summary={summary} />
|
||||
return (
|
||||
<DriverProfileBody
|
||||
summary={summary}
|
||||
availability={noticeFromResponse(summary, { includeLocal: false })}
|
||||
onRetry={() => {
|
||||
if (!summaryQuery.isFetching) void summaryQuery.refetch()
|
||||
}}
|
||||
retrying={summaryQuery.isFetching}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DriverProfileBody({ summary }: { summary: DriverSummary }) {
|
||||
function DriverProfileBody({
|
||||
summary,
|
||||
availability,
|
||||
onRetry,
|
||||
retrying,
|
||||
}: {
|
||||
summary: DriverSummary
|
||||
availability?: ReturnType<typeof noticeFromResponse>
|
||||
onRetry?: () => void
|
||||
retrying?: boolean
|
||||
}) {
|
||||
const color = teamColor(summary.team_colour)
|
||||
const deltas = gridFinishDeltas(summary.rounds)
|
||||
|
||||
return (
|
||||
<div className="dp-page" data-testid="driver-profile">
|
||||
{summary.enrichment === 'limited' && (
|
||||
<DataNotice
|
||||
availability="limited"
|
||||
message="Optional remote details are unavailable. Showing local season identity and results."
|
||||
onRetry={onRetry}
|
||||
retrying={retrying}
|
||||
testId="driver-profile-limited"
|
||||
/>
|
||||
)}
|
||||
{summary.source === 'local' && summary.enrichment !== 'limited' && (
|
||||
<DataNotice availability="local" message="Loaded from local season data." testId="driver-profile-local" />
|
||||
)}
|
||||
{availability === 'stale' && summary.enrichment !== 'limited' && (
|
||||
<DataNotice
|
||||
availability="stale"
|
||||
message={noticeMessage('stale')}
|
||||
onRetry={onRetry}
|
||||
retrying={retrying}
|
||||
testId="driver-profile-stale"
|
||||
/>
|
||||
)}
|
||||
<header className="dp-header" data-testid="dp-header" style={{ borderLeftColor: color }}>
|
||||
<div className="dp-identity">
|
||||
<div className="dp-title-row">
|
||||
|
||||
@@ -23,22 +23,25 @@ function analysisKey(weekend: Weekend | undefined): number | undefined {
|
||||
export function ExplorePage() {
|
||||
const now = useMemo(() => new Date(), [])
|
||||
|
||||
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons })
|
||||
const seasonsQuery = useQuery({
|
||||
queryKey: ['seasons'],
|
||||
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||
})
|
||||
const season = seasonsQuery.data?.[0] ?? null
|
||||
|
||||
const localMeetingsQuery = useQuery({
|
||||
queryKey: ['meetings', season, 'local'],
|
||||
queryFn: () => fetchLocalMeetings(season!),
|
||||
queryFn: ({ signal }) => fetchLocalMeetings(season!, signal),
|
||||
enabled: season != null,
|
||||
})
|
||||
const seasonMeetingsQuery = useQuery({
|
||||
queryKey: ['season-meetings', season],
|
||||
queryFn: () => fetchSeasonMeetings(season!),
|
||||
queryFn: ({ signal }) => fetchSeasonMeetings(season!, signal),
|
||||
enabled: season != null,
|
||||
})
|
||||
const championshipQuery = useQuery({
|
||||
queryKey: ['championship-hub', season],
|
||||
queryFn: () => fetchChampionshipHub(season!),
|
||||
queryFn: ({ signal }) => fetchChampionshipHub(season!, signal),
|
||||
enabled: season != null,
|
||||
})
|
||||
|
||||
@@ -48,7 +51,7 @@ export function ExplorePage() {
|
||||
const weekendQueries = useQueries({
|
||||
queries: localMeetings.map((m) => ({
|
||||
queryKey: ['weekend', m.meeting_key],
|
||||
queryFn: () => fetchWeekend(m.meeting_key),
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) => fetchWeekend(m.meeting_key, signal),
|
||||
enabled: localMeetings.length > 0,
|
||||
staleTime: 60_000,
|
||||
})),
|
||||
|
||||
@@ -36,6 +36,8 @@ import { EventRail } from '../components/live/EventRail'
|
||||
import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
|
||||
import { TyreDegPanel } from '../components/live/TyreDegPanel'
|
||||
import { LiveHandoff } from '../components/live/LiveHandoff'
|
||||
import { DataNotice, RouteState } from '../components/RouteState'
|
||||
import { weekendContextNotice } from '../lib/availability'
|
||||
import '../styles/live-state.css'
|
||||
|
||||
/** How often to re-check weekend-context while analysis is still ingesting. */
|
||||
@@ -56,6 +58,9 @@ export function LiveTimingPage() {
|
||||
const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({})
|
||||
const [, setPositions] = useState<Record<string, LivePosition>>({})
|
||||
const [events, setEvents] = useState<LiveEvent[]>([])
|
||||
// Authoritative SSE snapshot/event receipt — distinct from non-null timing rows.
|
||||
// An inactive `is_live:false,data:null,last_snapshot:null` event is still valid.
|
||||
const [authoritativeSseReceived, setAuthoritativeSseReceived] = useState(false)
|
||||
const prevSnapshotRef = useRef<LiveStreamData | null>(null)
|
||||
const sessionSigRef = useRef('')
|
||||
const isLiveRef = useRef(false)
|
||||
@@ -67,12 +72,15 @@ export function LiveTimingPage() {
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
isFetching,
|
||||
isFetched: liveStateFetched,
|
||||
} = useQuery({
|
||||
queryKey: ['live-state'],
|
||||
queryFn: fetchLiveState,
|
||||
queryFn: ({ signal }) => fetchLiveState(signal),
|
||||
staleTime: 5_000,
|
||||
})
|
||||
|
||||
@@ -109,7 +117,7 @@ export function LiveTimingPage() {
|
||||
// analysis-ready without a manual refresh.
|
||||
const contextQuery = useQuery({
|
||||
queryKey: ['weekend-context'],
|
||||
queryFn: fetchWeekendContext,
|
||||
queryFn: ({ signal }) => fetchWeekendContext(signal),
|
||||
enabled: !isLive,
|
||||
staleTime: WEEKEND_CONTEXT_STALE_MS,
|
||||
refetchInterval: (query) => {
|
||||
@@ -120,6 +128,12 @@ export function LiveTimingPage() {
|
||||
refetchIntervalInBackground: false,
|
||||
})
|
||||
const weekendContext = contextQuery.data
|
||||
// Context freshness belongs only on inactive/settling handoff — never relabel
|
||||
// active FIA timing because an optional REST supplement reported stale/limited.
|
||||
const contextAvailabilityNotice =
|
||||
(phase === 'settling' || phase === 'inactive') && weekendContext
|
||||
? weekendContextNotice(weekendContext)
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
@@ -169,6 +183,9 @@ export function LiveTimingPage() {
|
||||
events.addEventListener('snapshot', (event) => {
|
||||
const state = parseLiveStateEvent(event.data)
|
||||
if (!state || cancelled) return
|
||||
// Valid SSE state clears a false fatal REST error even when all snapshots
|
||||
// are null (inactive handoff) — do not require non-null timing rows.
|
||||
setAuthoritativeSseReceived(true)
|
||||
const nextLive = state.is_live && Boolean(state.data)
|
||||
setIsLive(nextLive)
|
||||
if (nextLive && state.data) {
|
||||
@@ -293,12 +310,25 @@ export function LiveTimingPage() {
|
||||
// feed-health truth rather than "Connection lost" + "Feed healthy".
|
||||
const feedHealth = effectiveFeedHealth(streamStatus, phase)
|
||||
|
||||
// Usable timing rows OR an authoritative SSE state event (including inactive
|
||||
// null payloads) clear the fatal initial REST error. Preserve error+Retry only
|
||||
// when neither REST nor SSE yields a valid state.
|
||||
const hasUsableStreamData = Boolean(activeSnapshot) || Boolean(archiveSnapshot)
|
||||
const showInitialError = isError && !hasUsableStreamData && !authoritativeSseReceived
|
||||
|
||||
return (
|
||||
<div className="page live-page" data-testid="live-page" data-phase={phase}>
|
||||
{isError && (
|
||||
<div className="error-box">
|
||||
{error instanceof Error ? error.message : 'Failed to load live timing state'}
|
||||
</div>
|
||||
{showInitialError && (
|
||||
<RouteState
|
||||
kind="error"
|
||||
title="Live timing unavailable"
|
||||
error={error}
|
||||
onRetry={() => {
|
||||
if (!isFetching) void refetch()
|
||||
}}
|
||||
retrying={isFetching}
|
||||
testId="live-initial-error"
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === 'disconnected' && (
|
||||
@@ -316,11 +346,22 @@ export function LiveTimingPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'connecting' && (
|
||||
<div className="loading-state">connecting to live timing…</div>
|
||||
{phase === 'connecting' && (isLoading || !liveStateFetched) && !hasUsableStreamData && (
|
||||
<RouteState
|
||||
kind="loading"
|
||||
title="connecting to live timing…"
|
||||
testId="live-initial-loading"
|
||||
/>
|
||||
)}
|
||||
|
||||
{(phase === 'settling' || phase === 'inactive') && (
|
||||
{(phase === 'settling' || phase === 'inactive') && !showInitialError && (
|
||||
<>
|
||||
{contextAvailabilityNotice && (
|
||||
<DataNotice
|
||||
availability={contextAvailabilityNotice}
|
||||
testId="live-context-data-notice"
|
||||
/>
|
||||
)}
|
||||
<LiveHandoff
|
||||
phase={phase}
|
||||
transport={feedHealth}
|
||||
@@ -330,6 +371,7 @@ export function LiveTimingPage() {
|
||||
hasArchive={hasArchive}
|
||||
onViewArchive={handleViewArchive}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{snapshot && rendersSnapshot(phase) && (
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
} from '../components/PreSessionView'
|
||||
import { WeekendSwitcher } from '../components/WeekendSwitcher'
|
||||
import { SourceBadge } from '../components/SourceBadge'
|
||||
import { DataNotice, RouteState } from '../components/RouteState'
|
||||
import { noticeFromResponse, noticeMessage } from '../lib/availability'
|
||||
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
||||
import { sessionTypeAbbrev } from '../lib/coverage'
|
||||
import { formatSessionScheduleTime, sortSessionsByStart } from '../lib/schedule'
|
||||
@@ -55,7 +57,7 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
|
||||
const contextQuery = useQuery({
|
||||
queryKey: ['weekend-context'],
|
||||
queryFn: fetchWeekendContext,
|
||||
queryFn: ({ signal }) => fetchWeekendContext(signal),
|
||||
staleTime: 15_000,
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
@@ -73,7 +75,7 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
|
||||
const raceHubQuery = useQuery({
|
||||
queryKey: ['race-hub', sessionKey],
|
||||
queryFn: () => fetchRaceHub(sessionKey),
|
||||
queryFn: ({ signal }) => fetchRaceHub(sessionKey, signal),
|
||||
enabled: sessionKey > 0,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
@@ -81,7 +83,7 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
const meetingKey = raceHubQuery.data?.meeting?.meeting_key
|
||||
const weekendQuery = useQuery({
|
||||
queryKey: ['weekend', meetingKey],
|
||||
queryFn: () => fetchWeekend(meetingKey!),
|
||||
queryFn: ({ signal }) => fetchWeekend(meetingKey!, signal),
|
||||
enabled: meetingKey != null && meetingKey > 0,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
@@ -102,33 +104,33 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
if (contextQuery.isLoading) {
|
||||
return (
|
||||
<div className="rh-page" style={accentStyle}>
|
||||
<div className="loading-state">resolving weekend context…</div>
|
||||
<RouteState
|
||||
kind="loading"
|
||||
title="resolving weekend context…"
|
||||
testId="race-hub-loading"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (contextQuery.isError) {
|
||||
return (
|
||||
<div className="rh-page" data-testid="race-hub-error" style={accentStyle}>
|
||||
<div className="rh-recover">
|
||||
<div className="error-box">
|
||||
{contextQuery.error instanceof Error
|
||||
? contextQuery.error.message
|
||||
: 'Failed to load weekend context.'}
|
||||
</div>
|
||||
<div className="rh-recover-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="rh-recover-btn primary"
|
||||
onClick={() => contextQuery.refetch()}
|
||||
data-testid="rh-retry"
|
||||
<RouteState
|
||||
kind="error"
|
||||
title="Weekend unavailable"
|
||||
error={contextQuery.error}
|
||||
onRetry={() => {
|
||||
if (!contextQuery.isFetching) void contextQuery.refetch()
|
||||
}}
|
||||
retrying={contextQuery.isFetching}
|
||||
retryTestId="rh-retry"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<div className="rh-recover-actions">
|
||||
<Link to="/" search={{}} className="rh-recover-btn" data-testid="rh-back-weekend">
|
||||
Back to Weekend
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</RouteState>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -170,7 +172,11 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
|
||||
return (
|
||||
<div className="rh-page" style={accentStyle}>
|
||||
<div className="loading-state">resolving weekend context…</div>
|
||||
<RouteState
|
||||
kind="loading"
|
||||
title="resolving weekend context…"
|
||||
testId="race-hub-loading"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -179,7 +185,11 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
if (raceHubQuery.isLoading) {
|
||||
return (
|
||||
<div className="rh-page" style={accentStyle}>
|
||||
<div className="loading-state">loading session {sessionKey}…</div>
|
||||
<RouteState
|
||||
kind="loading"
|
||||
title={`loading session ${sessionKey}…`}
|
||||
testId="race-hub-loading"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -191,21 +201,22 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
|
||||
return (
|
||||
<div className="rh-page" data-testid="race-hub-error" style={accentStyle}>
|
||||
<div className="rh-recover">
|
||||
<div className="error-box">
|
||||
{raceHubQuery.error instanceof Error
|
||||
? raceHubQuery.error.message
|
||||
: `Failed to load session ${sessionKey}.`}
|
||||
</div>
|
||||
<div className="rh-recover-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="rh-recover-btn primary"
|
||||
onClick={() => raceHubQuery.refetch()}
|
||||
data-testid="rh-retry"
|
||||
<RouteState
|
||||
kind="error"
|
||||
title="Session unavailable"
|
||||
error={raceHubQuery.error}
|
||||
message={
|
||||
raceHubQuery.error
|
||||
? undefined
|
||||
: `Session ${sessionKey} could not be loaded from local data.`
|
||||
}
|
||||
onRetry={() => {
|
||||
if (!raceHubQuery.isFetching) void raceHubQuery.refetch()
|
||||
}}
|
||||
retrying={raceHubQuery.isFetching}
|
||||
retryTestId="rh-retry"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<div className="rh-recover-actions">
|
||||
<Link
|
||||
to="/"
|
||||
search={backSearch}
|
||||
@@ -217,7 +228,7 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
Back to Weekend
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</RouteState>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -237,6 +248,11 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
const preparing = isPreparing(activeState)
|
||||
const unavailable = isUnavailable(activeState)
|
||||
const partial = isPartialAnalysis(activeState)
|
||||
// Distinct stale disclosure from response headers — skip routine local (already
|
||||
// covered by SourceBadge) and skip partial when the readiness banner is shown.
|
||||
const freshnessNotice = noticeFromResponse(data, { includeLocal: false })
|
||||
const showFreshnessNotice =
|
||||
freshnessNotice != null && !(partial && freshnessNotice === 'partial')
|
||||
|
||||
return (
|
||||
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
|
||||
@@ -268,6 +284,18 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showFreshnessNotice && freshnessNotice && (
|
||||
<DataNotice
|
||||
availability={freshnessNotice}
|
||||
message={noticeMessage(freshnessNotice)}
|
||||
onRetry={() => {
|
||||
if (!raceHubQuery.isFetching) void raceHubQuery.refetch()
|
||||
}}
|
||||
retrying={raceHubQuery.isFetching}
|
||||
testId="race-hub-data-notice"
|
||||
/>
|
||||
)}
|
||||
|
||||
{switcherOpen && (
|
||||
<WeekendSwitcher
|
||||
currentMeetingKey={meetingKey}
|
||||
|
||||
@@ -9,6 +9,15 @@ import {
|
||||
fetchStartingGrid,
|
||||
fetchTrackOutline,
|
||||
} from '../api'
|
||||
import { DataNotice, RouteState } from '../components/RouteState'
|
||||
import {
|
||||
aggregateNotices,
|
||||
noticeFromResponse,
|
||||
noticeMessage,
|
||||
shouldShowEmbeddedNotice,
|
||||
type DataAvailability,
|
||||
} from '../lib/availability'
|
||||
import { userFacingError } from '../lib/fetch'
|
||||
import { countryAccent, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
|
||||
import {
|
||||
buildTitleFightContext,
|
||||
@@ -37,7 +46,7 @@ function SectionState({
|
||||
children,
|
||||
}: {
|
||||
loading?: boolean
|
||||
error?: Error | null
|
||||
error?: unknown
|
||||
empty?: boolean
|
||||
emptyMessage?: string
|
||||
children: ReactNode
|
||||
@@ -47,8 +56,8 @@ function SectionState({
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="preview-section-state error">
|
||||
{error instanceof Error ? error.message : 'Failed to load'}
|
||||
<div className="preview-section-state error" role="status">
|
||||
{userFacingError(error)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -66,7 +75,7 @@ function TrackOutlineCard({
|
||||
}: {
|
||||
outline: TrackOutline | null | undefined
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
error: unknown
|
||||
accent: string
|
||||
}) {
|
||||
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
|
||||
@@ -101,7 +110,7 @@ function LastYearCard({
|
||||
}: {
|
||||
year: number | null
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
error: unknown
|
||||
podium: ReturnType<typeof extractPodium>
|
||||
pole: ReturnType<typeof extractPole>
|
||||
isFirstTime: boolean
|
||||
@@ -150,7 +159,7 @@ function TitleFightCard({
|
||||
season,
|
||||
}: {
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
error: unknown
|
||||
drivers: ReturnType<typeof buildTitleFightContext>
|
||||
sprintWeekend: boolean
|
||||
season: number | null
|
||||
@@ -259,7 +268,29 @@ function PreviewHeader({
|
||||
)
|
||||
}
|
||||
|
||||
export function RacePreviewPage() {
|
||||
export interface RacePreviewPageProps {
|
||||
/**
|
||||
* Canonical meeting identity from Weekend Context. When set, Preview must not
|
||||
* independently re-select another current meeting/session.
|
||||
*/
|
||||
meeting?: Meeting
|
||||
/** Season for championship supplement; defaults to meeting.year. */
|
||||
season?: number
|
||||
/** Embedded under Weekend — identity failures stay non-blocking. */
|
||||
embedded?: boolean
|
||||
/**
|
||||
* Availability already disclosed by the Weekend shell. Embedded Preview
|
||||
* suppresses only an equivalent notice kind; distinct truth stays visible.
|
||||
*/
|
||||
shellAvailability?: DataAvailability | null
|
||||
}
|
||||
|
||||
export function RacePreviewPage({
|
||||
meeting: canonicalMeeting,
|
||||
season: canonicalSeason,
|
||||
embedded = false,
|
||||
shellAvailability = null,
|
||||
}: RacePreviewPageProps = {}) {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
@@ -268,28 +299,34 @@ export function RacePreviewPage() {
|
||||
}, [])
|
||||
|
||||
const nowDate = useMemo(() => new Date(now), [now])
|
||||
const hasCanonicalMeeting = canonicalMeeting != null && canonicalMeeting.meeting_key > 0
|
||||
|
||||
const seasonsQuery = useQuery({
|
||||
queryKey: ['seasons'],
|
||||
queryFn: fetchSeasons,
|
||||
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||
enabled: !hasCanonicalMeeting,
|
||||
})
|
||||
|
||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||
const latestSeason =
|
||||
canonicalSeason ??
|
||||
canonicalMeeting?.year ??
|
||||
seasonsQuery.data?.[0] ??
|
||||
null
|
||||
|
||||
const meetingsQuery = useQuery({
|
||||
queryKey: ['meetings', latestSeason, 'auto'],
|
||||
queryFn: () => fetchMeetings(latestSeason!, 'auto'),
|
||||
enabled: latestSeason != null,
|
||||
queryFn: ({ signal }) => fetchMeetings(latestSeason!, 'auto', signal),
|
||||
enabled: !hasCanonicalMeeting && latestSeason != null,
|
||||
})
|
||||
|
||||
const previewMeeting = useMemo(
|
||||
() => pickPreviewMeeting(meetingsQuery.data ?? [], nowDate),
|
||||
[meetingsQuery.data, nowDate],
|
||||
)
|
||||
const previewMeeting = useMemo(() => {
|
||||
if (hasCanonicalMeeting) return canonicalMeeting
|
||||
return pickPreviewMeeting(meetingsQuery.data ?? [], nowDate)
|
||||
}, [hasCanonicalMeeting, canonicalMeeting, meetingsQuery.data, nowDate])
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
queryKey: ['sessions', previewMeeting?.meeting_key, 'auto'],
|
||||
queryFn: () => fetchSessions(previewMeeting!.meeting_key, 'auto'),
|
||||
queryFn: ({ signal }) => fetchSessions(previewMeeting!.meeting_key, 'auto', signal),
|
||||
enabled: previewMeeting != null,
|
||||
})
|
||||
|
||||
@@ -305,7 +342,7 @@ export function RacePreviewPage() {
|
||||
|
||||
const priorMeetingsQuery = useQuery({
|
||||
queryKey: ['meetings', priorYear, 'auto'],
|
||||
queryFn: () => fetchMeetings(priorYear!, 'auto'),
|
||||
queryFn: ({ signal }) => fetchMeetings(priorYear!, 'auto', signal),
|
||||
enabled: priorYear != null && previewMeeting != null,
|
||||
})
|
||||
|
||||
@@ -316,7 +353,7 @@ export function RacePreviewPage() {
|
||||
|
||||
const priorSessionsQuery = useQuery({
|
||||
queryKey: ['sessions', priorMeeting?.meeting_key, 'auto'],
|
||||
queryFn: () => fetchSessions(priorMeeting!.meeting_key, 'auto'),
|
||||
queryFn: ({ signal }) => fetchSessions(priorMeeting!.meeting_key, 'auto', signal),
|
||||
enabled: priorMeeting != null,
|
||||
})
|
||||
|
||||
@@ -327,25 +364,25 @@ export function RacePreviewPage() {
|
||||
|
||||
const priorResultsQuery = useQuery({
|
||||
queryKey: ['results', priorRaceSession?.session_key, 'auto'],
|
||||
queryFn: () => fetchResults(priorRaceSession!.session_key, 'auto'),
|
||||
queryFn: ({ signal }) => fetchResults(priorRaceSession!.session_key, 'auto', signal),
|
||||
enabled: priorRaceSession != null,
|
||||
})
|
||||
|
||||
const priorGridQuery = useQuery({
|
||||
queryKey: ['grid', priorRaceSession?.session_key, 'auto'],
|
||||
queryFn: () => fetchStartingGrid(priorRaceSession!.session_key, 'auto'),
|
||||
queryFn: ({ signal }) => fetchStartingGrid(priorRaceSession!.session_key, 'auto', signal),
|
||||
enabled: priorRaceSession != null,
|
||||
})
|
||||
|
||||
const trackOutlineQuery = useQuery({
|
||||
queryKey: ['track-outline', previewMeeting?.circuit_key, previewMeeting?.year],
|
||||
queryFn: () => fetchTrackOutline(previewMeeting!.circuit_key!, previewMeeting!.year),
|
||||
queryFn: ({ signal }) => fetchTrackOutline(previewMeeting!.circuit_key!, previewMeeting!.year, signal),
|
||||
enabled: previewMeeting?.circuit_key != null && previewMeeting.circuit_key > 0,
|
||||
})
|
||||
|
||||
const championshipQuery = useQuery({
|
||||
queryKey: ['championship-hub', latestSeason],
|
||||
queryFn: () => fetchChampionshipHub(latestSeason!),
|
||||
queryFn: ({ signal }) => fetchChampionshipHub(latestSeason!, signal),
|
||||
enabled: latestSeason != null,
|
||||
})
|
||||
|
||||
@@ -359,15 +396,56 @@ export function RacePreviewPage() {
|
||||
const accent = countryAccent(previewMeeting)
|
||||
const isFirstTimeCircuit = priorMeeting == null && priorYear != null && !priorMeetingsQuery.isLoading
|
||||
|
||||
if (seasonsQuery.isLoading || meetingsQuery.isLoading) {
|
||||
return <div className="page loading-state" data-testid="preview-loading">loading preview…</div>
|
||||
const identityLoading = !hasCanonicalMeeting && (seasonsQuery.isLoading || meetingsQuery.isLoading)
|
||||
const identityError = !hasCanonicalMeeting && (seasonsQuery.isError || meetingsQuery.isError)
|
||||
const identityRetrying = seasonsQuery.isFetching || meetingsQuery.isFetching
|
||||
|
||||
const retryIdentity = () => {
|
||||
if (seasonsQuery.isError && !seasonsQuery.isFetching) void seasonsQuery.refetch()
|
||||
if (meetingsQuery.isError && !meetingsQuery.isFetching) void meetingsQuery.refetch()
|
||||
}
|
||||
|
||||
if (seasonsQuery.isError || meetingsQuery.isError) {
|
||||
// Worst reported truth across primary + supplements — Local must not mask
|
||||
// Sessions/Prior Results Stale/Partial/Limited/Archive.
|
||||
const dataNotice = aggregateNotices([
|
||||
noticeFromResponse(championshipQuery.data, { includeLocal: true }),
|
||||
noticeFromResponse(sessionsQuery.data, { includeLocal: false }),
|
||||
noticeFromResponse(priorResultsQuery.data, { includeLocal: false }),
|
||||
])
|
||||
|
||||
const showFreshnessNotice =
|
||||
Boolean(dataNotice) &&
|
||||
(!embedded || shouldShowEmbeddedNotice(dataNotice, shellAvailability))
|
||||
|
||||
const sessionsFailed = sessionsQuery.isError
|
||||
const sessionsRetrying = sessionsQuery.isFetching
|
||||
|
||||
const retrySessions = () => {
|
||||
if (!sessionsQuery.isFetching) void sessionsQuery.refetch()
|
||||
}
|
||||
|
||||
if (identityLoading) {
|
||||
return (
|
||||
<div className={embedded ? 'preview-embedded' : 'page'} data-testid="preview-loading">
|
||||
<RouteState kind="loading" title="loading preview…" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (identityError) {
|
||||
const err = seasonsQuery.error ?? meetingsQuery.error
|
||||
return (
|
||||
<div className="page error-box" data-testid="preview-error">
|
||||
{err instanceof Error ? err.message : 'Failed to load preview'}
|
||||
<div className={embedded ? 'preview-embedded' : 'page'} data-testid="preview-error">
|
||||
<RouteState
|
||||
kind="error"
|
||||
title="Preview details unavailable"
|
||||
error={err}
|
||||
onRetry={() => {
|
||||
if (!identityRetrying) retryIdentity()
|
||||
}}
|
||||
retrying={identityRetrying}
|
||||
retryTestId="preview-retry"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -386,7 +464,7 @@ export function RacePreviewPage() {
|
||||
{titleFight.length > 0 && (
|
||||
<TitleFightCard
|
||||
loading={championshipQuery.isLoading}
|
||||
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
|
||||
error={championshipQuery.isError ? championshipQuery.error : null}
|
||||
drivers={titleFight}
|
||||
sprintWeekend={false}
|
||||
season={latestSeason}
|
||||
@@ -397,7 +475,34 @@ export function RacePreviewPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="preview-page" data-testid="preview-page">
|
||||
<div
|
||||
className="preview-page"
|
||||
data-testid="preview-page"
|
||||
data-meeting-key={previewMeeting.meeting_key}
|
||||
data-embedded={embedded ? 'true' : undefined}
|
||||
>
|
||||
{showFreshnessNotice && dataNotice && (
|
||||
<DataNotice
|
||||
availability={dataNotice}
|
||||
message={noticeMessage(dataNotice)}
|
||||
testId="preview-data-notice"
|
||||
/>
|
||||
)}
|
||||
|
||||
{sessionsFailed && (
|
||||
<RouteState
|
||||
kind="error"
|
||||
title="Session schedule unavailable"
|
||||
error={sessionsQuery.error}
|
||||
onRetry={retrySessions}
|
||||
retrying={sessionsRetrying}
|
||||
testId="preview-sessions-error"
|
||||
retryTestId="preview-sessions-retry"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Embedded Weekend already shows the canonical countdown header — skip the duplicate. */}
|
||||
{!embedded && (
|
||||
<PreviewHeader
|
||||
meeting={previewMeeting}
|
||||
sessions={sessions}
|
||||
@@ -405,12 +510,27 @@ export function RacePreviewPage() {
|
||||
now={nowDate}
|
||||
accent={accent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Embedded: still surface recovered schedule once sessions load. */}
|
||||
{embedded && sessions.length > 0 && (
|
||||
<div className="preview-schedule" data-testid="preview-schedule">
|
||||
{sessions.map((session) => (
|
||||
<div key={session.session_key} className="preview-schedule-item">
|
||||
<span className="preview-schedule-name">{session.session_name}</span>
|
||||
<span className="preview-schedule-time">
|
||||
{formatSessionScheduleTime(session.date_start)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="preview-grid">
|
||||
<TrackOutlineCard
|
||||
outline={trackOutlineQuery.data}
|
||||
loading={trackOutlineQuery.isLoading}
|
||||
error={trackOutlineQuery.isError ? (trackOutlineQuery.error as Error) : null}
|
||||
error={trackOutlineQuery.isError ? trackOutlineQuery.error : null}
|
||||
accent={accent}
|
||||
/>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
<TitleFightCard
|
||||
loading={championshipQuery.isLoading}
|
||||
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
|
||||
error={championshipQuery.isError ? championshipQuery.error : null}
|
||||
drivers={titleFight}
|
||||
sprintWeekend={sprintWeekend}
|
||||
season={latestSeason}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { LiveHandoffView } from '../components/weekend/LiveHandoffView'
|
||||
import { PreSessionView } from '../components/weekend/PreSessionView'
|
||||
import { WeekendError, WeekendLimited, WeekendLoading } from '../components/weekend/StatusViews'
|
||||
import { WeekendFocusBanner } from '../components/weekend/WeekendFocusBanner'
|
||||
import { DataNotice } from '../components/RouteState'
|
||||
import { noticeMessage, type DataAvailability } from '../lib/availability'
|
||||
import { resolveViewState } from '../lib/weekendContext'
|
||||
import type {
|
||||
WeekendBriefingItem,
|
||||
@@ -21,16 +23,18 @@ interface RenderArgs {
|
||||
briefing: WeekendBriefingItem[]
|
||||
/** When true (the /preview alias), foreground the preparation surface. */
|
||||
preview: boolean
|
||||
/** Availability already disclosed by the Weekend shell (for Preview dedupe). */
|
||||
shellAvailability: DataAvailability | null
|
||||
}
|
||||
|
||||
function renderState(view: WeekendViewState, args: RenderArgs) {
|
||||
const { context, now, championship, briefing, preview } = args
|
||||
const { context, now, championship, briefing, preview, shellAvailability } = args
|
||||
// The /preview alias always resolves to the preparation surface as long as
|
||||
// there is a next event to prepare for, regardless of the temporal state. This
|
||||
// keeps saved /preview links and the "Prepare for …" CTA meaningful instead of
|
||||
// redirecting straight back to the same between-races screen.
|
||||
if (preview && context.next_meeting) {
|
||||
return <PreSessionView context={context} now={now} />
|
||||
return <PreSessionView context={context} now={now} shellAvailability={shellAvailability} />
|
||||
}
|
||||
|
||||
switch (view) {
|
||||
@@ -54,12 +58,50 @@ function renderState(view: WeekendViewState, args: RenderArgs) {
|
||||
case 'session_live':
|
||||
return <LiveHandoffView context={context} />
|
||||
case 'pre_session':
|
||||
return <PreSessionView context={context} now={now} />
|
||||
return <PreSessionView context={context} now={now} shellAvailability={shellAvailability} />
|
||||
default:
|
||||
return <WeekendLimited season={context.season} />
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<DataNotice
|
||||
availability={availabilityNotice}
|
||||
message={noticeMessage(availabilityNotice)}
|
||||
onRetry={onRetry}
|
||||
retrying={retrying}
|
||||
testId="weekend-data-notice"
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (supplementsLimited) {
|
||||
return (
|
||||
<DataNotice
|
||||
availability="limited"
|
||||
message="Championship or briefing supplements are unavailable. Weekend schedule context is still shown."
|
||||
onRetry={onRetry}
|
||||
retrying={retrying}
|
||||
testId="weekend-data-notice"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function WeekendPage({
|
||||
preview = false,
|
||||
focusMeetingKey,
|
||||
@@ -71,7 +113,18 @@ export function WeekendPage({
|
||||
/** Restored from `/?session_key=` when returning from Race Hub analysis. */
|
||||
focusSessionKey?: number
|
||||
}) {
|
||||
const { context, loadState, error, championship, briefing, now } = useWeekendContext()
|
||||
const {
|
||||
context,
|
||||
loadState,
|
||||
error,
|
||||
championship,
|
||||
briefing,
|
||||
availabilityNotice,
|
||||
supplementsLimited,
|
||||
now,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useWeekendContext()
|
||||
const hasFocus =
|
||||
(focusMeetingKey != null && focusMeetingKey > 0) ||
|
||||
(focusSessionKey != null && focusSessionKey > 0)
|
||||
@@ -87,12 +140,13 @@ export function WeekendPage({
|
||||
if (loadState === 'error' || context == null) {
|
||||
return (
|
||||
<main className="wk-page" data-testid="weekend-page" data-state="error">
|
||||
<WeekendError message={error?.message} />
|
||||
<WeekendError error={error} onRetry={refetch} retrying={isFetching} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const view = resolveViewState(context)
|
||||
const shellAvailability = availabilityNotice ?? (supplementsLimited ? 'limited' : null)
|
||||
|
||||
return (
|
||||
<main
|
||||
@@ -107,7 +161,20 @@ export function WeekendPage({
|
||||
{hasFocus && (
|
||||
<WeekendFocusBanner meetingKey={focusMeetingKey} sessionKey={focusSessionKey} />
|
||||
)}
|
||||
{renderState(view, { context, now, championship, briefing, preview })}
|
||||
<WeekendAvailabilityNotices
|
||||
availabilityNotice={availabilityNotice}
|
||||
supplementsLimited={supplementsLimited}
|
||||
onRetry={refetch}
|
||||
retrying={isFetching}
|
||||
/>
|
||||
{renderState(view, {
|
||||
context,
|
||||
now,
|
||||
championship,
|
||||
briefing,
|
||||
preview,
|
||||
shellAvailability,
|
||||
})}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -410,6 +410,9 @@ a { color: inherit; text-decoration: none; }
|
||||
.badge-partial { background: rgba(255,214,0,.12); color: var(--yellow); border: 1px solid rgba(255,214,0,.25); }
|
||||
.badge-cancelled { background: rgba(255,107,53,.12); color: #ff8a5c; border: 1px solid rgba(255,107,53,.28); }
|
||||
.badge-none { background: rgba(80,80,80,.12); color: var(--text-3); border: 1px solid var(--border); }
|
||||
.badge-stale { background: rgba(255,214,0,.10); color: var(--yellow); border: 1px solid rgba(255,214,0,.22); }
|
||||
.badge-limited { background: rgba(120,160,220,.12); color: #9bb7e0; border: 1px solid rgba(120,160,220,.28); }
|
||||
.badge-missing { background: rgba(80,80,80,.12); color: var(--text-3); border: 1px solid var(--border); }
|
||||
|
||||
/* ── Dataset strip ── */
|
||||
.dataset-strip {
|
||||
@@ -585,6 +588,82 @@ a { color: inherit; text-decoration: none; }
|
||||
margin-bottom: var(--s5);
|
||||
}
|
||||
|
||||
.route-state {
|
||||
padding: var(--s7) var(--s5);
|
||||
text-align: center;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.route-state-availability {
|
||||
margin-bottom: var(--s4);
|
||||
}
|
||||
|
||||
.route-state-title {
|
||||
font-family: var(--f-display, var(--f-mono));
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: var(--s3);
|
||||
}
|
||||
|
||||
.route-state-message {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-2);
|
||||
margin-bottom: var(--s5);
|
||||
}
|
||||
|
||||
.route-state-retry {
|
||||
appearance: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
font-family: var(--f-mono);
|
||||
font-size: 12px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.route-state-retry:hover:not(:disabled) {
|
||||
border-color: rgba(225, 6, 0, 0.45);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.route-state-retry:focus-visible {
|
||||
outline: 2px solid var(--red);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.route-state-retry:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.data-notice {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
padding: var(--s3) var(--s4);
|
||||
margin-bottom: var(--s4);
|
||||
background: rgba(255, 214, 0, 0.06);
|
||||
border: 1px solid rgba(255, 214, 0, 0.2);
|
||||
border-radius: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.data-notice-text {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.data-notice-retry {
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
/* ── Live timing ── */
|
||||
.live-page { max-width: 1320px; }
|
||||
|
||||
|
||||
@@ -89,6 +89,12 @@
|
||||
}
|
||||
.wk-cta-primary { background: var(--red); color: #fff; }
|
||||
.wk-cta-primary:hover { background: #ff3b3f; }
|
||||
.wk-cta:disabled,
|
||||
.wk-cta[aria-busy='true'] {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
.wk-cta-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
|
||||
@@ -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<Meeting[]>((resolve) => {
|
||||
meetingsCalls += 1
|
||||
resolveMeetings = resolve
|
||||
}),
|
||||
)
|
||||
mockFetchHub.mockImplementation(
|
||||
() =>
|
||||
new Promise<ChampionshipHub>((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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('DataLibraryPage', () => {
|
||||
|
||||
expect(await screen.findByTestId('data-library')).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025)
|
||||
expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025, expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
expect(await screen.findByTestId('meeting-detail')).toBeInTheDocument()
|
||||
|
||||
@@ -120,7 +120,7 @@ describe('DriverProfilePage', () => {
|
||||
|
||||
// With an explicit year, the seasons list is not needed.
|
||||
expect(mockFetchSeasons).not.toHaveBeenCalled()
|
||||
expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025)
|
||||
expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025, expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
it('defaults to the latest season when no year is given', async () => {
|
||||
@@ -129,16 +129,19 @@ describe('DriverProfilePage', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('driver-profile')).toBeInTheDocument()
|
||||
})
|
||||
expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025)
|
||||
expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025, expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
it('shows the API error message on failure', async () => {
|
||||
it('shows a user-facing error with retry instead of raw API jargon', async () => {
|
||||
mockFetchSummary.mockRejectedValue(new Error('API 404: Not Found'))
|
||||
renderPage({ driverNumber: 99, year: 2025 })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('API 404: Not Found')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('driver-profile-error')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText(/API 404/i)).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('driver-profile-error')).toHaveTextContent(/Something went wrong/i)
|
||||
})
|
||||
|
||||
it('shows empty states when no rounds are completed', async () => {
|
||||
|
||||
@@ -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<string, Listener[]>()
|
||||
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(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<LiveTimingPage />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
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<string, Listener[]>()
|
||||
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(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<LiveTimingPage />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
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(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<LiveTimingPage />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(await screen.findByTestId('live-initial-error')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled()
|
||||
expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -125,7 +125,7 @@ describe('LocalDataNavigator', () => {
|
||||
|
||||
expect(await screen.findByTestId('local-nav')).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025)
|
||||
expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025, expect.any(AbortSignal))
|
||||
})
|
||||
expect(screen.getByText('Monaco')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -384,7 +384,8 @@ describe('RaceHubPage', () => {
|
||||
|
||||
renderRaceHub(0)
|
||||
|
||||
await waitFor(() => expect(mockFetchRaceHub).toHaveBeenCalledWith(9471))
|
||||
await waitFor(() => expect(mockFetchRaceHub).toHaveBeenCalledWith(9471, expect.any(AbortSignal)))
|
||||
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9600, expect.anything())
|
||||
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9600)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 = () => <RacePreviewPage {...props} />
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RacePreviewPage />
|
||||
<Page />
|
||||
</QueryClientProvider>
|
||||
),
|
||||
})
|
||||
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(<RouterProvider router={router} />)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -278,6 +278,15 @@ describe('RaceStoryCanvas replay map', () => {
|
||||
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
|
||||
})
|
||||
|
||||
it('exposes the final running order outcome region with winner position', () => {
|
||||
renderCanvas()
|
||||
|
||||
const outcome = screen.getByTestId('race-story-outcome')
|
||||
expect(outcome).toHaveAttribute('aria-label', 'Final running order')
|
||||
expect(outcome).toHaveTextContent('VER')
|
||||
expect(outcome.querySelector('.rs-pos-col')).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('renders the empty-state card when positions are unavailable', () => {
|
||||
renderCanvas({
|
||||
datasets: { positions: { status: 'missing', source: 'local', count: 0 } },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } 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 {
|
||||
Outlet,
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
fetchStartingGrid,
|
||||
fetchTrackOutline,
|
||||
} from '../api'
|
||||
import { rememberResponseAvailability } from '../lib/fetch'
|
||||
|
||||
const mockContext = vi.mocked(fetchWeekendContext)
|
||||
const mockHub = vi.mocked(fetchChampionshipHub)
|
||||
@@ -239,7 +240,39 @@ describe('WeekendPage canonical contract rendering', () => {
|
||||
mockContext.mockRejectedValue(new Error('API 500: boom'))
|
||||
renderAt('/')
|
||||
await waitFor(() => expect(screen.getByTestId('weekend-error')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('weekend-error')).toHaveTextContent('boom')
|
||||
// Sanitized — never leak raw HTTP status/body into the Weekend surface.
|
||||
expect(screen.getByTestId('weekend-error')).not.toHaveTextContent(/API 500|boom/i)
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('retries the canonical read and recovers into a temporal state', async () => {
|
||||
let calls = 0
|
||||
mockContext.mockImplementation(async () => {
|
||||
calls += 1
|
||||
if (calls === 1) throw new Error('API 503: unavailable')
|
||||
return context({ temporal_state: 'between_weekends' })
|
||||
})
|
||||
renderAt('/')
|
||||
await waitFor(() => expect(screen.getByTestId('weekend-error')).toBeInTheDocument())
|
||||
const retry = screen.getByRole('button', { name: 'Retry' })
|
||||
fireEvent.click(retry)
|
||||
await waitFor(() => expect(screen.getByTestId('weekend-between-races')).toBeInTheDocument())
|
||||
expect(calls).toBe(2)
|
||||
expect(screen.getByTestId('weekend-page')).toHaveAttribute(
|
||||
'data-temporal-state',
|
||||
'between_weekends',
|
||||
)
|
||||
})
|
||||
|
||||
it('failed canonical read creates zero seasons/meetings/weekend/live-state fanout', async () => {
|
||||
mockContext.mockRejectedValue(new Error('API 503: unavailable'))
|
||||
renderAt('/')
|
||||
await waitFor(() => expect(screen.getByTestId('weekend-error')).toBeInTheDocument())
|
||||
expect(mockSeasons).not.toHaveBeenCalled()
|
||||
expect(mockMeetings).not.toHaveBeenCalled()
|
||||
expect(mockWeekend).not.toHaveBeenCalled()
|
||||
expect(mockHub).not.toHaveBeenCalled()
|
||||
expect(mockNews).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const stateCases: Array<[TemporalState, string]> = [
|
||||
@@ -332,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<ChampionshipHub>((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({
|
||||
|
||||
344
frontend/src/test/availability.test.ts
Normal file
344
frontend/src/test/availability.test.ts
Normal file
@@ -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<typeof body>('/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<typeof body>('/api/v1/championship/hub'),
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const notices: Array<string | null> = []
|
||||
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()
|
||||
})
|
||||
})
|
||||
291
frontend/src/test/fetch.test.ts
Normal file
291
frontend/src/test/fetch.test.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ApiError,
|
||||
apiFetch,
|
||||
clearApiFetchInflight,
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
isTimeoutError,
|
||||
userFacingError,
|
||||
} from '../lib/fetch'
|
||||
|
||||
describe('apiFetch', () => {
|
||||
beforeEach(() => {
|
||||
clearApiFetchInflight()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
clearApiFetchInflight()
|
||||
})
|
||||
|
||||
it('resolves JSON within the timeout boundary', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () =>
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
await expect(apiFetch<{ ok: boolean }>('/api/v1/ping')).resolves.toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('times out and leaves no hanging promise after the deadline', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((_url: string, init?: RequestInit) => {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
const signal = init?.signal
|
||||
if (!signal) return
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(new DOMException('Aborted', 'AbortError'))
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const pending = apiFetch('/api/v1/slow', { timeoutMs: 1000 })
|
||||
const assertion = expect(pending).rejects.toSatisfy(
|
||||
(err: unknown) => isTimeoutError(err) && userFacingError(err).includes('too long'),
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await assertion
|
||||
})
|
||||
|
||||
it('propagates caller abort as an ApiError', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((_url: string, init?: RequestInit) => {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
reject(new DOMException('Aborted', 'AbortError'))
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const controller = new AbortController()
|
||||
const pending = apiFetch('/api/v1/abort-me', {
|
||||
signal: controller.signal,
|
||||
timeoutMs: 30_000,
|
||||
})
|
||||
const assertion = expect(pending).rejects.toMatchObject({ kind: 'abort' })
|
||||
controller.abort()
|
||||
await assertion
|
||||
})
|
||||
|
||||
it('deduplicates concurrent in-flight requests for the same key', async () => {
|
||||
let starts = 0
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
starts += 1
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
return new Response(JSON.stringify({ n: starts }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const a = apiFetch<{ n: number }>('/api/v1/shared', { dedupeKey: 'shared' })
|
||||
const b = apiFetch<{ n: number }>('/api/v1/shared', { dedupeKey: 'shared' })
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
const [ra, rb] = await Promise.all([a, b])
|
||||
expect(starts).toBe(1)
|
||||
expect(ra).toEqual(rb)
|
||||
})
|
||||
|
||||
it('does not poison a second caller when the first aborts the same dedupe key', async () => {
|
||||
let starts = 0
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((_url: string, init?: RequestInit) => {
|
||||
starts += 1
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
resolve(
|
||||
new Response(JSON.stringify({ ok: true, starts }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
}, 100)
|
||||
init?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer)
|
||||
reject(new DOMException('Aborted', 'AbortError'))
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const first = new AbortController()
|
||||
const second = new AbortController()
|
||||
const a = apiFetch<{ ok: boolean }>('/api/v1/shared', {
|
||||
dedupeKey: 'shared-abort-safe',
|
||||
signal: first.signal,
|
||||
timeoutMs: 30_000,
|
||||
})
|
||||
const b = apiFetch<{ ok: boolean }>('/api/v1/shared', {
|
||||
dedupeKey: 'shared-abort-safe',
|
||||
signal: second.signal,
|
||||
timeoutMs: 30_000,
|
||||
})
|
||||
|
||||
first.abort()
|
||||
await expect(a).rejects.toMatchObject({ kind: 'abort' })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await expect(b).resolves.toEqual({ ok: true, starts: 1 })
|
||||
expect(starts).toBe(1)
|
||||
})
|
||||
|
||||
it('cancels the shared request only after the last subscriber aborts', async () => {
|
||||
let aborted = false
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((_url: string, init?: RequestInit) => {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
aborted = true
|
||||
reject(new DOMException('Aborted', 'AbortError'))
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const first = new AbortController()
|
||||
const second = new AbortController()
|
||||
const a = apiFetch('/api/v1/shared', {
|
||||
dedupeKey: 'shared-last-abort',
|
||||
signal: first.signal,
|
||||
timeoutMs: 30_000,
|
||||
})
|
||||
const b = apiFetch('/api/v1/shared', {
|
||||
dedupeKey: 'shared-last-abort',
|
||||
signal: second.signal,
|
||||
timeoutMs: 30_000,
|
||||
})
|
||||
|
||||
first.abort()
|
||||
await expect(a).rejects.toMatchObject({ kind: 'abort' })
|
||||
expect(aborted).toBe(false)
|
||||
|
||||
second.abort()
|
||||
await expect(b).rejects.toMatchObject({ kind: 'abort' })
|
||||
expect(aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('survives a Strict Mode style remount: aborted first consumer, fresh second consumer', async () => {
|
||||
let starts = 0
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((_url: string, init?: RequestInit) => {
|
||||
starts += 1
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
resolve(
|
||||
new Response(JSON.stringify({ starts }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
}, 50)
|
||||
init?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer)
|
||||
reject(new DOMException('Aborted', 'AbortError'))
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
// Mount #1
|
||||
const first = new AbortController()
|
||||
const pendingFirst = apiFetch<{ starts: number }>('/api/v1/seasons', {
|
||||
dedupeKey: 'seasons',
|
||||
signal: first.signal,
|
||||
timeoutMs: 30_000,
|
||||
})
|
||||
|
||||
// Strict Mode unmount cancels the first subscription (sole subscriber → shared abort)
|
||||
first.abort()
|
||||
await expect(pendingFirst).rejects.toMatchObject({ kind: 'abort' })
|
||||
|
||||
// Remount starts a fresh consumer — must not inherit the aborted request
|
||||
const second = new AbortController()
|
||||
const pendingSecond = apiFetch<{ starts: number }>('/api/v1/seasons', {
|
||||
dedupeKey: 'seasons',
|
||||
signal: second.signal,
|
||||
timeoutMs: 30_000,
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
await expect(pendingSecond).resolves.toEqual({ starts: 2 })
|
||||
expect(starts).toBe(2)
|
||||
})
|
||||
|
||||
it('allows an explicit retry after the prior request settles', async () => {
|
||||
let starts = 0
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
starts += 1
|
||||
return new Response(JSON.stringify({ starts }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
await expect(apiFetch('/api/v1/retry', { dedupeKey: 'retry-once' })).resolves.toEqual({
|
||||
starts: 1,
|
||||
})
|
||||
await expect(apiFetch('/api/v1/retry', { dedupeKey: 'retry-once' })).resolves.toEqual({
|
||||
starts: 2,
|
||||
})
|
||||
expect(starts).toBe(2)
|
||||
})
|
||||
|
||||
it('maps HTTP errors without raw status jargon in userFacingError', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () =>
|
||||
new Response(JSON.stringify({ error: 'upstream failed', stale: true }), {
|
||||
status: 502,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
try {
|
||||
await apiFetch('/api/v1/fail')
|
||||
expect.unreachable('should throw')
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ApiError)
|
||||
expect((err as ApiError).stale).toBe(true)
|
||||
expect(userFacingError(err)).not.toMatch(/502|API /)
|
||||
expect(userFacingError(err)).toMatch(/cached|Retry/i)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the default primary-route timeout', () => {
|
||||
expect(DEFAULT_FETCH_TIMEOUT_MS).toBe(15_000)
|
||||
})
|
||||
})
|
||||
102
frontend/src/test/route-resilience.test.tsx
Normal file
102
frontend/src/test/route-resilience.test.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
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 { DriverProfilePage } from '../pages/DriverProfilePage'
|
||||
import { ApiError } from '../lib/fetch'
|
||||
import type { DriverSummary } from '../types'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
fetchSeasons: vi.fn(),
|
||||
fetchDriverSummary: vi.fn(),
|
||||
}))
|
||||
|
||||
import { fetchSeasons, fetchDriverSummary } from '../api'
|
||||
|
||||
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
||||
const mockFetchSummary = vi.mocked(fetchDriverSummary)
|
||||
|
||||
const summary: DriverSummary = {
|
||||
season: 2025,
|
||||
driver_number: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull',
|
||||
team_colour: '3671c6',
|
||||
headshot_url: '',
|
||||
points: 50,
|
||||
position: 1,
|
||||
wins: 2,
|
||||
podiums: 2,
|
||||
poles: 1,
|
||||
form: [25, 25],
|
||||
cumulative: [25, 50],
|
||||
round_labels: ['R1', 'R2'],
|
||||
rounds: [],
|
||||
source: 'local',
|
||||
enrichment: 'limited',
|
||||
}
|
||||
|
||||
function renderPage(props: { driverNumber: number; year?: number }) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DriverProfilePage driverNumber={props.driverNumber} year={props.year} />
|
||||
</QueryClientProvider>
|
||||
),
|
||||
})
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: () => null,
|
||||
})
|
||||
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
|
||||
return render(<RouterProvider router={router} />)
|
||||
}
|
||||
|
||||
describe('DriverProfilePage resilience', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchSeasons.mockResolvedValue([2025])
|
||||
})
|
||||
|
||||
it('leaves loading after a timeout error and offers retry', async () => {
|
||||
mockFetchSummary.mockRejectedValue(
|
||||
new ApiError('timeout', 'Request timed out', {
|
||||
userMessage: 'This request took too long. Check your connection, then retry.',
|
||||
}),
|
||||
)
|
||||
|
||||
renderPage({ driverNumber: 1, year: 2025 })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('driver-profile-error')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByTestId('driver-profile-loading')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('driver-profile-error')).toHaveTextContent(/took too long/i)
|
||||
})
|
||||
|
||||
it('dedupes concurrent retries via a single refetch gate', async () => {
|
||||
let calls = 0
|
||||
mockFetchSummary.mockImplementation(async () => {
|
||||
calls += 1
|
||||
if (calls === 1) {
|
||||
throw new ApiError('http', 'boom', { status: 500 })
|
||||
}
|
||||
return summary
|
||||
})
|
||||
|
||||
renderPage({ driverNumber: 1, year: 2025 })
|
||||
await waitFor(() => expect(screen.getByTestId('driver-profile-error')).toBeInTheDocument())
|
||||
|
||||
const retry = screen.getByRole('button', { name: 'Retry' })
|
||||
fireEvent.click(retry)
|
||||
fireEvent.click(retry)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('driver-profile')).toBeInTheDocument())
|
||||
expect(calls).toBe(2)
|
||||
expect(screen.getByTestId('driver-profile-limited')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -446,6 +446,10 @@ export interface DriverSummary {
|
||||
cumulative: number[]
|
||||
round_labels: string[]
|
||||
rounds: DriverSummaryRound[]
|
||||
/** Data origin: local domain DB or OpenF1 fallback. */
|
||||
source?: 'local' | 'openf1'
|
||||
/** Optional remote enrichment status — limited means profile still renders. */
|
||||
enrichment?: 'full' | 'limited' | 'none'
|
||||
}
|
||||
|
||||
// ── Weekend Context ──
|
||||
@@ -468,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
123
internal/api/freshness_test.go
Normal file
123
internal/api/freshness_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -383,30 +413,42 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -672,6 +739,8 @@ func champHubTTL(year int, now time.Time) time.Duration {
|
||||
|
||||
type champHubEntry struct {
|
||||
resp champHubResponse
|
||||
source string
|
||||
freshness string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
results, rerr := s.client.GetSessionResult(raceKey)
|
||||
grid, gerr := s.client.GetStartingGrid(raceKey)
|
||||
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 := 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()
|
||||
@@ -1281,14 +1416,17 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
|
||||
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,
|
||||
@@ -1495,17 +1641,27 @@ func (s *Server) handleLapsComparison(w http.ResponseWriter, r *http.Request) {
|
||||
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),
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
120
internal/web/component_freshness_test.go
Normal file
120
internal/web/component_freshness_test.go
Normal file
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/api"
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
)
|
||||
|
||||
// --- /api/v1/driver/summary ---
|
||||
// Per-driver season summary: championship standing plus per-round race results,
|
||||
// aggregated server-side from the same sources as the championship hub. Caching
|
||||
// relies on the OpenF1 client's HTTP cache TTLs — no extra layer here.
|
||||
// Per-driver season summary: championship standing plus per-round race results.
|
||||
// Current-season identity/results are local-first from the domain DB. Optional
|
||||
// OpenF1 enrichment (headshot / polished identity) is bounded so it cannot hang
|
||||
// the profile when remote data is slow or unavailable.
|
||||
|
||||
// driverEnrichmentTimeout bounds optional remote enrichment so a hung OpenF1
|
||||
// call never blocks a local-first profile response. Overridable in tests.
|
||||
var driverEnrichmentTimeout = 2 * time.Second
|
||||
|
||||
type driverSummaryRound struct {
|
||||
MeetingKey int `json:"meeting_key"`
|
||||
@@ -49,6 +56,11 @@ type driverSummaryResponse struct {
|
||||
Cumulative []float64 `json:"cumulative"`
|
||||
RoundLabels []string `json:"round_labels"`
|
||||
Rounds []driverSummaryRound `json:"rounds"`
|
||||
// Source is "local" when served from the domain DB, else "openf1".
|
||||
Source string `json:"source,omitempty"`
|
||||
// Enrichment is "full" when optional remote identity landed, "limited"
|
||||
// when it timed out/failed, or "none" when no enrichment was attempted.
|
||||
Enrichment string `json:"enrichment,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -61,12 +73,102 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
|
||||
if year == 0 {
|
||||
year = time.Now().Year()
|
||||
}
|
||||
mode := parseSourceMode(r)
|
||||
// Driver summary is local-first for current-season identity/results. When the
|
||||
// caller omits ?source=, prefer auto (local then OpenF1) rather than the
|
||||
// package default of openf1-only.
|
||||
if r.URL.Query().Get("source") == "" {
|
||||
mode = sourceAuto
|
||||
}
|
||||
client := s.client.Scoped()
|
||||
|
||||
champ, err := s.client.GetDriverChampionshipForYear(year)
|
||||
if err != nil {
|
||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
||||
if mode == sourceLocal || mode == sourceAuto {
|
||||
resp, sessionKey, ok, lerr := s.localDriverSummary(year, driverNumber)
|
||||
if lerr != nil {
|
||||
writeError(w, lerr, http.StatusInternalServerError, false)
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
if mode != sourceLocal {
|
||||
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
|
||||
}
|
||||
if mode == sourceLocal {
|
||||
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
resp, incomplete, err := openF1DriverSummary(client, year, driverNumber)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
func (s *Server) localDriverSummary(year, driverNumber int) (driverSummaryResponse, int, bool, error) {
|
||||
if !s.hasLocalQuery() {
|
||||
return driverSummaryResponse{}, 0, false, nil
|
||||
}
|
||||
inputs, err := s.query.GetChampionshipInputs(year)
|
||||
if err != nil {
|
||||
return driverSummaryResponse{}, 0, false, err
|
||||
}
|
||||
if len(inputs.Champ) == 0 {
|
||||
return driverSummaryResponse{}, 0, false, nil
|
||||
}
|
||||
|
||||
races := make([]meetingRace, 0, len(inputs.Races))
|
||||
for _, race := range inputs.Races {
|
||||
races = append(races, meetingRace{
|
||||
Meeting: race.Meeting,
|
||||
RaceSessionKey: race.RaceSessionKey,
|
||||
Results: race.Results,
|
||||
Grid: race.Grid,
|
||||
})
|
||||
}
|
||||
|
||||
resp, ok := aggregateDriverSummary(year, driverNumber, races, inputs.Champ, inputs.DriverMap)
|
||||
if !ok {
|
||||
return driverSummaryResponse{}, 0, false, nil
|
||||
}
|
||||
resp.Source = "local"
|
||||
resp.Enrichment = "none"
|
||||
|
||||
sessionKey := 0
|
||||
for _, c := range inputs.Champ {
|
||||
if c.DriverNumber == driverNumber && c.SessionKey > 0 {
|
||||
sessionKey = c.SessionKey
|
||||
break
|
||||
}
|
||||
if sessionKey == 0 && c.SessionKey > 0 {
|
||||
sessionKey = c.SessionKey
|
||||
}
|
||||
}
|
||||
return resp, sessionKey, true, nil
|
||||
}
|
||||
|
||||
func openF1DriverSummary(client *api.OpenF1Client, year, driverNumber int) (*driverSummaryResponse, bool, error) {
|
||||
champ, err := client.GetDriverChampionshipForYear(year)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var entry *models.ChampionshipDriver
|
||||
for i := range champ {
|
||||
if champ[i].DriverNumber == driverNumber {
|
||||
@@ -75,30 +177,83 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if entry == nil {
|
||||
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
|
||||
return
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
driverInfo := map[int]models.Driver{}
|
||||
if ds, derr := s.client.GetDriversForSession(champ[0].SessionKey); derr == nil {
|
||||
sessionKey := champ[0].SessionKey
|
||||
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 {
|
||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
||||
return
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
resp, ok := aggregateDriverSummary(year, driverNumber, races, champ, driverInfo)
|
||||
if !ok {
|
||||
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
|
||||
return nil, false, nil
|
||||
}
|
||||
incomplete := identityIncomplete || racesIncomplete
|
||||
resp.Source = "openf1"
|
||||
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 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
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
|
||||
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) {
|
||||
if d.HeadshotURL != "" {
|
||||
resp.HeadshotURL = d.HeadshotURL
|
||||
}
|
||||
if d.FullName != "" {
|
||||
resp.FullName = d.FullName
|
||||
}
|
||||
if d.NameAcronym != "" {
|
||||
resp.NameAcronym = d.NameAcronym
|
||||
}
|
||||
if d.TeamName != "" {
|
||||
resp.TeamName = d.TeamName
|
||||
}
|
||||
if d.TeamColour != "" {
|
||||
resp.TeamColour = d.TeamColour
|
||||
}
|
||||
}
|
||||
|
||||
// aggregateDriverSummary is the pure aggregation core (no network) so it can be
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/api"
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
"github.com/AmanTahiliani/box-box/internal/store"
|
||||
)
|
||||
|
||||
func driverSummaryFixtures() ([]meetingRace, []models.ChampionshipDriver, map[int]models.Driver) {
|
||||
@@ -134,3 +139,234 @@ func TestHandleDriverSummaryBadRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func seedDriverSummaryStore(t *testing.T, st *store.Store) {
|
||||
t.Helper()
|
||||
meetingKey := 1201
|
||||
sessionKey := 9901
|
||||
|
||||
if err := st.UpsertMeeting(store.Meeting{
|
||||
MeetingKey: meetingKey,
|
||||
MeetingName: "Bahrain GP",
|
||||
CountryCode: "BHR",
|
||||
CountryName: "Bahrain",
|
||||
Year: 2025,
|
||||
DateStart: "2025-03-02",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertMeeting: %v", err)
|
||||
}
|
||||
if err := st.UpsertSession(store.Session{
|
||||
SessionKey: sessionKey,
|
||||
MeetingKey: meetingKey,
|
||||
SessionName: "Race",
|
||||
SessionType: "Race",
|
||||
DateStart: "2025-03-02T15:00:00Z",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertSession: %v", err)
|
||||
}
|
||||
if err := st.UpsertDriver(store.Driver{
|
||||
DriverNumber: 1,
|
||||
FullName: "Max Verstappen",
|
||||
NameAcronym: "VER",
|
||||
TeamName: "Red Bull",
|
||||
TeamColour: "3671c6",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertDriver: %v", err)
|
||||
}
|
||||
if err := st.UpsertSessionDriver(store.SessionDriver{
|
||||
SessionKey: sessionKey,
|
||||
DriverNumber: 1,
|
||||
MeetingKey: meetingKey,
|
||||
FullName: "Max Verstappen",
|
||||
NameAcronym: "VER",
|
||||
TeamName: "Red Bull",
|
||||
TeamColour: "3671c6",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertSessionDriver: %v", err)
|
||||
}
|
||||
if err := st.UpsertSessionResult(store.SessionResult{
|
||||
SessionKey: sessionKey,
|
||||
DriverNumber: 1,
|
||||
MeetingKey: meetingKey,
|
||||
Position: 1,
|
||||
Points: 25,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertSessionResult: %v", err)
|
||||
}
|
||||
if err := st.UpsertStartingGridEntry(store.StartingGridEntry{
|
||||
SessionKey: sessionKey,
|
||||
DriverNumber: 1,
|
||||
MeetingKey: meetingKey,
|
||||
Position: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertStartingGridEntry: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDriverSummaryLocalFirstIgnoresHangingEnrichment(t *testing.T) {
|
||||
prev := driverEnrichmentTimeout
|
||||
driverEnrichmentTimeout = 40 * time.Millisecond
|
||||
t.Cleanup(func() { driverEnrichmentTimeout = prev })
|
||||
|
||||
st := openTestStore(t)
|
||||
seedDriverSummaryStore(t, st)
|
||||
|
||||
// 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() {
|
||||
close(release)
|
||||
hang.Close()
|
||||
})
|
||||
|
||||
client := api.NewOpenF1Client(hang.URL, 15*time.Second)
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
srv := NewServer(client, 8080, st)
|
||||
|
||||
start := time.Now()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/driver/summary?year=2025&driver_number=1", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleDriverSummary(rec, req)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Source != "local" {
|
||||
t.Errorf("source = %q, want local", resp.Source)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDriverSummaryLocalFirstWithFailingEnrichment(t *testing.T) {
|
||||
prev := driverEnrichmentTimeout
|
||||
driverEnrichmentTimeout = 200 * time.Millisecond
|
||||
t.Cleanup(func() { driverEnrichmentTimeout = prev })
|
||||
|
||||
st := openTestStore(t)
|
||||
seedDriverSummaryStore(t, st)
|
||||
|
||||
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "boom", http.StatusBadGateway)
|
||||
}))
|
||||
t.Cleanup(fail.Close)
|
||||
|
||||
client := api.NewOpenF1Client(fail.URL, 2*time.Second)
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
srv := NewServer(client, 8080, st)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/driver/summary?year=2025&driver_number=1&source=auto", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleDriverSummary(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp driverSummaryResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Source != "local" {
|
||||
t.Errorf("source = %q, want local", resp.Source)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDriverSummarySourceLocalOnly(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
seedDriverSummaryStore(t, st)
|
||||
|
||||
// Even with a broken OpenF1 client, source=local must succeed from the DB.
|
||||
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "nope", http.StatusInternalServerError)
|
||||
}))
|
||||
t.Cleanup(fail.Close)
|
||||
client := api.NewOpenF1Client(fail.URL, time.Second)
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
srv := NewServer(client, 8080, st)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/driver/summary?year=2025&driver_number=1&source=local", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleDriverSummary(rec, req)
|
||||
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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
65
internal/web/freshness.go
Normal file
65
internal/web/freshness.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
86
internal/web/freshness_test.go
Normal file
86
internal/web/freshness_test.go
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
385
tests/issue-76-availability.spec.ts
Normal file
385
tests/issue-76-availability.spec.ts
Normal file
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,8 +18,15 @@ test.describe('Production serving (Go + built React)', () => {
|
||||
await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible()
|
||||
|
||||
await page.getByRole('tab', { name: 'Race Story' }).click()
|
||||
await expect(page.getByText('Final Classification')).toBeVisible()
|
||||
await expect(page.locator('.drv-code', { hasText: 'VER' })).toBeVisible()
|
||||
|
||||
const outcome = page.getByRole('region', { name: 'Final running order' })
|
||||
await expect(outcome).toBeVisible()
|
||||
await expect(outcome).toHaveAttribute('data-testid', 'race-story-outcome')
|
||||
|
||||
const verRow = page.locator('.rs-driver-row', {
|
||||
has: page.locator('.rs-driver-name', { hasText: 'VER' }),
|
||||
})
|
||||
await expect(verRow.locator('.rs-pos-col')).toHaveText('1')
|
||||
})
|
||||
|
||||
test('serves admin / data health route', async ({ page }) => {
|
||||
|
||||
42
tests/route-resilience.spec.ts
Normal file
42
tests/route-resilience.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Primary-route resilience (forced errors)', () => {
|
||||
test('Weekend leaves loading and shows retry after weekend-context failure', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route('**/api/v1/weekend-context', async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'forced weekend-context failure', stale: false }),
|
||||
})
|
||||
})
|
||||
|
||||
await page.goto('/')
|
||||
await expect(page.getByTestId('weekend-error')).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible()
|
||||
await expect(page.getByTestId('weekend-error')).not.toHaveText(/API 503|forced weekend-context/i)
|
||||
})
|
||||
|
||||
test('Driver Profile leaves loading and shows retry after summary timeout', async ({ page }) => {
|
||||
await page.route('**/api/v1/driver/summary**', async (route) => {
|
||||
// Hold the connection past the UI wait without fulfilling — the browser
|
||||
// abort/timeout path is covered in unit tests; here we force an HTTP error
|
||||
// so the shared RouteState recovery UI is exercised hermetically.
|
||||
await route.fulfill({
|
||||
status: 504,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'forced timeout', stale: false }),
|
||||
})
|
||||
})
|
||||
await page.route('**/api/v1/seasons', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify([2025]) }),
|
||||
)
|
||||
|
||||
await page.goto('/drivers/1?year=2025')
|
||||
await expect(page.getByTestId('driver-profile-error')).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible()
|
||||
await expect(page.getByTestId('driver-profile-error')).not.toHaveText(/API 504/i)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user