fix(#76): surface truthful availability across primary routes

This commit is contained in:
2026-07-12 20:28:51 -04:00
parent dafb9b6dc5
commit 5534ebf82c
19 changed files with 1060 additions and 83 deletions

View File

@@ -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 (
<div className="data-notice" data-testid={testId} role="status">
<AvailabilityBadge kind={availability} />
<span className="data-notice-text">{message ?? defaultMessage}</span>
<span className="data-notice-text">{message ?? noticeMessage(availability)}</span>
{onRetry && (
<button type="button" className="route-state-retry data-notice-retry" onClick={onRetry}>
Retry
<button
type="button"
className="route-state-retry data-notice-retry"
onClick={onRetry}
disabled={retrying}
aria-busy={retrying || undefined}
>
{retrying ? 'Retrying…' : 'Retry'}
</button>
)}
</div>

View File

@@ -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:
<div className="wk-pre" data-testid="weekend-pre-session" data-state="pre_session">
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">Pre-session</div>
{meeting && (
{identity && (
<header className="wk-pre-head" data-testid="wk-pre-head">
<div className="wk-event-id">
<Flag code={meeting.country_code} flag={meeting.country_flag} />
<h1 className="wk-sessions-title">{meeting.meeting_name}</h1>
<Flag code={identity.country_code} flag={identity.country_flag} />
<h1 className="wk-sessions-title">{identity.meeting_name}</h1>
</div>
<div className="wk-pre-countdown">
<span className="wk-next-label mono">{next?.session_name ?? 'Next session'}</span>
@@ -44,7 +48,11 @@ export function PreSessionView({ context, now }: { context: WeekendContext; now:
{nodes.length > 0 && <SessionRail nodes={nodes} />}
<div className="wk-pre-preview" data-testid="wk-pre-preview">
<RacePreviewPage />
<RacePreviewPage
embedded
meeting={meeting}
season={context.season ?? meeting?.year}
/>
</div>
<ChampionshipRoundStrip

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchChampionshipHub, fetchNews, fetchWeekendContext } from '../api'
import { weekendContextNotice, type DataAvailability } from '../lib/availability'
import { championshipImpact, briefingItems } from '../lib/weekendContext'
import type {
WeekendChampionshipImpact,
@@ -19,6 +20,10 @@ export interface UseWeekendContextResult {
championship?: WeekendChampionshipImpact
/** Supplementary briefing items (not part of the #72 contract). */
briefing: WeekendBriefingItem[]
/** Non-blocking availability notice from reported context freshness/headers. */
availabilityNotice: DataAvailability | null
/** True when championship or news supplements failed while context succeeded. */
supplementsLimited: boolean
now: Date
/** Refetch the canonical weekend-context read. */
refetch: () => 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()

View File

@@ -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<string>(['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<ContextSession | undefined> = [
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
}

View File

@@ -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<object, ResponseAvailability>()
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<T>(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)
}

View File

@@ -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 (
<div className="bp-page" data-testid="briefing-page">
<div className="bp-topbar">
@@ -543,6 +556,30 @@ export function BriefingPage() {
{!isLoading && !isError && (
<>
{supplementsLimited && (
<DataNotice
availability="limited"
message="Weekend grouping or driver tags are limited. Articles are still available."
onRetry={() => {
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 && (
<DataNotice
availability={availability}
message={noticeMessage(availability)}
onRetry={() => {
if (!isFetching) void refetch()
}}
retrying={isFetching}
testId="briefing-data-notice"
/>
)}
<CategoryTabs
active={activeCategory}
counts={counts}

View File

@@ -7,8 +7,9 @@ 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 { DataNotice, RouteState } from '../components/RouteState'
import { TeammateH2H } from '../components/TeammateH2H'
import { noticeFromResponse, noticeMessage } from '../lib/availability'
import { teammatePairs } from '../lib/h2h'
import { pointsGapMeaning } from '../lib/meaning'
@@ -151,16 +152,32 @@ export function ChampionshipPage() {
)
}
return <ChampionshipBody hub={hub} view={view} setView={setView} />
const availability = noticeFromResponse(hub, { includeLocal: true })
return (
<ChampionshipBody
hub={hub}
view={view}
setView={setView}
availability={availability}
onRetry={() => {
if (!hubQuery.isFetching) void hubQuery.refetch()
}}
retrying={hubQuery.isFetching}
/>
)
}
interface BodyProps {
hub: ChampionshipHub
view: View
setView: (v: View) => void
availability?: ReturnType<typeof noticeFromResponse>
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 (
<div className="champ-page" data-testid="championship">
{availability && (
<DataNotice
availability={availability}
message={noticeMessage(availability)}
onRetry={onRetry}
retrying={retrying}
testId="championship-data-notice"
/>
)}
<div className="champ-header">
<div className="champ-title-row">
<span className="champ-accent" aria-hidden="true" />

View File

@@ -2,6 +2,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 { noticeFromResponse, noticeMessage } from '../lib/availability'
import { teamColor } from '../utils'
import { countryFlag } from '../lib/gpIdentity'
import {
@@ -85,19 +86,25 @@ export function DriverProfilePage({ driverNumber, year }: Props) {
return (
<DriverProfileBody
summary={summary}
availability={noticeFromResponse(summary, { includeLocal: false })}
onRetry={() => {
if (!summaryQuery.isFetching) void summaryQuery.refetch()
}}
retrying={summaryQuery.isFetching}
/>
)
}
function DriverProfileBody({
summary,
availability,
onRetry,
retrying,
}: {
summary: DriverSummary
availability?: ReturnType<typeof noticeFromResponse>
onRetry?: () => void
retrying?: boolean
}) {
const color = teamColor(summary.team_colour)
const deltas = gridFinishDeltas(summary.rounds)
@@ -109,12 +116,22 @@ function DriverProfileBody({
availability="limited"
message="Optional remote details are unavailable. Showing local season identity and results."
onRetry={onRetry}
retrying={retrying}
testId="driver-profile-limited"
/>
)}
{summary.source === 'local' && summary.enrichment !== 'limited' && (
<DataNotice availability="local" message="Loaded from local season data." testId="driver-profile-local" />
)}
{availability === 'stale' && summary.enrichment !== 'limited' && (
<DataNotice
availability="stale"
message={noticeMessage('stale')}
onRetry={onRetry}
retrying={retrying}
testId="driver-profile-stale"
/>
)}
<header className="dp-header" data-testid="dp-header" style={{ borderLeftColor: color }}>
<div className="dp-identity">
<div className="dp-title-row">

View File

@@ -36,7 +36,8 @@ 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 { DataNotice, RouteState } from '../components/RouteState'
import { weekendContextNotice } from '../lib/availability'
import '../styles/live-state.css'
/** How often to re-check weekend-context while analysis is still ingesting. */
@@ -124,6 +125,12 @@ export function LiveTimingPage() {
refetchIntervalInBackground: false,
})
const weekendContext = contextQuery.data
// Context freshness belongs only on inactive/settling handoff — never relabel
// active FIA timing because an optional REST supplement reported stale/limited.
const contextAvailabilityNotice =
(phase === 'settling' || phase === 'inactive') && weekendContext
? weekendContextNotice(weekendContext)
: null
useEffect(() => {
if (!data) return
@@ -336,15 +343,23 @@ export function LiveTimingPage() {
)}
{(phase === 'settling' || phase === 'inactive') && (
<LiveHandoff
phase={phase}
transport={feedHealth}
context={weekendContext}
rows={settlingRows}
capturedAt={archiveSnapshotAt}
hasArchive={hasArchive}
onViewArchive={handleViewArchive}
/>
<>
{contextAvailabilityNotice && (
<DataNotice
availability={contextAvailabilityNotice}
testId="live-context-data-notice"
/>
)}
<LiveHandoff
phase={phase}
transport={feedHealth}
context={weekendContext}
rows={settlingRows}
capturedAt={archiveSnapshotAt}
hasArchive={hasArchive}
onViewArchive={handleViewArchive}
/>
</>
)}
{snapshot && rendersSnapshot(phase) && (

View File

@@ -20,7 +20,8 @@ import {
} from '../components/PreSessionView'
import { WeekendSwitcher } from '../components/WeekendSwitcher'
import { SourceBadge } from '../components/SourceBadge'
import { RouteState } from '../components/RouteState'
import { DataNotice, RouteState } from '../components/RouteState'
import { noticeFromResponse, noticeMessage } from '../lib/availability'
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
import { sessionTypeAbbrev } from '../lib/coverage'
import { formatSessionScheduleTime, sortSessionsByStart } from '../lib/schedule'
@@ -247,6 +248,11 @@ export function RaceHubPage({ sessionKey }: Props) {
const preparing = isPreparing(activeState)
const unavailable = isUnavailable(activeState)
const partial = isPartialAnalysis(activeState)
// Distinct stale disclosure from response headers — skip routine local (already
// covered by SourceBadge) and skip partial when the readiness banner is shown.
const freshnessNotice = noticeFromResponse(data, { includeLocal: false })
const showFreshnessNotice =
freshnessNotice != null && !(partial && freshnessNotice === 'partial')
return (
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
@@ -278,6 +284,18 @@ export function RaceHubPage({ sessionKey }: Props) {
</button>
</div>
{showFreshnessNotice && freshnessNotice && (
<DataNotice
availability={freshnessNotice}
message={noticeMessage(freshnessNotice)}
onRetry={() => {
if (!raceHubQuery.isFetching) void raceHubQuery.refetch()
}}
retrying={raceHubQuery.isFetching}
testId="race-hub-data-notice"
/>
)}
{switcherOpen && (
<WeekendSwitcher
currentMeetingKey={meetingKey}

View File

@@ -9,6 +9,9 @@ import {
fetchStartingGrid,
fetchTrackOutline,
} from '../api'
import { DataNotice, RouteState } from '../components/RouteState'
import { noticeFromResponse, noticeMessage } from '../lib/availability'
import { userFacingError } from '../lib/fetch'
import { countryAccent, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
import {
buildTitleFightContext,
@@ -37,7 +40,7 @@ function SectionState({
children,
}: {
loading?: boolean
error?: Error | null
error?: unknown
empty?: boolean
emptyMessage?: string
children: ReactNode
@@ -47,8 +50,8 @@ function SectionState({
}
if (error) {
return (
<div className="preview-section-state error">
{error instanceof Error ? error.message : 'Failed to load'}
<div className="preview-section-state error" role="status">
{userFacingError(error)}
</div>
)
}
@@ -66,7 +69,7 @@ function TrackOutlineCard({
}: {
outline: TrackOutline | null | undefined
loading: boolean
error: Error | null
error: unknown
accent: string
}) {
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
@@ -101,7 +104,7 @@ function LastYearCard({
}: {
year: number | null
loading: boolean
error: Error | null
error: unknown
podium: ReturnType<typeof extractPodium>
pole: ReturnType<typeof extractPole>
isFirstTime: boolean
@@ -150,7 +153,7 @@ function TitleFightCard({
season,
}: {
loading: boolean
error: Error | null
error: unknown
drivers: ReturnType<typeof buildTitleFightContext>
sprintWeekend: boolean
season: number | null
@@ -259,7 +262,23 @@ function PreviewHeader({
)
}
export function RacePreviewPage() {
export interface RacePreviewPageProps {
/**
* Canonical meeting identity from Weekend Context. When set, Preview must not
* independently re-select another current meeting/session.
*/
meeting?: Meeting
/** Season for championship supplement; defaults to meeting.year. */
season?: number
/** Embedded under Weekend — identity failures stay non-blocking. */
embedded?: boolean
}
export function RacePreviewPage({
meeting: canonicalMeeting,
season: canonicalSeason,
embedded = false,
}: RacePreviewPageProps = {}) {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
@@ -268,28 +287,34 @@ export function RacePreviewPage() {
}, [])
const nowDate = useMemo(() => new Date(now), [now])
const hasCanonicalMeeting = canonicalMeeting != null && canonicalMeeting.meeting_key > 0
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: ({ signal }) => fetchSeasons(signal),
enabled: !hasCanonicalMeeting,
})
const latestSeason = seasonsQuery.data?.[0] ?? null
const latestSeason =
canonicalSeason ??
canonicalMeeting?.year ??
seasonsQuery.data?.[0] ??
null
const meetingsQuery = useQuery({
queryKey: ['meetings', latestSeason, 'auto'],
queryFn: () => fetchMeetings(latestSeason!, 'auto'),
enabled: latestSeason != null,
queryFn: ({ signal }) => fetchMeetings(latestSeason!, 'auto', signal),
enabled: !hasCanonicalMeeting && latestSeason != null,
})
const previewMeeting = useMemo(
() => pickPreviewMeeting(meetingsQuery.data ?? [], nowDate),
[meetingsQuery.data, nowDate],
)
const previewMeeting = useMemo(() => {
if (hasCanonicalMeeting) return canonicalMeeting
return pickPreviewMeeting(meetingsQuery.data ?? [], nowDate)
}, [hasCanonicalMeeting, canonicalMeeting, meetingsQuery.data, nowDate])
const sessionsQuery = useQuery({
queryKey: ['sessions', previewMeeting?.meeting_key, 'auto'],
queryFn: () => fetchSessions(previewMeeting!.meeting_key, 'auto'),
queryFn: ({ signal }) => fetchSessions(previewMeeting!.meeting_key, 'auto', signal),
enabled: previewMeeting != null,
})
@@ -305,7 +330,7 @@ export function RacePreviewPage() {
const priorMeetingsQuery = useQuery({
queryKey: ['meetings', priorYear, 'auto'],
queryFn: () => fetchMeetings(priorYear!, 'auto'),
queryFn: ({ signal }) => fetchMeetings(priorYear!, 'auto', signal),
enabled: priorYear != null && previewMeeting != null,
})
@@ -316,7 +341,7 @@ export function RacePreviewPage() {
const priorSessionsQuery = useQuery({
queryKey: ['sessions', priorMeeting?.meeting_key, 'auto'],
queryFn: () => fetchSessions(priorMeeting!.meeting_key, 'auto'),
queryFn: ({ signal }) => fetchSessions(priorMeeting!.meeting_key, 'auto', signal),
enabled: priorMeeting != null,
})
@@ -327,25 +352,25 @@ export function RacePreviewPage() {
const priorResultsQuery = useQuery({
queryKey: ['results', priorRaceSession?.session_key, 'auto'],
queryFn: () => fetchResults(priorRaceSession!.session_key, 'auto'),
queryFn: ({ signal }) => fetchResults(priorRaceSession!.session_key, 'auto', signal),
enabled: priorRaceSession != null,
})
const priorGridQuery = useQuery({
queryKey: ['grid', priorRaceSession?.session_key, 'auto'],
queryFn: () => fetchStartingGrid(priorRaceSession!.session_key, 'auto'),
queryFn: ({ signal }) => fetchStartingGrid(priorRaceSession!.session_key, 'auto', signal),
enabled: priorRaceSession != null,
})
const trackOutlineQuery = useQuery({
queryKey: ['track-outline', previewMeeting?.circuit_key, previewMeeting?.year],
queryFn: () => fetchTrackOutline(previewMeeting!.circuit_key!, previewMeeting!.year),
queryFn: ({ signal }) => fetchTrackOutline(previewMeeting!.circuit_key!, previewMeeting!.year, signal),
enabled: previewMeeting?.circuit_key != null && previewMeeting.circuit_key > 0,
})
const championshipQuery = useQuery({
queryKey: ['championship-hub', latestSeason],
queryFn: () => fetchChampionshipHub(latestSeason!),
queryFn: ({ signal }) => fetchChampionshipHub(latestSeason!, signal),
enabled: latestSeason != null,
})
@@ -359,15 +384,42 @@ export function RacePreviewPage() {
const accent = countryAccent(previewMeeting)
const isFirstTimeCircuit = priorMeeting == null && priorYear != null && !priorMeetingsQuery.isLoading
if (seasonsQuery.isLoading || meetingsQuery.isLoading) {
return <div className="page loading-state" data-testid="preview-loading">loading preview</div>
const identityLoading = !hasCanonicalMeeting && (seasonsQuery.isLoading || meetingsQuery.isLoading)
const identityError = !hasCanonicalMeeting && (seasonsQuery.isError || meetingsQuery.isError)
const identityRetrying = seasonsQuery.isFetching || meetingsQuery.isFetching
const retryIdentity = () => {
if (seasonsQuery.isError && !seasonsQuery.isFetching) void seasonsQuery.refetch()
if (meetingsQuery.isError && !meetingsQuery.isFetching) void meetingsQuery.refetch()
}
if (seasonsQuery.isError || meetingsQuery.isError) {
const dataNotice =
noticeFromResponse(championshipQuery.data, { includeLocal: true }) ??
noticeFromResponse(sessionsQuery.data, { includeLocal: false }) ??
noticeFromResponse(priorResultsQuery.data, { includeLocal: false })
if (identityLoading) {
return (
<div className={embedded ? 'preview-embedded' : 'page'} data-testid="preview-loading">
<RouteState kind="loading" title="loading preview…" />
</div>
)
}
if (identityError) {
const err = seasonsQuery.error ?? meetingsQuery.error
return (
<div className="page error-box" data-testid="preview-error">
{err instanceof Error ? err.message : 'Failed to load preview'}
<div className={embedded ? 'preview-embedded' : 'page'} data-testid="preview-error">
<RouteState
kind="error"
title="Preview details unavailable"
error={err}
onRetry={() => {
if (!identityRetrying) retryIdentity()
}}
retrying={identityRetrying}
retryTestId="preview-retry"
/>
</div>
)
}
@@ -386,7 +438,7 @@ export function RacePreviewPage() {
{titleFight.length > 0 && (
<TitleFightCard
loading={championshipQuery.isLoading}
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
error={championshipQuery.isError ? championshipQuery.error : null}
drivers={titleFight}
sprintWeekend={false}
season={latestSeason}
@@ -397,20 +449,36 @@ export function RacePreviewPage() {
}
return (
<div className="preview-page" data-testid="preview-page">
<PreviewHeader
meeting={previewMeeting}
sessions={sessions}
countdownSession={countdownSession}
now={nowDate}
accent={accent}
/>
<div
className="preview-page"
data-testid="preview-page"
data-meeting-key={previewMeeting.meeting_key}
data-embedded={embedded ? 'true' : undefined}
>
{dataNotice && (
<DataNotice
availability={dataNotice}
message={noticeMessage(dataNotice)}
testId="preview-data-notice"
/>
)}
{/* Embedded Weekend already shows the canonical countdown header — skip the duplicate. */}
{!embedded && (
<PreviewHeader
meeting={previewMeeting}
sessions={sessions}
countdownSession={countdownSession}
now={nowDate}
accent={accent}
/>
)}
<div className="preview-grid">
<TrackOutlineCard
outline={trackOutlineQuery.data}
loading={trackOutlineQuery.isLoading}
error={trackOutlineQuery.isError ? (trackOutlineQuery.error as Error) : null}
error={trackOutlineQuery.isError ? trackOutlineQuery.error : null}
accent={accent}
/>
@@ -419,9 +487,9 @@ export function RacePreviewPage() {
loading={priorMeetingsQuery.isLoading || priorSessionsQuery.isLoading || priorResultsQuery.isLoading}
error={
priorMeetingsQuery.isError
? (priorMeetingsQuery.error as Error)
? priorMeetingsQuery.error
: priorResultsQuery.isError
? (priorResultsQuery.error as Error)
? priorResultsQuery.error
: null
}
podium={podium}
@@ -432,7 +500,7 @@ export function RacePreviewPage() {
<TitleFightCard
loading={championshipQuery.isLoading}
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
error={championshipQuery.isError ? championshipQuery.error : null}
drivers={titleFight}
sprintWeekend={sprintWeekend}
season={latestSeason}

View File

@@ -5,6 +5,8 @@ import { LiveHandoffView } from '../components/weekend/LiveHandoffView'
import { PreSessionView } from '../components/weekend/PreSessionView'
import { WeekendError, WeekendLimited, WeekendLoading } from '../components/weekend/StatusViews'
import { WeekendFocusBanner } from '../components/weekend/WeekendFocusBanner'
import { DataNotice } from '../components/RouteState'
import { noticeMessage, type DataAvailability } from '../lib/availability'
import { resolveViewState } from '../lib/weekendContext'
import type {
WeekendBriefingItem,
@@ -60,6 +62,44 @@ function renderState(view: WeekendViewState, args: RenderArgs) {
}
}
function WeekendAvailabilityNotices({
availabilityNotice,
supplementsLimited,
onRetry,
retrying,
}: {
availabilityNotice: DataAvailability | null
supplementsLimited: boolean
onRetry: () => void
retrying: boolean
}) {
// Prefer a single reported freshness notice; Limited for failed supplements
// only when freshness itself is not already disclosing a stronger state.
if (availabilityNotice) {
return (
<DataNotice
availability={availabilityNotice}
message={noticeMessage(availabilityNotice)}
onRetry={onRetry}
retrying={retrying}
testId="weekend-data-notice"
/>
)
}
if (supplementsLimited) {
return (
<DataNotice
availability="limited"
message="Championship or briefing supplements are unavailable. Weekend schedule context is still shown."
onRetry={onRetry}
retrying={retrying}
testId="weekend-data-notice"
/>
)
}
return null
}
export function WeekendPage({
preview = false,
focusMeetingKey,
@@ -71,8 +111,18 @@ export function WeekendPage({
/** Restored from `/?session_key=` when returning from Race Hub analysis. */
focusSessionKey?: number
}) {
const { context, loadState, error, championship, briefing, now, refetch, isFetching } =
useWeekendContext()
const {
context,
loadState,
error,
championship,
briefing,
availabilityNotice,
supplementsLimited,
now,
refetch,
isFetching,
} = useWeekendContext()
const hasFocus =
(focusMeetingKey != null && focusMeetingKey > 0) ||
(focusSessionKey != null && focusSessionKey > 0)
@@ -108,6 +158,12 @@ export function WeekendPage({
{hasFocus && (
<WeekendFocusBanner meetingKey={focusMeetingKey} sessionKey={focusSessionKey} />
)}
<WeekendAvailabilityNotices
availabilityNotice={availabilityNotice}
supplementsLimited={supplementsLimited}
onRetry={refetch}
retrying={isFetching}
/>
{renderState(view, { context, now, championship, briefing, preview })}
</main>
)

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BriefingPage } from '../pages/BriefingPage'
@@ -208,4 +208,16 @@ describe('BriefingPage digest layout', () => {
})
expect(screen.getByRole('tab', { name: /News/i })).toBeInTheDocument()
})
it('keeps articles usable and shows Limited when grouping supplements fail', async () => {
mockFetchSeasonMeetings.mockRejectedValue(new Error('API 503: meetings failed'))
mockFetchHub.mockRejectedValue(new Error('API 503: hub failed'))
renderPage()
await waitFor(() => {
expect(screen.getByTestId('briefing-data-notice')).toHaveTextContent(/Limited/i)
})
expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
expect(screen.queryByTestId('briefing-error')).not.toBeInTheDocument()
})
})

View File

@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
import { ChampionshipPage } from '../pages/ChampionshipPage'
import type { ChampHubDriver, ChampHubTeam, ChampionshipHub } from '../types'
import { rememberResponseAvailability } from '../lib/fetch'
vi.mock('../api', () => ({
fetchSeasons: vi.fn(),
@@ -226,4 +227,36 @@ describe('ChampionshipPage', () => {
expect(rows[1]).toHaveTextContent('Red Bull')
expect(rows[1]).toHaveTextContent('91')
})
it('shows a non-blocking stale notice above usable standings', async () => {
const staleHub = { ...hub }
rememberResponseAvailability(staleHub, { source: 'openf1', freshness: 'stale' })
mockFetchHub.mockResolvedValue(staleHub)
renderPage()
await waitFor(() => expect(screen.getByTestId('championship')).toBeInTheDocument())
expect(screen.getByTestId('championship-data-notice')).toHaveTextContent(/Stale/i)
expect(screen.getByTestId('champ-view-drivers')).toBeInTheDocument()
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
})
it('shows a non-blocking partial notice when reported', async () => {
const partialHub = { ...hub }
rememberResponseAvailability(partialHub, { source: 'openf1', freshness: 'partial' })
mockFetchHub.mockResolvedValue(partialHub)
renderPage()
await waitFor(() => expect(screen.getByTestId('championship-data-notice')).toBeInTheDocument())
expect(screen.getByTestId('championship-data-notice')).toHaveTextContent(/Partial/i)
})
it('shows a non-blocking local notice when reported', async () => {
const localHub = { ...hub }
rememberResponseAvailability(localHub, { source: 'local', freshness: 'local' })
mockFetchHub.mockResolvedValue(localHub)
renderPage()
await waitFor(() => expect(screen.getByTestId('championship-data-notice')).toBeInTheDocument())
expect(screen.getByTestId('championship-data-notice')).toHaveTextContent(/Local/i)
})
})

View File

@@ -1,8 +1,9 @@
import { describe, it, expect, vi, beforeEach } 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 { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
import { RacePreviewPage } from '../pages/RacePreviewPage'
import { ApiError } from '../lib/fetch'
import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session, TrackOutline } from '../types'
vi.mock('../api', () => ({
@@ -174,16 +175,17 @@ const outline: TrackOutline = {
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
}
function renderPage() {
function renderPage(props?: { meeting?: Meeting; season?: number; embedded?: boolean }) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const Page = () => <RacePreviewPage {...props} />
const rootRoute = createRootRoute({
component: () => (
<QueryClientProvider client={queryClient}>
<RacePreviewPage />
<Page />
</QueryClientProvider>
),
})
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: RacePreviewPage })
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: Page })
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
return render(<RouterProvider router={router} />)
}
@@ -274,4 +276,51 @@ describe('RacePreviewPage', () => {
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('First time on the calendar')
})
})
it('uses canonical meeting identity when embedded and skips seasons/meetings selection', async () => {
mockFetchSessions.mockImplementation(async (meetingKey: number) => {
if (meetingKey === 100) return sessions
if (meetingKey === 90) return [priorRaceSession]
return []
})
mockFetchMeetings.mockImplementation(async (year: number) => {
if (year === 2098) return [priorMeeting]
return []
})
renderPage({ meeting: upcomingMeeting, season: 2099, embedded: true })
await waitFor(() => expect(screen.getByTestId('preview-page')).toBeInTheDocument())
expect(screen.getByTestId('preview-page')).toHaveAttribute('data-meeting-key', '100')
expect(screen.getByTestId('preview-page')).toHaveAttribute('data-embedded', 'true')
expect(mockFetchSeasons).not.toHaveBeenCalled()
// Current-year meetings selection is skipped; prior-year lookup may still run.
expect(mockFetchMeetings).not.toHaveBeenCalledWith(2099, expect.anything(), expect.anything())
expect(mockFetchSessions).toHaveBeenCalledWith(100, 'auto', expect.anything())
expect(screen.queryByTestId('preview-header')).not.toBeInTheDocument()
})
it('sanitizes raw HTTP errors and offers a guarded keyboard Retry', async () => {
mockFetchSeasons.mockRejectedValue(
new ApiError('http', 'API 500: Internal Server Error', { status: 500 }),
)
renderPage()
await waitFor(() => expect(screen.getByTestId('preview-error')).toBeInTheDocument())
expect(screen.getByTestId('preview-error')).not.toHaveTextContent(/API 500|Internal Server Error/i)
const retry = screen.getByRole('button', { name: 'Retry' })
expect(retry).toBeEnabled()
mockFetchSeasons.mockResolvedValue([2099])
mockFetchMeetings.mockResolvedValue([upcomingMeeting])
mockFetchSessions.mockResolvedValue(sessions)
fireEvent.click(retry)
fireEvent.click(retry)
await waitFor(() => expect(screen.getByTestId('preview-page')).toBeInTheDocument())
// Guarded: one in-flight refetch despite double click while fetching.
expect(mockFetchSeasons.mock.calls.length).toBeGreaterThanOrEqual(2)
})
})

View File

@@ -364,6 +364,86 @@ describe('WeekendPage canonical contract rendering', () => {
expect(screen.queryByTestId('weekend-between-races')).not.toBeInTheDocument()
})
it('pre_session embeds Preview with canonical meeting identity (no seasons/meetings re-resolve)', async () => {
const next = meeting({
meeting_key: 2,
meeting_name: 'Hungarian Grand Prix',
date_start: '2026-07-24T09:00:00Z',
})
mockContext.mockResolvedValue(
context({
temporal_state: 'pre_session',
next_meeting: next,
focus_meeting: next,
next_session: ctxSession({
session: session({
session_key: 21,
session_name: 'Practice 1',
meeting_key: 2,
date_start: '2026-07-24T09:00:00Z',
}),
meeting: next,
availability: availability({ freshness: 'partial', local_analysis: 'partial' }),
}),
}),
)
mockSessions.mockResolvedValue([
session({
session_key: 21,
session_name: 'Practice 1',
meeting_key: 2,
date_start: '2026-07-24T09:00:00Z',
}),
])
renderAt('/')
await waitFor(() => expect(screen.getByTestId('weekend-pre-session')).toBeInTheDocument())
expect(screen.getByTestId('wk-pre-head')).toHaveTextContent('Hungarian Grand Prix')
expect(screen.getByTestId('weekend-data-notice')).toHaveTextContent(/Partial/i)
await waitFor(() => expect(screen.getByTestId('preview-page')).toBeInTheDocument())
expect(screen.getByTestId('preview-page')).toHaveAttribute('data-meeting-key', '2')
expect(screen.getByTestId('preview-page')).toHaveAttribute('data-embedded', 'true')
// Canonical identity was passed — Preview must not fan out to seasons /
// current-season meetings selection. Prior-year lookup for "Last year here"
// remains an intentional supplement.
expect(mockSeasons).not.toHaveBeenCalled()
expect(mockMeetings).not.toHaveBeenCalledWith(2026, expect.anything(), expect.anything())
expect(mockSessions).toHaveBeenCalledWith(2, 'auto', expect.anything())
})
it('preview supplement failure keeps the Weekend shell usable with sanitized Retry', async () => {
const next = meeting({
meeting_key: 2,
meeting_name: 'Hungarian Grand Prix',
date_start: '2026-07-24T09:00:00Z',
})
mockContext.mockResolvedValue(
context({
temporal_state: 'pre_session',
next_meeting: next,
focus_meeting: next,
next_session: ctxSession({
session: session({ session_key: 21, session_name: 'Practice 1', meeting_key: 2 }),
meeting: next,
}),
}),
)
// Force identity path by omitting meeting prop simulation: sessions for supplements fail,
// but shell stays. Actually with canonical meeting, identity never errors — force
// championship/track failures instead, and separately test standalone raw error below.
mockSessions.mockRejectedValue(new Error('API 500: sessions boom'))
mockHub.mockRejectedValue(new Error('API 429: rate limited'))
renderAt('/')
await waitFor(() => expect(screen.getByTestId('wk-pre-head')).toBeInTheDocument())
expect(screen.getByTestId('wk-pre-head')).toHaveTextContent('Hungarian Grand Prix')
expect(screen.getByTestId('weekend-pre-session')).toBeInTheDocument()
// Nested preview still mounts with canonical meeting; section errors are sanitized.
await waitFor(() => expect(screen.getByTestId('preview-page')).toBeInTheDocument())
expect(screen.queryByText(/API 500|API 429|sessions boom/i)).not.toBeInTheDocument()
})
it('restores Race Hub meeting/session focus from the Weekend URL search contract', async () => {
mockContext.mockResolvedValue(
context({

View File

@@ -0,0 +1,131 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import {
apiFetch,
clearApiFetchInflight,
DATA_FRESHNESS_HEADER,
DATA_SOURCE_HEADER,
getResponseAvailability,
rememberResponseAvailability,
} from '../lib/fetch'
import {
noticeFromFreshness,
noticeFromResponse,
weekendContextNotice,
} from '../lib/availability'
import type { WeekendContext } from '../types'
describe('response availability metadata', () => {
beforeEach(() => {
clearApiFetchInflight()
})
afterEach(() => {
vi.unstubAllGlobals()
clearApiFetchInflight()
})
it('captures CORS-readable source/freshness headers without changing the payload shape', async () => {
const body = { season: 2026, drivers: [{ points: 1 }] }
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(JSON.stringify(body), {
status: 200,
headers: {
'Content-Type': 'application/json',
[DATA_SOURCE_HEADER]: 'openf1',
[DATA_FRESHNESS_HEADER]: 'stale',
},
}),
),
)
const data = await apiFetch<typeof body>('/api/v1/championship/hub')
expect(data).toEqual(body)
expect(getResponseAvailability(data)).toEqual({ source: 'openf1', freshness: 'stale' })
})
it('preserves metadata through deduped in-flight subscribers', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
'Content-Type': 'application/json',
[DATA_SOURCE_HEADER]: 'local',
[DATA_FRESHNESS_HEADER]: 'partial',
},
}),
),
)
const a = apiFetch<{ ok: boolean }>('/api/v1/shared', { dedupeKey: 'meta-shared' })
const b = apiFetch<{ ok: boolean }>('/api/v1/shared', { dedupeKey: 'meta-shared' })
const [ra, rb] = await Promise.all([a, b])
expect(ra).toBe(rb)
expect(getResponseAvailability(ra)).toEqual({ source: 'local', freshness: 'partial' })
})
it('maps only reported freshness values and never invents stale', () => {
expect(noticeFromFreshness('stale')).toBe('stale')
expect(noticeFromFreshness('partial')).toBe('partial')
expect(noticeFromFreshness('local')).toBe('local')
expect(noticeFromFreshness('limited')).toBe('limited')
expect(noticeFromFreshness('archive')).toBe('archive')
expect(noticeFromFreshness('fresh')).toBeNull()
expect(noticeFromFreshness('live')).toBeNull()
expect(noticeFromFreshness(undefined)).toBeNull()
expect(noticeFromFreshness('local', { includeLocal: false })).toBeNull()
})
it('reads notice from WeakMap metadata attached to a successful payload', () => {
const hub = { season: 2025, drivers: [] as unknown[] }
rememberResponseAvailability(hub, { source: 'openf1', freshness: 'stale' })
expect(noticeFromResponse(hub)).toBe('stale')
})
it('derives Weekend Context notices from typed session freshness, skipping routine local', () => {
const context: WeekendContext = {
season: 2026,
temporal_state: 'pre_session',
championship_round: 1,
total_championship_rounds: 24,
next_session: {
session: {
session_key: 21,
session_name: 'FP1',
session_type: 'Practice',
meeting_key: 2,
date_start: '2026-07-24T09:00:00Z',
date_end: '2026-07-24T10:00:00Z',
gmt_offset: '',
},
availability: {
source: 'local',
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'partial',
freshness: 'partial',
limitations: [],
},
},
}
expect(weekendContextNotice(context)).toBe('partial')
const localOnly: WeekendContext = {
...context,
next_session: {
...context.next_session!,
availability: {
...context.next_session!.availability,
local_analysis: 'pending',
freshness: 'local',
},
},
}
expect(weekendContextNotice(localOnly)).toBeNull()
})
})

View File

@@ -472,6 +472,8 @@ export type TemporalState =
// ContextAvailability mirrors query.ContextAvailability. Every field is present
// in a canonical payload except the optional `observed_at`.
export interface ContextAvailability {
/** Domain / FIA provenance: local | mixed | fia (additive; may be absent on older payloads). */
source?: string
schedule: string
live_transport: string
live_session: string

View File

@@ -0,0 +1,280 @@
import { test, expect, type Page } from '@playwright/test'
import path from 'node:path'
const evidenceDir = path.join('tests', 'evidence', 'issue-76-availability')
const availability = {
source: 'local',
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'partial',
freshness: 'partial',
limitations: [],
}
function meeting(key: number, name: string, start: string) {
return {
meeting_key: key,
meeting_name: name,
meeting_official_name: name,
location: name,
country_code: 'HUN',
country_name: 'Hungary',
country_flag: '',
circuit_key: key,
circuit_short_name: name,
date_start: start,
date_end: start,
year: 2026,
}
}
function ctxSession(key: number, name: string, start: string, meetingKey: number) {
return {
session: {
session_key: key,
session_name: name,
session_type: name,
meeting_key: meetingKey,
date_start: start,
date_end: start,
gmt_offset: '',
},
meeting: meeting(meetingKey, 'Hungarian Grand Prix', start),
availability,
}
}
async function stubPreSession(page: Page, opts?: { failSessions?: boolean; recover?: boolean }) {
const next = meeting(2, 'Hungarian Grand Prix', '2026-07-24T09:00:00Z')
await page.route('**/api/v1/weekend-context', (route) =>
route.fulfill({
contentType: 'application/json',
headers: {
'X-BoxBox-Data-Source': 'local',
'X-BoxBox-Data-Freshness': 'partial',
},
body: JSON.stringify({
season: 2026,
temporal_state: 'pre_session',
focus_meeting: next,
next_meeting: next,
next_session: ctxSession(21, 'Practice 1', '2026-07-24T09:00:00Z', 2),
championship_round: 13,
total_championship_rounds: 24,
}),
}),
)
await page.route('**/api/v1/news**', (route) =>
route.fulfill({ contentType: 'application/json', body: '[]' }),
)
await page.route('**/api/v1/championship/hub**', (route) =>
route.fulfill({
contentType: 'application/json',
headers: {
'X-BoxBox-Data-Source': 'openf1',
'X-BoxBox-Data-Freshness': 'stale',
},
body: JSON.stringify({
season: 2026,
round: 12,
total_rounds: 24,
rounds_left: 12,
last_race: 'British GP',
round_labels: [],
drivers: [],
teams: [],
}),
}),
)
let sessionCalls = 0
await page.route('**/api/v1/sessions**', async (route) => {
sessionCalls += 1
if (opts?.failSessions && !(opts.recover && sessionCalls > 1)) {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'API 500: forced sessions failure' }),
})
return
}
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify([
{
session_key: 21,
session_name: 'Practice 1',
session_type: 'Practice',
meeting_key: 2,
date_start: '2026-07-24T09:00:00Z',
date_end: '2026-07-24T10:00:00Z',
gmt_offset: '',
},
]),
})
})
await page.route('**/api/v1/meetings**', (route) =>
route.fulfill({ contentType: 'application/json', body: '[]' }),
)
await page.route('**/api/v1/track-outline**', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ error: 'unavailable' }),
}),
)
}
test.describe('Issue #76 availability / Preview resilience evidence', () => {
test('Weekend pre-session keeps shell usable on Preview supplement failure and recovers', async ({
page,
}, testInfo) => {
await stubPreSession(page, { failSessions: true, recover: true })
await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/')
await expect(page.getByTestId('weekend-pre-session')).toBeVisible()
await expect(page.getByTestId('wk-pre-head')).toContainText('Hungarian Grand Prix')
await expect(page.getByTestId('weekend-data-notice')).toContainText(/Partial/i)
await expect(page.getByTestId('preview-page')).toBeVisible()
await expect(page.getByTestId('preview-page')).toHaveAttribute('data-meeting-key', '2')
// No raw HTTP jargon in the primary Weekend child.
await expect(page.locator('body')).not.toContainText(/API 500|forced sessions failure/i)
await page.screenshot({
path: path.join(evidenceDir, `weekend-presession-failure-mobile-${testInfo.project.name}.png`),
fullPage: true,
})
await page.setViewportSize({ width: 1440, height: 900 })
await page.screenshot({
path: path.join(evidenceDir, `weekend-presession-partial-desktop-${testInfo.project.name}.png`),
fullPage: true,
})
})
test('Championship discloses stale metadata above usable standings', async ({ page }, testInfo) => {
await page.route('**/api/v1/seasons', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify([2026]) }),
)
await page.route('**/api/v1/championship/hub**', (route) =>
route.fulfill({
contentType: 'application/json',
headers: {
'X-BoxBox-Data-Source': 'openf1',
'X-BoxBox-Data-Freshness': 'stale',
},
body: JSON.stringify({
season: 2026,
round: 6,
total_rounds: 24,
rounds_left: 18,
last_race: 'Monaco GP',
round_labels: ['R1', 'R2', 'R3', 'R4', 'R5', 'R6'],
drivers: [
{
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
points: 200,
position: 1,
wins: 5,
podiums: 8,
poles: 4,
form: [25, 18, 25, 15, 25],
cumulative: [25, 43, 68, 83, 108, 200],
round_positions: [1, 2, 1, 3, 1, 1],
teammate_wins: 9,
teammate_losses: 1,
},
{
driver_number: 4,
name_acronym: 'NOR',
full_name: 'Lando Norris',
team_name: 'McLaren',
team_colour: 'ff8000',
points: 160,
position: 2,
wins: 3,
podiums: 5,
poles: 2,
form: [18, 25, 18, 25, 18],
cumulative: [18, 43, 61, 86, 104, 160],
round_positions: [2, 1, 2, 2, 2, 2],
teammate_wins: 6,
teammate_losses: 4,
},
],
teams: [
{ team_name: 'Red Bull', team_colour: '3671c6', points: 260, position: 1, wins: 6 },
{ team_name: 'McLaren', team_colour: 'ff8000', points: 220, position: 2, wins: 3 },
],
}),
}),
)
await page.setViewportSize({ width: 768, height: 1024 })
await page.goto('/championship')
await expect(page.getByTestId('championship-data-notice')).toContainText(/Stale/i)
await expect(page.getByTestId('champ-view-drivers')).toBeVisible()
await page.screenshot({
path: path.join(evidenceDir, `championship-stale-tablet-${testInfo.project.name}.png`),
fullPage: true,
})
})
test('Briefing Limited notice keeps articles usable', async ({ page }, testInfo) => {
await page.route('**/api/v1/news**', (route) =>
route.fulfill({
contentType: 'application/json',
headers: {
'X-BoxBox-Data-Source': 'local',
'X-BoxBox-Data-Freshness': 'local',
},
body: JSON.stringify([
{
id: 1,
title: 'Paddock briefing sample',
url: 'https://example.com/a',
source: 'Autosport',
published_at: '2025-04-01T12:00:00Z',
summary: 'Sample article remains usable.',
category: 'news',
},
]),
}),
)
await page.route('**/api/v1/seasons', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify([2025]) }),
)
await page.route('**/api/v1/meetings**', (route) =>
route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: 'API 503: meetings unavailable' }),
}),
)
await page.route('**/api/v1/championship/hub**', (route) =>
route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: 'API 503: hub unavailable' }),
}),
)
await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/briefing')
await expect(page.getByTestId('briefing-data-notice')).toContainText(/Limited/i)
await expect(page.getByText('Paddock briefing sample')).toBeVisible()
await expect(page.locator('body')).not.toContainText(/API 503/i)
await page.screenshot({
path: path.join(evidenceDir, `briefing-limited-mobile-${testInfo.project.name}.png`),
fullPage: true,
})
})
})