fix(#76): correct frontend availability, retry, and live recovery semantics

Make response freshness React Query-safe, retry the failed Weekend/Preview/Briefing resources with busy gating, preserve distinct embedded Preview notices, and clear fatal Live errors once SSE supplies usable timing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-12 20:43:51 -04:00
parent eabf87a2b8
commit 01d291507d
17 changed files with 659 additions and 63 deletions

View File

@@ -4,6 +4,7 @@ import type { WeekendContext } from '../../types'
import { RacePreviewPage } from '../../pages/RacePreviewPage' import { RacePreviewPage } from '../../pages/RacePreviewPage'
import { ChampionshipRoundStrip, CountdownDisplay, Flag, SessionRail, railNodes } from './shared' import { ChampionshipRoundStrip, CountdownDisplay, Flag, SessionRail, railNodes } from './shared'
import { meetingIdentity } from '../../lib/weekendContext' import { meetingIdentity } from '../../lib/weekendContext'
import type { DataAvailability } from '../../lib/availability'
/** /**
* PreSessionView folds the race preview surface into the Weekend home so there is * PreSessionView folds the race preview surface into the Weekend home so there is
@@ -14,7 +15,16 @@ import { meetingIdentity } from '../../lib/weekendContext'
* Canonical meeting/session identity is passed into Preview so the nested surface * Canonical meeting/session identity is passed into Preview so the nested surface
* cannot independently re-resolve a different current weekend. * cannot independently re-resolve a different current weekend.
*/ */
export function PreSessionView({ context, now }: { context: WeekendContext; now: Date }) { 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 meeting = context.next_meeting ?? context.focus_meeting
const identity = meetingIdentity(meeting) const identity = meetingIdentity(meeting)
const next = context.next_session?.session const next = context.next_session?.session
@@ -52,6 +62,7 @@ export function PreSessionView({ context, now }: { context: WeekendContext; now:
embedded embedded
meeting={meeting} meeting={meeting}
season={context.season ?? meeting?.year} season={context.season ?? meeting?.year}
shellAvailability={shellAvailability}
/> />
</div> </div>

View File

@@ -25,9 +25,9 @@ export interface UseWeekendContextResult {
/** True when championship or news supplements failed while context succeeded. */ /** True when championship or news supplements failed while context succeeded. */
supplementsLimited: boolean supplementsLimited: boolean
now: Date now: Date
/** Refetch the canonical weekend-context read. */ /** Refetch canonical context and/or failed supplements as appropriate. */
refetch: () => void refetch: () => void
/** True while a canonical refetch is in flight. */ /** True while a relevant refetch is in flight. */
isFetching: boolean isFetching: boolean
} }
@@ -88,15 +88,42 @@ export function useWeekendContext(): UseWeekendContextResult {
[canonical], [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 = const supplementsLimited =
canonicalReady && canonicalReady &&
((championshipQuery.isError && !championshipQuery.isFetching) || (supplementDegraded(championshipQuery) || supplementDegraded(newsQuery))
(newsQuery.isError && !newsQuery.isFetching))
let loadState: WeekendLoadState = 'loading' let loadState: WeekendLoadState = 'loading'
if (contextQuery.isError) loadState = 'error' if (contextQuery.isError) loadState = 'error'
else if (canonicalReady) loadState = 'ready' 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 { return {
context: canonical, context: canonical,
loadState, loadState,
@@ -106,9 +133,8 @@ export function useWeekendContext(): UseWeekendContextResult {
availabilityNotice, availabilityNotice,
supplementsLimited, supplementsLimited,
now: nowDate, now: nowDate,
refetch: () => { refetch,
if (!contextQuery.isFetching) void contextQuery.refetch() isFetching:
}, contextQuery.isFetching || championshipQuery.isFetching || newsQuery.isFetching,
isFetching: contextQuery.isFetching,
} }
} }

View File

@@ -74,3 +74,16 @@ export function weekendContextNotice(
} }
return null return null
} }
/**
* 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
}

View File

@@ -1,5 +1,7 @@
/** Bounded fetch helpers for primary-route resilience. */ /** Bounded fetch helpers for primary-route resilience. */
import { replaceEqualDeep } from '@tanstack/react-query'
export const DEFAULT_FETCH_TIMEOUT_MS = 15_000 export const DEFAULT_FETCH_TIMEOUT_MS = 15_000
/** CORS-readable success provenance headers from the Go API. */ /** CORS-readable success provenance headers from the Go API. */
@@ -55,6 +57,35 @@ export function rememberResponseAvailability(
responseAvailability.set(data, meta) 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<T>(
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 type ApiErrorKind = 'http' | 'timeout' | 'abort' | 'network'
export class ApiError extends Error { export class ApiError extends Error {

View File

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

View File

@@ -1,19 +1,12 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import { RouterProvider } from '@tanstack/react-router' 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 { router } from './router'
import './styles/app.css' import './styles/app.css'
const queryClient = new QueryClient({ const queryClient = createAppQueryClient()
defaultOptions: {
queries: {
staleTime: 30_000,
// Explicit Retry on RouteState — avoid automatic retry storms.
retry: false,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>

View File

@@ -517,12 +517,35 @@ export function BriefingPage() {
const hasDigest = meetings.length > 0 const hasDigest = meetings.length > 0
const showEmpty = tagFiltered.length === 0 && grouped.recent.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 = const supplementsLimited =
!isLoading && !isLoading &&
!isError && !isError &&
((meetingsQuery.isError && !meetingsQuery.isFetching) || (supplementDegraded(meetingsQuery) ||
(hubQuery.isError && !hubQuery.isFetching) || supplementDegraded(hubQuery) ||
(seasonsQuery.isError && !seasonsQuery.isFetching)) 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()
}
}
const newsAvailability = noticeFromResponse(allNews, { includeLocal: true }) const newsAvailability = noticeFromResponse(allNews, { includeLocal: true })
const hubAvailability = noticeFromResponse(hub, { includeLocal: false }) const hubAvailability = noticeFromResponse(hub, { includeLocal: false })
@@ -560,11 +583,8 @@ export function BriefingPage() {
<DataNotice <DataNotice
availability="limited" availability="limited"
message="Weekend grouping or driver tags are limited. Articles are still available." message="Weekend grouping or driver tags are limited. Articles are still available."
onRetry={() => { onRetry={retrySupplements}
if (meetingsQuery.isError && !meetingsQuery.isFetching) void meetingsQuery.refetch() retrying={supplementsRetrying}
if (hubQuery.isError && !hubQuery.isFetching) void hubQuery.refetch()
if (seasonsQuery.isError && !seasonsQuery.isFetching) void seasonsQuery.refetch()
}}
testId="briefing-data-notice" testId="briefing-data-notice"
/> />
)} )}

View File

@@ -304,9 +304,14 @@ export function LiveTimingPage() {
// feed-health truth rather than "Connection lost" + "Feed healthy". // feed-health truth rather than "Connection lost" + "Feed healthy".
const feedHealth = effectiveFeedHealth(streamStatus, phase) const feedHealth = effectiveFeedHealth(streamStatus, phase)
// A usable SSE/stream snapshot must not coexist with the fatal initial-state
// error — gate once live or retained archive data arrives.
const hasUsableStreamData = Boolean(activeSnapshot) || Boolean(archiveSnapshot)
const showInitialError = isError && !hasUsableStreamData
return ( return (
<div className="page live-page" data-testid="live-page" data-phase={phase}> <div className="page live-page" data-testid="live-page" data-phase={phase}>
{isError && ( {showInitialError && (
<RouteState <RouteState
kind="error" kind="error"
title="Live timing unavailable" title="Live timing unavailable"
@@ -334,7 +339,7 @@ export function LiveTimingPage() {
</div> </div>
)} )}
{phase === 'connecting' && (isLoading || !liveStateFetched) && ( {phase === 'connecting' && (isLoading || !liveStateFetched) && !hasUsableStreamData && (
<RouteState <RouteState
kind="loading" kind="loading"
title="connecting to live timing…" title="connecting to live timing…"

View File

@@ -10,7 +10,12 @@ import {
fetchTrackOutline, fetchTrackOutline,
} from '../api' } from '../api'
import { DataNotice, RouteState } from '../components/RouteState' import { DataNotice, RouteState } from '../components/RouteState'
import { noticeFromResponse, noticeMessage } from '../lib/availability' import {
noticeFromResponse,
noticeMessage,
shouldShowEmbeddedNotice,
type DataAvailability,
} from '../lib/availability'
import { userFacingError } from '../lib/fetch' import { userFacingError } from '../lib/fetch'
import { countryAccent, countryFlag, formatGpDateRange } from '../lib/gpIdentity' import { countryAccent, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
import { import {
@@ -272,12 +277,18 @@ export interface RacePreviewPageProps {
season?: number season?: number
/** Embedded under Weekend — identity failures stay non-blocking. */ /** Embedded under Weekend — identity failures stay non-blocking. */
embedded?: boolean embedded?: boolean
/**
* Availability already disclosed by the Weekend shell. Embedded Preview
* suppresses only an equivalent notice kind; distinct truth stays visible.
*/
shellAvailability?: DataAvailability | null
} }
export function RacePreviewPage({ export function RacePreviewPage({
meeting: canonicalMeeting, meeting: canonicalMeeting,
season: canonicalSeason, season: canonicalSeason,
embedded = false, embedded = false,
shellAvailability = null,
}: RacePreviewPageProps = {}) { }: RacePreviewPageProps = {}) {
const [now, setNow] = useState(() => Date.now()) const [now, setNow] = useState(() => Date.now())
@@ -398,6 +409,17 @@ export function RacePreviewPage({
noticeFromResponse(sessionsQuery.data, { includeLocal: false }) ?? noticeFromResponse(sessionsQuery.data, { includeLocal: false }) ??
noticeFromResponse(priorResultsQuery.data, { includeLocal: false }) noticeFromResponse(priorResultsQuery.data, { includeLocal: false })
const showFreshnessNotice =
Boolean(dataNotice) &&
(!embedded || shouldShowEmbeddedNotice(dataNotice, shellAvailability))
const sessionsFailed = sessionsQuery.isError
const sessionsRetrying = sessionsQuery.isFetching
const retrySessions = () => {
if (!sessionsQuery.isFetching) void sessionsQuery.refetch()
}
if (identityLoading) { if (identityLoading) {
return ( return (
<div className={embedded ? 'preview-embedded' : 'page'} data-testid="preview-loading"> <div className={embedded ? 'preview-embedded' : 'page'} data-testid="preview-loading">
@@ -455,8 +477,7 @@ export function RacePreviewPage({
data-meeting-key={previewMeeting.meeting_key} data-meeting-key={previewMeeting.meeting_key}
data-embedded={embedded ? 'true' : undefined} data-embedded={embedded ? 'true' : undefined}
> >
{/* Weekend shell owns availability disclosure when Preview is embedded. */} {showFreshnessNotice && dataNotice && (
{!embedded && dataNotice && (
<DataNotice <DataNotice
availability={dataNotice} availability={dataNotice}
message={noticeMessage(dataNotice)} message={noticeMessage(dataNotice)}
@@ -464,6 +485,18 @@ export function RacePreviewPage({
/> />
)} )}
{sessionsFailed && (
<RouteState
kind="error"
title="Session schedule unavailable"
error={sessionsQuery.error}
onRetry={retrySessions}
retrying={sessionsRetrying}
testId="preview-sessions-error"
retryTestId="preview-sessions-retry"
/>
)}
{/* Embedded Weekend already shows the canonical countdown header — skip the duplicate. */} {/* Embedded Weekend already shows the canonical countdown header — skip the duplicate. */}
{!embedded && ( {!embedded && (
<PreviewHeader <PreviewHeader
@@ -475,6 +508,20 @@ export function RacePreviewPage({
/> />
)} )}
{/* Embedded: still surface recovered schedule once sessions load. */}
{embedded && sessions.length > 0 && (
<div className="preview-schedule" data-testid="preview-schedule">
{sessions.map((session) => (
<div key={session.session_key} className="preview-schedule-item">
<span className="preview-schedule-name">{session.session_name}</span>
<span className="preview-schedule-time">
{formatSessionScheduleTime(session.date_start)}
</span>
</div>
))}
</div>
)}
<div className="preview-grid"> <div className="preview-grid">
<TrackOutlineCard <TrackOutlineCard
outline={trackOutlineQuery.data} outline={trackOutlineQuery.data}

View File

@@ -23,16 +23,18 @@ interface RenderArgs {
briefing: WeekendBriefingItem[] briefing: WeekendBriefingItem[]
/** When true (the /preview alias), foreground the preparation surface. */ /** When true (the /preview alias), foreground the preparation surface. */
preview: boolean preview: boolean
/** Availability already disclosed by the Weekend shell (for Preview dedupe). */
shellAvailability: DataAvailability | null
} }
function renderState(view: WeekendViewState, args: RenderArgs) { function renderState(view: WeekendViewState, args: RenderArgs) {
const { context, now, championship, briefing, preview } = args const { context, now, championship, briefing, preview, shellAvailability } = args
// The /preview alias always resolves to the preparation surface as long as // The /preview alias always resolves to the preparation surface as long as
// there is a next event to prepare for, regardless of the temporal state. This // there is a next event to prepare for, regardless of the temporal state. This
// keeps saved /preview links and the "Prepare for …" CTA meaningful instead of // keeps saved /preview links and the "Prepare for …" CTA meaningful instead of
// redirecting straight back to the same between-races screen. // redirecting straight back to the same between-races screen.
if (preview && context.next_meeting) { if (preview && context.next_meeting) {
return <PreSessionView context={context} now={now} /> return <PreSessionView context={context} now={now} shellAvailability={shellAvailability} />
} }
switch (view) { switch (view) {
@@ -56,7 +58,7 @@ function renderState(view: WeekendViewState, args: RenderArgs) {
case 'session_live': case 'session_live':
return <LiveHandoffView context={context} /> return <LiveHandoffView context={context} />
case 'pre_session': case 'pre_session':
return <PreSessionView context={context} now={now} /> return <PreSessionView context={context} now={now} shellAvailability={shellAvailability} />
default: default:
return <WeekendLimited season={context.season} /> return <WeekendLimited season={context.season} />
} }
@@ -144,6 +146,7 @@ export function WeekendPage({
} }
const view = resolveViewState(context) const view = resolveViewState(context)
const shellAvailability = availabilityNotice ?? (supplementsLimited ? 'limited' : null)
return ( return (
<main <main
@@ -164,7 +167,14 @@ export function WeekendPage({
onRetry={refetch} onRetry={refetch}
retrying={isFetching} retrying={isFetching}
/> />
{renderState(view, { context, now, championship, briefing, preview })} {renderState(view, {
context,
now,
championship,
briefing,
preview,
shellAvailability,
})}
</main> </main>
) )
} }

View File

@@ -219,5 +219,36 @@ describe('BriefingPage digest layout', () => {
}) })
expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument() expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
expect(screen.queryByTestId('briefing-error')).not.toBeInTheDocument() expect(screen.queryByTestId('briefing-error')).not.toBeInTheDocument()
let meetingsCalls = 1
let hubCalls = 1
let resolveMeetings!: (value: Meeting[]) => void
let resolveHub!: (value: ChampionshipHub) => void
mockFetchSeasonMeetings.mockImplementation(
() =>
new Promise<Meeting[]>((resolve) => {
meetingsCalls += 1
resolveMeetings = resolve
}),
)
mockFetchHub.mockImplementation(
() =>
new Promise<ChampionshipHub>((resolve) => {
hubCalls += 1
resolveHub = resolve
}),
)
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(screen.getByRole('button', { name: 'Retrying…' })).toBeDisabled())
expect(screen.getByRole('button', { name: 'Retrying…' })).toHaveAttribute('aria-busy', 'true')
fireEvent.click(screen.getByRole('button', { name: 'Retrying…' }))
expect(meetingsCalls).toBe(2)
expect(hubCalls).toBe(2)
resolveMeetings([bahrain, monaco])
resolveHub(hub)
await waitFor(() => expect(screen.queryByTestId('briefing-data-notice')).not.toBeInTheDocument())
expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
}) })
}) })

View File

@@ -356,7 +356,7 @@ describe('LiveTimingPage', () => {
expect(screen.queryByText('LIVE SESSION')).not.toBeInTheDocument() expect(screen.queryByText('LIVE SESSION')).not.toBeInTheDocument()
}) })
it('shows the inactive weekend context when nothing is live or retained', async () => { it('shows inactive handoff when nothing is live or retained', async () => {
renderPage( renderPage(
{ is_live: false, data: null }, { is_live: false, data: null },
{ {
@@ -392,4 +392,96 @@ describe('LiveTimingPage', () => {
expect(screen.getByTestId('live-handoff-next')).toHaveTextContent('Practice 1'), expect(screen.getByTestId('live-handoff-next')).toHaveTextContent('Practice 1'),
) )
}) })
it('clears fatal initial error once SSE supplies a live snapshot', async () => {
type Listener = (event: { data: string }) => void
class RecoveringEventSource {
onopen: (() => void) | null = null
onerror: (() => void) | null = null
private listeners = new Map<string, Listener[]>()
static latest: RecoveringEventSource | null = null
constructor() {
RecoveringEventSource.latest = this
setTimeout(() => this.onopen?.(), 0)
}
addEventListener(type: string, listener: Listener) {
const list = this.listeners.get(type) ?? []
list.push(listener)
this.listeners.set(type, list)
}
emit(type: string, data: unknown) {
for (const listener of this.listeners.get(type) ?? []) {
listener({ data: JSON.stringify(data) })
}
}
close() {}
}
Object.defineProperty(window, 'EventSource', {
value: RecoveringEventSource,
writable: true,
configurable: true,
})
mockFetchLiveState.mockRejectedValue(new Error('API 503: live state unavailable'))
mockFetchLiveTrackOutline.mockResolvedValue({
circuit_key: 1,
points: [],
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
})
mockFetchWeekendContext.mockResolvedValue({
temporal_state: 'no_season',
championship_round: 0,
total_championship_rounds: 0,
})
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<LiveTimingPage />
</QueryClientProvider>,
)
expect(await screen.findByTestId('live-initial-error')).toBeInTheDocument()
expect(screen.getByText(/Live timing unavailable/i)).toBeInTheDocument()
await act(async () => {
RecoveringEventSource.latest?.emit('snapshot', {
is_live: true,
data: raceSnapshot,
})
})
await waitFor(() => expect(screen.getByText('Timing Tower')).toBeInTheDocument())
expect(screen.queryByTestId('live-initial-error')).not.toBeInTheDocument()
expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'live')
expect(screen.queryByText(/Live timing unavailable/i)).not.toBeInTheDocument()
})
it('keeps bounded initial error + Retry when no stream data arrives', async () => {
mockFetchLiveState.mockRejectedValue(new Error('API 503: live state unavailable'))
mockFetchLiveTrackOutline.mockResolvedValue({
circuit_key: 1,
points: [],
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
})
mockFetchWeekendContext.mockResolvedValue({
temporal_state: 'no_season',
championship_round: 0,
total_championship_rounds: 0,
})
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<LiveTimingPage />
</QueryClientProvider>,
)
expect(await screen.findByTestId('live-initial-error')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled()
expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument()
})
}) })

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react' import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router' import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
import { rememberResponseAvailability } from '../lib/fetch'
import { RacePreviewPage } from '../pages/RacePreviewPage' import { RacePreviewPage } from '../pages/RacePreviewPage'
import { ApiError } from '../lib/fetch' import { ApiError } from '../lib/fetch'
import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session, TrackOutline } from '../types' import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session, TrackOutline } from '../types'
@@ -175,7 +176,12 @@ const outline: TrackOutline = {
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 }, bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
} }
function renderPage(props?: { meeting?: Meeting; season?: number; embedded?: boolean }) { function renderPage(props?: {
meeting?: Meeting
season?: number
embedded?: boolean
shellAvailability?: 'local' | 'partial' | 'stale' | 'archive' | 'limited' | 'missing' | null
}) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const Page = () => <RacePreviewPage {...props} /> const Page = () => <RacePreviewPage {...props} />
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({
@@ -288,7 +294,7 @@ describe('RacePreviewPage', () => {
return [] return []
}) })
renderPage({ meeting: upcomingMeeting, season: 2099, embedded: true }) renderPage({ meeting: upcomingMeeting, season: 2099, embedded: true, shellAvailability: 'partial' })
await waitFor(() => expect(screen.getByTestId('preview-page')).toBeInTheDocument()) 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-meeting-key', '100')
@@ -298,10 +304,76 @@ describe('RacePreviewPage', () => {
expect(mockFetchMeetings).not.toHaveBeenCalledWith(2099, expect.anything(), expect.anything()) expect(mockFetchMeetings).not.toHaveBeenCalledWith(2099, expect.anything(), expect.anything())
expect(mockFetchSessions).toHaveBeenCalledWith(100, 'auto', expect.anything()) expect(mockFetchSessions).toHaveBeenCalledWith(100, 'auto', expect.anything())
expect(screen.queryByTestId('preview-header')).not.toBeInTheDocument() expect(screen.queryByTestId('preview-header')).not.toBeInTheDocument()
// Weekend shell owns availability disclosure — no stacked Preview notice. })
it('dedupes an equivalent shell notice but shows distinct Preview freshness', 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 []
})
const staleHub = { ...hub }
rememberResponseAvailability(staleHub, { source: 'openf1', freshness: 'stale' })
mockFetchChampionshipHub.mockResolvedValue(staleHub)
const distinct = renderPage({
meeting: upcomingMeeting,
season: 2099,
embedded: true,
shellAvailability: 'partial',
})
await waitFor(() => expect(screen.getByTestId('preview-data-notice')).toHaveTextContent(/Stale/i))
distinct.unmount()
const partialHub = { ...hub }
rememberResponseAvailability(partialHub, { source: 'openf1', freshness: 'partial' })
mockFetchChampionshipHub.mockResolvedValue(partialHub)
renderPage({
meeting: upcomingMeeting,
season: 2099,
embedded: true,
shellAvailability: 'partial',
})
await waitFor(() => expect(screen.getByTestId('preview-page')).toBeInTheDocument())
expect(screen.queryByTestId('preview-data-notice')).not.toBeInTheDocument() expect(screen.queryByTestId('preview-data-notice')).not.toBeInTheDocument()
}) })
it('shows sanitized sessions failure with Retry that refetches once', async () => {
let sessionCalls = 0
mockFetchSessions.mockImplementation(async (meetingKey: number) => {
if (meetingKey !== 100) return [priorRaceSession]
sessionCalls += 1
if (sessionCalls === 1) {
throw new ApiError('http', 'API 500: sessions boom', { status: 500 })
}
return sessions
})
mockFetchMeetings.mockImplementation(async (year: number) => {
if (year === 2098) return [priorMeeting]
return []
})
renderPage({ meeting: upcomingMeeting, season: 2099, embedded: true })
await waitFor(() => expect(screen.getByTestId('preview-sessions-error')).toBeInTheDocument())
expect(screen.getByTestId('preview-sessions-error')).not.toHaveTextContent(/API 500|sessions boom/i)
expect(screen.getByTestId('preview-page')).toBeInTheDocument()
const retry = screen.getByTestId('preview-sessions-retry')
fireEvent.click(retry)
fireEvent.click(retry)
await waitFor(() => expect(screen.getByTestId('preview-schedule')).toHaveTextContent('FP1'))
expect(sessionCalls).toBe(2)
expect(screen.queryByTestId('preview-sessions-error')).not.toBeInTheDocument()
})
it('sanitizes raw HTTP errors and offers a guarded keyboard Retry', async () => { it('sanitizes raw HTTP errors and offers a guarded keyboard Retry', async () => {
mockFetchSeasons.mockRejectedValue( mockFetchSeasons.mockRejectedValue(
new ApiError('http', 'API 500: Internal Server Error', { status: 500 }), new ApiError('http', 'API 500: Internal Server Error', { status: 500 }),

View File

@@ -50,6 +50,7 @@ import {
fetchStartingGrid, fetchStartingGrid,
fetchTrackOutline, fetchTrackOutline,
} from '../api' } from '../api'
import { rememberResponseAvailability } from '../lib/fetch'
const mockContext = vi.mocked(fetchWeekendContext) const mockContext = vi.mocked(fetchWeekendContext)
const mockHub = vi.mocked(fetchChampionshipHub) const mockHub = vi.mocked(fetchChampionshipHub)
@@ -432,19 +433,99 @@ describe('WeekendPage canonical contract rendering', () => {
}), }),
}), }),
) )
// Force identity path by omitting meeting prop simulation: sessions for supplements fail, let sessionCalls = 0
// but shell stays. Actually with canonical meeting, identity never errors — force mockSessions.mockImplementation(async (meetingKey: number) => {
// championship/track failures instead, and separately test standalone raw error below. if (meetingKey !== 2) return []
mockSessions.mockRejectedValue(new Error('API 500: sessions boom')) sessionCalls += 1
mockHub.mockRejectedValue(new Error('API 429: rate limited')) if (sessionCalls === 1) throw new Error('API 500: sessions boom')
return [
session({
session_key: 21,
session_name: 'Practice 1',
meeting_key: 2,
date_start: '2026-07-24T09:00:00Z',
}),
]
})
renderAt('/') renderAt('/')
await waitFor(() => expect(screen.getByTestId('wk-pre-head')).toBeInTheDocument()) await waitFor(() => expect(screen.getByTestId('wk-pre-head')).toBeInTheDocument())
expect(screen.getByTestId('wk-pre-head')).toHaveTextContent('Hungarian Grand Prix') expect(screen.getByTestId('wk-pre-head')).toHaveTextContent('Hungarian Grand Prix')
expect(screen.getByTestId('weekend-pre-session')).toBeInTheDocument() await waitFor(() => expect(screen.getByTestId('preview-sessions-error')).toBeInTheDocument())
// Nested preview still mounts with canonical meeting; section errors are sanitized. expect(screen.queryByText(/API 500|sessions boom/i)).not.toBeInTheDocument()
await waitFor(() => expect(screen.getByTestId('preview-page')).toBeInTheDocument())
expect(screen.queryByText(/API 500|API 429|sessions boom/i)).not.toBeInTheDocument() fireEvent.click(screen.getByTestId('preview-sessions-retry'))
await waitFor(() => expect(screen.getByTestId('preview-schedule')).toHaveTextContent('Practice 1'))
expect(sessionCalls).toBe(2)
expect(screen.queryByTestId('preview-sessions-error')).not.toBeInTheDocument()
})
it('Limited Retry refetches failed championship/news supplements once', async () => {
mockContext.mockResolvedValue(context({ temporal_state: 'between_weekends' }))
let hubCalls = 0
let newsCalls = 0
mockHub.mockImplementation(async () => {
hubCalls += 1
if (hubCalls === 1) throw new Error('API 503: hub')
return hub
})
mockNews.mockImplementation(async () => {
newsCalls += 1
if (newsCalls === 1) throw new Error('API 503: news')
return []
})
renderAt('/')
await waitFor(() => expect(screen.getByTestId('weekend-data-notice')).toHaveTextContent(/Limited/i))
const retry = screen.getByTestId('weekend-data-notice').querySelector('button')!
expect(retry).toBeEnabled()
fireEvent.click(retry)
fireEvent.click(retry)
await waitFor(() => expect(screen.queryByTestId('weekend-data-notice')).not.toBeInTheDocument())
expect(hubCalls).toBe(2)
expect(newsCalls).toBe(2)
// Canonical context is not the failed resource — only one initial fetch.
expect(mockContext).toHaveBeenCalledTimes(1)
})
it('pre_session keeps shell Partial while disclosing distinct Preview stale', 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',
}),
])
rememberResponseAvailability(hub, { source: 'openf1', freshness: 'stale' })
mockHub.mockResolvedValue(hub)
renderAt('/')
await waitFor(() => expect(screen.getByTestId('weekend-data-notice')).toHaveTextContent(/Partial/i))
await waitFor(() => expect(screen.getByTestId('preview-data-notice')).toHaveTextContent(/Stale/i))
}) })
it('restores Race Hub meeting/session focus from the Weekend URL search contract', async () => { it('restores Race Hub meeting/session focus from the Weekend URL search contract', async () => {

View File

@@ -1,6 +1,8 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { QueryClient, QueryObserver } from '@tanstack/react-query'
import { import {
apiFetch, apiFetch,
availabilityAwareStructuralSharing,
clearApiFetchInflight, clearApiFetchInflight,
DATA_FRESHNESS_HEADER, DATA_FRESHNESS_HEADER,
DATA_SOURCE_HEADER, DATA_SOURCE_HEADER,
@@ -10,6 +12,7 @@ import {
import { import {
noticeFromFreshness, noticeFromFreshness,
noticeFromResponse, noticeFromResponse,
shouldShowEmbeddedNotice,
weekendContextNotice, weekendContextNotice,
} from '../lib/availability' } from '../lib/availability'
import type { WeekendContext } from '../types' import type { WeekendContext } from '../types'
@@ -85,6 +88,13 @@ describe('response availability metadata', () => {
expect(noticeFromResponse(hub)).toBe('stale') expect(noticeFromResponse(hub)).toBe('stale')
}) })
it('dedupes only equivalent embedded Preview notices', () => {
expect(shouldShowEmbeddedNotice('stale', 'partial')).toBe(true)
expect(shouldShowEmbeddedNotice('partial', 'partial')).toBe(false)
expect(shouldShowEmbeddedNotice('stale', null)).toBe(true)
expect(shouldShowEmbeddedNotice(null, 'partial')).toBe(false)
})
it('derives Weekend Context notices from typed session freshness, skipping routine local', () => { it('derives Weekend Context notices from typed session freshness, skipping routine local', () => {
const context: WeekendContext = { const context: WeekendContext = {
season: 2026, season: 2026,
@@ -128,4 +138,84 @@ describe('response availability metadata', () => {
} }
expect(weekendContextNotice(localOnly)).toBeNull() expect(weekendContextNotice(localOnly)).toBeNull()
}) })
it('updates metadata when structural sharing reuses an equal JSON object', () => {
const body = { season: 2026, drivers: [{ points: 1 }] }
const first = structuredClone(body)
const second = structuredClone(body)
rememberResponseAvailability(first, { source: 'openf1', freshness: 'stale' })
rememberResponseAvailability(second, { source: 'openf1', freshness: 'fresh' })
const shared = availabilityAwareStructuralSharing(first, second)
expect(shared).toBe(first)
expect(getResponseAvailability(shared)).toEqual({ source: 'openf1', freshness: 'fresh' })
expect(noticeFromResponse(shared)).toBeNull()
const third = structuredClone(body)
rememberResponseAvailability(third, { source: 'openf1', freshness: 'partial' })
const sharedAgain = availabilityAwareStructuralSharing(shared, third)
expect(sharedAgain).toBe(first)
expect(noticeFromResponse(sharedAgain)).toBe('partial')
})
it('QueryObserver stale→fresh and fresh→partial refetches update metadata and notices', async () => {
const body = { season: 2026, drivers: [{ points: 42 }] }
const sequence: Array<'stale' | 'fresh' | 'partial'> = ['stale', 'fresh', 'partial']
let call = 0
vi.stubGlobal(
'fetch',
vi.fn(async () => {
const freshness = sequence[Math.min(call, sequence.length - 1)]
call += 1
return new Response(JSON.stringify(body), {
status: 200,
headers: {
'Content-Type': 'application/json',
[DATA_SOURCE_HEADER]: 'openf1',
[DATA_FRESHNESS_HEADER]: freshness,
},
})
}),
)
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
structuralSharing: availabilityAwareStructuralSharing,
},
},
})
const observer = new QueryObserver(client, {
queryKey: ['availability-observer'],
queryFn: () => apiFetch<typeof body>('/api/v1/championship/hub'),
staleTime: 0,
})
const notices: Array<string | null> = []
const unsub = observer.subscribe((result) => {
if (result.data) notices.push(noticeFromResponse(result.data))
})
await observer.refetch()
expect(noticeFromResponse(observer.getCurrentResult().data)).toBe('stale')
expect(notices[notices.length - 1]).toBe('stale')
await observer.refetch()
expect(observer.getCurrentResult().data).toEqual(body)
expect(getResponseAvailability(observer.getCurrentResult().data!)).toEqual({
source: 'openf1',
freshness: 'fresh',
})
expect(noticeFromResponse(observer.getCurrentResult().data)).toBeNull()
expect(notices[notices.length - 1]).toBeNull()
await observer.refetch()
expect(noticeFromResponse(observer.getCurrentResult().data)).toBe('partial')
expect(notices[notices.length - 1]).toBe('partial')
expect(call).toBe(3)
unsub()
client.clear()
})
}) })

View File

@@ -184,33 +184,66 @@ async function stubPreSession(page: Page, opts?: { failSessions?: boolean; recov
} }
test.describe('Issue #76 availability / Preview resilience evidence', () => { test.describe('Issue #76 availability / Preview resilience evidence', () => {
test('Weekend pre-session keeps shell usable on Preview supplement failure and recovers', async ({ test('Weekend pre-session recovers Preview sessions via Retry and keeps distinct notices', async ({
page, page,
}, testInfo) => { }, testInfo) => {
await stubPreSession(page, { failSessions: true, recover: true }) let sessionCalls = 0
let allowSessions = false
await stubPreSession(page)
await page.unroute('**/api/v1/sessions**')
await page.route('**/api/v1/sessions**', async (route) => {
const url = new URL(route.request().url())
if (url.searchParams.get('meeting_key') === '2') {
sessionCalls += 1
}
if (!allowSessions) {
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.setViewportSize({ width: 390, height: 844 }) await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/') await page.goto('/')
await expect(page.getByTestId('weekend-pre-session')).toBeVisible() await expect(page.getByTestId('weekend-pre-session')).toBeVisible()
await expect(page.getByTestId('wk-pre-head')).toContainText('Hungarian Grand Prix') 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('weekend-data-notice')).toContainText(/Partial/i)
// Weekend shell owns disclosure — embedded Preview must not stack a second notice. // Shell Partial + Preview championship Stale are distinct — both may show.
await expect(page.getByTestId('preview-data-notice')).toHaveCount(0) await expect(page.getByTestId('preview-data-notice')).toContainText(/Stale/i)
await expect(page.getByTestId('preview-page')).toBeVisible() await expect(page.getByTestId('preview-page')).toBeVisible()
await expect(page.getByTestId('preview-page')).toHaveAttribute('data-meeting-key', '2') await expect(page.getByTestId('preview-page')).toHaveAttribute('data-meeting-key', '2')
// No raw HTTP jargon in the primary Weekend child. await expect(page.getByTestId('preview-sessions-error')).toBeVisible()
await expect(page.locator('body')).not.toContainText(/API 500|forced sessions failure/i) await expect(page.locator('body')).not.toContainText(/API 500|forced sessions failure/i)
// Title fight remains usable from the shared championship supplement.
await expect(page.getByTestId('preview-title-fight-card')).toContainText('VER') await expect(page.getByTestId('preview-title-fight-card')).toContainText('VER')
// Recovery: guarded Retry on the shell notice re-runs context; sessions auto-recover const failedCalls = sessionCalls
// on React Query retry (recover stub). Shell stays usable either way. expect(failedCalls).toBeGreaterThanOrEqual(1)
const retry = page.getByTestId('weekend-data-notice').getByRole('button', { name: 'Retry' })
allowSessions = true
const retry = page.getByTestId('preview-sessions-retry')
await expect(retry).toBeEnabled() await expect(retry).toBeEnabled()
await retry.click() await retry.click()
await expect(page.getByTestId('weekend-pre-session')).toBeVisible() await expect(page.getByTestId('preview-schedule')).toContainText('Practice 1')
await expect(page.getByTestId('preview-page')).toBeVisible() await expect(page.getByTestId('preview-sessions-error')).toHaveCount(0)
await expect(page.getByTestId('preview-title-fight-card')).toContainText('VER') expect(sessionCalls).toBe(failedCalls + 1)
await page.screenshot({ await page.screenshot({
path: path.join(evidenceDir, `weekend-presession-failure-mobile-${testInfo.project.name}.png`), path: path.join(evidenceDir, `weekend-presession-failure-mobile-${testInfo.project.name}.png`),
@@ -222,9 +255,8 @@ test.describe('Issue #76 availability / Preview resilience evidence', () => {
path: path.join(evidenceDir, `weekend-presession-partial-desktop-${testInfo.project.name}.png`), path: path.join(evidenceDir, `weekend-presession-partial-desktop-${testInfo.project.name}.png`),
fullPage: true, fullPage: true,
}) })
// Desktop evidence must also keep a single Partial notice vocabulary.
await expect(page.getByTestId('weekend-data-notice')).toContainText(/Partial/i) await expect(page.getByTestId('weekend-data-notice')).toContainText(/Partial/i)
await expect(page.getByTestId('preview-data-notice')).toHaveCount(0) await expect(page.getByTestId('preview-data-notice')).toContainText(/Stale/i)
}) })
test('Championship discloses stale metadata above usable standings', async ({ page }, testInfo) => { test('Championship discloses stale metadata above usable standings', async ({ page }, testInfo) => {
@@ -343,6 +375,8 @@ test.describe('Issue #76 availability / Preview resilience evidence', () => {
await expect(page.getByTestId('briefing-data-notice')).toContainText(/Limited/i) await expect(page.getByTestId('briefing-data-notice')).toContainText(/Limited/i)
await expect(page.getByText('Paddock briefing sample')).toBeVisible() await expect(page.getByText('Paddock briefing sample')).toBeVisible()
await expect(page.locator('body')).not.toContainText(/API 503/i) await expect(page.locator('body')).not.toContainText(/API 503/i)
const retry = page.getByTestId('briefing-data-notice').getByRole('button', { name: 'Retry' })
await expect(retry).toBeEnabled()
await page.screenshot({ await page.screenshot({
path: path.join(evidenceDir, `briefing-limited-mobile-${testInfo.project.name}.png`), path: path.join(evidenceDir, `briefing-limited-mobile-${testInfo.project.name}.png`),
fullPage: true, fullPage: true,

View File

@@ -452,4 +452,28 @@ test.describe('Live Timing (no session)', () => {
await expect(page.getByTestId('live-feed-health')).toContainText(/reconnecting/i) await expect(page.getByTestId('live-feed-health')).toContainText(/reconnecting/i)
await expect(page.getByTestId('live-feed-health')).not.toContainText(/feed healthy/i) await expect(page.getByTestId('live-feed-health')).not.toContainText(/feed healthy/i)
}) })
test('clears fatal initial error once SSE supplies a live snapshot', async ({ page }) => {
await page.route('**/api/v1/live/state', (route) =>
route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: 'API 503: live state unavailable' }),
}),
)
await page.route('**/api/v1/live/stream', (route) =>
route.fulfill({
contentType: 'text/event-stream',
headers: { 'Cache-Control': 'no-cache' },
body: `event: snapshot\ndata: ${JSON.stringify(raceSnapshot)}\n\n`,
}),
)
await page.goto('/live')
await expect(page.getByText('Timing Tower')).toBeVisible({ timeout: 15_000 })
await expect(page.getByTestId('live-initial-error')).toHaveCount(0)
await expect(page.getByText(/Live timing unavailable/i)).toHaveCount(0)
await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', /^(live|disconnected)$/)
await expect(page.locator('.live-tower')).toContainText('VER')
})
}) })