diff --git a/frontend/src/components/RouteState.tsx b/frontend/src/components/RouteState.tsx index b7d55a0..4612fa1 100644 --- a/frontend/src/components/RouteState.tsx +++ b/frontend/src/components/RouteState.tsx @@ -1,8 +1,8 @@ import type { ReactNode } from 'react' import { isTimeoutError, userFacingError } from '../lib/fetch' +import { noticeMessage, type DataAvailability } from '../lib/availability' -/** Weekend Context terminology for coverage / availability indicators. */ -export type DataAvailability = 'local' | 'partial' | 'stale' | 'archive' | 'limited' | 'missing' +export type { DataAvailability } export function availabilityLabel(kind: DataAvailability): string { switch (kind) { @@ -131,6 +131,7 @@ interface StaleNoticeProps { availability?: DataAvailability message?: string onRetry?: () => void + retrying?: boolean testId?: string } @@ -139,26 +140,22 @@ export function DataNotice({ availability = 'stale', message, onRetry, + retrying = false, 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} + {message ?? noticeMessage(availability)} {onRetry && ( - )}
diff --git a/frontend/src/components/weekend/PreSessionView.tsx b/frontend/src/components/weekend/PreSessionView.tsx index d295a2b..04a5746 100644 --- a/frontend/src/components/weekend/PreSessionView.tsx +++ b/frontend/src/components/weekend/PreSessionView.tsx @@ -10,9 +10,13 @@ import { meetingIdentity } from '../../lib/weekendContext' * no separate Preview destination. It reuses the existing preview page content and * frames it with the next-session countdown and compact season navigation. It also * backs the /preview alias, so saved preview links resolve here instead of looping. + * + * Canonical meeting/session identity is passed into Preview so the nested surface + * cannot independently re-resolve a different current weekend. */ export function PreSessionView({ context, now }: { context: WeekendContext; now: Date }) { - const meeting = meetingIdentity(context.next_meeting ?? context.focus_meeting) + const meeting = context.next_meeting ?? context.focus_meeting + const identity = meetingIdentity(meeting) const next = context.next_session?.session const nextStart = next?.date_start ?? meeting?.date_start const nodes = railNodes( @@ -25,11 +29,11 @@ export function PreSessionView({ context, now }: { context: WeekendContext; now:
Pre-session
- {meeting && ( + {identity && (
- -

{meeting.meeting_name}

+ +

{identity.meeting_name}

{next?.session_name ?? 'Next session'} @@ -44,7 +48,11 @@ export function PreSessionView({ context, now }: { context: WeekendContext; now: {nodes.length > 0 && }
- +
void @@ -78,6 +83,16 @@ export function useWeekendContext(): UseWeekendContextResult { ) const briefing = useMemo(() => briefingItems(newsQuery.data ?? []), [newsQuery.data]) + const availabilityNotice = useMemo( + () => (canonical ? weekendContextNotice(canonical) : null), + [canonical], + ) + + const supplementsLimited = + canonicalReady && + ((championshipQuery.isError && !championshipQuery.isFetching) || + (newsQuery.isError && !newsQuery.isFetching)) + let loadState: WeekendLoadState = 'loading' if (contextQuery.isError) loadState = 'error' else if (canonicalReady) loadState = 'ready' @@ -88,6 +103,8 @@ export function useWeekendContext(): UseWeekendContextResult { error: contextQuery.error instanceof Error ? contextQuery.error : undefined, championship, briefing, + availabilityNotice, + supplementsLimited, now: nowDate, refetch: () => { if (!contextQuery.isFetching) void contextQuery.refetch() diff --git a/frontend/src/lib/availability.ts b/frontend/src/lib/availability.ts new file mode 100644 index 0000000..af33a95 --- /dev/null +++ b/frontend/src/lib/availability.ts @@ -0,0 +1,76 @@ +import { getResponseAvailability, type ResponseAvailability } from './fetch' +import type { ContextSession, WeekendContext } from '../types' + +/** Shared Weekend Context terminology for coverage / availability indicators. */ +export type DataAvailability = 'local' | 'partial' | 'stale' | 'archive' | 'limited' | 'missing' + +/** Freshness values that warrant a non-blocking DataNotice. */ +const NOTICE_FRESHNESS = new Set(['stale', 'partial', 'local', 'limited', 'archive']) + +/** + * Map a reported freshness string onto shared DataNotice vocabulary. + * Returns null for unreported / routine-success values (`fresh`, `live`, …). + * Never invents stale from React Query staleTime. + */ +export function noticeFromFreshness( + freshness: string | undefined | null, + opts?: { includeLocal?: boolean }, +): DataAvailability | null { + if (!freshness) return null + const includeLocal = opts?.includeLocal ?? true + if (freshness === 'local' && !includeLocal) return null + if (!NOTICE_FRESHNESS.has(freshness)) return null + return freshness as DataAvailability +} + +/** Prefer header freshness, then optional typed payload freshness. */ +export function noticeFromResponse( + data: unknown, + opts?: { includeLocal?: boolean; fallbackFreshness?: string }, +): DataAvailability | null { + const meta = getResponseAvailability(data) + return ( + noticeFromFreshness(meta?.freshness, opts) ?? + noticeFromFreshness(opts?.fallbackFreshness, opts) + ) +} + +export function noticeMessage(kind: DataAvailability): string { + switch (kind) { + case 'stale': + return 'Showing stale cached data. Retry to refresh.' + case 'partial': + return 'Coverage is partial for this view.' + case 'local': + return 'Showing local season data.' + case 'limited': + return 'Some optional details are unavailable. Core data is still shown.' + case 'archive': + return 'Showing an archived snapshot.' + case 'missing': + return 'Some expected data is missing.' + } +} + +/** Pick the first notable freshness from Weekend Context session refs. */ +export function weekendContextNotice( + context: WeekendContext, + responseMeta?: ResponseAvailability, +): DataAvailability | null { + const fromHeader = + noticeFromFreshness(responseMeta?.freshness, { includeLocal: false }) ?? + noticeFromFreshness(getResponseAvailability(context)?.freshness, { includeLocal: false }) + if (fromHeader) return fromHeader + + const refs: Array = [ + context.active_session, + context.next_session, + context.previous_completed_session, + context.default_analysis_session, + ] + for (const ref of refs) { + const notice = noticeFromFreshness(ref?.availability.freshness, { includeLocal: false }) + if (notice) return notice + } + return null +} diff --git a/frontend/src/lib/fetch.ts b/frontend/src/lib/fetch.ts index 087b662..8b94698 100644 --- a/frontend/src/lib/fetch.ts +++ b/frontend/src/lib/fetch.ts @@ -2,6 +2,59 @@ export const DEFAULT_FETCH_TIMEOUT_MS = 15_000 +/** CORS-readable success provenance headers from the Go API. */ +export const DATA_SOURCE_HEADER = 'X-BoxBox-Data-Source' +export const DATA_FRESHNESS_HEADER = 'X-BoxBox-Data-Freshness' + +/** Reported response source values (`openf1|local|mixed`, plus `fia` for live). */ +export type DataSourceHeader = 'openf1' | 'local' | 'mixed' | 'fia' | string + +/** Reported freshness (`fresh|stale|local|partial`, plus live/archive/limited). */ +export type DataFreshnessHeader = + | 'fresh' + | 'stale' + | 'local' + | 'partial' + | 'live' + | 'archive' + | 'limited' + | string + +/** Additive success-response availability metadata (never inferred from React Query). */ +export interface ResponseAvailability { + source?: DataSourceHeader + freshness?: DataFreshnessHeader +} + +const responseAvailability = new WeakMap() + +function readHeader(headers: Headers, name: string): string | undefined { + const value = headers.get(name)?.trim() + return value || undefined +} + +function captureResponseAvailability(data: unknown, headers: Headers): void { + if (data === null || (typeof data !== 'object' && typeof data !== 'function')) return + const source = readHeader(headers, DATA_SOURCE_HEADER) + const freshness = readHeader(headers, DATA_FRESHNESS_HEADER) + if (!source && !freshness) return + responseAvailability.set(data as object, { source, freshness }) +} + +/** Read availability metadata captured from the last successful fetch of this payload. */ +export function getResponseAvailability(data: unknown): ResponseAvailability | undefined { + if (data === null || (typeof data !== 'object' && typeof data !== 'function')) return undefined + return responseAvailability.get(data as object) +} + +/** Test / manual helper — attach reported availability to a payload object. */ +export function rememberResponseAvailability( + data: object, + meta: ResponseAvailability, +): void { + responseAvailability.set(data, meta) +} + export type ApiErrorKind = 'http' | 'timeout' | 'abort' | 'network' export class ApiError extends Error { @@ -159,7 +212,9 @@ async function rawApiFetch(url: string, options: ApiFetchOptions = {}): Promi }) } - return (await res.json()) as T + const data = (await res.json()) as T + captureResponseAvailability(data, res.headers) + return data } finally { clearTimeout(timer) } diff --git a/frontend/src/pages/BriefingPage.tsx b/frontend/src/pages/BriefingPage.tsx index 22984e4..2700e1e 100644 --- a/frontend/src/pages/BriefingPage.tsx +++ b/frontend/src/pages/BriefingPage.tsx @@ -24,7 +24,8 @@ import { } from '../lib/digest' import { stripHtml, timeAgo } from '../utils' import type { ArticleContent, NewsItem } from '../types' -import { RouteState } from '../components/RouteState' +import { DataNotice, RouteState } from '../components/RouteState' +import { noticeFromResponse, noticeMessage } from '../lib/availability' import '../styles/digest.css' type Category = 'all' | 'official' | 'news' | 'video' @@ -516,6 +517,18 @@ export function BriefingPage() { const hasDigest = meetings.length > 0 const showEmpty = tagFiltered.length === 0 && grouped.recent.length === 0 + const supplementsLimited = + !isLoading && + !isError && + ((meetingsQuery.isError && !meetingsQuery.isFetching) || + (hubQuery.isError && !hubQuery.isFetching) || + (seasonsQuery.isError && !seasonsQuery.isFetching)) + + const newsAvailability = noticeFromResponse(allNews, { includeLocal: true }) + const hubAvailability = noticeFromResponse(hub, { includeLocal: false }) + const meetingsAvailability = noticeFromResponse(meetings, { includeLocal: false }) + const availability = newsAvailability ?? hubAvailability ?? meetingsAvailability + return (
@@ -543,6 +556,30 @@ export function BriefingPage() { {!isLoading && !isError && ( <> + {supplementsLimited && ( + { + if (meetingsQuery.isError && !meetingsQuery.isFetching) void meetingsQuery.refetch() + if (hubQuery.isError && !hubQuery.isFetching) void hubQuery.refetch() + if (seasonsQuery.isError && !seasonsQuery.isFetching) void seasonsQuery.refetch() + }} + testId="briefing-data-notice" + /> + )} + {!supplementsLimited && availability && ( + { + if (!isFetching) void refetch() + }} + retrying={isFetching} + testId="briefing-data-notice" + /> + )} + + const availability = noticeFromResponse(hub, { includeLocal: true }) + + return ( + { + if (!hubQuery.isFetching) void hubQuery.refetch() + }} + retrying={hubQuery.isFetching} + /> + ) } interface BodyProps { hub: ChampionshipHub view: View setView: (v: View) => void + availability?: ReturnType + onRetry?: () => void + retrying?: boolean } -function ChampionshipBody({ hub, view, setView }: BodyProps) { +function ChampionshipBody({ hub, view, setView, availability, onRetry, retrying }: BodyProps) { const { drivers, teams } = hub const leader = drivers[0] const remaining = hub.rounds_left * 25 @@ -216,6 +233,15 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) { return (
+ {availability && ( + + )}