mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
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:
@@ -5,6 +5,7 @@ import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
|
||||
import { formatCoverageHint } from '../lib/coverage'
|
||||
import { SourceBadge } from './SourceBadge'
|
||||
import { SessionCoverageDots } from './SessionCoverageDots'
|
||||
import { RouteState } from './RouteState'
|
||||
import type { Meeting, WeekendSession } from '../types'
|
||||
|
||||
function formatMeetingDates(meeting: Meeting): string {
|
||||
@@ -29,18 +30,18 @@ export function LocalDataNavigator({ onSelectSession }: Props) {
|
||||
|
||||
const seasonsQuery = useQuery({
|
||||
queryKey: ['seasons'],
|
||||
queryFn: fetchSeasons,
|
||||
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||
})
|
||||
|
||||
const meetingsQuery = useQuery({
|
||||
queryKey: ['meetings', selectedYear],
|
||||
queryFn: () => fetchLocalMeetings(selectedYear!),
|
||||
queryFn: ({ signal }) => fetchLocalMeetings(selectedYear!, signal),
|
||||
enabled: selectedYear != null,
|
||||
})
|
||||
|
||||
const weekendQuery = useQuery({
|
||||
queryKey: ['weekend', selectedMeetingKey],
|
||||
queryFn: () => fetchWeekend(selectedMeetingKey!),
|
||||
queryFn: ({ signal }) => fetchWeekend(selectedMeetingKey!, signal),
|
||||
enabled: selectedMeetingKey != null,
|
||||
})
|
||||
|
||||
@@ -68,14 +69,22 @@ export function LocalDataNavigator({ onSelectSession }: Props) {
|
||||
}
|
||||
|
||||
if (seasonsQuery.isLoading) {
|
||||
return <div className="nav-panel loading-state">loading local seasons…</div>
|
||||
return <RouteState kind="loading" title="loading local seasons…" className="nav-panel" />
|
||||
}
|
||||
|
||||
if (seasonsQuery.isError) {
|
||||
return (
|
||||
<div className="nav-panel error-box">
|
||||
{seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'}
|
||||
</div>
|
||||
<RouteState
|
||||
kind="error"
|
||||
className="nav-panel"
|
||||
title="Local seasons unavailable"
|
||||
error={seasonsQuery.error}
|
||||
onRetry={() => {
|
||||
if (!seasonsQuery.isFetching) void seasonsQuery.refetch()
|
||||
}}
|
||||
retrying={seasonsQuery.isFetching}
|
||||
testId="weekend-error"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -124,9 +133,16 @@ export function LocalDataNavigator({ onSelectSession }: Props) {
|
||||
)}
|
||||
|
||||
{meetingsQuery.isError && (
|
||||
<div className="error-box" style={{ marginTop: 'var(--s4)' }}>
|
||||
{meetingsQuery.error instanceof Error ? meetingsQuery.error.message : 'Failed to load meetings'}
|
||||
</div>
|
||||
<RouteState
|
||||
kind="error"
|
||||
title="Meetings unavailable"
|
||||
error={meetingsQuery.error}
|
||||
onRetry={() => {
|
||||
if (!meetingsQuery.isFetching) void meetingsQuery.refetch()
|
||||
}}
|
||||
retrying={meetingsQuery.isFetching}
|
||||
testId="weekend-meetings-error"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && (
|
||||
@@ -194,9 +210,16 @@ export function LocalDataNavigator({ onSelectSession }: Props) {
|
||||
)}
|
||||
|
||||
{selectedMeetingKey != null && weekendQuery.isError && (
|
||||
<div className="error-box" style={{ marginTop: 'var(--s4)' }}>
|
||||
{weekendQuery.error instanceof Error ? weekendQuery.error.message : 'Failed to load weekend'}
|
||||
</div>
|
||||
<RouteState
|
||||
kind="error"
|
||||
title="Weekend unavailable"
|
||||
error={weekendQuery.error}
|
||||
onRetry={() => {
|
||||
if (!weekendQuery.isFetching) void weekendQuery.refetch()
|
||||
}}
|
||||
retrying={weekendQuery.isFetching}
|
||||
testId="weekend-error"
|
||||
/>
|
||||
)}
|
||||
|
||||
{weekend && (
|
||||
|
||||
@@ -34,25 +34,25 @@ export function PaddockBriefing() {
|
||||
|
||||
const { data: news, isLoading, isError } = useQuery({
|
||||
queryKey: ['news'],
|
||||
queryFn: () => fetchNews(100),
|
||||
queryFn: ({ signal }) => fetchNews(100, undefined, signal),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const seasonsQuery = useQuery({
|
||||
queryKey: ['seasons'],
|
||||
queryFn: fetchSeasons,
|
||||
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||
})
|
||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||
|
||||
const meetingsQuery = useQuery({
|
||||
queryKey: ['season-meetings', latestSeason],
|
||||
queryFn: () => fetchSeasonMeetings(latestSeason!),
|
||||
queryFn: ({ signal }) => fetchSeasonMeetings(latestSeason!, signal),
|
||||
enabled: latestSeason != null,
|
||||
})
|
||||
|
||||
const hubQuery = useQuery({
|
||||
queryKey: ['championship-hub', latestSeason],
|
||||
queryFn: () => fetchChampionshipHub(latestSeason!),
|
||||
queryFn: ({ signal }) => fetchChampionshipHub(latestSeason!, signal),
|
||||
enabled: latestSeason != null,
|
||||
})
|
||||
|
||||
|
||||
162
frontend/src/components/RouteState.tsx
Normal file
162
frontend/src/components/RouteState.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user