diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 467f16e..eca0ceb 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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 { - 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 { + return apiFetch(`/api/v1/race-hub?session_key=${sessionKey}`, { + signal, + dedupeKey: `race-hub:${sessionKey}`, + }) } -export async function fetchSeasons(): Promise { - 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 { + const years = await apiFetch('/api/v1/seasons', { + signal, + dedupeKey: 'seasons', + }) return Array.isArray(years) ? years : [] } -export async function fetchLocalMeetings(year: number): Promise { - 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 { + const meetings = await apiFetch(`/api/v1/meetings?year=${year}&source=local`, { + signal, + dedupeKey: `meetings:local:${year}`, + }) return Array.isArray(meetings) ? meetings : [] } -export async function fetchSeasonMeetings(year: number): Promise { - return fetchMeetings(year, 'openf1') +export async function fetchSeasonMeetings(year: number, signal?: AbortSignal): Promise { + return fetchMeetings(year, 'openf1', signal) } -export async function fetchMeetings(year: number, source = 'auto'): Promise { - 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 { + const meetings = await apiFetch(`/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 { - 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 { + const results = await apiFetch( + `/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 { - 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 { + const grid = await apiFetch( + `/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 { - 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 { + try { + const data = await apiFetch( + `/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 { 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(`/api/v1/replay/frames?${params}`, { + signal, + dedupeKey: `replay-frames:${sessionKey}:${intervalMs}`, + }) } -export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise { - 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 { + const sessions = await apiFetch( + `/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 { - 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 { + return apiFetch(`/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 { // 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 { - 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 { + return apiFetch('/api/v1/weekend-context', { + signal, + dedupeKey: 'weekend-context', + }) } -export async function fetchChampionshipHub(year?: number): Promise { +export async function fetchChampionshipHub( + year?: number, + signal?: AbortSignal, +): Promise { 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(`/api/v1/championship/hub?${params.toString()}`, { + signal, + dedupeKey: `championship-hub:${year ?? 'latest'}`, + }) } export async function fetchDriverSummary( driverNumber: number, year?: number, + signal?: AbortSignal, ): Promise { - 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(`/api/v1/driver/summary?${params.toString()}`, { + signal, + dedupeKey: `driver-summary:${driverNumber}:${year ?? 'latest'}`, + }) } -export async function fetchLiveState(): Promise { - 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 { + return apiFetch('/api/v1/live/state', { + signal, + dedupeKey: 'live-state', + }) } export async function fetchLiveTrackOutline( session: LiveSessionMeta, year = new Date().getFullYear(), + signal?: AbortSignal, ): Promise { 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(`/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 { +export async function fetchNews( + limit?: number, + source?: string, + signal?: AbortSignal, +): Promise { 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(url, { + signal, + dedupeKey: `news:${limit ?? 'all'}:${source ?? 'all'}`, + }) } -export async function fetchNewsArticle(articleUrl: string): Promise { - 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 { + return apiFetch( + `/api/v1/news/article?url=${encodeURIComponent(articleUrl)}`, + { signal }, + ) } export async function markNewsRead(articleUrl: string): Promise { @@ -209,28 +231,26 @@ export async function markNewsRead(articleUrl: string): Promise { export async function fetchTelemetry( sessionKey: number, driverNumber: number, + signal?: AbortSignal, ): Promise { - const res = await fetch( + const data = await apiFetch( `/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 { 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(`/api/v1/laps/comparison?${params}`, { + signal, + dedupeKey: `laps-comparison:${sessionKey}:${drivers?.join(',') ?? 'all'}`, + }) } diff --git a/frontend/src/components/LocalDataNavigator.tsx b/frontend/src/components/LocalDataNavigator.tsx index e70a94d..0241a4b 100644 --- a/frontend/src/components/LocalDataNavigator.tsx +++ b/frontend/src/components/LocalDataNavigator.tsx @@ -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
loading local seasons…
+ return } if (seasonsQuery.isError) { return ( -
- {seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'} -
+ { + if (!seasonsQuery.isFetching) void seasonsQuery.refetch() + }} + retrying={seasonsQuery.isFetching} + testId="weekend-error" + /> ) } @@ -124,9 +133,16 @@ export function LocalDataNavigator({ onSelectSession }: Props) { )} {meetingsQuery.isError && ( -
- {meetingsQuery.error instanceof Error ? meetingsQuery.error.message : 'Failed to load meetings'} -
+ { + 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 && ( -
- {weekendQuery.error instanceof Error ? weekendQuery.error.message : 'Failed to load weekend'} -
+ { + if (!weekendQuery.isFetching) void weekendQuery.refetch() + }} + retrying={weekendQuery.isFetching} + testId="weekend-error" + /> )} {weekend && ( diff --git a/frontend/src/components/PaddockBriefing.tsx b/frontend/src/components/PaddockBriefing.tsx index 3f44aeb..6df364f 100644 --- a/frontend/src/components/PaddockBriefing.tsx +++ b/frontend/src/components/PaddockBriefing.tsx @@ -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, }) diff --git a/frontend/src/components/RouteState.tsx b/frontend/src/components/RouteState.tsx new file mode 100644 index 0000000..b7d55a0 --- /dev/null +++ b/frontend/src/components/RouteState.tsx @@ -0,0 +1,166 @@ +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 ( + + {label ?? availabilityLabel(kind)} + + ) +} + +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 = { + loading: 'Loading…', + empty: 'Nothing here yet', + error: 'Could not load this view', + timeout: 'Request timed out', +} + +const DEFAULT_MESSAGES: Record = { + 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 + )} + + )} + + ) +} + +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 ( +
+ + {message ?? defaultMessage} + {onRetry && ( + + )} +
+ ) +} diff --git a/frontend/src/components/WeekendSwitcher.tsx b/frontend/src/components/WeekendSwitcher.tsx index ff69aa7..5b7f5e2 100644 --- a/frontend/src/components/WeekendSwitcher.tsx +++ b/frontend/src/components/WeekendSwitcher.tsx @@ -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(null) const [openMeetingKey, setOpenMeetingKey] = useState( 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, }) diff --git a/frontend/src/components/weekend/StatusViews.tsx b/frontend/src/components/weekend/StatusViews.tsx index 609af6c..940d9ef 100644 --- a/frontend/src/components/weekend/StatusViews.tsx +++ b/frontend/src/components/weekend/StatusViews.tsx @@ -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 (
box-box · weekend

Weekend unavailable

-

{message ?? 'Something went wrong loading the weekend context.'}

+

{resolved}

+ {typeof onRetry === 'function' && ( +
+ +
+ )}
) } diff --git a/frontend/src/hooks/useWeekendContext.ts b/frontend/src/hooks/useWeekendContext.ts index 67bdabf..c46158a 100644 --- a/frontend/src/hooks/useWeekendContext.ts +++ b/frontend/src/hooks/useWeekendContext.ts @@ -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, } } diff --git a/frontend/src/lib/fetch.ts b/frontend/src/lib/fetch.ts new file mode 100644 index 0000000..087b662 --- /dev/null +++ b/frontend/src/lib/fetch.ts @@ -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 + /** Aborts the shared request only when the last subscriber cancels. */ + controller: AbortController + subscribers: number +} + +const inflight = new Map() + +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(url: string, options: ApiFetchOptions = {}): Promise { + 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( + key: string, + entry: InflightEntry, + callerSignal?: AbortSignal, +): Promise { + if (callerSignal?.aborted) { + return Promise.reject(abortError()) + } + + entry.subscribers += 1 + + return new Promise((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(url: string, options: ApiFetchOptions = {}): Promise { + const key = options.dedupeKey + if (!key) { + return rawApiFetch(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(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(key, entry, options.signal) +} + +/** Test helper — clears the in-flight dedupe map. */ +export function clearApiFetchInflight(): void { + inflight.clear() +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 6d21eca..8c8519c 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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, }, }, }) diff --git a/frontend/src/pages/BriefingPage.tsx b/frontend/src/pages/BriefingPage.tsx index d0ceda3..22984e4 100644 --- a/frontend/src/pages/BriefingPage.tsx +++ b/frontend/src/pages/BriefingPage.tsx @@ -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() { - {isLoading &&
loading briefing…
} - {isError &&
Failed to load paddock briefing.
} + {isLoading && ( + + )} + {isError && ( + { + if (!isFetching) void refetch() + }} + retrying={isFetching} + testId="briefing-error" + /> + )} {!isLoading && !isError && ( <> diff --git a/frontend/src/pages/ChampionshipPage.tsx b/frontend/src/pages/ChampionshipPage.tsx index 7f09490..6ccc939 100644 --- a/frontend/src/pages/ChampionshipPage.tsx +++ b/frontend/src/pages/ChampionshipPage.tsx @@ -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('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
loading championship…
+ return ( +
+ +
+ ) + } + if (seasonsQuery.isError) { + return ( +
+ { + if (!seasonsQuery.isFetching) void seasonsQuery.refetch() + }} + retrying={seasonsQuery.isFetching} + testId="championship-error" + /> +
+ ) } if (hubQuery.isError) { return ( -
- {hubQuery.error instanceof Error ? hubQuery.error.message : 'Failed to load championship'} +
+ { + if (!hubQuery.isFetching) void hubQuery.refetch() + }} + retrying={hubQuery.isFetching} + testId="championship-error" + />
) } diff --git a/frontend/src/pages/DataLibraryPage.tsx b/frontend/src/pages/DataLibraryPage.tsx index 7a283e5..315bfaa 100644 --- a/frontend/src/pages/DataLibraryPage.tsx +++ b/frontend/src/pages/DataLibraryPage.tsx @@ -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
loading local data library…
+ return ( +
+ +
+ ) } if (seasonsQuery.isError) { return ( -
- {seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'} +
+ { + if (!seasonsQuery.isFetching) void seasonsQuery.refetch() + }} + retrying={seasonsQuery.isFetching} + testId="weekend-error" + />
) } @@ -222,15 +236,20 @@ export function DataLibraryPage() {
{meetingsQuery.isLoading && ( -
loading meetings…
+ )} {meetingsQuery.isError && ( -
- {meetingsQuery.error instanceof Error - ? meetingsQuery.error.message - : 'Failed to load meetings'} -
+ { + 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() {
{weekendsLoading && selectedWeekend == null && ( -
loading weekend details…
+ )} {selectedWeekend && } {selectedMeetingKey != null && !weekendsLoading && selectedWeekend == null && ( -
Could not load weekend details.
+ )}
diff --git a/frontend/src/pages/DriverProfilePage.tsx b/frontend/src/pages/DriverProfilePage.tsx index b6294e7..3363126 100644 --- a/frontend/src/pages/DriverProfilePage.tsx +++ b/frontend/src/pages/DriverProfilePage.tsx @@ -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
Invalid driver number
+ return ( +
+ +
+ ) } if (!seasonsSettled || summaryQuery.isLoading) { - return
loading driver profile…
+ return ( +
+ +
+ ) } if (summaryQuery.isError) { return ( -
- {summaryQuery.error instanceof Error - ? summaryQuery.error.message - : 'Failed to load driver profile'} +
+ { + if (!summaryQuery.isFetching) void summaryQuery.refetch() + }} + retrying={summaryQuery.isFetching} + testId="driver-profile-error" + />
) } const summary = summaryQuery.data if (!summary) { - return
No driver data
+ return ( +
+ +
+ ) } - return + return ( + { + 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 (
+ {summary.enrichment === 'limited' && ( + + )} + {summary.source === 'local' && summary.enrichment !== 'limited' && ( + + )}
diff --git a/frontend/src/pages/ExplorePage.tsx b/frontend/src/pages/ExplorePage.tsx index 8acff0b..8364a78 100644 --- a/frontend/src/pages/ExplorePage.tsx +++ b/frontend/src/pages/ExplorePage.tsx @@ -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, })), diff --git a/frontend/src/pages/LiveTimingPage.tsx b/frontend/src/pages/LiveTimingPage.tsx index 0250d8c..0c7b9a6 100644 --- a/frontend/src/pages/LiveTimingPage.tsx +++ b/frontend/src/pages/LiveTimingPage.tsx @@ -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 (
{isError && ( -
- {error instanceof Error ? error.message : 'Failed to load live timing state'} -
+ { + if (!isFetching) void refetch() + }} + retrying={isFetching} + testId="live-initial-error" + /> )} {phase === 'disconnected' && ( @@ -316,8 +327,12 @@ export function LiveTimingPage() {
)} - {phase === 'connecting' && ( -
connecting to live timing…
+ {phase === 'connecting' && (isLoading || !liveStateFetched) && ( + )} {(phase === 'settling' || phase === 'inactive') && ( diff --git a/frontend/src/pages/RaceHubPage.tsx b/frontend/src/pages/RaceHubPage.tsx index c50d2ff..1733017 100644 --- a/frontend/src/pages/RaceHubPage.tsx +++ b/frontend/src/pages/RaceHubPage.tsx @@ -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,33 @@ export function RaceHubPage({ sessionKey }: Props) { if (contextQuery.isLoading) { return (
-
resolving weekend context…
+
) } if (contextQuery.isError) { return (
-
-
- {contextQuery.error instanceof Error - ? contextQuery.error.message - : 'Failed to load weekend context.'} -
+ { + if (!contextQuery.isFetching) void contextQuery.refetch() + }} + retrying={contextQuery.isFetching} + retryTestId="rh-retry" + >
- Back to Weekend
-
+
) } @@ -170,7 +171,11 @@ export function RaceHubPage({ sessionKey }: Props) { return (
-
resolving weekend context…
+
) } @@ -179,7 +184,11 @@ export function RaceHubPage({ sessionKey }: Props) { if (raceHubQuery.isLoading) { return (
-
loading session {sessionKey}…
+
) } @@ -191,21 +200,22 @@ export function RaceHubPage({ sessionKey }: Props) { return (
-
-
- {raceHubQuery.error instanceof Error - ? raceHubQuery.error.message - : `Failed to load session ${sessionKey}.`} -
+ { + if (!raceHubQuery.isFetching) void raceHubQuery.refetch() + }} + retrying={raceHubQuery.isFetching} + retryTestId="rh-retry" + >
-
-
+
) } diff --git a/frontend/src/pages/RacePreviewPage.tsx b/frontend/src/pages/RacePreviewPage.tsx index ca6d68a..1a5f07b 100644 --- a/frontend/src/pages/RacePreviewPage.tsx +++ b/frontend/src/pages/RacePreviewPage.tsx @@ -271,7 +271,7 @@ export function RacePreviewPage() { const seasonsQuery = useQuery({ queryKey: ['seasons'], - queryFn: fetchSeasons, + queryFn: ({ signal }) => fetchSeasons(signal), }) const latestSeason = seasonsQuery.data?.[0] ?? null diff --git a/frontend/src/pages/WeekendPage.tsx b/frontend/src/pages/WeekendPage.tsx index 57c99fc..b4c13f6 100644 --- a/frontend/src/pages/WeekendPage.tsx +++ b/frontend/src/pages/WeekendPage.tsx @@ -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 (
- +
) } diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 4d41ee6..760c24a 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -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; } diff --git a/frontend/src/styles/weekend.css b/frontend/src/styles/weekend.css index 9414d29..ba9482d 100644 --- a/frontend/src/styles/weekend.css +++ b/frontend/src/styles/weekend.css @@ -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); diff --git a/frontend/src/test/DataLibraryPage.test.tsx b/frontend/src/test/DataLibraryPage.test.tsx index 95f943c..a21eeb9 100644 --- a/frontend/src/test/DataLibraryPage.test.tsx +++ b/frontend/src/test/DataLibraryPage.test.tsx @@ -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() diff --git a/frontend/src/test/DriverProfilePage.test.tsx b/frontend/src/test/DriverProfilePage.test.tsx index 29b4841..6eb5351 100644 --- a/frontend/src/test/DriverProfilePage.test.tsx +++ b/frontend/src/test/DriverProfilePage.test.tsx @@ -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 () => { diff --git a/frontend/src/test/LocalDataNavigator.test.tsx b/frontend/src/test/LocalDataNavigator.test.tsx index 48a2dba..cda3ef3 100644 --- a/frontend/src/test/LocalDataNavigator.test.tsx +++ b/frontend/src/test/LocalDataNavigator.test.tsx @@ -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() }) diff --git a/frontend/src/test/RaceHubPage.test.tsx b/frontend/src/test/RaceHubPage.test.tsx index 4bd998b..8c0d967 100644 --- a/frontend/src/test/RaceHubPage.test.tsx +++ b/frontend/src/test/RaceHubPage.test.tsx @@ -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) }) diff --git a/frontend/src/test/WeekendPage.test.tsx b/frontend/src/test/WeekendPage.test.tsx index 3a3f229..c9178e6 100644 --- a/frontend/src/test/WeekendPage.test.tsx +++ b/frontend/src/test/WeekendPage.test.tsx @@ -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]> = [ diff --git a/frontend/src/test/fetch.test.ts b/frontend/src/test/fetch.test.ts new file mode 100644 index 0000000..da99489 --- /dev/null +++ b/frontend/src/test/fetch.test.ts @@ -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((_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((_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((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((_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((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) + }) +}) diff --git a/frontend/src/test/route-resilience.test.tsx b/frontend/src/test/route-resilience.test.tsx new file mode 100644 index 0000000..3b3a3e0 --- /dev/null +++ b/frontend/src/test/route-resilience.test.tsx @@ -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: () => ( + + + + ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => null, + }) + const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) }) + return render() +} + +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() + }) +}) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index efae0bf..02105ed 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 ── diff --git a/internal/web/driversummary.go b/internal/web/driversummary.go index 7c87e59..633e563 100644 --- a/internal/web/driversummary.go +++ b/internal/web/driversummary.go @@ -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 diff --git a/internal/web/driversummary_test.go b/internal/web/driversummary_test.go index 16e1dec..55020b9 100644 --- a/internal/web/driversummary_test.go +++ b/internal/web/driversummary_test.go @@ -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()) + } +} diff --git a/tests/route-resilience.spec.ts b/tests/route-resilience.spec.ts new file mode 100644 index 0000000..84edf6e --- /dev/null +++ b/tests/route-resilience.spec.ts @@ -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) + }) +})