feat(#76): bound primary-route fetches and local-first driver summary

Add shared apiFetch timeouts/abort/typed errors with in-flight dedupe, a
RouteState UI for loading/empty/timeout/error+retry, and wire it through
Weekend (Data Health), Championship, Driver Profile, Briefing, Live, and
Race Hub. Driver summary is local-first with bounded optional OpenF1
enrichment so a hung remote call cannot block the profile.

Spike note: parseSourceMode defaults to openf1; driver summary now treats
omitted ?source= as auto so local season data is preferred.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-12 18:00:37 -04:00
parent e8a1f15f8b
commit b3d1730324
29 changed files with 1636 additions and 241 deletions

View File

@@ -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()
if (data?.error || !Array.isArray(data?.points) || data.points.length < 2) return null
return data as TrackOutline
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'}`,
})
}

View File

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

View File

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

View File

@@ -0,0 +1,162 @@
import type { ReactNode } from 'react'
import { isTimeoutError, userFacingError } from '../lib/fetch'
/** Weekend Context terminology for coverage / availability indicators. */
export type DataAvailability = 'local' | 'partial' | 'stale' | 'archive' | 'limited' | 'missing'
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
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,
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}
>
{retrying ? 'Retrying…' : 'Retry'}
</button>
)}
</>
)}
</div>
)
}
interface StaleNoticeProps {
availability?: DataAvailability
message?: string
onRetry?: () => void
testId?: string
}
/** Inline notice when a successful payload is limited/stale/partial. */
export function DataNotice({
availability = 'stale',
message,
onRetry,
testId = 'data-notice',
}: StaleNoticeProps) {
const defaultMessage =
availability === 'stale'
? 'Showing stale cached data. Retry to refresh.'
: availability === 'limited'
? 'Some optional details are unavailable. Core local data is shown.'
: availability === 'partial'
? 'Coverage is partial for this weekend.'
: availability === 'archive'
? 'Showing an archived snapshot.'
: 'Data availability is limited.'
return (
<div className="data-notice" data-testid={testId} role="status">
<AvailabilityBadge kind={availability} />
<span className="data-notice-text">{message ?? defaultMessage}</span>
{onRetry && (
<button type="button" className="route-state-retry data-notice-retry" onClick={onRetry}>
Retry
</button>
)}
</div>
)
}

View File

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

View File

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

View File

@@ -20,6 +20,10 @@ export interface UseWeekendContextResult {
/** Supplementary briefing items (not part of the #72 contract). */
briefing: WeekendBriefingItem[]
now: Date
/** Refetch the canonical weekend-context read. */
refetch: () => void
/** True while a canonical refetch is in flight. */
isFetching: boolean
}
/**
@@ -44,7 +48,7 @@ export function useWeekendContext(): UseWeekendContextResult {
const contextQuery = useQuery({
queryKey: ['weekend-context'],
queryFn: fetchWeekendContext,
queryFn: ({ signal }) => fetchWeekendContext(signal),
staleTime: 30_000,
})
@@ -56,14 +60,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,
})
@@ -85,5 +89,9 @@ export function useWeekendContext(): UseWeekendContextResult {
championship,
briefing,
now: nowDate,
refetch: () => {
if (!contextQuery.isFetching) void contextQuery.refetch()
},
isFetching: contextQuery.isFetching,
}
}

267
frontend/src/lib/fetch.ts Normal file
View File

@@ -0,0 +1,267 @@
/** Bounded fetch helpers for primary-route resilience. */
export const DEFAULT_FETCH_TIMEOUT_MS = 15_000
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'),
})
}
return (await res.json()) as T
} 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()
}

View File

@@ -9,7 +9,8 @@ const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
// Explicit Retry on RouteState — avoid automatic retry storms.
retry: false,
},
},
})

View File

@@ -24,6 +24,7 @@ import {
} from '../lib/digest'
import { stripHtml, timeAgo } from '../utils'
import type { ArticleContent, NewsItem } from '../types'
import { RouteState } from '../components/RouteState'
import '../styles/digest.css'
type Category = 'all' | 'official' | 'news' | 'video'
@@ -406,27 +407,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,
})
@@ -524,8 +525,21 @@ 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 && (
<>

View File

@@ -7,6 +7,7 @@ import type { ChampHubDriver, ChampionshipHub } from '../types'
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
import { RivalryCompare } from '../components/RivalryCompare'
import { Meaning } from '../components/Meaning'
import { RouteState } from '../components/RouteState'
import { TeammateH2H } from '../components/TeammateH2H'
import { teammatePairs } from '../lib/h2h'
import { pointsGapMeaning } from '../lib/meaning'
@@ -81,23 +82,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>
)
}

View File

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

View File

@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { fetchDriverSummary, fetchSeasons } from '../api'
import { DataNotice, RouteState } from '../components/RouteState'
import { teamColor } from '../utils'
import { countryFlag } from '../lib/gpIdentity'
import {
@@ -26,7 +27,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 +35,86 @@ 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}
onRetry={() => {
if (!summaryQuery.isFetching) void summaryQuery.refetch()
}}
/>
)
}
function DriverProfileBody({ summary }: { summary: DriverSummary }) {
function DriverProfileBody({
summary,
onRetry,
}: {
summary: DriverSummary
onRetry?: () => void
}) {
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}
testId="driver-profile-limited"
/>
)}
{summary.source === 'local' && summary.enrichment !== 'limited' && (
<DataNotice availability="local" message="Loaded from local season data." testId="driver-profile-local" />
)}
<header className="dp-header" data-testid="dp-header" style={{ borderLeftColor: color }}>
<div className="dp-identity">
<div className="dp-title-row">

View File

@@ -36,6 +36,7 @@ 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 { RouteState } from '../components/RouteState'
import '../styles/live-state.css'
/** How often to re-check weekend-context while analysis is still ingesting. */
@@ -67,12 +68,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 +113,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) => {
@@ -296,9 +300,16 @@ export function LiveTimingPage() {
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>
<RouteState
kind="error"
title="Live timing unavailable"
error={error}
onRetry={() => {
if (!isFetching) void refetch()
}}
retrying={isFetching}
testId="live-initial-error"
/>
)}
{phase === 'disconnected' && (
@@ -316,8 +327,12 @@ export function LiveTimingPage() {
</div>
)}
{phase === 'connecting' && (
<div className="loading-state">connecting to live timing</div>
{phase === 'connecting' && (isLoading || !liveStateFetched) && (
<RouteState
kind="loading"
title="connecting to live timing…"
testId="live-initial-loading"
/>
)}
{(phase === 'settling' || phase === 'inactive') && (

View File

@@ -20,6 +20,7 @@ import {
} from '../components/PreSessionView'
import { WeekendSwitcher } from '../components/WeekendSwitcher'
import { SourceBadge } from '../components/SourceBadge'
import { RouteState } from '../components/RouteState'
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
import { sessionTypeAbbrev } from '../lib/coverage'
import { formatSessionScheduleTime, sortSessionsByStart } from '../lib/schedule'
@@ -55,7 +56,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 +74,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 +82,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 +103,32 @@ 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>
<RouteState
kind="error"
title="Weekend unavailable"
error={contextQuery.error}
onRetry={() => {
if (!contextQuery.isFetching) void contextQuery.refetch()
}}
retrying={contextQuery.isFetching}
>
<div className="rh-recover-actions">
<button
type="button"
className="rh-recover-btn primary"
onClick={() => contextQuery.refetch()}
data-testid="rh-retry"
>
Retry
</button>
<Link to="/" search={{}} className="rh-recover-btn" data-testid="rh-back-weekend">
Back to Weekend
</Link>
</div>
</div>
</RouteState>
</div>
)
}
@@ -170,7 +170,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 +183,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 +199,21 @@ 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>
<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}
>
<div className="rh-recover-actions">
<button
type="button"
className="rh-recover-btn primary"
onClick={() => raceHubQuery.refetch()}
data-testid="rh-retry"
>
Retry
</button>
<Link
to="/"
search={backSearch}
@@ -217,7 +225,7 @@ export function RaceHubPage({ sessionKey }: Props) {
Back to Weekend
</Link>
</div>
</div>
</RouteState>
</div>
)
}

View File

@@ -271,7 +271,7 @@ export function RacePreviewPage() {
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: fetchSeasons,
queryFn: ({ signal }) => fetchSeasons(signal),
})
const latestSeason = seasonsQuery.data?.[0] ?? null

View File

@@ -71,7 +71,8 @@ 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, now, refetch, isFetching } =
useWeekendContext()
const hasFocus =
(focusMeetingKey != null && focusMeetingKey > 0) ||
(focusSessionKey != null && focusSessionKey > 0)
@@ -87,7 +88,7 @@ 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>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
@@ -239,7 +239,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]> = [

View File

@@ -0,0 +1,151 @@
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('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)
})
})

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

View File

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

View File

@@ -10,9 +10,14 @@ import (
)
// --- /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 +54,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 +71,92 @@ 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
}
champ, err := s.client.GetDriverChampionshipForYear(year)
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 {
s.tryEnrichDriverSummary(&resp, sessionKey)
}
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, err := s.openF1DriverSummary(year, driverNumber)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
return
}
if resp == nil {
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
return
}
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 (s *Server) openF1DriverSummary(year, driverNumber int) (*driverSummaryResponse, error) {
champ, err := s.client.GetDriverChampionshipForYear(year)
if err != nil {
return nil, err
}
var entry *models.ChampionshipDriver
for i := range champ {
if champ[i].DriverNumber == driverNumber {
@@ -75,12 +165,12 @@ 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, nil
}
driverInfo := map[int]models.Driver{}
if ds, derr := s.client.GetDriversForSession(champ[0].SessionKey); derr == nil {
sessionKey := champ[0].SessionKey
if ds, derr := s.client.GetDriversForSession(sessionKey); derr == nil {
driverInfo = buildDriverMapFirst(ds)
}
if d, ok := s.championshipDriverInfo(entry.SessionKey, driverNumber, driverInfo); ok {
@@ -89,16 +179,69 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
races, _, err := s.fetchSeasonRaces(year)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
return
return nil, 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, nil
}
resp.Source = "openf1"
resp.Enrichment = "full"
return &resp, nil
}
// tryEnrichDriverSummary optionally fills headshot / polished identity from
// OpenF1. It never blocks longer than driverEnrichmentTimeout — on timeout or
// failure the local profile remains intact with enrichment=limited.
func (s *Server) tryEnrichDriverSummary(resp *driverSummaryResponse, sessionKey int) {
if resp == nil || s.client == nil || sessionKey <= 0 {
if resp != nil && resp.Enrichment == "none" {
// No session to enrich from — leave as none (local identity only).
}
return
}
writeJSON(w, resp)
type enrichResult struct {
driver models.Driver
ok bool
}
done := make(chan enrichResult, 1)
go func() {
d, ok := s.championshipDriverInfo(sessionKey, resp.DriverNumber, nil)
done <- enrichResult{driver: d, ok: ok}
}()
select {
case result := <-done:
if !result.ok {
resp.Enrichment = "limited"
return
}
applyDriverEnrichment(resp, result.driver)
resp.Enrichment = "full"
case <-time.After(driverEnrichmentTimeout):
resp.Enrichment = "limited"
}
}
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

View File

@@ -1,11 +1,15 @@
package web
import (
"encoding/json"
"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 +138,179 @@ 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{})
hang := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-release:
case <-r.Context().Done():
}
}))
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)
}
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 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 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())
}
}

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