Compare commits

..

32 Commits

Author SHA1 Message Date
Aman Tahiliani
3583a80d42 Merge pull request #85 from AmanTahiliani/fix/issue-76-qa-availability-truth
fix(#76): truthful availability metadata across primary routes
2026-07-12 21:18:20 -04:00
AmanTahiliani
a0ff8fa5a7 fix(#76): preserve pacing and empty-state truth 2026-07-12 21:01:42 -04:00
AmanTahiliani
c0d1cf2892 fix(#76): preserve worst availability truth and inactive SSE recovery
Aggregate conflicting freshness by severity so Local cannot mask Stale/Partial,
treat authoritative inactive SSE as valid state, honor Weekend focus headers,
and prove Limited Retry busy accessibility with deferred supplements.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 20:55:52 -04:00
AmanTahiliani
0bba213652 fix(#76): prevent false-fresh aggregate responses 2026-07-12 20:50:18 -04:00
AmanTahiliani
01d291507d 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>
2026-07-12 20:43:51 -04:00
AmanTahiliani
eabf87a2b8 fix(#76): keep embedded Preview from stacking freshness notices
Weekend shell already discloses context availability; a second Preview banner
from shared championship headers produced conflicting Partial/Stale vocabulary.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 20:32:44 -04:00
AmanTahiliani
5534ebf82c fix(#76): surface truthful availability across primary routes 2026-07-12 20:29:02 -04:00
AmanTahiliani
dafb9b6dc5 fix(#76): report request-scoped data freshness 2026-07-12 20:29:02 -04:00
Aman Tahiliani
1a9e08c0ab Merge pull request #84 from AmanTahiliani/fix/issue-83-prod-race-story-smoke
fix(#83): align prod Race Story smoke with final running order outcome
2026-07-12 20:22:33 -04:00
AmanTahiliani
14d9bd3dd4 fix(#83): align prod Race Story smoke with final running order outcome
Replace the removed "Final Classification" literal with an accessible
race-story-outcome region and assert VER is P1 in the built SPA smoke.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 20:13:07 -04:00
Aman Tahiliani
15d81c8eab Merge pull request #78 from AmanTahiliani/feat/issue-76-primary-route-resilience-and-bounded-loa
Primary-route resilience and bounded loading states (#76)
2026-07-12 20:03:51 -04:00
AmanTahiliani
2651569453 fix(#76): wrap ExplorePage fetchers for AbortSignal queryFns
Passing apiFetch-backed helpers directly as React Query queryFn breaks
TypeScript because the context object is not an AbortSignal.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 19:55:16 -04:00
AmanTahiliani
ad0eb26e05 fix(#76): preserve Race Hub retry testid after RouteState migrate
Keep rh-retry discoverable for existing Race Hub recovery tests and accept
AbortSignal on bare-route default analysis fetch assertions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 19:54:16 -04:00
AmanTahiliani
5ea8a4a9c4 fix(#76): make apiFetch dedupe abort-safe and migrate Command Center
Subscriber-aware in-flight dedupe so one caller's abort cannot poison
remounted consumers under Strict Mode; wire `/` to shared RouteState
retry and point the Weekend E2E at the real landing route.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 19:53:47 -04:00
AmanTahiliani
b3d1730324 feat(#76): bound primary-route fetches and local-first driver summary
Add shared apiFetch timeouts/abort/typed errors with in-flight dedupe, a
RouteState UI for loading/empty/timeout/error+retry, and wire it through
Weekend (Data Health), Championship, Driver Profile, Briefing, Live, and
Race Hub. Driver summary is local-first with bounded optional OpenF1
enrichment so a hung remote call cannot block the profile.

Spike note: parseSourceMode defaults to openf1; driver summary now treats
omitted ?source= as auto so local season data is preferred.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 19:53:39 -04:00
Aman Tahiliani
e8a1f15f8b Merge pull request #81 from AmanTahiliani/feat/issue-74-live-settling-analysis-handoff
Live → settling → analysis handoff (#74)
2026-07-12 19:50:53 -04:00
AmanTahiliani
e66a874755 fix(#74): settle on just-finished session and coherent feed health
Prefer previous_completed_session for Live settling link/poll/readiness so an
older ready default cannot short-circuit handoff; show reconnecting when phase
is disconnected even if browser SSE stays open; refresh Live visuals and add
active responsive snapshots against the Weekend shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 19:46:31 -04:00
AmanTahiliani
ad870c040c fix(#74): align Weekend deep-link E2E with live-inactive handoff
After rebasing onto the Weekend shell, /live no longer renders live-empty;
assert the inactive handoff surface instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 19:46:31 -04:00
AmanTahiliani
dbe3ac644d fix(#74): address PR #81 review blockers for live handoff
Distinguish FIA feed drops from true session end via terminal SessionStatus,
consume canonical /api/v1/weekend-context (post-#72 rebase), poll until
analysis-ready, and restore missing live-state styles plus transition/E2E/visual coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 19:46:31 -04:00
AmanTahiliani
1543a09ff1 feat(#74): Live → settling → analysis handoff
Implemented by claude via .agents/dev dispatch.
2026-07-12 19:46:16 -04:00
Aman Tahiliani
408de2da0c Merge pull request #82 from AmanTahiliani/feat/issue-75-race-hub-defaults-and-fan-facing-analysi
Race Hub defaults and fan-facing analysis hierarchy (#75)
2026-07-12 19:45:28 -04:00
AmanTahiliani
469028b07a fix(#75): preserve Race Hub meeting/session on Weekend return
Add an explicit Back to Weekend link that deep-links to /?meeting_key&session_key,
restore that focus on the adaptive Weekend home with a Continue analysis CTA, and
regenerate Race Hub visual baselines against the integrated #80 nav.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 19:40:04 -04:00
AmanTahiliani
5d5987cc7e fix(race-hub): consume canonical weekend context for #75 review
Address PR #82 review blockers: drop the competing /weekend
default_analysis_session resolver, land bare /race-hub via
/api/v1/weekend-context, derive Live/preparing/partial/unavailable from
authoritative context with a moving clock, hide Local Coverage behind
Diagnostics, isolate the future-session fixture from the shared seed,
and strengthen return-to-Weekend context coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 18:51:34 -04:00
AmanTahiliani
7c98489b91 feat(race-hub): trustworthy defaults and fan-facing analysis hierarchy (#75)
Make bare /race-hub resolve to a completed session and never open empty
post-session analysis for a future race.

- Backend: add `default_analysis_session` to the Weekend context. It never
  resolves to a future session (picks the richest completed session, ties
  toward the later one; 0 when everything is upcoming). Existing
  `default_session_key` and deep links are unchanged.
- Frontend default resolution prefers the most recently completed weekend
  (`pickAnalysisFocusMeeting`) and consumes `default_analysis_session`, falling
  back to the switcher when only upcoming sessions exist.
- New `sessionState` lib maps timing + coverage to user language
  (upcoming/live/preparing/partial/ready/cancelled); the session rail, active
  sub-bar, and WeekendSwitcher now label states instead of raw x/11 counts.
- Future sessions render a purpose-built PreSessionView (expected availability +
  countdown) instead of empty Winner/Podium/Pole/Strategy/Compare cards.
- Analysis navigation regrouped into Story / Analysis / Data & Context; every
  existing tab is preserved. Diagnostics (renamed from Data Status) is now a
  secondary action and the raw dataset strip is hidden behind an explicit
  toggle, so operational coverage no longer precedes fan content.
- Loading/error states offer Retry and a path back to Weekend.

Tests: Go query tests for future-exclusion; Vitest for default selection,
future pre-session, partial state, error/retry, grouped nav, and sessionState;
hermetic Playwright for bare/completed/future/return-to-Weekend; new
race-hub-future visual snapshots. Seed adds a far-future session inside the
Monaco meeting (kept in-meeting so Command Center focus is unaffected).

Note: `default_analysis_session` is an additive field on the existing
`/weekend` contract (no new endpoint), per the spec's "context contract" scope.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 18:51:13 -04:00
Aman Tahiliani
84a9a3579f Merge pull request #80 from AmanTahiliani/feat/issue-73-adaptive-weekend-shell-and-responsive-na
Adaptive Weekend shell and responsive navigation (#73)
2026-07-12 18:44:51 -04:00
AmanTahiliani
07e5857760 fix(#73): consume canonical Weekend Context contract and repair navigation
Address the independent review blockers on PR #80 after rebasing onto the
authoritative #72 Weekend Context API.

- Replace the invented frontend WeekendContext with the exact backend contract
  (temporal_state, previous/focus/next meetings, previous_completed/active/next/
  default_analysis sessions with availability). Every valid canonical payload now
  maps to a designed state via a total resolveViewState; a well-formed response
  can never fall through to the limited-data placeholder.
- Make the canonical read the single source of truth: useWeekendContext no longer
  fans out to season/meetings/per-weekend/OpenF1/live queries. Only supplementary
  championship + news reads run, and only once the canonical context resolves.
- Fix the Prepare/analysis flow: /preview is a stable alias that renders the
  preparation surface (PreSessionView) instead of redirecting back to the same
  between-races screen.
- One primary navigation system per breakpoint: the mobile top-bar links are
  hidden so the bottom bar is the sole primary nav, and Admin is moved out of
  every Primary landmark into an operator-utilities toolbar.
- Add Vitest coverage for the contract mapping, every temporal state, loading/
  error/limited surfaces, the no-fanout guarantee, the /preview CTA, and the nav
  hierarchy; add hermetic Playwright journeys (seeded + injected canonical
  payloads), 390/768/1440 overflow checks, and Weekend visual snapshots. Retire
  the stale Command Center specs/snapshots.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 18:38:08 -04:00
AmanTahiliani
06223d992f feat(#73): Adaptive Weekend shell and responsive navigation
Implemented by claude via .agents/dev dispatch.
2026-07-12 18:22:04 -04:00
Aman Tahiliani
ba55945b6f Merge pull request #79 from AmanTahiliani/feat/issue-72-canonical-weekend-context-api-and-sessio
Canonical Weekend Context API and session truth (#72)
2026-07-12 18:19:46 -04:00
AmanTahiliani
643af2355a fix: make weekend context transitions truthful 2026-07-12 18:15:14 -04:00
AmanTahiliani
45084558cd feat: add canonical weekend context API 2026-07-12 18:05:13 -04:00
AmanTahiliani
90c24ad6aa chore: make sprint harness models explicit 2026-07-12 17:25:19 -04:00
AmanTahiliani
e028e3e3fe docs: add v0.4.0 product direction and mockups 2026-07-12 17:24:42 -04:00
139 changed files with 12807 additions and 2296 deletions

View File

@@ -25,7 +25,8 @@ EOF
harness_claude() { # Claude Code — print mode, auto-accept edits harness_claude() { # Claude Code — print mode, auto-accept edits
local dir="$1" prompt="$2" local dir="$1" prompt="$2"
( cd "$dir" && claude -p "$(cat "$prompt")" --permission-mode acceptEdits ) local model="${BOXBOX_CLAUDE_MODEL:-opus}"
( cd "$dir" && claude -p "$(cat "$prompt")" --model "$model" --effort high --permission-mode acceptEdits )
} }
harness_codex() { # OpenAI Codex CLI — non-interactive exec, full auto harness_codex() { # OpenAI Codex CLI — non-interactive exec, full auto
@@ -40,7 +41,8 @@ harness_opencode() { # opencode — non-interactive run
harness_cursor() { # Cursor CLI agent — composer-2.5, headless full-auto harness_cursor() { # Cursor CLI agent — composer-2.5, headless full-auto
local dir="$1" prompt="$2" local dir="$1" prompt="$2"
( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --model composer-2.5 --force --trust ) local model="${BOXBOX_CURSOR_MODEL:-composer-2.5}"
( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --model "$model" --force --trust )
} }
harness_agy() { # Antigravity CLI — disabled until headless is verified harness_agy() { # Antigravity CLI — disabled until headless is verified

View File

@@ -0,0 +1,32 @@
# v0.4.0 — Current Weekend & Product Truth
Product research references for the v0.4.0 feature sprint.
## Direction
The sprint replaces route-first navigation with a state-aware Weekend experience:
- Weekend is the adaptive home for what happened, what is live, and what is next.
- Preview content folds into Weekend before a session.
- Live remains a stable deep link and becomes Weekend's active-session state.
- Race Hub remains explicit completed-session analysis rather than a primary landing destination.
- Championship and Briefing remain dedicated destinations.
- Explore owns secondary discovery; Admin moves to operator utility.
## Mockups
- `mockups/weekend-between-races.png` — desktop between-races/post-weekend state.
- `mockups/weekend-live.png` — desktop active-session state; the circuit is static sector context, not live GPS.
- `mockups/weekend-between-sessions-mobile.png` — 390×844 between-session state.
These are directional references, not pixel-perfect specifications. Implementations must preserve the established box-box visual language, accessibility, data constraints, and responsive behavior while satisfying their issue acceptance criteria.
## Constraints
- No OpenF1 REST dependency during active sessions.
- Public live GPS is not assumed to be available.
- Championship round numbers exclude tests and cancelled meetings.
- Connection health, live-session state, archive availability, and local-analysis readiness are separate concepts.
- Future sessions must not render empty post-session analysis.
The authoritative product decisions and research packet are recorded in GitHub issue #71 under epic #70.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -1,3 +1,4 @@
import { apiFetch } from './lib/fetch'
import type { import type {
ArticleContent, ArticleContent,
CarDataSample, CarDataSample,
@@ -15,152 +16,187 @@ import type {
Session, Session,
TrackOutline, TrackOutline,
Weekend, Weekend,
WeekendContext,
} from './types' } from './types'
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> { export async function fetchRaceHub(sessionKey: number, signal?: AbortSignal): Promise<RaceHub> {
const res = await fetch(`/api/v1/race-hub?session_key=${sessionKey}`) return apiFetch<RaceHub>(`/api/v1/race-hub?session_key=${sessionKey}`, {
if (!res.ok) { signal,
throw new Error(`API ${res.status}: ${res.statusText}`) dedupeKey: `race-hub:${sessionKey}`,
} })
return res.json()
} }
export async function fetchSeasons(): Promise<number[]> { export async function fetchSeasons(signal?: AbortSignal): Promise<number[]> {
const res = await fetch('/api/v1/seasons') const years = await apiFetch<number[]>('/api/v1/seasons', {
if (!res.ok) { signal,
throw new Error(`API ${res.status}: ${res.statusText}`) dedupeKey: 'seasons',
} })
const years = await res.json()
return Array.isArray(years) ? years : [] return Array.isArray(years) ? years : []
} }
export async function fetchLocalMeetings(year: number): Promise<Meeting[]> { export async function fetchLocalMeetings(year: number, signal?: AbortSignal): Promise<Meeting[]> {
const res = await fetch(`/api/v1/meetings?year=${year}&source=local`) const meetings = await apiFetch<Meeting[]>(`/api/v1/meetings?year=${year}&source=local`, {
if (!res.ok) { signal,
throw new Error(`API ${res.status}: ${res.statusText}`) dedupeKey: `meetings:local:${year}`,
} })
const meetings = await res.json()
return Array.isArray(meetings) ? meetings : [] return Array.isArray(meetings) ? meetings : []
} }
export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> { export async function fetchSeasonMeetings(year: number, signal?: AbortSignal): Promise<Meeting[]> {
return fetchMeetings(year, 'openf1') return fetchMeetings(year, 'openf1', signal)
} }
export async function fetchMeetings(year: number, source = 'auto'): Promise<Meeting[]> { export async function fetchMeetings(
const res = await fetch(`/api/v1/meetings?year=${year}&source=${source}`) year: number,
if (!res.ok) { source = 'auto',
throw new Error(`API ${res.status}: ${res.statusText}`) signal?: AbortSignal,
} ): Promise<Meeting[]> {
const meetings = await res.json() const meetings = await apiFetch<Meeting[]>(`/api/v1/meetings?year=${year}&source=${source}`, {
signal,
dedupeKey: `meetings:${source}:${year}`,
})
return Array.isArray(meetings) ? meetings : [] return Array.isArray(meetings) ? meetings : []
} }
export async function fetchResults(sessionKey: number, source = 'auto'): Promise<EnrichedResult[]> { export async function fetchResults(
const res = await fetch(`/api/v1/results?session_key=${sessionKey}&source=${source}`) sessionKey: number,
if (!res.ok) { source = 'auto',
throw new Error(`API ${res.status}: ${res.statusText}`) signal?: AbortSignal,
} ): Promise<EnrichedResult[]> {
const results = await res.json() const results = await apiFetch<EnrichedResult[]>(
`/api/v1/results?session_key=${sessionKey}&source=${source}`,
{ signal, dedupeKey: `results:${source}:${sessionKey}` },
)
return Array.isArray(results) ? results : [] return Array.isArray(results) ? results : []
} }
export async function fetchStartingGrid(sessionKey: number, source = 'auto'): Promise<EnrichedGrid[]> { export async function fetchStartingGrid(
const res = await fetch(`/api/v1/grid?session_key=${sessionKey}&source=${source}`) sessionKey: number,
if (!res.ok) { source = 'auto',
throw new Error(`API ${res.status}: ${res.statusText}`) signal?: AbortSignal,
} ): Promise<EnrichedGrid[]> {
const grid = await res.json() const grid = await apiFetch<EnrichedGrid[]>(
`/api/v1/grid?session_key=${sessionKey}&source=${source}`,
{ signal, dedupeKey: `grid:${source}:${sessionKey}` },
)
return Array.isArray(grid) ? grid : [] return Array.isArray(grid) ? grid : []
} }
export async function fetchTrackOutline(circuitKey: number, year: number): Promise<TrackOutline | null> { export async function fetchTrackOutline(
const res = await fetch(`/api/v1/track-outline?circuit_key=${circuitKey}&year=${year}`) circuitKey: number,
if (!res.ok) return null year: number,
const data = await res.json() signal?: AbortSignal,
): Promise<TrackOutline | null> {
try {
const data = await apiFetch<TrackOutline & { error?: string }>(
`/api/v1/track-outline?circuit_key=${circuitKey}&year=${year}`,
{ signal, dedupeKey: `track-outline:${circuitKey}:${year}` },
)
if (data?.error || !Array.isArray(data?.points) || data.points.length < 2) return null if (data?.error || !Array.isArray(data?.points) || data.points.length < 2) return null
return data as TrackOutline return data as TrackOutline
} catch {
return null
}
} }
export async function fetchReplayFrames( export async function fetchReplayFrames(
sessionKey: number, sessionKey: number,
intervalMs = 5000, intervalMs = 5000,
signal?: AbortSignal,
): Promise<ReplayFramesResponse> { ): Promise<ReplayFramesResponse> {
const params = new URLSearchParams({ const params = new URLSearchParams({
session_key: String(sessionKey), session_key: String(sessionKey),
interval_ms: String(intervalMs), interval_ms: String(intervalMs),
}) })
const res = await fetch(`/api/v1/replay/frames?${params}`) return apiFetch<ReplayFramesResponse>(`/api/v1/replay/frames?${params}`, {
if (!res.ok) { signal,
throw new Error(`API ${res.status}: ${res.statusText}`) dedupeKey: `replay-frames:${sessionKey}:${intervalMs}`,
} })
return res.json()
} }
export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise<Session[]> { export async function fetchSessions(
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`) meetingKey: number,
if (!res.ok) { source = 'openf1',
throw new Error(`API ${res.status}: ${res.statusText}`) signal?: AbortSignal,
} ): Promise<Session[]> {
const sessions = await res.json() const sessions = await apiFetch<Session[]>(
`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`,
{ signal, dedupeKey: `sessions:${source}:${meetingKey}` },
)
return Array.isArray(sessions) ? sessions : [] return Array.isArray(sessions) ? sessions : []
} }
export async function fetchWeekend(meetingKey: number): Promise<Weekend> { export async function fetchWeekend(meetingKey: number, signal?: AbortSignal): Promise<Weekend> {
const res = await fetch(`/api/v1/weekend?meeting_key=${meetingKey}`) return apiFetch<Weekend>(`/api/v1/weekend?meeting_key=${meetingKey}`, {
if (!res.ok) { signal,
throw new Error(`API ${res.status}: ${res.statusText}`) dedupeKey: `weekend:${meetingKey}`,
} })
return res.json()
} }
export async function fetchChampionshipHub(year?: number): Promise<ChampionshipHub> { // fetchWeekendContext consumes the canonical /api/v1/weekend-context endpoint
// (backend story #72). The response is the authoritative WeekendContext shape and
// is used verbatim as the Weekend home's source of truth. Any HTTP error throws
// so the hook can surface an explicit error state; there is no client-side
// re-derivation of the contract. Race Hub bare-default landing also reads this
// for `default_analysis_session` (#75).
export async function fetchWeekendContext(signal?: AbortSignal): Promise<WeekendContext> {
return apiFetch<WeekendContext>('/api/v1/weekend-context', {
signal,
dedupeKey: 'weekend-context',
})
}
export async function fetchChampionshipHub(
year?: number,
signal?: AbortSignal,
): Promise<ChampionshipHub> {
const params = new URLSearchParams({ source: 'auto' }) const params = new URLSearchParams({ source: 'auto' })
if (year) params.set('year', year.toString()) if (year) params.set('year', year.toString())
const url = `/api/v1/championship/hub?${params.toString()}` return apiFetch<ChampionshipHub>(`/api/v1/championship/hub?${params.toString()}`, {
const res = await fetch(url) signal,
if (!res.ok) { dedupeKey: `championship-hub:${year ?? 'latest'}`,
throw new Error(`API ${res.status}: ${res.statusText}`) })
}
return res.json()
} }
export async function fetchDriverSummary( export async function fetchDriverSummary(
driverNumber: number, driverNumber: number,
year?: number, year?: number,
signal?: AbortSignal,
): Promise<DriverSummary> { ): Promise<DriverSummary> {
const params = new URLSearchParams({ driver_number: String(driverNumber) }) const params = new URLSearchParams({ driver_number: String(driverNumber), source: 'auto' })
if (year) params.set('year', String(year)) if (year) params.set('year', String(year))
const res = await fetch(`/api/v1/driver/summary?${params.toString()}`) return apiFetch<DriverSummary>(`/api/v1/driver/summary?${params.toString()}`, {
if (!res.ok) { signal,
throw new Error(`API ${res.status}: ${res.statusText}`) dedupeKey: `driver-summary:${driverNumber}:${year ?? 'latest'}`,
} })
return res.json()
} }
export async function fetchLiveState(): Promise<LiveStateResponse> { export async function fetchLiveState(signal?: AbortSignal): Promise<LiveStateResponse> {
const res = await fetch('/api/v1/live/state') return apiFetch<LiveStateResponse>('/api/v1/live/state', {
if (!res.ok) { signal,
throw new Error(`API ${res.status}: ${res.statusText}`) dedupeKey: 'live-state',
} })
return res.json()
} }
export async function fetchLiveTrackOutline( export async function fetchLiveTrackOutline(
session: LiveSessionMeta, session: LiveSessionMeta,
year = new Date().getFullYear(), year = new Date().getFullYear(),
signal?: AbortSignal,
): Promise<TrackOutline> { ): Promise<TrackOutline> {
const params = new URLSearchParams({ year: year.toString() }) const params = new URLSearchParams({ year: year.toString() })
if (session.MeetingName) params.set('meeting_name', session.MeetingName) if (session.MeetingName) params.set('meeting_name', session.MeetingName)
if (session.CircuitName) params.set('circuit_name', session.CircuitName) if (session.CircuitName) params.set('circuit_name', session.CircuitName)
const res = await fetch(`/api/v1/track-outline?${params.toString()}`) return apiFetch<TrackOutline>(`/api/v1/track-outline?${params.toString()}`, {
if (!res.ok) { signal,
throw new Error(`API ${res.status}: ${res.statusText}`) dedupeKey: `live-track-outline:${year}:${session.MeetingName ?? ''}:${session.CircuitName ?? ''}`,
} })
return res.json()
} }
export async function fetchNews(limit?: number, source?: string): Promise<NewsItem[]> { export async function fetchNews(
limit?: number,
source?: string,
signal?: AbortSignal,
): Promise<NewsItem[]> {
const params = new URLSearchParams() const params = new URLSearchParams()
if (limit) params.set('limit', limit.toString()) if (limit) params.set('limit', limit.toString())
if (source) params.set('source', source) if (source) params.set('source', source)
@@ -168,19 +204,20 @@ export async function fetchNews(limit?: number, source?: string): Promise<NewsIt
const query = params.toString() const query = params.toString()
const url = query ? `/api/v1/news?${query}` : '/api/v1/news' const url = query ? `/api/v1/news?${query}` : '/api/v1/news'
const res = await fetch(url) return apiFetch<NewsItem[]>(url, {
if (!res.ok) { signal,
throw new Error(`API ${res.status}: ${res.statusText}`) dedupeKey: `news:${limit ?? 'all'}:${source ?? 'all'}`,
} })
return res.json()
} }
export async function fetchNewsArticle(articleUrl: string): Promise<ArticleContent> { export async function fetchNewsArticle(
const res = await fetch(`/api/v1/news/article?url=${encodeURIComponent(articleUrl)}`) articleUrl: string,
if (!res.ok) { signal?: AbortSignal,
throw new Error(`API ${res.status}: ${res.statusText}`) ): Promise<ArticleContent> {
} return apiFetch<ArticleContent>(
return res.json() `/api/v1/news/article?url=${encodeURIComponent(articleUrl)}`,
{ signal },
)
} }
export async function markNewsRead(articleUrl: string): Promise<void> { export async function markNewsRead(articleUrl: string): Promise<void> {
@@ -194,28 +231,26 @@ export async function markNewsRead(articleUrl: string): Promise<void> {
export async function fetchTelemetry( export async function fetchTelemetry(
sessionKey: number, sessionKey: number,
driverNumber: number, driverNumber: number,
signal?: AbortSignal,
): Promise<CarDataSample[]> { ): Promise<CarDataSample[]> {
const res = await fetch( const data = await apiFetch<CarDataSample[]>(
`/api/v1/telemetry?session_key=${sessionKey}&driver_number=${driverNumber}`, `/api/v1/telemetry?session_key=${sessionKey}&driver_number=${driverNumber}`,
{ signal, dedupeKey: `telemetry:${sessionKey}:${driverNumber}` },
) )
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
const data = await res.json()
return Array.isArray(data) ? data : [] return Array.isArray(data) ? data : []
} }
export async function fetchLapsComparison( export async function fetchLapsComparison(
sessionKey: number, sessionKey: number,
drivers?: number[], drivers?: number[],
signal?: AbortSignal,
): Promise<LapsComparisonResponse> { ): Promise<LapsComparisonResponse> {
const params = new URLSearchParams({ session_key: String(sessionKey) }) const params = new URLSearchParams({ session_key: String(sessionKey) })
if (drivers?.length) { if (drivers?.length) {
params.set('drivers', drivers.join(',')) params.set('drivers', drivers.join(','))
} }
const res = await fetch(`/api/v1/laps/comparison?${params}`) return apiFetch<LapsComparisonResponse>(`/api/v1/laps/comparison?${params}`, {
if (!res.ok) { signal,
throw new Error(`API ${res.status}: ${res.statusText}`) dedupeKey: `laps-comparison:${sessionKey}:${drivers?.join(',') ?? 'all'}`,
} })
return res.json()
} }

View File

@@ -5,6 +5,7 @@ import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
import { formatCoverageHint } from '../lib/coverage' import { formatCoverageHint } from '../lib/coverage'
import { SourceBadge } from './SourceBadge' import { SourceBadge } from './SourceBadge'
import { SessionCoverageDots } from './SessionCoverageDots' import { SessionCoverageDots } from './SessionCoverageDots'
import { RouteState } from './RouteState'
import type { Meeting, WeekendSession } from '../types' import type { Meeting, WeekendSession } from '../types'
function formatMeetingDates(meeting: Meeting): string { function formatMeetingDates(meeting: Meeting): string {
@@ -29,18 +30,18 @@ export function LocalDataNavigator({ onSelectSession }: Props) {
const seasonsQuery = useQuery({ const seasonsQuery = useQuery({
queryKey: ['seasons'], queryKey: ['seasons'],
queryFn: fetchSeasons, queryFn: ({ signal }) => fetchSeasons(signal),
}) })
const meetingsQuery = useQuery({ const meetingsQuery = useQuery({
queryKey: ['meetings', selectedYear], queryKey: ['meetings', selectedYear],
queryFn: () => fetchLocalMeetings(selectedYear!), queryFn: ({ signal }) => fetchLocalMeetings(selectedYear!, signal),
enabled: selectedYear != null, enabled: selectedYear != null,
}) })
const weekendQuery = useQuery({ const weekendQuery = useQuery({
queryKey: ['weekend', selectedMeetingKey], queryKey: ['weekend', selectedMeetingKey],
queryFn: () => fetchWeekend(selectedMeetingKey!), queryFn: ({ signal }) => fetchWeekend(selectedMeetingKey!, signal),
enabled: selectedMeetingKey != null, enabled: selectedMeetingKey != null,
}) })
@@ -68,14 +69,22 @@ export function LocalDataNavigator({ onSelectSession }: Props) {
} }
if (seasonsQuery.isLoading) { if (seasonsQuery.isLoading) {
return <div className="nav-panel loading-state">loading local seasons</div> return <RouteState kind="loading" title="loading local seasons…" className="nav-panel" />
} }
if (seasonsQuery.isError) { if (seasonsQuery.isError) {
return ( return (
<div className="nav-panel error-box"> <RouteState
{seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'} kind="error"
</div> className="nav-panel"
title="Local seasons unavailable"
error={seasonsQuery.error}
onRetry={() => {
if (!seasonsQuery.isFetching) void seasonsQuery.refetch()
}}
retrying={seasonsQuery.isFetching}
testId="weekend-error"
/>
) )
} }
@@ -124,9 +133,16 @@ export function LocalDataNavigator({ onSelectSession }: Props) {
)} )}
{meetingsQuery.isError && ( {meetingsQuery.isError && (
<div className="error-box" style={{ marginTop: 'var(--s4)' }}> <RouteState
{meetingsQuery.error instanceof Error ? meetingsQuery.error.message : 'Failed to load meetings'} kind="error"
</div> title="Meetings unavailable"
error={meetingsQuery.error}
onRetry={() => {
if (!meetingsQuery.isFetching) void meetingsQuery.refetch()
}}
retrying={meetingsQuery.isFetching}
testId="weekend-meetings-error"
/>
)} )}
{!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && ( {!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && (
@@ -194,9 +210,16 @@ export function LocalDataNavigator({ onSelectSession }: Props) {
)} )}
{selectedMeetingKey != null && weekendQuery.isError && ( {selectedMeetingKey != null && weekendQuery.isError && (
<div className="error-box" style={{ marginTop: 'var(--s4)' }}> <RouteState
{weekendQuery.error instanceof Error ? weekendQuery.error.message : 'Failed to load weekend'} kind="error"
</div> title="Weekend unavailable"
error={weekendQuery.error}
onRetry={() => {
if (!weekendQuery.isFetching) void weekendQuery.refetch()
}}
retrying={weekendQuery.isFetching}
testId="weekend-error"
/>
)} )}
{weekend && ( {weekend && (

View File

@@ -1,55 +1,66 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { Compass, FileText, Home, Trophy } from 'lucide-react'
import { fetchLiveState } from '../api'
import { isLiveSessionActive } from '../lib/live'
const PRIMARY = [
{ to: '/', label: 'Weekend', icon: Home, exact: true },
{ to: '/championship', label: 'Championship', icon: Trophy, exact: false },
{ to: '/briefing', label: 'Briefing', icon: FileText, exact: false },
{ to: '/explore', label: 'Explore', icon: Compass, exact: false },
] as const
/**
* Nav renders one primary navigation system per breakpoint:
* - Desktop/tablet: the top bar's `aria-label="Primary"` links.
* - Mobile (≤640px): the bottom `aria-label="Primary"` bar; the top bar's links
* are hidden via CSS so the two are never both active at once.
*
* Admin is an operator utility, deliberately outside every Primary landmark — it
* lives in a plain toolbar slot and never appears in the mobile bottom nav.
*/
export function Nav() { export function Nav() {
const { data: liveState } = useQuery({
queryKey: ['live-state'],
queryFn: fetchLiveState,
staleTime: 5_000,
refetchInterval: 30_000,
})
const liveActive = isLiveSessionActive(liveState)
return ( return (
<nav className="app-nav"> <>
<header className="app-nav">
<Link to="/" className="nav-logo"> <Link to="/" className="nav-logo">
box<em>-</em>box box<em>-</em>box
</Link> </Link>
<div className="nav-links"> <nav className="nav-links" aria-label="Primary">
<Link to="/" activeProps={{ className: 'active' }} activeOptions={{ exact: true }}> {PRIMARY.map(({ to, label, exact }) => (
Command
</Link>
<Link <Link
to="/live" key={to}
className={liveActive ? 'nav-live nav-live-on' : 'nav-live'} to={to}
activeProps={{ className: 'active' }} activeProps={{ className: 'active' }}
data-testid="nav-live" activeOptions={exact ? { exact: true } : undefined}
data-live-active={liveActive ? 'true' : 'false'}
> >
{liveActive && <span className="nav-live-dot" aria-hidden="true" />} {label}
Live
{liveActive && <span className="sr-only"> session active</span>}
</Link> </Link>
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}> ))}
Race Hub </nav>
</Link> <div className="nav-utility" role="toolbar" aria-label="Operator utilities">
<Link to="/preview" activeProps={{ className: 'active' }}> <Link
Preview to="/admin"
</Link> className="nav-utility-link"
<Link to="/championship" activeProps={{ className: 'active' }}> activeProps={{ className: 'nav-utility-link active' }}
Championship >
</Link>
<Link to="/briefing" activeProps={{ className: 'active' }}>
Briefing
</Link>
</div>
<div className="nav-utility">
<Link to="/admin" className="nav-utility-link" activeProps={{ className: 'nav-utility-link active' }}>
Admin Admin
</Link> </Link>
</div> </div>
</header>
<nav className="app-bottom-nav" aria-label="Primary">
{PRIMARY.map(({ to, label, icon: Icon, exact }) => (
<Link
key={to}
to={to}
className="bottom-nav-link"
activeProps={{ className: 'bottom-nav-link active' }}
activeOptions={exact ? { exact: true } : undefined}
>
<Icon size={18} aria-hidden="true" />
<span>{label}</span>
</Link>
))}
</nav> </nav>
</>
) )
} }

View File

@@ -1,6 +1,5 @@
import type { RaceHub } from '../types' import type { RaceHub } from '../types'
import { compareFinishPosition, formatDuration, formatGap, formatLapTime } from '../utils' import { compareFinishPosition, formatDuration, formatGap, formatLapTime } from '../utils'
import { countRaceHubDatasets } from '../lib/coverage'
import { Thermometer, Map, Droplets, Wind, CloudRain } from 'lucide-react' import { Thermometer, Map, Droplets, Wind, CloudRain } from 'lucide-react'
interface Props { interface Props {
@@ -17,7 +16,6 @@ export function OverviewView({ data }: Props) {
const fastest = pickFastestLap(data) const fastest = pickFastestLap(data)
const latestWeather = data.weather.length > 0 ? data.weather[data.weather.length - 1] : null const latestWeather = data.weather.length > 0 ? data.weather[data.weather.length - 1] : null
const rcHighlights = data.race_control.slice(-3).reverse() const rcHighlights = data.race_control.slice(-3).reverse()
const coverage = countRaceHubDatasets(data.datasets)
const sessionType = (data.session?.session_type ?? '').toLowerCase() const sessionType = (data.session?.session_type ?? '').toLowerCase()
const isRace = sessionType.includes('race') const isRace = sessionType.includes('race')
@@ -129,28 +127,6 @@ export function OverviewView({ data }: Props) {
</ul> </ul>
)} )}
</section> </section>
<section className="rh-panel ui-card">
<div className="sec-header">
<span className="sec-title">Local Coverage</span>
<span className="sec-meta mono">
{coverage.available}/{coverage.total}
</span>
</div>
<div className="rh-coverage-meter" aria-hidden="true">
<div
className="rh-coverage-fill"
style={{ width: `${(coverage.available / coverage.total) * 100}%` }}
/>
</div>
<div className="rh-empty-line" style={{ marginTop: 'var(--s2)' }}>
{coverage.available === coverage.total
? 'Every Race Hub dataset is local for this session.'
: `${coverage.total - coverage.available} dataset${
coverage.total - coverage.available === 1 ? '' : 's'
} not ingested yet — see Data Status tab.`}
</div>
</section>
</div> </div>
</div> </div>
) )

View File

@@ -34,25 +34,25 @@ export function PaddockBriefing() {
const { data: news, isLoading, isError } = useQuery({ const { data: news, isLoading, isError } = useQuery({
queryKey: ['news'], queryKey: ['news'],
queryFn: () => fetchNews(100), queryFn: ({ signal }) => fetchNews(100, undefined, signal),
staleTime: 60_000, staleTime: 60_000,
}) })
const seasonsQuery = useQuery({ const seasonsQuery = useQuery({
queryKey: ['seasons'], queryKey: ['seasons'],
queryFn: fetchSeasons, queryFn: ({ signal }) => fetchSeasons(signal),
}) })
const latestSeason = seasonsQuery.data?.[0] ?? null const latestSeason = seasonsQuery.data?.[0] ?? null
const meetingsQuery = useQuery({ const meetingsQuery = useQuery({
queryKey: ['season-meetings', latestSeason], queryKey: ['season-meetings', latestSeason],
queryFn: () => fetchSeasonMeetings(latestSeason!), queryFn: ({ signal }) => fetchSeasonMeetings(latestSeason!, signal),
enabled: latestSeason != null, enabled: latestSeason != null,
}) })
const hubQuery = useQuery({ const hubQuery = useQuery({
queryKey: ['championship-hub', latestSeason], queryKey: ['championship-hub', latestSeason],
queryFn: () => fetchChampionshipHub(latestSeason!), queryFn: ({ signal }) => fetchChampionshipHub(latestSeason!, signal),
enabled: latestSeason != null, enabled: latestSeason != null,
}) })

View File

@@ -0,0 +1,141 @@
import type { Session } from '../types'
import { RACE_HUB_DATASETS } from '../lib/coverage'
import { formatCountdown, formatSessionScheduleTime, sessionStartTime } from '../lib/schedule'
import {
sessionStateDescription,
type SessionState,
} from '../lib/sessionState'
const EXPECTED_LABELS: Record<string, string> = {
results: 'Final results',
starting_grid: 'Starting grid',
stints: 'Tyre strategy',
pit_stops: 'Pit stops',
positions: 'Position changes',
laps: 'Lap times',
race_control: 'Race control',
weather: 'Track conditions',
}
interface PreSessionProps {
session: Session
sessionName: string
now: Date
}
/**
* Purpose-built view for a session that has not run yet. Instead of rendering
* empty Winner / Podium / Pole / Strategy / Compare cards, it explains that the
* session is upcoming and previews the analysis that will appear once the data
* is ingested.
*/
export function PreSessionView({ session, sessionName, now }: PreSessionProps) {
const start = sessionStartTime(session)
const expected = RACE_HUB_DATASETS.filter((key) => EXPECTED_LABELS[key])
return (
<div className="rh-presession" data-testid="rh-presession">
<section className="rh-presession-band">
<span className="rh-presession-eyebrow mono">Upcoming session</span>
<h2 className="rh-presession-title">{sessionName}</h2>
<p className="rh-presession-sub">
This session hasnt run yet, so theres no result to analyse. Winner,
podium, pole, strategy and comparison views will appear here once the
session completes and its data is ingested.
</p>
<div className="rh-presession-countdown mono" data-testid="rh-presession-countdown">
{start
? `Starts ${formatSessionScheduleTime(session.date_start)} · in ${formatCountdown(start, now)}`
: 'Start time to be confirmed.'}
</div>
</section>
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Expected once complete</span>
</div>
<div className="rh-expected-grid">
{expected.map((key) => (
<div key={key} className="rh-expected-card">
<span className="rh-expected-dot" aria-hidden="true" />
<span>{EXPECTED_LABELS[key]}</span>
</div>
))}
</div>
</div>
</div>
)
}
interface PhaseProps {
state: Extract<SessionState, 'preparing' | 'unavailable' | 'cancelled'>
sessionName: string
onOpenDiagnostics?: () => void
}
/**
* Distinct fan-facing surfaces for settling/preparing and unavailable sessions.
* Genuine request failures stay on the page-level error recovery path.
*/
export function SessionPhaseView({ state, sessionName, onOpenDiagnostics }: PhaseProps) {
const title =
state === 'preparing'
? 'Analysis preparing'
: state === 'cancelled'
? 'Session cancelled'
: 'Analysis unavailable'
const testId =
state === 'preparing'
? 'rh-preparing'
: state === 'cancelled'
? 'rh-cancelled'
: 'rh-unavailable'
return (
<div className="rh-presession" data-testid={testId}>
<section className="rh-presession-band">
<span className="rh-presession-eyebrow mono">{sessionStateLabelEyebrow(state)}</span>
<h2 className="rh-presession-title">{title}</h2>
<p className="rh-presession-sub">
{sessionName}: {sessionStateDescription(state)}
</p>
{state === 'preparing' && (
<p className="rh-presession-sub">
Check back shortly, or open Diagnostics if you need raw dataset coverage.
</p>
)}
{onOpenDiagnostics && (state === 'preparing' || state === 'unavailable') && (
<div className="rh-empty-actions" style={{ marginTop: 'var(--s4)' }}>
<button type="button" className="rh-empty-action" onClick={onOpenDiagnostics}>
Open Diagnostics
</button>
</div>
)}
</section>
</div>
)
}
function sessionStateLabelEyebrow(state: PhaseProps['state']): string {
if (state === 'preparing') return 'Settling'
if (state === 'cancelled') return 'Cancelled'
return 'Unavailable'
}
interface PartialBannerProps {
onOpenDiagnostics?: () => void
}
export function PartialAnalysisBanner({ onOpenDiagnostics }: PartialBannerProps) {
return (
<div className="rh-partial-banner" data-testid="rh-partial-banner" role="status">
<span>Partial analysis some datasets are still missing.</span>
{onOpenDiagnostics && (
<button type="button" className="rh-partial-banner-link" onClick={onOpenDiagnostics}>
Diagnostics
</button>
)}
</div>
)
}

View File

@@ -704,7 +704,12 @@ export function RaceStoryCanvas({ data }: Props) {
</div> </div>
{displayResults.length > 0 && ( {displayResults.length > 0 && (
<div className="rs-field-list"> <div
className="rs-field-list"
role="region"
aria-label="Final running order"
data-testid="race-story-outcome"
>
{displayResults.map((r, i) => { {displayResults.map((r, i) => {
const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0 const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
const currentPos = scrubTime !== null ? i + 1 : r.position const currentPos = scrubTime !== null ? i + 1 : r.position

View File

@@ -0,0 +1,163 @@
import type { ReactNode } from 'react'
import { isTimeoutError, userFacingError } from '../lib/fetch'
import { noticeMessage, type DataAvailability } from '../lib/availability'
export type { DataAvailability }
export function availabilityLabel(kind: DataAvailability): string {
switch (kind) {
case 'local':
return 'Local'
case 'partial':
return 'Partial'
case 'stale':
return 'Stale'
case 'archive':
return 'Archive'
case 'limited':
return 'Limited'
case 'missing':
return 'Missing'
}
}
export function AvailabilityBadge({ kind, label }: { kind: DataAvailability; label?: string }) {
return (
<span className={`badge badge-${kind === 'archive' ? 'none' : kind}`} data-testid={`availability-${kind}`}>
{label ?? availabilityLabel(kind)}
</span>
)
}
export type RouteStateKind = 'loading' | 'empty' | 'error' | 'timeout'
interface RouteStateProps {
kind: RouteStateKind
title?: string
message?: ReactNode
error?: unknown
onRetry?: () => void
retrying?: boolean
testId?: string
/** Optional override for the Retry button's data-testid (defaults to none). */
retryTestId?: string
className?: string
/** Optional availability strip (stale/limited/partial) above the state body. */
availability?: DataAvailability
children?: ReactNode
}
const DEFAULT_TITLES: Record<RouteStateKind, string> = {
loading: 'Loading…',
empty: 'Nothing here yet',
error: 'Could not load this view',
timeout: 'Request timed out',
}
const DEFAULT_MESSAGES: Record<RouteStateKind, string> = {
loading: 'Fetching the latest local data.',
empty: 'No data is available for this view yet.',
error: 'Something went wrong. Retry to try again.',
timeout: 'This request took too long. Check your connection, then retry.',
}
/**
* Shared primary-route state surface: loading, empty, timeout/error + retry.
* Retry is a real <button> (keyboard accessible) and callers should gate
* concurrent refetches via React Query / deduped apiFetch.
*/
export function RouteState({
kind,
title,
message,
error,
onRetry,
retrying = false,
testId,
retryTestId,
className = '',
availability,
children,
}: RouteStateProps) {
const resolvedKind: RouteStateKind =
kind === 'error' && isTimeoutError(error) ? 'timeout' : kind
const resolvedMessage =
message ??
(error != null && (resolvedKind === 'error' || resolvedKind === 'timeout')
? userFacingError(error)
: DEFAULT_MESSAGES[resolvedKind])
const showRetry =
(resolvedKind === 'error' || resolvedKind === 'timeout') && typeof onRetry === 'function'
return (
<div
className={`route-state route-state-${resolvedKind} ${className}`.trim()}
data-testid={testId ?? `route-state-${resolvedKind}`}
role={resolvedKind === 'error' || resolvedKind === 'timeout' ? 'alert' : undefined}
>
{availability && (
<div className="route-state-availability">
<AvailabilityBadge kind={availability} />
</div>
)}
{resolvedKind === 'loading' ? (
<div className="loading-state">{title ?? 'loading…'}</div>
) : (
<>
<div className="route-state-title">{title ?? DEFAULT_TITLES[resolvedKind]}</div>
<div className="route-state-message">{resolvedMessage}</div>
{children}
{showRetry && (
<button
type="button"
className="route-state-retry"
onClick={onRetry}
disabled={retrying}
aria-busy={retrying || undefined}
data-testid={retryTestId}
>
{retrying ? 'Retrying…' : 'Retry'}
</button>
)}
</>
)}
</div>
)
}
interface StaleNoticeProps {
availability?: DataAvailability
message?: string
onRetry?: () => void
retrying?: boolean
testId?: string
}
/** Inline notice when a successful payload is limited/stale/partial. */
export function DataNotice({
availability = 'stale',
message,
onRetry,
retrying = false,
testId = 'data-notice',
}: StaleNoticeProps) {
return (
<div className="data-notice" data-testid={testId} role="status">
<AvailabilityBadge kind={availability} />
<span className="data-notice-text">{message ?? noticeMessage(availability)}</span>
{onRetry && (
<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

@@ -8,15 +8,46 @@ export type Tab =
| 'race_control' | 'race_control'
| 'data_status' | 'data_status'
const TABS: { id: Tab; label: string }[] = [ interface TabDef {
id: Tab
label: string
}
interface TabGroup {
id: string
label: string
tabs: TabDef[]
}
// Fan-facing hierarchy: Story first, then Analysis, then Data/Context. Every
// existing capability is preserved — only the grouping and ordering change.
const TAB_GROUPS: TabGroup[] = [
{
id: 'story',
label: 'Story',
tabs: [
{ id: 'overview', label: 'Overview' }, { id: 'overview', label: 'Overview' },
{ id: 'race_story', label: 'Race Story' }, { id: 'race_story', label: 'Race Story' },
],
},
{
id: 'analysis',
label: 'Analysis',
tabs: [
{ id: 'strategy', label: 'Strategy' }, { id: 'strategy', label: 'Strategy' },
{ id: 'compare', label: 'Compare' }, { id: 'compare', label: 'Compare' },
{ id: 'lap_data', label: 'Lap Data' }, { id: 'lap_data', label: 'Lap Data' },
],
},
{
id: 'context',
label: 'Data & Context',
tabs: [
{ id: 'conditions', label: 'Conditions' }, { id: 'conditions', label: 'Conditions' },
{ id: 'race_control', label: 'Race Control' }, { id: 'race_control', label: 'Race Control' },
{ id: 'data_status', label: 'Data Status' }, { id: 'data_status', label: 'Diagnostics' },
],
},
] ]
interface Props { interface Props {
@@ -26,8 +57,14 @@ interface Props {
export function TabBar({ active, onChange }: Props) { export function TabBar({ active, onChange }: Props) {
return ( return (
<div className="tab-bar" role="tablist"> <div className="tab-bar tab-bar-grouped" role="tablist" data-testid="rh-tabbar">
{TABS.map((t) => ( {TAB_GROUPS.map((group) => (
<div key={group.id} className="tab-group" data-testid={`rh-tabgroup-${group.id}`}>
<span className="tab-group-label mono" aria-hidden="true">
{group.label}
</span>
<div className="tab-group-btns">
{group.tabs.map((t) => (
<button <button
key={t.id} key={t.id}
role="tab" role="tab"
@@ -39,5 +76,8 @@ export function TabBar({ active, onChange }: Props) {
</button> </button>
))} ))}
</div> </div>
</div>
))}
</div>
) )
} }

View File

@@ -2,41 +2,60 @@ import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api' import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage' import { sessionTypeAbbrev } from '../lib/coverage'
import {
resolveSessionState,
sessionStateDotClass,
sessionStateLabel,
} from '../lib/sessionState'
import { countryDecal, formatGpDateRange } from '../lib/gpIdentity' import { countryDecal, formatGpDateRange } from '../lib/gpIdentity'
import type { WeekendContext } from '../types'
interface Props { interface Props {
currentMeetingKey?: number currentMeetingKey?: number
currentSessionKey?: number currentSessionKey?: number
context?: WeekendContext | null
now?: Date
onClose: () => void onClose: () => void
} }
export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose }: Props) { export function WeekendSwitcher({
currentMeetingKey,
currentSessionKey,
context,
now: nowProp,
onClose,
}: Props) {
const navigate = useNavigate() const navigate = useNavigate()
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons }) const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: ({ signal }) => fetchSeasons(signal) })
const [year, setYear] = useState<number | null>(null) const [year, setYear] = useState<number | null>(null)
const [openMeetingKey, setOpenMeetingKey] = useState<number | null>( const [openMeetingKey, setOpenMeetingKey] = useState<number | null>(
currentMeetingKey ?? null, currentMeetingKey ?? null,
) )
const now = nowProp ?? new Date()
useEffect(() => {
if (year == null && seasonsQuery.data?.length) {
setYear(seasonsQuery.data[0])
}
}, [seasonsQuery.data, year])
const meetingsQuery = useQuery({
queryKey: ['meetings', year],
queryFn: () => fetchLocalMeetings(year!),
enabled: year != null,
})
const weekendQuery = useQuery({ const weekendQuery = useQuery({
queryKey: ['weekend', openMeetingKey], queryKey: ['weekend', openMeetingKey],
queryFn: () => fetchWeekend(openMeetingKey!), queryFn: ({ signal }) => fetchWeekend(openMeetingKey!, signal),
enabled: openMeetingKey != null, enabled: openMeetingKey != null,
}) })
useEffect(() => {
if (year != null) return
const currentYear = weekendQuery.data?.meeting?.year
if (currentYear) {
setYear(currentYear)
} else if (seasonsQuery.data?.length) {
setYear(seasonsQuery.data[0])
}
}, [seasonsQuery.data, weekendQuery.data, year])
const meetingsQuery = useQuery({
queryKey: ['meetings', year],
queryFn: ({ signal }) => fetchLocalMeetings(year!, signal),
enabled: year != null,
})
const seasons = seasonsQuery.data ?? [] const seasons = seasonsQuery.data ?? []
const meetings = meetingsQuery.data ?? [] const meetings = meetingsQuery.data ?? []
const weekend = weekendQuery.data const weekend = weekendQuery.data
@@ -106,9 +125,16 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
{weekendQuery.isLoading && ( {weekendQuery.isLoading && (
<div className="rh-switcher-empty">loading sessions</div> <div className="rh-switcher-empty">loading sessions</div>
)} )}
{weekend && weekend.meeting_key === m.meeting_key && {weekend &&
weekend.sessions.map(({ session, source, datasets }) => { weekend.meeting_key === m.meeting_key &&
weekend.sessions.map((weekendSession) => {
const { session } = weekendSession
const active = session.session_key === currentSessionKey const active = session.session_key === currentSessionKey
const state = resolveSessionState({
weekendSession,
context,
now,
})
return ( return (
<button <button
key={session.session_key} key={session.session_key}
@@ -122,8 +148,11 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
</span> </span>
<span className="rh-switcher-sess-name">{session.session_name}</span> <span className="rh-switcher-sess-name">{session.session_name}</span>
<span className="rh-switcher-sess-cov mono"> <span className="rh-switcher-sess-cov mono">
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" /> <span
{formatCoverageHint(datasets)} className={`cc-cov-dot ${sessionStateDotClass(state)}`}
aria-hidden="true"
/>
{sessionStateLabel(state)}
</span> </span>
</button> </button>
) )

View File

@@ -0,0 +1,263 @@
import { Archive, ChevronRight, Flag, Radio } from 'lucide-react'
import type { LiveTimingRow } from '../../lib/live'
import { driverCode } from '../../lib/live'
import type { TransportHealth } from '../../lib/liveState'
import { feedHealthLabel } from '../../lib/liveState'
import type { ContextSession, WeekendContext } from '../../types'
import { analysisSessionKey } from '../../lib/weekendContext'
import { formatSessionScheduleTime } from '../../lib/schedule'
interface Props {
/** 'settling' immediately after a session; 'inactive' when nothing is live. */
phase: 'settling' | 'inactive'
transport: TransportHealth
/** Canonical weekend context (issue #72). May be undefined before it loads. */
context: WeekendContext | undefined
/** Top rows of the final snapshot (settling only), already position-sorted. */
rows: LiveTimingRow[]
capturedAt?: string | null
hasArchive: boolean
onViewArchive: () => void
}
function formatCapturedAt(capturedAt: string | null | undefined): string {
if (!capturedAt) return ''
const date = new Date(capturedAt)
if (Number.isNaN(date.getTime())) return ''
return date.toLocaleString()
}
function sessionKeyOf(session: ContextSession | undefined): number | undefined {
const key = session?.session.session_key
return key && key > 0 ? key : undefined
}
/**
* Fully ingested analysis (local_analysis === complete). Stricter than
* hasLocalAnalysis, which also treats partial as link-worthy — Live polling
* and "ready" chrome wait for the complete state.
*/
export function analysisIsReady(session: ContextSession | undefined): boolean {
return session?.availability?.local_analysis === 'complete'
}
/**
* Analysis target for the Live handoff surface.
*
* Settling must follow the just-finished session (`previous_completed_session`).
* The canonical backend can mark that session archive-complete while leaving
* `default_analysis_session` on an older already-ingested practice/qualifying —
* preferring default here would link/poll/label the wrong race.
*
* Inactive keeps the shared default-first preference via analysisSessionKey.
*/
export function handoffAnalysisSession(
context: WeekendContext | undefined,
phase: 'settling' | 'inactive',
): ContextSession | undefined {
if (!context) return undefined
if (phase === 'settling') {
if (sessionKeyOf(context.previous_completed_session)) {
return context.previous_completed_session
}
return sessionKeyOf(context.default_analysis_session)
? context.default_analysis_session
: undefined
}
const key = analysisSessionKey(context)
if (!key) return undefined
if (context.default_analysis_session?.session.session_key === key) {
return context.default_analysis_session
}
return context.previous_completed_session
}
/**
* Keep polling weekend-context while the just-finished session (or, absent
* that, the default analysis session) is still ingesting. An older ready
* default must not stop the settle→ready transition.
*/
export function shouldPollHandoffAnalysis(context: WeekendContext | undefined): boolean {
if (!context) return true
const previous = context.previous_completed_session
if (sessionKeyOf(previous) && !analysisIsReady(previous)) return true
const fallback = context.default_analysis_session
if (sessionKeyOf(fallback) && !analysisIsReady(fallback)) return true
return !sessionKeyOf(previous) && !sessionKeyOf(fallback)
}
export function LiveHandoff({
phase,
transport,
context,
rows,
capturedAt,
hasArchive,
onViewArchive,
}: Props) {
const isSettling = phase === 'settling'
const testid = isSettling ? 'live-settling' : 'live-inactive'
const capturedLabel = formatCapturedAt(capturedAt)
const focusName = context?.focus_meeting?.meeting_name
const activeName = context?.active_session?.meeting?.meeting_name
const title = focusName || activeName || 'Live Timing'
const topRows = rows.slice(0, 3)
const analysis = handoffAnalysisSession(context, phase)
const analysisKey = sessionKeyOf(analysis)
const analysisName = analysis?.session.session_name || 'session'
const analysisReady = analysisIsReady(analysis)
const next = context?.next_session
const previous = context?.previous_completed_session
// Avoid a duplicate recap card when previous == the analysis target.
const showRecap =
previous && previous.session.session_key !== analysisKey && previous.session.session_key !== 0
return (
<section className={`live-handoff live-handoff-${phase}`} data-testid={testid}>
<header className="live-handoff-head">
<span className="live-handoff-eyebrow mono">
{isSettling ? (
<>
<Flag size={13} /> SESSION SETTLING
</>
) : (
<>
<Radio size={13} /> NO LIVE SESSION
</>
)}
</span>
<h1 className="live-handoff-title">{title}</h1>
{isSettling && capturedLabel && (
<p className="live-handoff-captured mono" data-testid="live-handoff-captured">
Final feed snapshot captured {capturedLabel}
</p>
)}
{!isSettling && (
<p className="live-handoff-sub">
The timing feed is quiet between sessions. Here&apos;s where the weekend stands.
</p>
)}
<span className={`live-feed-health live-feed-${transport} live-handoff-feed`}>
<span className="live-feed-dot" aria-hidden="true" />
{feedHealthLabel(transport)}
</span>
</header>
{isSettling && topRows.length > 0 && (
<div className="live-handoff-snapshot" data-testid="live-handoff-snapshot">
<span className="live-handoff-snapshot-label mono">Provisional order at chequered</span>
<ol className="live-handoff-order">
{topRows.map((row) => (
<li key={row.RacingNumber}>
<span className="mono">P{row.Position}</span>
<span className="live-handoff-code">{driverCode(row)}</span>
</li>
))}
</ol>
</div>
)}
<div className="live-handoff-actions">
{analysisKey ? (
<a
className="live-handoff-action live-handoff-primary"
href={`/race-hub?session_key=${analysisKey}`}
data-testid="live-handoff-analysis"
data-ready={analysisReady ? 'true' : 'false'}
>
<span className="live-handoff-action-body">
<span className="live-handoff-action-label">
{isSettling ? `Open ${analysisName} analysis` : `Open ${analysisName} in Race Hub`}
</span>
<span className="live-handoff-action-meta mono">
{analysisReady
? 'Full timing, strategy & story ready'
: isSettling
? 'Settling — analysis will fill in as data ingests'
: 'Analysis will fill in as data ingests'}
</span>
</span>
<ChevronRight size={18} />
</a>
) : (
<div
className="live-handoff-action live-handoff-pending"
data-testid="live-handoff-analysis-pending"
>
<span className="live-handoff-action-body">
<span className="live-handoff-action-label">Analysis not ready yet</span>
<span className="live-handoff-action-meta mono">
The completed session will appear in Race Hub once it is ingested.
</span>
</span>
</div>
)}
{next && (
<div className="live-handoff-action live-handoff-next" data-testid="live-handoff-next">
<span className="live-handoff-action-body">
<span className="live-handoff-action-label">
Up next · {next.session.session_name}
</span>
<span className="live-handoff-action-meta mono">
{formatSessionScheduleTime(next.session.date_start)}
</span>
</span>
</div>
)}
{showRecap && (
<a
className="live-handoff-action"
href={`/race-hub?session_key=${previous!.session.session_key}`}
data-testid="live-handoff-recap"
>
<span className="live-handoff-action-body">
<span className="live-handoff-action-label">
Recap · {previous!.session.session_name}
</span>
<span className="live-handoff-action-meta mono">
Review the last completed session
</span>
</span>
<ChevronRight size={18} />
</a>
)}
{hasArchive && (
<button
type="button"
className="live-handoff-action live-handoff-archive-btn"
onClick={onViewArchive}
data-testid="live-handoff-archive"
>
<span className="live-handoff-action-body">
<span className="live-handoff-action-label">
<Archive size={14} /> View full timing (read-only)
</span>
<span className="live-handoff-action-meta mono">
Frozen final snapshot no live updates
</span>
</span>
<ChevronRight size={18} />
</button>
)}
{!analysisKey && !next && !showRecap && !hasArchive && (
<div className="live-handoff-fallback" data-testid="live-handoff-fallback">
<a href="/" className="live-handoff-action">
<span className="live-handoff-action-body">
<span className="live-handoff-action-label">Weekend</span>
<span className="live-handoff-action-meta mono">Weekend schedule &amp; standings</span>
</span>
<ChevronRight size={18} />
</a>
</div>
)}
</div>
</section>
)
}

View File

@@ -80,9 +80,7 @@ export function RaceControlFeed({ messages, driverInfo }: Props) {
<section className="live-rc panel-glass"> <section className="live-rc panel-glass">
<div className="sec-header sticky-header"> <div className="sec-header sticky-header">
<span className="sec-title">Race Control</span> <span className="sec-title">Race Control</span>
<span className="sec-meta" data-testid="rc-timezone"> {messages.length > 0 && <span className="sec-meta">{messages.length} messages</span>}
{messages.length > 0 ? `${messages.length} messages · ` : ''}times UTC
</span>
</div> </div>
{latest.length === 0 ? ( {latest.length === 0 ? (
<div className="missing-notice">No race control messages in the current live snapshot.</div> <div className="missing-notice">No race control messages in the current live snapshot.</div>
@@ -104,7 +102,7 @@ export function RaceControlFeed({ messages, driverInfo }: Props) {
return ( return (
<div className={`live-rc-row${flashClass}`} key={key}> <div className={`live-rc-row${flashClass}`} key={key}>
<span className="rc-time" title="UTC">{message.Time || '--:--'}</span> <span className="rc-time">{message.Time || '--:--'}</span>
{message.Lap > 0 && <span className="rc-lap">L{message.Lap}</span>} {message.Lap > 0 && <span className="rc-lap">L{message.Lap}</span>}
{message.Flag {message.Flag
? <span className={`rc-flag ${rcFlagClass(message.Flag)}`}>{message.Flag}</span> ? <span className={`rc-flag ${rcFlagClass(message.Flag)}`}>{message.Flag}</span>

View File

@@ -1,30 +1,60 @@
import type { LiveStreamData } from '../../types' import type { LiveStreamData } from '../../types'
import type { LiveTimingRow } from '../../lib/live' import type { LiveTimingRow } from '../../lib/live'
import { extrapolateClock, liveSessionDisplay } from '../../lib/live' import { extrapolateClock, liveSessionDisplay } from '../../lib/live'
import type { LivePhase, TransportHealth } from '../../lib/liveState'
import { feedHealthLabel } from '../../lib/liveState'
import { WeatherStrip } from './WeatherStrip' import { WeatherStrip } from './WeatherStrip'
interface Props { interface Props {
isLive: boolean /** Which of the live phases we are rendering: 'live' | 'disconnected' | 'archive'. */
isArchive?: boolean phase: LivePhase
snapshot: LiveStreamData snapshot: LiveStreamData
rows: LiveTimingRow[] rows: LiveTimingRow[]
connection: 'connected' | 'connecting' | 'disconnected' | 'error' transport: TransportHealth
now: number now: number
/** ISO capture time — required for archive, shown as the timestamp of record. */
capturedAt?: string | null
} }
export function SessionBanner({ isLive, isArchive = false, snapshot, rows, connection, now }: Props) { function formatCapturedAt(capturedAt: string | null | undefined): string {
if (!capturedAt) return ''
const date = new Date(capturedAt)
if (Number.isNaN(date.getTime())) return ''
return date.toLocaleString()
}
export function SessionBanner({ phase, snapshot, rows, transport, now, capturedAt }: Props) {
const session = snapshot.Session const session = snapshot.Session
const clock = extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now) const isArchive = phase === 'archive'
const isLiveSession = phase === 'live' || phase === 'disconnected'
// Archive is a frozen single frame: never extrapolate a running clock for it.
const clock = isArchive
? ''
: extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now)
const display = liveSessionDisplay(session, rows) const display = liveSessionDisplay(session, rows)
const atRiskLabel = const atRiskLabel =
display.atRiskStart && display.atRiskEnd ? `P${display.atRiskStart}-P${display.atRiskEnd} at risk` : '' display.atRiskStart && display.atRiskEnd ? `P${display.atRiskStart}-P${display.atRiskEnd} at risk` : ''
const stateLabel = isLive ? 'live' : isArchive ? 'archive' : 'stale' const capturedLabel = formatCapturedAt(capturedAt)
return ( return (
<section className="live-banner"> <section className={`live-banner${isArchive ? ' live-banner-archive' : ''}`} data-testid="live-banner">
<div className="live-banner-row"> <div className="live-banner-row">
<div className="live-banner-main"> <div className="live-banner-main">
<span className={`live-conn live-conn-${connection}`}>{connection}</span> {isArchive ? (
<span className="live-session-flag live-session-flag-archive" data-testid="live-archive-flag">
ARCHIVE
</span>
) : (
<span
className={`live-session-flag live-session-flag-live${
phase === 'disconnected' ? ' is-stale' : ''
}`}
data-testid="live-session-flag"
>
<span className="live-session-dot" aria-hidden="true" />
LIVE SESSION
</span>
)}
<div> <div>
<h1>{session?.MeetingName || 'Live Timing'}</h1> <h1>{session?.MeetingName || 'Live Timing'}</h1>
<p> <p>
@@ -33,17 +63,40 @@ export function SessionBanner({ isLive, isArchive = false, snapshot, rows, conne
</div> </div>
</div> </div>
<div className="live-session-board"> <div className="live-session-board">
{display.phaseLabel && <span className="live-phase-pill mono">{display.phaseLabel}</span>} {/* Feed health is strictly secondary and only present for a live session. */}
<div className="live-clock mono" data-testid="live-clock">{clock || '--:--:--'}</div> {isLiveSession && (
<div className="live-banner-meta"> <span
{display.advanceCount && <span>{display.advanceCount} advance</span>} className={`live-feed-health live-feed-${transport}`}
{atRiskLabel && <span>{atRiskLabel}</span>} data-testid="live-feed-health"
{display.isRace && ( title="Transport health — independent of session state"
<span data-testid="live-lap-counter"> >
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong> <span className="live-feed-dot" aria-hidden="true" />
{feedHealthLabel(transport)}
</span> </span>
)} )}
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{stateLabel}</span> {isArchive ? (
<div className="live-archive-stamp" data-testid="live-archive-stamp">
<span className="live-archive-readonly mono">READ-ONLY</span>
{capturedLabel && <span className="live-archive-captured mono">captured {capturedLabel}</span>}
</div>
) : (
<>
{display.phaseLabel && <span className="live-phase-pill mono">{display.phaseLabel}</span>}
<div className="live-clock mono" data-testid="live-clock">{clock || '--:--:--'}</div>
</>
)}
<div className="live-banner-meta">
{!isArchive && display.advanceCount && <span>{display.advanceCount} advance</span>}
{!isArchive && atRiskLabel && <span>{atRiskLabel}</span>}
<span>
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
</span>
{!isArchive && (
<span className={phase === 'live' ? 'live-state live-state-on' : 'live-state'}>
{phase === 'live' ? 'live' : 'stale'}
</span>
)}
{isArchive && <span className="live-state live-state-archive">archive</span>}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,10 +1,9 @@
import { useState, useMemo, Fragment } from 'react' import { useState, Fragment } from 'react'
import { teamColor } from '../../utils' import { teamColor } from '../../utils'
import { useAutoAnimate } from '@formkit/auto-animate/react' import { useAutoAnimate } from '@formkit/auto-animate/react'
import type { LiveSessionMeta, LiveStintData } from '../../types' import type { LiveSessionMeta, LiveStintData } from '../../types'
import type { LiveTimingRow } from '../../lib/live' import type { LiveTimingRow } from '../../lib/live'
import { import {
bestLapGaps,
driverCode, driverCode,
liveSessionDisplay, liveSessionDisplay,
positionDelta, positionDelta,
@@ -63,10 +62,6 @@ export function TimingTower({
const isQuali = sessionDisplay.isQualifying || !isRace const isQuali = sessionDisplay.isQualifying || !isRace
const columnCount = 7 + (isRace ? 3 : 0) + (isQuali ? 3 : 0) const columnCount = 7 + (isRace ? 3 : 0) + (isQuali ? 3 : 0)
// Practice/qualifying: derive a display-only gap to P1 from valid best laps
// when the upstream feed omits GapToLeader. Empty for races (feed is truth).
const practiceGaps = useMemo(() => bestLapGaps(isRace ? [] : rows), [isRace, rows])
return ( return (
<div className="scroll-x"> <div className="scroll-x">
<table className="data-table live-tower" style={{ minWidth: 760 }}> <table className="data-table live-tower" style={{ minWidth: 760 }}>
@@ -76,7 +71,7 @@ export function TimingTower({
{isRace && <th>Δ</th>} {isRace && <th>Δ</th>}
<th>Driver</th> <th>Driver</th>
<th>Tyre</th> <th>Tyre</th>
<th className={isRace ? undefined : 'hide-mobile'}>Last Lap</th> <th>Last Lap</th>
<th <th
className="interactive" className="interactive"
onClick={() => setGapMode(g => g === 'interval' ? 'leader' : 'interval')} onClick={() => setGapMode(g => g === 'interval' ? 'leader' : 'interval')}
@@ -86,10 +81,10 @@ export function TimingTower({
{gapMode === 'interval' && isRace ? 'Interval' : 'Gap to P1'} {gapMode === 'interval' && isRace ? 'Interval' : 'Gap to P1'}
</th> </th>
{isRace && <th>Trend</th>} {isRace && <th>Trend</th>}
{isQuali && <th className="hide-mobile">S1</th>} {isQuali && <th>S1</th>}
{isQuali && <th className="hide-mobile">S2</th>} {isQuali && <th>S2</th>}
{isQuali && <th className="hide-mobile">S3</th>} {isQuali && <th>S3</th>}
<th className={isRace ? 'hide-mobile' : undefined}>Best</th> <th className="hide-mobile">Best</th>
{isRace && <th className="hide-mobile r">Laps</th>} {isRace && <th className="hide-mobile r">Laps</th>}
<th className="r"></th> <th className="r"></th>
</tr> </tr>
@@ -107,15 +102,7 @@ export function TimingTower({
driver.Cutoff driver.Cutoff
const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition
let gapText: string const gapText = gapMode === 'interval' && isRace ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader
if (isRace) {
gapText = gapMode === 'interval' ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader
} else if (driver.GapToLeader) {
gapText = driver.GapToLeader
} else {
const computed = practiceGaps[row.RacingNumber]
gapText = computed ? (computed.isLeader ? '—' : computed.gap) : ''
}
const intervalAnnotation = const intervalAnnotation =
gapMode === 'interval' && isRace && row.Position > 1 gapMode === 'interval' && isRace && row.Position > 1
? intervalMeaning(parseIntervalSeconds(gapText)) ? intervalMeaning(parseIntervalSeconds(gapText))
@@ -166,10 +153,7 @@ export function TimingTower({
<td> <td>
<span className={`tyre-badge ${tyreClass(row.Tyre)}`}>{tyreLabel(row.Tyre)}</span> <span className={`tyre-badge ${tyreClass(row.Tyre)}`}>{tyreLabel(row.Tyre)}</span>
</td> </td>
<td className={[ <td className={driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono'}>
isRace ? '' : 'hide-mobile',
driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono',
].filter(Boolean).join(' ')}>
{driver.LastLapTime || '-'} {driver.LastLapTime || '-'}
</td> </td>
<td className="mono"> <td className="mono">
@@ -187,14 +171,11 @@ export function TimingTower({
</td> </td>
)} )}
{isQuali && <td className="hide-mobile">{renderSector(0)}</td>} {isQuali && <td>{renderSector(0)}</td>}
{isQuali && <td className="hide-mobile">{renderSector(1)}</td>} {isQuali && <td>{renderSector(1)}</td>}
{isQuali && <td className="hide-mobile">{renderSector(2)}</td>} {isQuali && <td>{renderSector(2)}</td>}
<td className={[ <td className={`hide-mobile ${driver.BestLapOB ? 'mono lap-ob' : 'mono'}`}>
isRace ? 'hide-mobile' : '',
driver.BestLapOB ? 'mono lap-ob' : 'mono',
].filter(Boolean).join(' ')}>
{driver.BestLapTime || '-'} {driver.BestLapTime || '-'}
</td> </td>
@@ -227,16 +208,6 @@ export function TimingTower({
<div className="mono">{driver.NumberOfLaps || 0}</div> <div className="mono">{driver.NumberOfLaps || 0}</div>
</div> </div>
)} )}
{!isRace && driver.Sectors?.some((sec) => sec?.Value) && (
<div data-testid="expanded-sectors">
<div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>SECTORS</div>
<div className="mono" style={{ display: 'flex', gap: '10px' }}>
{renderSector(0)}
{renderSector(1)}
{renderSector(2)}
</div>
</div>
)}
{driver.SpeedTrap && ( {driver.SpeedTrap && (
<div> <div>
<div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>SPEED TRAP</div> <div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>SPEED TRAP</div>

View File

@@ -52,9 +52,7 @@ function StintSparkline({ seconds }: { seconds: number[] }) {
} }
export function TyreDegPanel({ rows, sessionType, pinned }: Props) { export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
const isRace = isRaceSession(sessionType) const [collapsed, setCollapsed] = useState(false)
const [collapsed, setCollapsed] = useState(true)
const [readerChose, setReaderChose] = useState(false)
const [stints, setStints] = useState<StintHistoryMap>({}) const [stints, setStints] = useState<StintHistoryMap>({})
// One lap-history update per received snapshot (rows is rebuilt per snapshot). // One lap-history update per received snapshot (rows is rebuilt per snapshot).
@@ -63,6 +61,8 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
setStints((prev) => recordStintSamples(prev, rows.map(stintInputFromRow))) setStints((prev) => recordStintSamples(prev, rows.map(stintInputFromRow)))
}, [rows]) }, [rows])
const isRace = isRaceSession(sessionType)
const visible = useMemo( const visible = useMemo(
() => () =>
rows.filter( rows.filter(
@@ -74,30 +74,6 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
[rows, pinned], [rows, pinned],
) )
// One linear fit per driver per snapshot, shared by the readiness check and
// the rows below. degradationModel is O(laps) and this runs at feed rate.
const models = useMemo(() => {
const out: Record<string, ReturnType<typeof degradationModel>> = {}
for (const row of visible) {
out[row.RacingNumber] = degradationModel(stints[row.RacingNumber]?.samples ?? [])
}
return out
}, [visible, stints])
// Before any stint has enough clean laps to fit, every row reads "warming
// up" — a full-height panel of placeholders that pushed the Timing Tower off
// the fold for the first third of a race. Stay collapsed until there is
// something to say, then open. A reader who has toggled it keeps their choice.
const hasSignal = useMemo(
() => visible.some((row) => models[row.RacingNumber] != null),
[visible, models],
)
useEffect(() => {
if (readerChose) return
setCollapsed(!(isRace && hasSignal))
}, [isRace, hasSignal, readerChose])
if (visible.length === 0) return null if (visible.length === 0) return null
return ( return (
@@ -105,25 +81,18 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
<button <button
type="button" type="button"
className="sec-header tyredeg-toggle" className="sec-header tyredeg-toggle"
onClick={() => { onClick={() => setCollapsed((prev) => !prev)}
setReaderChose(true)
setCollapsed((prev) => !prev)
}}
aria-expanded={!collapsed} aria-expanded={!collapsed}
> >
<span className="sec-title">Tyre Deg &amp; Pit Window</span> <span className="sec-title">Tyre Deg &amp; Pit Window</span>
{!hasSignal ? ( {isRace && <span className="sec-meta">rejoin assumes ~{PIT_LOSS_SECONDS}s pit loss</span>}
<span className="sec-meta">collecting clean laps</span>
) : (
isRace && <span className="sec-meta">rejoin assumes ~{PIT_LOSS_SECONDS}s pit loss</span>
)}
<span className="tyredeg-chevron" aria-hidden="true">{collapsed ? '▸' : '▾'}</span> <span className="tyredeg-chevron" aria-hidden="true">{collapsed ? '▸' : '▾'}</span>
</button> </button>
{!collapsed && ( {!collapsed && (
<div className="tyredeg-rows"> <div className="tyredeg-rows">
{visible.map((row) => { {visible.map((row) => {
const model = models[row.RacingNumber] const model = degradationModel(stints[row.RacingNumber]?.samples ?? [])
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age) const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age)
return ( return (

View File

@@ -0,0 +1,152 @@
import { Link } from '@tanstack/react-router'
import { ChevronRight, Flag as FlagIcon } from 'lucide-react'
import type {
WeekendBriefingItem,
WeekendChampionshipImpact,
WeekendContext,
WeekendViewState,
} from '../../types'
import {
BriefingStrip,
ChampionshipImpactCard,
ChampionshipRoundStrip,
CountdownDisplay,
EventPodium,
Flag,
} from './shared'
import { analysisSessionKey, meetingIdentity } from '../../lib/weekendContext'
import { parseScheduleTime } from '../../lib/schedule'
const EYEBROW: Record<string, string> = {
between_weekends: 'Between races',
post_weekend: 'Post-weekend',
season_complete: 'Season complete',
}
function formatSessionLine(name: string | undefined, start: string | undefined): string {
if (!name) return 'Schedule to be confirmed'
const date = start ? parseScheduleTime(start) : null
if (!date) return name
const when = date.toLocaleString('en-GB', {
weekday: 'long',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
return `${name} · ${when}`
}
export function BetweenRacesView({
context,
now,
view,
championship,
briefing,
}: {
context: WeekendContext
now: Date
view: WeekendViewState
championship?: WeekendChampionshipImpact
briefing: WeekendBriefingItem[]
}) {
const eyebrow = EYEBROW[view] ?? 'Between races'
const previous = context.previous_completed_session
const previousMeeting = meetingIdentity(previous?.meeting ?? context.previous_meeting)
const nextMeeting = meetingIdentity(context.next_meeting)
const nextSession = context.next_session?.session
const analysisKey = analysisSessionKey(context)
return (
<div className="wk-between" data-testid="weekend-between-races" data-state={view}>
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">{eyebrow}</div>
<div className="wk-top-grid">
{previousMeeting && (
<section className="wk-event-card wk-event-last" data-testid="wk-last-event">
<header className="wk-event-head">
<div className="wk-event-id">
<Flag code={previousMeeting.country_code} flag={previousMeeting.country_flag} />
<h2 className="wk-event-name">{previousMeeting.meeting_name}</h2>
</div>
<span className="wk-event-tag wk-tag-done mono">
<FlagIcon size={13} aria-hidden="true" /> Completed
</span>
</header>
<div className="wk-event-body">
<EventPodium sessionKey={analysisKey} />
<div className="wk-story-card">
<span className="wk-story-label">What decided it?</span>
{analysisKey ? (
<Link
to="/race-hub"
search={{ session_key: analysisKey }}
className="wk-cta wk-cta-primary"
data-testid="wk-explore-race-story"
>
Explore Race Story <ChevronRight size={15} aria-hidden="true" />
</Link>
) : (
<p className="wk-story-text" data-testid="wk-no-analysis">
Analysis for this session is not available locally yet.
</p>
)}
</div>
</div>
</section>
)}
{nextMeeting ? (
<section className="wk-event-card wk-event-next" data-testid="wk-next-event">
<header className="wk-event-head">
<div className="wk-event-id">
<Flag code={nextMeeting.country_code} flag={nextMeeting.country_flag} />
<h2 className="wk-event-name">{nextMeeting.meeting_name}</h2>
</div>
<span className="wk-event-tag wk-tag-next mono">Next event</span>
</header>
<div className="wk-next-body">
<CountdownDisplay target={nextSession?.date_start ?? nextMeeting.date_start} now={now} />
<div className="wk-next-session">
<span className="wk-next-label mono">Next session</span>
<span className="wk-next-value">
{formatSessionLine(nextSession?.session_name, nextSession?.date_start)}
</span>
</div>
<Link to="/preview" className="wk-cta wk-cta-primary wk-cta-wide" data-testid="wk-prepare">
Prepare for {nextMeeting.short_name}
<ChevronRight size={15} aria-hidden="true" />
</Link>
</div>
</section>
) : (
<section className="wk-event-card wk-event-next" data-testid="wk-season-complete-card">
<header className="wk-event-head">
<h2 className="wk-event-name">That&apos;s a wrap</h2>
<span className="wk-event-tag wk-tag-next mono">Off-season</span>
</header>
<div className="wk-next-body">
<p className="wk-season-complete-copy">
The {context.season} calendar is complete. Explore the season&apos;s races or revisit the championship
battle while the next schedule is confirmed.
</p>
<Link to="/explore" className="wk-cta wk-cta-primary wk-cta-wide" data-testid="wk-season-complete-explore">
Explore the season <ChevronRight size={15} aria-hidden="true" />
</Link>
</div>
</section>
)}
</div>
<div className="wk-mid-grid">
{championship && <ChampionshipImpactCard impact={championship} />}
<ChampionshipRoundStrip
round={context.championship_round}
total={context.total_championship_rounds}
/>
</div>
{briefing.length > 0 && <BriefingStrip items={briefing} />}
</div>
)
}

View File

@@ -0,0 +1,86 @@
import { Link } from '@tanstack/react-router'
import { ChevronRight } from 'lucide-react'
import type { WeekendBriefingItem, WeekendContext, WeekendViewState } from '../../types'
import { BriefingStrip, CountdownDisplay, EventPodium, SessionRail, railNodes } from './shared'
import { analysisSessionKey, meetingIdentity } from '../../lib/weekendContext'
import { parseScheduleTime } from '../../lib/schedule'
function nextSessionWhen(start: string | undefined): string {
const date = start ? parseScheduleTime(start) : null
if (!date) return 'Time to be confirmed'
return date.toLocaleString('en-GB', {
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
}
export function BetweenSessionsView({
context,
now,
view,
briefing,
}: {
context: WeekendContext
now: Date
view: WeekendViewState
briefing: WeekendBriefingItem[]
}) {
const settling = view === 'session_settling'
const focus = meetingIdentity(context.focus_meeting)
const previous = context.previous_completed_session
const previousName = previous?.session.session_name ?? 'Last session'
const next = context.next_session?.session
// Settling recap must open the just-finished session, not an older default.
const previousKey = previous?.session.session_key
const analysisKey =
settling && previousKey && previousKey > 0 ? previousKey : analysisSessionKey(context)
const nodes = railNodes(previous, context.active_session, context.next_session)
return (
<div className="wk-sessions" data-testid="weekend-between-sessions" data-state={view}>
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">
{settling ? 'Session settling' : 'Between sessions'}
</div>
<header className="wk-sessions-head">
<h1 className="wk-sessions-title">{focus?.meeting_name ?? 'Race weekend'}</h1>
{focus?.circuit_short_name && <p className="wk-sessions-circuit mono">{focus.circuit_short_name}</p>}
</header>
<SessionRail nodes={nodes} />
{previous && (
<section className="wk-recap-card" data-testid="wk-last-session">
<span className="wk-recap-eyebrow mono">
{previousName} · {settling ? 'Settling' : 'Complete'}
</span>
<h2 className="wk-recap-title">What happened in {previousName}</h2>
<EventPodium sessionKey={analysisKey} />
</section>
)}
{next && (
<section className="wk-upnext-card" data-testid="wk-next-session">
<span className="wk-upnext-eyebrow mono">Up next</span>
<h2 className="wk-upnext-title">{next.session_name}</h2>
<CountdownDisplay target={next.date_start} now={now} compact />
<p className="wk-upnext-when mono">{nextSessionWhen(next.date_start)}</p>
{analysisKey && (
<Link
to="/race-hub"
search={{ session_key: analysisKey }}
className="wk-cta wk-cta-primary wk-cta-wide"
data-testid="wk-view-recap"
>
View {previousName} recap <ChevronRight size={15} aria-hidden="true" />
</Link>
)}
</section>
)}
{briefing.length > 0 && <BriefingStrip items={briefing} />}
</div>
)
}

View File

@@ -0,0 +1,47 @@
import { Link } from '@tanstack/react-router'
import { ChevronRight, Radio } from 'lucide-react'
import type { WeekendContext } from '../../types'
import { EventPodium, Flag, SessionRail, railNodes } from './shared'
import { analysisSessionKey, meetingIdentity } from '../../lib/weekendContext'
export function LiveHandoffView({ context }: { context: WeekendContext }) {
const focus = meetingIdentity(context.focus_meeting ?? context.active_session?.meeting)
const active = context.active_session?.session
const previous = context.previous_completed_session
const previousName = previous?.session.session_name ?? 'Last session'
const analysisKey = analysisSessionKey(context)
const nodes = railNodes(previous, context.active_session, context.next_session)
return (
<div className="wk-live" data-testid="weekend-live" data-state="session_live">
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">Session live</div>
<section className="wk-live-card" data-testid="wk-live-card">
<div className="wk-live-head">
<div className="wk-event-id">
<Flag code={focus?.country_code} flag={focus?.country_flag} />
<h1 className="wk-sessions-title">{focus?.meeting_name ?? 'Live session'}</h1>
</div>
<span className="wk-live-pulse" aria-hidden="true" />
</div>
{focus?.circuit_short_name && <p className="wk-sessions-circuit mono">{focus.circuit_short_name}</p>}
<p className="wk-live-lead">
{active?.session_name ? `${active.session_name} is on track now.` : 'A session is running now.'}
</p>
<Link to="/live" className="wk-cta wk-cta-primary wk-cta-wide" data-testid="wk-watch-live">
<Radio size={15} aria-hidden="true" /> Watch live timing <ChevronRight size={15} aria-hidden="true" />
</Link>
</section>
<SessionRail nodes={nodes} />
{previous && (
<section className="wk-recap-card" data-testid="wk-last-session">
<span className="wk-recap-eyebrow mono">{previousName} · Complete</span>
<h2 className="wk-recap-title">What happened in {previousName}</h2>
<EventPodium sessionKey={analysisKey} />
</section>
)}
</div>
)
}

View File

@@ -0,0 +1,75 @@
import { Link } from '@tanstack/react-router'
import { ChevronRight } from 'lucide-react'
import type { WeekendContext } from '../../types'
import { RacePreviewPage } from '../../pages/RacePreviewPage'
import { ChampionshipRoundStrip, CountdownDisplay, Flag, SessionRail, railNodes } from './shared'
import { meetingIdentity } from '../../lib/weekendContext'
import type { DataAvailability } from '../../lib/availability'
/**
* PreSessionView folds the race preview surface into the Weekend home so there is
* no separate Preview destination. It reuses the existing preview page content and
* frames it with the next-session countdown and compact season navigation. It also
* backs the /preview alias, so saved preview links resolve here instead of looping.
*
* Canonical meeting/session identity is passed into Preview so the nested surface
* cannot independently re-resolve a different current weekend.
*/
export function PreSessionView({
context,
now,
shellAvailability = null,
}: {
context: WeekendContext
now: Date
/** Weekend shell notice already shown — Preview dedupes only an equivalent kind. */
shellAvailability?: DataAvailability | null
}) {
const meeting = context.next_meeting ?? context.focus_meeting
const identity = meetingIdentity(meeting)
const next = context.next_session?.session
const nextStart = next?.date_start ?? meeting?.date_start
const nodes = railNodes(
context.previous_completed_session,
context.active_session,
context.next_session,
)
return (
<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>
{identity && (
<header className="wk-pre-head" data-testid="wk-pre-head">
<div className="wk-event-id">
<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>
<CountdownDisplay target={nextStart} now={now} />
</div>
<Link to="/live" className="wk-cta wk-cta-ghost" data-testid="wk-pre-live">
Live timing <ChevronRight size={14} aria-hidden="true" />
</Link>
</header>
)}
{nodes.length > 0 && <SessionRail nodes={nodes} />}
<div className="wk-pre-preview" data-testid="wk-pre-preview">
<RacePreviewPage
embedded
meeting={meeting}
season={context.season ?? meeting?.year}
shellAvailability={shellAvailability}
/>
</div>
<ChampionshipRoundStrip
round={context.championship_round}
total={context.total_championship_rounds}
/>
</div>
)
}

View File

@@ -0,0 +1,65 @@
import { Link } from '@tanstack/react-router'
import { userFacingError } from '../../lib/fetch'
export function WeekendLoading() {
return (
<div className="wk-status wk-status-loading" data-testid="weekend-loading" aria-busy="true">
<span className="wk-status-eyebrow mono">box-box · weekend</span>
<p className="wk-status-title">Reading the weekend</p>
</div>
)
}
export function WeekendError({
error,
message,
onRetry,
retrying = false,
}: {
error?: unknown
message?: string
onRetry?: () => void
retrying?: boolean
}) {
const resolved =
message ??
(error != null ? userFacingError(error) : 'Something went wrong loading the weekend context.')
return (
<div className="wk-status wk-status-error" data-testid="weekend-error" role="alert">
<span className="wk-status-eyebrow mono">box-box · weekend</span>
<p className="wk-status-title">Weekend unavailable</p>
<p className="wk-status-sub">{resolved}</p>
{typeof onRetry === 'function' && (
<div className="wk-status-actions">
<button
type="button"
className="wk-cta wk-cta-primary"
onClick={onRetry}
disabled={retrying}
aria-busy={retrying || undefined}
data-testid="weekend-retry"
>
{retrying ? 'Retrying…' : 'Retry'}
</button>
</div>
)}
</div>
)
}
export function WeekendLimited({ message, season }: { message?: string; season?: number }) {
return (
<div className="wk-status wk-status-limited" data-testid="weekend-limited">
<span className="wk-status-eyebrow mono">box-box · weekend</span>
<p className="wk-status-title">Nothing to show yet</p>
<p className="wk-status-sub">
{message ?? `No weekend data for the ${season || 'current'} season is available right now.`}
</p>
<div className="wk-status-actions">
<Link to="/live" className="wk-cta wk-cta-ghost">Live timing</Link>
<Link to="/explore" className="wk-cta wk-cta-ghost">Explore</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,65 @@
import { Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ChevronRight } from 'lucide-react'
import { fetchWeekend } from '../../api'
import { meetingIdentity } from '../../lib/weekendContext'
/**
* Restores Race Hub → Weekend meeting/session context from the URL search
* contract. Deep-linkable and reload-safe — no hidden component memory.
*/
export function WeekendFocusBanner({
meetingKey,
sessionKey,
}: {
meetingKey?: number
sessionKey?: number
}) {
const hasFocus = (meetingKey != null && meetingKey > 0) || (sessionKey != null && sessionKey > 0)
const weekendQuery = useQuery({
queryKey: ['weekend', meetingKey],
queryFn: () => fetchWeekend(meetingKey!),
enabled: meetingKey != null && meetingKey > 0,
staleTime: 60_000,
})
if (!hasFocus) return null
const weekend = weekendQuery.data
const meeting = meetingIdentity(weekend?.meeting)
const session = weekend?.sessions.find((s) => s.session.session_key === sessionKey)?.session
const meetingLabel = meeting?.meeting_name ?? (meetingKey ? `Meeting ${meetingKey}` : 'Selected weekend')
const sessionLabel = session?.session_name ?? (sessionKey ? `Session ${sessionKey}` : null)
return (
<section
className="wk-focus"
data-testid="wk-focus-context"
data-meeting-key={meetingKey ?? undefined}
data-session-key={sessionKey ?? undefined}
aria-label="Selected weekend context"
>
<div className="wk-focus-copy">
<span className="wk-focus-eyebrow mono">From Race Hub</span>
<h2 className="wk-focus-title" data-testid="wk-focus-meeting">
{meetingLabel}
</h2>
{sessionLabel && (
<p className="wk-focus-session" data-testid="wk-focus-session">
Selected session · {sessionLabel}
</p>
)}
</div>
{sessionKey != null && sessionKey > 0 && (
<Link
to="/race-hub"
search={{ session_key: sessionKey }}
className="wk-cta wk-cta-primary wk-focus-cta"
data-testid="wk-return-analysis"
>
Continue analysis <ChevronRight size={15} aria-hidden="true" />
</Link>
)}
</section>
)
}

View File

@@ -0,0 +1,277 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { fetchRaceHub } from '../../api'
import { countryFlag } from '../../lib/gpIdentity'
import { parseScheduleTime } from '../../lib/schedule'
import { podiumFromResults } from '../../lib/weekendContext'
import { teamColor } from '../../utils'
import type {
ContextSession,
WeekendChampionshipImpact,
WeekendBriefingItem,
WeekendPodiumEntry,
} from '../../types'
export function Flag({ code, flag }: { code?: string; flag?: string }) {
const glyph = countryFlag({ country_code: code ?? '', country_flag: flag ?? '' })
if (!glyph) return null
return <span className="wk-flag" aria-hidden="true">{glyph}</span>
}
interface CountdownParts {
days: number
hours: number
minutes: number
seconds: number
reached: boolean
}
export function countdownParts(target: string | undefined, now: Date): CountdownParts | null {
const date = target ? parseScheduleTime(target) : null
if (!date) return null
const diff = date.getTime() - now.getTime()
const clamped = Math.max(0, diff)
const totalSeconds = Math.floor(clamped / 1000)
return {
days: Math.floor(totalSeconds / 86400),
hours: Math.floor((totalSeconds % 86400) / 3600),
minutes: Math.floor((totalSeconds % 3600) / 60),
seconds: totalSeconds % 60,
reached: diff <= 0,
}
}
export function CountdownDisplay({
target,
now,
compact,
}: {
target: string | undefined
now: Date
compact?: boolean
}) {
const parts = countdownParts(target, now)
if (!parts) {
return (
<span className="wk-countdown-tbc mono" data-testid="wk-countdown">
Schedule TBC
</span>
)
}
if (parts.reached) {
return <span className="wk-countdown-live" data-testid="wk-countdown">Starting now</span>
}
if (compact || parts.days === 0) {
const hh = String(parts.days * 24 + parts.hours).padStart(2, '0')
return (
<span className="wk-countdown-clock mono" data-testid="wk-countdown">
{hh}:{String(parts.minutes).padStart(2, '0')}:{String(parts.seconds).padStart(2, '0')}
</span>
)
}
return (
<span className="wk-countdown" data-testid="wk-countdown">
<span className="wk-count-unit"><b>{parts.days}</b>D</span>
<span className="wk-count-unit"><b>{String(parts.hours).padStart(2, '0')}</b>H</span>
<span className="wk-count-unit"><b>{String(parts.minutes).padStart(2, '0')}</b>M</span>
</span>
)
}
/**
* EventPodium fetches and renders the podium for a completed analysis session.
* When no analysable session key is available (e.g. an archived result with no
* local analysis), it renders an explicit empty state rather than fetching.
*/
export function EventPodium({ sessionKey }: { sessionKey?: number }) {
const enabled = typeof sessionKey === 'number' && sessionKey > 0
const query = useQuery({
queryKey: ['race-hub', sessionKey, 'podium'],
queryFn: () => fetchRaceHub(sessionKey as number),
enabled,
staleTime: 60_000,
})
const podium: WeekendPodiumEntry[] = podiumFromResults(query.data?.results ?? [])
if (!enabled) {
return (
<div className="wk-podium-empty" data-testid="wk-podium-empty">
Result not available yet.
</div>
)
}
if (podium.length === 0) {
return (
<div className="wk-podium-empty" data-testid="wk-podium-empty">
{query.isLoading ? 'Loading result…' : 'Result not available yet.'}
</div>
)
}
return (
<ol className="wk-podium" data-testid="wk-podium">
{podium.map((p) => (
<li key={p.driver_number} className="wk-podium-row" style={{ ['--wk-team' as string]: teamColor(p.team_colour) }}>
<span className="wk-podium-pos mono">{p.position}</span>
<span className="wk-podium-code">{p.name_acronym}</span>
<span className="wk-podium-gap mono">{p.gap}</span>
</li>
))}
</ol>
)
}
export function ChampionshipImpactCard({ impact }: { impact: WeekendChampionshipImpact }) {
const leader = impact.leaders[0]
return (
<section className="wk-champ" data-testid="wk-champ-impact">
<header className="wk-card-head">
<span className="wk-card-title">Championship impact</span>
<Link to="/championship" className="wk-card-link">Standings </Link>
</header>
<ol className="wk-champ-list">
{impact.leaders.map((d) => {
const gap = leader && d.position !== 1 ? leader.points - d.points : 0
return (
<li key={d.driver_number} className="wk-champ-row" style={{ ['--wk-team' as string]: teamColor(d.team_colour) }}>
<span className="wk-champ-pos mono">{d.position}</span>
<span className="wk-champ-code">{d.name_acronym}</span>
<span className="wk-champ-pts mono">{d.points}</span>
<span className="wk-champ-gap mono">{d.position === 1 ? '—' : `-${gap}`}</span>
</li>
)
})}
</ol>
{impact.note && <p className="wk-champ-note">{impact.note}</p>}
</section>
)
}
export function BriefingStrip({ items }: { items: WeekendBriefingItem[] }) {
if (items.length === 0) return null
return (
<section className="wk-briefing" data-testid="wk-briefing">
<header className="wk-card-head">
<span className="wk-card-title">Since the chequered flag</span>
<Link to="/briefing" className="wk-card-link">All briefing </Link>
</header>
<ul className="wk-briefing-list">
{items.map((item) => (
<li key={item.url} className="wk-briefing-item">
<a href={item.url} target="_blank" rel="noreferrer" className="wk-briefing-link">
{item.category && <span className="wk-briefing-cat mono">{item.category}</span>}
<span className="wk-briefing-title">{item.title}</span>
<span className="wk-briefing-source mono">{item.source}</span>
</a>
</li>
))}
</ul>
</section>
)
}
/**
* ChampionshipRoundStrip is a compact, progressively-disclosed round indicator.
* The canonical contract exposes only the current round and total, so the strip
* shows "Round N of M" and links out to Explore for the full calendar rather than
* reintroducing the whole calendar on the Weekend home.
*/
export function ChampionshipRoundStrip({ round, total }: { round: number; total: number }) {
if (round <= 0 || total <= 0) return null
const pct = Math.min(100, Math.round((round / total) * 100))
return (
<section className="wk-season-nav" data-testid="wk-season-nav" aria-label="Season progress">
<div className="wk-season-progress">
<div className="wk-season-progress-head">
<span className="wk-card-title">Season progress</span>
<Link to="/explore" className="wk-card-link" data-testid="wk-season-explore">
Full calendar
</Link>
</div>
<div
className="wk-season-bar"
role="progressbar"
aria-valuenow={round}
aria-valuemin={1}
aria-valuemax={total}
aria-label={`Round ${round} of ${total}`}
>
<span className="wk-season-bar-fill" style={{ width: `${pct}%` }} aria-hidden="true" />
</div>
<p className="wk-season-progress-label mono">
Round {round} of {total}
</p>
</div>
</section>
)
}
interface TimelineNode {
key: string
session_name: string
status: 'done' | 'live' | 'next'
}
/**
* SessionRail renders the previous/active/next sessions the canonical context
* exposes as a compact three-node rail. It is intentionally derived only from the
* canonical refs — the contract does not enumerate a full weekend session list.
*/
export function SessionRail({ nodes }: { nodes: TimelineNode[] }) {
if (nodes.length === 0) return null
return (
<ol className="wk-timeline" data-testid="wk-timeline" role="list">
{nodes.map((n) => (
<li key={n.key} className={`wk-timeline-node wk-timeline-${n.status}`} role="listitem">
<span className="wk-timeline-dot" aria-hidden="true" />
<span className="wk-timeline-name">{shortSessionName(n.session_name)}</span>
<span className="wk-timeline-state mono">{stateLabel(n.status)}</span>
</li>
))}
</ol>
)
}
/** Build the compact session rail from the canonical previous/active/next refs. */
export function railNodes(
previous: ContextSession | undefined,
active: ContextSession | undefined,
next: ContextSession | undefined,
): TimelineNode[] {
const nodes: TimelineNode[] = []
if (previous?.session.session_name) {
nodes.push({ key: `prev-${previous.session.session_key}`, session_name: previous.session.session_name, status: 'done' })
}
if (active?.session.session_name) {
nodes.push({ key: `live-${active.session.session_key}`, session_name: active.session.session_name, status: 'live' })
}
if (next?.session.session_name) {
nodes.push({ key: `next-${next.session.session_key}`, session_name: next.session.session_name, status: 'next' })
}
return nodes
}
function shortSessionName(name: string): string {
const map: Record<string, string> = {
'Practice 1': 'FP1',
'Practice 2': 'FP2',
'Practice 3': 'FP3',
Qualifying: 'Quali',
'Sprint Qualifying': 'SQ',
'Sprint Shootout': 'SQ',
}
return map[name] ?? name
}
function stateLabel(status: TimelineNode['status']): string {
switch (status) {
case 'done':
return 'Complete'
case 'live':
return 'Live'
default:
return 'Next'
}
}

View File

@@ -0,0 +1,140 @@
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,
WeekendBriefingItem,
WeekendContext,
} from '../types'
export type WeekendLoadState = 'loading' | 'error' | 'ready'
export interface UseWeekendContextResult {
/** Canonical context, present only when loadState === 'ready'. */
context: WeekendContext | null
loadState: WeekendLoadState
error?: Error
/** Supplementary championship movers (not part of the #72 contract). */
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 canonical context and/or failed supplements as appropriate. */
refetch: () => void
/** True while a relevant refetch is in flight. */
isFetching: boolean
}
/**
* useWeekendContext reads the canonical /api/v1/weekend-context endpoint as the
* single source of truth for the Weekend home. When the canonical read succeeds
* it layers on two pieces of supplementary data that the contract intentionally
* omits — championship movers and the paddock briefing — and nothing else.
*
* It deliberately does NOT fan out to season / meetings / per-weekend / OpenF1
* session / live-state queries: those would defeat the local-first canonical read
* model and can hit the rate-limited OpenF1 REST surface during active sessions.
* Supplementary queries are gated on a successful canonical read so a failing or
* pending endpoint issues no extra requests.
*/
export function useWeekendContext(): UseWeekendContextResult {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [])
const nowDate = useMemo(() => new Date(now), [now])
const contextQuery = useQuery({
queryKey: ['weekend-context'],
queryFn: ({ signal }) => fetchWeekendContext(signal),
staleTime: 30_000,
})
const canonical = contextQuery.data ?? null
const canonicalReady = canonical != null
const season = canonical?.season
// Supplementary data — only fetched once the canonical context has resolved,
// so a pending/failed canonical read never triggers a request fan-out.
const championshipQuery = useQuery({
queryKey: ['championship-hub', season ?? 'current'],
queryFn: ({ signal }) => fetchChampionshipHub(season, signal),
enabled: canonicalReady,
staleTime: 60_000,
})
const newsQuery = useQuery({
queryKey: ['news', 6],
queryFn: ({ signal }) => fetchNews(6, undefined, signal),
enabled: canonicalReady,
staleTime: 60_000,
})
const championship = useMemo(
() => championshipImpact(championshipQuery.data),
[championshipQuery.data],
)
const briefing = useMemo(() => briefingItems(newsQuery.data ?? []), [newsQuery.data])
const availabilityNotice = useMemo(
() => (canonical ? weekendContextNotice(canonical) : null),
[canonical],
)
// Keep Limited visible while a failed supplement is refetching so Retry can
// expose disabled/aria-busy. React Query v5 clears isError during that refetch.
const supplementDegraded = (q: {
isError: boolean
isFetching: boolean
isFetched: boolean
data: unknown
}) => q.isError || (q.isFetching && q.isFetched && q.data == null)
const supplementsLimited =
canonicalReady &&
(supplementDegraded(championshipQuery) || supplementDegraded(newsQuery))
let loadState: WeekendLoadState = 'loading'
if (contextQuery.isError) loadState = 'error'
else if (canonicalReady) loadState = 'ready'
const refetch = () => {
if (!canonicalReady) {
if (!contextQuery.isFetching) void contextQuery.refetch()
return
}
// Freshness notice Retry refreshes the canonical context.
if (availabilityNotice && !contextQuery.isFetching) {
void contextQuery.refetch()
}
// Limited Retry must hit the failed supplements — not merely context.
// Gate on degraded (error or in-flight post-error refetch), not isError alone.
if (championshipQuery.isError && !championshipQuery.isFetching) {
void championshipQuery.refetch()
}
if (newsQuery.isError && !newsQuery.isFetching) {
void newsQuery.refetch()
}
}
return {
context: canonical,
loadState,
error: contextQuery.error instanceof Error ? contextQuery.error : undefined,
championship,
briefing,
availabilityNotice,
supplementsLimited,
now: nowDate,
refetch,
isFetching:
contextQuery.isFetching || championshipQuery.isFetching || newsQuery.isFetching,
}
}

View File

@@ -0,0 +1,135 @@
import { getResponseAvailability, type ResponseAvailability } from './fetch'
import type { ContextSession, WeekendContext } from '../types'
/** Shared Weekend Context terminology for coverage / availability indicators. */
export type DataAvailability = 'local' | 'partial' | 'stale' | 'archive' | 'limited' | 'missing'
/** Freshness values that warrant a non-blocking DataNotice. */
const NOTICE_FRESHNESS = new Set<string>(['stale', 'partial', 'local', 'limited', 'archive'])
/**
* Product-impact severity for aggregating conflicting reported freshness.
* Higher wins so routine Local cannot mask Limited/Partial/Stale/Archive.
*/
const NOTICE_SEVERITY: Record<DataAvailability, number> = {
local: 1,
archive: 2,
stale: 3,
partial: 4,
limited: 5,
missing: 6,
}
/**
* Map a reported freshness string onto shared DataNotice vocabulary.
* Returns null for unreported / routine-success values (`fresh`, `live`, …).
* Never invents stale from React Query staleTime.
*/
export function noticeFromFreshness(
freshness: string | undefined | null,
opts?: { includeLocal?: boolean },
): DataAvailability | null {
if (!freshness) return null
const includeLocal = opts?.includeLocal ?? true
if (freshness === 'local' && !includeLocal) return null
if (!NOTICE_FRESHNESS.has(freshness)) return null
return freshness as DataAvailability
}
/** Prefer header freshness, then optional typed payload freshness. */
export function noticeFromResponse(
data: unknown,
opts?: { includeLocal?: boolean; fallbackFreshness?: string },
): DataAvailability | null {
const meta = getResponseAvailability(data)
return (
noticeFromFreshness(meta?.freshness, opts) ??
noticeFromFreshness(opts?.fallbackFreshness, opts)
)
}
/**
* Pick the worst (highest product-impact) reported notice among sources.
* Equivalent kinds collapse to one; Local never masks a stronger disclosure.
*/
export function aggregateNotices(
notices: Array<DataAvailability | null | undefined>,
): DataAvailability | null {
let worst: DataAvailability | null = null
for (const notice of notices) {
if (!notice) continue
if (!worst || NOTICE_SEVERITY[notice] > NOTICE_SEVERITY[worst]) {
worst = notice
}
}
return worst
}
export function noticeMessage(kind: DataAvailability): string {
switch (kind) {
case 'stale':
return 'Showing stale cached data. Retry to refresh.'
case 'partial':
return 'Coverage is partial for this view.'
case 'local':
return 'Showing local season data.'
case 'limited':
return 'Some optional details are unavailable. Core data is still shown.'
case 'archive':
return 'Showing an archived snapshot.'
case 'missing':
return 'Some expected data is missing.'
}
}
/**
* Session whose state the Weekend shell is presenting — mirrors backend
* focusedContextSession so an older terminal session never overrides focus.
*/
export function focusedContextSession(context: WeekendContext): ContextSession | undefined {
if (context.active_session) return context.active_session
if (!context.focus_meeting) return undefined
const focusKey = context.focus_meeting.meeting_key
for (const ref of [
context.next_session,
context.previous_completed_session,
context.default_analysis_session,
]) {
if (ref?.meeting?.meeting_key === focusKey) return ref
// Session.meeting_key is always present even when meeting identity is sparse.
if (ref && ref.session.meeting_key === focusKey) return ref
}
return undefined
}
/**
* Weekend shell notice from authoritative response headers when present.
* A header that intentionally maps to no notice (e.g. focused local/local with
* Local suppressed) must not fall through to older previous-session archive/partial.
* When headers are absent, typed focus-session freshness may be used.
*/
export function weekendContextNotice(
context: WeekendContext,
responseMeta?: ResponseAvailability,
): DataAvailability | null {
const headerMeta = responseMeta ?? getResponseAvailability(context)
if (headerMeta) {
return noticeFromFreshness(headerMeta.freshness, { includeLocal: false })
}
const focus = focusedContextSession(context)
return noticeFromFreshness(focus?.availability.freshness, { includeLocal: false })
}
/**
* Embedded Preview should disclose its own freshness unless the Weekend shell
* already shows an equivalent notice (same DataAvailability kind).
*/
export function shouldShowEmbeddedNotice(
notice: DataAvailability | null | undefined,
shellNotice: DataAvailability | null | undefined,
): boolean {
if (!notice) return false
if (!shellNotice) return true
return notice !== shellNotice
}

353
frontend/src/lib/fetch.ts Normal file
View File

@@ -0,0 +1,353 @@
/** Bounded fetch helpers for primary-route resilience. */
import { replaceEqualDeep } from '@tanstack/react-query'
export const DEFAULT_FETCH_TIMEOUT_MS = 15_000
/** CORS-readable success provenance headers from the Go API. */
export const DATA_SOURCE_HEADER = 'X-BoxBox-Data-Source'
export const DATA_FRESHNESS_HEADER = 'X-BoxBox-Data-Freshness'
/** Reported response source values (`openf1|local|mixed`, plus `fia` for live). */
export type DataSourceHeader = 'openf1' | 'local' | 'mixed' | 'fia' | string
/** Reported freshness (`fresh|stale|local|partial`, plus live/archive/limited). */
export type DataFreshnessHeader =
| 'fresh'
| 'stale'
| 'local'
| 'partial'
| 'live'
| 'archive'
| 'limited'
| string
/** Additive success-response availability metadata (never inferred from React Query). */
export interface ResponseAvailability {
source?: DataSourceHeader
freshness?: DataFreshnessHeader
}
const responseAvailability = new WeakMap<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)
}
/** 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 class ApiError extends Error {
readonly kind: ApiErrorKind
readonly status?: number
readonly stale: boolean
readonly userMessage: string
constructor(
kind: ApiErrorKind,
message: string,
opts?: { status?: number; stale?: boolean; userMessage?: string },
) {
super(message)
this.name = 'ApiError'
this.kind = kind
this.status = opts?.status
this.stale = opts?.stale ?? false
this.userMessage = opts?.userMessage ?? defaultUserMessage(kind, opts?.stale)
}
}
function defaultUserMessage(kind: ApiErrorKind, stale?: boolean): string {
if (stale) {
return 'Showing cached data because a fresh update did not arrive. Retry to refresh.'
}
switch (kind) {
case 'timeout':
return 'This request took too long. Check your connection, then retry.'
case 'abort':
return 'This request was cancelled. Retry when you are ready.'
case 'network':
return 'Could not reach box-box. Check your connection, then retry.'
case 'http':
default:
return 'Something went wrong loading this view. Retry to try again.'
}
}
export function isApiError(err: unknown): err is ApiError {
return err instanceof ApiError
}
export function isTimeoutError(err: unknown): boolean {
return isApiError(err) && err.kind === 'timeout'
}
export function userFacingError(err: unknown): string {
if (isApiError(err)) return err.userMessage
if (err instanceof Error && err.message) {
// Never surface raw "API 500: Internal Server Error" style strings.
if (/^API\s+\d+/i.test(err.message) || /status\s+\d+/i.test(err.message)) {
return defaultUserMessage('http')
}
return err.message
}
return defaultUserMessage('http')
}
export interface ApiFetchOptions {
timeoutMs?: number
signal?: AbortSignal
/** When set, concurrent identical in-flight requests share one promise. */
dedupeKey?: string
method?: string
headers?: HeadersInit
body?: BodyInit | null
}
interface InflightEntry {
/** Shared underlying fetch — not tied to any single caller's AbortSignal. */
promise: Promise<unknown>
/** Aborts the shared request only when the last subscriber cancels. */
controller: AbortController
subscribers: number
}
const inflight = new Map<string, InflightEntry>()
function abortError(): ApiError {
return new ApiError('abort', 'Request aborted', {
userMessage: defaultUserMessage('abort'),
})
}
function combineAbortSignals(signals: AbortSignal[]): AbortSignal {
const controller = new AbortController()
const onAbort = () => {
const reason = signals.find((s) => s.aborted)?.reason
if (!controller.signal.aborted) {
controller.abort(reason)
}
}
for (const signal of signals) {
if (signal.aborted) {
onAbort()
break
}
signal.addEventListener('abort', onAbort, { once: true })
}
return controller.signal
}
async function parseErrorBody(res: Response): Promise<{ error?: string; stale?: boolean }> {
try {
const data = (await res.json()) as { error?: string; stale?: boolean }
return data ?? {}
} catch {
return {}
}
}
async function rawApiFetch<T>(url: string, options: ApiFetchOptions = {}): Promise<T> {
const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS
const timeoutController = new AbortController()
const timer = setTimeout(() => {
timeoutController.abort(new DOMException('Request timed out', 'TimeoutError'))
}, timeoutMs)
const signals = [timeoutController.signal]
if (options.signal) signals.push(options.signal)
const signal = combineAbortSignals(signals)
try {
let res: Response
try {
res = await fetch(url, {
method: options.method,
headers: options.headers,
body: options.body,
signal,
})
} catch (err) {
if (timeoutController.signal.aborted && !options.signal?.aborted) {
throw new ApiError('timeout', 'Request timed out', {
userMessage: defaultUserMessage('timeout'),
})
}
if (options.signal?.aborted || (err instanceof DOMException && err.name === 'AbortError')) {
throw abortError()
}
throw new ApiError('network', err instanceof Error ? err.message : 'Network error', {
userMessage: defaultUserMessage('network'),
})
}
if (!res.ok) {
const body = await parseErrorBody(res)
throw new ApiError('http', body.error || `HTTP ${res.status}`, {
status: res.status,
stale: Boolean(body.stale),
userMessage: body.stale
? defaultUserMessage('http', true)
: defaultUserMessage('http'),
})
}
const data = (await res.json()) as T
captureResponseAvailability(data, res.headers)
return data
} finally {
clearTimeout(timer)
}
}
/**
* Attach a caller to a shared in-flight request.
* One caller's abort rejects only that caller; the shared fetch continues
* while other subscribers remain, and is cancelled only when the last one leaves.
*/
function subscribeToInflight<T>(
key: string,
entry: InflightEntry,
callerSignal?: AbortSignal,
): Promise<T> {
if (callerSignal?.aborted) {
return Promise.reject(abortError())
}
entry.subscribers += 1
return new Promise<T>((resolve, reject) => {
let settled = false
const leave = () => {
entry.subscribers -= 1
if (entry.subscribers <= 0) {
if (!entry.controller.signal.aborted) {
entry.controller.abort()
}
if (inflight.get(key) === entry) {
inflight.delete(key)
}
}
}
const onCallerAbort = () => {
if (settled) return
settled = true
leave()
reject(abortError())
}
if (callerSignal) {
callerSignal.addEventListener('abort', onCallerAbort, { once: true })
}
entry.promise.then(
(value) => {
if (settled) return
settled = true
callerSignal?.removeEventListener('abort', onCallerAbort)
resolve(value as T)
},
(err) => {
if (settled) return
settled = true
callerSignal?.removeEventListener('abort', onCallerAbort)
reject(err)
},
)
})
}
/**
* Fetch JSON with a bounded timeout, abort propagation, typed errors,
* and optional in-flight deduplication (same key shares one underlying request).
*
* Deduped requests are subscriber-safe: aborting one consumer does not cancel
* or poison other consumers that still hold the same key (React Strict Mode remounts).
*/
export function apiFetch<T>(url: string, options: ApiFetchOptions = {}): Promise<T> {
const key = options.dedupeKey
if (!key) {
return rawApiFetch<T>(url, options)
}
if (options.signal?.aborted) {
return Promise.reject(abortError())
}
let entry = inflight.get(key)
if (!entry) {
const controller = new AbortController()
// Shared fetch only listens to the ref-counted controller + timeout —
// never to any single caller's AbortSignal.
const promise = rawApiFetch<T>(url, {
...options,
signal: controller.signal,
}).finally(() => {
if (inflight.get(key)?.promise === promise) {
inflight.delete(key)
}
})
entry = { promise, controller, subscribers: 0 }
inflight.set(key, entry)
}
return subscribeToInflight<T>(key, entry, options.signal)
}
/** Test helper — clears the in-flight dedupe map. */
export function clearApiFetchInflight(): void {
inflight.clear()
}

View File

@@ -268,63 +268,6 @@ export function driverCode(row: LiveTimingRow): string {
return row.Info?.Tla || row.RacingNumber return row.Info?.Tla || row.RacingNumber
} }
/**
* Parse an F1 lap-time string ("1:45.944", "45.944") into total seconds.
* Returns null for empty/invalid values so callers never invent a gap.
*/
export function parseLapTimeSeconds(value: string | null | undefined): number | null {
if (!value) return null
const match = value.trim().match(/^(?:(\d+):)?([0-5]?\d(?:\.\d+)?)$/)
if (!match) return null
const minutes = match[1] ? Number(match[1]) : 0
const seconds = Number(match[2])
if (!Number.isFinite(minutes) || !Number.isFinite(seconds)) return null
return minutes * 60 + seconds
}
export interface BestLapGap {
isLeader: boolean
gap: string
}
/**
* Display-only gap-to-P1 for practice/qualifying, derived from each driver's
* valid best lap. Only drivers with a parseable best lap get an entry, and the
* fastest is flagged as the leader. Never fabricates a gap from a missing or
* invalid lap — the upstream interval remains the source of truth for races.
*/
export function bestLapGaps(rows: ReadonlyArray<LiveTimingRow>): Record<string, BestLapGap> {
let leaderNumber = ''
let best = Infinity
for (const row of rows) {
const seconds = parseLapTimeSeconds(row.Driver.BestLapTime)
if (seconds === null) continue
if (seconds < best) {
best = seconds
leaderNumber = row.RacingNumber
}
}
const out: Record<string, BestLapGap> = {}
if (!Number.isFinite(best)) return out
for (const row of rows) {
const seconds = parseLapTimeSeconds(row.Driver.BestLapTime)
if (seconds === null) continue
out[row.RacingNumber] =
row.RacingNumber === leaderNumber
? { isLeader: true, gap: '' }
: { isLeader: false, gap: `+${(seconds - best).toFixed(3)}` }
}
return out
}
/** True when the live feed reports an in-progress session with timing data. */
export function isLiveSessionActive(
state: { is_live?: boolean; data?: unknown } | null | undefined,
): boolean {
return Boolean(state?.is_live && state.data)
}
export function trackStatusLabel(status: string): string { export function trackStatusLabel(status: string): string {
return TRACK_STATUS_LABELS[status] || status || 'UNKNOWN' return TRACK_STATUS_LABELS[status] || status || 'UNKNOWN'
} }

View File

@@ -0,0 +1,168 @@
// Live timing state model.
//
// The Live page has to hold four *orthogonal* concepts that used to be
// conflated into a single "connected / offline" flag:
//
// 1. transport health — is the SSE stream up? (connecting/connected/…)
// 2. active session — is a session actually running right now?
// 3. archive availability — do we retain a final snapshot to inspect?
// 4. analysis readiness — is the completed session ingested for Race Hub?
//
// `deriveLivePhase` collapses the first three inputs into a single UI phase so
// the page never, for example, calls a finished session "offline" or lets a
// dropped socket masquerade as "archive mode".
//
// Critical distinction (issue #74 P0): the server's `deactivate()` path moves a
// STILL-ACTIVE snapshot into `last_snapshot` whenever the upstream FIA SignalR
// connection ends or idles — WITHOUT changing SessionStatus. So `is_live=false`
// alone is ambiguous. We rely on the retained snapshot's terminal SessionStatus
// (Finished/Ended/…) as the only trustworthy "the session really ended" signal.
// A non-terminal retained snapshot means the feed dropped, not that the session
// finished, and must render as `disconnected` (retain snapshot, warn, recover),
// never `settling`/archive.
export type TransportHealth = 'connecting' | 'connected' | 'disconnected' | 'error'
export type LivePhase =
| 'connecting' // cold start: no snapshot yet, still opening the feed
| 'live' // a session is running and streaming
| 'disconnected' // was live, transport/feed dropped — keep the last snapshot, warn
| 'settling' // session truly ended; a final snapshot is retained, analysis pending
| 'archive' // user opened the retained snapshot as an explicit read-only view
| 'inactive' // no session and nothing retained — show weekend context instead
export interface LiveStateInputs {
transport: TransportHealth
/** The feed reports an active session AND carries a snapshot for it. */
isLive: boolean
hasActiveSnapshot: boolean
/** A retained snapshot from the last stream is held (may be a dropped feed). */
hasArchive: boolean
/** The user explicitly opened the archive as a read-only timing view. */
archiveMode: boolean
/**
* The retained snapshot ended on a *terminal* FIA SessionStatus. Only true
* here means the session genuinely finished (vs. the transport dropping).
*/
sessionEndedCleanly: boolean
/** We have observed this session live at least once in this page session. */
wasLive: boolean
/**
* The initial `/api/v1/live/state` query has resolved. `connecting` is a
* cold-start-only phase: once we know the current state (even if the SSE
* transport handshake is still pending), an idle feed is `inactive`, not a
* perpetual "connecting…" spinner.
*/
stateLoaded: boolean
}
// Raw FIA SessionStatus values that mean the session is genuinely over. Mirrors
// the backend `terminalSessionStatus` used by the weekend-context resolver so
// the client and server agree on "finished".
const TERMINAL_STATUSES = new Set(['finished', 'finalised', 'finalized', 'ended', 'aborted'])
function normalizeStatus(status: string | undefined | null): string {
if (!status) return ''
return status.replace(/[^a-zA-Z]/g, '').toLowerCase()
}
/** Whether a raw FIA SessionStatus represents a genuinely completed session. */
export function terminalSessionStatus(status: string | undefined | null): boolean {
return TERMINAL_STATUSES.has(normalizeStatus(status))
}
export function transportDown(transport: TransportHealth): boolean {
return transport === 'disconnected' || transport === 'error'
}
/**
* Map transport + session + archive inputs to one UI phase. Session lifecycle
* and transport health are deliberately orthogonal: a live session with a
* dropped socket is `disconnected` (snapshot retained), never `archive` or
* `settling`.
*/
export function deriveLivePhase(input: LiveStateInputs): LivePhase {
const {
transport,
isLive,
hasActiveSnapshot,
hasArchive,
archiveMode,
sessionEndedCleanly,
stateLoaded,
} = input
// Explicit read-only archive wins — it is a user-chosen mode.
if (archiveMode && hasArchive) return 'archive'
// An active session: transport health only downgrades the *presentation*,
// it never removes the session.
if (isLive && hasActiveSnapshot) {
return transportDown(transport) ? 'disconnected' : 'live'
}
// Feed reports no active session but a snapshot is retained. Distinguish a
// genuine session end from an upstream feed drop using the *only* trustworthy
// signal: whether the retained snapshot carries a terminal SessionStatus.
if (hasArchive) {
if (sessionEndedCleanly) return 'settling'
// Non-terminal retained snapshot means the upstream feed dropped, not that
// the session finished. Hold the last frame and warn — never settle/archive.
return 'disconnected'
}
// Cold start only: still opening the feed and we have not yet learned the
// current state. Once the initial state query resolves, an idle feed is
// `inactive`, not a perpetual "connecting…".
if (transport === 'connecting' && !stateLoaded) return 'connecting'
return 'inactive'
}
/** Phases that render the live timing tower / snapshot surface. */
export function rendersSnapshot(phase: LivePhase): boolean {
return phase === 'live' || phase === 'disconnected' || phase === 'archive'
}
/**
* Whether live-only interpretations (pit-window rejoin, tyre-deg slope, "what
* just happened" deltas) are meaningful. They require a moving session, so an
* archived single frame must never present them as current insight.
*/
export function allowsLiveInterpretations(phase: LivePhase): boolean {
return phase === 'live' || phase === 'disconnected'
}
/** Read-only, timestamped phases that must not show live/connected chrome. */
export function isReadOnlyPhase(phase: LivePhase): boolean {
return phase === 'archive'
}
/**
* Fan-facing feed health. Browser SSE can stay `connected` after an upstream
* FIA drop leaves us in `disconnected` with a retained non-terminal snapshot —
* present that as reconnecting, never "Feed healthy" beside "Connection lost".
*/
export function effectiveFeedHealth(
transport: TransportHealth,
phase: LivePhase,
): TransportHealth {
if (phase === 'disconnected' && !transportDown(transport)) {
return 'disconnected'
}
return transport
}
/** Short, human transport-health label — always secondary to session state. */
export function feedHealthLabel(transport: TransportHealth): string {
switch (transport) {
case 'connected':
return 'Feed healthy'
case 'connecting':
return 'Connecting'
case 'disconnected':
return 'Reconnecting'
case 'error':
return 'Feed unavailable'
}
}

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

@@ -0,0 +1,40 @@
/** Shared URL search contracts for deep-linkable Weekend ↔ Race Hub navigation. */
export type WeekendSearch = {
meeting_key?: number
session_key?: number
}
export type RaceHubSearch = {
session_key?: number
}
function parsePositiveInt(value: unknown): number | undefined {
const n = Number(value)
return Number.isFinite(n) && n > 0 ? n : undefined
}
export function parseWeekendSearch(search: Record<string, unknown>): WeekendSearch {
const meeting_key = parsePositiveInt(search.meeting_key)
const session_key = parsePositiveInt(search.session_key)
return {
...(meeting_key ? { meeting_key } : {}),
...(session_key ? { session_key } : {}),
}
}
export function parseRaceHubSearch(search: Record<string, unknown>): RaceHubSearch {
const session_key = parsePositiveInt(search.session_key)
return session_key ? { session_key } : {}
}
/** Build Weekend `/` search so a Race Hub return is reload-safe and deep-linkable. */
export function weekendFocusSearch(
meetingKey?: number | null,
sessionKey?: number | null,
): WeekendSearch {
return {
...(meetingKey && meetingKey > 0 ? { meeting_key: meetingKey } : {}),
...(sessionKey && sessionKey > 0 ? { session_key: sessionKey } : {}),
}
}

View File

@@ -0,0 +1,194 @@
import type { ContextSession, WeekendContext, WeekendSession } from '../types'
import { isSessionComplete } from './coverage'
import { sessionStartTime } from './schedule'
/**
* User-facing lifecycle state for a weekend session.
*
* Live comes only from Weekend Context's FIA-backed active identity — never from
* the scheduled start/end window alone. Preparing / partial / ready / unavailable
* come from structured `availability.local_analysis` when a context ref exists,
* otherwise from local dataset coverage after the scheduled start.
*/
export type SessionState =
| 'upcoming'
| 'live'
| 'preparing'
| 'partial'
| 'ready'
| 'unavailable'
| 'cancelled'
export interface SessionStateInput {
weekendSession?: WeekendSession
context?: WeekendContext | null
now: Date
}
function contextRefFor(
context: WeekendContext | null | undefined,
sessionKey: number | undefined,
): ContextSession | undefined {
if (!context || !sessionKey) return undefined
const refs = [
context.active_session,
context.default_analysis_session,
context.previous_completed_session,
context.next_session,
]
return refs.find((ref) => ref?.session.session_key === sessionKey)
}
function fromAvailability(ref: ContextSession): SessionState | undefined {
const { schedule, live_session, local_analysis } = ref.availability
if (live_session === 'active') return 'live'
if (schedule === 'unavailable' || local_analysis === 'unavailable') return 'unavailable'
if (local_analysis === 'not_applicable') return 'upcoming'
if (local_analysis === 'pending') return 'preparing'
if (local_analysis === 'partial') return 'partial'
if (local_analysis === 'complete') return 'ready'
return undefined
}
/**
* Resolve fan-facing session state. Prefer Weekend Context availability; never
* assert Live from wall-clock schedule alone.
*/
export function resolveSessionState({
weekendSession,
context,
now,
}: SessionStateInput): SessionState {
if (weekendSession?.source === 'cancelled') return 'cancelled'
const sessionKey = weekendSession?.session.session_key
const active = context?.active_session
if (
active &&
sessionKey &&
active.session.session_key === sessionKey &&
active.availability.live_session === 'active'
) {
return 'live'
}
const ref = contextRefFor(context, sessionKey)
if (ref) {
const fromCtx = fromAvailability(ref)
if (fromCtx) return fromCtx
}
// Settling temporal state with no analysis yet — even without a matching ref.
if (
context?.temporal_state === 'session_settling' &&
weekendSession &&
weekendSession.source === 'none'
) {
return 'preparing'
}
if (!weekendSession) return 'unavailable'
const start = sessionStartTime(weekendSession.session)
if (start && start > now) return 'upcoming'
if (isSessionComplete(weekendSession.datasets)) return 'ready'
if (weekendSession.source === 'none') return 'preparing'
if (weekendSession.source === 'partial') return 'partial'
return 'ready'
}
/** @deprecated Prefer resolveSessionState with Weekend Context. */
export function sessionState(session: WeekendSession, now: Date): SessionState {
return resolveSessionState({ weekendSession: session, now })
}
/** Short label suitable for chips and the session switcher. */
export function sessionStateLabel(state: SessionState): string {
switch (state) {
case 'upcoming':
return 'Upcoming'
case 'live':
return 'Live'
case 'preparing':
return 'Preparing'
case 'partial':
return 'Partial'
case 'ready':
return 'Ready'
case 'unavailable':
return 'Unavailable'
case 'cancelled':
return 'Cancelled'
}
}
/** Longer, sentence-style description for headers and empty states. */
export function sessionStateDescription(state: SessionState): string {
switch (state) {
case 'upcoming':
return 'Session has not started yet.'
case 'live':
return 'Session is running now.'
case 'preparing':
return 'Analysis is being prepared — local data is still settling.'
case 'partial':
return 'Partial analysis available — some datasets are still missing.'
case 'ready':
return 'Full analysis is ready.'
case 'unavailable':
return 'Analysis is not available for this session.'
case 'cancelled':
return 'This session was cancelled.'
}
}
/**
* Class suffix used for the coverage dot, so the rail can colour a session by
* its lifecycle state rather than only by data source.
*/
export function sessionStateDotClass(state: SessionState): string {
switch (state) {
case 'ready':
return 'rh-state-ready'
case 'partial':
return 'rh-state-partial'
case 'live':
return 'rh-state-live'
case 'upcoming':
return 'rh-state-upcoming'
case 'unavailable':
return 'rh-state-unavailable'
case 'cancelled':
return 'rh-state-cancelled'
default:
return 'rh-state-preparing'
}
}
/** Whether a session should render the pre-session (expected availability) view. */
export function isPreSession(state: SessionState): boolean {
return state === 'upcoming'
}
/** Settling / empty post-session — not yet analysable. */
export function isPreparing(state: SessionState): boolean {
return state === 'preparing'
}
export function isUnavailable(state: SessionState): boolean {
return state === 'unavailable' || state === 'cancelled'
}
/** Show fan analysis with a partial banner; keep available capabilities. */
export function isPartialAnalysis(state: SessionState): boolean {
return state === 'partial'
}
/** Canonical default analysis session key from Weekend Context, if any. */
export function defaultAnalysisSessionKey(
context: WeekendContext | null | undefined,
): number | undefined {
const key = context?.default_analysis_session?.session.session_key
return key && key > 0 ? key : undefined
}

View File

@@ -0,0 +1,181 @@
import type {
ChampionshipHub,
ContextSession,
EnrichedResult,
Meeting,
NewsItem,
Session,
WeekendBriefingItem,
WeekendChampionshipImpact,
WeekendChampionshipMover,
WeekendContext,
WeekendPodiumEntry,
WeekendViewState,
} from '../types'
/**
* resolveViewState maps the canonical `temporal_state` onto the rendered Weekend
* view. It is a total function: every valid canonical `temporal_state` resolves
* to a concrete view, so a well-formed payload never falls through to a
* limited/empty placeholder. Loading and error are hook-level states that are
* not part of the #72 contract.
*/
export function resolveViewState(context: WeekendContext): WeekendViewState {
switch (context.temporal_state) {
case 'no_season':
return 'no_season'
case 'between_weekends':
return 'between_weekends'
case 'pre_session':
return 'pre_session'
case 'session_live':
return 'session_live'
case 'session_settling':
return 'session_settling'
case 'between_sessions':
return 'between_sessions'
case 'post_weekend':
return 'post_weekend'
case 'season_complete':
return 'season_complete'
default:
// Unknown/absent temporal_state is an invalid payload — treat as no_season
// rather than inventing a state. The hook still shows an explicit surface.
return 'no_season'
}
}
export interface MeetingIdentity {
meeting_key: number
meeting_name: string
short_name: string
country_code: string
country_flag: string
circuit_short_name: string
circuit_key?: number
date_start?: string
date_end?: string
}
/** Presentation identity for a meeting, tolerant of partial/absent fields. */
export function meetingIdentity(meeting: Meeting | undefined): MeetingIdentity | undefined {
if (!meeting) return undefined
return {
meeting_key: meeting.meeting_key,
meeting_name: meeting.meeting_name || meeting.location || 'Grand Prix',
short_name: (meeting.meeting_name || meeting.location || 'Grand Prix').replace(/ Grand Prix$/i, ''),
country_code: meeting.country_code,
country_flag: meeting.country_flag,
circuit_short_name: meeting.circuit_short_name || meeting.location || '',
circuit_key: meeting.circuit_key,
date_start: meeting.date_start || undefined,
date_end: meeting.date_end || undefined,
}
}
/** Session identity paired with its resolved meeting for a canonical ContextSession. */
export function sessionMeeting(ref: ContextSession | undefined): Meeting | undefined {
return ref?.meeting
}
export function sessionOf(ref: ContextSession | undefined): Session | undefined {
return ref?.session
}
/**
* analysisSessionKey resolves the session a user should open to read analysis for
* a completed event. It prefers the canonical default analysis session, falling
* back to the previous completed session. Returns undefined when nothing analysable
* is available (e.g. an archived result with no local analysis).
*/
export function analysisSessionKey(context: WeekendContext): number | undefined {
const key =
context.default_analysis_session?.session.session_key ??
context.previous_completed_session?.session.session_key
return key && key > 0 ? key : undefined
}
/** True when a ContextSession has local analysis worth linking to. */
export function hasLocalAnalysis(ref: ContextSession | undefined): boolean {
const status = ref?.availability.local_analysis
return status === 'complete' || status === 'partial'
}
/** Countdown target for a session reference — its scheduled start, if known. */
export function sessionStart(ref: ContextSession | undefined): string | undefined {
const start = ref?.session.date_start
return start ? start : undefined
}
export function podiumFromResults(results: EnrichedResult[]): WeekendPodiumEntry[] {
return [...results]
.filter((r) => r.position >= 1)
.sort((a, b) => a.position - b.position)
.slice(0, 3)
.map((r) => ({
position: r.position,
driver_number: r.driver_number,
name_acronym: r.name_acronym,
team_name: r.team_name,
team_colour: r.team_colour,
gap: formatResultGap(r),
}))
}
function formatResultGap(r: EnrichedResult): string {
if (r.dnf) return 'DNF'
if (r.dns) return 'DNS'
if (r.dsq) return 'DSQ'
if (r.position === 1) {
if (typeof r.duration === 'number' && r.duration > 0) return formatDuration(r.duration)
return 'Winner'
}
const gap = Array.isArray(r.gap_to_leader) ? r.gap_to_leader[r.gap_to_leader.length - 1] : r.gap_to_leader
if (typeof gap === 'number') return `+${gap.toFixed(3)}`
if (typeof gap === 'string' && gap) return gap.startsWith('+') ? gap : `+${gap}`
return '—'
}
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${s.toFixed(3).padStart(6, '0')}`
return `${m}:${s.toFixed(3).padStart(6, '0')}`
}
/**
* championshipImpact derives the top-of-standings movers from the championship
* hub. This is supplementary data layered onto the canonical context, not part of
* the #72 contract.
*/
export function championshipImpact(hub: ChampionshipHub | undefined): WeekendChampionshipImpact | undefined {
if (!hub || hub.drivers.length === 0) return undefined
const leaders: WeekendChampionshipMover[] = hub.drivers.slice(0, 3).map((d) => {
const cumulative = d.cumulative ?? []
const delta =
cumulative.length >= 2 ? cumulative[cumulative.length - 1] - cumulative[cumulative.length - 2] : undefined
return {
position: d.position,
driver_number: d.driver_number,
name_acronym: d.name_acronym,
team_colour: d.team_colour,
points: d.points,
delta,
}
})
const note = hub.last_race ? `Standings after ${hub.last_race}.` : undefined
return { leaders, note }
}
/** At most three briefing items, mapped from the news feed. */
export function briefingItems(news: NewsItem[]): WeekendBriefingItem[] {
return news.slice(0, 3).map((item) => ({
category: item.category,
title: item.title,
url: item.url,
source: item.source,
published_at: item.published_at,
image_url: item.og_image_url,
}))
}

View File

@@ -1,18 +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,
retry: 1,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>

View File

@@ -24,6 +24,8 @@ import {
} from '../lib/digest' } from '../lib/digest'
import { stripHtml, timeAgo } from '../utils' import { stripHtml, timeAgo } from '../utils'
import type { ArticleContent, NewsItem } from '../types' import type { ArticleContent, NewsItem } from '../types'
import { DataNotice, RouteState } from '../components/RouteState'
import { aggregateNotices, noticeFromResponse, noticeMessage } from '../lib/availability'
import '../styles/digest.css' import '../styles/digest.css'
type Category = 'all' | 'official' | 'news' | 'video' type Category = 'all' | 'official' | 'news' | 'video'
@@ -406,27 +408,27 @@ export function BriefingPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const now = useMemo(() => new Date(), []) const now = useMemo(() => new Date(), [])
const { data: allNews = [], isLoading, isError } = useQuery({ const { data: allNews = [], isLoading, isError, error, refetch, isFetching } = useQuery({
queryKey: ['news'], queryKey: ['news'],
queryFn: () => fetchNews(100), queryFn: ({ signal }) => fetchNews(100, undefined, signal),
staleTime: 60_000, staleTime: 60_000,
}) })
const seasonsQuery = useQuery({ const seasonsQuery = useQuery({
queryKey: ['seasons'], queryKey: ['seasons'],
queryFn: fetchSeasons, queryFn: ({ signal }) => fetchSeasons(signal),
}) })
const latestSeason = seasonsQuery.data?.[0] ?? null const latestSeason = seasonsQuery.data?.[0] ?? null
const meetingsQuery = useQuery({ const meetingsQuery = useQuery({
queryKey: ['season-meetings', latestSeason], queryKey: ['season-meetings', latestSeason],
queryFn: () => fetchSeasonMeetings(latestSeason!), queryFn: ({ signal }) => fetchSeasonMeetings(latestSeason!, signal),
enabled: latestSeason != null, enabled: latestSeason != null,
}) })
const hubQuery = useQuery({ const hubQuery = useQuery({
queryKey: ['championship-hub', latestSeason], queryKey: ['championship-hub', latestSeason],
queryFn: () => fetchChampionshipHub(latestSeason!), queryFn: ({ signal }) => fetchChampionshipHub(latestSeason!, signal),
enabled: latestSeason != null, enabled: latestSeason != null,
}) })
@@ -515,6 +517,43 @@ 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 =
!isLoading &&
!isError &&
(supplementDegraded(meetingsQuery) ||
supplementDegraded(hubQuery) ||
supplementDegraded(seasonsQuery))
const supplementsRetrying = supplementsLimited && supplementsFetching
const retrySupplements = () => {
if (supplementDegraded(meetingsQuery) && !meetingsQuery.isFetching) {
void meetingsQuery.refetch()
}
if (supplementDegraded(hubQuery) && !hubQuery.isFetching) {
void hubQuery.refetch()
}
if (supplementDegraded(seasonsQuery) && !seasonsQuery.isFetching) {
void seasonsQuery.refetch()
}
}
// Aggregate by severity so routine local News cannot mask stale hub/meetings.
const availability = aggregateNotices([
noticeFromResponse(allNews, { includeLocal: true }),
noticeFromResponse(hub, { includeLocal: false }),
noticeFromResponse(meetings, { includeLocal: false }),
])
return ( return (
<div className="bp-page" data-testid="briefing-page"> <div className="bp-page" data-testid="briefing-page">
<div className="bp-topbar"> <div className="bp-topbar">
@@ -524,11 +563,45 @@ export function BriefingPage() {
</span> </span>
</div> </div>
{isLoading && <div className="loading-state">loading briefing</div>} {isLoading && (
{isError && <div className="error-box">Failed to load paddock briefing.</div>} <RouteState kind="loading" title="loading briefing…" testId="briefing-loading" />
)}
{isError && (
<RouteState
kind="error"
title="Briefing unavailable"
error={error}
onRetry={() => {
if (!isFetching) void refetch()
}}
retrying={isFetching}
testId="briefing-error"
/>
)}
{!isLoading && !isError && ( {!isLoading && !isError && (
<> <>
{supplementsLimited && (
<DataNotice
availability="limited"
message="Weekend grouping or driver tags are limited. Articles are still available."
onRetry={retrySupplements}
retrying={supplementsRetrying}
testId="briefing-data-notice"
/>
)}
{!supplementsLimited && availability && (
<DataNotice
availability={availability}
message={noticeMessage(availability)}
onRetry={() => {
if (!isFetching) void refetch()
}}
retrying={isFetching}
testId="briefing-data-notice"
/>
)}
<CategoryTabs <CategoryTabs
active={activeCategory} active={activeCategory}
counts={counts} counts={counts}

View File

@@ -7,7 +7,9 @@ import type { ChampHubDriver, ChampionshipHub } from '../types'
import { ChampionshipSimulator } from '../components/ChampionshipSimulator' import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
import { RivalryCompare } from '../components/RivalryCompare' import { RivalryCompare } from '../components/RivalryCompare'
import { Meaning } from '../components/Meaning' import { Meaning } from '../components/Meaning'
import { DataNotice, RouteState } from '../components/RouteState'
import { TeammateH2H } from '../components/TeammateH2H' import { TeammateH2H } from '../components/TeammateH2H'
import { noticeFromResponse, noticeMessage } from '../lib/availability'
import { teammatePairs } from '../lib/h2h' import { teammatePairs } from '../lib/h2h'
import { pointsGapMeaning } from '../lib/meaning' import { pointsGapMeaning } from '../lib/meaning'
@@ -81,23 +83,55 @@ function teamSplit(teamName: string, drivers: ChampHubDriver[]): TeamSplit {
export function ChampionshipPage() { export function ChampionshipPage() {
const [view, setView] = useState<View>('drivers') const [view, setView] = useState<View>('drivers')
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons }) const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: ({ signal }) => fetchSeasons(signal),
})
const latestSeason = seasonsQuery.data?.[0] ?? null const latestSeason = seasonsQuery.data?.[0] ?? null
const hubQuery = useQuery({ const hubQuery = useQuery({
queryKey: ['championship-hub', latestSeason], queryKey: ['championship-hub', latestSeason],
queryFn: () => fetchChampionshipHub(latestSeason ?? undefined), queryFn: ({ signal }) => fetchChampionshipHub(latestSeason ?? undefined, signal),
enabled: latestSeason != null, enabled: latestSeason != null,
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
}) })
if (seasonsQuery.isLoading || hubQuery.isLoading) { if (seasonsQuery.isLoading || hubQuery.isLoading) {
return <div className="page loading-state">loading championship</div> return (
<div className="page">
<RouteState kind="loading" title="loading championship…" testId="championship-loading" />
</div>
)
}
if (seasonsQuery.isError) {
return (
<div className="page">
<RouteState
kind="error"
title="Championship unavailable"
error={seasonsQuery.error}
onRetry={() => {
if (!seasonsQuery.isFetching) void seasonsQuery.refetch()
}}
retrying={seasonsQuery.isFetching}
testId="championship-error"
/>
</div>
)
} }
if (hubQuery.isError) { if (hubQuery.isError) {
return ( return (
<div className="page error-box"> <div className="page">
{hubQuery.error instanceof Error ? hubQuery.error.message : 'Failed to load championship'} <RouteState
kind="error"
title="Championship unavailable"
error={hubQuery.error}
onRetry={() => {
if (!hubQuery.isFetching) void hubQuery.refetch()
}}
retrying={hubQuery.isFetching}
testId="championship-error"
/>
</div> </div>
) )
} }
@@ -118,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 { interface BodyProps {
hub: ChampionshipHub hub: ChampionshipHub
view: View view: View
setView: (v: View) => void 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 { drivers, teams } = hub
const leader = drivers[0] const leader = drivers[0]
const remaining = hub.rounds_left * 25 const remaining = hub.rounds_left * 25
@@ -183,6 +233,15 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
return ( return (
<div className="champ-page" data-testid="championship"> <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-header">
<div className="champ-title-row"> <div className="champ-title-row">
<span className="champ-accent" aria-hidden="true" /> <span className="champ-accent" aria-hidden="true" />

View File

@@ -1,451 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { useQueries, useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import {
fetchChampionshipHub,
fetchLiveState,
fetchLocalMeetings,
fetchRaceHub,
fetchSeasonMeetings,
fetchSeasons,
fetchSessions,
fetchWeekend,
} from '../api'
import { CommandCenterHero } from '../components/CommandCenterHero'
import { PaddockBriefing } from '../components/PaddockBriefing'
import { RACE_HUB_DATASETS, countWeekendStats, formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
import { countryAccent, countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
import { classifySessionStatus, heroState } from '../lib/hero'
import {
currentAndNextSession,
focusMeetingKind,
formatSessionScheduleTime,
meetingHasStarted,
mostRecentPastMeeting,
nextUpcomingMeeting,
pickFocusMeeting,
sortSessionsByStart,
} from '../lib/schedule'
import type { Meeting, Session, Weekend, WeekendSession } from '../types'
import { Trophy } from 'lucide-react'
const missingDatasets = Object.fromEntries(
RACE_HUB_DATASETS.map((dataset) => [dataset, { status: 'missing', source: 'none', count: 0 }]),
) as WeekendSession['datasets']
function meetingStatus(meeting: Meeting, focusKey: number | undefined, now: Date) {
if (meeting.meeting_key === focusKey) return 'focus'
if (meetingHasStarted(meeting, now)) return 'past'
return 'future'
}
function pickAnalysisSession(weekend: Weekend | undefined): WeekendSession | undefined {
if (!weekend) return undefined
const local = weekend.sessions.filter((s) => s.source === 'local')
const partial = weekend.sessions.filter((s) => s.source === 'partial')
const pool = local.length > 0 ? local : partial.length > 0 ? partial : weekend.sessions
const race = pool.find((s) => s.session.session_type?.toLowerCase().includes('race'))
if (race) return race
const qual = pool.find((s) => s.session.session_type?.toLowerCase().includes('qualifying'))
if (qual) return qual
return pool[0]
}
export function CommandCenterPage() {
const [now, setNow] = useState(() => Date.now())
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: fetchSeasons,
})
const latestSeason = seasonsQuery.data?.[0] ?? null
const meetingsQuery = useQuery({
queryKey: ['meetings', latestSeason],
queryFn: () => fetchLocalMeetings(latestSeason!),
enabled: latestSeason != null,
})
const seasonMeetingsQuery = useQuery({
queryKey: ['season-meetings', latestSeason],
queryFn: () => fetchSeasonMeetings(latestSeason!),
enabled: latestSeason != null,
})
const localMeetings = meetingsQuery.data ?? []
const seasonMeetings = seasonMeetingsQuery.data?.length ? seasonMeetingsQuery.data : localMeetings
const focusMeetings = seasonMeetings.length > 0 ? seasonMeetings : localMeetings
const weekendQueries = useQueries({
queries: localMeetings.map((meeting) => ({
queryKey: ['weekend', meeting.meeting_key],
queryFn: () => fetchWeekend(meeting.meeting_key),
enabled: localMeetings.length > 0,
staleTime: 60_000,
})),
})
const liveQuery = useQuery({
queryKey: ['live-state'],
queryFn: fetchLiveState,
staleTime: 5_000,
})
const championshipQuery = useQuery({
queryKey: ['championship-hub', latestSeason],
queryFn: () => fetchChampionshipHub(latestSeason!),
enabled: latestSeason != null,
})
const champHub = championshipQuery.data
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [])
const nowDate = useMemo(() => new Date(now), [now])
const weekendsByKey = useMemo(() => {
const map = new Map<number, Weekend>()
localMeetings.forEach((meeting, i) => {
const data = weekendQueries[i]?.data
if (data) map.set(meeting.meeting_key, data)
})
return map
}, [localMeetings, weekendQueries])
const weekendList = useMemo(() => weekendQueries.map((q) => q.data), [weekendQueries])
const meetingStats = countWeekendStats(weekendList)
const focusMeeting = pickFocusMeeting(focusMeetings, nowDate)
const focusWeekend = focusMeeting ? weekendsByKey.get(focusMeeting.meeting_key) : undefined
const openF1SessionsQuery = useQuery({
queryKey: ['sessions', focusMeeting?.meeting_key, 'openf1'],
queryFn: () => fetchSessions(focusMeeting!.meeting_key, 'openf1'),
enabled: focusMeeting != null && focusWeekend == null,
staleTime: 60_000,
})
const openF1WeekendSessions: WeekendSession[] = useMemo(
() =>
(openF1SessionsQuery.data ?? []).map((session) => ({
session,
source: 'none',
datasets: missingDatasets,
})),
[openF1SessionsQuery.data],
)
const focusWeekendSessions = focusWeekend?.sessions ?? openF1WeekendSessions
const focusKind = focusMeeting ? focusMeetingKind(focusMeeting, nowDate) : null
const focusSessions: Session[] = sortSessionsByStart(focusWeekendSessions.map((s) => s.session))
const { current: currentSession, next: nextSession } = currentAndNextSession(focusSessions, nowDate)
const analysisSession = pickAnalysisSession(focusWeekend)
const actionSession =
analysisSession?.session ??
currentSession ??
nextSession ??
focusWeekendSessions.find((s) => s.session.session_key === focusWeekend?.default_session_key)?.session ??
focusWeekendSessions[0]?.session
const analysisSessionKey = actionSession?.session_key
const liveActive = liveQuery.data?.is_live === true
const heroStateKind = heroState({
now: nowDate,
liveActive,
currentSession,
focusKind,
})
const lastPastMeeting = useMemo(
() => mostRecentPastMeeting(focusMeetings, nowDate),
[focusMeetings, nowDate],
)
const lastPastWeekend = lastPastMeeting ? weekendsByKey.get(lastPastMeeting.meeting_key) : undefined
const lastRaceAnalysis = pickAnalysisSession(lastPastWeekend)
const lastRaceSessionKey = lastRaceAnalysis?.session.session_key
const lastRaceHubQuery = useQuery({
queryKey: ['race-hub', lastRaceSessionKey, 'hero-podium'],
queryFn: () => fetchRaceHub(lastRaceSessionKey!),
enabled: lastRaceSessionKey != null && heroStateKind === 'between',
staleTime: 60_000,
})
const nextMeetingForHero = useMemo(() => {
if (heroStateKind !== 'between') return null
return nextUpcomingMeeting(focusMeetings, nowDate)
}, [heroStateKind, focusMeetings, nowDate])
const lastRacePodium = lastRaceHubQuery.data?.results ?? []
const lastRaceName = champHub?.last_race ?? lastPastMeeting?.meeting_name ?? ''
if (seasonsQuery.isLoading) {
return <div className="page loading-state">loading command center</div>
}
if (seasonsQuery.isError) {
return (
<div className="page error-box">
{seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'}
</div>
)
}
const seasons = seasonsQuery.data ?? []
if (seasons.length === 0) {
return (
<div className="cc-page cc-empty" data-testid="command-center-empty">
<div className="cc-empty-band">
<span className="cc-empty-eyebrow mono">box-box · command center</span>
<h1 className="cc-empty-title">No local data yet</h1>
<p className="cc-empty-sub">
Ingest a race weekend from the CLI to populate this screen with live status, next-session
countdowns, and analysis links.
</p>
</div>
<div className="cc-empty-actions">
<Link to="/live" className="cc-action cc-action-live">
<span className="cc-action-label">Live Timing</span>
<span className="cc-action-meta">Standby</span>
</Link>
<Link to="/admin" className="cc-action">
<span className="cc-action-label">Admin · Data Health</span>
<span className="cc-action-meta">Ingestion guidance</span>
</Link>
</div>
</div>
)
}
const accent = countryAccent(focusMeeting ?? null)
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
return (
<div className="cc-page" data-testid="command-center" style={accentStyle}>
<div className="cc-topbar">
<span className="cc-topbar-label mono">box-box · command center</span>
<span className="cc-topbar-meta mono">
{latestSeason} season · {meetingStats.full}/{meetingStats.total || 0} weekends full
</span>
<span className="cc-live-pill" data-testid="cc-live-status">
<span className={`cc-live-dot ${liveActive ? 'live' : ''}`} />
{liveActive ? 'Live session active' : 'No live session'}
</span>
</div>
{!focusMeeting && (
<div className="missing-notice">
No meetings ingested for {latestSeason}. Run{' '}
<code>box-box --ingest-year {latestSeason}</code>
</div>
)}
{focusMeeting && focusKind && (
<CommandCenterHero
state={heroStateKind}
now={nowDate}
accent={accent}
liveActive={liveActive}
liveData={liveQuery.data?.data}
focusMeeting={focusMeeting}
focusKind={focusKind}
sessions={focusWeekendSessions}
currentSession={currentSession}
nextSession={nextSession}
analysisSessionKey={analysisSessionKey}
analysisSessionName={actionSession?.session_name}
lastRaceName={lastRaceName}
lastRacePodium={lastRacePodium}
lastRaceSessionKey={lastRaceSessionKey}
nextMeeting={nextMeetingForHero}
/>
)}
<div className="cc-dashboard-grid">
<div className="cc-dashboard-main">
<div
className="cc-ambient-aura"
aria-hidden="true"
style={focusMeeting ? { '--aura-accent': countryAccent(focusMeeting) } as React.CSSProperties : undefined}
/>
<div className="cc-dashboard-panel">
{seasonMeetings.length > 0 && (
<section className="cc-season-calendar" data-testid="cc-season-calendar">
<div className="sec-header">
<span className="sec-title">Season Calendar</span>
<span className="sec-meta mono">
{seasonMeetings.length} rounds · {meetingStats.full}/{meetingStats.total || 0} local
</span>
</div>
<div className="cc-calendar-grid" role="list">
{seasonMeetings.map((meeting, index) => {
const weekend = weekendsByKey.get(meeting.meeting_key)
const target = pickAnalysisSession(weekend)
const targetKey = target?.session.session_key ?? weekend?.default_session_key
const status = meetingStatus(meeting, focusMeeting?.meeting_key, nowDate)
const cardAccent = countryAccent(meeting)
return (
<Link
key={meeting.meeting_key}
to="/race-hub"
search={targetKey ? { session_key: targetKey } : {}}
className={`cc-calendar-card cc-calendar-${status}`}
data-testid={`cc-calendar-${meeting.meeting_key}`}
role="listitem"
style={{ '--gp-card-accent': cardAccent } as React.CSSProperties}
>
<div className="cc-calendar-accent" aria-hidden="true" />
<div className="cc-calendar-top mono">
<span className="cc-calendar-round">R{String(index + 1).padStart(2, '0')}</span>
<span className={`cc-cov-dot cc-cov-${weekend?.source ?? 'none'}`} aria-hidden="true" />
</div>
<div className="cc-calendar-id">
{countryFlag(meeting) && <span className="cc-calendar-flag">{countryFlag(meeting)}</span>}
</div>
<span className="cc-calendar-decal mono">{countryDecal(meeting)}</span>
<div className="cc-calendar-copy">
<div className="cc-calendar-name">{meeting.meeting_name}</div>
<div className="cc-calendar-circuit mono">{meeting.circuit_short_name || meeting.location}</div>
<div className="cc-calendar-date mono">{formatGpDateRange(meeting)}</div>
</div>
</Link>
)
})}
</div>
{seasonMeetingsQuery.isError && (
<div className="cc-side-empty">Using local meetings because the full calendar could not load.</div>
)}
</section>
)}
</div>
</div>
<div className="cc-dashboard-sidebar">
{champHub && (
<section className="cc-champ-snapshot glass-panel" data-testid="cc-champ-snapshot" style={{ padding: 'var(--s5)', borderRadius: '12px' }}>
<div className="cc-champ-header">
<span>Championship Leaders</span>
<Link to="/championship" className="cc-champ-link">Full Standings </Link>
</div>
<div className="cc-champ-group-label">Drivers</div>
<div className="cc-champ-list">
{champHub.drivers.slice(0, 3).map((d) => (
<div key={d.driver_number} className="cc-champ-row">
<div className="cc-champ-left">
<div className="cc-champ-color" style={{ color: `#${d.team_colour}`, background: 'currentColor' }} />
<span className="cc-champ-name">{d.name_acronym}</span>
</div>
<div className="cc-champ-form" title="Last 5 races">
<FormSparkline form={d.form} color={`#${d.team_colour}`} />
</div>
<div className="cc-champ-right">
<span className="cc-champ-gap">
{d.position === 1 ? '—' : `-${champHub.drivers[0].points - d.points}`}
</span>
<span className="cc-champ-pts">{d.points}</span>
</div>
</div>
))}
</div>
<div className="cc-champ-group-label">Constructors</div>
<div className="cc-champ-list">
{champHub.teams.slice(0, 3).map((t, i) => (
<div key={t.team_name} className="cc-champ-row">
<div className="cc-champ-left">
<div className="cc-champ-color" style={{ color: `#${t.team_colour}`, background: 'currentColor' }} />
<span className="cc-champ-name">{t.team_name}</span>
</div>
<div className="cc-champ-form" style={{ opacity: 0.4, fontSize: '11px', display: 'flex', gap: '4px', alignItems: 'center' }} title={`${t.wins} Wins`}>
<Trophy size={12} /> {t.wins}
</div>
<div className="cc-champ-right">
<span className="cc-champ-gap">
{i === 0 ? '—' : `-${champHub.teams[0].points - t.points}`}
</span>
<span className="cc-champ-pts">{t.points}</span>
</div>
</div>
))}
</div>
</section>
)}
{focusWeekendSessions.length > 0 && (
<section className="cc-schedule" data-testid="cc-schedule">
<div className="sec-header">
<span className="sec-title">Weekend Schedule</span>
<span className="sec-meta mono">{focusWeekendSessions.length} sessions</span>
</div>
<div className="cc-session-strip" role="list">
{focusWeekendSessions.map(({ session, source, datasets }) => {
const status = classifySessionStatus(session, nowDate)
const isNext = nextSession?.session_key === session.session_key
const isCurrent = currentSession?.session_key === session.session_key
const isLive = isCurrent && liveActive
return (
<Link
key={session.session_key}
to="/race-hub"
search={{ session_key: session.session_key }}
className={`cc-session-card ui-card interactive cc-status-${status}${isNext ? ' is-next' : ''}${
isLive ? ' is-live glass-panel' : ''
}`}
data-testid={`cc-session-${session.session_key}`}
role="listitem"
>
<div className="cc-session-card-head">
<span className="cc-session-abbrev mono">
{sessionTypeAbbrev(session.session_type, session.session_name)}
</span>
<span className={`cc-session-status cc-status-pill-${status}`}>
{isCurrent ? 'On track' : status === 'done' ? 'Done' : isNext ? 'Next' : 'Upcoming'}
</span>
</div>
<div className="cc-session-name">{session.session_name}</div>
<div className="cc-session-time mono">{formatSessionScheduleTime(session.date_start)}</div>
<div className="cc-session-cov mono">
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
{formatCoverageHint(datasets)}
</div>
</Link>
)
})}
</div>
</section>
)}
<PaddockBriefing />
</div>
</div>
</div>
)
}
function FormSparkline({ form, color }: { form: number[], color: string }) {
if (!form || form.length === 0) return null
const max = Math.max(...form, 26) // 26 is standard max for a race win
const width = 45
const height = 14
const step = width / Math.max(form.length - 1, 1)
const points = form.map((val, i) => {
const x = i * step
const y = height - (val / max) * height
return `${x},${y}`
}).join(' ')
return (
<svg width={width} height={height} style={{ overflow: 'visible' }}>
<polyline
fill="none"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
points={points}
/>
</svg>
)
}

View File

@@ -11,6 +11,7 @@ import {
import { SourceBadge, weekendStatusLabel } from '../components/SourceBadge' import { SourceBadge, weekendStatusLabel } from '../components/SourceBadge'
import { CliCommands, ingestYearCommands } from '../components/CliCommands' import { CliCommands, ingestYearCommands } from '../components/CliCommands'
import { MeetingDetailPanel } from '../components/MeetingDetailPanel' import { MeetingDetailPanel } from '../components/MeetingDetailPanel'
import { RouteState } from '../components/RouteState'
import type { Meeting, Weekend } from '../types' import type { Meeting, Weekend } from '../types'
function formatMeetingDate(meeting: Meeting): string { function formatMeetingDate(meeting: Meeting): string {
@@ -27,12 +28,12 @@ export function DataLibraryPage() {
const seasonsQuery = useQuery({ const seasonsQuery = useQuery({
queryKey: ['seasons'], queryKey: ['seasons'],
queryFn: fetchSeasons, queryFn: ({ signal }) => fetchSeasons(signal),
}) })
const meetingsQuery = useQuery({ const meetingsQuery = useQuery({
queryKey: ['meetings', selectedYear], queryKey: ['meetings', selectedYear],
queryFn: () => fetchLocalMeetings(selectedYear!), queryFn: ({ signal }) => fetchLocalMeetings(selectedYear!, signal),
enabled: selectedYear != null, enabled: selectedYear != null,
}) })
@@ -41,7 +42,7 @@ export function DataLibraryPage() {
const weekendQueries = useQueries({ const weekendQueries = useQueries({
queries: meetings.map((meeting) => ({ queries: meetings.map((meeting) => ({
queryKey: ['weekend', meeting.meeting_key], queryKey: ['weekend', meeting.meeting_key],
queryFn: () => fetchWeekend(meeting.meeting_key), queryFn: ({ signal }: { signal: AbortSignal }) => fetchWeekend(meeting.meeting_key, signal),
enabled: meetings.length > 0, enabled: meetings.length > 0,
staleTime: 60_000, staleTime: 60_000,
})), })),
@@ -78,13 +79,26 @@ export function DataLibraryPage() {
const weekendsLoading = weekendQueries.some((q) => q.isLoading) const weekendsLoading = weekendQueries.some((q) => q.isLoading)
if (seasonsQuery.isLoading) { if (seasonsQuery.isLoading) {
return <div className="page loading-state">loading local data library</div> return (
<div className="page">
<RouteState kind="loading" title="loading local data library…" testId="weekend-loading" />
</div>
)
} }
if (seasonsQuery.isError) { if (seasonsQuery.isError) {
return ( return (
<div className="page error-box"> <div className="page">
{seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'} <RouteState
kind="error"
title="Weekend data unavailable"
error={seasonsQuery.error}
onRetry={() => {
if (!seasonsQuery.isFetching) void seasonsQuery.refetch()
}}
retrying={seasonsQuery.isFetching}
testId="weekend-error"
/>
</div> </div>
) )
} }
@@ -222,15 +236,20 @@ export function DataLibraryPage() {
</div> </div>
{meetingsQuery.isLoading && ( {meetingsQuery.isLoading && (
<div className="loading-state">loading meetings</div> <RouteState kind="loading" title="loading meetings…" testId="weekend-meetings-loading" />
)} )}
{meetingsQuery.isError && ( {meetingsQuery.isError && (
<div className="error-box"> <RouteState
{meetingsQuery.error instanceof Error kind="error"
? meetingsQuery.error.message title="Weekend meetings unavailable"
: 'Failed to load meetings'} error={meetingsQuery.error}
</div> onRetry={() => {
if (!meetingsQuery.isFetching) void meetingsQuery.refetch()
}}
retrying={meetingsQuery.isFetching}
testId="weekend-error"
/>
)} )}
{!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && ( {!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && (
@@ -316,11 +335,16 @@ export function DataLibraryPage() {
<div className="dl-detail-wrap"> <div className="dl-detail-wrap">
{weekendsLoading && selectedWeekend == null && ( {weekendsLoading && selectedWeekend == null && (
<div className="loading-state">loading weekend details</div> <RouteState kind="loading" title="loading weekend details…" testId="weekend-detail-loading" />
)} )}
{selectedWeekend && <MeetingDetailPanel weekend={selectedWeekend} />} {selectedWeekend && <MeetingDetailPanel weekend={selectedWeekend} />}
{selectedMeetingKey != null && !weekendsLoading && selectedWeekend == null && ( {selectedMeetingKey != null && !weekendsLoading && selectedWeekend == null && (
<div className="missing-notice">Could not load weekend details.</div> <RouteState
kind="empty"
title="Weekend details missing"
message="Could not load weekend details for this meeting."
testId="weekend-detail-empty"
/>
)} )}
</div> </div>
</div> </div>

View File

@@ -1,6 +1,8 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { fetchDriverSummary, fetchSeasons } from '../api' import { fetchDriverSummary, fetchSeasons } from '../api'
import { DataNotice, RouteState } from '../components/RouteState'
import { noticeFromResponse, noticeMessage } from '../lib/availability'
import { teamColor } from '../utils' import { teamColor } from '../utils'
import { countryFlag } from '../lib/gpIdentity' import { countryFlag } from '../lib/gpIdentity'
import { import {
@@ -26,7 +28,7 @@ export function DriverProfilePage({ driverNumber, year }: Props) {
// seasons list is empty/unavailable the backend falls back to the current year. // seasons list is empty/unavailable the backend falls back to the current year.
const seasonsQuery = useQuery({ const seasonsQuery = useQuery({
queryKey: ['seasons'], queryKey: ['seasons'],
queryFn: fetchSeasons, queryFn: ({ signal }) => fetchSeasons(signal),
enabled: year == null, enabled: year == null,
}) })
const resolvedYear = year ?? seasonsQuery.data?.[0] const resolvedYear = year ?? seasonsQuery.data?.[0]
@@ -34,39 +36,102 @@ export function DriverProfilePage({ driverNumber, year }: Props) {
const summaryQuery = useQuery({ const summaryQuery = useQuery({
queryKey: ['driver-summary', driverNumber, resolvedYear ?? 'latest'], queryKey: ['driver-summary', driverNumber, resolvedYear ?? 'latest'],
queryFn: () => fetchDriverSummary(driverNumber, resolvedYear), queryFn: ({ signal }) => fetchDriverSummary(driverNumber, resolvedYear, signal),
enabled: seasonsSettled && driverNumber > 0, enabled: seasonsSettled && driverNumber > 0,
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
}) })
if (driverNumber <= 0) { if (driverNumber <= 0) {
return <div className="page error-box">Invalid driver number</div> return (
<div className="page">
<RouteState kind="empty" title="Invalid driver number" message="Pick a driver from the championship standings." />
</div>
)
} }
if (!seasonsSettled || summaryQuery.isLoading) { if (!seasonsSettled || summaryQuery.isLoading) {
return <div className="page loading-state">loading driver profile</div> return (
<div className="page">
<RouteState kind="loading" title="loading driver profile…" testId="driver-profile-loading" />
</div>
)
} }
if (summaryQuery.isError) { if (summaryQuery.isError) {
return ( return (
<div className="page error-box"> <div className="page">
{summaryQuery.error instanceof Error <RouteState
? summaryQuery.error.message kind="error"
: 'Failed to load driver profile'} title="Driver profile unavailable"
error={summaryQuery.error}
onRetry={() => {
if (!summaryQuery.isFetching) void summaryQuery.refetch()
}}
retrying={summaryQuery.isFetching}
testId="driver-profile-error"
/>
</div> </div>
) )
} }
const summary = summaryQuery.data const summary = summaryQuery.data
if (!summary) { if (!summary) {
return <div className="page error-box">No driver data</div> return (
<div className="page">
<RouteState
kind="empty"
title="No driver data"
message="This driver is not in the local season standings yet."
/>
</div>
)
} }
return <DriverProfileBody summary={summary} /> return (
<DriverProfileBody
summary={summary}
availability={noticeFromResponse(summary, { includeLocal: false })}
onRetry={() => {
if (!summaryQuery.isFetching) void summaryQuery.refetch()
}}
retrying={summaryQuery.isFetching}
/>
)
} }
function DriverProfileBody({ summary }: { summary: DriverSummary }) { function DriverProfileBody({
summary,
availability,
onRetry,
retrying,
}: {
summary: DriverSummary
availability?: ReturnType<typeof noticeFromResponse>
onRetry?: () => void
retrying?: boolean
}) {
const color = teamColor(summary.team_colour) const color = teamColor(summary.team_colour)
const deltas = gridFinishDeltas(summary.rounds) const deltas = gridFinishDeltas(summary.rounds)
return ( return (
<div className="dp-page" data-testid="driver-profile"> <div className="dp-page" data-testid="driver-profile">
{summary.enrichment === 'limited' && (
<DataNotice
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 }}> <header className="dp-header" data-testid="dp-header" style={{ borderLeftColor: color }}>
<div className="dp-identity"> <div className="dp-identity">
<div className="dp-title-row"> <div className="dp-title-row">

View File

@@ -0,0 +1,159 @@
import { useMemo } from 'react'
import { useQueries, useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import {
fetchChampionshipHub,
fetchLocalMeetings,
fetchSeasonMeetings,
fetchSeasons,
fetchWeekend,
} from '../api'
import { countryFlag, formatGpDateRange } from '../lib/gpIdentity'
import { meetingHasStarted } from '../lib/schedule'
import { teamColor } from '../utils'
import type { Weekend } from '../types'
import '../styles/weekend.css'
function analysisKey(weekend: Weekend | undefined): number | undefined {
if (!weekend) return undefined
const race = weekend.sessions.find((s) => s.session.session_type?.toLowerCase().includes('race'))
return race?.session.session_key ?? weekend.default_session_key
}
export function ExplorePage() {
const now = useMemo(() => new Date(), [])
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: ({ signal }) => fetchSeasons(signal),
})
const season = seasonsQuery.data?.[0] ?? null
const localMeetingsQuery = useQuery({
queryKey: ['meetings', season, 'local'],
queryFn: ({ signal }) => fetchLocalMeetings(season!, signal),
enabled: season != null,
})
const seasonMeetingsQuery = useQuery({
queryKey: ['season-meetings', season],
queryFn: ({ signal }) => fetchSeasonMeetings(season!, signal),
enabled: season != null,
})
const championshipQuery = useQuery({
queryKey: ['championship-hub', season],
queryFn: ({ signal }) => fetchChampionshipHub(season!, signal),
enabled: season != null,
})
const localMeetings = localMeetingsQuery.data ?? []
const meetings = seasonMeetingsQuery.data?.length ? seasonMeetingsQuery.data : localMeetings
const weekendQueries = useQueries({
queries: localMeetings.map((m) => ({
queryKey: ['weekend', m.meeting_key],
queryFn: ({ signal }: { signal: AbortSignal }) => fetchWeekend(m.meeting_key, signal),
enabled: localMeetings.length > 0,
staleTime: 60_000,
})),
})
const weekendsByKey = useMemo(() => {
const map = new Map<number, Weekend>()
localMeetings.forEach((m, i) => {
const data = weekendQueries[i]?.data
if (data) map.set(m.meeting_key, data)
})
return map
}, [localMeetings, weekendQueries])
const drivers = championshipQuery.data?.drivers ?? []
const activeMeetings = meetings.filter((m) => !m.is_cancelled)
if (seasonsQuery.isLoading) {
return <div className="wk-status" data-testid="explore-loading"><p className="wk-status-title">Loading</p></div>
}
return (
<main className="wk-page wk-explore" data-testid="explore-page">
<div className="wk-eyebrow mono">Explore · {season ?? 'season'}</div>
<section className="wk-explore-section" aria-labelledby="explore-calendar-title">
<header className="wk-card-head">
<span className="wk-card-title" id="explore-calendar-title">Season races</span>
<span className="wk-card-link mono">{activeMeetings.length} rounds</span>
</header>
{activeMeetings.length === 0 ? (
<p className="wk-status-sub" data-testid="explore-calendar-empty">No season calendar available yet.</p>
) : (
<ol className="wk-explore-grid" role="list">
{activeMeetings.map((meeting, index) => {
const weekend = weekendsByKey.get(meeting.meeting_key)
const target = analysisKey(weekend)
const done = meetingHasStarted(meeting, now)
const card = (
<>
<div className="wk-explore-top mono">
<span>R{String(index + 1).padStart(2, '0')}</span>
<span className="wk-explore-flag">
{countryFlag(meeting) || meeting.country_code}
</span>
</div>
<div className="wk-explore-name">{meeting.meeting_name}</div>
<div className="wk-explore-meta mono">{meeting.circuit_short_name || meeting.location}</div>
<div className="wk-explore-meta mono">{formatGpDateRange(meeting)}</div>
<span className={`wk-explore-state ${done ? 'is-done' : 'is-upcoming'}`}>
{done ? 'Completed' : 'Upcoming'}
</span>
</>
)
return (
<li key={meeting.meeting_key} className="wk-explore-item" role="listitem">
{target ? (
<Link
to="/race-hub"
search={{ session_key: target }}
className="wk-explore-card is-link"
data-testid={`explore-race-${meeting.meeting_key}`}
>
{card}
</Link>
) : (
<div className="wk-explore-card" data-testid={`explore-race-${meeting.meeting_key}`}>{card}</div>
)}
</li>
)
})}
</ol>
)}
</section>
<section className="wk-explore-section" aria-labelledby="explore-drivers-title">
<header className="wk-card-head">
<span className="wk-card-title" id="explore-drivers-title">Drivers</span>
<Link to="/championship" className="wk-card-link">Championship </Link>
</header>
{drivers.length === 0 ? (
<p className="wk-status-sub" data-testid="explore-drivers-empty">Driver data not available yet.</p>
) : (
<ol className="wk-explore-drivers" role="list">
{drivers.map((d) => (
<li key={d.driver_number} role="listitem">
<Link
to="/drivers/$driverNumber"
params={{ driverNumber: String(d.driver_number) }}
search={season ? { year: season } : {}}
className="wk-driver-chip"
style={{ ['--wk-team' as string]: teamColor(d.team_colour) }}
data-testid={`explore-driver-${d.driver_number}`}
>
<span className="wk-driver-pos mono">{d.position}</span>
<span className="wk-driver-code">{d.name_acronym}</span>
<span className="wk-driver-pts mono">{d.points}</span>
</Link>
</li>
))}
</ol>
)}
</section>
</main>
)
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { fetchLiveState } from '../api' import { fetchLiveState, fetchWeekendContext } from '../api'
import type { LivePosition, LiveStreamData } from '../types' import type { LivePosition, LiveStreamData } from '../types'
import { import {
loadPinnedDrivers, loadPinnedDrivers,
@@ -17,6 +17,15 @@ import { recordGapSamples } from '../lib/gapHistory'
import { battleNumbers, detectBattles } from '../lib/battles' import { battleNumbers, detectBattles } from '../lib/battles'
import type { LiveEvent } from '../lib/events' import type { LiveEvent } from '../lib/events'
import { appendEvents, diffSnapshots, sessionSignature } from '../lib/events' import { appendEvents, diffSnapshots, sessionSignature } from '../lib/events'
import {
allowsLiveInterpretations,
deriveLivePhase,
effectiveFeedHealth,
rendersSnapshot,
terminalSessionStatus,
} from '../lib/liveState'
import type { TransportHealth } from '../lib/liveState'
import { shouldPollHandoffAnalysis } from '../components/live/LiveHandoff'
import { SessionBanner } from '../components/live/SessionBanner' import { SessionBanner } from '../components/live/SessionBanner'
import { TrackStatusBanner } from '../components/live/TrackStatusBanner' import { TrackStatusBanner } from '../components/live/TrackStatusBanner'
import { TimingTower } from '../components/live/TimingTower' import { TimingTower } from '../components/live/TimingTower'
@@ -26,9 +35,14 @@ import { RaceControlFeed } from '../components/live/RaceControlFeed'
import { EventRail } from '../components/live/EventRail' import { EventRail } from '../components/live/EventRail'
import { TeamRadioTicker } from '../components/live/TeamRadioTicker' import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
import { TyreDegPanel } from '../components/live/TyreDegPanel' import { TyreDegPanel } from '../components/live/TyreDegPanel'
import { Archive, Radio } from 'lucide-react' import { LiveHandoff } from '../components/live/LiveHandoff'
import { DataNotice, RouteState } from '../components/RouteState'
import { weekendContextNotice } from '../lib/availability'
import '../styles/live-state.css'
type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error' /** How often to re-check weekend-context while analysis is still ingesting. */
export const WEEKEND_CONTEXT_POLL_MS = 15_000
const WEEKEND_CONTEXT_STALE_MS = 15_000
export function LiveTimingPage() { export function LiveTimingPage() {
const [activeSnapshot, setActiveSnapshot] = useState<LiveStreamData | null>(null) const [activeSnapshot, setActiveSnapshot] = useState<LiveStreamData | null>(null)
@@ -37,31 +51,96 @@ export function LiveTimingPage() {
const [archiveSnapshotAt, setArchiveSnapshotAt] = useState<string | null>(null) const [archiveSnapshotAt, setArchiveSnapshotAt] = useState<string | null>(null)
const [archiveMode, setArchiveMode] = useState(false) const [archiveMode, setArchiveMode] = useState(false)
const [isLive, setIsLive] = useState(false) const [isLive, setIsLive] = useState(false)
const [streamStatus, setStreamStatus] = useState<StreamStatus>('connecting') const [streamStatus, setStreamStatus] = useState<TransportHealth>('connecting')
const [now, setNow] = useState(Date.now()) const [now, setNow] = useState(Date.now())
const [gapHistory, setGapHistory] = useState<GapHistoryMap>({}) const [gapHistory, setGapHistory] = useState<GapHistoryMap>({})
const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers()) const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers())
const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({}) const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({})
const [positions, setPositions] = useState<Record<string, LivePosition>>({}) const [, setPositions] = useState<Record<string, LivePosition>>({})
const [events, setEvents] = useState<LiveEvent[]>([]) const [events, setEvents] = useState<LiveEvent[]>([])
// Authoritative SSE snapshot/event receipt — distinct from non-null timing rows.
// An inactive `is_live:false,data:null,last_snapshot:null` event is still valid.
const [authoritativeSseReceived, setAuthoritativeSseReceived] = useState(false)
const prevSnapshotRef = useRef<LiveStreamData | null>(null) const prevSnapshotRef = useRef<LiveStreamData | null>(null)
const sessionSigRef = useRef('') const sessionSigRef = useRef('')
const isLiveRef = useRef(false) const isLiveRef = useRef(false)
const archiveModeRef = useRef(false) const archiveModeRef = useRef(false)
const snapshot = isLive ? activeSnapshot : archiveMode ? archiveSnapshot : null // Whether we ever observed this session as live — used to reason about a
// dropped feed vs. a session that was never live in this page session.
const wasLiveRef = useRef(false)
const hasArchive = Boolean(archiveSnapshot) const hasArchive = Boolean(archiveSnapshot)
const { data, isLoading, isError, error } = useQuery({ const {
data,
isLoading,
isError,
error,
refetch,
isFetching,
isFetched: liveStateFetched,
} = useQuery({
queryKey: ['live-state'], queryKey: ['live-state'],
queryFn: fetchLiveState, queryFn: ({ signal }) => fetchLiveState(signal),
staleTime: 5_000, staleTime: 5_000,
}) })
// A retained snapshot is only "settled" (session genuinely finished) if it
// carries a terminal FIA SessionStatus. Otherwise the upstream feed dropped
// while the session was still active and we must stay in `disconnected`.
const sessionEndedCleanly = terminalSessionStatus(archiveSnapshot?.SessionStatus)
// Transport health, active-session state, and archive availability are
// orthogonal inputs; deriveLivePhase collapses them into one UI phase.
const phase = deriveLivePhase({
transport: streamStatus,
isLive,
hasActiveSnapshot: Boolean(activeSnapshot),
hasArchive,
archiveMode,
sessionEndedCleanly,
wasLive: wasLiveRef.current,
stateLoaded: liveStateFetched,
})
const snapshot =
phase === 'archive'
? archiveSnapshot
: isLive
? activeSnapshot
: phase === 'disconnected'
? archiveSnapshot
: null
// Canonical weekend context (issue #72) — the single source of truth for
// previous/next/default-analysis identity and temporal state. Only needed
// when no session is streaming; keep it idle during a live session. Poll
// while a completed session is still ingesting so `settling` can flip to
// analysis-ready without a manual refresh.
const contextQuery = useQuery({
queryKey: ['weekend-context'],
queryFn: ({ signal }) => fetchWeekendContext(signal),
enabled: !isLive,
staleTime: WEEKEND_CONTEXT_STALE_MS,
refetchInterval: (query) => {
// Poll until the just-finished previous_completed_session is ready — an
// older already-complete default_analysis_session must not stop us.
return shouldPollHandoffAnalysis(query.state.data) ? WEEKEND_CONTEXT_POLL_MS : false
},
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(() => { useEffect(() => {
if (!data) return if (!data) return
const nextLive = data.is_live && Boolean(data.data) const nextLive = data.is_live && Boolean(data.data)
setIsLive(nextLive) setIsLive(nextLive)
isLiveRef.current = nextLive isLiveRef.current = nextLive
if (nextLive) wasLiveRef.current = true
if (nextLive && data.data) { if (nextLive && data.data) {
setActiveSnapshot(data.data) setActiveSnapshot(data.data)
setArchiveMode(false) setArchiveMode(false)
@@ -104,6 +183,9 @@ export function LiveTimingPage() {
events.addEventListener('snapshot', (event) => { events.addEventListener('snapshot', (event) => {
const state = parseLiveStateEvent(event.data) const state = parseLiveStateEvent(event.data)
if (!state || cancelled) return if (!state || cancelled) return
// Valid SSE state clears a false fatal REST error even when all snapshots
// are null (inactive handoff) — do not require non-null timing rows.
setAuthoritativeSseReceived(true)
const nextLive = state.is_live && Boolean(state.data) const nextLive = state.is_live && Boolean(state.data)
setIsLive(nextLive) setIsLive(nextLive)
if (nextLive && state.data) { if (nextLive && state.data) {
@@ -111,6 +193,7 @@ export function LiveTimingPage() {
setPositions({}) setPositions({})
} }
isLiveRef.current = true isLiveRef.current = true
wasLiveRef.current = true
setActiveSnapshot(state.data) setActiveSnapshot(state.data)
setArchiveMode(false) setArchiveMode(false)
} else { } else {
@@ -167,6 +250,8 @@ export function LiveTimingPage() {
}, [snapshot]) }, [snapshot])
const rawRows = useMemo(() => sortLiveTimingRows(snapshot), [snapshot]) const rawRows = useMemo(() => sortLiveTimingRows(snapshot), [snapshot])
// Final-snapshot rows for the settling handoff (independent of live rows).
const settlingRows = useMemo(() => sortLiveTimingRows(archiveSnapshot), [archiveSnapshot])
useEffect(() => { useEffect(() => {
if (rawRows.length === 0) { if (rawRows.length === 0) {
@@ -210,68 +295,100 @@ export function LiveTimingPage() {
setPositions(archivePositions) setPositions(archivePositions)
} }
const handleExitArchive = () => {
setArchiveMode(false)
}
const archiveTimestamp = archiveSnapshotAt ? new Date(archiveSnapshotAt) : null const archiveTimestamp = archiveSnapshotAt ? new Date(archiveSnapshotAt) : null
const archiveLabel = const archiveLabel =
archiveTimestamp && !Number.isNaN(archiveTimestamp.getTime()) archiveTimestamp && !Number.isNaN(archiveTimestamp.getTime())
? `Archived snapshot from ${archiveTimestamp.toLocaleString()}` ? `Archived snapshot from ${archiveTimestamp.toLocaleString()}`
: 'Archived live timing snapshot' : 'Archived live timing snapshot'
const showLiveInterpretations = allowsLiveInterpretations(phase)
// Upstream FIA loss can leave the browser SSE open; present one coherent
// feed-health truth rather than "Connection lost" + "Feed healthy".
const feedHealth = effectiveFeedHealth(streamStatus, phase)
// Usable timing rows OR an authoritative SSE state event (including inactive
// null payloads) clear the fatal initial REST error. Preserve error+Retry only
// when neither REST nor SSE yields a valid state.
const hasUsableStreamData = Boolean(activeSnapshot) || Boolean(archiveSnapshot)
const showInitialError = isError && !hasUsableStreamData && !authoritativeSseReceived
return ( return (
<div className="page live-page" data-testid="live-page"> <div className="page live-page" data-testid="live-page" data-phase={phase}>
{isError && ( {showInitialError && (
<div className="error-box"> <RouteState
{error instanceof Error ? error.message : 'Failed to load live timing state'} kind="error"
title="Live timing unavailable"
error={error}
onRetry={() => {
if (!isFetching) void refetch()
}}
retrying={isFetching}
testId="live-initial-error"
/>
)}
{phase === 'disconnected' && (
<div className="live-status-strip live-status-warn" data-testid="live-disconnected-strip">
Connection lost showing the last live data while we reconnect. This is not an archive.
</div> </div>
)} )}
{streamStatus === 'disconnected' && snapshot && !archiveMode && ( {phase === 'archive' && (
<div className="live-status-strip live-status-warn">
Stream disconnected showing last received snapshot
</div>
)}
{archiveMode && snapshot && (
<div className="live-status-strip live-status-archive" data-testid="live-archive-strip"> <div className="live-status-strip live-status-archive" data-testid="live-archive-strip">
{archiveLabel} live updates are paused for this archive view <span>{archiveLabel} read-only, live updates are paused</span>
</div> <button type="button" className="live-archive-exit" onClick={handleExitArchive}>
)} Exit archive
{isLoading && !snapshot && (
<div className="loading-state">connecting to live timing</div>
)}
{!isLoading && !snapshot && (
<div className="empty-state ui-card glass-panel" style={{ padding: '40px', textAlign: 'center', marginTop: '20vh', maxWidth: '400px', marginLeft: 'auto', marginRight: 'auto' }} data-testid="live-empty">
<div className="live-empty-status" style={{ marginBottom: '16px' }}>
<span className={`live-conn live-conn-${streamStatus}`}>{streamStatus}</span>
</div>
<Radio size={48} style={{ color: 'var(--text-3)', margin: '0 auto 16px auto', display: 'block' }} />
<h2 className="empty-state-title" style={{ fontSize: '20px', marginBottom: '8px' }}>No live session active</h2>
<p className="empty-state-desc" style={{ color: 'var(--text-2)' }}>
The telemetry feed is currently offline. <br /><br /> Check the <a href="/" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Command Center</a> for the weekend schedule or explore historical data in the <a href="/race-hub" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Race Hub</a>.
</p>
{hasArchive && (
<button type="button" className="live-archive-btn" onClick={handleViewArchive}>
<Archive size={15} />
View Last Session
</button> </button>
)}
</div> </div>
)} )}
{snapshot && ( {phase === 'connecting' && (isLoading || !liveStateFetched) && !hasUsableStreamData && (
<RouteState
kind="loading"
title="connecting to live timing…"
testId="live-initial-loading"
/>
)}
{(phase === 'settling' || phase === 'inactive') && !showInitialError && (
<>
{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) && (
<> <>
<SessionBanner <SessionBanner
isLive={isLive} phase={phase}
isArchive={archiveMode}
snapshot={snapshot} snapshot={snapshot}
rows={rows} rows={rows}
connection={streamStatus} transport={feedHealth}
now={now} now={now}
capturedAt={phase === 'archive' ? archiveSnapshotAt : null}
/> />
<TrackStatusBanner status={snapshot.TrackStatus} /> <TrackStatusBanner status={snapshot.TrackStatus} />
<PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} /> <PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} />
{showLiveInterpretations && (
<TyreDegPanel rows={rows} sessionType={snapshot.Session?.SessionType} pinned={pinned} /> <TyreDegPanel rows={rows} sessionType={snapshot.Session?.SessionType} pinned={pinned} />
)}
<div className="live-columns"> <div className="live-columns">
<div className="live-tower-col"> <div className="live-tower-col">
<div className="sec-header"> <div className="sec-header">
@@ -289,16 +406,14 @@ export function LiveTimingPage() {
session={snapshot.Session} session={snapshot.Session}
/> />
</div> </div>
{/* Rail runs most-synthesized to most-raw: a reader arriving
mid-session wants "what did I miss" before the regulatory log. */}
<div className="live-rc-col"> <div className="live-rc-col">
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
<TeamRadioTicker <TeamRadioTicker
captures={snapshot.TeamRadio ?? []} captures={snapshot.TeamRadio ?? []}
driverInfo={snapshot.DriverInfo} driverInfo={snapshot.DriverInfo}
session={snapshot.Session} session={snapshot.Session}
/> />
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} /> <RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
</div> </div>
</div> </div>
</> </>

View File

@@ -1,12 +1,8 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router' import { Link, useNavigate } from '@tanstack/react-router'
import { import { fetchRaceHub, fetchWeekend, fetchWeekendContext } from '../api'
fetchLocalMeetings, import { weekendFocusSearch } from '../lib/routeSearch'
fetchRaceHub,
fetchSeasons,
fetchWeekend,
} from '../api'
import { DatasetStrip } from '../components/DatasetStrip' import { DatasetStrip } from '../components/DatasetStrip'
import { RaceStoryCanvas } from '../components/RaceStoryCanvas' import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
import { TabBar, type Tab } from '../components/TabBar' import { TabBar, type Tab } from '../components/TabBar'
@@ -17,80 +13,69 @@ import { CompareView } from '../components/CompareView'
import { RaceControlView } from '../components/RaceControlView' import { RaceControlView } from '../components/RaceControlView'
import { WeatherView } from '../components/WeatherView' import { WeatherView } from '../components/WeatherView'
import { OverviewView } from '../components/OverviewView' import { OverviewView } from '../components/OverviewView'
import {
PartialAnalysisBanner,
PreSessionView,
SessionPhaseView,
} from '../components/PreSessionView'
import { WeekendSwitcher } from '../components/WeekendSwitcher' import { WeekendSwitcher } from '../components/WeekendSwitcher'
import { SourceBadge } from '../components/SourceBadge' import { SourceBadge } from '../components/SourceBadge'
import { DataNotice, RouteState } from '../components/RouteState'
import { noticeFromResponse, noticeMessage } from '../lib/availability'
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity' import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage' import { sessionTypeAbbrev } from '../lib/coverage'
import { formatSessionScheduleTime, sortSessionsByStart } from '../lib/schedule'
import { import {
formatSessionScheduleTime, defaultAnalysisSessionKey,
pickFocusMeeting, isPartialAnalysis,
sortSessionsByStart, isPreSession,
} from '../lib/schedule' isPreparing,
import type { Weekend, WeekendSession } from '../types' isUnavailable,
resolveSessionState,
sessionStateDotClass,
sessionStateLabel,
} from '../lib/sessionState'
import type { WeekendSession } from '../types'
interface Props { interface Props {
sessionKey: number sessionKey: number
} }
function pickAnalysisSession(weekend: Weekend | undefined): WeekendSession | undefined {
if (!weekend) return undefined
const local = weekend.sessions.filter((s) => s.source === 'local')
const partial = weekend.sessions.filter((s) => s.source === 'partial')
const pool = local.length > 0 ? local : partial.length > 0 ? partial : weekend.sessions
const race = pool.find((s) => s.session.session_type?.toLowerCase().includes('race'))
if (race) return race
const qual = pool.find((s) => s.session.session_type?.toLowerCase().includes('qualifying'))
if (qual) return qual
return pool[0]
}
export function RaceHubPage({ sessionKey }: Props) { export function RaceHubPage({ sessionKey }: Props) {
const navigate = useNavigate() const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<Tab>('overview') const [activeTab, setActiveTab] = useState<Tab>('overview')
const [switcherOpen, setSwitcherOpen] = useState(false) const [switcherOpen, setSwitcherOpen] = useState(false)
const [showDiagnostics, setShowDiagnostics] = useState(false)
const [phaseDiagnostics, setPhaseDiagnostics] = useState(false)
const [now, setNow] = useState(() => new Date())
// ─── Auto-redirect when no session_key is supplied ─── // Keep schedule-adjacent UI (countdown) moving; Live/completion come from context.
const seasonsQuery = useQuery({ useEffect(() => {
queryKey: ['seasons'], const id = window.setInterval(() => setNow(new Date()), 1000)
queryFn: fetchSeasons, return () => window.clearInterval(id)
enabled: sessionKey === 0, }, [])
})
const contextQuery = useQuery({
const latestSeason = seasonsQuery.data?.[0] ?? null queryKey: ['weekend-context'],
queryFn: ({ signal }) => fetchWeekendContext(signal),
const meetingsQuery = useQuery({ staleTime: 15_000,
queryKey: ['meetings', latestSeason], refetchInterval: 30_000,
queryFn: () => fetchLocalMeetings(latestSeason!),
enabled: sessionKey === 0 && latestSeason != null,
})
const focusMeeting = useMemo(() => {
if (sessionKey !== 0 || !meetingsQuery.data) return null
return pickFocusMeeting(meetingsQuery.data, new Date())
}, [sessionKey, meetingsQuery.data])
const fallbackWeekendQuery = useQuery({
queryKey: ['weekend', focusMeeting?.meeting_key],
queryFn: () => fetchWeekend(focusMeeting!.meeting_key),
enabled: sessionKey === 0 && focusMeeting != null,
}) })
const context = contextQuery.data ?? null
// Bare `/race-hub` resolves only through canonical Weekend Context.
useEffect(() => { useEffect(() => {
if (sessionKey !== 0) return if (sessionKey !== 0) return
const weekend = fallbackWeekendQuery.data if (!contextQuery.isSuccess || !context) return
if (!weekend) return const target = defaultAnalysisSessionKey(context)
const target = pickAnalysisSession(weekend)?.session.session_key
?? weekend.default_session_key
?? weekend.sessions[0]?.session.session_key
if (target) { if (target) {
navigate({ to: '/race-hub', search: { session_key: target }, replace: true }) navigate({ to: '/race-hub', search: { session_key: target }, replace: true })
} }
}, [sessionKey, fallbackWeekendQuery.data, navigate]) }, [sessionKey, contextQuery.isSuccess, context, navigate])
// ─── Active session payload ───
const raceHubQuery = useQuery({ const raceHubQuery = useQuery({
queryKey: ['race-hub', sessionKey], queryKey: ['race-hub', sessionKey],
queryFn: () => fetchRaceHub(sessionKey), queryFn: ({ signal }) => fetchRaceHub(sessionKey, signal),
enabled: sessionKey > 0, enabled: sessionKey > 0,
staleTime: 30_000, staleTime: 30_000,
}) })
@@ -98,7 +83,7 @@ export function RaceHubPage({ sessionKey }: Props) {
const meetingKey = raceHubQuery.data?.meeting?.meeting_key const meetingKey = raceHubQuery.data?.meeting?.meeting_key
const weekendQuery = useQuery({ const weekendQuery = useQuery({
queryKey: ['weekend', meetingKey], queryKey: ['weekend', meetingKey],
queryFn: () => fetchWeekend(meetingKey!), queryFn: ({ signal }) => fetchWeekend(meetingKey!, signal),
enabled: meetingKey != null && meetingKey > 0, enabled: meetingKey != null && meetingKey > 0,
staleTime: 60_000, staleTime: 60_000,
}) })
@@ -108,37 +93,90 @@ export function RaceHubPage({ sessionKey }: Props) {
const accent = countryAccent(data?.meeting ?? null) const accent = countryAccent(data?.meeting ?? null)
const accentStyle = { '--gp-accent': accent } as React.CSSProperties const accentStyle = { '--gp-accent': accent } as React.CSSProperties
// ─── No session_key: show resolving state, fall back to switcher if no local data ─── const openDiagnostics = () => {
setActiveTab('data_status')
setShowDiagnostics(true)
setPhaseDiagnostics(true)
}
// ─── No session_key: resolve via Weekend Context ───
if (sessionKey === 0) { if (sessionKey === 0) {
if (seasonsQuery.isLoading || meetingsQuery.isLoading || fallbackWeekendQuery.isLoading) { if (contextQuery.isLoading) {
return ( return (
<div className="rh-page" style={accentStyle}> <div className="rh-page" style={accentStyle}>
<div className="loading-state">resolving latest local weekend</div> <RouteState
kind="loading"
title="resolving weekend context…"
testId="race-hub-loading"
/>
</div> </div>
) )
} }
const seasons = seasonsQuery.data ?? [] if (contextQuery.isError) {
if (seasons.length === 0) {
return ( return (
<div className="rh-page rh-empty" data-testid="race-hub-empty" style={accentStyle}> <div className="rh-page" data-testid="race-hub-error" style={accentStyle}>
<RouteState
kind="error"
title="Weekend unavailable"
error={contextQuery.error}
onRetry={() => {
if (!contextQuery.isFetching) void contextQuery.refetch()
}}
retrying={contextQuery.isFetching}
retryTestId="rh-retry"
>
<div className="rh-recover-actions">
<Link to="/" search={{}} className="rh-recover-btn" data-testid="rh-back-weekend">
Back to Weekend
</Link>
</div>
</RouteState>
</div>
)
}
if (context && !defaultAnalysisSessionKey(context)) {
return (
<div className="rh-page rh-empty" data-testid="race-hub-no-analysis" style={accentStyle}>
<div className="rh-empty-band"> <div className="rh-empty-band">
<span className="rh-empty-eyebrow mono">box-box · race hub</span> <span className="rh-empty-eyebrow mono">box-box · race hub</span>
<h1 className="rh-empty-title">No local sessions yet</h1> <h1 className="rh-empty-title">No completed session to analyse yet</h1>
<p className="rh-empty-sub"> <p className="rh-empty-sub">
The Race Hub reads from local ingest only. Once a weekend is ingested Weekend Context has no default analysis session. Pick a past session
it will open here automatically. to review, or check back once a session completes with local analysis.
</p> </p>
<div className="rh-empty-actions"> <div className="rh-empty-actions">
<a href="/admin" className="rh-empty-action">Open Admin · Data Health</a> <button
<a href="/" className="rh-empty-action">Back to Command Center</a> type="button"
className="rh-empty-action"
onClick={() => setSwitcherOpen(true)}
>
Browse Weekends
</button>
<Link to="/" search={{}} className="rh-empty-action" data-testid="rh-back-weekend">
Back to Weekend
</Link>
</div> </div>
</div> </div>
{switcherOpen && (
<WeekendSwitcher
currentMeetingKey={context.focus_meeting?.meeting_key}
context={context}
now={now}
onClose={() => setSwitcherOpen(false)}
/>
)}
</div> </div>
) )
} }
return ( return (
<div className="rh-page" style={accentStyle}> <div className="rh-page" style={accentStyle}>
<div className="loading-state">resolving latest local weekend</div> <RouteState
kind="loading"
title="resolving weekend context…"
testId="race-hub-loading"
/>
</div> </div>
) )
} }
@@ -147,18 +185,50 @@ export function RaceHubPage({ sessionKey }: Props) {
if (raceHubQuery.isLoading) { if (raceHubQuery.isLoading) {
return ( return (
<div className="rh-page" style={accentStyle}> <div className="rh-page" style={accentStyle}>
<div className="loading-state">loading session {sessionKey}</div> <RouteState
kind="loading"
title={`loading session ${sessionKey}`}
testId="race-hub-loading"
/>
</div> </div>
) )
} }
if (raceHubQuery.isError || !data) { if (raceHubQuery.isError || !data) {
const backMeeting =
context?.focus_meeting?.meeting_key ??
context?.previous_completed_session?.meeting?.meeting_key
const backSearch = weekendFocusSearch(backMeeting, sessionKey)
return ( return (
<div className="rh-page" style={accentStyle}> <div className="rh-page" data-testid="race-hub-error" style={accentStyle}>
<div className="error-box"> <RouteState
{raceHubQuery.error instanceof Error kind="error"
? raceHubQuery.error.message title="Session unavailable"
: `Failed to load session ${sessionKey}.`} error={raceHubQuery.error}
message={
raceHubQuery.error
? undefined
: `Session ${sessionKey} could not be loaded from local data.`
}
onRetry={() => {
if (!raceHubQuery.isFetching) void raceHubQuery.refetch()
}}
retrying={raceHubQuery.isFetching}
retryTestId="rh-retry"
>
<div className="rh-recover-actions">
<Link
to="/"
search={backSearch}
className="rh-recover-btn"
data-testid="rh-back-weekend"
data-meeting-key={backMeeting ?? undefined}
data-session-key={sessionKey}
>
Back to Weekend
</Link>
</div> </div>
</RouteState>
</div> </div>
) )
} }
@@ -168,12 +238,35 @@ export function RaceHubPage({ sessionKey }: Props) {
const sessionMeta = weekend const sessionMeta = weekend
? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w])) ? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w]))
: {} : {}
const activeSessionMeta = sessionMeta[sessionKey] const activeSessionMeta: WeekendSession | undefined = sessionMeta[sessionKey]
const activeState = resolveSessionState({
weekendSession: activeSessionMeta,
context,
now,
})
const preSession = isPreSession(activeState)
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 ( return (
<div className="rh-page" data-testid="race-hub" style={accentStyle}> <div className="rh-page" data-testid="race-hub" style={accentStyle}>
{/* Topbar */}
<div className="rh-topbar"> <div className="rh-topbar">
<Link
to="/"
search={weekendFocusSearch(meetingKey, sessionKey)}
className="rh-back-weekend"
data-testid="rh-back-weekend"
data-meeting-key={meetingKey ?? undefined}
data-session-key={sessionKey}
>
Back to Weekend
</Link>
<span className="rh-topbar-label mono"> <span className="rh-topbar-label mono">
box-box · race hub box-box · race hub
{data.meeting?.year ? ` · ${data.meeting.year}` : ''} {data.meeting?.year ? ` · ${data.meeting.year}` : ''}
@@ -191,15 +284,28 @@ export function RaceHubPage({ sessionKey }: Props) {
</button> </button>
</div> </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 && ( {switcherOpen && (
<WeekendSwitcher <WeekendSwitcher
currentMeetingKey={meetingKey} currentMeetingKey={meetingKey}
currentSessionKey={sessionKey} currentSessionKey={sessionKey}
context={context}
now={now}
onClose={() => setSwitcherOpen(false)} onClose={() => setSwitcherOpen(false)}
/> />
)} )}
{/* GP Identity band */}
{data.meeting && ( {data.meeting && (
<section className="rh-identity" data-testid="rh-identity"> <section className="rh-identity" data-testid="rh-identity">
<div className="rh-identity-accent" aria-hidden="true" /> <div className="rh-identity-accent" aria-hidden="true" />
@@ -220,12 +326,16 @@ export function RaceHubPage({ sessionKey }: Props) {
</section> </section>
)} )}
{/* Session rail */}
{sessions.length > 0 && ( {sessions.length > 0 && (
<nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail"> <nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail">
{sessions.map((session) => { {sessions.map((session) => {
const meta = sessionMeta[session.session_key] const meta = sessionMeta[session.session_key]
const active = session.session_key === sessionKey const active = session.session_key === sessionKey
const state = resolveSessionState({
weekendSession: meta,
context,
now,
})
return ( return (
<button <button
key={session.session_key} key={session.session_key}
@@ -247,43 +357,70 @@ export function RaceHubPage({ sessionKey }: Props) {
<span className="rh-session-time mono"> <span className="rh-session-time mono">
{formatSessionScheduleTime(session.date_start)} {formatSessionScheduleTime(session.date_start)}
</span> </span>
{meta && (
<span className="rh-session-cov mono"> <span className="rh-session-cov mono">
<span <span
className={`cc-cov-dot cc-cov-${meta.source}`} className={`cc-cov-dot ${sessionStateDotClass(state)}`}
aria-hidden="true" aria-hidden="true"
/> />
{formatCoverageHint(meta.datasets)} {sessionStateLabel(state)}
</span> </span>
)}
</button> </button>
) )
})} })}
</nav> </nav>
)} )}
{/* Active session sub-bar */}
{data.session && ( {data.session && (
<div className="rh-active-bar" data-testid="rh-active-bar"> <div className="rh-active-bar" data-testid="rh-active-bar">
<span className="rh-active-name">{data.session.session_name}</span> <span className="rh-active-name">{data.session.session_name}</span>
<span className="rh-active-meta mono"> <span className="rh-active-meta mono">
{formatSessionScheduleTime(data.session.date_start)} {formatSessionScheduleTime(data.session.date_start)}
</span> </span>
{activeSessionMeta && ( <span className="rh-active-cov mono" data-testid="rh-active-state">
<span className="rh-active-cov mono">
<span <span
className={`cc-cov-dot cc-cov-${activeSessionMeta.source}`} className={`cc-cov-dot ${sessionStateDotClass(activeState)}`}
aria-hidden="true" aria-hidden="true"
/> />
{formatCoverageHint(activeSessionMeta.datasets)} datasets local {sessionStateLabel(activeState)}
</span> </span>
)}
<span className="rh-active-key mono">key {sessionKey}</span> <span className="rh-active-key mono">key {sessionKey}</span>
</div> </div>
)} )}
{preSession && data.session ? (
<PreSessionView
session={data.session}
sessionName={data.session.session_name}
now={now}
/>
) : preparing || unavailable ? (
<>
<SessionPhaseView
state={
unavailable
? activeState === 'cancelled'
? 'cancelled'
: 'unavailable'
: 'preparing'
}
sessionName={data.session?.session_name ?? `Session ${sessionKey}`}
onOpenDiagnostics={openDiagnostics}
/>
{phaseDiagnostics && (
<div className="data-section" data-testid="rh-phase-diagnostics">
<div className="sec-header">
<span className="sec-title">Diagnostics</span>
</div>
<DatasetStatusView datasets={data.datasets} />
<div style={{ marginTop: 'var(--s5)' }} data-testid="rh-dataset-strip">
<DatasetStrip datasets={data.datasets} /> <DatasetStrip datasets={data.datasets} />
</div>
</div>
)}
</>
) : (
<>
{partial && <PartialAnalysisBanner onOpenDiagnostics={openDiagnostics} />}
<TabBar active={activeTab} onChange={setActiveTab} /> <TabBar active={activeTab} onChange={setActiveTab} />
{activeTab === 'overview' && <OverviewView data={data} />} {activeTab === 'overview' && <OverviewView data={data} />}
@@ -360,11 +497,27 @@ export function RaceHubPage({ sessionKey }: Props) {
{activeTab === 'data_status' && ( {activeTab === 'data_status' && (
<div className="data-section"> <div className="data-section">
<div className="sec-header"> <div className="sec-header">
<span className="sec-title">Data Status</span> <span className="sec-title">Diagnostics</span>
<button
type="button"
className={`rh-diagnostics-toggle${showDiagnostics ? ' active' : ''}`}
onClick={() => setShowDiagnostics((v) => !v)}
aria-expanded={showDiagnostics}
data-testid="rh-diagnostics-toggle"
>
{showDiagnostics ? 'Hide dataset coverage' : 'Show dataset coverage'}
</button>
</div> </div>
<DatasetStatusView datasets={data.datasets} /> <DatasetStatusView datasets={data.datasets} />
{showDiagnostics && (
<div style={{ marginTop: 'var(--s5)' }} data-testid="rh-dataset-strip">
<DatasetStrip datasets={data.datasets} />
</div> </div>
)} )}
</div> </div>
)}
</>
)}
</div>
) )
} }

View File

@@ -9,6 +9,15 @@ import {
fetchStartingGrid, fetchStartingGrid,
fetchTrackOutline, fetchTrackOutline,
} from '../api' } from '../api'
import { DataNotice, RouteState } from '../components/RouteState'
import {
aggregateNotices,
noticeFromResponse,
noticeMessage,
shouldShowEmbeddedNotice,
type DataAvailability,
} from '../lib/availability'
import { userFacingError } from '../lib/fetch'
import { countryAccent, countryFlag, formatGpDateRange } from '../lib/gpIdentity' import { countryAccent, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
import { import {
buildTitleFightContext, buildTitleFightContext,
@@ -37,7 +46,7 @@ function SectionState({
children, children,
}: { }: {
loading?: boolean loading?: boolean
error?: Error | null error?: unknown
empty?: boolean empty?: boolean
emptyMessage?: string emptyMessage?: string
children: ReactNode children: ReactNode
@@ -47,8 +56,8 @@ function SectionState({
} }
if (error) { if (error) {
return ( return (
<div className="preview-section-state error"> <div className="preview-section-state error" role="status">
{error instanceof Error ? error.message : 'Failed to load'} {userFacingError(error)}
</div> </div>
) )
} }
@@ -66,7 +75,7 @@ function TrackOutlineCard({
}: { }: {
outline: TrackOutline | null | undefined outline: TrackOutline | null | undefined
loading: boolean loading: boolean
error: Error | null error: unknown
accent: string accent: string
}) { }) {
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline]) const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
@@ -101,7 +110,7 @@ function LastYearCard({
}: { }: {
year: number | null year: number | null
loading: boolean loading: boolean
error: Error | null error: unknown
podium: ReturnType<typeof extractPodium> podium: ReturnType<typeof extractPodium>
pole: ReturnType<typeof extractPole> pole: ReturnType<typeof extractPole>
isFirstTime: boolean isFirstTime: boolean
@@ -150,7 +159,7 @@ function TitleFightCard({
season, season,
}: { }: {
loading: boolean loading: boolean
error: Error | null error: unknown
drivers: ReturnType<typeof buildTitleFightContext> drivers: ReturnType<typeof buildTitleFightContext>
sprintWeekend: boolean sprintWeekend: boolean
season: number | null season: number | null
@@ -259,7 +268,29 @@ 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
/**
* 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({
meeting: canonicalMeeting,
season: canonicalSeason,
embedded = false,
shellAvailability = null,
}: RacePreviewPageProps = {}) {
const [now, setNow] = useState(() => Date.now()) const [now, setNow] = useState(() => Date.now())
useEffect(() => { useEffect(() => {
@@ -268,28 +299,34 @@ export function RacePreviewPage() {
}, []) }, [])
const nowDate = useMemo(() => new Date(now), [now]) const nowDate = useMemo(() => new Date(now), [now])
const hasCanonicalMeeting = canonicalMeeting != null && canonicalMeeting.meeting_key > 0
const seasonsQuery = useQuery({ const seasonsQuery = useQuery({
queryKey: ['seasons'], queryKey: ['seasons'],
queryFn: fetchSeasons, queryFn: ({ signal }) => fetchSeasons(signal),
enabled: !hasCanonicalMeeting,
}) })
const latestSeason = seasonsQuery.data?.[0] ?? null const latestSeason =
canonicalSeason ??
canonicalMeeting?.year ??
seasonsQuery.data?.[0] ??
null
const meetingsQuery = useQuery({ const meetingsQuery = useQuery({
queryKey: ['meetings', latestSeason, 'auto'], queryKey: ['meetings', latestSeason, 'auto'],
queryFn: () => fetchMeetings(latestSeason!, 'auto'), queryFn: ({ signal }) => fetchMeetings(latestSeason!, 'auto', signal),
enabled: latestSeason != null, enabled: !hasCanonicalMeeting && latestSeason != null,
}) })
const previewMeeting = useMemo( const previewMeeting = useMemo(() => {
() => pickPreviewMeeting(meetingsQuery.data ?? [], nowDate), if (hasCanonicalMeeting) return canonicalMeeting
[meetingsQuery.data, nowDate], return pickPreviewMeeting(meetingsQuery.data ?? [], nowDate)
) }, [hasCanonicalMeeting, canonicalMeeting, meetingsQuery.data, nowDate])
const sessionsQuery = useQuery({ const sessionsQuery = useQuery({
queryKey: ['sessions', previewMeeting?.meeting_key, 'auto'], queryKey: ['sessions', previewMeeting?.meeting_key, 'auto'],
queryFn: () => fetchSessions(previewMeeting!.meeting_key, 'auto'), queryFn: ({ signal }) => fetchSessions(previewMeeting!.meeting_key, 'auto', signal),
enabled: previewMeeting != null, enabled: previewMeeting != null,
}) })
@@ -305,7 +342,7 @@ export function RacePreviewPage() {
const priorMeetingsQuery = useQuery({ const priorMeetingsQuery = useQuery({
queryKey: ['meetings', priorYear, 'auto'], queryKey: ['meetings', priorYear, 'auto'],
queryFn: () => fetchMeetings(priorYear!, 'auto'), queryFn: ({ signal }) => fetchMeetings(priorYear!, 'auto', signal),
enabled: priorYear != null && previewMeeting != null, enabled: priorYear != null && previewMeeting != null,
}) })
@@ -316,7 +353,7 @@ export function RacePreviewPage() {
const priorSessionsQuery = useQuery({ const priorSessionsQuery = useQuery({
queryKey: ['sessions', priorMeeting?.meeting_key, 'auto'], queryKey: ['sessions', priorMeeting?.meeting_key, 'auto'],
queryFn: () => fetchSessions(priorMeeting!.meeting_key, 'auto'), queryFn: ({ signal }) => fetchSessions(priorMeeting!.meeting_key, 'auto', signal),
enabled: priorMeeting != null, enabled: priorMeeting != null,
}) })
@@ -327,25 +364,25 @@ export function RacePreviewPage() {
const priorResultsQuery = useQuery({ const priorResultsQuery = useQuery({
queryKey: ['results', priorRaceSession?.session_key, 'auto'], queryKey: ['results', priorRaceSession?.session_key, 'auto'],
queryFn: () => fetchResults(priorRaceSession!.session_key, 'auto'), queryFn: ({ signal }) => fetchResults(priorRaceSession!.session_key, 'auto', signal),
enabled: priorRaceSession != null, enabled: priorRaceSession != null,
}) })
const priorGridQuery = useQuery({ const priorGridQuery = useQuery({
queryKey: ['grid', priorRaceSession?.session_key, 'auto'], queryKey: ['grid', priorRaceSession?.session_key, 'auto'],
queryFn: () => fetchStartingGrid(priorRaceSession!.session_key, 'auto'), queryFn: ({ signal }) => fetchStartingGrid(priorRaceSession!.session_key, 'auto', signal),
enabled: priorRaceSession != null, enabled: priorRaceSession != null,
}) })
const trackOutlineQuery = useQuery({ const trackOutlineQuery = useQuery({
queryKey: ['track-outline', previewMeeting?.circuit_key, previewMeeting?.year], 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, enabled: previewMeeting?.circuit_key != null && previewMeeting.circuit_key > 0,
}) })
const championshipQuery = useQuery({ const championshipQuery = useQuery({
queryKey: ['championship-hub', latestSeason], queryKey: ['championship-hub', latestSeason],
queryFn: () => fetchChampionshipHub(latestSeason!), queryFn: ({ signal }) => fetchChampionshipHub(latestSeason!, signal),
enabled: latestSeason != null, enabled: latestSeason != null,
}) })
@@ -359,15 +396,56 @@ export function RacePreviewPage() {
const accent = countryAccent(previewMeeting) const accent = countryAccent(previewMeeting)
const isFirstTimeCircuit = priorMeeting == null && priorYear != null && !priorMeetingsQuery.isLoading const isFirstTimeCircuit = priorMeeting == null && priorYear != null && !priorMeetingsQuery.isLoading
if (seasonsQuery.isLoading || meetingsQuery.isLoading) { const identityLoading = !hasCanonicalMeeting && (seasonsQuery.isLoading || meetingsQuery.isLoading)
return <div className="page loading-state" data-testid="preview-loading">loading preview</div> 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) { // Worst reported truth across primary + supplements — Local must not mask
// Sessions/Prior Results Stale/Partial/Limited/Archive.
const dataNotice = aggregateNotices([
noticeFromResponse(championshipQuery.data, { includeLocal: true }),
noticeFromResponse(sessionsQuery.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) {
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 const err = seasonsQuery.error ?? meetingsQuery.error
return ( return (
<div className="page error-box" data-testid="preview-error"> <div className={embedded ? 'preview-embedded' : 'page'} data-testid="preview-error">
{err instanceof Error ? err.message : 'Failed to load preview'} <RouteState
kind="error"
title="Preview details unavailable"
error={err}
onRetry={() => {
if (!identityRetrying) retryIdentity()
}}
retrying={identityRetrying}
retryTestId="preview-retry"
/>
</div> </div>
) )
} }
@@ -386,7 +464,7 @@ export function RacePreviewPage() {
{titleFight.length > 0 && ( {titleFight.length > 0 && (
<TitleFightCard <TitleFightCard
loading={championshipQuery.isLoading} loading={championshipQuery.isLoading}
error={championshipQuery.isError ? (championshipQuery.error as Error) : null} error={championshipQuery.isError ? championshipQuery.error : null}
drivers={titleFight} drivers={titleFight}
sprintWeekend={false} sprintWeekend={false}
season={latestSeason} season={latestSeason}
@@ -397,7 +475,34 @@ export function RacePreviewPage() {
} }
return ( return (
<div className="preview-page" data-testid="preview-page"> <div
className="preview-page"
data-testid="preview-page"
data-meeting-key={previewMeeting.meeting_key}
data-embedded={embedded ? 'true' : undefined}
>
{showFreshnessNotice && dataNotice && (
<DataNotice
availability={dataNotice}
message={noticeMessage(dataNotice)}
testId="preview-data-notice"
/>
)}
{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 && (
<PreviewHeader <PreviewHeader
meeting={previewMeeting} meeting={previewMeeting}
sessions={sessions} sessions={sessions}
@@ -405,12 +510,27 @@ export function RacePreviewPage() {
now={nowDate} now={nowDate}
accent={accent} accent={accent}
/> />
)}
{/* 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}
loading={trackOutlineQuery.isLoading} loading={trackOutlineQuery.isLoading}
error={trackOutlineQuery.isError ? (trackOutlineQuery.error as Error) : null} error={trackOutlineQuery.isError ? trackOutlineQuery.error : null}
accent={accent} accent={accent}
/> />
@@ -419,9 +539,9 @@ export function RacePreviewPage() {
loading={priorMeetingsQuery.isLoading || priorSessionsQuery.isLoading || priorResultsQuery.isLoading} loading={priorMeetingsQuery.isLoading || priorSessionsQuery.isLoading || priorResultsQuery.isLoading}
error={ error={
priorMeetingsQuery.isError priorMeetingsQuery.isError
? (priorMeetingsQuery.error as Error) ? priorMeetingsQuery.error
: priorResultsQuery.isError : priorResultsQuery.isError
? (priorResultsQuery.error as Error) ? priorResultsQuery.error
: null : null
} }
podium={podium} podium={podium}
@@ -432,7 +552,7 @@ export function RacePreviewPage() {
<TitleFightCard <TitleFightCard
loading={championshipQuery.isLoading} loading={championshipQuery.isLoading}
error={championshipQuery.isError ? (championshipQuery.error as Error) : null} error={championshipQuery.isError ? championshipQuery.error : null}
drivers={titleFight} drivers={titleFight}
sprintWeekend={sprintWeekend} sprintWeekend={sprintWeekend}
season={latestSeason} season={latestSeason}

View File

@@ -0,0 +1,180 @@
import { useWeekendContext } from '../hooks/useWeekendContext'
import { BetweenRacesView } from '../components/weekend/BetweenRacesView'
import { BetweenSessionsView } from '../components/weekend/BetweenSessionsView'
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,
WeekendChampionshipImpact,
WeekendContext,
WeekendViewState,
} from '../types'
import '../styles/weekend.css'
interface RenderArgs {
context: WeekendContext
now: Date
championship?: WeekendChampionshipImpact
briefing: WeekendBriefingItem[]
/** When true (the /preview alias), foreground the preparation surface. */
preview: boolean
/** Availability already disclosed by the Weekend shell (for Preview dedupe). */
shellAvailability: DataAvailability | null
}
function renderState(view: WeekendViewState, args: RenderArgs) {
const { context, now, championship, briefing, preview, shellAvailability } = args
// 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
// keeps saved /preview links and the "Prepare for …" CTA meaningful instead of
// redirecting straight back to the same between-races screen.
if (preview && context.next_meeting) {
return <PreSessionView context={context} now={now} shellAvailability={shellAvailability} />
}
switch (view) {
case 'no_season':
return <WeekendLimited season={context.season} />
case 'between_weekends':
case 'post_weekend':
case 'season_complete':
return (
<BetweenRacesView
context={context}
now={now}
view={view}
championship={championship}
briefing={briefing}
/>
)
case 'between_sessions':
case 'session_settling':
return <BetweenSessionsView context={context} now={now} view={view} briefing={briefing} />
case 'session_live':
return <LiveHandoffView context={context} />
case 'pre_session':
return <PreSessionView context={context} now={now} shellAvailability={shellAvailability} />
default:
return <WeekendLimited season={context.season} />
}
}
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,
focusSessionKey,
}: {
preview?: boolean
/** Restored from `/?meeting_key=` when returning from Race Hub analysis. */
focusMeetingKey?: number
/** Restored from `/?session_key=` when returning from Race Hub analysis. */
focusSessionKey?: number
}) {
const {
context,
loadState,
error,
championship,
briefing,
availabilityNotice,
supplementsLimited,
now,
refetch,
isFetching,
} = useWeekendContext()
const hasFocus =
(focusMeetingKey != null && focusMeetingKey > 0) ||
(focusSessionKey != null && focusSessionKey > 0)
if (loadState === 'loading') {
return (
<main className="wk-page" data-testid="weekend-page" data-state="loading">
<WeekendLoading />
</main>
)
}
if (loadState === 'error' || context == null) {
return (
<main className="wk-page" data-testid="weekend-page" data-state="error">
<WeekendError error={error} onRetry={refetch} retrying={isFetching} />
</main>
)
}
const view = resolveViewState(context)
const shellAvailability = availabilityNotice ?? (supplementsLimited ? 'limited' : null)
return (
<main
className="wk-page"
data-testid="weekend-page"
data-state={view}
data-temporal-state={context.temporal_state}
data-preview={preview ? 'true' : undefined}
data-meeting-key={focusMeetingKey ?? undefined}
data-session-key={focusSessionKey ?? undefined}
>
{hasFocus && (
<WeekendFocusBanner meetingKey={focusMeetingKey} sessionKey={focusSessionKey} />
)}
<WeekendAvailabilityNotices
availabilityNotice={availabilityNotice}
supplementsLimited={supplementsLimited}
onRetry={refetch}
retrying={isFetching}
/>
{renderState(view, {
context,
now,
championship,
briefing,
preview,
shellAvailability,
})}
</main>
)
}

View File

@@ -1,17 +1,14 @@
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router' import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router'
import { Nav } from './components/Nav' import { Nav } from './components/Nav'
import { CommandCenterPage } from './pages/CommandCenterPage' import { WeekendPage } from './pages/WeekendPage'
import { ExplorePage } from './pages/ExplorePage'
import { RaceHubPage } from './pages/RaceHubPage' import { RaceHubPage } from './pages/RaceHubPage'
import { DataLibraryPage } from './pages/DataLibraryPage' import { DataLibraryPage } from './pages/DataLibraryPage'
import { LiveTimingPage } from './pages/LiveTimingPage' import { LiveTimingPage } from './pages/LiveTimingPage'
import { BriefingPage } from './pages/BriefingPage' import { BriefingPage } from './pages/BriefingPage'
import { ChampionshipPage } from './pages/ChampionshipPage' import { ChampionshipPage } from './pages/ChampionshipPage'
import { DriverProfilePage } from './pages/DriverProfilePage' import { DriverProfilePage } from './pages/DriverProfilePage'
import { RacePreviewPage } from './pages/RacePreviewPage' import { parseRaceHubSearch, parseWeekendSearch } from './lib/routeSearch'
type RaceHubSearch = {
session_key?: number
}
type DriverProfileSearch = { type DriverProfileSearch = {
year?: number year?: number
@@ -26,19 +23,31 @@ const rootRoute = createRootRoute({
), ),
}) })
export const commandCenterRoute = createRoute({ // Weekend is the adaptive home. `/` renders the current temporal state.
// Optional meeting_key/session_key search restores Race Hub return context
// (deep-linkable, reload-safe — no hidden component memory).
export const weekendRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/', path: '/',
component: CommandCenterPage, validateSearch: (search: Record<string, unknown>) => parseWeekendSearch(search),
component: function WeekendRoute() {
const { meeting_key, session_key } = weekendRoute.useSearch()
return (
<WeekendPage focusMeetingKey={meeting_key} focusSessionKey={session_key} />
)
},
})
export const exploreRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/explore',
component: ExplorePage,
}) })
export const raceHubRoute = createRoute({ export const raceHubRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/race-hub', path: '/race-hub',
validateSearch: (search: Record<string, unknown>): RaceHubSearch => { validateSearch: (search: Record<string, unknown>) => parseRaceHubSearch(search),
const sessionKey = Number(search.session_key)
return Number.isFinite(sessionKey) && sessionKey > 0 ? { session_key: sessionKey } : {}
},
component: function RaceHubRoute() { component: function RaceHubRoute() {
const { session_key } = raceHubRoute.useSearch() const { session_key } = raceHubRoute.useSearch()
return <RaceHubPage sessionKey={session_key ?? 0} /> return <RaceHubPage sessionKey={session_key ?? 0} />
@@ -90,14 +99,21 @@ export const briefingRoute = createRoute({
component: BriefingPage, component: BriefingPage,
}) })
// Preview folds into the Weekend home. /preview is a stable alias that renders the
// Weekend page in its preparation surface (PreSessionView) whenever there is a next
// event — so saved preview links and the "Prepare for …" CTA reach real preview
// content instead of redirecting back to the same between-races screen.
export const previewRoute = createRoute({ export const previewRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/preview', path: '/preview',
component: RacePreviewPage, component: function PreviewRoute() {
return <WeekendPage preview />
},
}) })
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
commandCenterRoute, weekendRoute,
exploreRoute,
raceHubRoute, raceHubRoute,
adminRoute, adminRoute,
dataLibraryRoute, dataLibraryRoute,

View File

@@ -89,16 +89,6 @@ a { color: inherit; text-decoration: none; }
} }
.app-nav::-webkit-scrollbar { display: none; } .app-nav::-webkit-scrollbar { display: none; }
/* The nav scrolls horizontally with its scrollbar hidden. Below the width
where the links stop fitting, fade the trailing edge so the cut-off item
reads as "scroll for more" instead of as a clipping bug. */
@media (max-width: 560px) {
.app-nav {
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent 100%);
mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent 100%);
}
}
.nav-logo { .nav-logo {
font-family: var(--f-mono); font-family: var(--f-mono);
font-size: 15px; font-size: 15px;
@@ -122,30 +112,6 @@ a { color: inherit; text-decoration: none; }
.nav-links a:hover { color: var(--text); background: var(--surface-h); } .nav-links a:hover { color: var(--text); background: var(--surface-h); }
.nav-links a.active { color: var(--text); background: var(--surface-2); } .nav-links a.active { color: var(--text); background: var(--surface-2); }
/* Live nav gets a pulsing marker only while a session is on air. */
.nav-live { display: inline-flex; align-items: center; gap: 6px; }
.nav-links a.nav-live-on { color: var(--text); font-weight: 600; }
.nav-live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--red);
box-shadow: 0 0 0 0 rgba(225, 6, 0, 0.4);
animation: pulse-live 1.6s infinite;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.nav-utility { .nav-utility {
margin-left: auto; margin-left: auto;
display: flex; display: flex;
@@ -176,6 +142,53 @@ a { color: inherit; text-decoration: none; }
background: var(--surface-2); background: var(--surface-2);
} }
/* ── Mobile bottom nav ── */
.app-bottom-nav {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
height: 60px;
padding-bottom: env(safe-area-inset-bottom, 0);
background: rgba(18, 18, 18, 0.92);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.bottom-nav-link {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
font-size: 10px;
font-weight: 600;
color: var(--text-3);
}
.bottom-nav-link.active { color: var(--red); }
.bottom-nav-link:hover { color: var(--text-2); }
@media (max-width: 640px) {
/* On mobile the bottom bar is the single primary navigation system. The top
bar's Primary links are hidden so the two are never both shown; the top bar
keeps only the logo and the Admin operator utility. */
.app-bottom-nav { display: flex; }
.app-nav .nav-links { display: none; }
.app-nav { justify-content: space-between; }
/* Keep page content clear of the fixed bottom bar. */
.wk-page { padding-bottom: calc(60px + env(safe-area-inset-bottom, 0) + var(--s4)); }
}
/* ── Focus visibility (accessibility) ── */
:focus-visible {
outline: 2px solid var(--red);
outline-offset: 2px;
border-radius: 3px;
}
/* ── Page ── */ /* ── Page ── */
.page { .page {
max-width: 1040px; max-width: 1040px;
@@ -397,6 +410,9 @@ a { color: inherit; text-decoration: none; }
.badge-partial { background: rgba(255,214,0,.12); color: var(--yellow); border: 1px solid rgba(255,214,0,.25); } .badge-partial { background: rgba(255,214,0,.12); color: var(--yellow); border: 1px solid rgba(255,214,0,.25); }
.badge-cancelled { background: rgba(255,107,53,.12); color: #ff8a5c; border: 1px solid rgba(255,107,53,.28); } .badge-cancelled { background: rgba(255,107,53,.12); color: #ff8a5c; border: 1px solid rgba(255,107,53,.28); }
.badge-none { background: rgba(80,80,80,.12); color: var(--text-3); border: 1px solid var(--border); } .badge-none { background: rgba(80,80,80,.12); color: var(--text-3); border: 1px solid var(--border); }
.badge-stale { background: rgba(255,214,0,.10); color: var(--yellow); border: 1px solid rgba(255,214,0,.22); }
.badge-limited { background: rgba(120,160,220,.12); color: #9bb7e0; border: 1px solid rgba(120,160,220,.28); }
.badge-missing { background: rgba(80,80,80,.12); color: var(--text-3); border: 1px solid var(--border); }
/* ── Dataset strip ── */ /* ── Dataset strip ── */
.dataset-strip { .dataset-strip {
@@ -572,6 +588,82 @@ a { color: inherit; text-decoration: none; }
margin-bottom: var(--s5); margin-bottom: var(--s5);
} }
.route-state {
padding: var(--s7) var(--s5);
text-align: center;
max-width: 420px;
margin: 0 auto;
}
.route-state-availability {
margin-bottom: var(--s4);
}
.route-state-title {
font-family: var(--f-display, var(--f-mono));
font-size: 18px;
font-weight: 600;
color: var(--text);
margin-bottom: var(--s3);
}
.route-state-message {
font-size: 13px;
line-height: 1.5;
color: var(--text-2);
margin-bottom: var(--s5);
}
.route-state-retry {
appearance: none;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
font-family: var(--f-mono);
font-size: 12px;
padding: 8px 16px;
border-radius: 2px;
cursor: pointer;
}
.route-state-retry:hover:not(:disabled) {
border-color: rgba(225, 6, 0, 0.45);
color: #fff;
}
.route-state-retry:focus-visible {
outline: 2px solid var(--red);
outline-offset: 2px;
}
.route-state-retry:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.data-notice {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s3);
padding: var(--s3) var(--s4);
margin-bottom: var(--s4);
background: rgba(255, 214, 0, 0.06);
border: 1px solid rgba(255, 214, 0, 0.2);
border-radius: 2px;
font-size: 12px;
color: var(--text-2);
}
.data-notice-text {
flex: 1;
min-width: 160px;
}
.data-notice-retry {
padding: 4px 10px;
}
/* ── Live timing ── */ /* ── Live timing ── */
.live-page { max-width: 1320px; } .live-page { max-width: 1320px; }
@@ -1807,6 +1899,163 @@ a { color: inherit; text-decoration: none; }
.tab-btn:hover { color: var(--text-2); } .tab-btn:hover { color: var(--text-2); }
.tab-btn.active { color: var(--text); border-bottom-color: var(--red); } .tab-btn.active { color: var(--text); border-bottom-color: var(--red); }
/* ── Grouped analysis navigation ── */
.tab-bar-grouped {
gap: var(--s5);
align-items: flex-end;
}
.tab-group {
display: flex;
flex-direction: column;
gap: 2px;
flex-shrink: 0;
}
.tab-group + .tab-group {
border-left: 1px solid var(--border);
padding-left: var(--s5);
}
.tab-group-label {
font-size: 9px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-3);
opacity: 0.7;
padding: 0 14px;
}
.tab-group-btns {
display: flex;
}
/* ── Session lifecycle state dots ── */
.rh-state-ready { background: var(--green); }
.rh-state-partial { background: var(--yellow); }
.rh-state-live { background: var(--red); }
.rh-state-upcoming { background: var(--text-3); }
.rh-state-preparing { background: var(--text-3); opacity: 0.5; }
.rh-state-unavailable { background: var(--text-3); opacity: 0.4; }
.rh-state-cancelled { background: var(--text-3); opacity: 0.35; }
/* ── Pre-session (expected availability) view ── */
.rh-presession {
display: flex;
flex-direction: column;
gap: var(--s5);
}
.rh-presession-band {
padding: var(--s6) var(--s5);
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--gp-accent);
border-radius: 4px;
display: flex;
flex-direction: column;
gap: var(--s3);
}
.rh-presession-eyebrow {
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-3);
}
.rh-presession-title {
font-size: 22px;
font-weight: 700;
margin: 0;
}
.rh-presession-sub { color: var(--text-2); max-width: 60ch; }
.rh-presession-countdown {
font-size: 15px;
color: var(--text);
margin-top: var(--s3);
}
.rh-partial-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s3);
margin: 0 var(--s5) var(--s3);
padding: var(--s3) var(--s4);
border: 1px solid color-mix(in srgb, var(--yellow) 45%, var(--border));
background: color-mix(in srgb, var(--yellow) 12%, transparent);
color: var(--text-2);
font-size: 12px;
}
.rh-partial-banner-link {
appearance: none;
background: transparent;
border: 0;
color: var(--text);
text-decoration: underline;
cursor: pointer;
font: inherit;
}
.rh-expected-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--s4);
}
.rh-expected-card {
padding: var(--s4);
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
display: flex;
align-items: center;
gap: var(--s3);
color: var(--text-2);
font-size: 13px;
}
.rh-expected-card .rh-expected-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-3);
opacity: 0.6;
}
/* ── Loading / error recovery ── */
.rh-recover {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--s4);
padding: var(--s6) var(--s5);
}
.rh-recover-actions {
display: flex;
gap: var(--s3);
flex-wrap: wrap;
}
.rh-recover-btn {
padding: 8px 14px;
font-size: 12px;
font-weight: 600;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
color: var(--text);
cursor: pointer;
text-decoration: none;
}
.rh-recover-btn:hover { border-color: var(--gp-accent); }
.rh-recover-btn.primary { border-color: var(--red); color: var(--text); }
/* ── Diagnostics (secondary) action ── */
.rh-diagnostics-toggle {
align-self: flex-start;
margin-left: auto;
padding: 6px 12px;
font-size: 11px;
font-weight: 600;
color: var(--text-3);
background: none;
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
}
.rh-diagnostics-toggle:hover { color: var(--text-2); border-color: var(--gp-accent); }
.rh-diagnostics-toggle.active { color: var(--text); }
/* ── Dataset status view ── */ /* ── Dataset status view ── */
.ds-legend { .ds-legend {
display: flex; display: flex;
@@ -2656,6 +2905,31 @@ a { color: inherit; text-decoration: none; }
.rh-topbar-spacer { flex: 1; } .rh-topbar-spacer { flex: 1; }
.rh-back-weekend {
flex-shrink: 0;
padding: 4px 10px;
font-size: 10px;
font-weight: 700;
font-family: var(--f-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-2);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 2px;
transition: color 0.1s, border-color 0.1s, background 0.1s;
}
.rh-back-weekend:hover,
.rh-back-weekend:focus-visible {
color: var(--text);
border-color: var(--gp-accent);
background: var(--surface-h);
outline: none;
}
.rh-back-weekend:focus-visible {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--gp-accent) 45%, transparent);
}
.rh-switcher-toggle { .rh-switcher-toggle {
padding: 4px 10px; padding: 4px 10px;
font-size: 10px; font-size: 10px;
@@ -3231,7 +3505,10 @@ a { color: inherit; text-decoration: none; }
.rh-overview-row { grid-template-columns: 1fr; } .rh-overview-row { grid-template-columns: 1fr; }
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.rh-page { padding: var(--s4); gap: var(--s4); } .rh-page { padding: var(--s4); gap: var(--s4); padding-bottom: 88px; }
.rh-topbar { flex-wrap: wrap; gap: var(--s3); }
.rh-topbar-spacer { display: none; }
.rh-back-weekend { order: -1; }
.rh-identity-body { padding: var(--s3) var(--s4); gap: var(--s3); } .rh-identity-body { padding: var(--s3) var(--s4); gap: var(--s3); }
.rh-identity-decal { font-size: 28px; } .rh-identity-decal { font-size: 28px; }
.rh-identity-name { font-size: 17px; } .rh-identity-name { font-size: 17px; }

View File

@@ -0,0 +1,414 @@
/* ── Live → settling → analysis handoff (issue #74) ──
*
* State-transition presentation for Live: session/flag chrome that replaces
* the old connection-as-session conflation, the connection-loss warning, the
* archive read-only strip, and the settling/inactive handoff surface.
*/
/* Session flag: primary session state. Feed health is secondary. */
.live-session-flag {
display: inline-flex;
align-items: center;
gap: var(--s2);
padding: 5px 9px;
border-radius: 3px;
font-family: var(--f-mono);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.live-session-flag-live {
background: rgba(225, 6, 0, 0.14);
border: 1px solid rgba(225, 6, 0, 0.35);
color: #ff6b6b;
}
.live-session-flag-live.is-stale {
background: rgba(255, 214, 0, 0.1);
border-color: rgba(255, 214, 0, 0.35);
color: var(--yellow);
}
.live-session-flag-archive {
background: rgba(70, 140, 255, 0.12);
border: 1px solid rgba(70, 140, 255, 0.35);
color: #8bb7ff;
}
.live-session-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 0 0 currentColor;
animation: live-pulse 1.6s ease-in-out infinite;
}
.live-session-flag-live.is-stale .live-session-dot {
animation: none;
opacity: 0.55;
}
.live-feed-health {
display: inline-flex;
align-items: center;
gap: var(--s2);
padding: 4px 7px;
border-radius: 2px;
font-family: var(--f-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
border: 1px solid var(--border-2);
background: rgba(255, 255, 255, 0.03);
}
.live-feed-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.live-feed-connected {
color: var(--green);
border-color: rgba(57, 199, 58, 0.25);
background: rgba(57, 199, 58, 0.08);
}
.live-feed-connecting {
color: var(--yellow);
border-color: rgba(255, 214, 0, 0.25);
background: rgba(255, 214, 0, 0.08);
}
.live-feed-disconnected,
.live-feed-error {
color: #ff6b6b;
border-color: rgba(225, 6, 0, 0.24);
background: rgba(225, 6, 0, 0.08);
}
.live-banner-archive {
border-color: rgba(70, 140, 255, 0.28);
}
.live-archive-stamp {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.live-archive-readonly {
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
color: #8bb7ff;
}
.live-archive-captured {
font-size: 10px;
color: var(--text-3);
}
.live-state-archive {
background: rgba(70, 140, 255, 0.12);
color: #8bb7ff;
border: 1px solid rgba(70, 140, 255, 0.28);
}
/* Disconnected warning: a live session whose feed dropped. Distinct from the
* blue archive strip so a dropped feed is never mistaken for a read-only
* archive. The retained tower stays visible beneath it. */
.live-status-strip.live-status-warn {
display: flex;
align-items: center;
gap: var(--s3);
}
.live-status-strip.live-status-warn::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--yellow);
animation: live-pulse 1.6s ease-in-out infinite;
}
@keyframes live-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.live-status-strip.live-status-archive {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s4);
flex-wrap: wrap;
}
.live-archive-exit {
min-height: 28px;
padding: 0 var(--s4);
border: 1px solid rgba(70, 140, 255, 0.4);
border-radius: 4px;
background: rgba(70, 140, 255, 0.12);
color: #8bb7ff;
font-family: var(--f-mono);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
cursor: pointer;
}
.live-archive-exit:hover {
border-color: rgba(70, 140, 255, 0.7);
background: rgba(70, 140, 255, 0.2);
}
/* ── Handoff surface (settling + inactive) ── */
.live-handoff {
border: 1px solid var(--border-2);
border-radius: 10px;
padding: var(--s6) var(--s5);
background:
radial-gradient(120% 120% at 0% 0%, rgba(255, 255, 255, 0.04), transparent 55%),
var(--surface);
}
.live-handoff-settling {
border-left: 3px solid var(--yellow);
}
.live-handoff-inactive {
border-left: 3px solid var(--border-2);
}
.live-handoff-head {
display: flex;
flex-direction: column;
gap: var(--s3);
margin-bottom: var(--s5);
}
.live-handoff-eyebrow {
display: inline-flex;
align-items: center;
gap: var(--s2);
align-self: flex-start;
padding: 4px var(--s3);
border-radius: 3px;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.live-handoff-settling .live-handoff-eyebrow {
color: var(--yellow);
background: rgba(245, 158, 11, 0.14);
border: 1px solid rgba(245, 158, 11, 0.3);
}
.live-handoff-inactive .live-handoff-eyebrow {
color: var(--text-2);
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-2);
}
.live-handoff-title {
font-size: 26px;
line-height: 1.15;
font-weight: 800;
}
.live-handoff-captured {
font-size: 11px;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.live-handoff-sub {
font-size: 13px;
color: var(--text-2);
max-width: 62ch;
}
.live-handoff-feed {
align-self: flex-start;
margin-top: var(--s2);
}
/* Provisional final-order snapshot chips (settling only). */
.live-handoff-snapshot {
margin-bottom: var(--s5);
padding: var(--s4);
border: 1px dashed var(--border-2);
border-radius: 8px;
background: rgba(255, 255, 255, 0.02);
}
.live-handoff-snapshot-label {
display: block;
margin-bottom: var(--s3);
font-size: 10px;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.live-handoff-order {
display: flex;
flex-wrap: wrap;
gap: var(--s3);
list-style: none;
}
.live-handoff-order li {
display: inline-flex;
align-items: center;
gap: var(--s2);
padding: var(--s2) var(--s3);
border: 1px solid var(--border-2);
border-radius: 4px;
background: rgba(255, 255, 255, 0.03);
font-size: 12px;
}
.live-handoff-order li span:first-child {
color: var(--text-3);
font-size: 11px;
}
.live-handoff-code {
font-weight: 800;
color: var(--text);
}
/* ── Action cards ── */
.live-handoff-actions {
display: grid;
gap: var(--s3);
}
.live-handoff-action {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s4);
width: 100%;
padding: var(--s4) var(--s5);
border: 1px solid var(--border-2);
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
color: var(--text);
text-align: left;
cursor: pointer;
transition:
border-color 0.15s ease,
background 0.15s ease,
transform 0.15s ease;
}
button.live-handoff-action {
font: inherit;
}
.live-handoff-action:hover {
border-color: var(--border-glow);
background: rgba(255, 255, 255, 0.06);
transform: translateX(2px);
}
.live-handoff-action-body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.live-handoff-action-label {
display: inline-flex;
align-items: center;
gap: var(--s2);
font-size: 14px;
font-weight: 700;
}
.live-handoff-action-meta {
font-size: 11px;
color: var(--text-3);
}
/* The primary "open analysis" action is the strongest affordance. */
.live-handoff-primary {
border-color: rgba(230, 36, 41, 0.4);
background: linear-gradient(90deg, rgba(230, 36, 41, 0.14), transparent 60%);
}
.live-handoff-primary:hover {
border-color: rgba(230, 36, 41, 0.7);
background: linear-gradient(90deg, rgba(230, 36, 41, 0.22), transparent 60%);
}
/* Pending-analysis states read as "waiting", not actionable links. */
.live-handoff-primary[data-ready='false'] {
border-color: rgba(245, 158, 11, 0.4);
background: linear-gradient(90deg, rgba(245, 158, 11, 0.1), transparent 60%);
}
.live-handoff-pending {
cursor: default;
opacity: 0.75;
}
.live-handoff-pending:hover {
transform: none;
background: rgba(255, 255, 255, 0.03);
border-color: var(--border-2);
}
.live-handoff-next {
cursor: default;
}
.live-handoff-next:hover {
transform: none;
}
/* ── Responsive: keep the handoff usable at tablet / mobile widths ── */
@media (max-width: 640px) {
.live-handoff {
padding: var(--s5) var(--s4);
}
.live-handoff-title {
font-size: 21px;
}
.live-handoff-action {
padding: var(--s4);
}
.live-handoff-action-label {
font-size: 13px;
}
.live-status-strip.live-status-archive {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -0,0 +1,419 @@
/* ── Weekend adaptive home ── */
.wk-page {
max-width: 1280px;
margin: 0 auto;
padding: var(--s6) var(--s6) 64px;
width: 100%;
}
.wk-focus {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s5);
margin-bottom: var(--s5);
padding: var(--s5);
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--red);
border-radius: 8px;
}
.wk-focus-copy { display: flex; flex-direction: column; gap: var(--s2); min-width: 0; }
.wk-focus-eyebrow {
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-3);
}
.wk-focus-title {
font-size: 18px;
font-weight: 700;
line-height: 1.2;
}
.wk-focus-session {
font-size: 13px;
color: var(--text-2);
}
.wk-focus-cta { flex-shrink: 0; }
.wk-eyebrow {
display: flex;
align-items: center;
gap: var(--s4);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text-2);
margin-bottom: var(--s5);
}
.wk-eyebrow::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(90deg, var(--red), transparent);
}
.wk-card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--s4);
margin-bottom: var(--s4);
}
.wk-card-title {
font-size: 13px;
font-weight: 700;
color: var(--text);
}
.wk-card-link {
font-size: 11px;
color: var(--text-2);
white-space: nowrap;
}
.wk-card-link:hover { color: var(--text); }
.wk-flag { font-size: 22px; line-height: 1; }
/* ── CTAs ── */
.wk-cta {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--s3);
padding: var(--s4) var(--s5);
font-size: 13px;
font-weight: 600;
border-radius: 6px;
transition: background 0.12s, border-color 0.12s, transform 0.12s;
}
.wk-cta-primary { background: var(--red); color: #fff; }
.wk-cta-primary:hover { background: #ff3b3f; }
.wk-cta:disabled,
.wk-cta[aria-busy='true'] {
opacity: 0.55;
cursor: not-allowed;
pointer-events: none;
}
.wk-cta-ghost {
background: transparent;
color: var(--text-2);
border: 1px solid var(--border-2);
}
.wk-cta-ghost:hover { color: var(--text); border-color: var(--text-3); }
.wk-cta-wide { width: 100%; }
/* ── Between-races top grid ── */
.wk-top-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--s5);
margin-bottom: var(--s5);
}
.wk-mid-grid {
display: grid;
grid-template-columns: minmax(0, 340px) minmax(0, 1fr);
gap: var(--s5);
margin-bottom: var(--s5);
}
.wk-event-card {
min-width: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: var(--s5);
display: flex;
flex-direction: column;
gap: var(--s5);
}
.wk-event-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s4);
}
.wk-event-id { display: flex; align-items: center; gap: var(--s4); min-width: 0; }
.wk-event-name {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.01em;
overflow: hidden;
text-overflow: ellipsis;
}
.wk-event-tag {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
white-space: nowrap;
}
.wk-tag-done { color: var(--text-2); }
.wk-tag-next { color: var(--red); }
.wk-event-body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 200px);
gap: var(--s5);
align-items: stretch;
}
/* ── Podium ── */
.wk-podium { list-style: none; display: flex; flex-direction: column; gap: var(--s3); }
.wk-podium-row {
display: grid;
grid-template-columns: 20px minmax(0, 1fr) auto;
align-items: center;
gap: var(--s4);
padding: var(--s3) var(--s4);
background: var(--surface-h);
border-left: 3px solid var(--wk-team, var(--border-2));
border-radius: 4px;
}
.wk-podium-pos { font-weight: 700; color: var(--text-2); }
.wk-podium-code { font-weight: 700; color: var(--wk-team, var(--text)); overflow: hidden; text-overflow: ellipsis; }
.wk-podium-gap { color: var(--text-2); font-size: 12px; }
.wk-podium-empty { color: var(--text-3); font-size: 12px; padding: var(--s4); }
.wk-story-card {
display: flex;
flex-direction: column;
gap: var(--s3);
padding: var(--s4);
background: rgba(230, 36, 41, 0.05);
border: 1px solid rgba(230, 36, 41, 0.25);
border-radius: 8px;
}
.wk-story-label { font-size: 13px; font-weight: 700; color: var(--red); }
.wk-story-text { font-size: 12px; color: var(--text-2); flex: 1; }
/* ── Next event ── */
.wk-next-body { display: flex; flex-direction: column; gap: var(--s5); }
.wk-countdown { display: flex; gap: var(--s5); align-items: baseline; }
.wk-count-unit { font-size: 13px; color: var(--text-2); font-family: var(--f-mono); }
.wk-count-unit b { font-size: 40px; color: var(--red); font-weight: 700; margin-right: 2px; }
.wk-countdown-clock { font-size: 32px; font-weight: 700; color: var(--blue); }
.wk-countdown-live { font-size: 20px; font-weight: 700; color: var(--red); }
.wk-next-session { display: flex; flex-direction: column; gap: 2px; }
.wk-next-label { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-3); }
.wk-next-value { font-size: 15px; font-weight: 600; }
.wk-season-complete-copy { font-size: 13px; color: var(--text-2); }
/* ── Championship impact ── */
.wk-champ, .wk-briefing, .wk-season-nav {
min-width: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: var(--s5);
}
.wk-champ-list { list-style: none; display: flex; flex-direction: column; gap: var(--s3); }
.wk-champ-row {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto auto;
align-items: center;
gap: var(--s4);
padding: var(--s2) var(--s3);
border-left: 3px solid var(--wk-team, var(--border-2));
}
.wk-champ-pos { color: var(--text-3); }
.wk-champ-code { font-weight: 700; color: var(--wk-team, var(--text)); }
.wk-champ-pts { font-weight: 700; }
.wk-champ-gap { color: var(--text-3); font-size: 11px; min-width: 34px; text-align: right; }
.wk-champ-note { margin-top: var(--s4); font-size: 11px; color: var(--text-3); }
/* ── Season progress strip ── */
.wk-countdown-tbc { font-size: 14px; color: var(--text-3); }
.wk-season-progress { display: flex; flex-direction: column; gap: var(--s3); }
.wk-season-progress-head { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); }
.wk-season-bar {
position: relative;
height: 8px;
border-radius: 999px;
background: var(--surface-3);
overflow: hidden;
}
.wk-season-bar-fill {
position: absolute;
inset: 0 auto 0 0;
background: var(--red);
border-radius: 999px;
}
.wk-season-progress-label { font-size: 11px; color: var(--text-3); }
/* ── Briefing ── */
.wk-briefing-list { list-style: none; display: flex; flex-direction: column; }
.wk-briefing-item { border-top: 1px solid var(--border); }
.wk-briefing-item:first-child { border-top: none; }
.wk-briefing-link {
display: grid;
grid-template-columns: minmax(0, auto) minmax(0, 1fr) auto;
align-items: center;
gap: var(--s4);
padding: var(--s4) 0;
}
.wk-briefing-link:hover .wk-briefing-title { color: var(--red); }
.wk-briefing-cat { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--red); white-space: nowrap; }
.wk-briefing-title { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wk-briefing-source { font-size: 11px; color: var(--text-3); white-space: nowrap; }
/* ── Between-sessions / live ── */
.wk-sessions, .wk-live, .wk-pre { display: flex; flex-direction: column; gap: var(--s5); }
.wk-sessions-head { display: flex; flex-direction: column; gap: 2px; }
.wk-sessions-title { font-size: 28px; font-weight: 800; letter-spacing: -0.01em; }
.wk-sessions-circuit { font-size: 12px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; }
.wk-timeline {
list-style: none;
display: flex;
gap: var(--s3);
overflow-x: auto;
padding-bottom: var(--s3);
scrollbar-width: thin;
}
.wk-timeline-node {
flex: 1 0 auto;
min-width: 72px;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s2);
padding: var(--s3);
border: 1px solid var(--border);
border-radius: 6px;
}
.wk-timeline-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--surface-3); }
.wk-timeline-done .wk-timeline-dot { background: var(--green); }
.wk-timeline-live .wk-timeline-dot { background: var(--red); box-shadow: 0 0 0 3px rgba(230,36,41,0.25); }
.wk-timeline-next { border-color: var(--red); }
.wk-timeline-next .wk-timeline-dot { border: 2px solid var(--red); }
.wk-timeline-name { font-size: 12px; font-weight: 600; }
.wk-timeline-state { font-size: 9px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-3); }
.wk-timeline-done .wk-timeline-state { color: var(--green); }
.wk-timeline-live .wk-timeline-state, .wk-timeline-next .wk-timeline-state { color: var(--red); }
.wk-recap-card, .wk-upnext-card, .wk-live-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: var(--s5);
display: flex;
flex-direction: column;
gap: var(--s4);
}
.wk-recap-eyebrow, .wk-upnext-eyebrow { font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--green); }
.wk-recap-title, .wk-upnext-title { font-size: 20px; font-weight: 700; }
.wk-upnext-when { font-size: 12px; color: var(--text-3); }
.wk-upnext-card { border-color: rgba(230,36,41,0.3); }
.wk-live-card { border-color: rgba(230,36,41,0.35); }
.wk-live-head { display: flex; align-items: center; justify-content: space-between; }
.wk-live-pulse { width: 12px; height: 12px; border-radius: 50%; background: var(--red); box-shadow: 0 0 0 4px rgba(230,36,41,0.2); }
.wk-live-lead { font-size: 14px; color: var(--text-2); }
/* ── Pre-session ── */
.wk-pre-head {
display: flex;
align-items: center;
gap: var(--s5);
flex-wrap: wrap;
}
.wk-pre-countdown { display: flex; flex-direction: column; gap: 2px; margin-left: auto; }
.wk-pre-preview { min-width: 0; }
/* The folded preview page ships its own max-width page; reset it inside Weekend. */
.wk-pre-preview .preview-page { max-width: 100%; padding: 0; }
/* ── Status views ── */
.wk-status {
max-width: 640px;
margin: 64px auto;
text-align: center;
display: flex;
flex-direction: column;
gap: var(--s4);
align-items: center;
}
.wk-status-eyebrow { font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--text-3); }
.wk-status-title { font-size: 22px; font-weight: 700; }
.wk-status-sub { font-size: 13px; color: var(--text-2); }
.wk-status-actions { display: flex; gap: var(--s4); margin-top: var(--s4); }
.wk-status-error .wk-status-title { color: var(--red); }
/* ── Responsive ── */
@media (max-width: 900px) {
.wk-top-grid, .wk-mid-grid { grid-template-columns: 1fr; }
.wk-event-body { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
.wk-page { padding: var(--s5) var(--s5) 88px; }
.wk-focus {
flex-direction: column;
align-items: stretch;
}
.wk-focus-cta { width: 100%; justify-content: center; }
.wk-event-name { font-size: 20px; }
.wk-sessions-title { font-size: 22px; }
.wk-count-unit b { font-size: 32px; }
.wk-pre-head { flex-direction: column; align-items: flex-start; }
.wk-pre-countdown { margin-left: 0; }
}
/* ── Explore ── */
.wk-explore { display: flex; flex-direction: column; gap: var(--s6); }
.wk-explore-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: var(--s5);
}
.wk-explore-grid {
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--s4);
}
.wk-explore-card {
display: flex;
flex-direction: column;
gap: var(--s2);
padding: var(--s4);
min-width: 0;
background: var(--surface-h);
border: 1px solid var(--border);
border-radius: 8px;
height: 100%;
}
.wk-explore-card.is-link { transition: border-color 0.12s, background 0.12s; }
.wk-explore-card.is-link:hover { border-color: var(--text-3); background: var(--surface-2); }
.wk-explore-top { display: flex; justify-content: space-between; align-items: center; font-size: 11px; color: var(--text-3); }
.wk-explore-flag { font-size: 18px; }
.wk-explore-name { font-size: 14px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; }
.wk-explore-meta { font-size: 11px; color: var(--text-3); }
.wk-explore-state { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; margin-top: var(--s2); }
.wk-explore-state.is-done { color: var(--green); }
.wk-explore-state.is-upcoming { color: var(--text-3); }
.wk-explore-drivers { list-style: none; display: flex; flex-wrap: wrap; gap: var(--s3); }
.wk-driver-chip {
display: inline-flex;
align-items: center;
gap: var(--s3);
padding: var(--s3) var(--s4);
background: var(--surface-h);
border: 1px solid var(--border);
border-left: 3px solid var(--wk-team, var(--border-2));
border-radius: 6px;
transition: border-color 0.12s;
}
.wk-driver-chip:hover { border-color: var(--text-3); }
.wk-driver-pos { color: var(--text-3); font-size: 11px; }
.wk-driver-code { font-weight: 700; color: var(--wk-team, var(--text)); }
.wk-driver-pts { color: var(--text-2); font-size: 12px; }
/* ── Reduced motion ── */
@media (prefers-reduced-motion: reduce) {
.wk-cta, .wk-briefing-title, .wk-live-pulse, .wk-timeline-live .wk-timeline-dot { transition: none; }
}

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 { render, screen, waitFor, fireEvent, within } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BriefingPage } from '../pages/BriefingPage' import { BriefingPage } from '../pages/BriefingPage'
@@ -21,6 +21,7 @@ import {
fetchChampionshipHub, fetchChampionshipHub,
markNewsRead, markNewsRead,
} from '../api' } from '../api'
import { rememberResponseAvailability } from '../lib/fetch'
const mockFetchNews = vi.mocked(fetchNews) const mockFetchNews = vi.mocked(fetchNews)
const mockFetchNewsArticle = vi.mocked(fetchNewsArticle) const mockFetchNewsArticle = vi.mocked(fetchNewsArticle)
@@ -208,4 +209,62 @@ describe('BriefingPage digest layout', () => {
}) })
expect(screen.getByRole('tab', { name: /News/i })).toBeInTheDocument() 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()
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()
})
it('preserves Stale hub over routine local News when sources conflict', async () => {
const localNews = [...newsItems]
rememberResponseAvailability(localNews, { source: 'local', freshness: 'local' })
const staleHub = { ...hub }
rememberResponseAvailability(staleHub, { source: 'openf1', freshness: 'stale' })
mockFetchNews.mockResolvedValue(localNews)
mockFetchHub.mockResolvedValue(staleHub)
renderPage()
await waitFor(() => expect(screen.getByTestId('briefing-data-notice')).toHaveTextContent(/Stale/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 { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
import { ChampionshipPage } from '../pages/ChampionshipPage' import { ChampionshipPage } from '../pages/ChampionshipPage'
import type { ChampHubDriver, ChampHubTeam, ChampionshipHub } from '../types' import type { ChampHubDriver, ChampHubTeam, ChampionshipHub } from '../types'
import { rememberResponseAvailability } from '../lib/fetch'
vi.mock('../api', () => ({ vi.mock('../api', () => ({
fetchSeasons: vi.fn(), fetchSeasons: vi.fn(),
@@ -226,4 +227,36 @@ describe('ChampionshipPage', () => {
expect(rows[1]).toHaveTextContent('Red Bull') expect(rows[1]).toHaveTextContent('Red Bull')
expect(rows[1]).toHaveTextContent('91') 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,245 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
import { CommandCenterPage } from '../pages/CommandCenterPage'
import type { DatasetInfo, Meeting, Weekend } from '../types'
vi.mock('../api', () => ({
fetchSeasons: vi.fn(),
fetchLocalMeetings: vi.fn(),
fetchSeasonMeetings: vi.fn(),
fetchSessions: vi.fn(),
fetchWeekend: vi.fn(),
fetchLiveState: vi.fn(),
fetchChampionshipHub: vi.fn(),
fetchRaceHub: vi.fn(),
}))
import {
fetchSeasons,
fetchLocalMeetings,
fetchSeasonMeetings,
fetchSessions,
fetchWeekend,
fetchLiveState,
fetchChampionshipHub,
fetchRaceHub,
} from '../api'
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
const mockFetchSeasonMeetings = vi.mocked(fetchSeasonMeetings)
const mockFetchSessions = vi.mocked(fetchSessions)
const mockFetchWeekend = vi.mocked(fetchWeekend)
const mockFetchLiveState = vi.mocked(fetchLiveState)
const mockFetchChampionshipHub = vi.mocked(fetchChampionshipHub)
const mockFetchRaceHub = vi.mocked(fetchRaceHub)
const meeting: Meeting = {
meeting_key: 1229,
meeting_name: 'Monaco',
meeting_official_name: 'FORMULA 1 GRAND PRIX DE MONACO 2025',
location: 'Monaco',
country_name: 'Monaco',
country_code: 'MON',
country_flag: '',
circuit_short_name: 'Monaco',
date_start: '2025-05-23T00:00:00+00:00',
date_end: '2025-05-25T00:00:00+00:00',
year: 2025,
}
const fullDatasets: Record<string, DatasetInfo> = {
meeting: { status: 'available', source: 'local', count: 1 },
session: { status: 'available', source: 'local', count: 1 },
drivers: { status: 'available', source: 'local', count: 20 },
results: { status: 'available', source: 'local', count: 20 },
starting_grid: { status: 'available', source: 'local', count: 20 },
stints: { status: 'available', source: 'local', count: 2 },
pit_stops: { status: 'available', source: 'local', count: 1 },
positions: { status: 'available', source: 'local', count: 3 },
race_control: { status: 'available', source: 'local', count: 1 },
weather: { status: 'available', source: 'local', count: 1 },
laps: { status: 'available', source: 'local', count: 1 },
}
const weekend: Weekend = {
source: 'local',
meeting_key: 1229,
meeting,
default_session_key: 9472,
sessions: [
{
session: {
session_key: 9472,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1229,
date_start: '2025-05-25T13:00:00+00:00',
date_end: '2025-05-25T15:00:00+00:00',
gmt_offset: '02:00:00',
},
source: 'local',
datasets: fullDatasets,
},
],
}
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
const rootRoute = createRootRoute({
component: () => (
<QueryClientProvider client={queryClient}>
<CommandCenterPage />
</QueryClientProvider>
),
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: CommandCenterPage,
})
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
return render(<RouterProvider router={router} />)
}
describe('CommandCenterPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchLiveState.mockResolvedValue({ is_live: false, data: null })
mockFetchSessions.mockResolvedValue([])
mockFetchChampionshipHub.mockResolvedValue({
season: 2025,
round: 1,
total_rounds: 1,
rounds_left: 0,
last_race: 'Monaco',
round_labels: ['R1'],
drivers: [],
teams: [],
})
mockFetchRaceHub.mockResolvedValue({
source: 'local',
session_key: 9472,
datasets: fullDatasets,
results: [
{
driver_number: 1,
position: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull Racing',
team_colour: '3671c6',
dnf: false,
dns: false,
dsq: false,
duration: 7200,
gap_to_leader: null,
number_of_laps: 78,
points: 25,
session_key: 9472,
meeting_key: 1229,
},
],
starting_grid: [],
drivers: [],
stints: [],
pit_stops: [],
positions: [],
race_control: [],
weather: [],
laps: [],
chapters: [],
})
})
it('shows empty state when no seasons are ingested', async () => {
mockFetchSeasons.mockResolvedValue([])
renderPage()
await waitFor(() => {
expect(screen.getByTestId('command-center-empty')).toBeInTheDocument()
})
expect(screen.getByText('No local data yet')).toBeInTheDocument()
})
it('shows weekend identity band and schedule when data exists', async () => {
mockFetchSeasons.mockResolvedValue([2025])
mockFetchLocalMeetings.mockResolvedValue([meeting])
mockFetchSeasonMeetings.mockResolvedValue([meeting])
mockFetchWeekend.mockResolvedValue(weekend)
renderPage()
await waitFor(() => {
expect(screen.getByTestId('command-center')).toBeInTheDocument()
})
await waitFor(() => {
expect(screen.getByTestId('cc-session-9472')).toBeInTheDocument()
})
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar')
expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01')
expect(screen.getByText('No live session')).toBeInTheDocument()
expect(screen.getByTestId('hero-last-race-link')).toHaveTextContent('Monaco')
})
it('uses OpenF1 calendar metadata to focus the current weekend when local ingest is behind', async () => {
const canada = {
...meeting,
meeting_key: 1301,
meeting_name: 'Canada',
country_name: 'Canada',
country_code: 'CAN',
circuit_short_name: 'Montreal',
date_start: '2026-05-22T00:00:00+00:00',
date_end: '2026-05-24T23:59:59+00:00',
year: 2026,
}
const monaco = {
...meeting,
meeting_key: 1302,
meeting_name: 'Monaco',
date_start: '2026-06-05T00:00:00+00:00',
date_end: '2026-06-07T23:59:59+00:00',
year: 2026,
}
vi.setSystemTime(new Date('2026-06-06T15:15:00Z'))
mockFetchSeasons.mockResolvedValue([2026])
mockFetchLocalMeetings.mockResolvedValue([canada])
mockFetchSeasonMeetings.mockResolvedValue([canada, monaco])
mockFetchWeekend.mockResolvedValue({ ...weekend, meeting: canada, meeting_key: canada.meeting_key })
mockFetchSessions.mockResolvedValue([
{
session_key: 9602,
session_name: 'Qualifying',
session_type: 'Qualifying',
meeting_key: monaco.meeting_key,
date_start: '2026-06-06T15:00:00+00:00',
date_end: '2026-06-06T16:00:00+00:00',
gmt_offset: '02:00:00',
},
])
renderPage()
await waitFor(() => {
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
})
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Live now')
expect(screen.getByTestId('cc-session-9602')).toHaveTextContent('On track')
expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live')
vi.useRealTimers()
})
})

View File

@@ -116,7 +116,7 @@ describe('DataLibraryPage', () => {
expect(await screen.findByTestId('data-library')).toBeInTheDocument() expect(await screen.findByTestId('data-library')).toBeInTheDocument()
await waitFor(() => { await waitFor(() => {
expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025) expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025, expect.any(AbortSignal))
}) })
expect(await screen.findByTestId('meeting-detail')).toBeInTheDocument() expect(await screen.findByTestId('meeting-detail')).toBeInTheDocument()

View File

@@ -120,7 +120,7 @@ describe('DriverProfilePage', () => {
// With an explicit year, the seasons list is not needed. // With an explicit year, the seasons list is not needed.
expect(mockFetchSeasons).not.toHaveBeenCalled() expect(mockFetchSeasons).not.toHaveBeenCalled()
expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025) expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025, expect.any(AbortSignal))
}) })
it('defaults to the latest season when no year is given', async () => { it('defaults to the latest season when no year is given', async () => {
@@ -129,16 +129,19 @@ describe('DriverProfilePage', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('driver-profile')).toBeInTheDocument() expect(screen.getByTestId('driver-profile')).toBeInTheDocument()
}) })
expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025) expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025, expect.any(AbortSignal))
}) })
it('shows the API error message on failure', async () => { it('shows a user-facing error with retry instead of raw API jargon', async () => {
mockFetchSummary.mockRejectedValue(new Error('API 404: Not Found')) mockFetchSummary.mockRejectedValue(new Error('API 404: Not Found'))
renderPage({ driverNumber: 99, year: 2025 }) renderPage({ driverNumber: 99, year: 2025 })
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('API 404: Not Found')).toBeInTheDocument() expect(screen.getByTestId('driver-profile-error')).toBeInTheDocument()
}) })
expect(screen.queryByText(/API 404/i)).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
expect(screen.getByTestId('driver-profile-error')).toHaveTextContent(/Something went wrong/i)
}) })
it('shows empty states when no rounds are completed', async () => { it('shows empty states when no rounds are completed', async () => {

View File

@@ -209,32 +209,6 @@ describe('TimingTower', () => {
expect(screen.getByText('DRS range')).toBeInTheDocument() expect(screen.getByText('DRS range')).toBeInTheDocument()
}) })
it('derives a gap to P1 from best laps when the practice feed omits GapToLeader', () => {
const practiceRows = [
makeRow('1', 1, 'VER', { BestLapTime: '1:45.944', GapToLeader: '' }),
makeRow('4', 2, 'NOR', { BestLapTime: '1:46.134', GapToLeader: '' }),
makeRow('16', 3, 'LEC', { BestLapTime: '', GapToLeader: '' }),
]
render(
<TimingTower
rows={practiceRows}
session={{
MeetingName: 'Belgian Grand Prix',
CircuitName: 'Spa',
SessionType: 'Practice',
SessionName: 'Practice 2',
Path: '',
}}
/>,
)
// Leader shows a clear leader marker, not a fabricated gap.
expect(screen.getByText('VER').closest('tr')).toHaveTextContent('—')
expect(screen.getByText('+0.190')).toBeInTheDocument()
// A driver without a valid best lap gets no invented gap.
const lecRow = screen.getByText('LEC').closest('tr')!
expect(lecRow).not.toHaveTextContent('+')
})
it('renders the SQ1 cutoff after P17 and marks rows below as at risk', () => { it('renders the SQ1 cutoff after P17 and marks rows below as at risk', () => {
const sprintRows = Array.from({ length: 22 }, (_, index) => const sprintRows = Array.from({ length: 22 }, (_, index) =>
makeRow(String(index + 1), index + 1, `D${index + 1}`), makeRow(String(index + 1), index + 1, `D${index + 1}`),

View File

@@ -0,0 +1,289 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import {
LiveHandoff,
analysisIsReady,
handoffAnalysisSession,
shouldPollHandoffAnalysis,
} from '../components/live/LiveHandoff'
import type { LiveTimingRow } from '../lib/live'
import type { ContextSession, WeekendContext } from '../types'
function session(key: number, name: string, type = name): ContextSession['session'] {
return {
session_key: key,
session_name: name,
session_type: type,
meeting_key: 1,
date_start: '2026-07-05T14:00:00Z',
date_end: '2026-07-05T16:00:00Z',
gmt_offset: '',
}
}
function contextSession(
key: number,
name: string,
localAnalysis: string,
): ContextSession {
return {
session: session(key, name),
availability: {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: localAnalysis,
freshness: 'fresh',
limitations: [],
},
}
}
const baseContext: WeekendContext = {
temporal_state: 'session_settling',
focus_meeting: {
meeting_key: 1,
meeting_name: 'British Grand Prix',
meeting_official_name: 'British Grand Prix',
location: 'Silverstone',
country_name: 'UK',
country_code: 'GB',
country_flag: '',
circuit_short_name: 'Silverstone',
date_start: '2026-07-03T09:00:00Z',
date_end: '2026-07-05T16:00:00Z',
year: 2026,
},
championship_round: 1,
total_championship_rounds: 1,
}
/** Canonical contract: archive-only just-finished Race vs older ready Practice. */
function archiveOnlySettlingContext(previousAnalysis: string): WeekendContext {
return {
...baseContext,
// Older already-ingested session remains the default analysis target.
default_analysis_session: contextSession(10, 'Practice 1', 'complete'),
// Just-finished Race is archive-complete but not yet analysis-ready.
previous_completed_session: contextSession(99, 'Race', previousAnalysis),
next_session: contextSession(12, 'Qualifying', 'not_applicable'),
}
}
const rows: LiveTimingRow[] = [
{ RacingNumber: '1', Position: 1, Driver: { RacingNumber: '1', Position: 1 } as never, Info: { Tla: 'VER' } as never },
{ RacingNumber: '4', Position: 2, Driver: { RacingNumber: '4', Position: 2 } as never, Info: { Tla: 'NOR' } as never },
]
describe('analysisIsReady', () => {
it('is true only when local analysis is complete', () => {
expect(analysisIsReady(contextSession(11, 'Race', 'complete'))).toBe(true)
expect(analysisIsReady(contextSession(11, 'Race', 'pending'))).toBe(false)
expect(analysisIsReady(contextSession(11, 'Race', 'partial'))).toBe(false)
expect(analysisIsReady(undefined)).toBe(false)
})
})
describe('handoffAnalysisSession', () => {
it('settling prefers previous_completed_session over an older default', () => {
const ctx = archiveOnlySettlingContext('pending')
const analysis = handoffAnalysisSession(ctx, 'settling')
expect(analysis?.session.session_key).toBe(99)
expect(analysis?.session.session_name).toBe('Race')
expect(analysisIsReady(analysis)).toBe(false)
})
it('inactive prefers default_analysis_session (shared analysisSessionKey)', () => {
const ctx = archiveOnlySettlingContext('pending')
const analysis = handoffAnalysisSession(ctx, 'inactive')
expect(analysis?.session.session_key).toBe(10)
expect(analysis?.session.session_name).toBe('Practice 1')
})
it('settling falls back to default when previous is absent', () => {
const ctx = {
...baseContext,
default_analysis_session: contextSession(11, 'Race', 'pending'),
}
expect(handoffAnalysisSession(ctx, 'settling')?.session.session_key).toBe(11)
})
})
describe('shouldPollHandoffAnalysis', () => {
it('keeps polling when previous is pending even if default is already complete', () => {
expect(shouldPollHandoffAnalysis(archiveOnlySettlingContext('pending'))).toBe(true)
})
it('stops polling once the just-finished previous session is ready', () => {
expect(shouldPollHandoffAnalysis(archiveOnlySettlingContext('complete'))).toBe(false)
})
})
describe('LiveHandoff settling', () => {
it('shows SESSION SETTLING with a pending analysis action while ingesting', () => {
render(
<LiveHandoff
phase="settling"
transport="connected"
context={{
...baseContext,
previous_completed_session: contextSession(11, 'Race', 'pending'),
}}
rows={rows}
capturedAt="2026-07-05T16:02:00Z"
hasArchive
onViewArchive={vi.fn()}
/>,
)
expect(screen.getByTestId('live-settling')).toBeInTheDocument()
expect(screen.getByText('SESSION SETTLING')).toBeInTheDocument()
expect(screen.getByTestId('live-handoff-captured')).toHaveTextContent('Final feed snapshot captured')
const action = screen.getByTestId('live-handoff-analysis')
expect(action).toHaveAttribute('href', '/race-hub?session_key=11')
expect(action).toHaveAttribute('data-ready', 'false')
expect(action).toHaveTextContent('Open Race analysis')
expect(action).toHaveTextContent(/analysis will fill in as data ingests/i)
// Provisional final order from the retained snapshot.
expect(screen.getByTestId('live-handoff-snapshot')).toHaveTextContent('VER')
})
it('links/labels/readiness follow archive-only previous, not an older ready default', () => {
render(
<LiveHandoff
phase="settling"
transport="connected"
context={archiveOnlySettlingContext('pending')}
rows={rows}
capturedAt="2026-07-05T16:02:00Z"
hasArchive
onViewArchive={vi.fn()}
/>,
)
const action = screen.getByTestId('live-handoff-analysis')
expect(action).toHaveAttribute('href', '/race-hub?session_key=99')
expect(action).toHaveAttribute('data-ready', 'false')
expect(action).toHaveTextContent('Open Race analysis')
expect(action).toHaveTextContent(/Settling — analysis will fill in as data ingests/i)
expect(action).not.toHaveTextContent('Practice 1')
expect(action).not.toHaveTextContent(/full timing, strategy & story ready/i)
})
it('flips to analysis-ready once local ingestion completes', () => {
render(
<LiveHandoff
phase="settling"
transport="connected"
context={{
...baseContext,
previous_completed_session: contextSession(11, 'Race', 'complete'),
}}
rows={rows}
capturedAt="2026-07-05T16:02:00Z"
hasArchive={false}
onViewArchive={vi.fn()}
/>,
)
const action = screen.getByTestId('live-handoff-analysis')
expect(action).toHaveAttribute('data-ready', 'true')
expect(action).toHaveTextContent(/full timing, strategy & story ready/i)
})
it('never renders live/connected chrome or a countdown in settling', () => {
const { container } = render(
<LiveHandoff
phase="settling"
transport="connected"
context={{
...baseContext,
previous_completed_session: contextSession(11, 'Race', 'complete'),
}}
rows={rows}
capturedAt="2026-07-05T16:02:00Z"
hasArchive
onViewArchive={vi.fn()}
/>,
)
expect(screen.queryByText('LIVE SESSION')).not.toBeInTheDocument()
expect(screen.queryByTestId('live-clock')).not.toBeInTheDocument()
expect(container.querySelector('.live-session-flag-live')).toBeNull()
})
it('uses non-settling readiness copy when inactive and analysis is still ingesting', () => {
render(
<LiveHandoff
phase="inactive"
transport="connected"
context={{ ...baseContext, default_analysis_session: contextSession(11, 'Race', 'pending') }}
rows={[]}
hasArchive={false}
onViewArchive={vi.fn()}
/>,
)
const action = screen.getByTestId('live-handoff-analysis')
expect(action).toHaveAttribute('data-ready', 'false')
expect(action).toHaveTextContent(/analysis will fill in as data ingests/i)
expect(action).not.toHaveTextContent(/^Settling/i)
})
})
describe('LiveHandoff inactive', () => {
it('uses shared next + recap context instead of a telemetry-offline dead end', () => {
render(
<LiveHandoff
phase="inactive"
transport="connected"
context={{
...baseContext,
temporal_state: 'between_sessions',
default_analysis_session: contextSession(11, 'Practice 1', 'complete'),
next_session: contextSession(12, 'Qualifying', 'not_applicable'),
previous_completed_session: contextSession(10, 'Practice 2', 'complete'),
}}
rows={[]}
hasArchive={false}
onViewArchive={vi.fn()}
/>,
)
expect(screen.getByTestId('live-inactive')).toBeInTheDocument()
expect(screen.getByText('NO LIVE SESSION')).toBeInTheDocument()
expect(screen.getByTestId('live-handoff-next')).toHaveTextContent('Qualifying')
expect(screen.getByTestId('live-handoff-recap')).toHaveTextContent('Practice 2')
// No provisional-order snapshot when inactive.
expect(screen.queryByTestId('live-handoff-snapshot')).not.toBeInTheDocument()
})
it('does not duplicate the recap card when previous equals the analysis target', () => {
render(
<LiveHandoff
phase="inactive"
transport="connected"
context={{
...baseContext,
default_analysis_session: contextSession(11, 'Race', 'complete'),
previous_completed_session: contextSession(11, 'Race', 'complete'),
}}
rows={[]}
hasArchive={false}
onViewArchive={vi.fn()}
/>,
)
expect(screen.queryByTestId('live-handoff-recap')).not.toBeInTheDocument()
})
it('falls back to Weekend when the context has nothing to offer', () => {
render(
<LiveHandoff
phase="inactive"
transport="error"
context={{ temporal_state: 'no_season', championship_round: 0, total_championship_rounds: 0 }}
rows={[]}
hasArchive={false}
onViewArchive={vi.fn()}
/>,
)
expect(screen.getByTestId('live-handoff-fallback')).toHaveTextContent('Weekend')
})
})

View File

@@ -1,32 +1,34 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LiveTimingPage } from '../pages/LiveTimingPage' import { LiveTimingPage, WEEKEND_CONTEXT_POLL_MS } from '../pages/LiveTimingPage'
import type { LiveStateResponse, LiveStreamData } from '../types' import type { LiveStateResponse, LiveStreamData, WeekendContext } from '../types'
vi.mock('../api', () => ({ vi.mock('../api', () => ({
fetchLiveState: vi.fn(), fetchLiveState: vi.fn(),
fetchLiveTrackOutline: vi.fn(), fetchLiveTrackOutline: vi.fn(),
fetchWeekendContext: vi.fn(),
})) }))
import { fetchLiveState, fetchLiveTrackOutline } from '../api' import { fetchLiveState, fetchLiveTrackOutline, fetchWeekendContext } from '../api'
const mockFetchLiveState = vi.mocked(fetchLiveState) const mockFetchLiveState = vi.mocked(fetchLiveState)
const mockFetchLiveTrackOutline = vi.mocked(fetchLiveTrackOutline) const mockFetchLiveTrackOutline = vi.mocked(fetchLiveTrackOutline)
const mockFetchWeekendContext = vi.mocked(fetchWeekendContext)
// A MockEventSource that opens on construct but never emits a snapshot, so the
// initial /api/v1/live/state query drives the rendered phase.
class MockEventSource { class MockEventSource {
onopen: (() => void) | null = null onopen: (() => void) | null = null
onerror: (() => void) | null = null onerror: (() => void) | null = null
constructor() { constructor() {
setTimeout(() => this.onopen?.(), 0) setTimeout(() => this.onopen?.(), 0)
} }
addEventListener() {} addEventListener() {}
close() {} close() {}
} }
const archivedSnapshot: LiveStreamData = { const raceSnapshot: LiveStreamData = {
Drivers: { Drivers: {
'1': { '1': {
RacingNumber: '1', RacingNumber: '1',
@@ -63,19 +65,10 @@ const archivedSnapshot: LiveStreamData = {
LastName: 'Verstappen', LastName: 'Verstappen',
}, },
}, },
Tyres: { Tyres: { '1': { Compound: 'HARD', New: false, Age: 12 } },
'1': { Compound: 'HARD', New: false, Age: 12 },
},
Telemetry: {}, Telemetry: {},
RCMessages: [], RCMessages: [],
Weather: { Weather: { AirTemp: 22, TrackTemp: 41, Humidity: 58, WindSpeed: 3, WindDir: 180, Rainfall: false },
AirTemp: 22,
TrackTemp: 41,
Humidity: 58,
WindSpeed: 3,
WindDir: 180,
Rainfall: false,
},
Session: { Session: {
MeetingName: 'Testonia Grand Prix', MeetingName: 'Testonia Grand Prix',
CircuitName: 'Testring', CircuitName: 'Testring',
@@ -84,9 +77,9 @@ const archivedSnapshot: LiveStreamData = {
Path: '', Path: '',
}, },
TeamRadio: [], TeamRadio: [],
SessionStatus: 'Finished', SessionStatus: 'Started',
TrackStatus: '1', TrackStatus: '1',
CurrentLap: 57, CurrentLap: 30,
TotalLaps: 57, TotalLaps: 57,
Clock: '', Clock: '',
ClockRefTime: '', ClockRefTime: '',
@@ -94,17 +87,93 @@ const archivedSnapshot: LiveStreamData = {
Stints: {}, Stints: {},
} }
function renderPage(response: LiveStateResponse) { function weekendContext(localAnalysis: string): WeekendContext {
const queryClient = new QueryClient({ return {
defaultOptions: { queries: { retry: false } }, temporal_state: 'session_settling',
}) focus_meeting: {
meeting_key: 1,
meeting_name: 'Testonia Grand Prix',
meeting_official_name: 'Testonia Grand Prix',
location: 'Testring',
country_name: 'Testonia',
country_code: 'TS',
country_flag: '',
circuit_short_name: 'Testring',
date_start: '2026-07-03T09:00:00Z',
date_end: '2026-07-05T16:00:00Z',
year: 2026,
},
previous_completed_session: {
session: {
session_key: 99,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1,
date_start: '2026-07-05T14:00:00Z',
date_end: '2026-07-05T16:00:00Z',
gmt_offset: '',
},
availability: {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'available',
local_analysis: localAnalysis,
freshness: 'fresh',
limitations: [],
},
},
championship_round: 1,
total_championship_rounds: 1,
}
}
/** Just-finished archive-only Race + older already-ready Practice default. */
function archiveOnlySettlingContext(previousAnalysis: string): WeekendContext {
return {
...weekendContext(previousAnalysis),
default_analysis_session: {
session: {
session_key: 10,
session_name: 'Practice 1',
session_type: 'Practice',
meeting_key: 1,
date_start: '2026-07-04T12:00:00Z',
date_end: '2026-07-04T13:00:00Z',
gmt_offset: '',
},
availability: {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'available',
local_analysis: 'complete',
freshness: 'fresh',
limitations: [],
},
},
}
}
function renderPage(
response: LiveStateResponse,
context?: WeekendContext,
options: { setWeekendContext?: boolean } = {},
) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
mockFetchLiveState.mockResolvedValue(response) mockFetchLiveState.mockResolvedValue(response)
mockFetchLiveTrackOutline.mockResolvedValue({ mockFetchLiveTrackOutline.mockResolvedValue({
circuit_key: 1, circuit_key: 1,
points: [], points: [],
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 }, bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
}) })
// Allow callers to pre-configure the weekend-context mock (e.g. pending→complete
// polling) without this helper overwriting the chain.
if (options.setWeekendContext !== false) {
mockFetchWeekendContext.mockResolvedValue(
context ?? { temporal_state: 'no_season', championship_round: 0, total_championship_rounds: 0 },
)
}
return render( return render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<LiveTimingPage /> <LiveTimingPage />
@@ -112,7 +181,7 @@ function renderPage(response: LiveStateResponse) {
) )
} }
describe('LiveTimingPage archive mode', () => { describe('LiveTimingPage', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
Object.defineProperty(window, 'EventSource', { Object.defineProperty(window, 'EventSource', {
@@ -122,68 +191,365 @@ describe('LiveTimingPage archive mode', () => {
}) })
}) })
it('keeps archived snapshots behind the View Last Session action', async () => { afterEach(() => {
renderPage({ vi.useRealTimers()
})
it('renders the live timing tower for an active session', async () => {
renderPage({ is_live: true, data: raceSnapshot })
expect(await screen.findByText('Timing Tower')).toBeInTheDocument()
expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'live')
expect(screen.getByTestId('live-session-flag')).toHaveTextContent('LIVE SESSION')
expect(screen.getByTestId('live-feed-health')).toBeInTheDocument()
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
})
it('enters the settling handoff (not archive) when the session ends cleanly', async () => {
renderPage(
{
is_live: false, is_live: false,
data: null, data: null,
last_snapshot: archivedSnapshot, last_snapshot: { ...raceSnapshot, SessionStatus: 'Finished' },
last_positions: { last_snapshot_at: '2026-07-05T16:02:00Z',
'1': { x: 10, y: 20, z: 0, status: 'OnTrack' },
}, },
last_snapshot_at: '2026-07-04T14:00:00Z', weekendContext('pending'),
)
const settling = await screen.findByTestId('live-settling')
expect(settling).toBeInTheDocument()
expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'settling')
// Just-finished previous_completed_session drives the primary action target.
await waitFor(() =>
expect(screen.getByTestId('live-handoff-analysis')).toHaveAttribute(
'href',
'/race-hub?session_key=99',
),
)
// The timing tower is not shown while settling — it is a handoff surface.
expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument()
}) })
expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active') it('settles against archive-only previous, not an older ready default_analysis_session', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
mockFetchWeekendContext
.mockResolvedValueOnce(archiveOnlySettlingContext('pending'))
.mockResolvedValue(archiveOnlySettlingContext('complete'))
renderPage(
{
is_live: false,
data: null,
last_snapshot: { ...raceSnapshot, SessionStatus: 'Finished' },
last_snapshot_at: '2026-07-05T16:02:00Z',
},
undefined,
{ setWeekendContext: false },
)
const action = await screen.findByTestId('live-handoff-analysis')
expect(action).toHaveAttribute('href', '/race-hub?session_key=99')
expect(action).toHaveAttribute('data-ready', 'false')
expect(action).toHaveTextContent('Open Race analysis')
expect(action).not.toHaveTextContent('Practice 1')
// Older default is already complete — polling must continue for previous.
await act(async () => {
await vi.advanceTimersByTimeAsync(WEEKEND_CONTEXT_POLL_MS + 500)
})
await waitFor(() =>
expect(screen.getByTestId('live-handoff-analysis')).toHaveAttribute('data-ready', 'true'),
)
expect(screen.getByTestId('live-handoff-analysis')).toHaveAttribute(
'href',
'/race-hub?session_key=99',
)
expect(mockFetchWeekendContext.mock.calls.length).toBeGreaterThan(1)
})
it('flips settling → analysis-ready when polling sees ingestion complete', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
// First fetch: pending. Subsequent polls: complete.
mockFetchWeekendContext
.mockResolvedValueOnce(weekendContext('pending'))
.mockResolvedValue(weekendContext('complete'))
renderPage(
{
is_live: false,
data: null,
last_snapshot: { ...raceSnapshot, SessionStatus: 'Finished' },
last_snapshot_at: '2026-07-05T16:02:00Z',
},
undefined,
{ setWeekendContext: false },
)
const action = await screen.findByTestId('live-handoff-analysis')
expect(action).toHaveAttribute('data-ready', 'false')
expect(action).toHaveTextContent(/analysis will fill in as data ingests/i)
await act(async () => {
await vi.advanceTimersByTimeAsync(WEEKEND_CONTEXT_POLL_MS + 500)
})
await waitFor(() =>
expect(screen.getByTestId('live-handoff-analysis')).toHaveAttribute('data-ready', 'true'),
)
expect(screen.getByTestId('live-handoff-analysis')).toHaveTextContent(
/full timing, strategy & story ready/i,
)
})
it('retains the last snapshot with a disconnected warning (not settling) on a feed drop', async () => {
// is_live=false but the retained snapshot has a NON-terminal SessionStatus:
// the FIA feed dropped while the session was still active.
renderPage(
{
is_live: false,
data: null,
last_snapshot: { ...raceSnapshot, SessionStatus: 'Started' },
last_snapshot_at: '2026-07-05T15:30:00Z',
},
weekendContext('pending'),
)
expect(await screen.findByTestId('live-disconnected-strip')).toHaveTextContent(
/showing the last live data/i,
)
expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'disconnected')
// The retained live tower stays visible; we never fell into settling/archive.
expect(screen.getByText('Timing Tower')).toBeInTheDocument()
expect(screen.queryByTestId('live-settling')).not.toBeInTheDocument()
expect(screen.queryByTestId('live-archive-strip')).not.toBeInTheDocument()
// SSE may still be open (MockEventSource opens) — health must not say healthy.
await waitFor(() =>
expect(screen.getByTestId('live-feed-health')).toHaveTextContent(/reconnecting/i),
)
expect(screen.getByTestId('live-feed-health')).not.toHaveTextContent(/feed healthy/i)
})
it('keeps the settled snapshot behind an explicit read-only archive action', async () => {
renderPage(
{
is_live: false,
data: null,
last_snapshot: { ...raceSnapshot, SessionStatus: 'Finished' },
last_positions: { '1': { x: 10, y: 20, z: 0, status: 'OnTrack' } },
last_snapshot_at: '2026-07-05T16:02:00Z',
},
weekendContext('complete'),
)
// Settling first — the tower is hidden until the user opens the archive.
await screen.findByTestId('live-settling')
expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument() expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /view last session/i })) fireEvent.click(screen.getByTestId('live-handoff-archive'))
await waitFor(() => { await waitFor(() =>
expect(screen.getByTestId('live-archive-strip')).toHaveTextContent('Archived snapshot') expect(screen.getByTestId('live-archive-strip')).toHaveTextContent(/read-only/i),
}) )
expect(screen.getByText('Timing Tower')).toBeInTheDocument() expect(screen.getByText('Timing Tower')).toBeInTheDocument()
expect(screen.getAllByText('VER').length).toBeGreaterThan(0) // Archive presents its read-only, timestamped chrome — never LIVE.
expect(screen.getByText('archive')).toBeInTheDocument() expect(screen.getByText('archive')).toBeInTheDocument()
expect(screen.queryByText('LIVE SESSION')).not.toBeInTheDocument()
}) })
it('does not show the archive action when no snapshot is retained', async () => { it('shows inactive handoff when nothing is live or retained', async () => {
renderPage({ is_live: false, data: null }) renderPage(
{ is_live: false, data: null },
expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active') {
expect(screen.queryByRole('button', { name: /view last session/i })).not.toBeInTheDocument() temporal_state: 'between_weekends',
}) next_session: {
session: {
it('renders the live session for a red-flag paused snapshot instead of the inactive empty state', async () => { session_key: 21,
renderPage({ session_name: 'Practice 1',
is_live: true, session_type: 'Practice 1',
data: { meeting_key: 2,
...archivedSnapshot, date_start: '2026-07-17T09:00:00Z',
SessionStatus: 'Inactive', date_end: '2026-07-17T10:00:00Z',
TrackStatus: '2', gmt_offset: '',
Clock: '00:03:27', },
Session: { availability: {
MeetingName: 'Belgian Grand Prix', schedule: 'available',
CircuitName: 'Spa-Francorchamps', live_transport: 'unknown',
SessionType: 'Practice', live_session: 'inactive',
SessionName: 'Practice 2', archive: 'unavailable',
Path: '', local_analysis: 'not_applicable',
freshness: 'fresh',
limitations: [],
}, },
}, },
championship_round: 2,
total_championship_rounds: 24,
},
)
expect(await screen.findByTestId('live-inactive')).toBeInTheDocument()
expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'inactive')
await waitFor(() =>
expect(screen.getByTestId('live-handoff-next')).toHaveTextContent('Practice 1'),
)
}) })
expect(await screen.findByText('Timing Tower')).toBeInTheDocument() it('clears fatal initial error once SSE supplies a live snapshot', async () => {
expect(screen.queryByTestId('live-empty')).not.toBeInTheDocument() type Listener = (event: { data: string }) => void
expect(screen.queryByTestId('live-archive-strip')).not.toBeInTheDocument() 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,
}) })
it('temporarily omits the track map while live GPS is unavailable', async () => { mockFetchLiveState.mockRejectedValue(new Error('API 503: live state unavailable'))
renderPage({ 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, is_live: true,
data: { ...archivedSnapshot, SessionStatus: 'Started' }, data: raceSnapshot,
})
}) })
expect(await screen.findByText('Timing Tower')).toBeInTheDocument() await waitFor(() => expect(screen.getByText('Timing Tower')).toBeInTheDocument())
expect(screen.queryByText('Track Map')).not.toBeInTheDocument() expect(screen.queryByTestId('live-initial-error')).not.toBeInTheDocument()
expect(mockFetchLiveTrackOutline).not.toHaveBeenCalled() expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'live')
expect(screen.queryByText(/Live timing unavailable/i)).not.toBeInTheDocument()
})
it('clears fatal initial error when SSE reports authoritative inactive null state', async () => {
type Listener = (event: { data: string }) => void
class InactiveEventSource {
onopen: (() => void) | null = null
onerror: (() => void) | null = null
private listeners = new Map<string, Listener[]>()
static latest: InactiveEventSource | null = null
constructor() {
InactiveEventSource.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: InactiveEventSource,
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: 'between_weekends',
championship_round: 5,
total_championship_rounds: 24,
season: 2026,
})
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<LiveTimingPage />
</QueryClientProvider>,
)
expect(await screen.findByTestId('live-initial-error')).toBeInTheDocument()
await act(async () => {
InactiveEventSource.latest?.emit('snapshot', {
is_live: false,
data: null,
last_snapshot: null,
})
})
await waitFor(() => expect(screen.getByTestId('live-inactive')).toBeInTheDocument())
expect(screen.queryByTestId('live-initial-error')).not.toBeInTheDocument()
expect(screen.queryByText(/Live timing unavailable/i)).not.toBeInTheDocument()
expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'inactive')
expect(screen.queryByText('Timing Tower')).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

@@ -125,7 +125,7 @@ describe('LocalDataNavigator', () => {
expect(await screen.findByTestId('local-nav')).toBeInTheDocument() expect(await screen.findByTestId('local-nav')).toBeInTheDocument()
await waitFor(() => { await waitFor(() => {
expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025) expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025, expect.any(AbortSignal))
}) })
expect(screen.getByText('Monaco')).toBeInTheDocument() expect(screen.getByText('Monaco')).toBeInTheDocument()
}) })

View File

@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import {
Outlet,
RouterProvider,
createRouter,
createRootRoute,
createRoute,
createMemoryHistory,
} from '@tanstack/react-router'
import { Nav } from '../components/Nav'
function renderNav() {
const rootRoute = createRootRoute({
component: () => (
<>
<Nav />
<Outlet />
</>
),
})
const stub = (p: string, id: string) =>
createRoute({ getParentRoute: () => rootRoute, path: p, component: () => <div data-testid={id} /> })
const router = createRouter({
routeTree: rootRoute.addChildren([
stub('/', 'home'),
stub('/championship', 'championship'),
stub('/briefing', 'briefing'),
stub('/explore', 'explore'),
stub('/admin', 'admin'),
]),
history: createMemoryHistory({ initialEntries: ['/'] }),
})
return render(<RouterProvider router={router} />)
}
const DESTINATIONS = ['Weekend', 'Championship', 'Briefing', 'Explore']
describe('Nav — one primary navigation system per breakpoint', () => {
it('exposes exactly two Primary landmarks (one per breakpoint), never more', async () => {
renderNav()
await waitFor(() => expect(screen.getAllByRole('navigation', { name: 'Primary' })).toHaveLength(2))
})
it('every Primary landmark contains all four destinations', async () => {
renderNav()
await waitFor(() => expect(screen.getAllByRole('navigation', { name: 'Primary' }).length).toBe(2))
const primaries = screen.getAllByRole('navigation', { name: 'Primary' })
for (const nav of primaries) {
for (const label of DESTINATIONS) {
expect(within(nav).getByRole('link', { name: new RegExp(`^${label}$`) })).toBeInTheDocument()
}
}
})
it('keeps Admin out of every Primary landmark (operator utility only)', async () => {
renderNav()
await waitFor(() => expect(screen.getAllByRole('navigation', { name: 'Primary' }).length).toBe(2))
const primaries = screen.getAllByRole('navigation', { name: 'Primary' })
for (const nav of primaries) {
expect(within(nav).queryByRole('link', { name: /Admin/i })).not.toBeInTheDocument()
}
// Admin lives in the operator utilities toolbar.
const toolbar = screen.getByRole('toolbar', { name: 'Operator utilities' })
expect(within(toolbar).getByRole('link', { name: /Admin/i })).toBeInTheDocument()
})
})

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 } 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 { import {
@@ -9,21 +9,38 @@ import {
createRoute, createRoute,
} from '@tanstack/react-router' } from '@tanstack/react-router'
import { RaceHubPage } from '../pages/RaceHubPage' import { RaceHubPage } from '../pages/RaceHubPage'
import type { DatasetInfo, Meeting, RaceHub, Session, Weekend } from '../types' import type {
DatasetInfo,
Meeting,
RaceHub,
Session,
Weekend,
WeekendContext,
} from '../types'
vi.mock('../api', () => ({ vi.mock('../api', () => ({
fetchRaceHub: vi.fn(), fetchRaceHub: vi.fn(),
fetchSeasons: vi.fn(), fetchSeasons: vi.fn(),
fetchLocalMeetings: vi.fn(), fetchLocalMeetings: vi.fn(),
fetchWeekend: vi.fn(), fetchWeekend: vi.fn(),
fetchWeekendContext: vi.fn(),
})) }))
import { fetchRaceHub, fetchSeasons, fetchLocalMeetings, fetchWeekend } from '../api' import {
fetchRaceHub,
fetchSeasons,
fetchLocalMeetings,
fetchWeekend,
fetchWeekendContext,
} from '../api'
const mockFetchRaceHub = vi.mocked(fetchRaceHub) const mockFetchRaceHub = vi.mocked(fetchRaceHub)
const mockFetchSeasons = vi.mocked(fetchSeasons) const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings) const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
const mockFetchWeekend = vi.mocked(fetchWeekend) const mockFetchWeekend = vi.mocked(fetchWeekend)
const mockFetchWeekendContext = vi.mocked(fetchWeekendContext)
const NOW = new Date('2025-06-01T00:00:00Z')
const meeting: Meeting = { const meeting: Meeting = {
meeting_key: 1229, meeting_key: 1229,
@@ -59,6 +76,16 @@ const qualSession: Session = {
gmt_offset: '02:00:00', gmt_offset: '02:00:00',
} }
const futureSession: Session = {
session_key: 9600,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1300,
date_start: '2099-05-25T13:00:00+00:00',
date_end: '2099-05-25T15:00:00+00:00',
gmt_offset: '02:00:00',
}
const fullDatasets: Record<string, DatasetInfo> = { const fullDatasets: Record<string, DatasetInfo> = {
meeting: { status: 'available', source: 'local', count: 1 }, meeting: { status: 'available', source: 'local', count: 1 },
session: { status: 'available', source: 'local', count: 1 }, session: { status: 'available', source: 'local', count: 1 },
@@ -169,6 +196,41 @@ const weekend: Weekend = {
], ],
} }
const weekendContext: WeekendContext = {
season: 2025,
temporal_state: 'post_weekend',
focus_meeting: meeting,
previous_meeting: meeting,
default_analysis_session: {
session: raceSession,
meeting,
availability: {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'complete',
freshness: 'fresh',
limitations: [],
},
},
previous_completed_session: {
session: raceSession,
meeting,
availability: {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'complete',
freshness: 'fresh',
limitations: [],
},
},
championship_round: 8,
total_championship_rounds: 24,
}
function renderRaceHub(sessionKey: number) { function renderRaceHub(sessionKey: number) {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }, defaultOptions: { queries: { retry: false } },
@@ -181,6 +243,19 @@ function renderRaceHub(sessionKey: number) {
</QueryClientProvider> </QueryClientProvider>
), ),
}) })
const weekendRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
validateSearch: (search: Record<string, unknown>) => {
const meetingKey = Number(search.meeting_key)
const sk = Number(search.session_key)
return {
...(Number.isFinite(meetingKey) && meetingKey > 0 ? { meeting_key: meetingKey } : {}),
...(Number.isFinite(sk) && sk > 0 ? { session_key: sk } : {}),
}
},
component: () => <div data-testid="weekend-stub" />,
})
const raceHubRoute = createRoute({ const raceHubRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/race-hub', path: '/race-hub',
@@ -194,11 +269,10 @@ function renderRaceHub(sessionKey: number) {
}, },
}) })
const router = createRouter({ const router = createRouter({
routeTree: rootRoute.addChildren([raceHubRoute]), routeTree: rootRoute.addChildren([weekendRoute, raceHubRoute]),
history: undefined, history: undefined,
}) })
// Navigate to the URL before mounting
router.navigate({ to: '/race-hub', search: sessionKey ? { session_key: sessionKey } : {} }) router.navigate({ to: '/race-hub', search: sessionKey ? { session_key: sessionKey } : {} })
return render(<RouterProvider router={router} />) return render(<RouterProvider router={router} />)
} }
@@ -206,10 +280,17 @@ function renderRaceHub(sessionKey: number) {
describe('RaceHubPage', () => { describe('RaceHubPage', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.useFakeTimers({ shouldAdvanceTime: true })
vi.setSystemTime(NOW)
mockFetchSeasons.mockResolvedValue([2025]) mockFetchSeasons.mockResolvedValue([2025])
mockFetchLocalMeetings.mockResolvedValue([meeting]) mockFetchLocalMeetings.mockResolvedValue([meeting])
mockFetchWeekend.mockResolvedValue(weekend) mockFetchWeekend.mockResolvedValue(weekend)
mockFetchRaceHub.mockResolvedValue(raceHub) mockFetchRaceHub.mockResolvedValue(raceHub)
mockFetchWeekendContext.mockResolvedValue(weekendContext)
})
afterEach(() => {
vi.useRealTimers()
}) })
it('renders the workspace identity band, session rail, and overview for a known session', async () => { it('renders the workspace identity band, session rail, and overview for a known session', async () => {
@@ -224,9 +305,9 @@ describe('RaceHubPage', () => {
) )
expect(screen.getByTestId('rh-session-9471')).toBeInTheDocument() expect(screen.getByTestId('rh-session-9471')).toBeInTheDocument()
// Overview is default
expect(screen.getByTestId('rh-overview')).toBeInTheDocument() expect(screen.getByTestId('rh-overview')).toBeInTheDocument()
expect(screen.getByText('Winner')).toBeInTheDocument() expect(screen.getByText('Winner')).toBeInTheDocument()
expect(screen.queryByText('Local Coverage')).not.toBeInTheDocument()
}) })
it('exposes Race Story sub-controls for classification, grid, and positions', async () => { it('exposes Race Story sub-controls for classification, grid, and positions', async () => {
@@ -236,17 +317,43 @@ describe('RaceHubPage', () => {
fireEvent.click(screen.getByRole('tab', { name: 'Race Story' })) fireEvent.click(screen.getByRole('tab', { name: 'Race Story' }))
expect(screen.getByText('VER')).toBeInTheDocument() expect(screen.getByText('VER')).toBeInTheDocument()
}) })
it('keeps Data Status accessible and free of inline CLI guidance', async () => { it('keeps Diagnostics accessible behind a secondary action, free of inline CLI guidance', async () => {
renderRaceHub(9472) renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument()) await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
fireEvent.click(screen.getByRole('tab', { name: 'Data Status' })) fireEvent.click(screen.getByRole('tab', { name: 'Diagnostics' }))
expect(screen.getByTestId('rh-data-status')).toBeInTheDocument() expect(screen.getByTestId('rh-data-status')).toBeInTheDocument()
expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument() expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument()
expect(screen.queryByTestId('rh-dataset-strip')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('rh-diagnostics-toggle'))
expect(screen.getByTestId('rh-dataset-strip')).toBeInTheDocument()
})
it('does not render the raw dataset strip before fan-facing content', async () => {
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
expect(screen.getByTestId('rh-overview')).toBeInTheDocument()
expect(screen.queryByTestId('rh-dataset-strip')).not.toBeInTheDocument()
})
it('groups analysis navigation into Story, Analysis, and Data & Context', async () => {
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
expect(screen.getByTestId('rh-tabgroup-story')).toBeInTheDocument()
expect(screen.getByTestId('rh-tabgroup-analysis')).toBeInTheDocument()
expect(screen.getByTestId('rh-tabgroup-context')).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Strategy' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Compare' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Diagnostics' })).toBeInTheDocument()
}) })
it('toggles the inline weekend switcher', async () => { it('toggles the inline weekend switcher', async () => {
@@ -256,4 +363,214 @@ describe('RaceHubPage', () => {
fireEvent.click(screen.getByTestId('rh-switch-weekend')) fireEvent.click(screen.getByTestId('rh-switch-weekend'))
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument() expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
}) })
it('resolves bare /race-hub through canonical Weekend Context default analysis', async () => {
mockFetchWeekendContext.mockResolvedValue({
...weekendContext,
default_analysis_session: {
session: qualSession,
meeting,
availability: {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'complete',
freshness: 'fresh',
limitations: [],
},
},
})
renderRaceHub(0)
await waitFor(() => expect(mockFetchRaceHub).toHaveBeenCalledWith(9471, expect.any(AbortSignal)))
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9600, expect.anything())
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9600)
})
it('shows no-analysis fallback when Weekend Context has no default analysis', async () => {
mockFetchWeekendContext.mockResolvedValue({
...weekendContext,
default_analysis_session: undefined,
previous_completed_session: undefined,
})
renderRaceHub(0)
await waitFor(() => expect(screen.getByTestId('race-hub-no-analysis')).toBeInTheDocument())
expect(mockFetchRaceHub).not.toHaveBeenCalled()
})
it('renders a pre-session view instead of empty analysis for a future session', async () => {
mockFetchRaceHub.mockResolvedValue({
...raceHub,
session_key: 9600,
source: 'none',
session: futureSession,
meeting: { ...meeting, meeting_key: 1300 },
results: [],
starting_grid: [],
datasets: {},
})
mockFetchWeekend.mockResolvedValue({
source: 'none',
meeting_key: 1300,
meeting: { ...meeting, meeting_key: 1300 },
sessions: [{ session: futureSession, source: 'none', datasets: {} }],
})
renderRaceHub(9600)
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
expect(await screen.findByTestId('rh-presession')).toBeInTheDocument()
expect(screen.queryByTestId('rh-overview')).not.toBeInTheDocument()
expect(screen.queryByText('Winner')).not.toBeInTheDocument()
})
it('renders a preparing view for a settling session with no local analysis', async () => {
mockFetchWeekend.mockResolvedValue({
...weekend,
sessions: [
{ session: raceSession, source: 'none', datasets: {} },
],
})
mockFetchWeekendContext.mockResolvedValue({
...weekendContext,
temporal_state: 'session_settling',
default_analysis_session: undefined,
previous_completed_session: {
session: raceSession,
meeting,
availability: {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'pending',
freshness: 'fresh',
limitations: [],
},
},
})
mockFetchRaceHub.mockResolvedValue({
...raceHub,
source: 'none',
results: [],
starting_grid: [],
datasets: {},
})
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('rh-preparing')).toBeInTheDocument())
expect(screen.queryByTestId('rh-overview')).not.toBeInTheDocument()
})
it('renders unavailable distinctly from request errors', async () => {
mockFetchWeekend.mockResolvedValue({
...weekend,
sessions: [{ session: raceSession, source: 'none', datasets: {} }],
})
mockFetchWeekendContext.mockResolvedValue({
...weekendContext,
previous_completed_session: {
session: raceSession,
meeting,
availability: {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'unavailable',
freshness: 'stale',
limitations: ['analysis_blocked'],
},
},
default_analysis_session: {
session: raceSession,
meeting,
availability: {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'unavailable',
freshness: 'stale',
limitations: ['analysis_blocked'],
},
},
})
mockFetchRaceHub.mockResolvedValue({
...raceHub,
source: 'none',
results: [],
datasets: {},
})
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('rh-unavailable')).toBeInTheDocument())
expect(screen.queryByTestId('race-hub-error')).not.toBeInTheDocument()
})
it('labels a completed but partial session as Partial and keeps analysis', async () => {
mockFetchWeekend.mockResolvedValue({
...weekend,
sessions: [
{ session: qualSession, source: 'local', datasets: fullDatasets },
{ session: raceSession, source: 'partial', datasets: { drivers: fullDatasets.drivers } },
],
})
mockFetchWeekendContext.mockResolvedValue({
...weekendContext,
default_analysis_session: {
session: raceSession,
meeting,
availability: {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'partial',
freshness: 'fresh',
limitations: [],
},
},
})
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('rh-active-state')).toBeInTheDocument())
expect(screen.getByTestId('rh-active-state')).toHaveTextContent('Partial')
expect(screen.getByTestId('rh-partial-banner')).toBeInTheDocument()
expect(screen.getByTestId('rh-overview')).toBeInTheDocument()
})
it('offers retry and back-to-Weekend on an error', async () => {
mockFetchRaceHub.mockRejectedValue(new Error('boom'))
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('race-hub-error')).toBeInTheDocument())
expect(screen.getByTestId('rh-retry')).toBeInTheDocument()
const back = screen.getByTestId('rh-back-weekend')
expect(back).toHaveAttribute('href', expect.stringMatching(/meeting_key=1229/))
expect(back).toHaveAttribute('href', expect.stringMatching(/session_key=9472/))
expect(back.getAttribute('href')).toMatch(/^\//)
mockFetchRaceHub.mockResolvedValue(raceHub)
fireEvent.click(screen.getByTestId('rh-retry'))
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
})
it('exposes a Back to Weekend link that carries meeting and session context', async () => {
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
const back = screen.getByRole('link', { name: 'Back to Weekend' })
expect(back).toHaveAttribute('data-testid', 'rh-back-weekend')
expect(back).toHaveAttribute('href', expect.stringMatching(/meeting_key=1229/))
expect(back).toHaveAttribute('href', expect.stringMatching(/session_key=9472/))
})
}) })

View File

@@ -1,8 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' 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 { 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 type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session, TrackOutline } from '../types' import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session, TrackOutline } from '../types'
vi.mock('../api', () => ({ vi.mock('../api', () => ({
@@ -174,16 +176,22 @@ const outline: TrackOutline = {
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 }, bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
} }
function renderPage() { 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 rootRoute = createRootRoute({ const rootRoute = createRootRoute({
component: () => ( component: () => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<RacePreviewPage /> <Page />
</QueryClientProvider> </QueryClientProvider>
), ),
}) })
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: RacePreviewPage }) const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: Page })
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) }) const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
return render(<RouterProvider router={router} />) return render(<RouterProvider router={router} />)
} }
@@ -274,4 +282,145 @@ describe('RacePreviewPage', () => {
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('First time on the calendar') 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, shellAvailability: 'partial' })
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('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()
})
it('preserves Stale/Partial over routine Championship Local from conflicting sources', async () => {
mockFetchSessions.mockImplementation(async (meetingKey: number) => {
if (meetingKey === 100) {
const payload = [...sessions]
rememberResponseAvailability(payload, { source: 'openf1', freshness: 'stale' })
return payload
}
if (meetingKey === 90) return [priorRaceSession]
return []
})
mockFetchMeetings.mockImplementation(async (year: number) => {
if (year === 2098) return [priorMeeting]
return []
})
const localHub = { ...hub }
rememberResponseAvailability(localHub, { source: 'local', freshness: 'local' })
mockFetchChampionshipHub.mockResolvedValue(localHub)
renderPage({ meeting: upcomingMeeting, season: 2099 })
await waitFor(() => expect(screen.getByTestId('preview-data-notice')).toHaveTextContent(/Stale/i))
expect(screen.getByTestId('preview-title-fight-card')).toBeInTheDocument()
expect(screen.getByTestId('preview-schedule')).toHaveTextContent('FP1')
})
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 () => {
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

@@ -278,6 +278,15 @@ describe('RaceStoryCanvas replay map', () => {
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active') expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
}) })
it('exposes the final running order outcome region with winner position', () => {
renderCanvas()
const outcome = screen.getByTestId('race-story-outcome')
expect(outcome).toHaveAttribute('aria-label', 'Final running order')
expect(outcome).toHaveTextContent('VER')
expect(outcome.querySelector('.rs-pos-col')).toHaveTextContent('1')
})
it('renders the empty-state card when positions are unavailable', () => { it('renders the empty-state card when positions are unavailable', () => {
renderCanvas({ renderCanvas({
datasets: { positions: { status: 'missing', source: 'local', count: 0 } }, datasets: { positions: { status: 'missing', source: 'local', count: 0 } },

View File

@@ -1,61 +0,0 @@
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import { SessionBanner } from '../components/live/SessionBanner'
import type { LiveStreamData } from '../types'
function makeSnapshot(sessionType: string, sessionName: string): LiveStreamData {
return {
Drivers: {},
DriverInfo: {},
Tyres: {},
RCMessages: [],
Weather: { AirTemp: 0, TrackTemp: 0, Humidity: 0, WindSpeed: 0, WindDir: 0, Rainfall: false },
Session: {
MeetingName: 'Belgian Grand Prix',
CircuitName: 'Spa-Francorchamps',
SessionType: sessionType,
SessionName: sessionName,
Path: '',
},
TeamRadio: [],
TrackStatus: '1',
CurrentLap: 0,
TotalLaps: 0,
Clock: '00:45:00',
ClockRefTime: '',
ClockExtrapolating: false,
Stints: {},
}
}
describe('SessionBanner', () => {
it('never renders a race lap counter for a practice session', () => {
render(
<SessionBanner
isLive
snapshot={makeSnapshot('Practice', 'Practice 2')}
rows={[]}
connection="connected"
now={0}
/>,
)
expect(screen.queryByTestId('live-lap-counter')).not.toBeInTheDocument()
// Session identity and clock stay intact.
expect(screen.getByText('Belgian Grand Prix')).toBeInTheDocument()
expect(screen.getByTestId('live-clock')).toHaveTextContent('00:45:00')
})
it('shows the lap counter for a race session', () => {
const snapshot = { ...makeSnapshot('Race', 'Race'), CurrentLap: 12, TotalLaps: 44 }
render(
<SessionBanner
isLive
snapshot={snapshot}
rows={[]}
connection="connected"
now={0}
/>,
)
expect(screen.getByTestId('live-lap-counter')).toHaveTextContent('L12/44')
})
})

View File

@@ -3,7 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react'
import { TabBar } from '../components/TabBar' import { TabBar } from '../components/TabBar'
describe('TabBar', () => { describe('TabBar', () => {
it('renders all Race Hub workspace tabs', () => { it('renders all Race Hub workspace tabs grouped into a hierarchy', () => {
render(<TabBar active="overview" onChange={() => {}} />) render(<TabBar active="overview" onChange={() => {}} />)
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Race Story' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Race Story' })).toBeInTheDocument()
@@ -12,7 +12,11 @@ describe('TabBar', () => {
expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Conditions' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Conditions' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Data Status' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Diagnostics' })).toBeInTheDocument()
expect(screen.getByTestId('rh-tabgroup-story')).toBeInTheDocument()
expect(screen.getByTestId('rh-tabgroup-analysis')).toBeInTheDocument()
expect(screen.getByTestId('rh-tabgroup-context')).toBeInTheDocument()
}) })
it('marks the active tab with aria-selected', () => { it('marks the active tab with aria-selected', () => {

View File

@@ -47,20 +47,9 @@ function snapshotRows(lap: number, lastLapTime: string): LiveTimingRow[] {
} }
describe('TyreDegPanel', () => { describe('TyreDegPanel', () => {
it('stays collapsed while every stint is still warming up', () => { it('shows a warming-up placeholder until enough clean laps accumulate', () => {
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />) render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
const panel = screen.getByTestId('tyredeg-panel') const panel = screen.getByTestId('tyredeg-panel')
// A panel of "warming up" placeholders carries no information and used to
// push the Timing Tower off the fold for the first third of a race.
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
expect(panel).toHaveTextContent('collecting clean laps')
})
it('shows a warming-up placeholder on each row once expanded', () => {
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
const panel = screen.getByTestId('tyredeg-panel')
expect(panel).toHaveTextContent('VER') expect(panel).toHaveTextContent('VER')
expect(panel).toHaveTextContent('M +5') expect(panel).toHaveTextContent('M +5')
expect(panel).toHaveTextContent('fresh') expect(panel).toHaveTextContent('fresh')
@@ -72,7 +61,6 @@ describe('TyreDegPanel', () => {
makeRow('1', 1, 'VER', { NumberOfLaps: 10, LastLapTime: '1:30.000' }, { Compound: 'MEDIUM', Age: 12 }), makeRow('1', 1, 'VER', { NumberOfLaps: 10, LastLapTime: '1:30.000' }, { Compound: 'MEDIUM', Age: 12 }),
] ]
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={[]} />) render(<TyreDegPanel rows={rows} sessionType="Race" pinned={[]} />)
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
expect(screen.getByText('mid-life')).toBeInTheDocument() expect(screen.getByText('mid-life')).toBeInTheDocument()
}) })
@@ -93,46 +81,13 @@ describe('TyreDegPanel', () => {
expect(panel).toHaveTextContent('→ ~P2') expect(panel).toHaveTextContent('→ ~P2')
}) })
it('collapses by default outside race sessions and hides the rejoin estimate when expanded', () => { it('hides the rejoin estimate outside race sessions and collapses on toggle', () => {
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Qualifying" pinned={[]} />) render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Qualifying" pinned={[]} />)
const panel = screen.getByTestId('tyredeg-panel') const panel = screen.getByTestId('tyredeg-panel')
// Practice/qualifying starts collapsed so the Timing Tower stays above the fold.
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
expect(panel).not.toHaveTextContent('~P') expect(panel).not.toHaveTextContent('~P')
})
it('opens itself during a race as soon as a stint has signal', () => {
const { rerender } = render(
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
)
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
for (let lap = 2; lap <= 6; lap++) {
const time = `1:30.${String((lap - 1) * 100).padStart(3, '0')}`
rerender(<TyreDegPanel rows={snapshotRows(lap, time)} sessionType="Race" pinned={[]} />)
}
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2) expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
}) fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
it('keeps the reader\'s own collapse choice when signal arrives', () => {
const { rerender } = render(
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
)
// Reader opens it early, then closes it again — that decision must stick
// even once the panel would otherwise auto-open.
const toggle = screen.getByRole('button', { name: /tyre deg/i })
fireEvent.click(toggle)
fireEvent.click(toggle)
for (let lap = 2; lap <= 6; lap++) {
const time = `1:30.${String((lap - 1) * 100).padStart(3, '0')}`
rerender(<TyreDegPanel rows={snapshotRows(lap, time)} sessionType="Race" pinned={[]} />)
}
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0) expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
}) })
@@ -141,7 +96,6 @@ describe('TyreDegPanel', () => {
makeRow(String(index + 1), index + 1, `D${index + 1}`), makeRow(String(index + 1), index + 1, `D${index + 1}`),
) )
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={['14']} />) render(<TyreDegPanel rows={rows} sessionType="Race" pinned={['14']} />)
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(11) expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(11)
expect(screen.getByText('D14')).toBeInTheDocument() expect(screen.getByText('D14')).toBeInTheDocument()
expect(screen.queryByText('D12')).not.toBeInTheDocument() expect(screen.queryByText('D12')).not.toBeInTheDocument()

View File

@@ -0,0 +1,651 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
Outlet,
RouterProvider,
createRouter,
createRootRoute,
createRoute,
createMemoryHistory,
} from '@tanstack/react-router'
import { WeekendPage } from '../pages/WeekendPage'
import type {
ChampionshipHub,
ContextAvailability,
ContextSession,
Meeting,
RaceHub,
Session,
TemporalState,
WeekendContext,
} from '../types'
import { parseWeekendSearch } from '../lib/routeSearch'
vi.mock('../api', () => ({
fetchWeekendContext: vi.fn(),
fetchChampionshipHub: vi.fn(),
fetchNews: vi.fn(),
fetchRaceHub: vi.fn(),
fetchWeekend: vi.fn(),
// Consumed transitively by RacePreviewPage (folded into PreSessionView):
fetchSeasons: vi.fn(),
fetchMeetings: vi.fn(),
fetchSessions: vi.fn(),
fetchResults: vi.fn(),
fetchStartingGrid: vi.fn(),
fetchTrackOutline: vi.fn(),
}))
import {
fetchWeekendContext,
fetchChampionshipHub,
fetchNews,
fetchRaceHub,
fetchWeekend,
fetchSeasons,
fetchMeetings,
fetchSessions,
fetchResults,
fetchStartingGrid,
fetchTrackOutline,
} from '../api'
import { rememberResponseAvailability } from '../lib/fetch'
const mockContext = vi.mocked(fetchWeekendContext)
const mockHub = vi.mocked(fetchChampionshipHub)
const mockNews = vi.mocked(fetchNews)
const mockRaceHub = vi.mocked(fetchRaceHub)
const mockWeekend = vi.mocked(fetchWeekend)
const mockSeasons = vi.mocked(fetchSeasons)
const mockMeetings = vi.mocked(fetchMeetings)
const mockSessions = vi.mocked(fetchSessions)
const mockResults = vi.mocked(fetchResults)
const mockGrid = vi.mocked(fetchStartingGrid)
const mockTrack = vi.mocked(fetchTrackOutline)
function availability(overrides: Partial<ContextAvailability> = {}): ContextAvailability {
return {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'complete',
freshness: 'fresh',
limitations: [],
...overrides,
}
}
function meeting(overrides: Partial<Meeting> = {}): Meeting {
return {
meeting_key: 1,
meeting_name: 'British Grand Prix',
meeting_official_name: 'FORMULA 1 BRITISH GRAND PRIX',
location: 'Silverstone',
country_name: 'United Kingdom',
country_code: 'GBR',
country_flag: '',
circuit_key: 2,
circuit_short_name: 'Silverstone',
date_start: '2026-07-03T09:00:00Z',
date_end: '2026-07-05T16:00:00Z',
year: 2026,
...overrides,
}
}
function session(overrides: Partial<Session> = {}): Session {
return {
session_key: 11,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1,
date_start: '2026-07-05T14:00:00Z',
date_end: '2026-07-05T16:00:00Z',
gmt_offset: '',
...overrides,
}
}
function ctxSession(overrides: Partial<ContextSession> = {}): ContextSession {
return {
session: session(),
meeting: meeting(),
availability: availability(),
...overrides,
}
}
function context(overrides: Partial<WeekendContext> = {}): WeekendContext {
return {
season: 2026,
temporal_state: 'between_weekends',
championship_round: 5,
total_championship_rounds: 24,
...overrides,
}
}
const hub: ChampionshipHub = {
season: 2026,
round: 5,
total_rounds: 24,
rounds_left: 19,
last_race: 'British GP',
round_labels: [],
drivers: [
{ driver_number: 1, name_acronym: 'VER', full_name: 'Max', team_name: 'RB', team_colour: '3671c6', points: 120, position: 1, wins: 4, podiums: 5, poles: 3, form: [], cumulative: [95, 120], teammate_wins: 0, teammate_losses: 0, round_positions: [] },
],
teams: [],
}
const raceHub = {
session_key: 11,
results: [
{ driver_number: 1, position: 1, name_acronym: 'VER', full_name: 'Max', team_name: 'RB', team_colour: '3671c6', dnf: false, dns: false, dsq: false, duration: 5400, gap_to_leader: null },
],
} as unknown as RaceHub
function renderAt(path: string) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const rootRoute = createRootRoute({
component: () => (
<QueryClientProvider client={queryClient}>
<Outlet />
</QueryClientProvider>
),
})
const homeRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
validateSearch: (s: Record<string, unknown>) => parseWeekendSearch(s),
component: function HomeRoute() {
const { meeting_key, session_key } = homeRoute.useSearch()
return <WeekendPage focusMeetingKey={meeting_key} focusSessionKey={session_key} />
},
})
const previewRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/preview',
component: () => <WeekendPage preview />,
})
const raceHubRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/race-hub',
validateSearch: (s: Record<string, unknown>) => {
const sk = Number(s.session_key)
return Number.isFinite(sk) && sk > 0 ? { session_key: sk } : {}
},
component: () => <div data-testid="race-hub-stub" />,
})
const stub = (p: string, id: string) =>
createRoute({ getParentRoute: () => rootRoute, path: p, component: () => <div data-testid={id} /> })
const router = createRouter({
routeTree: rootRoute.addChildren([
homeRoute,
previewRoute,
raceHubRoute,
stub('/live', 'live-stub'),
stub('/explore', 'explore-stub'),
stub('/championship', 'championship-stub'),
stub('/briefing', 'briefing-stub'),
]),
history: createMemoryHistory({ initialEntries: [path] }),
})
return render(<RouterProvider router={router} />)
}
describe('WeekendPage canonical contract rendering', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.setSystemTime(new Date('2026-07-08T12:00:00Z'))
mockHub.mockResolvedValue(hub)
mockNews.mockResolvedValue([])
mockRaceHub.mockResolvedValue(raceHub)
mockWeekend.mockResolvedValue({
source: 'local',
meeting_key: 1,
meeting: meeting(),
sessions: [
{
session: session({ session_key: 11, session_name: 'Race' }),
source: 'local',
datasets: {},
},
],
default_session_key: 11,
})
mockSeasons.mockResolvedValue([2026])
mockMeetings.mockResolvedValue([meeting()])
mockSessions.mockResolvedValue([session()])
mockResults.mockResolvedValue([])
mockGrid.mockResolvedValue([])
mockTrack.mockResolvedValue(null)
})
afterEach(() => {
vi.useRealTimers()
})
it('shows the loading surface before the canonical context resolves', async () => {
mockContext.mockReturnValue(new Promise(() => {}))
renderAt('/')
await waitFor(() => expect(screen.getByTestId('weekend-loading')).toBeInTheDocument())
})
it('shows an explicit error surface when the canonical endpoint fails', async () => {
mockContext.mockRejectedValue(new Error('API 500: boom'))
renderAt('/')
await waitFor(() => expect(screen.getByTestId('weekend-error')).toBeInTheDocument())
// Sanitized — never leak raw HTTP status/body into the Weekend surface.
expect(screen.getByTestId('weekend-error')).not.toHaveTextContent(/API 500|boom/i)
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
})
it('retries the canonical read and recovers into a temporal state', async () => {
let calls = 0
mockContext.mockImplementation(async () => {
calls += 1
if (calls === 1) throw new Error('API 503: unavailable')
return context({ temporal_state: 'between_weekends' })
})
renderAt('/')
await waitFor(() => expect(screen.getByTestId('weekend-error')).toBeInTheDocument())
const retry = screen.getByRole('button', { name: 'Retry' })
fireEvent.click(retry)
await waitFor(() => expect(screen.getByTestId('weekend-between-races')).toBeInTheDocument())
expect(calls).toBe(2)
expect(screen.getByTestId('weekend-page')).toHaveAttribute(
'data-temporal-state',
'between_weekends',
)
})
it('failed canonical read creates zero seasons/meetings/weekend/live-state fanout', async () => {
mockContext.mockRejectedValue(new Error('API 503: unavailable'))
renderAt('/')
await waitFor(() => expect(screen.getByTestId('weekend-error')).toBeInTheDocument())
expect(mockSeasons).not.toHaveBeenCalled()
expect(mockMeetings).not.toHaveBeenCalled()
expect(mockWeekend).not.toHaveBeenCalled()
expect(mockHub).not.toHaveBeenCalled()
expect(mockNews).not.toHaveBeenCalled()
})
const stateCases: Array<[TemporalState, string]> = [
['between_weekends', 'weekend-between-races'],
['post_weekend', 'weekend-between-races'],
['season_complete', 'weekend-between-races'],
['pre_session', 'weekend-pre-session'],
['between_sessions', 'weekend-between-sessions'],
['session_settling', 'weekend-between-sessions'],
['session_live', 'weekend-live'],
]
it.each(stateCases)('renders a designed surface for canonical temporal_state %s', async (temporal, testid) => {
mockContext.mockResolvedValue(
context({
temporal_state: temporal,
previous_completed_session: ctxSession({ session: session({ session_key: 11, session_name: 'Race' }) }),
default_analysis_session: ctxSession({ session: session({ session_key: 11 }) }),
next_meeting: meeting({ meeting_key: 2, meeting_name: 'Hungarian Grand Prix', date_start: '2026-07-24T09:00:00Z' }),
next_session: ctxSession({ session: session({ session_key: 21, session_name: 'Practice 1', date_start: '2026-07-24T09:00:00Z' }) }),
active_session: temporal === 'session_live'
? ctxSession({ session: session({ session_key: 12, session_name: 'Race' }), availability: availability({ live_session: 'active' }) })
: undefined,
focus_meeting: meeting(),
}),
)
renderAt('/')
await waitFor(() => expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-temporal-state', temporal))
expect(screen.getByTestId(testid)).toBeInTheDocument()
})
it('renders the limited surface for no_season and never falls through to it for a valid payload', async () => {
mockContext.mockResolvedValue(context({ temporal_state: 'no_season', championship_round: 0, total_championship_rounds: 0 }))
renderAt('/')
await waitFor(() => expect(screen.getByTestId('weekend-limited')).toBeInTheDocument())
})
it('between-races pairs the completed analysis CTA with the next-event countdown and championship impact', async () => {
mockContext.mockResolvedValue(
context({
previous_completed_session: ctxSession({ session: session({ session_key: 11, session_name: 'Race' }) }),
default_analysis_session: ctxSession({ session: session({ session_key: 11 }) }),
next_meeting: meeting({ meeting_key: 2, meeting_name: 'Hungarian Grand Prix', date_start: '2026-07-24T09:00:00Z' }),
next_session: ctxSession({ session: session({ session_key: 21, session_name: 'Practice 1', date_start: '2026-07-24T09:00:00Z' }) }),
}),
)
renderAt('/')
await waitFor(() => expect(screen.getByTestId('wk-last-event')).toBeInTheDocument())
expect(screen.getByTestId('wk-next-event')).toBeInTheDocument()
const story = screen.getByTestId('wk-explore-race-story')
expect(story).toHaveAttribute('href', expect.stringContaining('session_key=11'))
expect(screen.getByTestId('wk-prepare')).toHaveAttribute('href', '/preview')
await waitFor(() => expect(screen.getByTestId('wk-champ-impact')).toBeInTheDocument())
expect(screen.getByTestId('wk-season-nav')).toHaveTextContent('Round 5 of 24')
})
it('does not fan out to season/meeting/OpenF1/live queries when the canonical context succeeds', async () => {
mockContext.mockResolvedValue(context({ temporal_state: 'between_weekends' }))
renderAt('/')
await waitFor(() => expect(screen.getByTestId('weekend-between-races')).toBeInTheDocument())
// Only supplementary championship + news reads are allowed; no season /
// meetings / sessions / live fan-out from the Weekend home hook.
expect(mockSeasons).not.toHaveBeenCalled()
expect(mockSessions).not.toHaveBeenCalled()
// fetchMeetings may still be reached only through the folded preview surface,
// which is not mounted in the between-races state.
expect(mockMeetings).not.toHaveBeenCalled()
})
it('supplementary reads stay dormant while the canonical context is pending', async () => {
mockContext.mockReturnValue(new Promise(() => {}))
renderAt('/')
await waitFor(() => expect(screen.getByTestId('weekend-loading')).toBeInTheDocument())
expect(mockHub).not.toHaveBeenCalled()
expect(mockNews).not.toHaveBeenCalled()
})
it('the /preview alias renders the preparation surface instead of looping back', async () => {
mockContext.mockResolvedValue(
context({
temporal_state: 'between_weekends',
next_meeting: meeting({ meeting_key: 2, meeting_name: 'Hungarian Grand Prix', date_start: '2026-07-24T09:00:00Z' }),
next_session: ctxSession({ session: session({ session_key: 21, session_name: 'Practice 1', date_start: '2026-07-24T09:00:00Z' }) }),
}),
)
renderAt('/preview')
await waitFor(() => expect(screen.getByTestId('weekend-pre-session')).toBeInTheDocument())
expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-preview', 'true')
// The between-races surface must NOT be what /preview renders.
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')
// Single shell notice — Preview must not stack a second freshness banner.
expect(screen.getByTestId('weekend-data-notice')).toHaveTextContent(/Partial/i)
expect(screen.queryByTestId('preview-data-notice')).not.toBeInTheDocument()
// 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,
}),
}),
)
let sessionCalls = 0
mockSessions.mockImplementation(async (meetingKey: number) => {
if (meetingKey !== 2) return []
sessionCalls += 1
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('/')
await waitFor(() => expect(screen.getByTestId('wk-pre-head')).toBeInTheDocument())
expect(screen.getByTestId('wk-pre-head')).toHaveTextContent('Hungarian Grand Prix')
await waitFor(() => expect(screen.getByTestId('preview-sessions-error')).toBeInTheDocument())
expect(screen.queryByText(/API 500|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('Limited Retry exposes disabled Retrying… aria-busy while deferred supplements recover', async () => {
mockContext.mockResolvedValue(context({ temporal_state: 'between_weekends' }))
let hubCalls = 0
let newsCalls = 0
let resolveHub!: (value: ChampionshipHub) => void
let resolveNews!: (value: []) => void
mockHub.mockImplementation(
() =>
new Promise<ChampionshipHub>((resolve, reject) => {
hubCalls += 1
if (hubCalls === 1) {
reject(new Error('API 503: hub'))
return
}
resolveHub = resolve
}),
)
mockNews.mockImplementation(
() =>
new Promise<[]>((resolve, reject) => {
newsCalls += 1
if (newsCalls === 1) {
reject(new Error('API 503: news'))
return
}
resolveNews = resolve
}),
)
renderAt('/')
await waitFor(() => expect(screen.getByTestId('weekend-data-notice')).toHaveTextContent(/Limited/i))
expect(mockContext).toHaveBeenCalledTimes(1)
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')
// Double-click while pending must not issue extra supplement or context requests.
fireEvent.click(screen.getByRole('button', { name: 'Retrying…' }))
expect(hubCalls).toBe(2)
expect(newsCalls).toBe(2)
expect(mockContext).toHaveBeenCalledTimes(1)
resolveHub(hub)
resolveNews([])
await waitFor(() => expect(screen.queryByTestId('weekend-data-notice')).not.toBeInTheDocument())
expect(hubCalls).toBe(2)
expect(newsCalls).toBe(2)
expect(mockContext).toHaveBeenCalledTimes(1)
})
it('between-sessions header local/local suppresses Archive from terminal previous', async () => {
const focus = meeting({
meeting_key: 2,
meeting_name: 'Belgian Grand Prix',
date_start: '2026-07-10T09:00:00Z',
})
const previous = meeting({
meeting_key: 1,
meeting_name: 'British Grand Prix',
circuit_short_name: 'Silverstone',
date_start: '2026-07-05T09:00:00Z',
})
const payload = context({
temporal_state: 'between_sessions',
focus_meeting: focus,
next_meeting: focus,
previous_meeting: previous,
next_session: ctxSession({
session: session({
session_key: 21,
session_name: 'Practice 1',
meeting_key: 2,
date_start: '2026-07-10T09:00:00Z',
}),
meeting: focus,
availability: availability({ source: 'local', freshness: 'local' }),
}),
previous_completed_session: ctxSession({
session: session({
session_key: 99,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1,
date_start: '2026-07-05T14:00:00Z',
}),
meeting: previous,
availability: availability({ source: 'fia', freshness: 'archive', archive: 'available' }),
}),
})
rememberResponseAvailability(payload, { source: 'local', freshness: 'local' })
mockContext.mockResolvedValue(payload)
renderAt('/')
await waitFor(() => expect(screen.getByTestId('weekend-between-sessions')).toBeInTheDocument())
expect(screen.queryByTestId('weekend-data-notice')).not.toBeInTheDocument()
expect(screen.queryByText(/archived snapshot/i)).not.toBeInTheDocument()
})
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 () => {
mockContext.mockResolvedValue(
context({
temporal_state: 'season_complete',
previous_completed_session: ctxSession({ session: session({ session_key: 11, session_name: 'Race' }) }),
default_analysis_session: ctxSession({ session: session({ session_key: 11 }) }),
}),
)
renderAt('/?meeting_key=1&session_key=11')
await waitFor(() => expect(screen.getByTestId('wk-focus-context')).toBeInTheDocument())
expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-meeting-key', '1')
expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-session-key', '11')
await waitFor(() =>
expect(screen.getByTestId('wk-focus-meeting')).toHaveTextContent('British Grand Prix'),
)
await waitFor(() =>
expect(screen.getByTestId('wk-focus-session')).toHaveTextContent('Race'),
)
const continueAnalysis = screen.getByRole('link', { name: /Continue analysis/i })
expect(continueAnalysis).toHaveAttribute('href', expect.stringContaining('session_key=11'))
expect(mockWeekend).toHaveBeenCalledWith(1)
})
})

View File

@@ -0,0 +1,344 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { QueryClient, QueryObserver } from '@tanstack/react-query'
import {
apiFetch,
availabilityAwareStructuralSharing,
clearApiFetchInflight,
DATA_FRESHNESS_HEADER,
DATA_SOURCE_HEADER,
getResponseAvailability,
rememberResponseAvailability,
} from '../lib/fetch'
import {
aggregateNotices,
noticeFromFreshness,
noticeFromResponse,
shouldShowEmbeddedNotice,
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('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('aggregates conflicting sources by severity so Local cannot mask worse truth', () => {
expect(aggregateNotices(['local', 'stale'])).toBe('stale')
expect(aggregateNotices(['local', 'partial'])).toBe('partial')
expect(aggregateNotices(['local', 'limited'])).toBe('limited')
expect(aggregateNotices(['local', 'archive'])).toBe('archive')
expect(aggregateNotices(['stale', 'partial', 'local'])).toBe('partial')
expect(aggregateNotices(['stale', 'stale', null])).toBe('stale')
expect(aggregateNotices([null, undefined])).toBeNull()
})
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,
focus_meeting: {
meeting_key: 2,
meeting_name: 'Belgian Grand Prix',
meeting_official_name: '',
location: 'Spa',
country_name: 'Belgium',
country_code: 'BEL',
country_flag: '',
circuit_key: 7,
circuit_short_name: 'Spa',
date_start: '2026-07-24T09:00:00Z',
date_end: '2026-07-26T16:00:00Z',
year: 2026,
},
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: '',
},
meeting: {
meeting_key: 2,
meeting_name: 'Belgian Grand Prix',
meeting_official_name: '',
location: 'Spa',
country_name: 'Belgium',
country_code: 'BEL',
country_flag: '',
circuit_key: 7,
circuit_short_name: 'Spa',
date_start: '2026-07-24T09:00:00Z',
date_end: '2026-07-26T16:00:00Z',
year: 2026,
},
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()
})
it('authoritative Weekend header local/local does not fall through to previous archive', () => {
const focusMeeting = {
meeting_key: 2,
meeting_name: 'Belgian Grand Prix',
meeting_official_name: '',
location: 'Spa',
country_name: 'Belgium',
country_code: 'BEL',
country_flag: '',
circuit_key: 7,
circuit_short_name: 'Spa',
date_start: '2026-07-10T09:00:00Z',
date_end: '2026-07-12T16:00:00Z',
year: 2026,
}
const previousMeeting = {
...focusMeeting,
meeting_key: 1,
meeting_name: 'British Grand Prix',
circuit_short_name: 'Silverstone',
location: 'Silverstone',
country_name: 'United Kingdom',
country_code: 'GBR',
}
const context: WeekendContext = {
season: 2026,
temporal_state: 'between_sessions',
championship_round: 12,
total_championship_rounds: 24,
focus_meeting: focusMeeting,
next_session: {
session: {
session_key: 21,
session_name: 'Practice 1',
session_type: 'Practice',
meeting_key: 2,
date_start: '2026-07-10T09:00:00Z',
date_end: '2026-07-10T10:00:00Z',
gmt_offset: '',
},
meeting: focusMeeting,
availability: {
source: 'local',
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'pending',
freshness: 'local',
limitations: [],
},
},
previous_completed_session: {
session: {
session_key: 99,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1,
date_start: '2026-07-05T14:00:00Z',
date_end: '2026-07-05T16:00:00Z',
gmt_offset: '',
},
meeting: previousMeeting,
availability: {
source: 'fia',
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'available',
local_analysis: 'complete',
freshness: 'archive',
limitations: [],
},
},
}
rememberResponseAvailability(context, { source: 'local', freshness: 'local' })
expect(weekendContextNotice(context)).toBeNull()
// Without an authoritative header, typed focus (upcoming local) still suppresses Local.
const noHeader: WeekendContext = { ...context }
expect(weekendContextNotice(noHeader)).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

@@ -0,0 +1,291 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
ApiError,
apiFetch,
clearApiFetchInflight,
DEFAULT_FETCH_TIMEOUT_MS,
isTimeoutError,
userFacingError,
} from '../lib/fetch'
describe('apiFetch', () => {
beforeEach(() => {
clearApiFetchInflight()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
clearApiFetchInflight()
})
it('resolves JSON within the timeout boundary', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
),
)
await expect(apiFetch<{ ok: boolean }>('/api/v1/ping')).resolves.toEqual({ ok: true })
})
it('times out and leaves no hanging promise after the deadline', async () => {
vi.stubGlobal(
'fetch',
vi.fn((_url: string, init?: RequestInit) => {
return new Promise<Response>((_resolve, reject) => {
const signal = init?.signal
if (!signal) return
signal.addEventListener('abort', () => {
reject(new DOMException('Aborted', 'AbortError'))
})
})
}),
)
const pending = apiFetch('/api/v1/slow', { timeoutMs: 1000 })
const assertion = expect(pending).rejects.toSatisfy(
(err: unknown) => isTimeoutError(err) && userFacingError(err).includes('too long'),
)
await vi.advanceTimersByTimeAsync(1000)
await assertion
})
it('propagates caller abort as an ApiError', async () => {
vi.stubGlobal(
'fetch',
vi.fn((_url: string, init?: RequestInit) => {
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
reject(new DOMException('Aborted', 'AbortError'))
})
})
}),
)
const controller = new AbortController()
const pending = apiFetch('/api/v1/abort-me', {
signal: controller.signal,
timeoutMs: 30_000,
})
const assertion = expect(pending).rejects.toMatchObject({ kind: 'abort' })
controller.abort()
await assertion
})
it('deduplicates concurrent in-flight requests for the same key', async () => {
let starts = 0
vi.stubGlobal(
'fetch',
vi.fn(async () => {
starts += 1
await new Promise((r) => setTimeout(r, 50))
return new Response(JSON.stringify({ n: starts }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}),
)
const a = apiFetch<{ n: number }>('/api/v1/shared', { dedupeKey: 'shared' })
const b = apiFetch<{ n: number }>('/api/v1/shared', { dedupeKey: 'shared' })
await vi.advanceTimersByTimeAsync(50)
const [ra, rb] = await Promise.all([a, b])
expect(starts).toBe(1)
expect(ra).toEqual(rb)
})
it('does not poison a second caller when the first aborts the same dedupe key', async () => {
let starts = 0
vi.stubGlobal(
'fetch',
vi.fn((_url: string, init?: RequestInit) => {
starts += 1
return new Promise<Response>((resolve, reject) => {
const timer = setTimeout(() => {
resolve(
new Response(JSON.stringify({ ok: true, starts }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
}, 100)
init?.signal?.addEventListener(
'abort',
() => {
clearTimeout(timer)
reject(new DOMException('Aborted', 'AbortError'))
},
{ once: true },
)
})
}),
)
const first = new AbortController()
const second = new AbortController()
const a = apiFetch<{ ok: boolean }>('/api/v1/shared', {
dedupeKey: 'shared-abort-safe',
signal: first.signal,
timeoutMs: 30_000,
})
const b = apiFetch<{ ok: boolean }>('/api/v1/shared', {
dedupeKey: 'shared-abort-safe',
signal: second.signal,
timeoutMs: 30_000,
})
first.abort()
await expect(a).rejects.toMatchObject({ kind: 'abort' })
await vi.advanceTimersByTimeAsync(100)
await expect(b).resolves.toEqual({ ok: true, starts: 1 })
expect(starts).toBe(1)
})
it('cancels the shared request only after the last subscriber aborts', async () => {
let aborted = false
vi.stubGlobal(
'fetch',
vi.fn((_url: string, init?: RequestInit) => {
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener(
'abort',
() => {
aborted = true
reject(new DOMException('Aborted', 'AbortError'))
},
{ once: true },
)
})
}),
)
const first = new AbortController()
const second = new AbortController()
const a = apiFetch('/api/v1/shared', {
dedupeKey: 'shared-last-abort',
signal: first.signal,
timeoutMs: 30_000,
})
const b = apiFetch('/api/v1/shared', {
dedupeKey: 'shared-last-abort',
signal: second.signal,
timeoutMs: 30_000,
})
first.abort()
await expect(a).rejects.toMatchObject({ kind: 'abort' })
expect(aborted).toBe(false)
second.abort()
await expect(b).rejects.toMatchObject({ kind: 'abort' })
expect(aborted).toBe(true)
})
it('survives a Strict Mode style remount: aborted first consumer, fresh second consumer', async () => {
let starts = 0
vi.stubGlobal(
'fetch',
vi.fn((_url: string, init?: RequestInit) => {
starts += 1
return new Promise<Response>((resolve, reject) => {
const timer = setTimeout(() => {
resolve(
new Response(JSON.stringify({ starts }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
}, 50)
init?.signal?.addEventListener(
'abort',
() => {
clearTimeout(timer)
reject(new DOMException('Aborted', 'AbortError'))
},
{ once: true },
)
})
}),
)
// Mount #1
const first = new AbortController()
const pendingFirst = apiFetch<{ starts: number }>('/api/v1/seasons', {
dedupeKey: 'seasons',
signal: first.signal,
timeoutMs: 30_000,
})
// Strict Mode unmount cancels the first subscription (sole subscriber → shared abort)
first.abort()
await expect(pendingFirst).rejects.toMatchObject({ kind: 'abort' })
// Remount starts a fresh consumer — must not inherit the aborted request
const second = new AbortController()
const pendingSecond = apiFetch<{ starts: number }>('/api/v1/seasons', {
dedupeKey: 'seasons',
signal: second.signal,
timeoutMs: 30_000,
})
await vi.advanceTimersByTimeAsync(50)
await expect(pendingSecond).resolves.toEqual({ starts: 2 })
expect(starts).toBe(2)
})
it('allows an explicit retry after the prior request settles', async () => {
let starts = 0
vi.stubGlobal(
'fetch',
vi.fn(async () => {
starts += 1
return new Response(JSON.stringify({ starts }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}),
)
await expect(apiFetch('/api/v1/retry', { dedupeKey: 'retry-once' })).resolves.toEqual({
starts: 1,
})
await expect(apiFetch('/api/v1/retry', { dedupeKey: 'retry-once' })).resolves.toEqual({
starts: 2,
})
expect(starts).toBe(2)
})
it('maps HTTP errors without raw status jargon in userFacingError', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(JSON.stringify({ error: 'upstream failed', stale: true }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
}),
),
)
try {
await apiFetch('/api/v1/fail')
expect.unreachable('should throw')
} catch (err) {
expect(err).toBeInstanceOf(ApiError)
expect((err as ApiError).stale).toBe(true)
expect(userFacingError(err)).not.toMatch(/502|API /)
expect(userFacingError(err)).toMatch(/cached|Retry/i)
}
})
it('uses the default primary-route timeout', () => {
expect(DEFAULT_FETCH_TIMEOUT_MS).toBe(15_000)
})
})

View File

@@ -1,15 +1,12 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
bestLapGaps,
compoundClass, compoundClass,
compoundLetter, compoundLetter,
extrapolateClock, extrapolateClock,
isLiveSessionActive,
latestRaceControl, latestRaceControl,
liveSessionDisplay, liveSessionDisplay,
loadPinnedDrivers, loadPinnedDrivers,
mergeVisibleSectors, mergeVisibleSectors,
parseLapTimeSeconds,
positionDeltaClass, positionDeltaClass,
parseLiveStateEvent, parseLiveStateEvent,
rcFlagClass, rcFlagClass,
@@ -282,43 +279,6 @@ describe('live qualifying display', () => {
}) })
}) })
describe('practice/qualifying computed gaps', () => {
it('parses lap-time strings into seconds and rejects invalid input', () => {
expect(parseLapTimeSeconds('1:45.944')).toBeCloseTo(105.944, 3)
expect(parseLapTimeSeconds('45.944')).toBeCloseTo(45.944, 3)
expect(parseLapTimeSeconds('')).toBeNull()
expect(parseLapTimeSeconds(undefined)).toBeNull()
expect(parseLapTimeSeconds('-')).toBeNull()
expect(parseLapTimeSeconds('nope')).toBeNull()
})
it('derives a gap to P1 from valid best laps only, flagging the leader', () => {
const gaps = bestLapGaps([
timingRow('1', 1, { BestLapTime: '1:45.944' }),
timingRow('4', 2, { BestLapTime: '1:46.134' }),
timingRow('16', 3, { BestLapTime: '' }),
])
expect(gaps['1']).toEqual({ isLeader: true, gap: '' })
expect(gaps['4']).toEqual({ isLeader: false, gap: '+0.190' })
// No valid best lap → no fabricated gap.
expect(gaps['16']).toBeUndefined()
})
it('returns no gaps when nobody has set a lap', () => {
expect(bestLapGaps([timingRow('1', 1, { BestLapTime: '' })])).toEqual({})
})
})
describe('live session activity', () => {
it('is active only when the feed reports a live session with data', () => {
expect(isLiveSessionActive({ is_live: true, data: snapshot })).toBe(true)
expect(isLiveSessionActive({ is_live: true, data: null })).toBe(false)
expect(isLiveSessionActive({ is_live: false, data: snapshot })).toBe(false)
expect(isLiveSessionActive(null)).toBe(false)
expect(isLiveSessionActive(undefined)).toBe(false)
})
})
describe('visible sector display', () => { describe('visible sector display', () => {
it('holds S1 and S2 through temporary blanks while a flying lap is active', () => { it('holds S1 and S2 through temporary blanks while a flying lap is active', () => {
const first = [timingRow('4', 1, { const first = [timingRow('4', 1, {

View File

@@ -0,0 +1,183 @@
import { describe, expect, it } from 'vitest'
import {
allowsLiveInterpretations,
deriveLivePhase,
effectiveFeedHealth,
isReadOnlyPhase,
rendersSnapshot,
terminalSessionStatus,
type LiveStateInputs,
} from '../lib/liveState'
const base: LiveStateInputs = {
transport: 'connecting',
isLive: false,
hasActiveSnapshot: false,
hasArchive: false,
archiveMode: false,
sessionEndedCleanly: false,
wasLive: false,
stateLoaded: false,
}
describe('terminalSessionStatus', () => {
it('recognizes genuine session-end statuses', () => {
for (const s of ['Finished', 'Finalised', 'Finalized', 'ENDED', 'Aborted']) {
expect(terminalSessionStatus(s)).toBe(true)
}
})
it('treats still-running / unknown statuses as non-terminal', () => {
for (const s of ['Started', 'Resumed', 'Inactive', '', undefined, null]) {
expect(terminalSessionStatus(s)).toBe(false)
}
})
})
describe('deriveLivePhase transitions', () => {
it('connecting: cold start with the feed still opening', () => {
expect(deriveLivePhase({ ...base, transport: 'connecting' })).toBe('connecting')
})
it('connecting → live once a session snapshot arrives', () => {
expect(
deriveLivePhase({
...base,
transport: 'connected',
isLive: true,
hasActiveSnapshot: true,
}),
).toBe('live')
})
it('live → disconnected when the SSE transport drops mid-session', () => {
expect(
deriveLivePhase({
...base,
transport: 'disconnected',
isLive: true,
hasActiveSnapshot: true,
wasLive: true,
}),
).toBe('disconnected')
})
it('live → settling when the session ends with a terminal status', () => {
// Feed reports is_live=false, transport healthy, retained snapshot carries a
// terminal SessionStatus → the session genuinely finished.
expect(
deriveLivePhase({
...base,
transport: 'connected',
isLive: false,
hasArchive: true,
sessionEndedCleanly: true,
wasLive: true,
}),
).toBe('settling')
})
it('live → disconnected (NOT settling) when the FIA feed drops but status is non-terminal', () => {
// This is the P0 case: server deactivate() moved a still-active snapshot to
// last_snapshot with is_live=false but a non-terminal SessionStatus. The
// browser transport is healthy. We must warn + retain, never settle/archive.
expect(
deriveLivePhase({
...base,
transport: 'connected',
isLive: false,
hasArchive: true,
sessionEndedCleanly: false,
wasLive: true,
}),
).toBe('disconnected')
})
it('a retained non-terminal snapshot on cold load is disconnected, not settling', () => {
expect(
deriveLivePhase({
...base,
transport: 'connected',
isLive: false,
hasArchive: true,
sessionEndedCleanly: false,
wasLive: false,
}),
).toBe('disconnected')
})
it('settling → archive when the user explicitly opens the read-only snapshot', () => {
expect(
deriveLivePhase({
...base,
transport: 'connected',
isLive: false,
hasArchive: true,
sessionEndedCleanly: true,
archiveMode: true,
}),
).toBe('archive')
})
it('archive mode wins over an active session (user-chosen read-only view)', () => {
expect(
deriveLivePhase({
...base,
transport: 'connected',
isLive: true,
hasActiveSnapshot: true,
hasArchive: true,
archiveMode: true,
}),
).toBe('archive')
})
it('inactive when there is no session and nothing retained', () => {
expect(deriveLivePhase({ ...base, transport: 'connected', stateLoaded: true })).toBe('inactive')
})
it('leaves connecting for inactive once the initial state resolves, even mid-handshake', () => {
// connecting → inactive: the SSE transport handshake is still pending
// (transport === "connecting") but the initial live-state query resolved as
// idle, so we must not spin on "connecting…" forever.
expect(deriveLivePhase({ ...base, transport: 'connecting', stateLoaded: true })).toBe('inactive')
})
})
describe('phase capability helpers', () => {
it('renders the snapshot surface only for live/disconnected/archive', () => {
expect(rendersSnapshot('live')).toBe(true)
expect(rendersSnapshot('disconnected')).toBe(true)
expect(rendersSnapshot('archive')).toBe(true)
expect(rendersSnapshot('settling')).toBe(false)
expect(rendersSnapshot('inactive')).toBe(false)
expect(rendersSnapshot('connecting')).toBe(false)
})
it('allows live-only interpretations only while the session is moving', () => {
expect(allowsLiveInterpretations('live')).toBe(true)
expect(allowsLiveInterpretations('disconnected')).toBe(true)
// A frozen archive frame must never present live-only interpretations.
expect(allowsLiveInterpretations('archive')).toBe(false)
expect(allowsLiveInterpretations('settling')).toBe(false)
})
it('marks archive as the read-only phase', () => {
expect(isReadOnlyPhase('archive')).toBe(true)
expect(isReadOnlyPhase('live')).toBe(false)
expect(isReadOnlyPhase('disconnected')).toBe(false)
})
})
describe('effectiveFeedHealth', () => {
it('downgrades a still-open SSE to reconnecting while phase is disconnected', () => {
expect(effectiveFeedHealth('connected', 'disconnected')).toBe('disconnected')
expect(effectiveFeedHealth('connecting', 'disconnected')).toBe('disconnected')
})
it('preserves transport when the session phase is live or settling', () => {
expect(effectiveFeedHealth('connected', 'live')).toBe('connected')
expect(effectiveFeedHealth('connected', 'settling')).toBe('connected')
expect(effectiveFeedHealth('error', 'disconnected')).toBe('error')
})
})

View File

@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
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 { DriverProfilePage } from '../pages/DriverProfilePage'
import { ApiError } from '../lib/fetch'
import type { DriverSummary } from '../types'
vi.mock('../api', () => ({
fetchSeasons: vi.fn(),
fetchDriverSummary: vi.fn(),
}))
import { fetchSeasons, fetchDriverSummary } from '../api'
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchSummary = vi.mocked(fetchDriverSummary)
const summary: DriverSummary = {
season: 2025,
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
headshot_url: '',
points: 50,
position: 1,
wins: 2,
podiums: 2,
poles: 1,
form: [25, 25],
cumulative: [25, 50],
round_labels: ['R1', 'R2'],
rounds: [],
source: 'local',
enrichment: 'limited',
}
function renderPage(props: { driverNumber: number; year?: number }) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const rootRoute = createRootRoute({
component: () => (
<QueryClientProvider client={queryClient}>
<DriverProfilePage driverNumber={props.driverNumber} year={props.year} />
</QueryClientProvider>
),
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => null,
})
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
return render(<RouterProvider router={router} />)
}
describe('DriverProfilePage resilience', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchSeasons.mockResolvedValue([2025])
})
it('leaves loading after a timeout error and offers retry', async () => {
mockFetchSummary.mockRejectedValue(
new ApiError('timeout', 'Request timed out', {
userMessage: 'This request took too long. Check your connection, then retry.',
}),
)
renderPage({ driverNumber: 1, year: 2025 })
await waitFor(() => {
expect(screen.getByTestId('driver-profile-error')).toBeInTheDocument()
})
expect(screen.queryByTestId('driver-profile-loading')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
expect(screen.getByTestId('driver-profile-error')).toHaveTextContent(/took too long/i)
})
it('dedupes concurrent retries via a single refetch gate', async () => {
let calls = 0
mockFetchSummary.mockImplementation(async () => {
calls += 1
if (calls === 1) {
throw new ApiError('http', 'boom', { status: 500 })
}
return summary
})
renderPage({ driverNumber: 1, year: 2025 })
await waitFor(() => expect(screen.getByTestId('driver-profile-error')).toBeInTheDocument())
const retry = screen.getByRole('button', { name: 'Retry' })
fireEvent.click(retry)
fireEvent.click(retry)
await waitFor(() => expect(screen.getByTestId('driver-profile')).toBeInTheDocument())
expect(calls).toBe(2)
expect(screen.getByTestId('driver-profile-limited')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest'
import { parseRaceHubSearch, parseWeekendSearch, weekendFocusSearch } from '../lib/routeSearch'
describe('routeSearch', () => {
it('parses positive weekend meeting/session keys and drops invalid values', () => {
expect(parseWeekendSearch({ meeting_key: '1229', session_key: '9000' })).toEqual({
meeting_key: 1229,
session_key: 9000,
})
expect(parseWeekendSearch({ meeting_key: '0', session_key: '-1', foo: 'bar' })).toEqual({})
expect(parseWeekendSearch({})).toEqual({})
})
it('parses race-hub session keys', () => {
expect(parseRaceHubSearch({ session_key: '9472' })).toEqual({ session_key: 9472 })
expect(parseRaceHubSearch({ session_key: 'nope' })).toEqual({})
})
it('builds Weekend focus search for Race Hub returns', () => {
expect(weekendFocusSearch(1229, 9000)).toEqual({ meeting_key: 1229, session_key: 9000 })
expect(weekendFocusSearch(null, 9000)).toEqual({ session_key: 9000 })
expect(weekendFocusSearch(1229, undefined)).toEqual({ meeting_key: 1229 })
})
})

View File

@@ -0,0 +1,146 @@
import { describe, it, expect } from 'vitest'
import {
resolveSessionState,
sessionState,
sessionStateLabel,
} from '../lib/sessionState'
import type {
ContextAvailability,
ContextSession,
DatasetInfo,
Session,
WeekendContext,
WeekendSession,
} from '../types'
const NOW = new Date('2025-06-01T00:00:00Z')
function mk(
overrides: Partial<Session>,
source: WeekendSession['source'],
datasets: Record<string, DatasetInfo> = {},
): WeekendSession {
return {
session: {
session_key: 1,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1,
date_start: '2025-05-25T13:00:00+00:00',
date_end: '2025-05-25T15:00:00+00:00',
gmt_offset: '00:00:00',
...overrides,
},
source,
datasets,
}
}
const FULL: Record<string, DatasetInfo> = Object.fromEntries(
[
'meeting',
'session',
'drivers',
'results',
'starting_grid',
'stints',
'pit_stops',
'positions',
'race_control',
'weather',
'laps',
].map((k) => [k, { status: 'available', source: 'local', count: 1 }]),
)
function availability(overrides: Partial<ContextAvailability> = {}): ContextAvailability {
return {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'complete',
freshness: 'fresh',
limitations: [],
...overrides,
}
}
function contextSession(
session: Session,
avail: Partial<ContextAvailability> = {},
): ContextSession {
return { session, availability: availability(avail) }
}
function context(overrides: Partial<WeekendContext> = {}): WeekendContext {
return {
temporal_state: 'post_weekend',
championship_round: 1,
total_championship_rounds: 1,
...overrides,
}
}
describe('sessionState', () => {
it('marks a future session as upcoming', () => {
const s = mk({ date_start: '2099-05-25T13:00:00+00:00', date_end: '2099-05-25T15:00:00+00:00' }, 'none')
expect(sessionState(s, NOW)).toBe('upcoming')
})
it('does not mark Live from schedule alone', () => {
const start = new Date(NOW.getTime() - 60_000).toISOString()
const end = new Date(NOW.getTime() + 60_000).toISOString()
const s = mk({ date_start: start, date_end: end }, 'none')
expect(sessionState(s, NOW)).toBe('preparing')
})
it('marks Live only when Weekend Context active identity matches', () => {
const start = new Date(NOW.getTime() - 60_000).toISOString()
const end = new Date(NOW.getTime() + 60_000).toISOString()
const s = mk({ session_key: 42, date_start: start, date_end: end }, 'none')
const ctx = context({
temporal_state: 'session_live',
active_session: contextSession(s.session, { live_session: 'active' }),
})
expect(resolveSessionState({ weekendSession: s, context: ctx, now: NOW })).toBe('live')
})
it('marks a finished session with full local data as ready', () => {
const s = mk({}, 'local', FULL)
expect(sessionState(s, NOW)).toBe('ready')
})
it('marks a finished session with no data as preparing', () => {
const s = mk({}, 'none', {})
expect(sessionState(s, NOW)).toBe('preparing')
})
it('marks a finished session with partial data as partial', () => {
const s = mk({}, 'partial', { drivers: { status: 'available', source: 'local', count: 20 } })
expect(sessionState(s, NOW)).toBe('partial')
})
it('marks unavailable from context availability', () => {
const s = mk({ session_key: 7 }, 'none')
const ctx = context({
previous_completed_session: contextSession(s.session, {
local_analysis: 'unavailable',
}),
})
expect(resolveSessionState({ weekendSession: s, context: ctx, now: NOW })).toBe(
'unavailable',
)
})
it('marks a cancelled session as cancelled', () => {
const s = mk({}, 'cancelled')
expect(sessionState(s, NOW)).toBe('cancelled')
})
it('uses user language labels rather than coverage counts', () => {
expect(sessionStateLabel('ready')).toBe('Ready')
expect(sessionStateLabel('upcoming')).toBe('Upcoming')
expect(sessionStateLabel('partial')).toBe('Partial')
expect(sessionStateLabel('unavailable')).toBe('Unavailable')
})
})

View File

@@ -0,0 +1,223 @@
import { describe, it, expect } from 'vitest'
import {
analysisSessionKey,
briefingItems,
championshipImpact,
hasLocalAnalysis,
meetingIdentity,
podiumFromResults,
resolveViewState,
} from '../lib/weekendContext'
import type {
ChampionshipHub,
ContextAvailability,
ContextSession,
EnrichedResult,
Meeting,
NewsItem,
Session,
TemporalState,
WeekendContext,
} from '../types'
function availability(overrides: Partial<ContextAvailability> = {}): ContextAvailability {
return {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'complete',
freshness: 'fresh',
limitations: [],
...overrides,
}
}
function meeting(overrides: Partial<Meeting> = {}): Meeting {
return {
meeting_key: 1,
meeting_name: 'British Grand Prix',
meeting_official_name: 'FORMULA 1 BRITISH GRAND PRIX',
location: 'Silverstone',
country_name: 'United Kingdom',
country_code: 'GBR',
country_flag: '',
circuit_key: 2,
circuit_short_name: 'Silverstone',
date_start: '2026-07-03T09:00:00Z',
date_end: '2026-07-05T16:00:00Z',
year: 2026,
...overrides,
}
}
function session(overrides: Partial<Session> = {}): Session {
return {
session_key: 11,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1,
date_start: '2026-07-05T14:00:00Z',
date_end: '2026-07-05T16:00:00Z',
gmt_offset: '',
...overrides,
}
}
function ctxSession(overrides: Partial<ContextSession> = {}): ContextSession {
return {
session: session(),
meeting: meeting(),
availability: availability(),
...overrides,
}
}
function context(overrides: Partial<WeekendContext> = {}): WeekendContext {
return {
season: 2026,
temporal_state: 'between_weekends',
championship_round: 1,
total_championship_rounds: 24,
...overrides,
}
}
describe('resolveViewState', () => {
const cases: Array<[TemporalState, string]> = [
['no_season', 'no_season'],
['between_weekends', 'between_weekends'],
['pre_session', 'pre_session'],
['session_live', 'session_live'],
['session_settling', 'session_settling'],
['between_sessions', 'between_sessions'],
['post_weekend', 'post_weekend'],
['season_complete', 'season_complete'],
]
it.each(cases)('maps canonical temporal_state %s to view %s', (temporal, expected) => {
expect(resolveViewState(context({ temporal_state: temporal }))).toBe(expected)
})
it('treats an unknown/absent temporal_state as no_season rather than inventing a state', () => {
expect(resolveViewState(context({ temporal_state: 'garbage' as TemporalState }))).toBe('no_season')
})
})
describe('analysisSessionKey', () => {
it('prefers the default analysis session', () => {
const ctx = context({
default_analysis_session: ctxSession({ session: session({ session_key: 32 }) }),
previous_completed_session: ctxSession({ session: session({ session_key: 11 }) }),
})
expect(analysisSessionKey(ctx)).toBe(32)
})
it('falls back to the previous completed session', () => {
const ctx = context({
previous_completed_session: ctxSession({ session: session({ session_key: 11 }) }),
})
expect(analysisSessionKey(ctx)).toBe(11)
})
it('returns undefined when there is no analysable session', () => {
expect(analysisSessionKey(context())).toBeUndefined()
})
it('returns undefined for a zero/synthetic session key', () => {
const ctx = context({
previous_completed_session: ctxSession({ session: session({ session_key: 0 }) }),
})
expect(analysisSessionKey(ctx)).toBeUndefined()
})
})
describe('hasLocalAnalysis', () => {
it('is true for complete or partial local analysis', () => {
expect(hasLocalAnalysis(ctxSession({ availability: availability({ local_analysis: 'complete' }) }))).toBe(true)
expect(hasLocalAnalysis(ctxSession({ availability: availability({ local_analysis: 'partial' }) }))).toBe(true)
})
it('is false for pending/not_applicable/absent', () => {
expect(hasLocalAnalysis(ctxSession({ availability: availability({ local_analysis: 'pending' }) }))).toBe(false)
expect(hasLocalAnalysis(ctxSession({ availability: availability({ local_analysis: 'not_applicable' }) }))).toBe(false)
expect(hasLocalAnalysis(undefined)).toBe(false)
})
})
describe('meetingIdentity', () => {
it('derives a short name and tolerates partial fields', () => {
const id = meetingIdentity(meeting({ meeting_name: 'Monaco Grand Prix', circuit_short_name: '', location: 'Monaco' }))
expect(id?.short_name).toBe('Monaco')
expect(id?.circuit_short_name).toBe('Monaco')
})
it('returns undefined for an absent meeting', () => {
expect(meetingIdentity(undefined)).toBeUndefined()
})
it('drops empty date strings', () => {
const id = meetingIdentity(meeting({ date_start: '', date_end: '' }))
expect(id?.date_start).toBeUndefined()
expect(id?.date_end).toBeUndefined()
})
})
describe('championshipImpact', () => {
const hub: ChampionshipHub = {
season: 2026,
round: 2,
total_rounds: 24,
rounds_left: 22,
last_race: 'British GP',
round_labels: ['R1', 'R2'],
drivers: [
{ driver_number: 1, name_acronym: 'VER', full_name: 'Max', team_name: 'RB', team_colour: '3671c6', points: 50, position: 1, wins: 2, podiums: 2, poles: 1, form: [], cumulative: [25, 50], teammate_wins: 0, teammate_losses: 0, round_positions: [] },
{ driver_number: 4, name_acronym: 'NOR', full_name: 'Lando', team_name: 'McL', team_colour: 'ff8000', points: 40, position: 2, wins: 1, podiums: 2, poles: 0, form: [], cumulative: [18, 40], teammate_wins: 0, teammate_losses: 0, round_positions: [] },
],
teams: [],
}
it('maps top-3 movers with deltas and a note', () => {
const impact = championshipImpact(hub)
expect(impact?.leaders).toHaveLength(2)
expect(impact?.leaders[0].delta).toBe(25)
expect(impact?.note).toContain('British GP')
})
it('returns undefined for an empty hub', () => {
expect(championshipImpact(undefined)).toBeUndefined()
expect(championshipImpact({ ...hub, drivers: [] })).toBeUndefined()
})
})
describe('briefingItems', () => {
it('caps at three items and maps fields', () => {
const news: NewsItem[] = Array.from({ length: 5 }, (_, i) => ({
source: 'src',
title: `Item ${i}`,
url: `https://x/${i}`,
fetched_at: '2026-07-06T00:00:00Z',
category: 'news',
og_image_url: 'img',
}))
const items = briefingItems(news)
expect(items).toHaveLength(3)
expect(items[0]).toMatchObject({ title: 'Item 0', image_url: 'img' })
})
})
describe('podiumFromResults', () => {
it('sorts by position, caps at three, and formats gaps', () => {
const results: EnrichedResult[] = [
{ driver_number: 44, position: 2, name_acronym: 'HAM', full_name: 'Lewis', team_name: 'Ferrari', team_colour: 'e8002d', dnf: false, dns: false, dsq: false, duration: null, gap_to_leader: 5.123 } as EnrichedResult,
{ driver_number: 1, position: 1, name_acronym: 'VER', full_name: 'Max', team_name: 'RB', team_colour: '3671c6', dnf: false, dns: false, dsq: false, duration: 5400, gap_to_leader: null } as EnrichedResult,
{ driver_number: 16, position: 3, name_acronym: 'LEC', full_name: 'Charles', team_name: 'Ferrari', team_colour: 'e8002d', dnf: false, dns: false, dsq: false, duration: null, gap_to_leader: '+10.5' } as EnrichedResult,
{ driver_number: 55, position: 4, name_acronym: 'SAI', full_name: 'Carlos', team_name: 'W', team_colour: 'fff', dnf: false, dns: false, dsq: false, duration: null, gap_to_leader: 20 } as EnrichedResult,
]
const podium = podiumFromResults(results)
expect(podium.map((p) => p.name_acronym)).toEqual(['VER', 'HAM', 'LEC'])
expect(podium[1].gap).toBe('+5.123')
expect(podium[2].gap).toBe('+10.5')
})
})

View File

@@ -446,6 +446,113 @@ export interface DriverSummary {
cumulative: number[] cumulative: number[]
round_labels: string[] round_labels: string[]
rounds: DriverSummaryRound[] rounds: DriverSummaryRound[]
/** Data origin: local domain DB or OpenF1 fallback. */
source?: 'local' | 'openf1'
/** Optional remote enrichment status — limited means profile still renders. */
enrichment?: 'full' | 'limited' | 'none'
}
// ── Weekend Context ──
// Canonical local-first contract for the adaptive Weekend home, served verbatim
// by /api/v1/weekend-context (backend story #72, internal/query/context.go).
// The frontend consumes this shape as the single source of truth; view-model
// derivation lives in lib/weekendContext.ts.
// TemporalState mirrors query.TemporalState exactly (the JSON `temporal_state`).
export type TemporalState =
| 'no_season'
| 'between_weekends'
| 'pre_session'
| 'session_live'
| 'session_settling'
| 'between_sessions'
| 'post_weekend'
| 'season_complete'
// 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
archive: string
local_analysis: string
freshness: string
observed_at?: string
limitations: string[]
}
// ContextSession mirrors query.ContextSession: a session identity coupled with
// its meeting and structured availability contract.
export interface ContextSession {
session: Session
meeting?: Meeting
availability: ContextAvailability
}
// WeekendContext mirrors query.WeekendContext (the canonical endpoint body).
export interface WeekendContext {
season?: number
temporal_state: TemporalState
previous_meeting?: Meeting
focus_meeting?: Meeting
next_meeting?: Meeting
previous_completed_session?: ContextSession
active_session?: ContextSession
next_session?: ContextSession
default_analysis_session?: ContextSession
championship_round: number
total_championship_rounds: number
}
// ── Weekend view model (client-only) ──
// The rendered Weekend home layers a small set of non-canonical UI states
// (loading/error) plus supplementary data (championship movers, briefing) on top
// of the canonical context. None of these are part of the #72 contract.
export type WeekendViewState =
| 'loading'
| 'error'
| 'no_season'
| 'between_weekends'
| 'pre_session'
| 'session_live'
| 'session_settling'
| 'between_sessions'
| 'post_weekend'
| 'season_complete'
export interface WeekendChampionshipMover {
position: number
driver_number: number
name_acronym: string
team_colour: string
points: number
delta?: number
}
export interface WeekendChampionshipImpact {
leaders: WeekendChampionshipMover[]
note?: string
}
export interface WeekendBriefingItem {
category?: string
title: string
url: string
source: string
published_at?: string
image_url?: string
}
export interface WeekendPodiumEntry {
position: number
driver_number: number
name_acronym: string
team_name: string
team_colour: string
gap: string
} }
export interface NewsItem { export interface NewsItem {

View File

@@ -164,8 +164,8 @@ func (c *Cache) Get(key string) ([]byte, bool) {
if ttl > 0 { if ttl > 0 {
age := time.Since(time.Unix(createdAt, 0)) age := time.Since(time.Unix(createdAt, 0))
if age > ttl { if age > ttl {
// Expired — delete and return miss. // Expired entries remain stored so get() can use them as a stale
_, _ = c.db.Exec(`DELETE FROM cache WHERE key = ?`, key) // fallback if the live request fails. Prune() owns physical cleanup.
atomic.AddInt64(&c.stats.Misses, 1) atomic.AddInt64(&c.stats.Misses, 1)
return nil, false return nil, false
} }

View File

@@ -1,6 +1,7 @@
package api package api
import ( import (
"context"
"net/http" "net/http"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -26,8 +27,12 @@ type requestPacer struct {
// wait blocks until this caller's reserved slot arrives. // wait blocks until this caller's reserved slot arrives.
func (p *requestPacer) wait() { func (p *requestPacer) wait() {
_ = p.waitContext(context.Background())
}
func (p *requestPacer) waitContext(ctx context.Context) error {
if p == nil || p.interval <= 0 { if p == nil || p.interval <= 0 {
return return nil
} }
p.mu.Lock() p.mu.Lock()
now := time.Now() now := time.Now()
@@ -38,8 +43,18 @@ func (p *requestPacer) wait() {
p.next = p.next.Add(p.interval) p.next = p.next.Add(p.interval)
p.mu.Unlock() p.mu.Unlock()
if sleep > 0 { if sleep > 0 {
time.Sleep(sleep) timer := time.NewTimer(sleep)
defer timer.Stop()
select {
case <-timer.C:
case <-ctx.Done():
// Keep the unused reservation in the schedule. Blindly reclaiming an
// interval can collide with later callers that already reserved their
// wake times, releasing two requests simultaneously.
return ctx.Err()
} }
}
return nil
} }
type OpenF1Client struct { type OpenF1Client struct {
@@ -56,6 +71,26 @@ type OpenF1Client struct {
staleFlag int32 staleFlag int32
} }
// Scoped returns a lightweight request-scoped view of the client. Network,
// pacing and cache resources are shared, while the stale fallback indicator is
// deliberately not shared. Web handlers use this view so a stale fallback in
// one concurrent HTTP request can never mark an unrelated response as stale.
//
// The legacy client-wide stale flag remains available for the TUI, whose loads
// are intentionally aggregated into one navigation-level notice.
func (c *OpenF1Client) Scoped() *OpenF1Client {
if c == nil {
return nil
}
return &OpenF1Client{
url: c.url,
apiKey: c.apiKey,
httpClient: c.httpClient,
cache: c.cache,
pacer: c.pacer,
}
}
func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client { func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client {
return &OpenF1Client{ return &OpenF1Client{
url: url, url: url,

View File

@@ -0,0 +1,123 @@
package api
import (
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
func expireCacheEntry(t *testing.T, c *OpenF1Client, key string) {
t.Helper()
if _, err := c.cache.db.Exec(`UPDATE cache SET created_at = ? WHERE key = ?`, time.Now().Add(-48*time.Hour).Unix(), key); err != nil {
t.Fatal(err)
}
}
func TestScopedClientReportsStaleFallbackWithoutMutatingParent(t *testing.T) {
year := time.Now().Year()
var fail atomic.Bool
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if fail.Load() {
http.Error(w, "unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `[{
"meeting_key": 1,
"meeting_name": "British Grand Prix",
"year": %d
}]`, year)
}))
defer upstream.Close()
client := NewOpenF1Client(upstream.URL, time.Second)
defer client.Close()
client.pacer.interval = 0
key := fmt.Sprintf("%s/v1/meetings?year=%d", upstream.URL, year)
_, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key = ?`, key)
defer func() { _, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key = ?`, key) }()
if _, err := client.GetMeetingsForYear(year); err != nil {
t.Fatalf("prime cache: %v", err)
}
expireCacheEntry(t, client, key)
fail.Store(true)
scoped := client.Scoped()
meetings, err := scoped.GetMeetingsForYear(year)
if err != nil || len(meetings) != 1 {
t.Fatalf("stale fallback = (%+v, %v)", meetings, err)
}
if !scoped.LastResponseWasStale() {
t.Fatal("scoped request did not report its stale fallback")
}
if client.LastResponseWasStale() {
t.Fatal("request-scoped fallback leaked into the parent client")
}
}
func TestScopedClientsDoNotLeakFreshnessAcrossConcurrentRequests(t *testing.T) {
year := time.Now().Year()
staleStarted := make(chan struct{})
releaseStale := make(chan struct{})
var failMeetings atomic.Bool
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/meetings":
if failMeetings.Load() {
close(staleStarted)
<-releaseStale
http.Error(w, "unavailable", http.StatusServiceUnavailable)
return
}
_, _ = fmt.Fprintf(w, `[{"meeting_key":1,"meeting_name":"British Grand Prix","year":%d}]`, year)
case "/v1/sessions":
_, _ = w.Write([]byte(`[{"session_key":11,"meeting_key":2,"session_name":"Race"}]`))
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
client := NewOpenF1Client(upstream.URL, 2*time.Second)
defer client.Close()
client.pacer.interval = 0
meetingKey := fmt.Sprintf("%s/v1/meetings?year=%d", upstream.URL, year)
sessionKey := upstream.URL + "/v1/sessions?meeting_key=2"
_, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key IN (?, ?)`, meetingKey, sessionKey)
defer func() { _, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key IN (?, ?)`, meetingKey, sessionKey) }()
if _, err := client.GetMeetingsForYear(year); err != nil {
t.Fatal(err)
}
expireCacheEntry(t, client, meetingKey)
failMeetings.Store(true)
staleClient := client.Scoped()
staleDone := make(chan error, 1)
go func() {
_, err := staleClient.GetMeetingsForYear(year)
staleDone <- err
}()
<-staleStarted
freshClient := client.Scoped()
if _, err := freshClient.GetSessionsForMeeting(2); err != nil {
t.Fatalf("fresh concurrent request: %v", err)
}
if freshClient.LastResponseWasStale() {
t.Fatal("fresh request inherited concurrent request's stale state")
}
close(releaseStale)
if err := <-staleDone; err != nil {
t.Fatalf("stale request: %v", err)
}
if !staleClient.LastResponseWasStale() {
t.Fatal("stale request lost its own freshness state")
}
if freshClient.LastResponseWasStale() || client.LastResponseWasStale() {
t.Fatal("stale state leaked after concurrent requests completed")
}
}

View File

@@ -2,6 +2,7 @@ package api
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -59,8 +60,14 @@ func retryAfter429(resp *http.Response) time.Duration {
// Without this, concurrent fan-outs (championship hub, track prefetch) burst // Without this, concurrent fan-outs (championship hub, track prefetch) burst
// past the free-tier limit and callers silently treat 429s as missing data. // past the free-tier limit and callers silently treat 429s as missing data.
func (c *OpenF1Client) doPaced(req *http.Request) (*http.Response, error) { func (c *OpenF1Client) doPaced(req *http.Request) (*http.Response, error) {
return c.doPacedContext(req.Context(), req)
}
func (c *OpenF1Client) doPacedContext(ctx context.Context, req *http.Request) (*http.Response, error) {
for attempt := 0; ; attempt++ { for attempt := 0; ; attempt++ {
c.pacer.wait() if err := c.pacer.waitContext(ctx); err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -70,7 +77,14 @@ func (c *OpenF1Client) doPaced(req *http.Request) (*http.Response, error) {
} }
delay := retryAfter429(resp) delay := retryAfter429(resp)
resp.Body.Close() resp.Body.Close()
time.Sleep(delay) timer := time.NewTimer(delay)
select {
case <-timer.C:
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
}
timer.Stop()
} }
} }
@@ -83,13 +97,23 @@ func (c *OpenF1Client) doPaced(req *http.Request) (*http.Response, error) {
// entry for this URL, that stale entry is returned instead of propagating the // entry for this URL, that stale entry is returned instead of propagating the
// error. The client's staleFlag is set so the UI can show a disclaimer. // error. The client's staleFlag is set so the UI can show a disclaimer.
func (c *OpenF1Client) get(url string) (io.ReadCloser, error) { func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
return c.getContext(context.Background(), url)
}
// getContext is the cancellable form used by bounded optional web enrichment.
// A caller cancellation never falls back to stale data: the work is no longer
// relevant to that response and must stop instead of continuing in background.
func (c *OpenF1Client) getContext(ctx context.Context, url string) (io.ReadCloser, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
// 1. Check the cache for a fresh (non-expired) entry. // 1. Check the cache for a fresh (non-expired) entry.
if cachedData, ok := c.cache.Get(url); ok { if cachedData, ok := c.cache.Get(url); ok {
return io.NopCloser(bytes.NewReader(cachedData)), nil return io.NopCloser(bytes.NewReader(cachedData)), nil
} }
// 2. Attempt a live network request. // 2. Attempt a live network request.
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { if err != nil {
// Even a request-construction failure warrants a stale fallback. // Even a request-construction failure warrants a stale fallback.
return c.tryStale(url, err) return c.tryStale(url, err)
@@ -98,8 +122,11 @@ func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("Authorization", "Bearer "+c.apiKey)
} }
resp, err := c.doPaced(req) resp, err := c.doPacedContext(ctx, req)
if err != nil { if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
return c.tryStale(url, err) return c.tryStale(url, err)
} }
defer resp.Body.Close() defer resp.Body.Close()
@@ -128,6 +155,9 @@ func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
// 3. Success — read the body, store in cache, return. // 3. Success — read the body, store in cache, return.
data, err := io.ReadAll(resp.Body) data, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
return c.tryStale(url, err) return c.tryStale(url, err)
} }
@@ -245,7 +275,13 @@ func (c *OpenF1Client) GetDriversForSession(sessionKey int) ([]models.Driver, er
} }
func (c *OpenF1Client) GetDriver(sessionKey, driverNumber int) (*models.Driver, error) { func (c *OpenF1Client) GetDriver(sessionKey, driverNumber int) (*models.Driver, error) {
body, err := c.get(fmt.Sprintf("%s/v1/drivers?session_key=%d&driver_number=%d", c.url, sessionKey, driverNumber)) return c.GetDriverContext(context.Background(), sessionKey, driverNumber)
}
// GetDriverContext is a cancellable single-driver lookup for optional bounded
// enrichment. Other public methods retain their existing background semantics.
func (c *OpenF1Client) GetDriverContext(ctx context.Context, sessionKey, driverNumber int) (*models.Driver, error) {
body, err := c.getContext(ctx, fmt.Sprintf("%s/v1/drivers?session_key=%d&driver_number=%d", c.url, sessionKey, driverNumber))
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -1,7 +1,9 @@
package api package api
import ( import (
"context"
"encoding/json" "encoding/json"
"errors"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -49,6 +51,70 @@ func TestRequestPacerNilSafe(t *testing.T) {
p.wait() // must not panic p.wait() // must not panic
} }
func TestRequestPacerCancellationDoesNotCollideReservedWaiters(t *testing.T) {
const interval = 80 * time.Millisecond
p := &requestPacer{interval: interval}
if err := p.waitContext(context.Background()); err != nil {
t.Fatal(err)
}
p.mu.Lock()
initialNext := p.next
p.mu.Unlock()
waitForReservation := func(want time.Time) {
t.Helper()
deadline := time.Now().Add(250 * time.Millisecond)
for time.Now().Before(deadline) {
p.mu.Lock()
got := p.next
p.mu.Unlock()
if got.Equal(want) {
return
}
time.Sleep(time.Millisecond)
}
t.Fatalf("reservation did not reach %v", want)
}
ctxB, cancelB := context.WithCancel(context.Background())
bDone := make(chan error, 1)
go func() { bDone <- p.waitContext(ctxB) }()
waitForReservation(initialNext.Add(interval))
cDone := make(chan time.Time, 1)
go func() {
_ = p.waitContext(context.Background())
cDone <- time.Now()
}()
waitForReservation(initialNext.Add(2 * interval))
cancelStarted := time.Now()
cancelB()
select {
case err := <-bDone:
if !errors.Is(err, context.Canceled) {
t.Fatalf("B error = %v, want context.Canceled", err)
}
if elapsed := time.Since(cancelStarted); elapsed > 30*time.Millisecond {
t.Fatalf("B cancellation took %v", elapsed)
}
case <-time.After(50 * time.Millisecond):
t.Fatal("B did not return promptly after cancellation")
}
dDone := make(chan time.Time, 1)
go func() {
_ = p.waitContext(context.Background())
dDone <- time.Now()
}()
waitForReservation(initialNext.Add(3 * interval))
cAt, dAt := <-cDone, <-dDone
if separation := dAt.Sub(cAt); separation < interval/2 {
t.Fatalf("C and D collided: wake separation %v, want at least %v", separation, interval/2)
}
}
func TestGetRetriesOn429(t *testing.T) { func TestGetRetriesOn429(t *testing.T) {
var calls atomic.Int32 var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@@ -307,28 +307,6 @@ func TestSessionStatusIsActive(t *testing.T) {
} }
} }
func TestSessionStatusIsTerminal(t *testing.T) {
tests := []struct {
status string
want bool
}{
{"Finished", true},
{"Finalised", true},
{"Finalized", true},
{"Ends", true},
{"Aborted", true},
{"Started", false},
{"Resumed", false},
{"Inactive", false},
{"", false},
}
for _, tt := range tests {
if got := live.SessionStatusIsTerminal(tt.status); got != tt.want {
t.Errorf("SessionStatusIsTerminal(%q) = %v, want %v", tt.status, got, tt.want)
}
}
}
func TestProcessTopicRaceControlMessages(t *testing.T) { func TestProcessTopicRaceControlMessages(t *testing.T) {
state := live.NewState() state := live.NewState()
data := json.RawMessage(`{ data := json.RawMessage(`{

View File

@@ -186,19 +186,6 @@ func SessionStatusIsActive(status string) bool {
} }
} }
// SessionStatusIsTerminal reports whether a raw F1 live timing SessionStatus
// value represents a session that has ended and will not resume. A temporarily
// inactive session (e.g. a red-flag pause reported as "Inactive") is neither
// active nor terminal.
func SessionStatusIsTerminal(status string) bool {
switch normalizeSessionStatus(status) {
case "finished", "finalised", "finalized", "ends", "aborted":
return true
default:
return false
}
}
func normalizeSessionStatus(status string) string { func normalizeSessionStatus(status string) string {
out := make([]rune, 0, len(status)) out := make([]rune, 0, len(status))
for _, r := range status { for _, r := range status {

449
internal/query/context.go Normal file
View File

@@ -0,0 +1,449 @@
package query
import (
"sort"
"strings"
"time"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/store"
)
// TemporalState describes where the fan is in the current season/weekend.
type TemporalState string
const (
TemporalNoSeason TemporalState = "no_season"
TemporalBetweenWeekends TemporalState = "between_weekends"
TemporalPreSession TemporalState = "pre_session"
TemporalSessionLive TemporalState = "session_live"
TemporalSessionSettling TemporalState = "session_settling"
TemporalBetweenSessions TemporalState = "between_sessions"
TemporalPostWeekend TemporalState = "post_weekend"
TemporalSeasonComplete TemporalState = "season_complete"
preSessionWindow = 48 * time.Hour
postWeekendWindow = 48 * time.Hour
)
// LiveEvidence is the small, transport-independent subset of FIA state needed
// by the context resolver. Active identity is authoritative over the schedule.
type LiveEvidence struct {
Active bool
Final bool
MeetingName string
CircuitName string
SessionName string
SessionType string
ObservedAt time.Time
}
// ContextAvailability is structured source state for a referenced session.
type ContextAvailability struct {
Source string `json:"source"`
Schedule string `json:"schedule"`
LiveTransport string `json:"live_transport"`
LiveSession string `json:"live_session"`
Archive string `json:"archive"`
LocalAnalysis string `json:"local_analysis"`
Freshness string `json:"freshness"`
ObservedAt string `json:"observed_at,omitempty"`
Limitations []string `json:"limitations"`
}
// ContextSession couples a session identity with its availability contract.
type ContextSession struct {
Session models.Session `json:"session"`
Meeting *models.Meeting `json:"meeting,omitempty"`
Availability ContextAvailability `json:"availability"`
}
// WeekendContext is the canonical local-first previous/current/next model.
type WeekendContext struct {
Season int `json:"season,omitempty"`
TemporalState TemporalState `json:"temporal_state"`
PreviousMeeting *models.Meeting `json:"previous_meeting,omitempty"`
FocusMeeting *models.Meeting `json:"focus_meeting,omitempty"`
NextMeeting *models.Meeting `json:"next_meeting,omitempty"`
PreviousCompletedSession *ContextSession `json:"previous_completed_session,omitempty"`
ActiveSession *ContextSession `json:"active_session,omitempty"`
NextSession *ContextSession `json:"next_session,omitempty"`
DefaultAnalysisSession *ContextSession `json:"default_analysis_session,omitempty"`
ChampionshipRound int `json:"championship_round"`
TotalChampionshipRounds int `json:"total_championship_rounds"`
}
type contextCandidate struct {
meeting store.Meeting
session store.Session
start time.Time
end time.Time
counts store.SessionDatasetCounts
complete bool
archived bool
}
// ResolveWeekendContext computes the canonical context using only the domain
// store, the service clock, and optional in-memory FIA evidence.
func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext, error) {
now := s.now().UTC()
out := WeekendContext{TemporalState: TemporalNoSeason}
years, err := s.store.ListYears()
if err != nil || len(years) == 0 {
return out, err
}
out.Season = currentLocalSeason(years, now.Year())
meetings, err := s.store.ListMeetingsByYear(out.Season)
if err != nil {
return WeekendContext{}, err
}
if len(meetings) == 0 {
return out, nil
}
byMeeting := make(map[int][]store.Session, len(meetings))
var candidates []contextCandidate
for i := range meetings {
m := meetings[i]
sessions, listErr := s.store.ListSessionsByMeeting(m.MeetingKey)
if listErr != nil {
return WeekendContext{}, listErr
}
byMeeting[m.MeetingKey] = sessions
applySessionDisplayRange(&meetings[i], sessions)
m = meetings[i]
for _, sess := range sessions {
if m.IsCancelled || sess.IsCancelled {
continue
}
start, _ := parseContextTime(sess.DateStart)
end, _ := parseContextTime(sess.DateEnd)
if end.IsZero() && !start.IsZero() {
end = start.Add(3 * time.Hour)
}
counts, countErr := s.store.CountSessionDatasets(sess.SessionKey)
if countErr != nil {
return WeekendContext{}, countErr
}
archived := evidence.Final && liveMatches(evidence, m, sess)
candidates = append(candidates, contextCandidate{
meeting: m, session: sess, start: start, end: end, counts: counts,
complete: hasMeaningfulAnalysis(counts) || archived, archived: archived,
})
}
}
sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].start.Before(candidates[j].start) })
champMeetings := championshipMeetings(meetings, byMeeting)
out.TotalChampionshipRounds = len(champMeetings)
var active *contextCandidate
if evidence.Active {
for i := range candidates {
if liveMatches(evidence, candidates[i].meeting, candidates[i].session) {
active = &candidates[i]
break
}
}
if active == nil {
active = syntheticLiveCandidate(evidence, now)
}
}
var previous, next, defaultAnalysis *contextCandidate
for i := range candidates {
c := &candidates[i]
isActive := active != nil && active.session.SessionKey != 0 && c.session.SessionKey == active.session.SessionKey
completionEligible := c.start.IsZero() || !c.start.After(now) || c.archived
if !isActive && c.complete && completionEligible && (previous == nil || candidateTime(*c).After(candidateTime(*previous))) {
previous = c
}
if !isActive && c.complete && hasMeaningfulAnalysis(c.counts) && (c.start.IsZero() || !c.start.After(now)) && (defaultAnalysis == nil || candidateTime(*c).After(candidateTime(*defaultAnalysis))) {
defaultAnalysis = c
}
if !isActive && !c.start.IsZero() && !c.start.Before(now) && (next == nil || c.start.Before(next.start)) {
next = c
}
}
if previous != nil {
out.PreviousCompletedSession = sessionRef(*previous, evidence, now)
out.PreviousMeeting = meetingModelByKey(meetings, previous.meeting.MeetingKey)
}
if defaultAnalysis != nil {
out.DefaultAnalysisSession = sessionRef(*defaultAnalysis, evidence, now)
}
if next != nil {
out.NextSession = sessionRef(*next, evidence, now)
out.NextMeeting = meetingModelByKey(meetings, next.meeting.MeetingKey)
}
if active != nil {
out.ActiveSession = sessionRef(*active, evidence, now)
out.FocusMeeting = out.ActiveSession.Meeting
out.TemporalState = TemporalSessionLive
} else {
out.FocusMeeting = chooseFocusMeeting(meetings, previous, next)
out.TemporalState = classifyTemporalState(now, previous, next, candidates, champMeetings, championshipScheduleUnknown(champMeetings, byMeeting))
}
if out.FocusMeeting != nil {
out.ChampionshipRound = championshipRound(champMeetings, int(out.FocusMeeting.MeetingKey))
}
return out, nil
}
func currentLocalSeason(years []int, current int) int {
for _, year := range years {
if year == current {
return year
}
}
for _, year := range years {
if year < current {
return year
}
}
return years[len(years)-1]
}
func parseContextTime(value string) (time.Time, bool) {
if value == "" {
return time.Time{}, false
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05"} {
if parsed, err := time.Parse(layout, value); err == nil {
return parsed.UTC(), true
}
}
return time.Time{}, false
}
func hasMeaningfulAnalysis(c store.SessionDatasetCounts) bool {
return c.Results > 0 || c.Laps > 0 || c.Stints > 0 || c.Positions > 0 || c.RaceControl > 0
}
func candidateTime(c contextCandidate) time.Time {
if !c.end.IsZero() {
return c.end
}
return c.start
}
func liveMatches(e LiveEvidence, m store.Meeting, s store.Session) bool {
meetingMatch := normalizedContains(e.MeetingName, m.MeetingName) || normalizedContains(e.CircuitName, m.CircuitShortName)
sessionMatch := normalizedEqual(e.SessionName, s.SessionName)
if normalizeIdentity(e.SessionName) == "" {
sessionMatch = normalizedEqual(e.SessionType, s.SessionType)
}
return meetingMatch && sessionMatch
}
func normalizedEqual(a, b string) bool {
return normalizeIdentity(a) != "" && normalizeIdentity(a) == normalizeIdentity(b)
}
func normalizedContains(a, b string) bool {
a, b = normalizeIdentity(a), normalizeIdentity(b)
return a != "" && b != "" && (strings.Contains(a, b) || strings.Contains(b, a))
}
func normalizeIdentity(v string) string {
return strings.Map(func(r rune) rune {
if r >= 'A' && r <= 'Z' {
return r + ('a' - 'A')
}
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
return r
}
return -1
}, v)
}
func syntheticLiveCandidate(e LiveEvidence, now time.Time) *contextCandidate {
return &contextCandidate{meeting: store.Meeting{MeetingName: e.MeetingName, CircuitShortName: e.CircuitName}, session: store.Session{SessionName: e.SessionName, SessionType: e.SessionType, DateStart: now.Format(time.RFC3339)}, start: now}
}
func applySessionDisplayRange(m *store.Meeting, sessions []store.Session) {
var first, last time.Time
for _, sess := range sessions {
if sess.IsCancelled {
continue
}
start, ok := parseContextTime(sess.DateStart)
if ok && (first.IsZero() || start.Before(first)) {
first = start
}
end, ok := parseContextTime(sess.DateEnd)
if !ok {
end = start
}
if !end.IsZero() && (last.IsZero() || end.After(last)) {
last = end
}
}
if !first.IsZero() {
m.DateStart = first.Format(time.RFC3339)
}
if !last.IsZero() {
m.DateEnd = last.Format(time.RFC3339)
}
}
func meetingModelByKey(meetings []store.Meeting, key int) *models.Meeting {
for _, meeting := range meetings {
if meeting.MeetingKey == key {
model := meetingToModel(meeting)
return &model
}
}
return nil
}
func sessionRef(c contextCandidate, evidence LiveEvidence, now time.Time) *ContextSession {
session := sessionToModel(c.session)
meeting := meetingToModel(c.meeting)
// Schedule and analysis are domain-store facts. Without an ingestion
// timestamp the resolver cannot honestly call them network-fresh, so local
// is the baseline freshness vocabulary exposed to clients.
availability := ContextAvailability{Source: "local", Schedule: "available", LiveSession: "inactive", Archive: "unavailable", Freshness: "local", Limitations: []string{}}
availability.LiveTransport = "unknown"
if c.session.SessionKey == 0 {
availability.Schedule = "unavailable"
availability.Limitations = append(availability.Limitations, "schedule_identity_unmatched")
}
if evidence.Active && liveMatches(evidence, c.meeting, c.session) {
availability.Source = "mixed"
availability.LiveTransport = "connected"
availability.LiveSession = "active"
availability.Freshness = "live"
if !evidence.ObservedAt.IsZero() {
availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339)
}
}
if c.archived {
availability.Source = "mixed"
availability.Archive = "available"
availability.Freshness = "archive"
if !evidence.ObservedAt.IsZero() {
availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339)
}
}
if c.counts.Results > 0 && (c.counts.Laps+c.counts.Stints+c.counts.Positions+c.counts.RaceControl > 0) {
availability.LocalAnalysis = "complete"
} else if hasMeaningfulAnalysis(c.counts) || c.counts.Drivers+c.counts.StartingGrid+c.counts.Weather > 0 {
availability.LocalAnalysis = "partial"
} else if !c.start.IsZero() && c.start.After(now) {
availability.LocalAnalysis = "not_applicable"
} else {
availability.LocalAnalysis = "pending"
}
if availability.LocalAnalysis == "partial" && availability.Freshness == "local" {
availability.Freshness = "partial"
}
if c.session.SessionKey == 0 && availability.LiveSession == "active" {
availability.Source = "fia"
}
return &ContextSession{Session: session, Meeting: &meeting, Availability: availability}
}
func chooseFocusMeeting(meetings []store.Meeting, previous, next *contextCandidate) *models.Meeting {
if next != nil {
return meetingModelByKey(meetings, next.meeting.MeetingKey)
}
if previous != nil {
return meetingModelByKey(meetings, previous.meeting.MeetingKey)
}
return nil
}
func classifyTemporalState(now time.Time, previous, next *contextCandidate, candidates []contextCandidate, championship []store.Meeting, scheduleUnknown bool) TemporalState {
var latestStarted *contextCandidate
for i := range candidates {
if !candidates[i].start.IsZero() && !candidates[i].start.After(now) && (latestStarted == nil || candidates[i].start.After(latestStarted.start)) {
latestStarted = &candidates[i]
}
}
// Once a new meeting enters its preparation window, an ingest gap from an
// older meeting must not keep the product stuck in settling.
if next != nil && next.start.Sub(now) <= preSessionWindow && (latestStarted == nil || latestStarted.meeting.MeetingKey != next.meeting.MeetingKey) {
return TemporalPreSession
}
if latestStarted != nil && !latestStarted.complete && !latestStarted.end.IsZero() {
if now.Before(latestStarted.end) {
return TemporalPreSession
}
return TemporalSessionSettling
}
if previous != nil && next != nil && previous.meeting.MeetingKey == next.meeting.MeetingKey {
return TemporalBetweenSessions
}
if previous != nil && meetingFinalSession(*previous, candidates) && !candidateTime(*previous).After(now) && now.Sub(candidateTime(*previous)) <= postWeekendWindow {
return TemporalPostWeekend
}
if next != nil && next.start.Sub(now) <= preSessionWindow {
return TemporalPreSession
}
if next == nil && len(championship) > 0 && !scheduleUnknown {
return TemporalSeasonComplete
}
return TemporalBetweenWeekends
}
func championshipScheduleUnknown(meetings []store.Meeting, sessions map[int][]store.Session) bool {
for _, meeting := range meetings {
for _, session := range sessions[meeting.MeetingKey] {
if !session.IsCancelled && isChampionshipRace(session) {
if _, ok := parseContextTime(session.DateStart); !ok {
return true
}
}
}
}
return false
}
func meetingFinalSession(previous contextCandidate, candidates []contextCandidate) bool {
latest := previous.start
for _, c := range candidates {
if c.meeting.MeetingKey == previous.meeting.MeetingKey && c.start.After(latest) {
return false
}
}
return true
}
func championshipMeetings(meetings []store.Meeting, sessions map[int][]store.Session) []store.Meeting {
var out []store.Meeting
for _, m := range meetings {
if m.IsCancelled || isTestMeeting(m) {
continue
}
for _, sess := range sessions[m.MeetingKey] {
if !sess.IsCancelled && isChampionshipRace(sess) {
out = append(out, m)
break
}
}
}
sort.SliceStable(out, func(i, j int) bool {
a, _ := parseContextTime(out[i].DateStart)
b, _ := parseContextTime(out[j].DateStart)
return a.Before(b)
})
return out
}
func isTestMeeting(m store.Meeting) bool {
n := strings.ToLower(m.MeetingName + " " + m.MeetingOfficialName)
return strings.Contains(n, "test")
}
func isChampionshipRace(s store.Session) bool {
n := strings.ToLower(s.SessionName)
t := strings.ToLower(s.SessionType)
return (n == "race" || t == "race") && !strings.Contains(n, "sprint") && !strings.Contains(t, "sprint")
}
func championshipRound(meetings []store.Meeting, key int) int {
for i, m := range meetings {
if m.MeetingKey == key {
return i + 1
}
}
return 0
}

View File

@@ -0,0 +1,369 @@
package query
import (
"testing"
"time"
"github.com/AmanTahiliani/box-box/internal/store"
)
func contextService(t *testing.T, now time.Time) *Service {
t.Helper()
base := openTestService(t)
return NewServiceWithClock(base.store, func() time.Time { return now })
}
func addContextMeeting(t *testing.T, svc *Service, key int, name, start, end string, cancelled bool) {
t.Helper()
if err := svc.store.UpsertMeeting(store.Meeting{MeetingKey: key, MeetingName: name, MeetingOfficialName: name, CircuitShortName: name, Year: 2026, DateStart: start, DateEnd: end, IsCancelled: cancelled}); err != nil {
t.Fatal(err)
}
}
func addContextSession(t *testing.T, svc *Service, key, meeting int, name, start, end string, cancelled bool) {
t.Helper()
if err := svc.store.UpsertSession(store.Session{SessionKey: key, MeetingKey: meeting, SessionName: name, SessionType: name, DateStart: start, DateEnd: end, IsCancelled: cancelled}); err != nil {
t.Fatal(err)
}
}
func completeContextSession(t *testing.T, svc *Service, key, meeting int) {
t.Helper()
if err := svc.store.UpsertSessionResult(store.SessionResult{SessionKey: key, MeetingKey: meeting, DriverNumber: 1, Position: 1}); err != nil {
t.Fatal(err)
}
}
func TestResolveWeekendContextTemporalStates(t *testing.T) {
tests := []struct {
name string
now string
seed func(*testing.T, *Service)
evidence LiveEvidence
want TemporalState
}{
{name: "no season", now: "2026-06-01T12:00:00Z", want: TemporalNoSeason},
{name: "between weekends", now: "2026-06-10T12:00:00Z", seed: func(t *testing.T, s *Service) {
addContextMeeting(t, s, 1, "Monaco Grand Prix", "2026-06-01T09:00:00Z", "2026-06-02T16:00:00Z", false)
addContextSession(t, s, 11, 1, "Race", "2026-06-02T14:00:00Z", "2026-06-02T16:00:00Z", false)
completeContextSession(t, s, 11, 1)
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
}, want: TemporalBetweenWeekends},
{name: "pre session", now: "2026-06-19T12:00:00Z", seed: func(t *testing.T, s *Service) {
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
}, want: TemporalPreSession},
{name: "session live overrides schedule", now: "2026-06-20T12:00:00Z", seed: func(t *testing.T, s *Service) {
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
}, evidence: LiveEvidence{Active: true, MeetingName: "Canadian Grand Prix", CircuitName: "Canada Grand Prix", SessionName: "Practice 1", SessionType: "Practice 1"}, want: TemporalSessionLive},
{name: "session settling", now: "2026-06-20T11:00:00Z", seed: func(t *testing.T, s *Service) {
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
}, want: TemporalSessionSettling},
{name: "between sessions", now: "2026-06-20T11:00:00Z", seed: func(t *testing.T, s *Service) {
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
completeContextSession(t, s, 21, 2)
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
}, want: TemporalBetweenSessions},
{name: "post weekend", now: "2026-06-22T18:00:00Z", seed: func(t *testing.T, s *Service) {
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
completeContextSession(t, s, 22, 2)
}, want: TemporalPostWeekend},
{name: "season complete", now: "2026-06-30T12:00:00Z", seed: func(t *testing.T, s *Service) {
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
completeContextSession(t, s, 22, 2)
}, want: TemporalSeasonComplete},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, tt.now)
svc := contextService(t, now)
if tt.seed != nil {
tt.seed(t, svc)
}
got, err := svc.ResolveWeekendContext(tt.evidence)
if err != nil {
t.Fatal(err)
}
if got.TemporalState != tt.want {
t.Fatalf("state = %s, want %s; context=%+v", got.TemporalState, tt.want, got)
}
})
}
}
func TestResolveWeekendContextTruthRules(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-10T12:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1, "Pre-Season Testing", "2026-02-01T00:00:00Z", "2026-02-03T00:00:00Z", false)
addContextSession(t, svc, 10, 1, "Race", "2026-02-03T10:00:00Z", "2026-02-03T12:00:00Z", false)
addContextMeeting(t, svc, 2, "Cancelled Grand Prix", "2026-03-01T00:00:00Z", "2026-03-03T00:00:00Z", true)
addContextSession(t, svc, 20, 2, "Race", "2026-03-03T10:00:00Z", "2026-03-03T12:00:00Z", false)
addContextMeeting(t, svc, 3, "British Grand Prix", "2026-07-01T00:00:00Z", "2026-07-05T00:00:00Z", false)
addContextSession(t, svc, 30, 3, "Practice 1", "2026-07-03T09:00:00Z", "2026-07-03T10:00:00Z", false)
addContextSession(t, svc, 31, 3, "Sprint", "2026-07-04T10:00:00Z", "2026-07-04T11:00:00Z", false)
addContextSession(t, svc, 32, 3, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
completeContextSession(t, svc, 32, 3)
addContextMeeting(t, svc, 4, "Belgian Grand Prix", "2026-07-16T00:00:00Z", "2026-07-18T00:00:00Z", false)
addContextSession(t, svc, 40, 4, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
addContextSession(t, svc, 41, 4, "Cancelled Practice", "2026-07-18T09:00:00Z", "2026-07-18T10:00:00Z", true)
addContextSession(t, svc, 42, 4, "Race", "2026-07-19T14:00:00Z", "2026-07-19T16:00:00Z", false)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.TotalChampionshipRounds != 2 || got.ChampionshipRound != 2 {
t.Fatalf("rounds = %d/%d, want 2/2", got.ChampionshipRound, got.TotalChampionshipRounds)
}
if got.DefaultAnalysisSession == nil || got.DefaultAnalysisSession.Session.SessionKey != 32 {
t.Fatalf("default analysis = %+v, want completed race 32", got.DefaultAnalysisSession)
}
if got.NextSession == nil || got.NextSession.Session.SessionKey != 40 {
t.Fatalf("next = %+v, want 40", got.NextSession)
}
if got.NextMeeting.DateStart != "2026-07-17T09:00:00Z" || got.NextMeeting.DateEnd != "2026-07-19T16:00:00Z" {
t.Fatalf("display range = %s..%s", got.NextMeeting.DateStart, got.NextMeeting.DateEnd)
}
}
func TestResolveWeekendContextPassedTimeDoesNotCompleteSession(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-05T18:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T00:00:00Z", "2026-07-05T16:00:00Z", false)
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.PreviousCompletedSession != nil || got.DefaultAnalysisSession != nil {
t.Fatalf("passed schedule was treated complete: %+v", got)
}
archive, err := svc.ResolveWeekendContext(LiveEvidence{Final: true, MeetingName: "British Grand Prix", CircuitName: "British Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
if err != nil {
t.Fatal(err)
}
if archive.PreviousCompletedSession == nil || archive.PreviousCompletedSession.Availability.Archive != "available" {
t.Fatalf("final archive not used: %+v", archive)
}
if archive.DefaultAnalysisSession != nil {
t.Fatal("archive without local analysis must not become default analysis")
}
}
func TestResolveWeekendContextAvailabilityUsesTruthfulSources(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-05T18:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T00:00:00Z", "2026-07-05T16:00:00Z", false)
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
// Results without laps/stints/positions are meaningful but incomplete local
// analysis, so they must not be labelled universally fresh.
completeContextSession(t, svc, 11, 1)
addContextMeeting(t, svc, 2, "Belgian Grand Prix", "2026-07-17T00:00:00Z", "2026-07-19T16:00:00Z", false)
addContextSession(t, svc, 21, 2, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
addContextSession(t, svc, 22, 2, "Race", "2026-07-19T14:00:00Z", "2026-07-19T16:00:00Z", false)
local, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got := local.PreviousCompletedSession.Availability; got.Source != "local" || got.Freshness != "partial" || got.LocalAnalysis != "partial" {
t.Fatalf("partial local availability = %+v", got)
}
if got := local.NextSession.Availability; got.Source != "local" || got.Freshness != "local" {
t.Fatalf("future local availability = %+v", got)
}
liveContext, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "Belgian Grand Prix", CircuitName: "Belgian Grand Prix", SessionName: "Practice 1", SessionType: "Practice 1", ObservedAt: now})
if err != nil {
t.Fatal(err)
}
if got := liveContext.ActiveSession.Availability; got.Source != "mixed" || got.Freshness != "live" || got.LiveSession != "active" {
t.Fatalf("FIA + local availability = %+v", got)
}
archiveContext, err := svc.ResolveWeekendContext(LiveEvidence{Final: true, MeetingName: "British Grand Prix", CircuitName: "British Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
if err != nil {
t.Fatal(err)
}
if got := archiveContext.PreviousCompletedSession.Availability; got.Source != "mixed" || got.Freshness != "archive" || got.Archive != "available" {
t.Fatalf("FIA archive + local availability = %+v", got)
}
synthetic, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "Unscheduled Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
if err != nil {
t.Fatal(err)
}
if got := synthetic.ActiveSession.Availability; got.Source != "fia" || got.Freshness != "live" || got.Schedule != "unavailable" {
t.Fatalf("synthetic FIA availability = %+v", got)
}
}
func TestResolveWeekendContextNeverUsesFutureAnalysis(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T00:00:00Z", "2026-07-05T16:00:00Z", false)
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
completeContextSession(t, svc, 11, 1) // bad/preloaded data must not make a future session canonical
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.PreviousCompletedSession != nil || got.DefaultAnalysisSession != nil {
t.Fatalf("future analysis selected: %+v", got)
}
}
func TestResolveWeekendContextSprintWeekendHandoff(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-04T12:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
addContextSession(t, svc, 11, 1, "Sprint", "2026-07-04T10:00:00Z", "2026-07-04T11:00:00Z", false)
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
completeContextSession(t, svc, 11, 1)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.TemporalState != TemporalBetweenSessions || got.PreviousCompletedSession.Session.SessionKey != 11 || got.NextSession.Session.SessionKey != 12 {
t.Fatalf("sprint handoff = %+v", got)
}
if got.TotalChampionshipRounds != 1 {
t.Fatalf("sprint created extra championship round: %d", got.TotalChampionshipRounds)
}
}
func TestResolveWeekendContextBoundaryTimestamps(t *testing.T) {
t.Run("pre-session window is inclusive", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-01T09:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
addContextSession(t, svc, 11, 1, "Practice 1", "2026-07-03T09:00:00Z", "2026-07-03T10:00:00Z", false)
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.TemporalState != TemporalPreSession {
t.Fatalf("state = %s", got.TemporalState)
}
})
t.Run("scheduled end enters settling", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-03T10:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
addContextSession(t, svc, 11, 1, "Practice 1", "2026-07-03T09:00:00Z", "2026-07-03T10:00:00Z", false)
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.TemporalState != TemporalSessionSettling {
t.Fatalf("state = %s", got.TemporalState)
}
})
t.Run("post-weekend window is inclusive", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-07T16:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
completeContextSession(t, svc, 12, 1)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.TemporalState != TemporalPostWeekend {
t.Fatalf("state = %s", got.TemporalState)
}
})
}
func TestResolveWeekendContextPartialFutureSchedule(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "", false)
addContextSession(t, svc, 11, 1, "Practice 1", "", "", false)
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "", false)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.NextSession == nil || got.NextSession.Session.SessionKey != 12 {
t.Fatalf("partial schedule next = %+v", got.NextSession)
}
if got.FocusMeeting == nil || got.FocusMeeting.DateStart != "2026-07-05T14:00:00Z" || got.FocusMeeting.DateEnd != "2026-07-05T14:00:00Z" {
t.Fatalf("partial display range = %+v", got.FocusMeeting)
}
}
func TestResolveWeekendContextActiveSessionIsNotCompletedOrDefault(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-05T15:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
addContextSession(t, svc, 10, 1, "Qualifying", "2026-07-04T14:00:00Z", "2026-07-04T15:00:00Z", false)
completeContextSession(t, svc, 10, 1)
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
completeContextSession(t, svc, 11, 1)
got, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "British Grand Prix", CircuitName: "British Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
if err != nil {
t.Fatal(err)
}
if got.ActiveSession == nil || got.ActiveSession.Session.SessionKey != 11 {
t.Fatalf("active = %+v", got.ActiveSession)
}
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Session.SessionKey != 10 {
t.Fatalf("previous = %+v, want earlier completed session", got.PreviousCompletedSession)
}
if got.DefaultAnalysisSession == nil || got.DefaultAnalysisSession.Session.SessionKey != 10 {
t.Fatalf("default = %+v, want earlier completed session", got.DefaultAnalysisSession)
}
}
func TestResolveWeekendContextOldIncompleteSessionDoesNotSuppressNextWeekend(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-16T12:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
addContextMeeting(t, svc, 2, "Belgian Grand Prix", "2026-07-17T09:00:00Z", "2026-07-19T16:00:00Z", false)
addContextSession(t, svc, 21, 2, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
addContextSession(t, svc, 22, 2, "Race", "2026-07-19T14:00:00Z", "2026-07-19T16:00:00Z", false)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.TemporalState != TemporalPreSession {
t.Fatalf("state = %s, want %s; context=%+v", got.TemporalState, TemporalPreSession, got)
}
if got.FocusMeeting == nil || got.FocusMeeting.MeetingKey != 2 {
t.Fatalf("focus = %+v, want Belgian weekend", got.FocusMeeting)
}
}
func TestResolveWeekendContextMissingScheduleDoesNotClaimSeasonComplete(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1, "British Grand Prix", "", "", false)
addContextSession(t, svc, 11, 1, "Race", "", "", false)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.TemporalState != TemporalBetweenWeekends {
t.Fatalf("state = %s, want limited %s context", got.TemporalState, TemporalBetweenWeekends)
}
if got.TotalChampionshipRounds != 1 {
t.Fatalf("total rounds = %d, want scheduled round retained", got.TotalChampionshipRounds)
}
}

View File

@@ -19,6 +19,8 @@ type WeekendSession struct {
} }
// Weekend is the local-first read model for one race weekend. // Weekend is the local-first read model for one race weekend.
// Fan-facing default analysis resolution lives on /api/v1/weekend-context
// (DefaultAnalysisSession); this payload only supplies meeting rail + coverage.
type Weekend struct { type Weekend struct {
Source string `json:"source"` Source string `json:"source"`
MeetingKey int `json:"meeting_key"` MeetingKey int `json:"meeting_key"`

View File

@@ -3,6 +3,7 @@ package query
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"time"
"github.com/AmanTahiliani/box-box/internal/chapters" "github.com/AmanTahiliani/box-box/internal/chapters"
"github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/models"
@@ -12,11 +13,20 @@ import (
// Service assembles store-backed read models. // Service assembles store-backed read models.
type Service struct { type Service struct {
store *store.Store store *store.Store
now func() time.Time
} }
// NewService creates a query service over a domain store. // NewService creates a query service over a domain store.
func NewService(st *store.Store) *Service { func NewService(st *store.Store) *Service {
return &Service{store: st} return NewServiceWithClock(st, time.Now)
}
// NewServiceWithClock creates a query service with an injected clock.
func NewServiceWithClock(st *store.Store, now func() time.Time) *Service {
if now == nil {
now = time.Now
}
return &Service{store: st, now: now}
} }
// EnrichedResult is a session result with driver identity fields. // EnrichedResult is a session result with driver identity fields.

View File

@@ -16,6 +16,7 @@ import (
readability "codeberg.org/readeck/go-readability/v2" readability "codeberg.org/readeck/go-readability/v2"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/query" "github.com/AmanTahiliani/box-box/internal/query"
) )
@@ -43,6 +44,7 @@ func (s *Server) handleMeetings(w http.ResponseWriter, r *http.Request) {
switch parseSourceMode(r) { switch parseSourceMode(r) {
case sourceLocal: case sourceLocal:
markLocalResponse(w, false)
if !s.hasLocalQuery() { if !s.hasLocalQuery() {
writeJSON(w, []models.Meeting{}) writeJSON(w, []models.Meeting{})
return return
@@ -62,17 +64,20 @@ func (s *Server) handleMeetings(w http.ResponseWriter, r *http.Request) {
return return
} }
if len(meetings) > 0 { if len(meetings) > 0 {
markLocalResponse(w, false)
writeJSON(w, meetings) writeJSON(w, meetings)
return return
} }
} }
} }
meetings, err := s.client.GetMeetingsForYear(year) client := s.client.Scoped()
meetings, err := client.GetMeetingsForYear(year)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
markOpenF1Response(w, client)
writeJSON(w, meetings) writeJSON(w, meetings)
} }
@@ -87,6 +92,7 @@ func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
switch parseSourceMode(r) { switch parseSourceMode(r) {
case sourceLocal: case sourceLocal:
markLocalResponse(w, false)
if !s.hasLocalQuery() { if !s.hasLocalQuery() {
writeJSON(w, []models.Session{}) writeJSON(w, []models.Session{})
return return
@@ -106,23 +112,27 @@ func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
return return
} }
if len(sessions) > 0 { if len(sessions) > 0 {
markLocalResponse(w, false)
writeJSON(w, sessions) writeJSON(w, sessions)
return return
} }
} }
} }
sessions, err := s.client.GetSessionsForMeeting(meetingKey) client := s.client.Scoped()
sessions, err := client.GetSessionsForMeeting(meetingKey)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
markOpenF1Response(w, client)
writeJSON(w, sessions) writeJSON(w, sessions)
} }
// --- /api/v1/news --- // --- /api/v1/news ---
func (s *Server) handleNews(w http.ResponseWriter, r *http.Request) { func (s *Server) handleNews(w http.ResponseWriter, r *http.Request) {
markLocalResponse(w, false)
if !s.hasLocalQuery() { if !s.hasLocalQuery() {
writeJSON(w, []query.NewsItem{}) writeJSON(w, []query.NewsItem{})
return return
@@ -223,6 +233,7 @@ func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) {
switch parseSourceMode(r) { switch parseSourceMode(r) {
case sourceLocal: case sourceLocal:
markLocalResponse(w, false)
if !s.hasLocalQuery() { if !s.hasLocalQuery() {
writeJSON(w, []models.Driver{}) writeJSON(w, []models.Driver{})
return return
@@ -242,6 +253,7 @@ func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) {
if s.hasLocalQuery() { if s.hasLocalQuery() {
drivers, err := s.query.ListDrivers(sessionKey) drivers, err := s.query.ListDrivers(sessionKey)
if err == nil && len(drivers) > 0 { if err == nil && len(drivers) > 0 {
markLocalResponse(w, false)
writeJSON(w, drivers) writeJSON(w, drivers)
return return
} }
@@ -252,11 +264,13 @@ func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) {
} }
} }
drivers, err := s.client.GetDriversForSession(sessionKey) client := s.client.Scoped()
drivers, err := client.GetDriversForSession(sessionKey)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
markOpenF1Response(w, client)
writeJSON(w, drivers) writeJSON(w, drivers)
} }
@@ -279,6 +293,7 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
switch parseSourceMode(r) { switch parseSourceMode(r) {
case sourceLocal: case sourceLocal:
markLocalResponse(w, false)
if !s.hasLocalQuery() { if !s.hasLocalQuery() {
writeJSON(w, []resultWithDriver{}) writeJSON(w, []resultWithDriver{})
return return
@@ -294,6 +309,7 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
if s.hasLocalQuery() { if s.hasLocalQuery() {
results, err := s.query.ListResults(sessionKey) results, err := s.query.ListResults(sessionKey)
if err == nil && len(results) > 0 { if err == nil && len(results) > 0 {
markLocalResponse(w, false)
writeJSON(w, enrichedResultsToAPI(results)) writeJSON(w, enrichedResultsToAPI(results))
return return
} }
@@ -308,30 +324,42 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
results []models.SessionResult results []models.SessionResult
drivers []models.Driver drivers []models.Driver
resultsErr error resultsErr error
driversErr error
wg sync.WaitGroup wg sync.WaitGroup
) )
client := s.client.Scoped()
wg.Add(2) wg.Add(2)
go func() { defer wg.Done(); results, resultsErr = s.client.GetSessionResult(sessionKey) }() go func() { defer wg.Done(); results, resultsErr = client.GetSessionResult(sessionKey) }()
go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }() go func() { defer wg.Done(); drivers, driversErr = client.GetDriversForSession(sessionKey) }()
wg.Wait() wg.Wait()
if resultsErr != nil { if resultsErr != nil {
writeError(w, resultsErr, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, resultsErr, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
driverMap := buildDriverMap(drivers) driverMap := buildDriverMap(drivers)
incomplete := driversErr != nil
enriched := make([]resultWithDriver, 0, len(results)) enriched := make([]resultWithDriver, 0, len(results))
for _, res := range results { for _, res := range results {
e := resultWithDriver{SessionResult: res} e := resultWithDriver{SessionResult: res}
if d, ok := driverMap[res.DriverNumber]; ok { if d, ok := driverMap[res.DriverNumber]; ok && hasDriverPresentation(d) {
e.NameAcronym = d.NameAcronym e.NameAcronym = d.NameAcronym
e.FullName = d.FullName e.FullName = d.FullName
e.TeamName = d.TeamName e.TeamName = d.TeamName
e.TeamColour = d.TeamColour e.TeamColour = d.TeamColour
} else {
incomplete = true
} }
enriched = append(enriched, e) enriched = append(enriched, e)
} }
resultsFreshness := "fresh"
if len(results) == 0 {
resultsFreshness = "limited"
} else if incomplete {
resultsFreshness = "partial"
}
markOpenF1Availability(w, client, resultsFreshness)
writeJSON(w, enriched) writeJSON(w, enriched)
} }
@@ -354,6 +382,7 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
switch parseSourceMode(r) { switch parseSourceMode(r) {
case sourceLocal: case sourceLocal:
markLocalResponse(w, false)
if !s.hasLocalQuery() { if !s.hasLocalQuery() {
writeJSON(w, []gridWithDriver{}) writeJSON(w, []gridWithDriver{})
return return
@@ -369,6 +398,7 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
if s.hasLocalQuery() { if s.hasLocalQuery() {
grid, err := s.query.ListStartingGrid(sessionKey) grid, err := s.query.ListStartingGrid(sessionKey)
if err == nil && len(grid) > 0 { if err == nil && len(grid) > 0 {
markLocalResponse(w, false)
writeJSON(w, enrichedGridToAPI(grid)) writeJSON(w, enrichedGridToAPI(grid))
return return
} }
@@ -383,30 +413,42 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
grid []models.StartingGrid grid []models.StartingGrid
drivers []models.Driver drivers []models.Driver
gridErr error gridErr error
driversErr error
wg sync.WaitGroup wg sync.WaitGroup
) )
client := s.client.Scoped()
wg.Add(2) wg.Add(2)
go func() { defer wg.Done(); grid, gridErr = s.client.GetStartingGrid(sessionKey) }() go func() { defer wg.Done(); grid, gridErr = client.GetStartingGrid(sessionKey) }()
go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }() go func() { defer wg.Done(); drivers, driversErr = client.GetDriversForSession(sessionKey) }()
wg.Wait() wg.Wait()
if gridErr != nil { if gridErr != nil {
writeError(w, gridErr, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, gridErr, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
driverMap := buildDriverMap(drivers) driverMap := buildDriverMap(drivers)
incomplete := driversErr != nil
enriched := make([]gridWithDriver, 0, len(grid)) enriched := make([]gridWithDriver, 0, len(grid))
for _, g := range grid { for _, g := range grid {
e := gridWithDriver{StartingGrid: g} e := gridWithDriver{StartingGrid: g}
if d, ok := driverMap[g.DriverNumber]; ok { if d, ok := driverMap[g.DriverNumber]; ok && hasDriverPresentation(d) {
e.NameAcronym = d.NameAcronym e.NameAcronym = d.NameAcronym
e.FullName = d.FullName e.FullName = d.FullName
e.TeamName = d.TeamName e.TeamName = d.TeamName
e.TeamColour = d.TeamColour e.TeamColour = d.TeamColour
} else {
incomplete = true
} }
enriched = append(enriched, e) enriched = append(enriched, e)
} }
gridFreshness := "fresh"
if len(grid) == 0 {
gridFreshness = "limited"
} else if incomplete {
gridFreshness = "partial"
}
markOpenF1Availability(w, client, gridFreshness)
writeJSON(w, enriched) writeJSON(w, enriched)
} }
@@ -419,26 +461,29 @@ func (s *Server) handleLaps(w http.ResponseWriter, r *http.Request) {
return return
} }
client := s.client.Scoped()
if dnStr := r.URL.Query().Get("driver_number"); dnStr != "" { if dnStr := r.URL.Query().Get("driver_number"); dnStr != "" {
driverNumber, err := strconv.Atoi(dnStr) driverNumber, err := strconv.Atoi(dnStr)
if err != nil || driverNumber == 0 { if err != nil || driverNumber == 0 {
http.Error(w, "invalid driver_number", http.StatusBadRequest) http.Error(w, "invalid driver_number", http.StatusBadRequest)
return return
} }
laps, err := s.client.GetLapsForDriver(sessionKey, driverNumber) laps, err := client.GetLapsForDriver(sessionKey, driverNumber)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
markOpenF1Response(w, client)
writeJSON(w, laps) writeJSON(w, laps)
return return
} }
laps, err := s.client.GetLapsForSession(sessionKey) laps, err := client.GetLapsForSession(sessionKey)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
markOpenF1Response(w, client)
writeJSON(w, laps) writeJSON(w, laps)
} }
@@ -450,11 +495,13 @@ func (s *Server) handleWeather(w http.ResponseWriter, r *http.Request) {
http.Error(w, "session_key required", http.StatusBadRequest) http.Error(w, "session_key required", http.StatusBadRequest)
return return
} }
weather, err := s.client.GetWeather(sessionKey) client := s.client.Scoped()
weather, err := client.GetWeather(sessionKey)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
markOpenF1Response(w, client)
writeJSON(w, weather) writeJSON(w, weather)
} }
@@ -466,11 +513,13 @@ func (s *Server) handleRaceControl(w http.ResponseWriter, r *http.Request) {
http.Error(w, "session_key required", http.StatusBadRequest) http.Error(w, "session_key required", http.StatusBadRequest)
return return
} }
rc, err := s.client.GetRaceControl(sessionKey) client := s.client.Scoped()
rc, err := client.GetRaceControl(sessionKey)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
markOpenF1Response(w, client)
writeJSON(w, rc) writeJSON(w, rc)
} }
@@ -487,11 +536,13 @@ func (s *Server) handleTelemetry(w http.ResponseWriter, r *http.Request) {
http.Error(w, "driver_number required", http.StatusBadRequest) http.Error(w, "driver_number required", http.StatusBadRequest)
return return
} }
carData, err := s.client.GetCarData(sessionKey, driverNumber) client := s.client.Scoped()
carData, err := client.GetCarData(sessionKey, driverNumber)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
markOpenF1Response(w, client)
writeJSON(w, carData) writeJSON(w, carData)
} }
@@ -503,11 +554,13 @@ func (s *Server) handleOvertakes(w http.ResponseWriter, r *http.Request) {
http.Error(w, "session_key required", http.StatusBadRequest) http.Error(w, "session_key required", http.StatusBadRequest)
return return
} }
overtakes, err := s.client.GetOvertakesForSession(sessionKey) client := s.client.Scoped()
overtakes, err := client.GetOvertakesForSession(sessionKey)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
markOpenF1Response(w, client)
writeJSON(w, overtakes) writeJSON(w, overtakes)
} }
@@ -524,11 +577,13 @@ func (s *Server) handleTeamRadio(w http.ResponseWriter, r *http.Request) {
http.Error(w, "driver_number required", http.StatusBadRequest) http.Error(w, "driver_number required", http.StatusBadRequest)
return return
} }
radios, err := s.client.GetTeamRadio(sessionKey, driverNumber) client := s.client.Scoped()
radios, err := client.GetTeamRadio(sessionKey, driverNumber)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
markOpenF1Response(w, client)
writeJSON(w, radios) writeJSON(w, radios)
} }
@@ -547,36 +602,42 @@ func (s *Server) handleChampionshipDrivers(w http.ResponseWriter, r *http.Reques
if year == 0 { if year == 0 {
year = time.Now().Year() year = time.Now().Year()
} }
champ, err := s.client.GetDriverChampionshipForYear(year) client := s.client.Scoped()
champ, err := client.GetDriverChampionshipForYear(year)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
if len(champ) == 0 { if len(champ) == 0 {
markOpenF1Availability(w, client, "limited")
writeJSON(w, []any{}) writeJSON(w, []any{})
return return
} }
drivers, _ := s.client.GetDriversForSession(champ[0].SessionKey) drivers, driversErr := client.GetDriversForSession(champ[0].SessionKey)
driverMap := buildDriverMapFirst(drivers) driverMap := buildDriverMapFirst(drivers)
incomplete := driversErr != nil
enriched := make([]champDriverWithInfo, 0, len(champ)) enriched := make([]champDriverWithInfo, 0, len(champ))
for _, c := range champ { for _, c := range champ {
e := champDriverWithInfo{ChampionshipDriver: c} e := champDriverWithInfo{ChampionshipDriver: c}
d, ok := s.championshipDriverInfo(c.SessionKey, c.DriverNumber, driverMap) d, ok := championshipDriverInfo(client, c.SessionKey, c.DriverNumber, driverMap)
if ok { if ok && hasDriverPresentation(d) {
e.NameAcronym = d.NameAcronym e.NameAcronym = d.NameAcronym
e.FullName = d.FullName e.FullName = d.FullName
e.TeamName = d.TeamName e.TeamName = d.TeamName
e.TeamColour = d.TeamColour e.TeamColour = d.TeamColour
} else {
incomplete = true
} }
enriched = append(enriched, e) enriched = append(enriched, e)
} }
markOpenF1AggregateResponse(w, client, incomplete)
writeJSON(w, enriched) writeJSON(w, enriched)
} }
func (s *Server) championshipDriverInfo(sessionKey, driverNumber int, fallback map[int]models.Driver) (models.Driver, bool) { func championshipDriverInfo(client *api.OpenF1Client, sessionKey, driverNumber int, fallback map[int]models.Driver) (models.Driver, bool) {
if d, err := s.client.GetDriver(sessionKey, driverNumber); err == nil && d != nil { if d, err := client.GetDriver(sessionKey, driverNumber); err == nil && d != nil {
return *d, true return *d, true
} }
d, ok := fallback[driverNumber] d, ok := fallback[driverNumber]
@@ -590,11 +651,17 @@ func (s *Server) handleChampionshipTeams(w http.ResponseWriter, r *http.Request)
if year == 0 { if year == 0 {
year = time.Now().Year() year = time.Now().Year()
} }
teams, err := s.client.GetTeamChampionshipForYear(year) client := s.client.Scoped()
teams, err := client.GetTeamChampionshipForYear(year)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
if len(teams) == 0 {
markOpenF1Availability(w, client, "limited")
} else {
markOpenF1Response(w, client)
}
writeJSON(w, teams) writeJSON(w, teams)
} }
@@ -672,6 +739,8 @@ func champHubTTL(year int, now time.Time) time.Duration {
type champHubEntry struct { type champHubEntry struct {
resp champHubResponse resp champHubResponse
source string
freshness string
expires time.Time expires time.Time
} }
@@ -693,12 +762,26 @@ func (c *champHubCache) get(year int, now time.Time) (champHubResponse, bool) {
} }
func (c *champHubCache) put(year int, resp champHubResponse, now time.Time, ttl time.Duration) { func (c *champHubCache) put(year int, resp champHubResponse, now time.Time, ttl time.Duration) {
c.putWithMetadata(year, resp, "local", "local", now, ttl)
}
func (c *champHubCache) getWithMetadata(year int, now time.Time) (champHubResponse, string, string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries[year]
if !ok || now.After(e.expires) {
return champHubResponse{}, "", "", false
}
return e.resp, e.source, e.freshness, true
}
func (c *champHubCache) putWithMetadata(year int, resp champHubResponse, source, freshness string, now time.Time, ttl time.Duration) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if c.entries == nil { if c.entries == nil {
c.entries = map[int]champHubEntry{} c.entries = map[int]champHubEntry{}
} }
c.entries[year] = champHubEntry{resp: resp, expires: now.Add(ttl)} c.entries[year] = champHubEntry{resp: resp, source: source, freshness: freshness, expires: now.Add(ttl)}
} }
// fetchMeetingRaces fans fetch out across meetings with bounded concurrency. // fetchMeetingRaces fans fetch out across meetings with bounded concurrency.
@@ -740,13 +823,6 @@ func (s *Server) handleChampionshipHub(w http.ResponseWriter, r *http.Request) {
} }
mode := parseSourceMode(r) mode := parseSourceMode(r)
if mode != sourceLocal {
if resp, ok := s.hubCache.get(year, time.Now()); ok {
writeJSON(w, resp)
return
}
}
if mode == sourceLocal || mode == sourceAuto { if mode == sourceLocal || mode == sourceAuto {
resp, ok, err := s.localChampionshipHub(year) resp, ok, err := s.localChampionshipHub(year)
if err != nil { if err != nil {
@@ -754,21 +830,33 @@ func (s *Server) handleChampionshipHub(w http.ResponseWriter, r *http.Request) {
return return
} }
if ok { if ok {
s.hubCache.put(year, resp, time.Now(), champHubTTL(year, time.Now())) s.hubCache.putWithMetadata(year, resp, "local", "local", time.Now(), champHubTTL(year, time.Now()))
markLocalResponse(w, false)
writeJSON(w, resp) writeJSON(w, resp)
return return
} }
if mode == sourceLocal { if mode == sourceLocal {
markDataResponse(w, "none", "limited")
writeJSON(w, resp) writeJSON(w, resp)
return return
} }
} }
resp, err := s.openF1ChampionshipHub(year) // At this point an auto request has no usable domain aggregate and an
if err != nil { // explicit OpenF1 request must not be satisfied by a local cache entry.
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) if resp, source, freshness, ok := s.hubCache.getWithMetadata(year, time.Now()); ok && source == "openf1" {
markDataResponse(w, source, freshness)
writeJSON(w, resp)
return return
} }
client := s.client.Scoped()
resp, incomplete, err := s.openF1ChampionshipHub(client, year)
if err != nil {
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
markOpenF1AggregateResponse(w, client, incomplete)
writeJSON(w, resp) writeJSON(w, resp)
} }
@@ -797,25 +885,35 @@ func (s *Server) localChampionshipHub(year int) (champHubResponse, bool, error)
return aggregateChampionshipHub(year, races, inputs.Champ, inputs.Teams, inputs.DriverMap), true, nil return aggregateChampionshipHub(year, races, inputs.Champ, inputs.Teams, inputs.DriverMap), true, nil
} }
func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) { func (s *Server) openF1ChampionshipHub(client *api.OpenF1Client, year int) (champHubResponse, bool, error) {
champ, err := s.client.GetDriverChampionshipForYear(year) champ, err := client.GetDriverChampionshipForYear(year)
if err != nil { if err != nil {
return champHubResponse{}, err return champHubResponse{}, false, err
} }
if len(champ) == 0 { if len(champ) == 0 {
return champHubResponse{Season: year, RoundLabels: []string{}, Drivers: []champHubDriver{}, Teams: []champHubTeam{}}, nil return champHubResponse{Season: year, RoundLabels: []string{}, Drivers: []champHubDriver{}, Teams: []champHubTeam{}}, true, nil
} }
teams, _ := s.client.GetTeamChampionshipForYear(year) teams, teamsErr := client.GetTeamChampionshipForYear(year)
driverInfo := map[int]models.Driver{} driverInfo := map[int]models.Driver{}
if ds, derr := s.client.GetDriversForSession(champ[0].SessionKey); derr == nil { driversIncomplete := false
if ds, derr := client.GetDriversForSession(champ[0].SessionKey); derr == nil {
driverInfo = buildDriverMapFirst(ds) driverInfo = buildDriverMapFirst(ds)
} else {
driversIncomplete = true
}
for _, standing := range champ {
if !hasDriverPresentation(driverInfo[standing.DriverNumber]) {
driversIncomplete = true
break
}
} }
races, incomplete, err := s.fetchSeasonRaces(year) races, incomplete, err := fetchSeasonRaces(client, year)
if err != nil { if err != nil {
return champHubResponse{}, err return champHubResponse{}, false, err
} }
incomplete = incomplete || teamsErr != nil || driversIncomplete
resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo) resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo)
ttl := champHubTTL(year, time.Now()) ttl := champHubTTL(year, time.Now())
@@ -825,16 +923,22 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
// so a partial view of the season doesn't stick around for the full TTL. // so a partial view of the season doesn't stick around for the full TTL.
ttl = champHubIncompleteTTL ttl = champHubIncompleteTTL
} }
s.hubCache.put(year, resp, time.Now(), ttl) freshness := "fresh"
return resp, nil if client.LastResponseWasStale() {
freshness = "stale"
} else if incomplete {
freshness = "partial"
}
s.hubCache.putWithMetadata(year, resp, "openf1", freshness, time.Now(), ttl)
return resp, incomplete, nil
} }
// fetchSeasonRaces returns a season's GP meetings in date order, each bundled // fetchSeasonRaces returns a season's GP meetings in date order, each bundled
// with its race results and starting grid fetched from OpenF1. incomplete // with its race results and starting grid fetched from OpenF1. incomplete
// reports whether any per-meeting fetch failed, so callers can avoid caching a // reports whether any per-meeting fetch failed, so callers can avoid caching a
// partial view of the season for long. // partial view of the season for long.
func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete bool, err error) { func fetchSeasonRaces(client *api.OpenF1Client, year int) (races []meetingRace, incomplete bool, err error) {
meetings, err := s.client.GetMeetingsForYear(year) meetings, err := client.GetMeetingsForYear(year)
if err != nil { if err != nil {
return nil, false, err return nil, false, err
} }
@@ -842,7 +946,7 @@ func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete boo
var failed atomic.Bool var failed atomic.Bool
races = fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) { races = fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) {
sessions, serr := s.client.GetSessionsForMeeting(int(m.MeetingKey)) sessions, serr := client.GetSessionsForMeeting(int(m.MeetingKey))
if serr != nil { if serr != nil {
failed.Store(true) failed.Store(true)
return meetingRace{}, false return meetingRace{}, false
@@ -855,10 +959,17 @@ func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete boo
} }
} }
if raceKey == 0 { if raceKey == 0 {
return meetingRace{}, false // not a GP meeting (e.g. pre-season testing) if isKnownNonChampionshipMeeting(m, sessions) {
return meetingRace{}, false
} }
results, rerr := s.client.GetSessionResult(raceKey) failed.Store(true)
grid, gerr := s.client.GetStartingGrid(raceKey) // The meeting list does not identify non-championship events. Skipping
// a meeting without a Race may be expected (testing), but the aggregate
// is not proven complete and must be labelled partial.
return meetingRace{}, false
}
results, rerr := client.GetSessionResult(raceKey)
grid, gerr := client.GetStartingGrid(raceKey)
if rerr != nil || gerr != nil { if rerr != nil || gerr != nil {
failed.Store(true) failed.Store(true)
} }
@@ -867,6 +978,29 @@ func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete boo
return races, failed.Load(), nil return races, failed.Load(), nil
} }
func isKnownNonChampionshipMeeting(meeting models.Meeting, sessions []models.Session) bool {
if hasTestingToken(meeting.MeetingName + " " + meeting.MeetingOfficialName) {
return true
}
for _, session := range sessions {
if hasTestingToken(session.SessionName + " " + session.SessionType) {
return true
}
}
return false
}
func hasTestingToken(value string) bool {
for _, token := range strings.FieldsFunc(strings.ToLower(value), func(r rune) bool {
return (r < 'a' || r > 'z') && (r < '0' || r > '9')
}) {
if token == "test" || token == "tests" || token == "testing" {
return true
}
}
return false
}
// aggregateChampionshipHub is the pure aggregation core (no network) so it can be // aggregateChampionshipHub is the pure aggregation core (no network) so it can be
// unit-tested with synthetic data. races must be ordered ascending by date and // unit-tested with synthetic data. races must be ordered ascending by date and
// contain only GP meetings (those with a Race session). // contain only GP meetings (those with a Race session).
@@ -1077,6 +1211,7 @@ type trackOutlineResponse struct {
} }
func (s *Server) handleTrackOutline(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTrackOutline(w http.ResponseWriter, r *http.Request) {
markLocalResponse(w, false)
year, _ := strconv.Atoi(r.URL.Query().Get("year")) year, _ := strconv.Atoi(r.URL.Query().Get("year"))
if year == 0 { if year == 0 {
year = time.Now().Year() year = time.Now().Year()
@@ -1281,14 +1416,17 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
stintsErr error stintsErr error
pitsErr error pitsErr error
resErr error resErr error
driversErr error
rcErr error
wg sync.WaitGroup wg sync.WaitGroup
) )
client := s.client.Scoped()
wg.Add(5) wg.Add(5)
go func() { defer wg.Done(); stints, stintsErr = s.client.GetStintsForSession(sessionKey) }() go func() { defer wg.Done(); stints, stintsErr = client.GetStintsForSession(sessionKey) }()
go func() { defer wg.Done(); pits, pitsErr = s.client.GetPitStopsForSession(sessionKey) }() go func() { defer wg.Done(); pits, pitsErr = client.GetPitStopsForSession(sessionKey) }()
go func() { defer wg.Done(); results, resErr = s.client.GetSessionResult(sessionKey) }() go func() { defer wg.Done(); results, resErr = client.GetSessionResult(sessionKey) }()
go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }() go func() { defer wg.Done(); drivers, driversErr = client.GetDriversForSession(sessionKey) }()
go func() { defer wg.Done(); rc, _ = s.client.GetRaceControl(sessionKey) }() go func() { defer wg.Done(); rc, rcErr = client.GetRaceControl(sessionKey) }()
wg.Wait() wg.Wait()
if stintsErr != nil || pitsErr != nil || resErr != nil { if stintsErr != nil || pitsErr != nil || resErr != nil {
@@ -1299,17 +1437,21 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
if e == nil { if e == nil {
e = resErr e = resErr
} }
writeError(w, e, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, e, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
// Non-race sessions have no stints. // Empty strategy data may mean a non-race session or a race still settling.
if len(stints) == 0 { if len(stints) == 0 {
// Without session-type evidence, an empty primary strategy dataset is
// not enough to prove "not applicable" (it may still be settling).
markOpenF1Availability(w, client, "limited")
writeJSON(w, map[string]any{"note": "Not applicable", "drivers": []any{}}) writeJSON(w, map[string]any{"note": "Not applicable", "drivers": []any{}})
return return
} }
driverMap := buildDriverMap(drivers) driverMap := buildDriverMap(drivers)
incomplete := driversErr != nil || rcErr != nil
resultMap := make(map[int]models.SessionResult, len(results)) resultMap := make(map[int]models.SessionResult, len(results))
totalLaps := 0 totalLaps := 0
@@ -1342,6 +1484,9 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
stratDrivers := make([]strategyDriver, 0, len(seenDrivers)) stratDrivers := make([]strategyDriver, 0, len(seenDrivers))
for dn := range seenDrivers { for dn := range seenDrivers {
d := driverMap[dn] d := driverMap[dn]
if !hasDriverPresentation(d) {
incomplete = true
}
res := resultMap[dn] res := resultMap[dn]
sd := strategyDriver{ sd := strategyDriver{
@@ -1396,6 +1541,7 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
return pi < pj return pi < pj
}) })
markOpenF1AggregateResponse(w, client, incomplete)
writeJSON(w, strategyResponse{ writeJSON(w, strategyResponse{
SessionKey: sessionKey, SessionKey: sessionKey,
TotalLaps: totalLaps, TotalLaps: totalLaps,
@@ -1495,17 +1641,27 @@ func (s *Server) handleLapsComparison(w http.ResponseWriter, r *http.Request) {
stints []models.Stint stints []models.Stint
pits []models.Pit pits []models.Pit
rc []models.RaceControl rc []models.RaceControl
lapsErr error
stintsErr error
pitsErr error
rcErr error
wg sync.WaitGroup wg sync.WaitGroup
) )
client := s.client.Scoped()
wg.Add(4) wg.Add(4)
go func() { defer wg.Done(); allLaps, _ = s.client.GetLapsForSession(sessionKey) }() go func() { defer wg.Done(); allLaps, lapsErr = client.GetLapsForSession(sessionKey) }()
go func() { defer wg.Done(); stints, _ = s.client.GetStintsForSession(sessionKey) }() go func() { defer wg.Done(); stints, stintsErr = client.GetStintsForSession(sessionKey) }()
go func() { defer wg.Done(); pits, _ = s.client.GetPitStopsForSession(sessionKey) }() go func() { defer wg.Done(); pits, pitsErr = client.GetPitStopsForSession(sessionKey) }()
go func() { defer wg.Done(); rc, _ = s.client.GetRaceControl(sessionKey) }() go func() { defer wg.Done(); rc, rcErr = client.GetRaceControl(sessionKey) }()
wg.Wait() wg.Wait()
if lapsErr != nil {
writeError(w, lapsErr, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
allDrivers, _ := s.client.GetDriversForSession(sessionKey) allDrivers, driversErr := client.GetDriversForSession(sessionKey)
driverMap := buildDriverMap(allDrivers) driverMap := buildDriverMap(allDrivers)
incomplete := stintsErr != nil || pitsErr != nil || rcErr != nil || driversErr != nil
// If no filter, default to first 3 unique driver numbers from lap data. // If no filter, default to first 3 unique driver numbers from lap data.
if len(requestedDrivers) == 0 { if len(requestedDrivers) == 0 {
@@ -1543,6 +1699,9 @@ func (s *Server) handleLapsComparison(w http.ResponseWriter, r *http.Request) {
compDrivers := make([]comparisonDriver, 0, len(requestedDrivers)) compDrivers := make([]comparisonDriver, 0, len(requestedDrivers))
for _, dn := range requestedDrivers { for _, dn := range requestedDrivers {
d := driverMap[dn] d := driverMap[dn]
if !hasDriverPresentation(d) {
incomplete = true
}
cd := comparisonDriver{ cd := comparisonDriver{
DriverNumber: dn, DriverNumber: dn,
NameAcronym: d.NameAcronym, NameAcronym: d.NameAcronym,
@@ -1558,6 +1717,13 @@ func (s *Server) handleLapsComparison(w http.ResponseWriter, r *http.Request) {
compDrivers = append(compDrivers, cd) compDrivers = append(compDrivers, cd)
} }
freshness := "fresh"
if len(allLaps) == 0 {
freshness = "limited"
} else if incomplete {
freshness = "partial"
}
markOpenF1Availability(w, client, freshness)
writeJSON(w, lapsComparisonResponse{ writeJSON(w, lapsComparisonResponse{
SessionKey: sessionKey, SessionKey: sessionKey,
SCPeriods: extractSCPeriods(rc), SCPeriods: extractSCPeriods(rc),

View File

@@ -1,13 +1,136 @@
package web package web
import ( import (
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/models"
) )
func championshipTestUpstream(t *testing.T, driversOK, meetingHasRace bool) *httptest.Server {
t.Helper()
completed := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/v1/sessions":
if r.URL.Query().Get("session_name") == "Race" {
_, _ = fmt.Fprintf(w, `[{"session_key":99,"session_name":"Race","date_end":%q}]`, completed)
return
}
if meetingHasRace {
_, _ = w.Write([]byte(`[{"session_key":101,"meeting_key":1,"session_name":"Race"}]`))
} else {
_, _ = w.Write([]byte(`[{"session_key":100,"meeting_key":1,"session_name":"Practice 1"}]`))
}
case "/v1/championship_drivers":
_, _ = w.Write([]byte(`[{"driver_number":1,"session_key":99,"position_current":1,"points_current":25}]`))
case "/v1/championship_teams":
_, _ = w.Write([]byte(`[{"team_name":"Red Bull","position_current":1,"points_current":25}]`))
case "/v1/drivers":
if !driversOK {
http.Error(w, "identity unavailable", http.StatusBadGateway)
return
}
_, _ = w.Write([]byte(`[{"driver_number":1,"name_acronym":"VER","full_name":"Max Verstappen","team_name":"Red Bull","team_colour":"3671c6"}]`))
case "/v1/meetings":
_, _ = w.Write([]byte(`[{"meeting_key":1,"meeting_name":"Mystery Grand Prix"}]`))
case "/v1/session_result":
_, _ = w.Write([]byte(`[{"driver_number":1,"position":1,"points":25}]`))
case "/v1/starting_grid":
_, _ = w.Write([]byte(`[{"driver_number":1,"position":1}]`))
default:
http.NotFound(w, r)
}
}))
}
func TestOpenF1ChampionshipHubIdentityFailureIsPartialAndCached(t *testing.T) {
upstream := championshipTestUpstream(t, false, true)
defer upstream.Close()
client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
defer client.Close()
server := NewServer(client, 0, nil)
year := time.Now().Year()
_, incomplete, err := server.openF1ChampionshipHub(client.Scoped(), year)
if err != nil {
t.Fatal(err)
}
if !incomplete {
t.Fatal("missing championship driver identity was labelled complete")
}
_, source, freshness, ok := server.hubCache.getWithMetadata(year, time.Now())
if !ok || source != "openf1" || freshness != "partial" {
t.Fatalf("cached metadata = hit %v, %q/%q", ok, source, freshness)
}
}
func TestFetchSeasonRacesMeetingWithoutRaceIsIncomplete(t *testing.T) {
upstream := championshipTestUpstream(t, true, false)
defer upstream.Close()
client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
defer client.Close()
races, incomplete, err := fetchSeasonRaces(client.Scoped(), time.Now().Year())
if err != nil {
t.Fatal(err)
}
if !incomplete || len(races) != 0 {
t.Fatalf("no-Race meeting = races %d, incomplete %v", len(races), incomplete)
}
}
func TestFetchSeasonRacesRecognizedTestingMeetingIsNotIncomplete(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/meetings":
_, _ = w.Write([]byte(`[{"meeting_key":1253,"meeting_name":"Pre-Season Testing"}]`))
case "/v1/sessions":
_, _ = w.Write([]byte(`[{"session_key":1,"meeting_key":1253,"session_name":"Day 1","session_type":"Testing"}]`))
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
defer client.Close()
races, incomplete, err := fetchSeasonRaces(client.Scoped(), time.Now().Year())
if err != nil {
t.Fatal(err)
}
if incomplete || len(races) != 0 {
t.Fatalf("recognized testing meeting = races %d, incomplete %v", len(races), incomplete)
}
}
func TestKnownNonChampionshipMeetingRequiresTestingToken(t *testing.T) {
if !isKnownNonChampionshipMeeting(models.Meeting{MeetingName: "Pre-Season Testing"}, nil) {
t.Fatal("pre-season testing was not recognized")
}
if isKnownNonChampionshipMeeting(models.Meeting{MeetingName: "Fastest Grand Prix"}, nil) {
t.Fatal("substring inside a normal word was treated as testing")
}
if !isKnownNonChampionshipMeeting(models.Meeting{MeetingName: "Winter Event"}, []models.Session{{SessionType: "Test"}}) {
t.Fatal("explicit Test session was not recognized")
}
}
func TestHandleChampionshipHubSourceLocalWithoutAggregateIsLimited(t *testing.T) {
server := NewServer(nil, 0, nil)
recorder := httptest.NewRecorder()
server.handleChampionshipHub(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/championship/hub?year=2026&source=local", nil))
if recorder.Code != http.StatusOK || recorder.Header().Get(dataSourceHeader) != "none" || recorder.Header().Get(dataFreshnessHeader) != "limited" {
t.Fatalf("empty local championship = %d %q/%q body=%s", recorder.Code, recorder.Header().Get(dataSourceHeader), recorder.Header().Get(dataFreshnessHeader), recorder.Body.String())
}
}
func raceResult(num, pos int, pts float64) models.SessionResult { func raceResult(num, pos int, pts float64) models.SessionResult {
return models.SessionResult{DriverNumber: num, Position: pos, Points: pts} return models.SessionResult{DriverNumber: num, Position: pos, Points: pts}
} }

View File

@@ -0,0 +1,120 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/AmanTahiliani/box-box/internal/api"
)
func componentTestServer(t *testing.T, responses map[string]string, failures map[string]bool) *Server {
t.Helper()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if failures[r.URL.Path] {
http.Error(w, "component unavailable", http.StatusBadGateway)
return
}
body, ok := responses[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
}))
t.Cleanup(upstream.Close)
client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
t.Cleanup(func() { _ = client.Close() })
return NewServer(client, 0, nil)
}
func assertAvailabilityHeaders(t *testing.T, recorder *httptest.ResponseRecorder, source, freshness string) {
t.Helper()
if recorder.Code != http.StatusOK || recorder.Header().Get(dataSourceHeader) != source || recorder.Header().Get(dataFreshnessHeader) != freshness {
t.Fatalf("response = status %d, metadata %q/%q, body=%s", recorder.Code, recorder.Header().Get(dataSourceHeader), recorder.Header().Get(dataFreshnessHeader), recorder.Body.String())
}
}
func TestResultsAndGridIdentityFailuresReportPartial(t *testing.T) {
tests := []struct {
name string
path string
body string
run func(*Server, http.ResponseWriter, *http.Request)
}{
{name: "results", path: "/v1/session_result", body: `[{"driver_number":1,"position":1}]`, run: func(s *Server, w http.ResponseWriter, r *http.Request) { s.handleResults(w, r) }},
{name: "grid", path: "/v1/starting_grid", body: `[{"driver_number":1,"position":1}]`, run: func(s *Server, w http.ResponseWriter, r *http.Request) { s.handleGrid(w, r) }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := componentTestServer(t, map[string]string{tt.path: tt.body}, map[string]bool{"/v1/drivers": true})
recorder := httptest.NewRecorder()
tt.run(server, recorder, httptest.NewRequest(http.MethodGet, "/api/v1/"+tt.name+"?session_key=99&source=openf1", nil))
assertAvailabilityHeaders(t, recorder, "openf1", "partial")
})
}
}
func TestStrategyOptionalComponentFailureReportsPartial(t *testing.T) {
server := componentTestServer(t, map[string]string{
"/v1/stints": `[{"driver_number":1,"stint_number":1,"lap_start":1,"lap_end":10,"compound":"MEDIUM"}]`,
"/v1/pit": `[]`,
"/v1/session_result": `[{"driver_number":1,"position":1,"number_of_laps":10}]`,
"/v1/race_control": `[]`,
}, map[string]bool{"/v1/drivers": true})
recorder := httptest.NewRecorder()
server.handleStrategy(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/strategy?session_key=99", nil))
assertAvailabilityHeaders(t, recorder, "openf1", "partial")
}
func TestStrategyEmptyPrimaryDataReportsLimited(t *testing.T) {
server := componentTestServer(t, map[string]string{
"/v1/stints": `[]`,
"/v1/pit": `[]`,
"/v1/session_result": `[{"driver_number":1,"position":1,"number_of_laps":10}]`,
"/v1/drivers": `[{"driver_number":1,"full_name":"Max Verstappen","team_name":"Red Bull","team_colour":"3671c6"}]`,
"/v1/race_control": `[]`,
}, nil)
recorder := httptest.NewRecorder()
server.handleStrategy(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/strategy?session_key=99", nil))
assertAvailabilityHeaders(t, recorder, "openf1", "limited")
}
func TestLapsComparisonDoesNotLabelMissingComponentsFresh(t *testing.T) {
tests := []struct {
name string
laps string
freshness string
}{
{name: "empty primary data", laps: `[]`, freshness: "limited"},
{name: "optional components failed", laps: `[{"driver_number":1,"lap_number":1}]`, freshness: "partial"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := componentTestServer(t, map[string]string{"/v1/laps": tt.laps}, map[string]bool{
"/v1/stints": true,
"/v1/pit": true,
"/v1/race_control": true,
"/v1/drivers": true,
})
recorder := httptest.NewRecorder()
server.handleLapsComparison(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/laps/comparison?session_key=99", nil))
assertAvailabilityHeaders(t, recorder, "openf1", tt.freshness)
})
}
t.Run("primary laps failure is an error", func(t *testing.T) {
server := componentTestServer(t, nil, map[string]bool{
"/v1/laps": true,
"/v1/stints": true,
"/v1/pit": true,
"/v1/race_control": true,
})
recorder := httptest.NewRecorder()
server.handleLapsComparison(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/laps/comparison?session_key=99", nil))
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String())
}
})
}

77
internal/web/context.go Normal file
View File

@@ -0,0 +1,77 @@
package web
import (
"net/http"
"strings"
"github.com/AmanTahiliani/box-box/internal/live"
"github.com/AmanTahiliani/box-box/internal/query"
)
func (s *Server) handleWeekendContext(w http.ResponseWriter, _ *http.Request) {
if !s.hasLocalQuery() {
markDataResponse(w, "none", "limited")
writeJSON(w, query.WeekendContext{TemporalState: query.TemporalNoSeason})
return
}
state := s.hub.State()
evidence := query.LiveEvidence{}
if state.IsLive && state.Data != nil {
evidence = liveEvidence(state.Data, true, false)
} else if state.LastSnapshot != nil && terminalSessionStatus(state.LastSnapshot.SessionStatus) {
evidence = liveEvidence(state.LastSnapshot, false, true)
if state.LastSnapshotAt != nil {
evidence.ObservedAt = *state.LastSnapshotAt
}
}
context, err := s.query.ResolveWeekendContext(evidence)
if err != nil {
writeError(w, err, http.StatusInternalServerError, false)
return
}
if focus := focusedContextSession(context); focus != nil {
markDataResponse(w, focus.Availability.Source, focus.Availability.Freshness)
} else {
markDataResponse(w, "none", "limited")
}
writeJSON(w, context)
}
// focusedContextSession selects the session whose state the Weekend shell is
// presenting. An older terminal/default session must never override an
// upcoming focus session's metadata.
func focusedContextSession(context query.WeekendContext) *query.ContextSession {
if context.ActiveSession != nil {
return context.ActiveSession
}
if context.FocusMeeting == nil {
return nil
}
focusKey := context.FocusMeeting.MeetingKey
for _, ref := range []*query.ContextSession{context.NextSession, context.PreviousCompletedSession, context.DefaultAnalysisSession} {
if ref != nil && ref.Meeting != nil && ref.Meeting.MeetingKey == focusKey {
return ref
}
}
return nil
}
func liveEvidence(data *live.LiveStreamData, active, final bool) query.LiveEvidence {
return query.LiveEvidence{Active: active, Final: final, MeetingName: data.Session.MeetingName, CircuitName: data.Session.CircuitName, SessionName: data.Session.SessionName, SessionType: data.Session.SessionType}
}
func terminalSessionStatus(status string) bool {
normalized := strings.ToLower(strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
return r
}
return -1
}, status))
switch normalized {
case "finished", "finalised", "finalized", "ended", "aborted":
return true
default:
return false
}
}

View File

@@ -0,0 +1,169 @@
package web
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/AmanTahiliani/box-box/internal/live"
"github.com/AmanTahiliani/box-box/internal/query"
"github.com/AmanTahiliani/box-box/internal/store"
)
func openContextStore(t *testing.T) *store.Store {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "context.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
return st
}
func seedContextHandler(t *testing.T, st *store.Store) {
t.Helper()
if err := st.UpsertMeeting(store.Meeting{MeetingKey: 1, MeetingName: "British Grand Prix", CircuitShortName: "Silverstone", Year: 2026, DateStart: "2026-07-03T09:00:00Z", DateEnd: "2026-07-05T16:00:00Z"}); err != nil {
t.Fatal(err)
}
if err := st.UpsertSession(store.Session{SessionKey: 11, MeetingKey: 1, SessionName: "Race", SessionType: "Race", DateStart: "2026-07-05T14:00:00Z", DateEnd: "2026-07-05T16:00:00Z"}); err != nil {
t.Fatal(err)
}
}
func TestWeekendContextHandlerWithoutStoreReturnsNoSeason(t *testing.T) {
s := NewServer(nil, 0, nil)
rr := httptest.NewRecorder()
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
var got query.WeekendContext
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.TemporalState != query.TemporalNoSeason {
t.Fatalf("state = %s", got.TemporalState)
}
if rr.Header().Get(dataSourceHeader) != "none" || rr.Header().Get(dataFreshnessHeader) != "limited" {
t.Fatalf("missing context metadata = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
}
}
func TestWeekendContextHandlerEmptyStoreReportsLimited(t *testing.T) {
st := openContextStore(t)
s := NewServer(nil, 0, st)
rr := httptest.NewRecorder()
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
if rr.Code != http.StatusOK || rr.Header().Get(dataSourceHeader) != "none" || rr.Header().Get(dataFreshnessHeader) != "limited" {
t.Fatalf("empty context = %d %q/%q body=%s", rr.Code, rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader), rr.Body.String())
}
}
func TestWeekendContextHandlerUsesLiveHubIdentityWithoutOpenF1(t *testing.T) {
st := openContextStore(t)
seedContextHandler(t, st)
now, _ := time.Parse(time.RFC3339, "2026-07-05T13:55:00Z")
s := NewServer(nil, 0, st) // a nil OpenF1 client makes any REST dependency panic
s.query = query.NewServiceWithClock(st, func() time.Time { return now })
s.hub.applySnapshot(live.LiveStreamData{SessionStatus: "Started", Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", CircuitName: "Silverstone", SessionName: "Race", SessionType: "Race"}}, now)
handler, err := s.routes()
if err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rr.Code, rr.Body.String())
}
var got query.WeekendContext
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.TemporalState != query.TemporalSessionLive || got.ActiveSession == nil || got.ActiveSession.Session.SessionKey != 11 {
t.Fatalf("live context = %+v", got)
}
if got.ActiveSession.Availability.LiveTransport != "connected" || got.ActiveSession.Availability.LiveSession != "active" {
t.Fatalf("availability = %+v", got.ActiveSession.Availability)
}
if got.ActiveSession.Availability.Source != "mixed" || got.ActiveSession.Availability.Freshness != "live" {
t.Fatalf("live source/freshness = %+v", got.ActiveSession.Availability)
}
if rr.Header().Get(dataSourceHeader) != "mixed" || rr.Header().Get(dataFreshnessHeader) != "live" {
t.Fatalf("response source/freshness = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
}
}
func TestWeekendContextHandlerUsesTerminalArchiveAsCompletionEvidence(t *testing.T) {
st := openContextStore(t)
seedContextHandler(t, st)
now, _ := time.Parse(time.RFC3339, "2026-07-05T16:05:00Z")
s := NewServer(nil, 0, st)
s.query = query.NewServiceWithClock(st, func() time.Time { return now })
s.hub.applySnapshot(live.LiveStreamData{SessionStatus: "Finished", Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", CircuitName: "Silverstone", SessionName: "Race", SessionType: "Race"}}, now)
rr := httptest.NewRecorder()
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
var got query.WeekendContext
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Availability.Archive != "available" {
t.Fatalf("archive context = %+v", got)
}
if got.PreviousCompletedSession.Availability.Source != "mixed" || got.PreviousCompletedSession.Availability.Freshness != "archive" {
t.Fatalf("archive source/freshness = %+v", got.PreviousCompletedSession.Availability)
}
if rr.Header().Get(dataSourceHeader) != "mixed" || rr.Header().Get(dataFreshnessHeader) != "archive" {
t.Fatalf("archive response source/freshness = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
}
if got.DefaultAnalysisSession != nil {
t.Fatal("archive-only session must not become local default analysis")
}
}
func TestWeekendContextMetadataFollowsUpcomingFocusNotTerminalPrevious(t *testing.T) {
st := openContextStore(t)
seedContextHandler(t, st)
if err := st.UpsertMeeting(store.Meeting{MeetingKey: 2, MeetingName: "Belgian Grand Prix", CircuitShortName: "Spa", Year: 2026, DateStart: "2026-07-10T09:00:00Z", DateEnd: "2026-07-12T16:00:00Z"}); err != nil {
t.Fatal(err)
}
if err := st.UpsertSession(store.Session{SessionKey: 21, MeetingKey: 2, SessionName: "Practice 1", SessionType: "Practice", DateStart: "2026-07-10T09:00:00Z", DateEnd: "2026-07-10T10:00:00Z"}); err != nil {
t.Fatal(err)
}
now, _ := time.Parse(time.RFC3339, "2026-07-05T16:05:00Z")
s := NewServer(nil, 0, st)
s.query = query.NewServiceWithClock(st, func() time.Time { return now })
s.hub.applySnapshot(live.LiveStreamData{SessionStatus: "Finished", Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", CircuitName: "Silverstone", SessionName: "Race", SessionType: "Race"}}, now)
rr := httptest.NewRecorder()
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
var got query.WeekendContext
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.FocusMeeting == nil || got.FocusMeeting.MeetingKey != 2 || got.NextSession == nil {
t.Fatalf("focus context = %+v", got)
}
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Availability.Freshness != "archive" {
t.Fatalf("terminal previous missing = %+v", got.PreviousCompletedSession)
}
if rr.Header().Get(dataSourceHeader) != "local" || rr.Header().Get(dataFreshnessHeader) != "local" {
t.Fatalf("focus metadata was overridden by archive = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
}
}
func TestTerminalSessionStatus(t *testing.T) {
for _, status := range []string{"Finished", "Finalised", "ENDED", "Aborted"} {
if !terminalSessionStatus(status) {
t.Errorf("%q should be terminal", status)
}
}
for _, status := range []string{"Started", "Resumed", "Inactive", ""} {
if terminalSessionStatus(status) {
t.Errorf("%q should not be terminal", status)
}
}
}

View File

@@ -1,18 +1,25 @@
package web package web
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/models"
) )
// --- /api/v1/driver/summary --- // --- /api/v1/driver/summary ---
// Per-driver season summary: championship standing plus per-round race results, // Per-driver season summary: championship standing plus per-round race results.
// aggregated server-side from the same sources as the championship hub. Caching // Current-season identity/results are local-first from the domain DB. Optional
// relies on the OpenF1 client's HTTP cache TTLs — no extra layer here. // OpenF1 enrichment (headshot / polished identity) is bounded so it cannot hang
// the profile when remote data is slow or unavailable.
// driverEnrichmentTimeout bounds optional remote enrichment so a hung OpenF1
// call never blocks a local-first profile response. Overridable in tests.
var driverEnrichmentTimeout = 2 * time.Second
type driverSummaryRound struct { type driverSummaryRound struct {
MeetingKey int `json:"meeting_key"` MeetingKey int `json:"meeting_key"`
@@ -49,6 +56,11 @@ type driverSummaryResponse struct {
Cumulative []float64 `json:"cumulative"` Cumulative []float64 `json:"cumulative"`
RoundLabels []string `json:"round_labels"` RoundLabels []string `json:"round_labels"`
Rounds []driverSummaryRound `json:"rounds"` Rounds []driverSummaryRound `json:"rounds"`
// Source is "local" when served from the domain DB, else "openf1".
Source string `json:"source,omitempty"`
// Enrichment is "full" when optional remote identity landed, "limited"
// when it timed out/failed, or "none" when no enrichment was attempted.
Enrichment string `json:"enrichment,omitempty"`
} }
func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
@@ -61,12 +73,102 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
if year == 0 { if year == 0 {
year = time.Now().Year() year = time.Now().Year()
} }
mode := parseSourceMode(r)
// Driver summary is local-first for current-season identity/results. When the
// caller omits ?source=, prefer auto (local then OpenF1) rather than the
// package default of openf1-only.
if r.URL.Query().Get("source") == "" {
mode = sourceAuto
}
client := s.client.Scoped()
champ, err := s.client.GetDriverChampionshipForYear(year) if mode == sourceLocal || mode == sourceAuto {
if err != nil { resp, sessionKey, ok, lerr := s.localDriverSummary(year, driverNumber)
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) if lerr != nil {
writeError(w, lerr, http.StatusInternalServerError, false)
return return
} }
if ok {
if mode != sourceLocal {
tryEnrichDriverSummary(r.Context(), client, &resp, sessionKey)
}
switch resp.Enrichment {
case "full":
markMixedResponse(w, client, false)
case "limited":
markDataResponse(w, "local", "limited")
default:
markLocalResponse(w, false)
}
writeJSON(w, resp)
return
}
if mode == sourceLocal {
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
return
}
}
resp, incomplete, err := openF1DriverSummary(client, year, driverNumber)
if err != nil {
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
if resp == nil {
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
return
}
markOpenF1AggregateResponse(w, client, incomplete)
writeJSON(w, resp)
}
func (s *Server) localDriverSummary(year, driverNumber int) (driverSummaryResponse, int, bool, error) {
if !s.hasLocalQuery() {
return driverSummaryResponse{}, 0, false, nil
}
inputs, err := s.query.GetChampionshipInputs(year)
if err != nil {
return driverSummaryResponse{}, 0, false, err
}
if len(inputs.Champ) == 0 {
return driverSummaryResponse{}, 0, false, nil
}
races := make([]meetingRace, 0, len(inputs.Races))
for _, race := range inputs.Races {
races = append(races, meetingRace{
Meeting: race.Meeting,
RaceSessionKey: race.RaceSessionKey,
Results: race.Results,
Grid: race.Grid,
})
}
resp, ok := aggregateDriverSummary(year, driverNumber, races, inputs.Champ, inputs.DriverMap)
if !ok {
return driverSummaryResponse{}, 0, false, nil
}
resp.Source = "local"
resp.Enrichment = "none"
sessionKey := 0
for _, c := range inputs.Champ {
if c.DriverNumber == driverNumber && c.SessionKey > 0 {
sessionKey = c.SessionKey
break
}
if sessionKey == 0 && c.SessionKey > 0 {
sessionKey = c.SessionKey
}
}
return resp, sessionKey, true, nil
}
func openF1DriverSummary(client *api.OpenF1Client, year, driverNumber int) (*driverSummaryResponse, bool, error) {
champ, err := client.GetDriverChampionshipForYear(year)
if err != nil {
return nil, false, err
}
var entry *models.ChampionshipDriver var entry *models.ChampionshipDriver
for i := range champ { for i := range champ {
if champ[i].DriverNumber == driverNumber { if champ[i].DriverNumber == driverNumber {
@@ -75,30 +177,83 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
} }
} }
if entry == nil { if entry == nil {
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound) return nil, false, nil
return
} }
driverInfo := map[int]models.Driver{} driverInfo := map[int]models.Driver{}
if ds, derr := s.client.GetDriversForSession(champ[0].SessionKey); derr == nil { sessionKey := champ[0].SessionKey
if ds, derr := client.GetDriversForSession(sessionKey); derr == nil {
driverInfo = buildDriverMapFirst(ds) driverInfo = buildDriverMapFirst(ds)
} }
if d, ok := s.championshipDriverInfo(entry.SessionKey, driverNumber, driverInfo); ok { d, directErr := client.GetDriver(entry.SessionKey, driverNumber)
driverInfo[driverNumber] = d if directErr == nil && d != nil {
driverInfo[driverNumber] = *d
} else if fallback, ok := driverInfo[driverNumber]; ok {
driverInfo[driverNumber] = fallback
} }
identityIncomplete := !hasDriverPresentation(driverInfo[driverNumber])
races, _, err := s.fetchSeasonRaces(year) races, racesIncomplete, err := fetchSeasonRaces(client, year)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) return nil, false, err
return
} }
resp, ok := aggregateDriverSummary(year, driverNumber, races, champ, driverInfo) resp, ok := aggregateDriverSummary(year, driverNumber, races, champ, driverInfo)
if !ok { if !ok {
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound) return nil, false, nil
}
incomplete := identityIncomplete || racesIncomplete
resp.Source = "openf1"
if identityIncomplete {
resp.Enrichment = "limited"
} else {
resp.Enrichment = "full"
}
return &resp, incomplete, nil
}
func hasDriverPresentation(driver models.Driver) bool {
hasName := driver.FullName != "" || driver.NameAcronym != "" || driver.BroadcastName != ""
return hasName && driver.TeamName != "" && driver.TeamColour != ""
}
// tryEnrichDriverSummary optionally fills headshot / polished identity from
// OpenF1. It never blocks longer than driverEnrichmentTimeout — on timeout or
// failure the local profile remains intact with enrichment=limited.
func tryEnrichDriverSummary(parent context.Context, client *api.OpenF1Client, resp *driverSummaryResponse, sessionKey int) {
if resp == nil || client == nil || sessionKey <= 0 {
if resp != nil && resp.Enrichment == "none" {
// No session to enrich from — leave as none (local identity only).
}
return return
} }
writeJSON(w, resp)
ctx, cancel := context.WithTimeout(parent, driverEnrichmentTimeout)
defer cancel()
driver, err := client.GetDriverContext(ctx, sessionKey, resp.DriverNumber)
if err != nil || driver == nil {
resp.Enrichment = "limited"
return
}
applyDriverEnrichment(resp, *driver)
resp.Enrichment = "full"
}
func applyDriverEnrichment(resp *driverSummaryResponse, d models.Driver) {
if d.HeadshotURL != "" {
resp.HeadshotURL = d.HeadshotURL
}
if d.FullName != "" {
resp.FullName = d.FullName
}
if d.NameAcronym != "" {
resp.NameAcronym = d.NameAcronym
}
if d.TeamName != "" {
resp.TeamName = d.TeamName
}
if d.TeamColour != "" {
resp.TeamColour = d.TeamColour
}
} }
// aggregateDriverSummary is the pure aggregation core (no network) so it can be // aggregateDriverSummary is the pure aggregation core (no network) so it can be

View File

@@ -1,11 +1,16 @@
package web package web
import ( import (
"encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/store"
) )
func driverSummaryFixtures() ([]meetingRace, []models.ChampionshipDriver, map[int]models.Driver) { func driverSummaryFixtures() ([]meetingRace, []models.ChampionshipDriver, map[int]models.Driver) {
@@ -134,3 +139,234 @@ func TestHandleDriverSummaryBadRequest(t *testing.T) {
} }
} }
} }
func seedDriverSummaryStore(t *testing.T, st *store.Store) {
t.Helper()
meetingKey := 1201
sessionKey := 9901
if err := st.UpsertMeeting(store.Meeting{
MeetingKey: meetingKey,
MeetingName: "Bahrain GP",
CountryCode: "BHR",
CountryName: "Bahrain",
Year: 2025,
DateStart: "2025-03-02",
}); err != nil {
t.Fatalf("UpsertMeeting: %v", err)
}
if err := st.UpsertSession(store.Session{
SessionKey: sessionKey,
MeetingKey: meetingKey,
SessionName: "Race",
SessionType: "Race",
DateStart: "2025-03-02T15:00:00Z",
}); err != nil {
t.Fatalf("UpsertSession: %v", err)
}
if err := st.UpsertDriver(store.Driver{
DriverNumber: 1,
FullName: "Max Verstappen",
NameAcronym: "VER",
TeamName: "Red Bull",
TeamColour: "3671c6",
}); err != nil {
t.Fatalf("UpsertDriver: %v", err)
}
if err := st.UpsertSessionDriver(store.SessionDriver{
SessionKey: sessionKey,
DriverNumber: 1,
MeetingKey: meetingKey,
FullName: "Max Verstappen",
NameAcronym: "VER",
TeamName: "Red Bull",
TeamColour: "3671c6",
}); err != nil {
t.Fatalf("UpsertSessionDriver: %v", err)
}
if err := st.UpsertSessionResult(store.SessionResult{
SessionKey: sessionKey,
DriverNumber: 1,
MeetingKey: meetingKey,
Position: 1,
Points: 25,
}); err != nil {
t.Fatalf("UpsertSessionResult: %v", err)
}
if err := st.UpsertStartingGridEntry(store.StartingGridEntry{
SessionKey: sessionKey,
DriverNumber: 1,
MeetingKey: meetingKey,
Position: 1,
}); err != nil {
t.Fatalf("UpsertStartingGridEntry: %v", err)
}
}
func TestHandleDriverSummaryLocalFirstIgnoresHangingEnrichment(t *testing.T) {
prev := driverEnrichmentTimeout
driverEnrichmentTimeout = 40 * time.Millisecond
t.Cleanup(func() { driverEnrichmentTimeout = prev })
st := openTestStore(t)
seedDriverSummaryStore(t, st)
// Enrichment seam: OpenF1 hangs until released. Local summary must still return.
release := make(chan struct{})
cancelObserved := make(chan struct{})
hang := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-release:
case <-r.Context().Done():
close(cancelObserved)
}
}))
t.Cleanup(func() {
close(release)
hang.Close()
})
client := api.NewOpenF1Client(hang.URL, 15*time.Second)
t.Cleanup(func() { _ = client.Close() })
srv := NewServer(client, 8080, st)
start := time.Now()
req := httptest.NewRequest(http.MethodGet, "/api/v1/driver/summary?year=2025&driver_number=1", nil)
rec := httptest.NewRecorder()
srv.handleDriverSummary(rec, req)
elapsed := time.Since(start)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
if elapsed > 500*time.Millisecond {
t.Fatalf("handler blocked on enrichment for %v", elapsed)
}
select {
case <-cancelObserved:
case <-time.After(250 * time.Millisecond):
t.Fatal("timed-out enrichment did not cancel its upstream request")
}
var resp driverSummaryResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Source != "local" {
t.Errorf("source = %q, want local", resp.Source)
}
if resp.Enrichment != "limited" {
t.Errorf("enrichment = %q, want limited", resp.Enrichment)
}
if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "limited" {
t.Errorf("limited metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
}
if resp.DriverNumber != 1 || resp.NameAcronym != "VER" || resp.Points != 25 {
t.Errorf("local identity/results missing: %+v", resp)
}
}
func TestHandleDriverSummaryLocalFirstWithFailingEnrichment(t *testing.T) {
prev := driverEnrichmentTimeout
driverEnrichmentTimeout = 200 * time.Millisecond
t.Cleanup(func() { driverEnrichmentTimeout = prev })
st := openTestStore(t)
seedDriverSummaryStore(t, st)
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusBadGateway)
}))
t.Cleanup(fail.Close)
client := api.NewOpenF1Client(fail.URL, 2*time.Second)
t.Cleanup(func() { _ = client.Close() })
srv := NewServer(client, 8080, st)
req := httptest.NewRequest(http.MethodGet, "/api/v1/driver/summary?year=2025&driver_number=1&source=auto", nil)
rec := httptest.NewRecorder()
srv.handleDriverSummary(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
var resp driverSummaryResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Source != "local" {
t.Errorf("source = %q, want local", resp.Source)
}
if resp.Enrichment != "limited" {
t.Errorf("enrichment = %q, want limited", resp.Enrichment)
}
if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "limited" {
t.Errorf("limited metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
}
if resp.FullName != "Max Verstappen" {
t.Errorf("full_name = %q, want local identity", resp.FullName)
}
}
func TestHandleDriverSummarySourceLocalOnly(t *testing.T) {
st := openTestStore(t)
seedDriverSummaryStore(t, st)
// Even with a broken OpenF1 client, source=local must succeed from the DB.
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "nope", http.StatusInternalServerError)
}))
t.Cleanup(fail.Close)
client := api.NewOpenF1Client(fail.URL, time.Second)
t.Cleanup(func() { _ = client.Close() })
srv := NewServer(client, 8080, st)
req := httptest.NewRequest(http.MethodGet, "/api/v1/driver/summary?year=2025&driver_number=1&source=local", nil)
rec := httptest.NewRecorder()
srv.handleDriverSummary(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "local" {
t.Fatalf("local metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
}
}
func TestHandleRemoteDriverSummaryReportsIdentityAndRoundLimitations(t *testing.T) {
tests := []struct {
name string
driversOK bool
meetingHasRace bool
wantEnrichment string
}{
{name: "missing identity", driversOK: false, meetingHasRace: true, wantEnrichment: "limited"},
{name: "missing race round", driversOK: true, meetingHasRace: false, wantEnrichment: "full"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
upstream := championshipTestUpstream(t, tt.driversOK, tt.meetingHasRace)
defer upstream.Close()
client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
defer client.Close()
server := NewServer(client, 0, nil)
year := time.Now().Year()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/driver/summary?year=%d&driver_number=1&source=openf1", year), nil)
server.handleDriverSummary(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String())
}
var response driverSummaryResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Enrichment != tt.wantEnrichment {
t.Fatalf("enrichment = %q, want %q", response.Enrichment, tt.wantEnrichment)
}
if recorder.Header().Get(dataSourceHeader) != "openf1" || recorder.Header().Get(dataFreshnessHeader) != "partial" {
t.Fatalf("remote limitation metadata = %q/%q", recorder.Header().Get(dataSourceHeader), recorder.Header().Get(dataFreshnessHeader))
}
})
}
}

65
internal/web/freshness.go Normal file
View File

@@ -0,0 +1,65 @@
package web
import (
"net/http"
)
const (
dataSourceHeader = "X-BoxBox-Data-Source"
dataFreshnessHeader = "X-BoxBox-Data-Freshness"
)
type staleResponseReporter interface {
LastResponseWasStale() bool
}
// markOpenF1Response publishes request-scoped success provenance. Callers must
// pass the scoped client used for this response, never Server.client.
func markOpenF1Response(w http.ResponseWriter, client staleResponseReporter) {
markOpenF1AggregateResponse(w, client, false)
}
func markOpenF1AggregateResponse(w http.ResponseWriter, client staleResponseReporter, partial bool) {
freshness := "fresh"
if partial {
freshness = "partial"
}
markOpenF1Availability(w, client, freshness)
}
func markOpenF1Availability(w http.ResponseWriter, client staleResponseReporter, freshness string) {
w.Header().Set(dataSourceHeader, "openf1")
if client != nil && client.LastResponseWasStale() {
w.Header().Set(dataFreshnessHeader, "stale")
return
}
if freshness == "" {
freshness = "fresh"
}
w.Header().Set(dataFreshnessHeader, freshness)
}
func markDataResponse(w http.ResponseWriter, source, freshness string) {
w.Header().Set(dataSourceHeader, source)
w.Header().Set(dataFreshnessHeader, freshness)
}
func markLocalResponse(w http.ResponseWriter, partial bool) {
w.Header().Set(dataSourceHeader, "local")
if partial {
w.Header().Set(dataFreshnessHeader, "partial")
return
}
w.Header().Set(dataFreshnessHeader, "local")
}
func markMixedResponse(w http.ResponseWriter, client staleResponseReporter, partial bool) {
w.Header().Set(dataSourceHeader, "mixed")
if client != nil && client.LastResponseWasStale() {
w.Header().Set(dataFreshnessHeader, "stale")
} else if partial {
w.Header().Set(dataFreshnessHeader, "partial")
} else {
w.Header().Set(dataFreshnessHeader, "local")
}
}

View File

@@ -0,0 +1,86 @@
package web
import (
"database/sql"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/AmanTahiliani/box-box/internal/api"
_ "modernc.org/sqlite"
)
type fakeStaleReporter bool
func (f fakeStaleReporter) LastResponseWasStale() bool { return bool(f) }
func TestStaleFreshnessTakesPrecedenceOverPartialAndLimited(t *testing.T) {
for _, fallback := range []string{"partial", "limited"} {
recorder := httptest.NewRecorder()
markOpenF1Availability(recorder, fakeStaleReporter(true), fallback)
if recorder.Header().Get(dataFreshnessHeader) != "stale" {
t.Fatalf("fallback %q overrode stale: %q", fallback, recorder.Header().Get(dataFreshnessHeader))
}
}
}
func TestOpenF1HandlerReportsFreshThenStaleSuccess(t *testing.T) {
year := time.Now().Year()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, `[{"meeting_key":1,"meeting_name":"British Grand Prix","year":%d}]`, year)
}))
client := api.NewOpenF1Client(upstream.URL, time.Second)
t.Cleanup(func() { _ = client.Close() })
server := NewServer(client, 0, nil)
cacheKey := fmt.Sprintf("%s/v1/meetings?year=%d", upstream.URL, year)
requestURL := fmt.Sprintf("/api/v1/meetings?year=%d&source=openf1", year)
defer func() {
// Keep the shared application cache clean even if this test fails.
db, err := sql.Open("sqlite", api.DefaultCacheDBPath()+"?_busy_timeout=5000")
if err == nil {
_, _ = db.Exec(`DELETE FROM cache WHERE key = ?`, cacheKey)
_ = db.Close()
}
}()
fresh := httptest.NewRecorder()
server.handleMeetings(fresh, httptest.NewRequest(http.MethodGet, requestURL, nil))
if fresh.Code != http.StatusOK || fresh.Header().Get(dataSourceHeader) != "openf1" || fresh.Header().Get(dataFreshnessHeader) != "fresh" {
t.Fatalf("fresh response status/metadata = %d %q/%q body=%s", fresh.Code, fresh.Header().Get(dataSourceHeader), fresh.Header().Get(dataFreshnessHeader), fresh.Body.String())
}
db, err := sql.Open("sqlite", api.DefaultCacheDBPath()+"?_busy_timeout=5000")
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`UPDATE cache SET created_at = ? WHERE key = ?`, time.Now().Add(-48*time.Hour).Unix(), cacheKey); err != nil {
_ = db.Close()
t.Fatal(err)
}
_ = db.Close()
upstream.Close()
stale := httptest.NewRecorder()
server.handleMeetings(stale, httptest.NewRequest(http.MethodGet, requestURL, nil))
if stale.Code != http.StatusOK || stale.Header().Get(dataSourceHeader) != "openf1" || stale.Header().Get(dataFreshnessHeader) != "stale" {
t.Fatalf("stale response status/metadata = %d %q/%q body=%s", stale.Code, stale.Header().Get(dataSourceHeader), stale.Header().Get(dataFreshnessHeader), stale.Body.String())
}
}
func TestFreshnessHeadersAreExposedToBrowserClients(t *testing.T) {
handler := withCORS(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
markDataResponse(w, "local", "partial")
writeJSON(w, map[string]bool{"ok": true})
}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/test", nil))
if got := recorder.Header().Get("Access-Control-Expose-Headers"); got != dataSourceHeader+", "+dataFreshnessHeader {
t.Fatalf("exposed headers = %q", got)
}
if recorder.Header().Get(dataSourceHeader) != "local" || recorder.Header().Get(dataFreshnessHeader) != "partial" {
t.Fatalf("data metadata = %q/%q", recorder.Header().Get(dataSourceHeader), recorder.Header().Get(dataFreshnessHeader))
}
}

View File

@@ -134,7 +134,7 @@ func (h *SSEHub) applySnapshot(data live.LiveStreamData, now time.Time) liveStat
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
if snapshotIsLive(data) { if live.SessionStatusIsActive(data.SessionStatus) {
h.isLive = true h.isLive = true
h.activeSnapshot = &data h.activeSnapshot = &data
if data.PositionUpdated && len(data.Positions) > 0 { if data.PositionUpdated && len(data.Positions) > 0 {
@@ -208,40 +208,6 @@ func (h *SSEHub) stateLocked() liveStatePayload {
return payload return payload
} }
// snapshotIsLive reports whether the current live-timing snapshot represents an
// ongoing session that should be surfaced as live.
//
// An actively running session (Started/Resumed) is always live. A non-terminal
// but temporarily inactive session — e.g. a red-flag pause where SessionStatus
// drops to "Inactive" while the session is still in progress — is also live when
// the snapshot itself carries live evidence: a session clock with time remaining
// plus session metadata. This keeps the paused track state visible instead of
// collapsing to the inactive empty state.
//
// Terminal sessions (Finished/Finalised/Ends/Aborted) are never live. A generic
// inactive/no-session stale snapshot is not live either: the predicate keys off
// the current snapshot's clock and session, so old metadata alone is not enough.
func snapshotIsLive(data live.LiveStreamData) bool {
if live.SessionStatusIsActive(data.SessionStatus) {
return true
}
if live.SessionStatusIsTerminal(data.SessionStatus) {
return false
}
return clockHasTimeRemaining(data.Clock) && data.Session.SessionName != ""
}
// clockHasTimeRemaining reports whether an "HH:MM:SS" session clock has any time
// left. Empty or all-zero clocks (a spent or absent session) return false.
func clockHasTimeRemaining(clock string) bool {
for _, r := range clock {
if r >= '1' && r <= '9' {
return true
}
}
return false
}
func hasLiveSnapshotData(data live.LiveStreamData) bool { func hasLiveSnapshotData(data live.LiveStreamData) bool {
return len(data.Drivers) > 0 || return len(data.Drivers) > 0 ||
len(data.DriverInfo) > 0 || len(data.DriverInfo) > 0 ||
@@ -364,7 +330,15 @@ func cloneLivePositions(in map[string]live.LivePositionData) map[string]live.Liv
// handleLiveState returns the current live data snapshot as JSON. // handleLiveState returns the current live data snapshot as JSON.
func (s *Server) handleLiveState(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLiveState(w http.ResponseWriter, r *http.Request) {
writeJSON(w, s.hub.State()) state := s.hub.State()
freshness := "limited"
if state.IsLive {
freshness = "live"
} else if state.LastSnapshot != nil {
freshness = "archive"
}
markDataResponse(w, "fia", freshness)
writeJSON(w, state)
} }
// handleSSEStream is the persistent SSE endpoint for live data. // handleSSEStream is the persistent SSE endpoint for live data.

View File

@@ -51,101 +51,6 @@ func TestSSEHubArchivesTerminalSessionSnapshot(t *testing.T) {
} }
} }
func TestSnapshotIsLive(t *testing.T) {
redFlag := live.LiveStreamData{
SessionStatus: "Inactive",
TrackStatus: "2",
Clock: "00:03:27",
Session: live.LiveSessionMeta{MeetingName: "Belgian Grand Prix", SessionName: "Practice 2", SessionType: "Practice"},
}
tests := []struct {
name string
data live.LiveStreamData
want bool
}{
{"started", live.LiveStreamData{SessionStatus: "Started"}, true},
{"resumed", live.LiveStreamData{SessionStatus: "Resumed"}, true},
{"red flag inactive with clock and session", redFlag, true},
{"terminal finished with clock", func() live.LiveStreamData {
d := redFlag
d.SessionStatus = "Finished"
return d
}(), false},
{"inactive spent clock", func() live.LiveStreamData {
d := redFlag
d.Clock = "00:00:00"
return d
}(), false},
{"inactive no clock", func() live.LiveStreamData {
d := redFlag
d.Clock = ""
return d
}(), false},
{"inactive no session metadata", func() live.LiveStreamData {
d := redFlag
d.Session = live.LiveSessionMeta{}
return d
}(), false},
}
for _, tt := range tests {
if got := snapshotIsLive(tt.data); got != tt.want {
t.Errorf("%s: snapshotIsLive = %v, want %v", tt.name, got, tt.want)
}
}
}
func TestSSEHubKeepsRedFlagSessionLive(t *testing.T) {
hub := newSSEHub()
now := time.Date(2026, 7, 26, 14, 0, 0, 0, time.UTC)
redFlag := live.LiveStreamData{
SessionStatus: "Inactive",
TrackStatus: "2",
Clock: "00:03:27",
Session: live.LiveSessionMeta{MeetingName: "Belgian Grand Prix", SessionName: "Practice 2", SessionType: "Practice"},
SnapshotUpdated: true,
}
state := hub.applySnapshot(redFlag, now)
if !state.IsLive {
t.Fatal("red-flag paused session should report is_live=true")
}
if state.Data == nil {
t.Fatal("red-flag paused session must still send the active snapshot")
}
if state.Data.TrackStatus != "2" {
t.Fatalf("track status = %q, want the paused state \"2\"", state.Data.TrackStatus)
}
if state.LastSnapshot != nil {
t.Fatalf("red-flag session should not be archived: %+v", state.LastSnapshot)
}
}
func TestSSEHubStaleInactiveSnapshotNotLive(t *testing.T) {
hub := newSSEHub()
now := time.Date(2026, 7, 26, 14, 0, 0, 0, time.UTC)
// Inactive snapshot with session metadata but no remaining clock: this is a
// stale/no-live-evidence snapshot and must not be surfaced as live.
stale := live.LiveStreamData{
SessionStatus: "Inactive",
Clock: "00:00:00",
Session: live.LiveSessionMeta{MeetingName: "Belgian Grand Prix", SessionName: "Practice 2"},
SnapshotUpdated: true,
}
state := hub.applySnapshot(stale, now)
if state.IsLive {
t.Fatal("stale inactive snapshot should report is_live=false")
}
if state.Data != nil {
t.Fatalf("stale inactive snapshot leaked into active data: %+v", state.Data)
}
if state.LastSnapshot == nil {
t.Fatal("stale inactive snapshot with metadata should be archived")
}
}
func TestHandleLiveStateKeepsArchiveOutOfActiveData(t *testing.T) { func TestHandleLiveStateKeepsArchiveOutOfActiveData(t *testing.T) {
hub := newSSEHub() hub := newSSEHub()
now := time.Date(2026, 7, 4, 14, 0, 0, 0, time.UTC) now := time.Date(2026, 7, 4, 14, 0, 0, 0, time.UTC)

View File

@@ -9,6 +9,7 @@ import (
) )
func (s *Server) handleSeasons(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSeasons(w http.ResponseWriter, r *http.Request) {
markLocalResponse(w, false)
if !s.hasLocalQuery() { if !s.hasLocalQuery() {
writeJSON(w, []int{}) writeJSON(w, []int{})
return return
@@ -26,6 +27,7 @@ func (s *Server) handleSeasons(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) handleWeekend(w http.ResponseWriter, r *http.Request) { func (s *Server) handleWeekend(w http.ResponseWriter, r *http.Request) {
markLocalResponse(w, false)
meetingKey, err := strconv.Atoi(r.URL.Query().Get("meeting_key")) meetingKey, err := strconv.Atoi(r.URL.Query().Get("meeting_key"))
if err != nil || meetingKey == 0 { if err != nil || meetingKey == 0 {
http.Error(w, "meeting_key required", http.StatusBadRequest) http.Error(w, "meeting_key required", http.StatusBadRequest)
@@ -46,5 +48,6 @@ func (s *Server) handleWeekend(w http.ResponseWriter, r *http.Request) {
writeError(w, err, http.StatusInternalServerError, false) writeError(w, err, http.StatusInternalServerError, false)
return return
} }
markLocalResponse(w, weekend.Source == query.ResponseSourcePartial)
writeJSON(w, weekend) writeJSON(w, weekend)
} }

Some files were not shown because too many files have changed in this diff Show More