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

@@ -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>
)