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..1374a73 100644 --- a/frontend/src/components/weekend/PreSessionView.tsx +++ b/frontend/src/components/weekend/PreSessionView.tsx @@ -4,15 +4,29 @@ import type { WeekendContext } from '../../types' import { RacePreviewPage } from '../../pages/RacePreviewPage' import { ChampionshipRoundStrip, CountdownDisplay, Flag, SessionRail, railNodes } from './shared' import { meetingIdentity } from '../../lib/weekendContext' +import type { DataAvailability } from '../../lib/availability' /** * PreSessionView folds the race preview surface into the Weekend home so there is * no separate Preview destination. It reuses the existing preview page content and * frames it with the next-session countdown and compact season navigation. It also * backs the /preview alias, so saved preview links resolve here instead of looping. + * + * Canonical meeting/session identity is passed into Preview so the nested surface + * cannot independently re-resolve a different current weekend. */ -export function PreSessionView({ context, now }: { context: WeekendContext; now: Date }) { - const meeting = meetingIdentity(context.next_meeting ?? context.focus_meeting) +export function PreSessionView({ + context, + now, + shellAvailability = null, +}: { + context: WeekendContext + now: Date + /** Weekend shell notice already shown — Preview dedupes only an equivalent kind. */ + shellAvailability?: DataAvailability | null +}) { + const meeting = context.next_meeting ?? context.focus_meeting + const identity = meetingIdentity(meeting) const next = context.next_session?.session const nextStart = next?.date_start ?? meeting?.date_start const nodes = railNodes( @@ -25,11 +39,11 @@ export function PreSessionView({ context, now }: { context: WeekendContext; now:
Pre-session
- {meeting && ( + {identity && (
- -

{meeting.meeting_name}

+ +

{identity.meeting_name}

{next?.session_name ?? 'Next session'} @@ -44,7 +58,12 @@ export function PreSessionView({ context, now }: { context: WeekendContext; now: {nodes.length > 0 && }
- +
void - /** True while a canonical refetch is in flight. */ + /** True while a relevant refetch is in flight. */ isFetching: boolean } @@ -78,20 +83,58 @@ export function useWeekendContext(): UseWeekendContextResult { ) const briefing = useMemo(() => briefingItems(newsQuery.data ?? []), [newsQuery.data]) + const availabilityNotice = useMemo( + () => (canonical ? weekendContextNotice(canonical) : null), + [canonical], + ) + + // Keep Limited visible while a failed supplement is refetching so Retry can + // expose disabled/aria-busy. React Query v5 clears isError during that refetch. + const supplementDegraded = (q: { + isError: boolean + isFetching: boolean + isFetched: boolean + data: unknown + }) => q.isError || (q.isFetching && q.isFetched && q.data == null) + + const supplementsLimited = + canonicalReady && + (supplementDegraded(championshipQuery) || supplementDegraded(newsQuery)) + let loadState: WeekendLoadState = 'loading' if (contextQuery.isError) loadState = 'error' else if (canonicalReady) loadState = 'ready' + const refetch = () => { + if (!canonicalReady) { + if (!contextQuery.isFetching) void contextQuery.refetch() + return + } + // Freshness notice Retry refreshes the canonical context. + if (availabilityNotice && !contextQuery.isFetching) { + void contextQuery.refetch() + } + // Limited Retry must hit the failed supplements — not merely context. + // Gate on degraded (error or in-flight post-error refetch), not isError alone. + if (championshipQuery.isError && !championshipQuery.isFetching) { + void championshipQuery.refetch() + } + if (newsQuery.isError && !newsQuery.isFetching) { + void newsQuery.refetch() + } + } + return { context: canonical, loadState, error: contextQuery.error instanceof Error ? contextQuery.error : undefined, championship, briefing, + availabilityNotice, + supplementsLimited, now: nowDate, - refetch: () => { - if (!contextQuery.isFetching) void contextQuery.refetch() - }, - isFetching: contextQuery.isFetching, + refetch, + isFetching: + contextQuery.isFetching || championshipQuery.isFetching || newsQuery.isFetching, } } diff --git a/frontend/src/lib/availability.ts b/frontend/src/lib/availability.ts new file mode 100644 index 0000000..23316f9 --- /dev/null +++ b/frontend/src/lib/availability.ts @@ -0,0 +1,135 @@ +import { getResponseAvailability, type ResponseAvailability } from './fetch' +import type { ContextSession, WeekendContext } from '../types' + +/** Shared Weekend Context terminology for coverage / availability indicators. */ +export type DataAvailability = 'local' | 'partial' | 'stale' | 'archive' | 'limited' | 'missing' + +/** Freshness values that warrant a non-blocking DataNotice. */ +const NOTICE_FRESHNESS = new Set(['stale', 'partial', 'local', 'limited', 'archive']) + +/** + * Product-impact severity for aggregating conflicting reported freshness. + * Higher wins so routine Local cannot mask Limited/Partial/Stale/Archive. + */ +const NOTICE_SEVERITY: Record = { + local: 1, + archive: 2, + stale: 3, + partial: 4, + limited: 5, + missing: 6, +} + +/** + * Map a reported freshness string onto shared DataNotice vocabulary. + * Returns null for unreported / routine-success values (`fresh`, `live`, …). + * Never invents stale from React Query staleTime. + */ +export function noticeFromFreshness( + freshness: string | undefined | null, + opts?: { includeLocal?: boolean }, +): DataAvailability | null { + if (!freshness) return null + const includeLocal = opts?.includeLocal ?? true + if (freshness === 'local' && !includeLocal) return null + if (!NOTICE_FRESHNESS.has(freshness)) return null + return freshness as DataAvailability +} + +/** Prefer header freshness, then optional typed payload freshness. */ +export function noticeFromResponse( + data: unknown, + opts?: { includeLocal?: boolean; fallbackFreshness?: string }, +): DataAvailability | null { + const meta = getResponseAvailability(data) + return ( + noticeFromFreshness(meta?.freshness, opts) ?? + noticeFromFreshness(opts?.fallbackFreshness, opts) + ) +} + +/** + * Pick the worst (highest product-impact) reported notice among sources. + * Equivalent kinds collapse to one; Local never masks a stronger disclosure. + */ +export function aggregateNotices( + notices: Array, +): DataAvailability | null { + let worst: DataAvailability | null = null + for (const notice of notices) { + if (!notice) continue + if (!worst || NOTICE_SEVERITY[notice] > NOTICE_SEVERITY[worst]) { + worst = notice + } + } + return worst +} + +export function noticeMessage(kind: DataAvailability): string { + switch (kind) { + case 'stale': + return 'Showing stale cached data. Retry to refresh.' + case 'partial': + return 'Coverage is partial for this view.' + case 'local': + return 'Showing local season data.' + case 'limited': + return 'Some optional details are unavailable. Core data is still shown.' + case 'archive': + return 'Showing an archived snapshot.' + case 'missing': + return 'Some expected data is missing.' + } +} + +/** + * Session whose state the Weekend shell is presenting — mirrors backend + * focusedContextSession so an older terminal session never overrides focus. + */ +export function focusedContextSession(context: WeekendContext): ContextSession | undefined { + if (context.active_session) return context.active_session + if (!context.focus_meeting) return undefined + const focusKey = context.focus_meeting.meeting_key + for (const ref of [ + context.next_session, + context.previous_completed_session, + context.default_analysis_session, + ]) { + if (ref?.meeting?.meeting_key === focusKey) return ref + // Session.meeting_key is always present even when meeting identity is sparse. + if (ref && ref.session.meeting_key === focusKey) return ref + } + return undefined +} + +/** + * Weekend shell notice from authoritative response headers when present. + * A header that intentionally maps to no notice (e.g. focused local/local with + * Local suppressed) must not fall through to older previous-session archive/partial. + * When headers are absent, typed focus-session freshness may be used. + */ +export function weekendContextNotice( + context: WeekendContext, + responseMeta?: ResponseAvailability, +): DataAvailability | null { + const headerMeta = responseMeta ?? getResponseAvailability(context) + if (headerMeta) { + return noticeFromFreshness(headerMeta.freshness, { includeLocal: false }) + } + + const focus = focusedContextSession(context) + return noticeFromFreshness(focus?.availability.freshness, { includeLocal: false }) +} + +/** + * Embedded Preview should disclose its own freshness unless the Weekend shell + * already shows an equivalent notice (same DataAvailability kind). + */ +export function shouldShowEmbeddedNotice( + notice: DataAvailability | null | undefined, + shellNotice: DataAvailability | null | undefined, +): boolean { + if (!notice) return false + if (!shellNotice) return true + return notice !== shellNotice +} diff --git a/frontend/src/lib/fetch.ts b/frontend/src/lib/fetch.ts index 087b662..a20bc2b 100644 --- a/frontend/src/lib/fetch.ts +++ b/frontend/src/lib/fetch.ts @@ -1,7 +1,91 @@ /** Bounded fetch helpers for primary-route resilience. */ +import { replaceEqualDeep } from '@tanstack/react-query' + export const DEFAULT_FETCH_TIMEOUT_MS = 15_000 +/** CORS-readable success provenance headers from the Go API. */ +export const DATA_SOURCE_HEADER = 'X-BoxBox-Data-Source' +export const DATA_FRESHNESS_HEADER = 'X-BoxBox-Data-Freshness' + +/** Reported response source values (`openf1|local|mixed`, plus `fia` for live). */ +export type DataSourceHeader = 'openf1' | 'local' | 'mixed' | 'fia' | string + +/** Reported freshness (`fresh|stale|local|partial`, plus live/archive/limited). */ +export type DataFreshnessHeader = + | 'fresh' + | 'stale' + | 'local' + | 'partial' + | 'live' + | 'archive' + | 'limited' + | string + +/** Additive success-response availability metadata (never inferred from React Query). */ +export interface ResponseAvailability { + source?: DataSourceHeader + freshness?: DataFreshnessHeader +} + +const responseAvailability = new WeakMap() + +function readHeader(headers: Headers, name: string): string | undefined { + const value = headers.get(name)?.trim() + return value || undefined +} + +function captureResponseAvailability(data: unknown, headers: Headers): void { + if (data === null || (typeof data !== 'object' && typeof data !== 'function')) return + const source = readHeader(headers, DATA_SOURCE_HEADER) + const freshness = readHeader(headers, DATA_FRESHNESS_HEADER) + if (!source && !freshness) return + responseAvailability.set(data as object, { source, freshness }) +} + +/** Read availability metadata captured from the last successful fetch of this payload. */ +export function getResponseAvailability(data: unknown): ResponseAvailability | undefined { + if (data === null || (typeof data !== 'object' && typeof data !== 'function')) return undefined + return responseAvailability.get(data as object) +} + +/** Test / manual helper — attach reported availability to a payload object. */ +export function rememberResponseAvailability( + data: object, + meta: ResponseAvailability, +): void { + responseAvailability.set(data, meta) +} + +/** Drop availability metadata from a payload (e.g. after a header-less refetch). */ +export function clearResponseAvailability(data: object): void { + responseAvailability.delete(data) +} + +/** + * React Query structural sharing that keeps header availability in sync. + * + * Default `replaceEqualDeep` reuses the previous object when JSON is equal, which + * would leave stale WeakMap metadata attached after a stale→fresh (or fresh→partial) + * refetch of an identical body. Always re-bind the latest fetch's metadata onto the + * object that lands in the query cache. + */ +export function availabilityAwareStructuralSharing( + oldData: T | undefined, + newData: T, +): T { + const meta = getResponseAvailability(newData) + const shared = replaceEqualDeep(oldData, newData) as T + if (shared !== null && typeof shared === 'object') { + if (meta) { + rememberResponseAvailability(shared as object, meta) + } else { + clearResponseAvailability(shared as object) + } + } + return shared +} + export type ApiErrorKind = 'http' | 'timeout' | 'abort' | 'network' export class ApiError extends Error { @@ -159,7 +243,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/lib/queryClient.ts b/frontend/src/lib/queryClient.ts new file mode 100644 index 0000000..b94637e --- /dev/null +++ b/frontend/src/lib/queryClient.ts @@ -0,0 +1,16 @@ +import { QueryClient } from '@tanstack/react-query' +import { availabilityAwareStructuralSharing } from './fetch' + +/** Shared QueryClient defaults — availability-aware structural sharing for header metadata. */ +export function createAppQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + // Explicit Retry on RouteState — avoid automatic retry storms. + retry: false, + structuralSharing: availabilityAwareStructuralSharing, + }, + }, + }) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 8c8519c..9ee4f5d 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,19 +1,12 @@ import React from 'react' import ReactDOM from 'react-dom/client' import { RouterProvider } from '@tanstack/react-router' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClientProvider } from '@tanstack/react-query' +import { createAppQueryClient } from './lib/queryClient' import { router } from './router' import './styles/app.css' -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30_000, - // Explicit Retry on RouteState — avoid automatic retry storms. - retry: false, - }, - }, -}) +const queryClient = createAppQueryClient() ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/pages/BriefingPage.tsx b/frontend/src/pages/BriefingPage.tsx index 22984e4..f7571f3 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 { aggregateNotices, noticeFromResponse, noticeMessage } from '../lib/availability' import '../styles/digest.css' type Category = 'all' | 'official' | 'news' | 'video' @@ -516,6 +517,43 @@ export function BriefingPage() { const hasDigest = meetings.length > 0 const showEmpty = tagFiltered.length === 0 && grouped.recent.length === 0 + const supplementsFetching = + meetingsQuery.isFetching || hubQuery.isFetching || seasonsQuery.isFetching + // React Query v5 clears isError while a post-error refetch is in flight + // (status → pending). Keep Limited mounted so Retry can show aria-busy. + const supplementDegraded = (q: { + isError: boolean + isFetching: boolean + isFetched: boolean + data: unknown + }) => q.isError || (q.isFetching && q.isFetched && q.data == null) + const supplementsLimited = + !isLoading && + !isError && + (supplementDegraded(meetingsQuery) || + supplementDegraded(hubQuery) || + supplementDegraded(seasonsQuery)) + const supplementsRetrying = supplementsLimited && supplementsFetching + + const retrySupplements = () => { + if (supplementDegraded(meetingsQuery) && !meetingsQuery.isFetching) { + void meetingsQuery.refetch() + } + if (supplementDegraded(hubQuery) && !hubQuery.isFetching) { + void hubQuery.refetch() + } + if (supplementDegraded(seasonsQuery) && !seasonsQuery.isFetching) { + void seasonsQuery.refetch() + } + } + + // Aggregate by severity so routine local News cannot mask stale hub/meetings. + const availability = aggregateNotices([ + noticeFromResponse(allNews, { includeLocal: true }), + noticeFromResponse(hub, { includeLocal: false }), + noticeFromResponse(meetings, { includeLocal: false }), + ]) + return (
@@ -543,6 +581,27 @@ export function BriefingPage() { {!isLoading && !isError && ( <> + {supplementsLimited && ( + + )} + {!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 && ( + + )}