Compare commits

..

36 Commits

Author SHA1 Message Date
Aman Tahiliani
3a377d0bc4 Merge pull request #101 from AmanTahiliani/release/v0.4.0-current-weekend
release: v0.4.0 - Weekend fidelity recovery
2026-07-29 23:41:54 -04:00
AmanTahiliani
3228e4adbd fix(#91): read committed fidelity sign-off 2026-07-29 23:25:36 -04:00
AmanTahiliani
44a32bfd73 test(#89): cover season calendar fallback 2026-07-29 23:22:06 -04:00
AmanTahiliani
deda3bc4ad fix(#91): reject stale fidelity approvals 2026-07-29 23:22:06 -04:00
AmanTahiliani
48030a6d7c test(#89): stabilize command center visual fixtures 2026-07-29 23:18:06 -04:00
AmanTahiliani
74b33d1853 test(#89): seed visual command center calendar 2026-07-29 23:17:10 -04:00
AmanTahiliani
cefdac7006 fix(#91): make fidelity evidence states reproducible 2026-07-29 23:12:03 -04:00
AmanTahiliani
7434621024 fix(#89): archive completed focused meetings 2026-07-29 23:12:03 -04:00
AmanTahiliani
7be0b808fe test(#89): update command center visual baselines 2026-07-29 23:03:05 -04:00
AmanTahiliani
26cd1857f6 fix(#91): serialize shared visual test harness 2026-07-29 23:02:00 -04:00
AmanTahiliani
8e0d0253c2 test(#89): allow seeded calendar readiness 2026-07-29 22:58:36 -04:00
AmanTahiliani
de614e6339 fix(#91): wait for seeded calendar in fidelity capture 2026-07-29 22:55:26 -04:00
AmanTahiliani
9ef8c7e9a3 test(#89): stabilize command center visual readiness 2026-07-29 22:55:26 -04:00
AmanTahiliani
dff187da92 fix(#91): stabilize fidelity command center capture 2026-07-29 22:50:44 -04:00
AmanTahiliani
9c0d37904c fix(#89): retain archived session rail 2026-07-29 22:46:55 -04:00
AmanTahiliani
54ddc13f57 feat(#91): add release fidelity evidence gate 2026-07-29 22:41:37 -04:00
AmanTahiliani
a1d8f85fd3 feat(#89): recut weekend command center 2026-07-29 22:41:37 -04:00
AmanTahiliani
3b9b58ff7b docs(#100): preserve v0.4 mockups 2026-07-29 22:41:37 -04:00
Aman Tahiliani
480e6ca860 Merge pull request #99 from AmanTahiliani/feat/issue-98-keep-completed-analysis-as-the-race-hub-
Keep completed analysis as the Race Hub default until the next Grand Prix is imminent (#98)
2026-07-29 03:58:30 -04:00
AmanTahiliani
81deed4c75 fix(#98): rearm capped race hub refresh 2026-07-29 03:56:22 -04:00
AmanTahiliani
d6d0558c72 fix(#98): expose weekend switcher before sessions 2026-07-29 03:52:51 -04:00
AmanTahiliani
b884ba8885 fix(#98): preserve bare race hub handoff 2026-07-29 03:46:57 -04:00
AmanTahiliani
56ea860101 feat(#98): Keep completed analysis as the Race Hub default until the next Grand Prix is imminent
Implemented by opencode via .agents/dev dispatch.
2026-07-29 03:28:42 -04:00
Aman Tahiliani
7c7886e6c6 Merge pull request #95 from AmanTahiliani/fix/live-stint-history-merge
fix(live): merge stint deltas instead of replacing stint history
2026-07-29 01:25:12 -04:00
Aman Tahiliani
71f81924ee Merge pull request #94 from AmanTahiliani/feat/frontend-design-uxpass
Live page design pass: give the timing tower the fold back
2026-07-29 01:25:09 -04:00
Aman Tahiliani
7b86698b22 Merge pull request #93 from AmanTahiliani/feat/issue-76-availability-truth-backend
Harvest availability and freshness truth, backend only (re-cut of #76)
2026-07-29 01:25:06 -04:00
Aman Tahiliani
e88e0885ec Merge pull request #92 from AmanTahiliani/feat/issue-72-canonical-weekend-context
Harvest canonical Weekend Context API onto main (re-cut of #72)
2026-07-29 01:24:54 -04:00
AmanTahiliani
255b296ecc fix(live): merge stint deltas instead of replacing stint history
The F1 feed sends TimingAppData stints as sparse deltas keyed by stint
index — a mid-stint update is just {"1": {"TotalLaps": 14}}. The parser
replaced the whole stint slice with whatever a delta carried, so pit
history collapsed to a single entry and tyre age was pinned near zero for
the entire race. A partial delta was also dropped outright, because the
parser required a Compound field that mid-stint updates do not send.

Observed live at lap 49 of the 70-lap 2026 Hungarian GP: all 22 drivers
reported exactly one stint with age 0 or 3, after most had pitted twice.
With the fix, the same feed at lap 51 yields 3 stints for 15 drivers and
2 for 6, ages spread 1-30 — e.g. car 1 as MEDIUM 17 / HARD 22 / HARD 11.

Stints now merge by index, and Compound, New and TotalLaps each apply only
when the delta actually carries them.

Also stop folding non-numeric keys into index 0 in indexedRawValues. The
feed's "_kf" key-frame marker parsed as 0 and overwrote the first entry;
only the CurrentTyres path guarded against it, leaving the other five
callers exposed.

This feeds the tyre column, the deg model, stint history and the pit
window, all of which were reading near-zero tyre age all race.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 10:16:38 -04:00
AmanTahiliani
888378e210 fix(live): give the timing tower the fold back
The tyre deg panel expanded by default during races and rendered a
placeholder row per driver until enough clean laps existed to fit a
degradation model. For the first third of a race that was ~525px of
'warming up' rows above the Timing Tower, pushing the tower — the thing
the page exists for — off the fold entirely.

The panel now opens when it has something to say rather than because the
session is a race, and says 'collecting clean laps' while it waits. A
reader who toggles it keeps their choice.

Also:
- Hoist degradationModel into a memo shared by the readiness check and the
  rows. It is O(laps) per driver and previously re-ran on every render at
  feed rate.
- Order the side rail most-synthesized to most-raw, so a reader arriving
  mid-session gets 'what just happened' before the regulatory log.
- Fade the trailing edge of the nav below 560px. It scrolls with a hidden
  scrollbar, so the cut-off item read as a clipping bug rather than as
  scrollable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 09:23:17 -04:00
AmanTahiliani
77a6b0f2dd feat(#76): harvest request-scoped availability and freshness truth
Backend-only re-cut of the #76 availability work onto main, stacked on the
canonical Weekend Context API. Adds request-scoped freshness reporting so
aggregate responses cannot report fresh when a component is stale, plus
local-first driver summary resolution and cache/pacing truth.

The frontend half of #76 is deliberately excluded: it is built on the
Weekend shell that failed owner review, including the full-width Partial
banner treatment. Availability presentation is re-cut with the shell in #89.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 09:14:37 -04:00
AmanTahiliani
d03e480e03 test(#90): regress analysis selection against later unrun race
Reproduces the production shape from the v0.4.0 release blocker: a run
British race and an unrun Belgian race two weekends apart, both carrying
result rows. Analysis must resolve to the race that actually happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 09:11:03 -04:00
AmanTahiliani
0b9c3fff0a fix: make weekend context transitions truthful 2026-07-26 09:08:38 -04:00
AmanTahiliani
adfa934f9d feat: add canonical weekend context API 2026-07-26 09:08:38 -04:00
Aman Tahiliani
c475011c49 Merge pull request #88 from AmanTahiliani/feat/issue-87-live-correct-fp-q-timing-truth-hierarchy
[Live] Correct FP/Q timing truth, hierarchy, and mobile navigation (#87)
2026-07-17 12:08:45 -04:00
AmanTahiliani
5a6323d3b4 fix(live): keep red-flagged sessions active 2026-07-17 12:04:13 -04:00
AmanTahiliani
ed3b8cf628 feat(#87): [Live] Correct FP/Q timing truth, hierarchy, and mobile navigation
Implemented by claude via .agents/dev dispatch.
2026-07-17 11:53:38 -04:00
128 changed files with 3454 additions and 10232 deletions

View File

@@ -25,8 +25,7 @@ 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"
local model="${BOXBOX_CLAUDE_MODEL:-opus}" ( cd "$dir" && claude -p "$(cat "$prompt")" --permission-mode acceptEdits )
( 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
@@ -41,8 +40,7 @@ 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"
local model="${BOXBOX_CURSOR_MODEL:-composer-2.5}" ( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --model composer-2.5 --force --trust )
( 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

1
.gitignore vendored
View File

@@ -30,6 +30,7 @@ node_modules/
/test-results/ /test-results/
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/release-fidelity/
/playwright/.cache/ /playwright/.cache/
/playwright/.auth/ /playwright/.auth/

View File

@@ -57,3 +57,58 @@ npm run test:visual:prod:update
``` ```
Snapshots live under `tests/visual/__snapshots__/`. Snapshots live under `tests/visual/__snapshots__/`.
## Release Fidelity and Owner Review
Visual regression and mockup fidelity are deliberately separate gates. `npm run test:visual:prod` verifies the candidate against committed regression snapshots; it does not establish that the current UI matches the approved product design.
After the approved references are available at `docs/product/<version>/mockups/`, make an offline review packet. For v0.4.0 the packet pairs `weekend-between-races` and `weekend-live` at the 1280×800 desktop reference viewport, plus `weekend-between-sessions-mobile` at the 390×844 mobile reference viewport. The capture configuration uses the same seeded SQLite data and unreachable OpenF1 endpoint as the visual suite.
```bash
export RELEASE_FIDELITY_VERSION=v0.4.0
npm run release:fidelity:capture
npm run release:fidelity:packet
```
Open `release-fidelity/v0.4.0/index.html` and review every approved-mockup/candidate pair. The owner, not an automated tool, records a decision in the committed file `docs/release/owner-reviews/v0.4.0.md`:
```md
# Owner Fidelity Sign-off: v0.4.0
- Version: v0.4.0
- Candidate commit: <full commit SHA>
- Reviewed by: <owner name>
- Reviewed on: YYYY-MM-DD
- Decision: approved
```
Do not create the file or use `approved` until the owner has reviewed the packet. Once it is committed, the release gate can verify the evidence and decision:
```bash
npm run release:fidelity:verify
```
The verifier requires the sign-off commit to be `HEAD` and to change only `docs/release/owner-reviews/<version>.md`. Its full candidate SHA must equal `HEAD^`; any code change after approval requires a new owner sign-off. It also requires the owner, date, and approved decision fields. It intentionally cannot assess visual fidelity or create approval.
For a release candidate, `npm run release:fidelity:gate` runs production visual regression first, then capture, packet generation, and owner-evidence verification in that order. It will remain red until the owner has committed the sign-off.
## Deployment and Rollback
Build and preserve a SHA-256 record with the deployable binary. Verify the staged binary before replacing the running one:
```bash
mkdir -p dist
go build -trimpath -o dist/box-box ./cmd/main.go
sha256sum dist/box-box | tee dist/box-box.sha256
sha256sum -c dist/box-box.sha256
```
Record the current production binary and its SHA before deployment. If owner review rejects the release or deployment fails, restore that saved binary, then verify the restored SHA is byte-identical to the pre-deployment record:
```bash
sha256sum /srv/box-box/box-box
install -m 0755 /srv/box-box/backups/box-box.previous /srv/box-box/box-box
sha256sum -c /srv/box-box/backups/box-box.previous.sha256
```
Restart and health-check the service using the deployment environment's normal procedure. Keep the candidate SHA, prior SHA, fidelity packet path, and owner sign-off path with the release record.

View File

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

View File

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

View File

@@ -1,66 +1,55 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { Compass, FileText, Home, Trophy } from 'lucide-react' import { useQuery } from '@tanstack/react-query'
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() {
return ( const { data: liveState } = useQuery({
<> queryKey: ['live-state'],
<header className="app-nav"> queryFn: fetchLiveState,
<Link to="/" className="nav-logo"> staleTime: 5_000,
box<em>-</em>box refetchInterval: 30_000,
</Link> })
<nav className="nav-links" aria-label="Primary"> const liveActive = isLiveSessionActive(liveState)
{PRIMARY.map(({ to, label, exact }) => (
<Link
key={to}
to={to}
activeProps={{ className: 'active' }}
activeOptions={exact ? { exact: true } : undefined}
>
{label}
</Link>
))}
</nav>
<div className="nav-utility" role="toolbar" aria-label="Operator utilities">
<Link
to="/admin"
className="nav-utility-link"
activeProps={{ className: 'nav-utility-link active' }}
>
Admin
</Link>
</div>
</header>
<nav className="app-bottom-nav" aria-label="Primary"> return (
{PRIMARY.map(({ to, label, icon: Icon, exact }) => ( <nav className="app-nav">
<Link <Link to="/" className="nav-logo">
key={to} box<em>-</em>box
to={to} </Link>
className="bottom-nav-link" <div className="nav-links">
activeProps={{ className: 'bottom-nav-link active' }} <Link to="/" activeProps={{ className: 'active' }} activeOptions={{ exact: true }}>
activeOptions={exact ? { exact: true } : undefined} Command
> </Link>
<Icon size={18} aria-hidden="true" /> <Link
<span>{label}</span> to="/live"
</Link> className={liveActive ? 'nav-live nav-live-on' : 'nav-live'}
))} activeProps={{ className: 'active' }}
</nav> data-testid="nav-live"
</> data-live-active={liveActive ? 'true' : 'false'}
>
{liveActive && <span className="nav-live-dot" aria-hidden="true" />}
Live
{liveActive && <span className="sr-only"> session active</span>}
</Link>
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
Race Hub
</Link>
<Link to="/preview" activeProps={{ className: 'active' }}>
Preview
</Link>
<Link to="/championship" activeProps={{ className: '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
</Link>
</div>
</nav>
) )
} }

View File

@@ -1,5 +1,6 @@
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 {
@@ -16,6 +17,7 @@ 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')
@@ -127,6 +129,28 @@ 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: ({ signal }) => fetchNews(100, undefined, signal), queryFn: () => fetchNews(100),
staleTime: 60_000, staleTime: 60_000,
}) })
const seasonsQuery = useQuery({ const seasonsQuery = useQuery({
queryKey: ['seasons'], queryKey: ['seasons'],
queryFn: ({ signal }) => fetchSeasons(signal), queryFn: fetchSeasons,
}) })
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: ({ signal }) => fetchSeasonMeetings(latestSeason!, signal), queryFn: () => fetchSeasonMeetings(latestSeason!),
enabled: latestSeason != null, enabled: latestSeason != null,
}) })
const hubQuery = useQuery({ const hubQuery = useQuery({
queryKey: ['championship-hub', latestSeason], queryKey: ['championship-hub', latestSeason],
queryFn: ({ signal }) => fetchChampionshipHub(latestSeason!, signal), queryFn: () => fetchChampionshipHub(latestSeason!),
enabled: latestSeason != null, enabled: latestSeason != null,
}) })

View File

@@ -1,141 +0,0 @@
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,12 +704,7 @@ export function RaceStoryCanvas({ data }: Props) {
</div> </div>
{displayResults.length > 0 && ( {displayResults.length > 0 && (
<div <div className="rs-field-list">
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

@@ -1,163 +0,0 @@
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,46 +8,15 @@ export type Tab =
| 'race_control' | 'race_control'
| 'data_status' | 'data_status'
interface TabDef { const TABS: { id: Tab; label: string }[] = [
id: Tab { id: 'overview', label: 'Overview' },
label: string { id: 'race_story', label: 'Race Story' },
} { id: 'strategy', label: 'Strategy' },
{ id: 'compare', label: 'Compare' },
interface TabGroup { { id: 'lap_data', label: 'Lap Data' },
id: string { id: 'conditions', label: 'Conditions' },
label: string { id: 'race_control', label: 'Race Control' },
tabs: TabDef[] { id: 'data_status', label: 'Data Status' },
}
// 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: 'race_story', label: 'Race Story' },
],
},
{
id: 'analysis',
label: 'Analysis',
tabs: [
{ id: 'strategy', label: 'Strategy' },
{ id: 'compare', label: 'Compare' },
{ id: 'lap_data', label: 'Lap Data' },
],
},
{
id: 'context',
label: 'Data & Context',
tabs: [
{ id: 'conditions', label: 'Conditions' },
{ id: 'race_control', label: 'Race Control' },
{ id: 'data_status', label: 'Diagnostics' },
],
},
] ]
interface Props { interface Props {
@@ -57,26 +26,17 @@ interface Props {
export function TabBar({ active, onChange }: Props) { export function TabBar({ active, onChange }: Props) {
return ( return (
<div className="tab-bar tab-bar-grouped" role="tablist" data-testid="rh-tabbar"> <div className="tab-bar" role="tablist">
{TAB_GROUPS.map((group) => ( {TABS.map((t) => (
<div key={group.id} className="tab-group" data-testid={`rh-tabgroup-${group.id}`}> <button
<span className="tab-group-label mono" aria-hidden="true"> key={t.id}
{group.label} role="tab"
</span> aria-selected={active === t.id}
<div className="tab-group-btns"> className={`tab-btn${active === t.id ? ' active' : ''}`}
{group.tabs.map((t) => ( onClick={() => onChange(t.id)}
<button >
key={t.id} {t.label}
role="tab" </button>
aria-selected={active === t.id}
className={`tab-btn${active === t.id ? ' active' : ''}`}
onClick={() => onChange(t.id)}
>
{t.label}
</button>
))}
</div>
</div>
))} ))}
</div> </div>
) )

View File

@@ -2,60 +2,41 @@ 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 { sessionTypeAbbrev } from '../lib/coverage' import { formatCoverageHint, 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({ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose }: Props) {
currentMeetingKey,
currentSessionKey,
context,
now: nowProp,
onClose,
}: Props) {
const navigate = useNavigate() const navigate = useNavigate()
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: ({ signal }) => fetchSeasons(signal) }) const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons })
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()
const weekendQuery = useQuery({
queryKey: ['weekend', openMeetingKey],
queryFn: ({ signal }) => fetchWeekend(openMeetingKey!, signal),
enabled: openMeetingKey != null,
})
useEffect(() => { useEffect(() => {
if (year != null) return if (year == null && seasonsQuery.data?.length) {
const currentYear = weekendQuery.data?.meeting?.year
if (currentYear) {
setYear(currentYear)
} else if (seasonsQuery.data?.length) {
setYear(seasonsQuery.data[0]) setYear(seasonsQuery.data[0])
} }
}, [seasonsQuery.data, weekendQuery.data, year]) }, [seasonsQuery.data, year])
const meetingsQuery = useQuery({ const meetingsQuery = useQuery({
queryKey: ['meetings', year], queryKey: ['meetings', year],
queryFn: ({ signal }) => fetchLocalMeetings(year!, signal), queryFn: () => fetchLocalMeetings(year!),
enabled: year != null, enabled: year != null,
}) })
const weekendQuery = useQuery({
queryKey: ['weekend', openMeetingKey],
queryFn: () => fetchWeekend(openMeetingKey!),
enabled: openMeetingKey != 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
@@ -66,7 +47,7 @@ export function WeekendSwitcher({
} }
return ( return (
<div className="rh-switcher" data-testid="rh-switcher"> <div id="rh-weekend-switcher" className="rh-switcher" data-testid="rh-switcher">
<div className="rh-switcher-head"> <div className="rh-switcher-head">
<span className="sec-title">Switch Weekend</span> <span className="sec-title">Switch Weekend</span>
<div className="rh-switcher-years"> <div className="rh-switcher-years">
@@ -125,16 +106,9 @@ export function WeekendSwitcher({
{weekendQuery.isLoading && ( {weekendQuery.isLoading && (
<div className="rh-switcher-empty">loading sessions</div> <div className="rh-switcher-empty">loading sessions</div>
)} )}
{weekend && {weekend && weekend.meeting_key === m.meeting_key &&
weekend.meeting_key === m.meeting_key && weekend.sessions.map(({ session, source, datasets }) => {
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}
@@ -148,11 +122,8 @@ export function WeekendSwitcher({
</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 <span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
className={`cc-cov-dot ${sessionStateDotClass(state)}`} {formatCoverageHint(datasets)}
aria-hidden="true"
/>
{sessionStateLabel(state)}
</span> </span>
</button> </button>
) )

View File

@@ -1,263 +0,0 @@
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,7 +80,9 @@ 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>
{messages.length > 0 && <span className="sec-meta">{messages.length} messages</span>} <span className="sec-meta" data-testid="rc-timezone">
{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>
@@ -102,7 +104,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">{message.Time || '--:--'}</span> <span className="rc-time" title="UTC">{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,60 +1,30 @@
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 {
/** Which of the live phases we are rendering: 'live' | 'disconnected' | 'archive'. */ isLive: boolean
phase: LivePhase isArchive?: boolean
snapshot: LiveStreamData snapshot: LiveStreamData
rows: LiveTimingRow[] rows: LiveTimingRow[]
transport: TransportHealth connection: 'connected' | 'connecting' | 'disconnected' | 'error'
now: number now: number
/** ISO capture time — required for archive, shown as the timestamp of record. */
capturedAt?: string | null
} }
function formatCapturedAt(capturedAt: string | null | undefined): string { export function SessionBanner({ isLive, isArchive = false, snapshot, rows, connection, now }: Props) {
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 isArchive = phase === 'archive' const clock = extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now)
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 capturedLabel = formatCapturedAt(capturedAt) const stateLabel = isLive ? 'live' : isArchive ? 'archive' : 'stale'
return ( return (
<section className={`live-banner${isArchive ? ' live-banner-archive' : ''}`} data-testid="live-banner"> <section className="live-banner">
<div className="live-banner-row"> <div className="live-banner-row">
<div className="live-banner-main"> <div className="live-banner-main">
{isArchive ? ( <span className={`live-conn live-conn-${connection}`}>{connection}</span>
<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>
@@ -63,40 +33,17 @@ export function SessionBanner({ phase, snapshot, rows, transport, now, capturedA
</div> </div>
</div> </div>
<div className="live-session-board"> <div className="live-session-board">
{/* Feed health is strictly secondary and only present for a live session. */} {display.phaseLabel && <span className="live-phase-pill mono">{display.phaseLabel}</span>}
{isLiveSession && ( <div className="live-clock mono" data-testid="live-clock">{clock || '--:--:--'}</div>
<span
className={`live-feed-health live-feed-${transport}`}
data-testid="live-feed-health"
title="Transport health — independent of session state"
>
<span className="live-feed-dot" aria-hidden="true" />
{feedHealthLabel(transport)}
</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"> <div className="live-banner-meta">
{!isArchive && display.advanceCount && <span>{display.advanceCount} advance</span>} {display.advanceCount && <span>{display.advanceCount} advance</span>}
{!isArchive && atRiskLabel && <span>{atRiskLabel}</span>} {atRiskLabel && <span>{atRiskLabel}</span>}
<span> {display.isRace && (
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong> <span data-testid="live-lap-counter">
</span> L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
{!isArchive && (
<span className={phase === 'live' ? 'live-state live-state-on' : 'live-state'}>
{phase === 'live' ? 'live' : 'stale'}
</span> </span>
)} )}
{isArchive && <span className="live-state live-state-archive">archive</span>} <span className={isLive ? 'live-state live-state-on' : 'live-state'}>{stateLabel}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,10 @@
import { useState, Fragment } from 'react' import { useState, useMemo, 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,
@@ -62,6 +63,10 @@ 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 }}>
@@ -71,7 +76,7 @@ export function TimingTower({
{isRace && <th>Δ</th>} {isRace && <th>Δ</th>}
<th>Driver</th> <th>Driver</th>
<th>Tyre</th> <th>Tyre</th>
<th>Last Lap</th> <th className={isRace ? undefined : 'hide-mobile'}>Last Lap</th>
<th <th
className="interactive" className="interactive"
onClick={() => setGapMode(g => g === 'interval' ? 'leader' : 'interval')} onClick={() => setGapMode(g => g === 'interval' ? 'leader' : 'interval')}
@@ -81,10 +86,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>S1</th>} {isQuali && <th className="hide-mobile">S1</th>}
{isQuali && <th>S2</th>} {isQuali && <th className="hide-mobile">S2</th>}
{isQuali && <th>S3</th>} {isQuali && <th className="hide-mobile">S3</th>}
<th className="hide-mobile">Best</th> <th className={isRace ? 'hide-mobile' : undefined}>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>
@@ -102,7 +107,15 @@ export function TimingTower({
driver.Cutoff driver.Cutoff
const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition
const gapText = gapMode === 'interval' && isRace ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader let gapText: string
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))
@@ -153,7 +166,10 @@ 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={driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono'}> <td className={[
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">
@@ -171,11 +187,14 @@ export function TimingTower({
</td> </td>
)} )}
{isQuali && <td>{renderSector(0)}</td>} {isQuali && <td className="hide-mobile">{renderSector(0)}</td>}
{isQuali && <td>{renderSector(1)}</td>} {isQuali && <td className="hide-mobile">{renderSector(1)}</td>}
{isQuali && <td>{renderSector(2)}</td>} {isQuali && <td className="hide-mobile">{renderSector(2)}</td>}
<td className={`hide-mobile ${driver.BestLapOB ? 'mono lap-ob' : 'mono'}`}> <td className={[
isRace ? 'hide-mobile' : '',
driver.BestLapOB ? 'mono lap-ob' : 'mono',
].filter(Boolean).join(' ')}>
{driver.BestLapTime || '-'} {driver.BestLapTime || '-'}
</td> </td>
@@ -208,6 +227,16 @@ 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,7 +52,9 @@ function StintSparkline({ seconds }: { seconds: number[] }) {
} }
export function TyreDegPanel({ rows, sessionType, pinned }: Props) { export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
const [collapsed, setCollapsed] = useState(false) const isRace = isRaceSession(sessionType)
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).
@@ -61,8 +63,6 @@ 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,6 +74,30 @@ 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 (
@@ -81,18 +105,25 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
<button <button
type="button" type="button"
className="sec-header tyredeg-toggle" className="sec-header tyredeg-toggle"
onClick={() => setCollapsed((prev) => !prev)} onClick={() => {
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>
{isRace && <span className="sec-meta">rejoin assumes ~{PIT_LOSS_SECONDS}s pit loss</span>} {!hasSignal ? (
<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 = degradationModel(stints[row.RacingNumber]?.samples ?? []) const model = models[row.RacingNumber]
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

@@ -1,152 +0,0 @@
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

@@ -1,86 +0,0 @@
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

@@ -1,47 +0,0 @@
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

@@ -1,75 +0,0 @@
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

@@ -1,65 +0,0 @@
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

@@ -1,65 +0,0 @@
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

@@ -1,277 +0,0 @@
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

@@ -1,140 +0,0 @@
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

@@ -1,135 +0,0 @@
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
}

View File

@@ -1,353 +0,0 @@
/** 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,6 +268,63 @@ 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

@@ -1,168 +0,0 @@
// 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

@@ -1,16 +0,0 @@
import { QueryClient } from '@tanstack/react-query'
import { availabilityAwareStructuralSharing } from './fetch'
/** Shared QueryClient defaults — availability-aware structural sharing for header metadata. */
export function createAppQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
// Explicit Retry on RouteState — avoid automatic retry storms.
retry: false,
structuralSharing: availabilityAwareStructuralSharing,
},
},
})
}

View File

@@ -1,40 +0,0 @@
/** 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

@@ -145,6 +145,15 @@ export function formatSessionScheduleTime(value: string): string {
}) })
} }
export const MAX_BROWSER_TIMEOUT = 2_147_483_647
export function refreshDeadlineDelay(refreshAt: string | undefined, now = Date.now()): number | null {
if (!refreshAt) return null
const deadline = Date.parse(refreshAt)
if (Number.isNaN(deadline)) return null
return Math.min(Math.max(0, deadline - now), MAX_BROWSER_TIMEOUT)
}
export type FocusMeetingKind = 'current' | 'next' | 'recent' | 'fallback' export type FocusMeetingKind = 'current' | 'next' | 'recent' | 'fallback'
export function focusMeetingKind(meeting: Meeting, now: Date): FocusMeetingKind { export function focusMeetingKind(meeting: Meeting, now: Date): FocusMeetingKind {

View File

@@ -1,194 +0,0 @@
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

@@ -1,181 +0,0 @@
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,12 +1,18 @@
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 { QueryClientProvider } from '@tanstack/react-query' import { QueryClient, 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 = createAppQueryClient() const queryClient = new QueryClient({
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,8 +24,6 @@ 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'
@@ -408,27 +406,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, error, refetch, isFetching } = useQuery({ const { data: allNews = [], isLoading, isError } = useQuery({
queryKey: ['news'], queryKey: ['news'],
queryFn: ({ signal }) => fetchNews(100, undefined, signal), queryFn: () => fetchNews(100),
staleTime: 60_000, staleTime: 60_000,
}) })
const seasonsQuery = useQuery({ const seasonsQuery = useQuery({
queryKey: ['seasons'], queryKey: ['seasons'],
queryFn: ({ signal }) => fetchSeasons(signal), queryFn: fetchSeasons,
}) })
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: ({ signal }) => fetchSeasonMeetings(latestSeason!, signal), queryFn: () => fetchSeasonMeetings(latestSeason!),
enabled: latestSeason != null, enabled: latestSeason != null,
}) })
const hubQuery = useQuery({ const hubQuery = useQuery({
queryKey: ['championship-hub', latestSeason], queryKey: ['championship-hub', latestSeason],
queryFn: ({ signal }) => fetchChampionshipHub(latestSeason!, signal), queryFn: () => fetchChampionshipHub(latestSeason!),
enabled: latestSeason != null, enabled: latestSeason != null,
}) })
@@ -517,43 +515,6 @@ 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">
@@ -563,45 +524,11 @@ export function BriefingPage() {
</span> </span>
</div> </div>
{isLoading && ( {isLoading && <div className="loading-state">loading briefing</div>}
<RouteState kind="loading" title="loading briefing…" testId="briefing-loading" /> {isError && <div className="error-box">Failed to load paddock briefing.</div>}
)}
{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,9 +7,7 @@ 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'
@@ -83,55 +81,23 @@ 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({ const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons })
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: ({ signal }) => fetchChampionshipHub(latestSeason ?? undefined, signal), queryFn: () => fetchChampionshipHub(latestSeason ?? undefined),
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 ( return <div className="page loading-state">loading championship</div>
<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"> <div className="page error-box">
<RouteState {hubQuery.error instanceof Error ? hubQuery.error.message : 'Failed to load championship'}
kind="error"
title="Championship unavailable"
error={hubQuery.error}
onRetry={() => {
if (!hubQuery.isFetching) void hubQuery.refetch()
}}
retrying={hubQuery.isFetching}
testId="championship-error"
/>
</div> </div>
) )
} }
@@ -152,32 +118,16 @@ export function ChampionshipPage() {
) )
} }
const availability = noticeFromResponse(hub, { includeLocal: true }) return <ChampionshipBody hub={hub} view={view} setView={setView} />
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, availability, onRetry, retrying }: BodyProps) { function ChampionshipBody({ hub, view, setView }: 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
@@ -233,15 +183,6 @@ function ChampionshipBody({ hub, view, setView, availability, onRetry, retrying
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

@@ -0,0 +1,495 @@
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,
meetingEndTime,
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) {
const end = meetingEndTime(meeting)
if (end && now >= end) return 'past'
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 railSessions =
focusWeekendSessions.length > 0
? focusWeekendSessions
: heroStateKind === 'between'
? lastPastWeekend?.sessions ?? []
: []
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 ?? ''
const focusNarrative =
heroStateKind === 'between'
? lastRaceName
? `${lastRaceName} is in the archive. The next chapter begins at ${focusMeeting?.circuit_short_name || focusMeeting?.meeting_name || 'the next round'}.`
: 'The season is between race weekends. Use the timeline to revisit a completed round or look ahead.'
: currentSession
? `${currentSession.session_name} is the active chapter of this weekend. Session detail remains available as it lands locally.`
: nextSession
? `${nextSession.session_name} is next on the timetable. The weekend rail keeps every session and its local analysis in reach.`
: 'The weekend timetable is ready to explore.'
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 · weekend desk
</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}
/>
)}
{focusMeeting && (
<section className="cc-context" data-testid="cc-circuit-context">
<div className="cc-context-decal mono" aria-hidden="true">
{countryDecal(focusMeeting)}
</div>
<div className="cc-context-copy">
<span className="cc-context-kicker mono">Circuit context</span>
<h2>{focusMeeting.circuit_short_name || focusMeeting.meeting_name}</h2>
<p>{focusNarrative}</p>
</div>
<div className="cc-context-meta mono">
<span>{focusMeeting.location || focusMeeting.country_name}</span>
<span>{formatGpDateRange(focusMeeting)}</span>
<Link to="/preview" className="cc-context-preview">
Weekend preview
</Link>
</div>
</section>
)}
<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-calendar-status cc-calendar-status-${status}`}>
{status === 'past' ? 'Archive' : status === 'focus' ? 'Now' : 'Ahead'}
</span>
</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>
)}
{railSessions.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">{railSessions.length} sessions</span>
</div>
<div className="cc-session-strip" role="list">
{railSessions.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" title={formatCoverageHint(datasets)}>
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
{source === 'local' ? 'Analysis ready' : formatCoverageHint(datasets)}
</div>
</Link>
)
})}
</div>
</section>
)}
<PaddockBriefing />
<div className="cc-data-note mono" data-testid="cc-data-note">
<span className="cc-cov-dot cc-cov-local" aria-hidden="true" />
{meetingStats.full}/{meetingStats.total || 0} locally complete · availability is shown per session
</div>
</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,7 +11,6 @@ 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 {
@@ -28,12 +27,12 @@ export function DataLibraryPage() {
const seasonsQuery = useQuery({ const seasonsQuery = useQuery({
queryKey: ['seasons'], queryKey: ['seasons'],
queryFn: ({ signal }) => fetchSeasons(signal), queryFn: fetchSeasons,
}) })
const meetingsQuery = useQuery({ const meetingsQuery = useQuery({
queryKey: ['meetings', selectedYear], queryKey: ['meetings', selectedYear],
queryFn: ({ signal }) => fetchLocalMeetings(selectedYear!, signal), queryFn: () => fetchLocalMeetings(selectedYear!),
enabled: selectedYear != null, enabled: selectedYear != null,
}) })
@@ -42,7 +41,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: ({ signal }: { signal: AbortSignal }) => fetchWeekend(meeting.meeting_key, signal), queryFn: () => fetchWeekend(meeting.meeting_key),
enabled: meetings.length > 0, enabled: meetings.length > 0,
staleTime: 60_000, staleTime: 60_000,
})), })),
@@ -79,26 +78,13 @@ export function DataLibraryPage() {
const weekendsLoading = weekendQueries.some((q) => q.isLoading) const weekendsLoading = weekendQueries.some((q) => q.isLoading)
if (seasonsQuery.isLoading) { if (seasonsQuery.isLoading) {
return ( return <div className="page loading-state">loading local data library</div>
<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"> <div className="page error-box">
<RouteState {seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'}
kind="error"
title="Weekend data unavailable"
error={seasonsQuery.error}
onRetry={() => {
if (!seasonsQuery.isFetching) void seasonsQuery.refetch()
}}
retrying={seasonsQuery.isFetching}
testId="weekend-error"
/>
</div> </div>
) )
} }
@@ -236,20 +222,15 @@ export function DataLibraryPage() {
</div> </div>
{meetingsQuery.isLoading && ( {meetingsQuery.isLoading && (
<RouteState kind="loading" title="loading meetings…" testId="weekend-meetings-loading" /> <div className="loading-state">loading meetings</div>
)} )}
{meetingsQuery.isError && ( {meetingsQuery.isError && (
<RouteState <div className="error-box">
kind="error" {meetingsQuery.error instanceof Error
title="Weekend meetings unavailable" ? meetingsQuery.error.message
error={meetingsQuery.error} : 'Failed to load meetings'}
onRetry={() => { </div>
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 && (
@@ -335,16 +316,11 @@ export function DataLibraryPage() {
<div className="dl-detail-wrap"> <div className="dl-detail-wrap">
{weekendsLoading && selectedWeekend == null && ( {weekendsLoading && selectedWeekend == null && (
<RouteState kind="loading" title="loading weekend details…" testId="weekend-detail-loading" /> <div className="loading-state">loading weekend details</div>
)} )}
{selectedWeekend && <MeetingDetailPanel weekend={selectedWeekend} />} {selectedWeekend && <MeetingDetailPanel weekend={selectedWeekend} />}
{selectedMeetingKey != null && !weekendsLoading && selectedWeekend == null && ( {selectedMeetingKey != null && !weekendsLoading && selectedWeekend == null && (
<RouteState <div className="missing-notice">Could not load weekend details.</div>
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,8 +1,6 @@
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 {
@@ -28,7 +26,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: ({ signal }) => fetchSeasons(signal), queryFn: fetchSeasons,
enabled: year == null, enabled: year == null,
}) })
const resolvedYear = year ?? seasonsQuery.data?.[0] const resolvedYear = year ?? seasonsQuery.data?.[0]
@@ -36,102 +34,39 @@ 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: ({ signal }) => fetchDriverSummary(driverNumber, resolvedYear, signal), queryFn: () => fetchDriverSummary(driverNumber, resolvedYear),
enabled: seasonsSettled && driverNumber > 0, enabled: seasonsSettled && driverNumber > 0,
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
}) })
if (driverNumber <= 0) { if (driverNumber <= 0) {
return ( return <div className="page error-box">Invalid driver number</div>
<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 ( return <div className="page loading-state">loading driver profile</div>
<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"> <div className="page error-box">
<RouteState {summaryQuery.error instanceof Error
kind="error" ? summaryQuery.error.message
title="Driver profile unavailable" : 'Failed to load driver profile'}
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 ( return <div className="page error-box">No driver data</div>
<div className="page">
<RouteState
kind="empty"
title="No driver data"
message="This driver is not in the local season standings yet."
/>
</div>
)
} }
return ( return <DriverProfileBody summary={summary} />
<DriverProfileBody
summary={summary}
availability={noticeFromResponse(summary, { includeLocal: false })}
onRetry={() => {
if (!summaryQuery.isFetching) void summaryQuery.refetch()
}}
retrying={summaryQuery.isFetching}
/>
)
} }
function DriverProfileBody({ function DriverProfileBody({ summary }: { summary: DriverSummary }) {
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

@@ -1,159 +0,0 @@
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, fetchWeekendContext } from '../api' import { fetchLiveState } from '../api'
import type { LivePosition, LiveStreamData } from '../types' import type { LivePosition, LiveStreamData } from '../types'
import { import {
loadPinnedDrivers, loadPinnedDrivers,
@@ -17,15 +17,6 @@ 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'
@@ -35,14 +26,9 @@ 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 { LiveHandoff } from '../components/live/LiveHandoff' import { Archive, Radio } from 'lucide-react'
import { DataNotice, RouteState } from '../components/RouteState'
import { weekendContextNotice } from '../lib/availability'
import '../styles/live-state.css'
/** How often to re-check weekend-context while analysis is still ingesting. */ type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
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)
@@ -51,96 +37,31 @@ 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<TransportHealth>('connecting') const [streamStatus, setStreamStatus] = useState<StreamStatus>('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 [, setPositions] = useState<Record<string, LivePosition>>({}) const [positions, 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)
// Whether we ever observed this session as live — used to reason about a const snapshot = isLive ? activeSnapshot : archiveMode ? archiveSnapshot : null
// 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 { const { data, isLoading, isError, error } = useQuery({
data,
isLoading,
isError,
error,
refetch,
isFetching,
isFetched: liveStateFetched,
} = useQuery({
queryKey: ['live-state'], queryKey: ['live-state'],
queryFn: ({ signal }) => fetchLiveState(signal), queryFn: fetchLiveState,
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)
@@ -183,9 +104,6 @@ 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) {
@@ -193,7 +111,6 @@ 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 {
@@ -250,8 +167,6 @@ 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) {
@@ -295,100 +210,68 @@ 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" data-phase={phase}> <div className="page live-page" data-testid="live-page">
{showInitialError && ( {isError && (
<RouteState <div className="error-box">
kind="error" {error instanceof Error ? error.message : 'Failed to load live timing state'}
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>
)} )}
{phase === 'archive' && ( {streamStatus === 'disconnected' && snapshot && !archiveMode && (
<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">
<span>{archiveLabel} read-only, live updates are paused</span> {archiveLabel} live updates are paused for this archive view
<button type="button" className="live-archive-exit" onClick={handleExitArchive}>
Exit archive
</button>
</div> </div>
)} )}
{phase === 'connecting' && (isLoading || !liveStateFetched) && !hasUsableStreamData && ( {isLoading && !snapshot && (
<RouteState <div className="loading-state">connecting to live timing</div>
kind="loading"
title="connecting to live timing…"
testId="live-initial-loading"
/>
)} )}
{(phase === 'settling' || phase === 'inactive') && !showInitialError && ( {!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">
{contextAvailabilityNotice && ( <div className="live-empty-status" style={{ marginBottom: '16px' }}>
<DataNotice <span className={`live-conn live-conn-${streamStatus}`}>{streamStatus}</span>
availability={contextAvailabilityNotice} </div>
testId="live-context-data-notice" <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>
)} )}
<LiveHandoff </div>
phase={phase}
transport={feedHealth}
context={weekendContext}
rows={settlingRows}
capturedAt={archiveSnapshotAt}
hasArchive={hasArchive}
onViewArchive={handleViewArchive}
/>
</>
)} )}
{snapshot && rendersSnapshot(phase) && ( {snapshot && (
<> <>
<SessionBanner <SessionBanner
phase={phase} isLive={isLive}
isArchive={archiveMode}
snapshot={snapshot} snapshot={snapshot}
rows={rows} rows={rows}
transport={feedHealth} connection={streamStatus}
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">
@@ -406,14 +289,16 @@ 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,8 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link, useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { fetchRaceHub, fetchWeekend, fetchWeekendContext } from '../api' import { fetchRaceHub, fetchWeekend, fetchWeekendContext } from '../api'
import { weekendFocusSearch } from '../lib/routeSearch'
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'
@@ -13,29 +12,18 @@ 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 { sessionTypeAbbrev } from '../lib/coverage' import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
import { formatSessionScheduleTime, sortSessionsByStart } from '../lib/schedule'
import { import {
defaultAnalysisSessionKey, MAX_BROWSER_TIMEOUT,
isPartialAnalysis, formatCountdown,
isPreSession, formatSessionScheduleTime,
isPreparing, refreshDeadlineDelay,
isUnavailable, sortSessionsByStart,
resolveSessionState, } from '../lib/schedule'
sessionStateDotClass, import type { ContextSession, Weekend } from '../types'
sessionStateLabel,
} from '../lib/sessionState'
import type { WeekendSession } from '../types'
interface Props { interface Props {
sessionKey: number sessionKey: number
@@ -45,45 +33,63 @@ 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 [now, setNow] = useState(() => Date.now())
const [phaseDiagnostics, setPhaseDiagnostics] = useState(false) const [refreshGeneration, setRefreshGeneration] = useState(0)
const [now, setNow] = useState(() => new Date())
// Keep schedule-adjacent UI (countdown) moving; Live/completion come from context.
useEffect(() => {
const id = window.setInterval(() => setNow(new Date()), 1000)
return () => window.clearInterval(id)
}, [])
// The server owns bare Race Hub selection so every open tab crosses the
// one-hour handoff at the same instant.
const contextQuery = useQuery({ const contextQuery = useQuery({
queryKey: ['weekend-context'], queryKey: ['weekend-context'],
queryFn: ({ signal }) => fetchWeekendContext(signal), queryFn: fetchWeekendContext,
staleTime: 15_000, enabled: sessionKey === 0,
refetchInterval: 30_000, })
const { refetch: refetchContext } = contextQuery
const context = contextQuery.data
const preSession = sessionKey === 0 && context?.race_hub_pre_session === true
const preSessionRef = context?.race_hub_default_session
const preSessionMeetingKey = preSessionRef?.meeting?.meeting_key
const preSessionWeekendQuery = useQuery({
queryKey: ['weekend', preSessionMeetingKey],
queryFn: () => fetchWeekend(preSessionMeetingKey!),
enabled: preSession && preSessionMeetingKey != null && preSessionMeetingKey > 0,
}) })
const context = contextQuery.data ?? null
// Bare `/race-hub` resolves only through canonical Weekend Context.
useEffect(() => { useEffect(() => {
if (sessionKey !== 0) return if (sessionKey !== 0) return
if (!contextQuery.isSuccess || !context) return const delay = refreshDeadlineDelay(context?.race_hub_refresh_at)
const target = defaultAnalysisSessionKey(context) if (delay == null) return
if (target) { const rearmAfterRefetch = delay === MAX_BROWSER_TIMEOUT
navigate({ to: '/race-hub', search: { session_key: target }, replace: true }) const timer = window.setTimeout(() => {
} void refetchContext().finally(() => {
}, [sessionKey, contextQuery.isSuccess, context, navigate]) if (rearmAfterRefetch) setRefreshGeneration((generation) => generation + 1)
})
}, delay)
return () => window.clearTimeout(timer)
}, [sessionKey, context?.race_hub_refresh_at, refetchContext, refreshGeneration])
useEffect(() => {
if (!preSession) return
const timer = window.setInterval(() => setNow(Date.now()), 1_000)
return () => window.clearInterval(timer)
}, [preSession])
// A bare route retains canonical context ownership while rendering its
// completed analysis selection. Explicit URLs remain user-owned.
const selectedSessionKey = sessionKey || context?.race_hub_default_session?.session.session_key || 0
// ─── Active session payload ───
const raceHubQuery = useQuery({ const raceHubQuery = useQuery({
queryKey: ['race-hub', sessionKey], queryKey: ['race-hub', selectedSessionKey],
queryFn: ({ signal }) => fetchRaceHub(sessionKey, signal), queryFn: () => fetchRaceHub(selectedSessionKey),
enabled: sessionKey > 0, enabled: selectedSessionKey > 0 && (sessionKey > 0 || !preSession),
staleTime: 30_000, staleTime: 30_000,
}) })
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: ({ signal }) => fetchWeekend(meetingKey!, signal), queryFn: () => fetchWeekend(meetingKey!),
enabled: meetingKey != null && meetingKey > 0, enabled: meetingKey != null && meetingKey > 0,
staleTime: 60_000, staleTime: 60_000,
}) })
@@ -93,142 +99,63 @@ 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
const openDiagnostics = () => { // ─── No session_key: resolve exclusively through canonical Weekend Context ───
setActiveTab('data_status')
setShowDiagnostics(true)
setPhaseDiagnostics(true)
}
// ─── No session_key: resolve via Weekend Context ───
if (sessionKey === 0) { if (sessionKey === 0) {
if (contextQuery.isLoading) { if (contextQuery.isLoading || (preSession && preSessionWeekendQuery.isLoading)) {
return ( return (
<div className="rh-page" style={accentStyle}> <div className="rh-page" style={accentStyle}>
<RouteState <div className="loading-state">resolving latest local weekend</div>
kind="loading"
title="resolving weekend context…"
testId="race-hub-loading"
/>
</div> </div>
) )
} }
if (contextQuery.isError) { if (preSession && preSessionRef) {
return ( return (
<div className="rh-page" data-testid="race-hub-error" style={accentStyle}> <RaceHubPreSession
<RouteState session={preSessionRef}
kind="error" weekend={preSessionWeekendQuery.data}
title="Weekend unavailable" now={now}
error={contextQuery.error} switcherOpen={switcherOpen}
onRetry={() => { onToggleSwitcher={() => setSwitcherOpen((open) => !open)}
if (!contextQuery.isFetching) void contextQuery.refetch() onCloseSwitcher={() => setSwitcherOpen(false)}
}} />
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 (!selectedSessionKey) {
if (context && !defaultAnalysisSessionKey(context)) {
return ( return (
<div className="rh-page rh-empty" data-testid="race-hub-no-analysis" style={accentStyle}> <div className="rh-page rh-empty" data-testid="race-hub-empty" 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 completed session to analyse yet</h1> <h1 className="rh-empty-title">No completed local analysis yet</h1>
<p className="rh-empty-sub"> <p className="rh-empty-sub">
Weekend Context has no default analysis session. Pick a past session Race Hub opens completed local analysis between weekends. Check Data Health
to review, or check back once a session completes with local analysis. to ingest a completed session.
</p> </p>
<div className="rh-empty-actions"> <div className="rh-empty-actions">
<button <a href="/admin" className="rh-empty-action">Open Admin · Data Health</a>
type="button" <a href="/" className="rh-empty-action">Back to Command Center</a>
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 (
<div className="rh-page" style={accentStyle}>
<RouteState
kind="loading"
title="resolving weekend context…"
testId="race-hub-loading"
/>
</div>
)
} }
// ─── Loading / error for the requested session_key ─── // ─── Loading / error for the selected session ───
if (raceHubQuery.isLoading) { if (raceHubQuery.isLoading) {
return ( return (
<div className="rh-page" style={accentStyle}> <div className="rh-page" style={accentStyle}>
<RouteState <div className="loading-state">loading session {selectedSessionKey}</div>
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" data-testid="race-hub-error" style={accentStyle}> <div className="rh-page" style={accentStyle}>
<RouteState <div className="error-box">
kind="error" {raceHubQuery.error instanceof Error
title="Session unavailable" ? raceHubQuery.error.message
error={raceHubQuery.error} : `Failed to load session ${selectedSessionKey}.`}
message={ </div>
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>
</RouteState>
</div> </div>
) )
} }
@@ -238,35 +165,12 @@ 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: WeekendSession | undefined = sessionMeta[sessionKey] const activeSessionMeta = sessionMeta[selectedSessionKey]
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}` : ''}
@@ -284,28 +188,15 @@ 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={selectedSessionKey}
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" />
@@ -326,16 +217,12 @@ 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 === selectedSessionKey
const state = resolveSessionState({
weekendSession: meta,
context,
now,
})
return ( return (
<button <button
key={session.session_key} key={session.session_key}
@@ -357,167 +244,193 @@ 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>
<span className="rh-session-cov mono"> {meta && (
<span <span className="rh-session-cov mono">
className={`cc-cov-dot ${sessionStateDotClass(state)}`} <span
aria-hidden="true" className={`cc-cov-dot cc-cov-${meta.source}`}
/> aria-hidden="true"
{sessionStateLabel(state)} />
</span> {formatCoverageHint(meta.datasets)}
</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>
<span className="rh-active-cov mono" data-testid="rh-active-state"> {activeSessionMeta && (
<span <span className="rh-active-cov mono">
className={`cc-cov-dot ${sessionStateDotClass(activeState)}`} <span
aria-hidden="true" className={`cc-cov-dot cc-cov-${activeSessionMeta.source}`}
/> aria-hidden="true"
{sessionStateLabel(activeState)} />
</span> {formatCoverageHint(activeSessionMeta.datasets)} datasets local
<span className="rh-active-key mono">key {sessionKey}</span> </span>
)}
<span className="rh-active-key mono">key {selectedSessionKey}</span>
</div> </div>
)} )}
{preSession && data.session ? ( <DatasetStrip datasets={data.datasets} />
<PreSessionView
session={data.session} <TabBar active={activeTab} onChange={setActiveTab} />
sessionName={data.session.session_name}
now={now} {activeTab === 'overview' && <OverviewView data={data} />}
/>
) : preparing || unavailable ? ( {activeTab === 'race_story' && (
<> <div className="data-section">
<SessionPhaseView <RaceStoryCanvas data={data} />
state={ </div>
unavailable )}
? activeState === 'cancelled'
? 'cancelled' {activeTab === 'strategy' && (
: 'unavailable' <div className="data-section">
: 'preparing' <div className="sec-header">
} <span className="sec-title">Race Strategy</span>
sessionName={data.session?.session_name ?? `Session ${sessionKey}`} </div>
onOpenDiagnostics={openDiagnostics} <StrategyView
results={data.results}
stints={data.stints}
pit_stops={data.pit_stops}
hasStints={data.datasets['stints']?.status === 'available'}
/> />
{phaseDiagnostics && ( </div>
<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} />
</div>
</div>
)}
</>
) : (
<>
{partial && <PartialAnalysisBanner onOpenDiagnostics={openDiagnostics} />}
<TabBar active={activeTab} onChange={setActiveTab} />
{activeTab === 'overview' && <OverviewView data={data} />} {activeTab === 'compare' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Driver Compare</span>
</div>
<CompareView
sessionKey={selectedSessionKey}
results={data.results}
drivers={data.drivers}
/>
</div>
)}
{activeTab === 'race_story' && ( {activeTab === 'lap_data' && (
<div className="data-section"> <div className="data-section">
<RaceStoryCanvas data={data} /> <div className="sec-header">
</div> <span className="sec-title">Lap Data</span>
)} {data.laps.length > 0 && (
<span className="sec-meta mono">{data.laps.length} samples</span>
)}
</div>
<LapsView laps={data.laps} drivers={data.drivers} />
</div>
)}
{activeTab === 'strategy' && ( {activeTab === 'conditions' && (
<div className="data-section"> <div className="data-section">
<div className="sec-header"> <div className="sec-header">
<span className="sec-title">Race Strategy</span> <span className="sec-title">Conditions</span>
</div> {data.weather.length > 0 && (
<StrategyView <span className="sec-meta mono">{data.weather.length} samples</span>
results={data.results} )}
stints={data.stints} </div>
pit_stops={data.pit_stops} <WeatherView weather={data.weather} />
hasStints={data.datasets['stints']?.status === 'available'} </div>
/> )}
</div>
)}
{activeTab === 'compare' && ( {activeTab === 'race_control' && (
<div className="data-section"> <div className="data-section">
<div className="sec-header"> <div className="sec-header">
<span className="sec-title">Driver Compare</span> <span className="sec-title">Race Control</span>
</div> {data.race_control.length > 0 && (
<CompareView <span className="sec-meta mono">{data.race_control.length} messages</span>
sessionKey={sessionKey} )}
results={data.results} </div>
drivers={data.drivers} <RaceControlView messages={data.race_control} />
/> </div>
</div> )}
)}
{activeTab === 'lap_data' && ( {activeTab === 'data_status' && (
<div className="data-section"> <div className="data-section">
<div className="sec-header"> <div className="sec-header">
<span className="sec-title">Lap Data</span> <span className="sec-title">Data Status</span>
{data.laps.length > 0 && ( </div>
<span className="sec-meta mono">{data.laps.length} samples</span> <DatasetStatusView datasets={data.datasets} />
)} </div>
</div>
<LapsView laps={data.laps} drivers={data.drivers} />
</div>
)}
{activeTab === 'conditions' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Conditions</span>
{data.weather.length > 0 && (
<span className="sec-meta mono">{data.weather.length} samples</span>
)}
</div>
<WeatherView weather={data.weather} />
</div>
)}
{activeTab === 'race_control' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Race Control</span>
{data.race_control.length > 0 && (
<span className="sec-meta mono">{data.race_control.length} messages</span>
)}
</div>
<RaceControlView messages={data.race_control} />
</div>
)}
{activeTab === 'data_status' && (
<div className="data-section">
<div className="sec-header">
<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>
<DatasetStatusView datasets={data.datasets} />
{showDiagnostics && (
<div style={{ marginTop: 'var(--s5)' }} data-testid="rh-dataset-strip">
<DatasetStrip datasets={data.datasets} />
</div>
)}
</div>
)}
</>
)} )}
</div> </div>
) )
} }
function RaceHubPreSession({
session,
weekend,
now,
switcherOpen,
onToggleSwitcher,
onCloseSwitcher,
}: {
session: ContextSession
weekend?: Weekend
now: number
switcherOpen: boolean
onToggleSwitcher: () => void
onCloseSwitcher: () => void
}) {
const meeting = session.meeting
const sessions = sortSessionsByStart((weekend?.sessions ?? []).map((entry) => entry.session))
const target = new Date(session.session.date_start)
const accent = countryAccent(meeting ?? null)
const pendingLiveEvidence = target.getTime() <= now
return (
<div className="rh-page rh-empty" data-testid="race-hub-pre-session" style={{ '--gp-accent': accent } as React.CSSProperties}>
<div className="rh-topbar">
<span className="rh-topbar-label mono">box-box · race hub</span>
<span className="rh-topbar-spacer" />
<button
type="button"
className={`rh-switcher-toggle${switcherOpen ? ' active' : ''}`}
onClick={onToggleSwitcher}
aria-expanded={switcherOpen}
aria-controls="rh-weekend-switcher"
data-testid="rh-switch-weekend"
>
{switcherOpen ? 'Close' : 'Switch Weekend'}
</button>
</div>
{switcherOpen && (
<WeekendSwitcher
currentMeetingKey={meeting?.meeting_key}
currentSessionKey={session.session.session_key}
onClose={onCloseSwitcher}
/>
)}
<section className="rh-empty-band">
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
<h1 className="rh-empty-title">{meeting?.meeting_name ?? 'Next race weekend'}</h1>
<p className="rh-empty-sub">
{pendingLiveEvidence
? `${session.session.session_name} is scheduled; awaiting live timing.`
: <>{session.session.session_name} begins in <span className="mono">{formatCountdown(target, new Date(now))}</span></>}
</p>
{sessions.length > 0 && (
<div className="preview-schedule" data-testid="rh-pre-session-schedule">
{sessions.map((scheduled) => (
<div key={scheduled.session_key} className="preview-schedule-item">
<span className="preview-schedule-name">{scheduled.session_name}</span>
<span className="preview-schedule-time">{formatSessionScheduleTime(scheduled.date_start)}</span>
</div>
))}
</div>
)}
</section>
</div>
)
}

View File

@@ -9,15 +9,6 @@ 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,
@@ -46,7 +37,7 @@ function SectionState({
children, children,
}: { }: {
loading?: boolean loading?: boolean
error?: unknown error?: Error | null
empty?: boolean empty?: boolean
emptyMessage?: string emptyMessage?: string
children: ReactNode children: ReactNode
@@ -56,8 +47,8 @@ function SectionState({
} }
if (error) { if (error) {
return ( return (
<div className="preview-section-state error" role="status"> <div className="preview-section-state error">
{userFacingError(error)} {error instanceof Error ? error.message : 'Failed to load'}
</div> </div>
) )
} }
@@ -75,7 +66,7 @@ function TrackOutlineCard({
}: { }: {
outline: TrackOutline | null | undefined outline: TrackOutline | null | undefined
loading: boolean loading: boolean
error: unknown error: Error | null
accent: string accent: string
}) { }) {
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline]) const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
@@ -110,7 +101,7 @@ function LastYearCard({
}: { }: {
year: number | null year: number | null
loading: boolean loading: boolean
error: unknown error: Error | null
podium: ReturnType<typeof extractPodium> podium: ReturnType<typeof extractPodium>
pole: ReturnType<typeof extractPole> pole: ReturnType<typeof extractPole>
isFirstTime: boolean isFirstTime: boolean
@@ -159,7 +150,7 @@ function TitleFightCard({
season, season,
}: { }: {
loading: boolean loading: boolean
error: unknown error: Error | null
drivers: ReturnType<typeof buildTitleFightContext> drivers: ReturnType<typeof buildTitleFightContext>
sprintWeekend: boolean sprintWeekend: boolean
season: number | null season: number | null
@@ -268,29 +259,7 @@ function PreviewHeader({
) )
} }
export interface RacePreviewPageProps { export function RacePreviewPage() {
/**
* 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(() => {
@@ -299,34 +268,28 @@ 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: ({ signal }) => fetchSeasons(signal), queryFn: fetchSeasons,
enabled: !hasCanonicalMeeting,
}) })
const latestSeason = const latestSeason = seasonsQuery.data?.[0] ?? null
canonicalSeason ??
canonicalMeeting?.year ??
seasonsQuery.data?.[0] ??
null
const meetingsQuery = useQuery({ const meetingsQuery = useQuery({
queryKey: ['meetings', latestSeason, 'auto'], queryKey: ['meetings', latestSeason, 'auto'],
queryFn: ({ signal }) => fetchMeetings(latestSeason!, 'auto', signal), queryFn: () => fetchMeetings(latestSeason!, 'auto'),
enabled: !hasCanonicalMeeting && latestSeason != null, enabled: latestSeason != null,
}) })
const previewMeeting = useMemo(() => { const previewMeeting = useMemo(
if (hasCanonicalMeeting) return canonicalMeeting () => pickPreviewMeeting(meetingsQuery.data ?? [], nowDate),
return pickPreviewMeeting(meetingsQuery.data ?? [], nowDate) [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: ({ signal }) => fetchSessions(previewMeeting!.meeting_key, 'auto', signal), queryFn: () => fetchSessions(previewMeeting!.meeting_key, 'auto'),
enabled: previewMeeting != null, enabled: previewMeeting != null,
}) })
@@ -342,7 +305,7 @@ export function RacePreviewPage({
const priorMeetingsQuery = useQuery({ const priorMeetingsQuery = useQuery({
queryKey: ['meetings', priorYear, 'auto'], queryKey: ['meetings', priorYear, 'auto'],
queryFn: ({ signal }) => fetchMeetings(priorYear!, 'auto', signal), queryFn: () => fetchMeetings(priorYear!, 'auto'),
enabled: priorYear != null && previewMeeting != null, enabled: priorYear != null && previewMeeting != null,
}) })
@@ -353,7 +316,7 @@ export function RacePreviewPage({
const priorSessionsQuery = useQuery({ const priorSessionsQuery = useQuery({
queryKey: ['sessions', priorMeeting?.meeting_key, 'auto'], queryKey: ['sessions', priorMeeting?.meeting_key, 'auto'],
queryFn: ({ signal }) => fetchSessions(priorMeeting!.meeting_key, 'auto', signal), queryFn: () => fetchSessions(priorMeeting!.meeting_key, 'auto'),
enabled: priorMeeting != null, enabled: priorMeeting != null,
}) })
@@ -364,25 +327,25 @@ export function RacePreviewPage({
const priorResultsQuery = useQuery({ const priorResultsQuery = useQuery({
queryKey: ['results', priorRaceSession?.session_key, 'auto'], queryKey: ['results', priorRaceSession?.session_key, 'auto'],
queryFn: ({ signal }) => fetchResults(priorRaceSession!.session_key, 'auto', signal), queryFn: () => fetchResults(priorRaceSession!.session_key, 'auto'),
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: ({ signal }) => fetchStartingGrid(priorRaceSession!.session_key, 'auto', signal), queryFn: () => fetchStartingGrid(priorRaceSession!.session_key, 'auto'),
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: ({ signal }) => fetchTrackOutline(previewMeeting!.circuit_key!, previewMeeting!.year, signal), queryFn: () => fetchTrackOutline(previewMeeting!.circuit_key!, previewMeeting!.year),
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: ({ signal }) => fetchChampionshipHub(latestSeason!, signal), queryFn: () => fetchChampionshipHub(latestSeason!),
enabled: latestSeason != null, enabled: latestSeason != null,
}) })
@@ -396,56 +359,15 @@ 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
const identityLoading = !hasCanonicalMeeting && (seasonsQuery.isLoading || meetingsQuery.isLoading) if (seasonsQuery.isLoading || meetingsQuery.isLoading) {
const identityError = !hasCanonicalMeeting && (seasonsQuery.isError || meetingsQuery.isError) return <div className="page loading-state" data-testid="preview-loading">loading preview</div>
const identityRetrying = seasonsQuery.isFetching || meetingsQuery.isFetching
const retryIdentity = () => {
if (seasonsQuery.isError && !seasonsQuery.isFetching) void seasonsQuery.refetch()
if (meetingsQuery.isError && !meetingsQuery.isFetching) void meetingsQuery.refetch()
} }
// Worst reported truth across primary + supplements — Local must not mask if (seasonsQuery.isError || meetingsQuery.isError) {
// 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={embedded ? 'preview-embedded' : 'page'} data-testid="preview-error"> <div className="page error-box" data-testid="preview-error">
<RouteState {err instanceof Error ? err.message : 'Failed to load preview'}
kind="error"
title="Preview details unavailable"
error={err}
onRetry={() => {
if (!identityRetrying) retryIdentity()
}}
retrying={identityRetrying}
retryTestId="preview-retry"
/>
</div> </div>
) )
} }
@@ -464,7 +386,7 @@ export function RacePreviewPage({
{titleFight.length > 0 && ( {titleFight.length > 0 && (
<TitleFightCard <TitleFightCard
loading={championshipQuery.isLoading} loading={championshipQuery.isLoading}
error={championshipQuery.isError ? championshipQuery.error : null} error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
drivers={titleFight} drivers={titleFight}
sprintWeekend={false} sprintWeekend={false}
season={latestSeason} season={latestSeason}
@@ -475,62 +397,20 @@ export function RacePreviewPage({
} }
return ( return (
<div <div className="preview-page" data-testid="preview-page">
className="preview-page" <PreviewHeader
data-testid="preview-page" meeting={previewMeeting}
data-meeting-key={previewMeeting.meeting_key} sessions={sessions}
data-embedded={embedded ? 'true' : undefined} countdownSession={countdownSession}
> now={nowDate}
{showFreshnessNotice && dataNotice && ( accent={accent}
<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
meeting={previewMeeting}
sessions={sessions}
countdownSession={countdownSession}
now={nowDate}
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 : null} error={trackOutlineQuery.isError ? (trackOutlineQuery.error as Error) : null}
accent={accent} accent={accent}
/> />
@@ -539,9 +419,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 ? (priorMeetingsQuery.error as Error)
: priorResultsQuery.isError : priorResultsQuery.isError
? priorResultsQuery.error ? (priorResultsQuery.error as Error)
: null : null
} }
podium={podium} podium={podium}
@@ -552,7 +432,7 @@ export function RacePreviewPage({
<TitleFightCard <TitleFightCard
loading={championshipQuery.isLoading} loading={championshipQuery.isLoading}
error={championshipQuery.isError ? championshipQuery.error : null} error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
drivers={titleFight} drivers={titleFight}
sprintWeekend={sprintWeekend} sprintWeekend={sprintWeekend}
season={latestSeason} season={latestSeason}

View File

@@ -1,180 +0,0 @@
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,14 +1,17 @@
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 { WeekendPage } from './pages/WeekendPage' import { CommandCenterPage } from './pages/CommandCenterPage'
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 { parseRaceHubSearch, parseWeekendSearch } from './lib/routeSearch' import { RacePreviewPage } from './pages/RacePreviewPage'
type RaceHubSearch = {
session_key?: number
}
type DriverProfileSearch = { type DriverProfileSearch = {
year?: number year?: number
@@ -23,31 +26,19 @@ const rootRoute = createRootRoute({
), ),
}) })
// Weekend is the adaptive home. `/` renders the current temporal state. export const commandCenterRoute = createRoute({
// 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: '/',
validateSearch: (search: Record<string, unknown>) => parseWeekendSearch(search), component: CommandCenterPage,
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>) => parseRaceHubSearch(search), validateSearch: (search: Record<string, unknown>): RaceHubSearch => {
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} />
@@ -99,21 +90,14 @@ 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: function PreviewRoute() { component: RacePreviewPage,
return <WeekendPage preview />
},
}) })
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
weekendRoute, commandCenterRoute,
exploreRoute,
raceHubRoute, raceHubRoute,
adminRoute, adminRoute,
dataLibraryRoute, dataLibraryRoute,

View File

@@ -89,6 +89,16 @@ 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;
@@ -112,6 +122,30 @@ 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;
@@ -142,53 +176,6 @@ 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;
@@ -410,9 +397,6 @@ 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 {
@@ -588,82 +572,6 @@ 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; }
@@ -1899,163 +1807,6 @@ 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;
@@ -2905,31 +2656,6 @@ 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;
@@ -3505,10 +3231,7 @@ 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); padding-bottom: 88px; } .rh-page { padding: var(--s4); gap: var(--s4); }
.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; }
@@ -5041,3 +4764,137 @@ a { color: inherit; text-decoration: none; }
background: #f82f34; background: #f82f34;
transform: translateY(-1px); transform: translateY(-1px);
} }
/* Weekend recut: put the active circuit and season journey ahead of data plumbing. */
.cc-context {
position: relative;
display: grid;
grid-template-columns: minmax(84px, 0.3fr) minmax(0, 1fr) auto;
gap: var(--s5);
align-items: center;
min-height: 112px;
padding: var(--s5) var(--s6);
overflow: hidden;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
background:
linear-gradient(90deg, color-mix(in srgb, var(--gp-accent) 9%, transparent), transparent 46%),
var(--surface);
}
.cc-context-decal {
color: color-mix(in srgb, var(--gp-accent) 58%, var(--text));
font-size: clamp(38px, 6vw, 72px);
font-weight: 800;
letter-spacing: -0.08em;
line-height: 0.75;
opacity: 0.72;
}
.cc-context-copy h2 {
font-size: 18px;
line-height: 1.15;
}
.cc-context-kicker {
display: block;
margin-bottom: var(--s2);
color: var(--text-3);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.cc-context-copy p {
max-width: 64ch;
margin-top: var(--s2);
color: var(--text-2);
font-size: 12px;
}
.cc-context-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--s2);
color: var(--text-3);
font-size: 10px;
text-align: right;
}
.cc-context-preview {
margin-top: var(--s2);
color: var(--text-2);
font-weight: 700;
}
.cc-context-preview:hover { color: var(--text); }
.cc-calendar-grid {
display: flex;
gap: 0;
padding: var(--s3) 0 var(--s4);
overflow-x: auto;
scrollbar-width: thin;
}
.cc-calendar-card {
min-width: 112px;
min-height: 138px;
flex: 1 0 112px;
padding: var(--s3) var(--s4);
border-radius: 0;
border-width: 1px 1px 1px 0;
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.cc-calendar-card:first-child { border-left-width: 1px; }
.cc-calendar-card:hover { transform: none; box-shadow: inset 0 -2px 0 var(--gp-card-accent); }
.cc-calendar-card::before { display: none; }
.cc-calendar-accent { top: auto; bottom: 0; height: 2px; opacity: 0.65; }
.cc-calendar-past { filter: none; opacity: 0.62; }
.cc-calendar-past:hover { filter: none; opacity: 1; }
.cc-calendar-focus { background: color-mix(in srgb, var(--gp-card-accent) 10%, transparent); }
.cc-calendar-decal { font-size: 60px; bottom: -5px; }
.cc-calendar-status {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-3);
}
.cc-calendar-status-focus { color: var(--gp-card-accent); }
.cc-calendar-status-future { color: var(--text-2); }
.cc-data-note {
display: flex;
align-items: center;
gap: var(--s2);
padding-top: var(--s4);
border-top: 1px solid var(--border);
color: var(--text-3);
font-size: 10px;
}
@media (max-width: 899px) {
.cc-dashboard-grid { gap: var(--s5); }
.cc-dashboard-sidebar { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s5); }
.cc-briefing, .cc-data-note { grid-column: 1 / -1; }
}
@media (max-width: 640px) {
.cc-context { grid-template-columns: 1fr auto; gap: var(--s4); padding: var(--s4); }
.cc-context-decal { display: none; }
.cc-context-meta { align-items: flex-start; grid-column: 1 / -1; text-align: left; }
.cc-context-copy h2 { font-size: 17px; }
.cc-context-copy p { font-size: 11px; }
.cc-dashboard-sidebar { display: flex; }
.cc-calendar-grid { margin: 0 calc(var(--s4) * -1); padding-left: var(--s4); padding-right: var(--s4); }
.cc-calendar-card { min-width: 104px; flex-basis: 104px; }
.cc-calendar-circuit { display: none; }
.cc-champ-snapshot { order: 2; }
.cc-schedule { order: 1; }
.cc-briefing { order: 3; margin-top: var(--s4); }
.cc-data-note { order: 4; }
}

View File

@@ -1,414 +0,0 @@
/* ── 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

@@ -1,419 +0,0 @@
/* ── 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, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach } 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,7 +21,6 @@ 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)
@@ -209,62 +208,4 @@ 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,7 +4,6 @@ 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(),
@@ -227,36 +226,4 @@ 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

@@ -0,0 +1,280 @@
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.getByTestId('cc-circuit-context')).toHaveTextContent('Circuit context')
expect(screen.getByTestId('cc-circuit-context')).toHaveTextContent('Weekend preview')
expect(screen.getByTestId('cc-data-note')).toHaveTextContent('availability is shown per session')
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('cc-calendar-1301')).toHaveTextContent('Archive')
expect(screen.getByTestId('cc-calendar-1302')).toHaveTextContent('Now')
expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live')
vi.useRealTimers()
})
it('keeps the completed local session rail available when the next weekend has no sessions', async () => {
const nextMeeting = {
...meeting,
meeting_key: 1303,
meeting_name: 'Canada',
country_name: 'Canada',
country_code: 'CAN',
circuit_short_name: 'Montreal',
date_start: '2026-06-12T00:00:00+00:00',
date_end: '2026-06-14T23:59:59+00:00',
year: 2026,
}
vi.setSystemTime(new Date('2026-06-06T15:15:00Z'))
mockFetchSeasons.mockResolvedValue([2026])
mockFetchLocalMeetings.mockResolvedValue([meeting])
mockFetchSeasonMeetings.mockResolvedValue([meeting, nextMeeting])
mockFetchWeekend.mockResolvedValue(weekend)
mockFetchSessions.mockResolvedValue([])
renderPage()
await waitFor(() => {
expect(screen.getByTestId('cc-session-9472')).toBeInTheDocument()
})
expect(screen.getByTestId('cc-schedule')).toHaveTextContent('1 sessions')
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.any(AbortSignal)) expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025)
}) })
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.any(AbortSignal)) expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025)
}) })
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,19 +129,16 @@ describe('DriverProfilePage', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('driver-profile')).toBeInTheDocument() expect(screen.getByTestId('driver-profile')).toBeInTheDocument()
}) })
expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025, expect.any(AbortSignal)) expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025)
}) })
it('shows a user-facing error with retry instead of raw API jargon', async () => { it('shows the API error message on failure', 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.getByTestId('driver-profile-error')).toBeInTheDocument() expect(screen.getByText('API 404: Not Found')).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,6 +209,32 @@ 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

@@ -1,289 +0,0 @@
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,34 +1,32 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LiveTimingPage, WEEKEND_CONTEXT_POLL_MS } from '../pages/LiveTimingPage' import { LiveTimingPage } from '../pages/LiveTimingPage'
import type { LiveStateResponse, LiveStreamData, WeekendContext } from '../types' import type { LiveStateResponse, LiveStreamData } 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, fetchWeekendContext } from '../api' import { fetchLiveState, fetchLiveTrackOutline } 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 raceSnapshot: LiveStreamData = { const archivedSnapshot: LiveStreamData = {
Drivers: { Drivers: {
'1': { '1': {
RacingNumber: '1', RacingNumber: '1',
@@ -65,10 +63,19 @@ const raceSnapshot: LiveStreamData = {
LastName: 'Verstappen', LastName: 'Verstappen',
}, },
}, },
Tyres: { '1': { Compound: 'HARD', New: false, Age: 12 } }, Tyres: {
'1': { Compound: 'HARD', New: false, Age: 12 },
},
Telemetry: {}, Telemetry: {},
RCMessages: [], RCMessages: [],
Weather: { AirTemp: 22, TrackTemp: 41, Humidity: 58, WindSpeed: 3, WindDir: 180, Rainfall: false }, Weather: {
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',
@@ -77,9 +84,9 @@ const raceSnapshot: LiveStreamData = {
Path: '', Path: '',
}, },
TeamRadio: [], TeamRadio: [],
SessionStatus: 'Started', SessionStatus: 'Finished',
TrackStatus: '1', TrackStatus: '1',
CurrentLap: 30, CurrentLap: 57,
TotalLaps: 57, TotalLaps: 57,
Clock: '', Clock: '',
ClockRefTime: '', ClockRefTime: '',
@@ -87,93 +94,17 @@ const raceSnapshot: LiveStreamData = {
Stints: {}, Stints: {},
} }
function weekendContext(localAnalysis: string): WeekendContext { function renderPage(response: LiveStateResponse) {
return { const queryClient = new QueryClient({
temporal_state: 'session_settling', defaultOptions: { queries: { retry: false } },
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 />
@@ -181,7 +112,7 @@ function renderPage(
) )
} }
describe('LiveTimingPage', () => { describe('LiveTimingPage archive mode', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
Object.defineProperty(window, 'EventSource', { Object.defineProperty(window, 'EventSource', {
@@ -191,365 +122,68 @@ describe('LiveTimingPage', () => {
}) })
}) })
afterEach(() => { it('keeps archived snapshots behind the View Last Session action', async () => {
vi.useRealTimers() renderPage({
}) is_live: false,
data: null,
last_snapshot: archivedSnapshot,
last_positions: {
'1': { x: 10, y: 20, z: 0, status: 'OnTrack' },
},
last_snapshot_at: '2026-07-04T14:00:00Z',
})
it('renders the live timing tower for an active session', async () => { expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active')
renderPage({ is_live: true, data: raceSnapshot }) expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument()
expect(await screen.findByText('Timing Tower')).toBeInTheDocument()
expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'live') fireEvent.click(screen.getByRole('button', { name: /view last session/i }))
expect(screen.getByTestId('live-session-flag')).toHaveTextContent('LIVE SESSION')
expect(screen.getByTestId('live-feed-health')).toBeInTheDocument() await waitFor(() => {
expect(screen.getByTestId('live-archive-strip')).toHaveTextContent('Archived snapshot')
})
expect(screen.getByText('Timing Tower')).toBeInTheDocument()
expect(screen.getAllByText('VER').length).toBeGreaterThan(0) expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
})
it('enters the settling handoff (not archive) when the session ends cleanly', async () => {
renderPage(
{
is_live: false,
data: null,
last_snapshot: { ...raceSnapshot, SessionStatus: 'Finished' },
last_snapshot_at: '2026-07-05T16:02: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()
})
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()
fireEvent.click(screen.getByTestId('live-handoff-archive'))
await waitFor(() =>
expect(screen.getByTestId('live-archive-strip')).toHaveTextContent(/read-only/i),
)
expect(screen.getByText('Timing Tower')).toBeInTheDocument()
// 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('shows inactive handoff when nothing is live or retained', async () => { it('does not show the archive action when no snapshot is retained', async () => {
renderPage( renderPage({ is_live: false, data: null })
{ is_live: false, data: null },
{ expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active')
temporal_state: 'between_weekends', expect(screen.queryByRole('button', { name: /view last session/i })).not.toBeInTheDocument()
next_session: { })
session: {
session_key: 21, it('renders the live session for a red-flag paused snapshot instead of the inactive empty state', async () => {
session_name: 'Practice 1', renderPage({
session_type: 'Practice 1', is_live: true,
meeting_key: 2, data: {
date_start: '2026-07-17T09:00:00Z', ...archivedSnapshot,
date_end: '2026-07-17T10:00:00Z', SessionStatus: 'Inactive',
gmt_offset: '', TrackStatus: '2',
}, Clock: '00:03:27',
availability: { Session: {
schedule: 'available', MeetingName: 'Belgian Grand Prix',
live_transport: 'unknown', CircuitName: 'Spa-Francorchamps',
live_session: 'inactive', SessionType: 'Practice',
archive: 'unavailable', SessionName: 'Practice 2',
local_analysis: 'not_applicable', Path: '',
freshness: 'fresh',
limitations: [],
},
}, },
championship_round: 2,
total_championship_rounds: 24,
}, },
) })
expect(await screen.findByTestId('live-inactive')).toBeInTheDocument() expect(await screen.findByText('Timing Tower')).toBeInTheDocument()
expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'inactive') expect(screen.queryByTestId('live-empty')).not.toBeInTheDocument()
await waitFor(() => expect(screen.queryByTestId('live-archive-strip')).not.toBeInTheDocument()
expect(screen.getByTestId('live-handoff-next')).toHaveTextContent('Practice 1'),
)
}) })
it('clears fatal initial error once SSE supplies a live snapshot', async () => { it('temporarily omits the track map while live GPS is unavailable', async () => {
type Listener = (event: { data: string }) => void renderPage({
class RecoveringEventSource { is_live: true,
onopen: (() => void) | null = null data: { ...archivedSnapshot, SessionStatus: 'Started' },
onerror: (() => void) | null = null
private listeners = new Map<string, Listener[]>()
static latest: RecoveringEventSource | null = null
constructor() {
RecoveringEventSource.latest = this
setTimeout(() => this.onopen?.(), 0)
}
addEventListener(type: string, listener: Listener) {
const list = this.listeners.get(type) ?? []
list.push(listener)
this.listeners.set(type, list)
}
emit(type: string, data: unknown) {
for (const listener of this.listeners.get(type) ?? []) {
listener({ data: JSON.stringify(data) })
}
}
close() {}
}
Object.defineProperty(window, 'EventSource', {
value: RecoveringEventSource,
writable: true,
configurable: true,
}) })
mockFetchLiveState.mockRejectedValue(new Error('API 503: live state unavailable')) expect(await screen.findByText('Timing Tower')).toBeInTheDocument()
mockFetchLiveTrackOutline.mockResolvedValue({ expect(screen.queryByText('Track Map')).not.toBeInTheDocument()
circuit_key: 1, expect(mockFetchLiveTrackOutline).not.toHaveBeenCalled()
points: [],
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
})
mockFetchWeekendContext.mockResolvedValue({
temporal_state: 'no_season',
championship_round: 0,
total_championship_rounds: 0,
})
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<LiveTimingPage />
</QueryClientProvider>,
)
expect(await screen.findByTestId('live-initial-error')).toBeInTheDocument()
expect(screen.getByText(/Live timing unavailable/i)).toBeInTheDocument()
await act(async () => {
RecoveringEventSource.latest?.emit('snapshot', {
is_live: true,
data: raceSnapshot,
})
})
await waitFor(() => expect(screen.getByText('Timing Tower')).toBeInTheDocument())
expect(screen.queryByTestId('live-initial-error')).not.toBeInTheDocument()
expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'live')
expect(screen.queryByText(/Live timing unavailable/i)).not.toBeInTheDocument()
})
it('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.any(AbortSignal)) expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025)
}) })
expect(screen.getByText('Monaco')).toBeInTheDocument() expect(screen.getByText('Monaco')).toBeInTheDocument()
}) })

View File

@@ -1,67 +0,0 @@
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,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react' import { act, render, screen, waitFor, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { import {
Outlet, Outlet,
@@ -9,14 +9,8 @@ import {
createRoute, createRoute,
} from '@tanstack/react-router' } from '@tanstack/react-router'
import { RaceHubPage } from '../pages/RaceHubPage' import { RaceHubPage } from '../pages/RaceHubPage'
import type { import { MAX_BROWSER_TIMEOUT } from '../lib/schedule'
DatasetInfo, import type { ContextAvailability, DatasetInfo, Meeting, RaceHub, Session, Weekend, WeekendContext } from '../types'
Meeting,
RaceHub,
Session,
Weekend,
WeekendContext,
} from '../types'
vi.mock('../api', () => ({ vi.mock('../api', () => ({
fetchRaceHub: vi.fn(), fetchRaceHub: vi.fn(),
@@ -26,13 +20,7 @@ vi.mock('../api', () => ({
fetchWeekendContext: vi.fn(), fetchWeekendContext: vi.fn(),
})) }))
import { import { fetchLocalMeetings, fetchRaceHub, fetchSeasons, fetchWeekend, fetchWeekendContext } from '../api'
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)
@@ -40,8 +28,6 @@ const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
const mockFetchWeekend = vi.mocked(fetchWeekend) const mockFetchWeekend = vi.mocked(fetchWeekend)
const mockFetchWeekendContext = vi.mocked(fetchWeekendContext) 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,
meeting_name: 'Monaco Grand Prix', meeting_name: 'Monaco Grand Prix',
@@ -76,16 +62,6 @@ 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 },
@@ -196,38 +172,23 @@ const weekend: Weekend = {
], ],
} }
const weekendContext: WeekendContext = { const availability: ContextAvailability = {
season: 2025, source: 'local',
temporal_state: 'post_weekend', schedule: 'available',
focus_meeting: meeting, live_transport: 'unknown',
previous_meeting: meeting, live_session: 'inactive',
default_analysis_session: { archive: 'unavailable',
session: raceSession, local_analysis: 'complete',
meeting, freshness: 'local',
availability: { limitations: [],
schedule: 'available', }
live_transport: 'unknown',
live_session: 'inactive', const analysisContext: WeekendContext = {
archive: 'unavailable', temporal_state: 'between_weekends',
local_analysis: 'complete', default_analysis_session: { session: raceSession, meeting, availability },
freshness: 'fresh', race_hub_default_session: { session: raceSession, meeting, availability },
limitations: [], race_hub_pre_session: false,
}, championship_round: 1,
},
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, total_championship_rounds: 24,
} }
@@ -243,19 +204,6 @@ 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',
@@ -268,25 +216,24 @@ function renderRaceHub(sessionKey: number) {
return <RaceHubPage sessionKey={session_key ?? 0} /> return <RaceHubPage sessionKey={session_key ?? 0} />
}, },
}) })
window.history.pushState({}, '', sessionKey ? `/race-hub?session_key=${sessionKey}` : '/race-hub')
const router = createRouter({ const router = createRouter({
routeTree: rootRoute.addChildren([weekendRoute, raceHubRoute]), routeTree: rootRoute.addChildren([raceHubRoute]),
history: undefined, history: undefined,
}) })
router.navigate({ to: '/race-hub', search: sessionKey ? { session_key: sessionKey } : {} }) return { queryClient, ...render(<RouterProvider router={router} />) }
return render(<RouterProvider router={router} />)
} }
describe('RaceHubPage', () => { describe('RaceHubPage', () => {
beforeEach(() => { beforeEach(() => {
vi.useRealTimers()
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) mockFetchWeekendContext.mockResolvedValue(analysisContext)
}) })
afterEach(() => { afterEach(() => {
@@ -305,9 +252,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 () => {
@@ -317,43 +264,17 @@ 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 Diagnostics accessible behind a secondary action, free of inline CLI guidance', async () => { it('keeps Data Status accessible and 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: 'Diagnostics' })) fireEvent.click(screen.getByRole('tab', { name: 'Data Status' }))
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 () => {
@@ -364,213 +285,121 @@ describe('RaceHubPage', () => {
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 () => { it('uses the server-selected completed analysis session for bare Race Hub without changing the URL', 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) renderRaceHub(0)
await waitFor(() => expect(mockFetchRaceHub).toHaveBeenCalledWith(9471, expect.any(AbortSignal))) await waitFor(() => expect(mockFetchRaceHub).toHaveBeenCalledWith(9472))
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9600, expect.anything()) expect(mockFetchWeekendContext).toHaveBeenCalledTimes(1)
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9600)
}) })
it('shows no-analysis fallback when Weekend Context has no default analysis', async () => { it('renders the intentional pre-session state without analysis cards', async () => {
mockFetchWeekendContext.mockResolvedValue({ mockFetchWeekendContext.mockResolvedValue({
...weekendContext, ...analysisContext,
default_analysis_session: undefined, race_hub_default_session: {
previous_completed_session: undefined, session: { ...raceSession, session_key: 9473, session_name: 'Practice 1', session_type: 'Practice', date_start: '2099-05-23T13:00:00Z' },
meeting,
availability,
},
race_hub_pre_session: true,
race_hub_refresh_at: '2099-05-23T13:00:00Z',
}) })
renderRaceHub(0) renderRaceHub(0)
await waitFor(() => expect(screen.getByTestId('race-hub-no-analysis')).toBeInTheDocument()) expect(await screen.findByTestId('race-hub-pre-session')).toBeInTheDocument()
expect(screen.getByTestId('rh-pre-session-schedule')).toBeInTheDocument()
expect(screen.queryByText('Winner')).not.toBeInTheDocument()
expect(mockFetchRaceHub).not.toHaveBeenCalled() expect(mockFetchRaceHub).not.toHaveBeenCalled()
}) })
it('renders a pre-session view instead of empty analysis for a future session', async () => { it('opens the weekend switcher from pre-session and navigates to the selected explicit 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({ mockFetchWeekendContext.mockResolvedValue({
...weekendContext, ...analysisContext,
temporal_state: 'session_settling', race_hub_default_session: {
default_analysis_session: undefined, session: { ...raceSession, session_key: 9473, session_name: 'Practice 1', session_type: 'Practice', date_start: '2099-05-23T13:00:00Z' },
previous_completed_session: {
session: raceSession,
meeting, meeting,
availability: { availability,
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'pending',
freshness: 'fresh',
limitations: [],
},
}, },
}) race_hub_pre_session: true,
mockFetchRaceHub.mockResolvedValue({ race_hub_refresh_at: '2099-05-23T13:00:00Z',
...raceHub,
source: 'none',
results: [],
starting_grid: [],
datasets: {},
}) })
renderRaceHub(9472) renderRaceHub(0)
await waitFor(() => expect(screen.getByTestId('rh-preparing')).toBeInTheDocument()) const switchWeekend = await screen.findByTestId('rh-switch-weekend')
expect(screen.queryByTestId('rh-overview')).not.toBeInTheDocument() expect(switchWeekend).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(switchWeekend)
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
expect(switchWeekend).toHaveAttribute('aria-expanded', 'true')
fireEvent.click(await screen.findByTestId('rh-switcher-session-9471'))
await waitFor(() => expect(window.location.search).toBe('?session_key=9471'))
}) })
it('renders unavailable distinctly from request errors', async () => { it('shows recovery instead of selecting an empty future session', async () => {
mockFetchWeekend.mockResolvedValue({
...weekend,
sessions: [{ session: raceSession, source: 'none', datasets: {} }],
})
mockFetchWeekendContext.mockResolvedValue({ mockFetchWeekendContext.mockResolvedValue({
...weekendContext, ...analysisContext,
previous_completed_session: { race_hub_default_session: undefined,
session: raceSession, race_hub_pre_session: false,
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) renderRaceHub(0)
await waitFor(() => expect(screen.getByTestId('rh-unavailable')).toBeInTheDocument()) expect(await screen.findByTestId('race-hub-empty')).toHaveTextContent('No completed local analysis yet')
expect(screen.queryByTestId('race-hub-error')).not.toBeInTheDocument() expect(mockFetchRaceHub).not.toHaveBeenCalled()
}) })
it('labels a completed but partial session as Partial and keeps analysis', async () => { it('hands a bare route from completed analysis to pre-session at the supplied refresh boundary', async () => {
mockFetchWeekend.mockResolvedValue({ vi.useFakeTimers({ shouldAdvanceTime: true })
...weekend, const handoff = new Date(Date.now() + 10_000).toISOString()
sessions: [ const pendingContext: WeekendContext = {
{ session: qualSession, source: 'local', datasets: fullDatasets }, ...analysisContext,
{ session: raceSession, source: 'partial', datasets: { drivers: fullDatasets.drivers } }, race_hub_default_session: {
], session: { ...raceSession, session_key: 9473, session_name: 'Practice 1', session_type: 'Practice', date_start: handoff },
}) meeting,
availability,
},
race_hub_pre_session: true,
race_hub_refresh_at: new Date(Date.now() + 16_000).toISOString(),
}
mockFetchWeekendContext
.mockResolvedValueOnce({ ...analysisContext, race_hub_refresh_at: handoff })
.mockResolvedValueOnce(pendingContext)
renderRaceHub(0)
await screen.findByTestId('race-hub')
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
expect(await screen.findByTestId('race-hub-pre-session')).toBeInTheDocument()
expect(mockFetchWeekendContext).toHaveBeenCalledTimes(2)
expect(mockFetchRaceHub).toHaveBeenCalledWith(9472)
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9473)
})
it('re-arms a bare route refresh after a capped browser timer', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
mockFetchWeekendContext.mockResolvedValue({ mockFetchWeekendContext.mockResolvedValue({
...weekendContext, ...analysisContext,
default_analysis_session: { race_hub_refresh_at: new Date(Date.now() + MAX_BROWSER_TIMEOUT + 1_000).toISOString(),
session: raceSession,
meeting,
availability: {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'partial',
freshness: 'fresh',
limitations: [],
},
},
}) })
renderRaceHub(9472) renderRaceHub(0)
await screen.findByTestId('race-hub')
await act(async () => { await vi.advanceTimersByTimeAsync(MAX_BROWSER_TIMEOUT) })
await waitFor(() => expect(screen.getByTestId('rh-active-state')).toBeInTheDocument()) await waitFor(() => expect(mockFetchWeekendContext).toHaveBeenCalledTimes(2))
expect(screen.getByTestId('rh-active-state')).toHaveTextContent('Partial') await act(async () => { await vi.advanceTimersByTimeAsync(1_000) })
expect(screen.getByTestId('rh-partial-banner')).toBeInTheDocument() await waitFor(() => expect(mockFetchWeekendContext).toHaveBeenCalledTimes(3))
expect(screen.getByTestId('rh-overview')).toBeInTheDocument()
}) })
it('offers retry and back-to-Weekend on an error', async () => { it('keeps an explicit session URL stable across the canonical refresh boundary', async () => {
mockFetchRaceHub.mockRejectedValue(new Error('boom')) vi.useFakeTimers()
renderRaceHub(9472) renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('race-hub-error')).toBeInTheDocument()) await act(async () => { await vi.advanceTimersByTimeAsync(60_000) })
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) expect(mockFetchWeekendContext).not.toHaveBeenCalled()
fireEvent.click(screen.getByTestId('rh-retry')) expect(mockFetchRaceHub).toHaveBeenCalledWith(9472)
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument()) expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9473)
})
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,10 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react' import { render, screen, waitFor } 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', () => ({
@@ -176,22 +174,16 @@ const outline: TrackOutline = {
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 }, bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
} }
function renderPage(props?: { function renderPage() {
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}>
<Page /> <RacePreviewPage />
</QueryClientProvider> </QueryClientProvider>
), ),
}) })
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: Page }) const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: RacePreviewPage })
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) }) const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
return render(<RouterProvider router={router} />) return render(<RouterProvider router={router} />)
} }
@@ -282,145 +274,4 @@ 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,15 +278,6 @@ 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

@@ -0,0 +1,61 @@
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 grouped into a hierarchy', () => { it('renders all Race Hub workspace tabs', () => {
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,11 +12,7 @@ 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: 'Diagnostics' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Data Status' })).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,9 +47,20 @@ function snapshotRows(lap: number, lastLapTime: string): LiveTimingRow[] {
} }
describe('TyreDegPanel', () => { describe('TyreDegPanel', () => {
it('shows a warming-up placeholder until enough clean laps accumulate', () => { it('stays collapsed while every stint is still warming up', () => {
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')
@@ -61,6 +72,7 @@ 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()
}) })
@@ -81,13 +93,46 @@ describe('TyreDegPanel', () => {
expect(panel).toHaveTextContent('→ ~P2') expect(panel).toHaveTextContent('→ ~P2')
}) })
it('hides the rejoin estimate outside race sessions and collapses on toggle', () => { it('collapses by default outside race sessions and hides the rejoin estimate when expanded', () => {
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)
}) })
@@ -96,6 +141,7 @@ 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

@@ -1,651 +0,0 @@
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

@@ -1,344 +0,0 @@
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

@@ -1,291 +0,0 @@
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,12 +1,15 @@
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,
@@ -279,6 +282,43 @@ 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

@@ -1,183 +0,0 @@
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

@@ -1,102 +0,0 @@
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

@@ -1,24 +0,0 @@
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

@@ -5,8 +5,10 @@ import {
focusMeetingKind, focusMeetingKind,
focusMeetingLabel, focusMeetingLabel,
formatCountdown, formatCountdown,
MAX_BROWSER_TIMEOUT,
nextUpcomingMeeting, nextUpcomingMeeting,
pickFocusMeeting, pickFocusMeeting,
refreshDeadlineDelay,
} from '../lib/schedule' } from '../lib/schedule'
import type { Meeting, Session } from '../types' import type { Meeting, Session } from '../types'
@@ -78,4 +80,15 @@ describe('schedule helpers', () => {
const target = new Date('2025-05-25T13:00:00+00:00') const target = new Date('2025-05-25T13:00:00+00:00')
expect(formatCountdown(target, now)).toBe('0d 01h 00m 00s') expect(formatCountdown(target, now)).toBe('0d 01h 00m 00s')
}) })
it('uses the server refresh deadline without local timezone conversion', () => {
expect(refreshDeadlineDelay('2025-05-25T13:00:00Z', Date.parse('2025-05-25T12:59:30Z'))).toBe(30_000)
expect(refreshDeadlineDelay(undefined)).toBeNull()
})
it('caps a refresh deadline beyond the browser timer maximum', () => {
const now = Date.parse('2025-05-25T12:00:00Z')
const deadline = new Date(now + MAX_BROWSER_TIMEOUT + 1_000).toISOString()
expect(refreshDeadlineDelay(deadline, now)).toBe(MAX_BROWSER_TIMEOUT)
})
}) })

View File

@@ -1,146 +0,0 @@
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

@@ -1,223 +0,0 @@
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

@@ -217,6 +217,41 @@ export interface Weekend {
default_session_key?: number default_session_key?: number
} }
export interface ContextAvailability {
source: string
schedule: string
live_transport: string
live_session: string
archive: string
local_analysis: string
freshness: string
observed_at?: string
limitations: string[]
}
export interface ContextSession {
session: Session
meeting?: Meeting
availability: ContextAvailability
}
export interface WeekendContext {
season?: number
temporal_state: string
previous_meeting?: Meeting
focus_meeting?: Meeting
next_meeting?: Meeting
previous_completed_session?: ContextSession
active_session?: ContextSession
next_session?: ContextSession
default_analysis_session?: ContextSession
race_hub_default_session?: ContextSession
race_hub_pre_session: boolean
race_hub_refresh_at?: string
championship_round: number
total_championship_rounds: number
}
export interface LiveStateResponse { export interface LiveStateResponse {
is_live: boolean is_live: boolean
data: LiveStreamData | null data: LiveStreamData | null
@@ -446,113 +481,6 @@ 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

@@ -307,6 +307,28 @@ 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(`{
@@ -450,6 +472,62 @@ func TestProcessTopicTimingAppData(t *testing.T) {
} }
} }
// The feed sends stints as sparse deltas keyed by stint index. Replacing the
// slice on each delta collapsed pit history to one entry and pinned tyre age
// near zero — observed live at lap 49 of a 70-lap race, where every driver
// reported a single stint of age 0 despite having pitted.
func TestProcessTopicTimingAppDataMergesSparseStintDeltas(t *testing.T) {
state := live.NewState()
state.ProcessTopic("TimingAppData", json.RawMessage(`{
"Lines": {"4": {"Stints": {"0": {"Compound": "MEDIUM", "New": "true", "TotalLaps": 0}}}}
}`))
// Stint 0 runs to 18 laps, then the driver pits onto a new hard.
state.ProcessTopic("TimingAppData", json.RawMessage(`{
"Lines": {"4": {"Stints": {"0": {"TotalLaps": 18}}}}
}`))
state.ProcessTopic("TimingAppData", json.RawMessage(`{
"Lines": {"4": {"Stints": {"1": {"Compound": "HARD", "New": "true", "TotalLaps": 0}}}}
}`))
state.ProcessTopic("TimingAppData", json.RawMessage(`{
"Lines": {"4": {"Stints": {"1": {"TotalLaps": 12}}}}
}`))
snap := state.Snapshot()
stints := snap.Stints["4"]
if len(stints) != 2 {
t.Fatalf("expected 2 stints after a pit stop, got %d: %+v", len(stints), stints)
}
if stints[0].Compound != "MEDIUM" || stints[0].Laps != 18 {
t.Errorf("first stint lost across deltas: %+v", stints[0])
}
if stints[1].Compound != "HARD" || stints[1].Laps != 12 {
t.Errorf("second stint = %+v", stints[1])
}
if tyre := snap.Tyres["4"]; tyre.Compound != "HARD" || tyre.Age != 12 {
t.Errorf("current tyre should track the latest stint, got %+v", tyre)
}
}
func TestProcessTopicTimingAppDataIgnoresNonNumericStintKeys(t *testing.T) {
state := live.NewState()
state.ProcessTopic("TimingAppData", json.RawMessage(`{
"Lines": {"4": {"Stints": {"0": {"Compound": "SOFT", "New": "true", "TotalLaps": 9}}}}
}`))
// "_kf" is a feed key-frame marker, not a stint index. Parsing it as 0
// would overwrite the real first stint.
state.ProcessTopic("TimingAppData", json.RawMessage(`{
"Lines": {"4": {"Stints": {"_kf": {"Compound": "HARD", "TotalLaps": 99}}}}
}`))
stints := state.Snapshot().Stints["4"]
if len(stints) != 1 {
t.Fatalf("expected 1 stint, got %d: %+v", len(stints), stints)
}
if stints[0].Compound != "SOFT" || stints[0].Laps != 9 {
t.Errorf("key-frame marker corrupted stint 0: %+v", stints[0])
}
}
func TestProcessTopicTimingStats(t *testing.T) { func TestProcessTopicTimingStats(t *testing.T) {
state := live.NewState() state := live.NewState()
state.Drivers["55"] = live.LiveDriverData{RacingNumber: "55"} state.Drivers["55"] = live.LiveDriverData{RacingNumber: "55"}

View File

@@ -10,6 +10,7 @@ import (
"io" "io"
"log" "log"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
) )
@@ -404,22 +405,42 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
Stints json.RawMessage `json:"Stints"` Stints json.RawMessage `json:"Stints"`
} }
if json.Unmarshal(lineRaw, &line) == nil && line.Stints != nil { if json.Unmarshal(lineRaw, &line) == nil && line.Stints != nil {
var driverStints []LiveStintData // The feed sends stints as sparse deltas keyed by stint index:
// a mid-stint update is just {"1": {"TotalLaps": 14}}. Merge
// each entry into the stint it addresses. Replacing the slice
// wholesale discarded every earlier stint, so pit history
// collapsed to one entry and tyre age stuck near zero for the
// whole race.
driverStints := append([]LiveStintData(nil), s.Stints[num]...)
changed := false
for _, sRaw := range indexedRawValues(line.Stints) { for _, sRaw := range indexedRawValues(line.Stints) {
var st struct { var st struct {
Compound string `json:"Compound"` Compound *string `json:"Compound"`
New string `json:"New"` New *string `json:"New"`
TotalLaps int `json:"TotalLaps"` TotalLaps *int `json:"TotalLaps"`
} }
if json.Unmarshal(sRaw.Raw, &st) == nil && st.Compound != "" { if json.Unmarshal(sRaw.Raw, &st) != nil {
driverStints = append(driverStints, LiveStintData{ continue
Compound: st.Compound,
New: st.New == "true" || st.New == "True",
Laps: st.TotalLaps,
})
} }
if st.Compound == nil && st.New == nil && st.TotalLaps == nil {
continue
}
for len(driverStints) <= sRaw.Index {
driverStints = append(driverStints, LiveStintData{})
}
entry := &driverStints[sRaw.Index]
if st.Compound != nil && *st.Compound != "" {
entry.Compound = *st.Compound
}
if st.New != nil {
entry.New = *st.New == "true" || *st.New == "True"
}
if st.TotalLaps != nil {
entry.Laps = *st.TotalLaps
}
changed = true
} }
if len(driverStints) > 0 { if changed {
s.Stints[num] = driverStints s.Stints[num] = driverStints
lastStint := driverStints[len(driverStints)-1] lastStint := driverStints[len(driverStints)-1]
t := s.Tyres[num] t := s.Tyres[num]
@@ -874,8 +895,13 @@ func indexedRawValues(raw json.RawMessage) []indexedRaw {
if err := json.Unmarshal(raw, &obj); err == nil { if err := json.Unmarshal(raw, &obj); err == nil {
values := make([]indexedRaw, 0, len(obj)) values := make([]indexedRaw, 0, len(obj))
for k, v := range obj { for k, v := range obj {
i := 0 // Keys are array indices in the feed's delta form. Non-numeric keys
fmt.Sscanf(k, "%d", &i) // are feed metadata — "_kf" (key frame) is the common one — and must
// not be folded in as index 0, which would clobber the first entry.
i, err := strconv.Atoi(k)
if err != nil || i < 0 {
continue
}
values = append(values, indexedRaw{Index: i, Raw: v}) values = append(values, indexedRaw{Index: i, Raw: v})
} }
sort.Slice(values, func(i, j int) bool { sort.Slice(values, func(i, j int) bool {

View File

@@ -186,6 +186,19 @@ 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 {

View File

@@ -24,6 +24,7 @@ const (
preSessionWindow = 48 * time.Hour preSessionWindow = 48 * time.Hour
postWeekendWindow = 48 * time.Hour postWeekendWindow = 48 * time.Hour
raceHubPendingPollInterval = 15 * time.Second
) )
// LiveEvidence is the small, transport-independent subset of FIA state needed // LiveEvidence is the small, transport-independent subset of FIA state needed
@@ -69,6 +70,9 @@ type WeekendContext struct {
ActiveSession *ContextSession `json:"active_session,omitempty"` ActiveSession *ContextSession `json:"active_session,omitempty"`
NextSession *ContextSession `json:"next_session,omitempty"` NextSession *ContextSession `json:"next_session,omitempty"`
DefaultAnalysisSession *ContextSession `json:"default_analysis_session,omitempty"` DefaultAnalysisSession *ContextSession `json:"default_analysis_session,omitempty"`
RaceHubDefaultSession *ContextSession `json:"race_hub_default_session,omitempty"`
RaceHubPreSession bool `json:"race_hub_pre_session"`
RaceHubRefreshAt string `json:"race_hub_refresh_at,omitempty"`
ChampionshipRound int `json:"championship_round"` ChampionshipRound int `json:"championship_round"`
TotalChampionshipRounds int `json:"total_championship_rounds"` TotalChampionshipRounds int `json:"total_championship_rounds"`
} }
@@ -150,7 +154,7 @@ func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext,
} }
} }
var previous, next, defaultAnalysis *contextCandidate var previous, next, defaultAnalysis, pending *contextCandidate
for i := range candidates { for i := range candidates {
c := &candidates[i] c := &candidates[i]
isActive := active != nil && active.session.SessionKey != 0 && c.session.SessionKey == active.session.SessionKey isActive := active != nil && active.session.SessionKey != 0 && c.session.SessionKey == active.session.SessionKey
@@ -164,6 +168,10 @@ func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext,
if !isActive && !c.start.IsZero() && !c.start.Before(now) && (next == nil || c.start.Before(next.start)) { if !isActive && !c.start.IsZero() && !c.start.Before(now) && (next == nil || c.start.Before(next.start)) {
next = c next = c
} }
if !isActive && !c.complete && !c.start.IsZero() && !c.start.After(now) &&
(c.end.IsZero() || now.Before(c.end)) && (pending == nil || c.start.After(pending.start)) {
pending = c
}
} }
if previous != nil { if previous != nil {
@@ -188,9 +196,40 @@ func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext,
if out.FocusMeeting != nil { if out.FocusMeeting != nil {
out.ChampionshipRound = championshipRound(champMeetings, int(out.FocusMeeting.MeetingKey)) out.ChampionshipRound = championshipRound(champMeetings, int(out.FocusMeeting.MeetingKey))
} }
applyRaceHubDefault(&out, active, defaultAnalysis, next, pending, now)
return out, nil return out, nil
} }
// applyRaceHubDefault is deliberately distinct from TemporalPreSession. Other
// weekend surfaces begin preparation 48 hours ahead; Race Hub remains an
// analysis destination until the one-hour handoff before the next session.
func applyRaceHubDefault(out *WeekendContext, active, analysis, next, pending *contextCandidate, now time.Time) {
if active != nil {
out.RaceHubDefaultSession = out.ActiveSession
return
}
if next != nil {
handoff := next.start.Add(-time.Hour)
if now.Before(handoff) {
out.RaceHubRefreshAt = handoff.Format(time.RFC3339)
} else if now.Before(next.start) {
out.RaceHubDefaultSession = out.NextSession
out.RaceHubPreSession = true
out.RaceHubRefreshAt = next.start.Format(time.RFC3339)
return
}
}
if pending != nil {
out.RaceHubDefaultSession = sessionRef(*pending, LiveEvidence{}, now)
out.RaceHubPreSession = true
out.RaceHubRefreshAt = now.Add(raceHubPendingPollInterval).Format(time.RFC3339)
return
}
if analysis != nil {
out.RaceHubDefaultSession = out.DefaultAnalysisSession
}
}
func currentLocalSeason(years []int, current int) int { func currentLocalSeason(years []int, current int) int {
for _, year := range years { for _, year := range years {
if year == current { if year == current {

View File

@@ -223,6 +223,38 @@ func TestResolveWeekendContextNeverUsesFutureAnalysis(t *testing.T) {
} }
} }
// Regression for the v0.4.0 release blocker (#90). Production held a run
// British race (11326) and an unrun Belgian race (11334) two weekends apart.
// Analysis must resolve to the race that actually happened, even when the
// later meeting carries preloaded rows.
func TestResolveWeekendContextPrefersRunRaceOverLaterUnrunRace(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-13T12:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1289, "British Grand Prix", "2026-07-03T11:30:00Z", "2026-07-05T16:00:00Z", false)
addContextSession(t, svc, 11326, 1289, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
completeContextSession(t, svc, 11326, 1289)
addContextMeeting(t, svc, 1290, "Belgian Grand Prix", "2026-07-17T11:30:00Z", "2026-07-19T15:00:00Z", false)
addContextSession(t, svc, 11334, 1290, "Race", "2026-07-19T13:00:00Z", "2026-07-19T15:00:00Z", false)
completeContextSession(t, svc, 11334, 1290)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.DefaultAnalysisSession == nil {
t.Fatal("no analysis session resolved")
}
if key := got.DefaultAnalysisSession.Session.SessionKey; key != 11326 {
t.Fatalf("analysis session = %d, want 11326 (British); 11334 is the unrun Belgian race", key)
}
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Session.SessionKey != 11326 {
t.Fatalf("previous completed = %+v, want 11326", got.PreviousCompletedSession)
}
if got.NextSession == nil || got.NextSession.Session.SessionKey != 11334 {
t.Fatalf("next session = %+v, want 11334", got.NextSession)
}
}
func TestResolveWeekendContextSprintWeekendHandoff(t *testing.T) { func TestResolveWeekendContextSprintWeekendHandoff(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-04T12:00:00Z") now, _ := time.Parse(time.RFC3339, "2026-07-04T12:00:00Z")
svc := contextService(t, now) svc := contextService(t, now)
@@ -367,3 +399,88 @@ func TestResolveWeekendContextMissingScheduleDoesNotClaimSeasonComplete(t *testi
t.Fatalf("total rounds = %d, want scheduled round retained", got.TotalChampionshipRounds) t.Fatalf("total rounds = %d, want scheduled round retained", got.TotalChampionshipRounds)
} }
} }
func TestResolveWeekendContextRaceHubDefault(t *testing.T) {
seed := func(t *testing.T, svc *Service) {
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)
completeContextSession(t, svc, 11, 1)
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)
}
t.Run("keeps completed analysis before handoff", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-16T07:59:59Z")
svc := contextService(t, now)
seed(t, svc)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 11 || got.RaceHubPreSession {
t.Fatalf("race hub default = %+v, pre-session = %t", got.RaceHubDefaultSession, got.RaceHubPreSession)
}
if got.RaceHubRefreshAt != "2026-07-17T08:00:00Z" {
t.Fatalf("refresh = %q", got.RaceHubRefreshAt)
}
})
t.Run("hands off exactly one hour before first session", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-17T08:00:00Z")
svc := contextService(t, now)
seed(t, svc)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 21 || !got.RaceHubPreSession {
t.Fatalf("race hub handoff = %+v, pre-session = %t", got.RaceHubDefaultSession, got.RaceHubPreSession)
}
if got.RaceHubRefreshAt != "2026-07-17T09:00:00Z" {
t.Fatalf("refresh = %q", got.RaceHubRefreshAt)
}
})
t.Run("keeps the scheduled session pending after its start without live evidence", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-17T09:00:00Z")
svc := contextService(t, now)
seed(t, svc)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 21 || !got.RaceHubPreSession {
t.Fatalf("scheduled race hub default = %+v, pre-session = %t", got.RaceHubDefaultSession, got.RaceHubPreSession)
}
if got.RaceHubRefreshAt != "2026-07-17T09:00:15Z" {
t.Fatalf("refresh = %q", got.RaceHubRefreshAt)
}
})
t.Run("active live session wins", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-17T08:30:00Z")
svc := contextService(t, now)
seed(t, svc)
got, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "Belgian Grand Prix", SessionName: "Practice 1", SessionType: "Practice 1"})
if err != nil {
t.Fatal(err)
}
if got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 21 || got.RaceHubPreSession {
t.Fatalf("live default = %+v, pre-session = %t", got.RaceHubDefaultSession, got.RaceHubPreSession)
}
})
t.Run("does not select an empty future session before handoff", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-16T12:00:00Z")
svc := contextService(t, now)
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)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.RaceHubDefaultSession != nil || got.RaceHubPreSession {
t.Fatalf("unexpected empty future default: %+v", got)
}
})
}

View File

@@ -19,8 +19,6 @@ 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

@@ -167,3 +167,30 @@ func TestTerminalSessionStatus(t *testing.T) {
} }
} }
} }
func TestWeekendContextHandlerSerializesRaceHubRefreshDeadline(t *testing.T) {
st := openContextStore(t)
seedContextHandler(t, st)
if err := st.UpsertSessionResult(store.SessionResult{SessionKey: 11, MeetingKey: 1, DriverNumber: 1, Position: 1}); err != nil {
t.Fatal(err)
}
if err := st.UpsertMeeting(store.Meeting{MeetingKey: 2, MeetingName: "Belgian Grand Prix", CircuitShortName: "Spa", Year: 2026, DateStart: "2026-07-17T09:00:00Z", DateEnd: "2026-07-19T16: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-17T09:00:00Z", DateEnd: "2026-07-17T10:00:00Z"}); err != nil {
t.Fatal(err)
}
s := NewServer(nil, 0, st)
s.query = query.NewServiceWithClock(st, func() time.Time {
return time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)
})
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.RaceHubPreSession || got.RaceHubRefreshAt != "2026-07-17T09:00:00Z" || got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 21 {
t.Fatalf("race hub context = %+v", got)
}
}

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 live.SessionStatusIsActive(data.SessionStatus) { if snapshotIsLive(data) {
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,6 +208,40 @@ 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 ||

View File

@@ -51,6 +51,101 @@ 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

@@ -11,7 +11,12 @@
"test:visual": "playwright test --config playwright.visual.config.ts", "test:visual": "playwright test --config playwright.visual.config.ts",
"test:visual:prod": "playwright test --config playwright.visual.prod.config.ts", "test:visual:prod": "playwright test --config playwright.visual.prod.config.ts",
"test:visual:update": "playwright test --config playwright.visual.config.ts --update-snapshots", "test:visual:update": "playwright test --config playwright.visual.config.ts --update-snapshots",
"test:visual:prod:update": "playwright test --config playwright.visual.prod.config.ts --update-snapshots" "test:visual:prod:update": "playwright test --config playwright.visual.prod.config.ts --update-snapshots",
"release:fidelity:capture": "playwright test --config playwright.fidelity.config.ts",
"release:fidelity:packet": "node scripts/release-fidelity/generate.mjs",
"release:fidelity:verify": "node scripts/release-fidelity/verify.mjs",
"release:fidelity:gate": "npm run test:visual:prod && npm run release:fidelity:capture && npm run release:fidelity:packet && npm run release:fidelity:verify",
"test:release-fidelity": "node --test scripts/release-fidelity/*.test.mjs"
}, },
"type": "commonjs", "type": "commonjs",
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,37 @@
import { defineConfig } from '@playwright/test'
import { VIEWPORTS } from './tests/visual/helpers'
const E2E_DB = '.playwright/boxbox-fidelity.db'
const API_PORT = process.env.BOXBOX_API_PORT ?? '18080'
const WEB_PORT = process.env.BOXBOX_WEB_PORT ?? '15173'
// Candidate captures are evidence for owner review, not regression baselines.
export default defineConfig({
testDir: './tests/release-fidelity',
fullyParallel: true,
forbidOnly: !!process.env.CI,
reporter: process.env.CI ? 'github' : 'list',
use: {
baseURL: `http://localhost:${WEB_PORT}`,
colorScheme: 'dark',
trace: 'on-first-retry',
},
projects: [
{ name: 'desktop', use: { browserName: 'chromium', viewport: VIEWPORTS.desktop } },
{ name: 'mobile', use: { browserName: 'chromium', viewport: VIEWPORTS.mobile } },
],
webServer: [
{
command: `go run ./scripts/seed-e2e-db/main.go --db ${E2E_DB} && BOXBOX_DISABLE_LIVE=1 BOXBOX_OPENF1_BASE_URL=http://127.0.0.1:9 go run ./cmd/main.go --web --db ${E2E_DB} --port ${API_PORT}`,
url: `http://localhost:${API_PORT}/api/v1/race-hub?session_key=9472`,
reuseExistingServer: false,
timeout: 120_000,
},
{
command: `BOXBOX_API_PORT=${API_PORT} npm run dev --prefix frontend -- --port ${WEB_PORT} --strictPort`,
url: `http://localhost:${WEB_PORT}`,
reuseExistingServer: false,
timeout: 120_000,
},
],
})

View File

@@ -7,10 +7,11 @@ const WEB_PORT = process.env.BOXBOX_WEB_PORT ?? '15173'
export default defineConfig({ export default defineConfig({
testDir: './tests/visual', testDir: './tests/visual',
fullyParallel: true, // All projects share one seeded SQLite database behind the same Go server.
fullyParallel: false,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined, workers: 1,
reporter: process.env.CI ? 'github' : 'html', reporter: process.env.CI ? 'github' : 'html',
snapshotPathTemplate: '{testDir}/{testFileDir}/__snapshots__/{projectName}/{arg}{ext}', snapshotPathTemplate: '{testDir}/{testFileDir}/__snapshots__/{projectName}/{arg}{ext}',
expect: { expect: {

View File

@@ -0,0 +1,86 @@
import { readdir, readFile, stat, writeFile } from 'node:fs/promises'
import { join, relative, resolve } from 'node:path'
const references = [
{ viewport: 'desktop', name: 'weekend-between-races' },
{ viewport: 'desktop', name: 'weekend-live' },
{ viewport: 'mobile', name: 'weekend-between-sessions-mobile' },
]
function option(name, fallback) {
const index = process.argv.indexOf(`--${name}`)
return index === -1 ? fallback : process.argv[index + 1]
}
async function filesBelow(directory) {
const entries = await readdir(directory, { withFileTypes: true })
const files = await Promise.all(entries.map(async (entry) => {
const path = join(directory, entry.name)
return entry.isDirectory() ? filesBelow(path) : [path]
}))
return files.flat()
}
async function locateMockup(root, name) {
const preferred = join(root, `${name}.png`)
try {
await stat(preferred)
return preferred
} catch {
const matches = (await filesBelow(root)).filter((path) => path.endsWith(`${name}.png`))
return matches.length === 1 ? matches[0] : null
}
}
function imageData(path) {
return readFile(path).then((data) => `data:image/png;base64,${data.toString('base64')}`)
}
export async function generatePacket({ version, evidence, mockups }) {
const candidateRoot = join(evidence, 'candidate')
const pairs = []
const missing = []
try {
await stat(mockups)
} catch {
throw new Error(`Approved mockups directory is missing: ${mockups}. It is supplied by the product reference integration.`)
}
for (const { viewport, name } of references) {
const candidate = join(candidateRoot, viewport, `${name}.png`)
const reference = await locateMockup(mockups, name)
try {
await stat(candidate)
} catch {
missing.push(`candidate: ${candidate}`)
}
if (!reference) missing.push(`mockup: ${join(mockups, `${name}.png`)}`)
if (reference) pairs.push({ viewport, name, candidate, reference })
}
if (missing.length) {
throw new Error(`Release-fidelity packet is incomplete:\n${missing.join('\n')}`)
}
const cards = await Promise.all(pairs.map(async ({ viewport, name, candidate, reference }) => `
<section><h2>${viewport}: ${name}</h2><div class="pair">
<figure><figcaption>Approved mockup</figcaption><img src="${await imageData(reference)}"></figure>
<figure><figcaption>Candidate</figcaption><img src="${await imageData(candidate)}"></figure>
</div><p>Reference: <code>${relative(process.cwd(), reference)}</code></p></section>`))
const summary = `# Release Fidelity Evidence: ${version}\n\n- Candidate screenshots: candidate/\n- Approved mockups: ${relative(process.cwd(), mockups)}\n- Review: human owner decision required; this packet does not approve the release.\n\nOpen index.html for side-by-side evidence.\n`
const html = `<!doctype html><title>${version} release-fidelity review</title><style>body{background:#111;color:#eee;font:16px system-ui;margin:2rem}section{margin:3rem 0}.pair{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}figure{margin:0}img{max-width:100%;border:1px solid #555}figcaption{font-weight:700;margin-bottom:.5rem}code{color:#9fd}@media(max-width:700px){.pair{grid-template-columns:1fr}}</style><h1>${version} Release-Fidelity Review</h1><p>Visual regression is a separate automated gate. This packet is evidence for a subjective owner review and is not approval.</p>${cards.join('')}</html>`
await writeFile(join(evidence, 'summary.md'), summary)
await writeFile(join(evidence, 'index.html'), html)
}
if (import.meta.url === `file://${process.argv[1]}`) {
const version = option('version', process.env.RELEASE_FIDELITY_VERSION ?? 'v0.4.0')
const output = process.env.RELEASE_FIDELITY_OUTPUT ?? 'release-fidelity'
const evidence = resolve(option('evidence', join(output, version)))
const mockups = resolve(option('mockups', join('docs/product', version, 'mockups')))
generatePacket({ version, evidence, mockups }).then(
() => console.log(`Release-fidelity packet: ${join(evidence, 'index.html')}`),
(error) => { console.error(error.message); process.exitCode = 1 },
)
}

View File

@@ -0,0 +1,27 @@
import assert from 'node:assert/strict'
import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { generatePacket } from './generate.mjs'
test('generates a self-contained side-by-side packet for desktop and mobile', async () => {
const root = await mkdtemp(join(tmpdir(), 'boxbox-fidelity-'))
const evidence = join(root, 'evidence')
const mockups = join(root, 'mockups')
const references = [
['desktop', 'weekend-between-races'],
['desktop', 'weekend-live'],
['mobile', 'weekend-between-sessions-mobile'],
]
for (const [viewport, name] of references) {
await mkdir(join(evidence, 'candidate', viewport), { recursive: true })
await mkdir(mockups, { recursive: true })
await writeFile(join(evidence, 'candidate', viewport, `${name}.png`), 'candidate')
await writeFile(join(mockups, `${name}.png`), 'mockup')
}
await generatePacket({ version: 'v-test', evidence, mockups })
assert.match(await readFile(join(evidence, 'index.html'), 'utf8'), /Approved mockup/)
assert.match(await readFile(join(evidence, 'summary.md'), 'utf8'), /human owner decision required/)
})

View File

@@ -0,0 +1,50 @@
import { readFile } from 'node:fs/promises'
import { execFileSync } from 'node:child_process'
import { join, resolve } from 'node:path'
function option(name, fallback) {
const index = process.argv.indexOf(`--${name}`)
return index === -1 ? fallback : process.argv[index + 1]
}
function git(...args) {
return execFileSync('git', args, { encoding: 'utf8' }).trim()
}
const version = option('version', process.env.RELEASE_FIDELITY_VERSION ?? 'v0.4.0')
const output = process.env.RELEASE_FIDELITY_OUTPUT ?? 'release-fidelity'
const evidence = resolve(option('evidence', join(output, version)))
const signoff = option('signoff', join('docs/release/owner-reviews', `${version}.md`))
const required = ['index.html', 'summary.md']
try {
for (const file of required) await readFile(join(evidence, file))
git('cat-file', '-e', `HEAD:${signoff}`)
const text = git('show', `HEAD:${signoff}`)
const candidate = text.match(/^- Candidate commit: ([0-9a-f]{40})$/mi)?.[1]
if (!candidate) throw new Error(`${signoff} must contain a full candidate commit SHA`)
git('cat-file', '-e', `${candidate}^{commit}`)
const parent = git('rev-parse', 'HEAD^')
const changed = git('diff', '--name-only', 'HEAD^', 'HEAD').split('\n').filter(Boolean)
if (changed.length !== 1 || changed[0] !== signoff) {
throw new Error(`HEAD must contain only the sign-off file change: ${signoff}`)
}
if (candidate.toLowerCase() !== parent.toLowerCase()) {
throw new Error(`${signoff} candidate must equal HEAD^ (${parent})`)
}
const fields = [
['Version', version],
['Reviewed by', '.+'],
['Reviewed on', '\\d{4}-\\d{2}-\\d{2}'],
['Decision', 'approved'],
]
for (const [name, value] of fields) {
if (!new RegExp(`^- ${name}: ${value}$`, 'm').test(text)) {
throw new Error(`${signoff} must contain "- ${name}: ${value}"`)
}
}
console.log(`Release-fidelity evidence and committed owner approval verified for ${version} at ${candidate}.`)
} catch (error) {
console.error(`Release-fidelity gate blocked: ${error.message}`)
process.exitCode = 1
}

View File

@@ -0,0 +1,80 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import test from 'node:test'
const verifier = resolve('scripts/release-fidelity/verify.mjs')
function git(directory, ...args) {
return execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim()
}
function verify(root, evidence) {
return execFileSync(process.execPath, [verifier, '--version', 'v-test', '--evidence', evidence], {
cwd: root,
encoding: 'utf8',
})
}
async function approvedCandidate({ changeCodeWithSignoff = false, decision = 'approved' } = {}) {
const root = await mkdtemp(join(tmpdir(), 'boxbox-fidelity-verify-'))
git(root, 'init')
git(root, 'config', 'user.email', 'test@example.com')
git(root, 'config', 'user.name', 'Test')
await writeFile(join(root, 'candidate.txt'), 'candidate')
git(root, 'add', 'candidate.txt')
git(root, 'commit', '-m', 'candidate')
const candidate = git(root, 'rev-parse', 'HEAD')
await mkdir(join(root, 'docs/release/owner-reviews'), { recursive: true })
await writeFile(join(root, 'docs/release/owner-reviews/v-test.md'), `- Version: v-test\n- Candidate commit: ${candidate}\n- Reviewed by: Owner\n- Reviewed on: 2026-07-30\n- Decision: ${decision}\n`)
if (changeCodeWithSignoff) await writeFile(join(root, 'candidate.txt'), 'changed with approval')
git(root, 'add', 'docs/release/owner-reviews/v-test.md')
if (changeCodeWithSignoff) git(root, 'add', 'candidate.txt')
git(root, 'commit', '-m', 'owner sign-off')
const evidence = join(root, 'evidence')
await mkdir(evidence)
await writeFile(join(evidence, 'index.html'), '')
await writeFile(join(evidence, 'summary.md'), '')
return { candidate, evidence, root }
}
function assertBlocked(root, evidence) {
assert.throws(() => verify(root, evidence), (error) => {
assert.match(String(error.stderr), /HEAD must contain only the sign-off file change/)
return true
})
}
test('accepts a sign-off-only HEAD for its direct parent candidate', async () => {
const { candidate, evidence, root } = await approvedCandidate()
const output = verify(root, evidence)
assert.match(output, new RegExp(candidate))
})
test('rejects code committed after approval', async () => {
const { evidence, root } = await approvedCandidate()
await writeFile(join(root, 'candidate.txt'), 'changed after review')
git(root, 'add', 'candidate.txt')
git(root, 'commit', '-m', 'code after approval')
assertBlocked(root, evidence)
})
test('rejects a sign-off commit that also changes code', async () => {
const { evidence, root } = await approvedCandidate({ changeCodeWithSignoff: true })
assertBlocked(root, evidence)
})
test('rejects a dirty working-tree edit that spoofs approval', async () => {
const { candidate, evidence, root } = await approvedCandidate({ decision: 'rejected' })
await writeFile(join(root, 'docs/release/owner-reviews/v-test.md'), `- Version: v-test\n- Candidate commit: ${candidate}\n- Reviewed by: Owner\n- Reviewed on: 2026-07-30\n- Decision: approved\n`)
assert.throws(() => verify(root, evidence), (error) => {
assert.match(String(error.stderr), /Decision: approved/)
return true
})
})

View File

@@ -84,20 +84,14 @@ func seedMeeting(st *store.Store, meetingKey int) error {
} }
func seedSession(st *store.Store, sessionKey, meetingKey int, name string) error { func seedSession(st *store.Store, sessionKey, meetingKey int, name string) error {
start, end := "2025-05-25T13:00:00+00:00", "2025-05-25T15:00:00+00:00"
// Core-only is an earlier weekend session so Weekend Context's
// default_analysis_session prefers the later full Race.
if name == "Core Only" {
start, end = "2025-05-24T13:00:00+00:00", "2025-05-24T15:00:00+00:00"
}
return st.UpsertSession(store.Session{ return st.UpsertSession(store.Session{
SessionKey: sessionKey, SessionKey: sessionKey,
MeetingKey: meetingKey, MeetingKey: meetingKey,
SessionName: name, SessionName: name,
SessionType: "Race", SessionType: "Race",
CircuitKey: 10, CircuitKey: 10,
DateStart: start, DateStart: "2025-05-25T13:00:00+00:00",
DateEnd: end, DateEnd: "2025-05-25T15:00:00+00:00",
}) })
} }

View File

@@ -0,0 +1,101 @@
import { test, expect } from '@playwright/test'
const FULL_SESSION = 9472
test.describe('Command Center', () => {
test('loads as default route with weekend identity band', async ({ page }) => {
await page.goto('/')
await expect(page.getByTestId('command-center')).toBeVisible()
await expect(page.getByTestId('cc-focus')).toBeVisible()
await expect(page.getByTestId('cc-session-9472')).toBeVisible()
await expect(page.getByTestId('hero-last-race-link')).toBeVisible()
})
test('falls back to the local calendar when season metadata fails', async ({ page }) => {
await page.route(/\/api\/v1\/meetings(?:\?.*)?$/, async (route) => {
const url = new URL(route.request().url())
if (url.searchParams.get('year') !== '2025' || url.searchParams.get('source') !== 'openf1') {
await route.continue()
return
}
await route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: 'season calendar unavailable' }),
})
})
await page.goto('/')
await expect(page.getByTestId('command-center')).toBeVisible()
await expect(page.getByTestId('cc-calendar-1229')).toBeVisible()
await expect(
page.getByText('Using local meetings because the full calendar could not load.'),
).toBeVisible({ timeout: 15_000 })
})
test('nav link reaches command center from race hub', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await page.getByRole('link', { name: 'Command' }).click()
await expect(page).toHaveURL('/')
await expect(page.getByTestId('command-center')).toBeVisible()
})
test('open analysis action opens race hub for the focus session', async ({ page }) => {
await page.goto('/')
await expect(page.getByTestId('hero-last-race-link')).toBeVisible()
await page.getByTestId('hero-last-race-link').click()
await expect(page).toHaveURL(new RegExp(`/race-hub\\?session_key=${FULL_SESSION}`))
await expect(page.getByTestId('rh-identity')).toBeVisible()
await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible()
})
test('archived live snapshot does not mark command center live', async ({ page }) => {
await page.route('**/api/v1/live/state', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
is_live: false,
data: null,
last_snapshot: {
Drivers: { '1': { RacingNumber: '1', Position: 1 } },
DriverInfo: { '1': { RacingNumber: '1', Tla: 'VER', TeamColour: '3671C6' } },
Tyres: {},
RCMessages: [],
Weather: {},
Session: { MeetingName: 'Archived GP', SessionName: 'Race', SessionType: 'Race' },
TeamRadio: [],
SessionStatus: 'Finished',
TrackStatus: '1',
CurrentLap: 57,
TotalLaps: 57,
Clock: '',
ClockRefTime: '',
ClockExtrapolating: false,
Stints: {},
},
last_snapshot_at: '2026-07-04T14:00:00Z',
}),
}),
)
await page.goto('/')
await expect(page.getByTestId('command-center')).toBeVisible()
await expect(page.getByTestId('cc-live-status')).toContainText('No live session')
await expect(page.getByTestId('cc-live-status')).not.toContainText('Live session active')
})
test('existing routes continue to work', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await expect(page.getByTestId('race-hub')).toBeVisible()
await expect(page.getByTestId('rh-overview')).toBeVisible()
await page.goto('/admin')
await expect(page.getByTestId('data-library')).toBeVisible()
await page.goto('/live')
await expect(page.getByTestId('live-empty')).toBeVisible()
})
})

View File

@@ -1,78 +0,0 @@
import type { Page } from '@playwright/test'
/** Isolated future-session key — not present in the shared e2e seed DB. */
export const FUTURE_SESSION = 9600
export const FUTURE_MEETING = 1300
const emptyDatasets = {}
const futureMeeting = {
meeting_key: FUTURE_MEETING,
meeting_name: 'Future Grand Prix',
meeting_official_name: 'FORMULA 1 FUTURE GRAND PRIX 2099',
location: 'Futureville',
country_name: 'Testland',
country_code: 'TST',
country_flag: '',
circuit_short_name: 'Future',
date_start: '2099-05-23T00:00:00+00:00',
date_end: '2099-05-25T00:00:00+00:00',
year: 2099,
}
const futureSession = {
session_key: FUTURE_SESSION,
session_name: 'Race',
session_type: 'Race',
meeting_key: FUTURE_MEETING,
date_start: '2099-05-25T13:00:00+00:00',
date_end: '2099-05-25T15:00:00+00:00',
gmt_offset: '00:00:00',
}
const futureRaceHub = {
source: 'none',
session_key: FUTURE_SESSION,
datasets: emptyDatasets,
meeting: futureMeeting,
session: futureSession,
drivers: [],
results: [],
starting_grid: [],
stints: [],
pit_stops: [],
positions: [],
race_control: [],
weather: [],
laps: [],
chapters: [],
}
const futureWeekend = {
source: 'none',
meeting_key: FUTURE_MEETING,
meeting: futureMeeting,
default_session_key: FUTURE_SESSION,
sessions: [{ session: futureSession, source: 'none', datasets: emptyDatasets }],
}
/**
* Route-mock a far-future session without contaminating the shared Monaco seed
* (which would rewrite Command Center / Data Library baselines).
*/
export async function mockFutureRaceHubSession(page: Page): Promise<void> {
await page.route(`**/api/v1/race-hub?session_key=${FUTURE_SESSION}`, async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(futureRaceHub),
})
})
await page.route(`**/api/v1/weekend?meeting_key=${FUTURE_MEETING}`, async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(futureWeekend),
})
})
}

View File

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

View File

@@ -93,7 +93,6 @@ const raceSnapshot = {
SessionType: 'Race', SessionType: 'Race',
SessionName: 'Race', SessionName: 'Race',
}, },
SessionStatus: 'Started',
TrackStatus: '2', TrackStatus: '2',
CurrentLap: 30, CurrentLap: 30,
TotalLaps: 57, TotalLaps: 57,
@@ -139,7 +138,6 @@ const sprintQualifyingSnapshot = {
SessionType: 'Sprint Qualifying', SessionType: 'Sprint Qualifying',
SessionName: 'Sprint Qualifying', SessionName: 'Sprint Qualifying',
}, },
SessionStatus: 'Started',
TrackStatus: '1', TrackStatus: '1',
CurrentLap: 0, CurrentLap: 0,
TotalLaps: 0, TotalLaps: 0,
@@ -149,6 +147,58 @@ const sprintQualifyingSnapshot = {
}, },
} }
// Practice: the feed sets best laps but no GapToLeader/Interval, and there is
// no race lap-total concept. Exercises computed gaps, the non-race banner, the
// collapsed-by-default tyre panel, and the 390px core-field layout.
const practiceSnapshot = {
is_live: true,
data: {
...raceSnapshot.data,
Drivers: Object.fromEntries(
Array.from({ length: 10 }, (_, index) => {
const num = String(index + 1)
return [
num,
driver(num, index + 1, '', '', {
// Distinct, increasing best laps; P1 fastest, +0.200s per position.
BestLapTime: `1:${(45.9 + index * 0.2).toFixed(3).padStart(6, '0')}`,
LastLapTime: '1:46.500',
NumberOfLaps: 12,
Sectors: index === 0
? [
{ Value: '28.500', PersonalFastest: true, OverallFastest: false },
{ Value: '52.100', PersonalFastest: false, OverallFastest: false },
{ Value: '25.344', PersonalFastest: false, OverallFastest: false },
]
: [],
}),
]
}),
),
DriverInfo: Object.fromEntries(
Array.from({ length: 10 }, (_, index) => {
const num = String(index + 1)
return [num, info(num, `D${index + 1}`, 'Driver', String(index + 1), 'Test Team', index % 2 ? 'FF8000' : '27F4D2')]
}),
),
Tyres: Object.fromEntries(
Array.from({ length: 10 }, (_, index) => [String(index + 1), { Compound: 'SOFT', New: false, Age: index % 4 }]),
),
Session: {
MeetingName: 'Belgian Grand Prix',
CircuitName: 'Spa-Francorchamps',
SessionType: 'Practice',
SessionName: 'Practice 2',
},
TrackStatus: '1',
CurrentLap: 0,
TotalLaps: 0,
Clock: '00:45:00',
ClockRefTime: '2026-07-17T13:00:00Z',
ClockExtrapolating: false,
},
}
test.describe('Live Timing (mocked snapshot)', () => { test.describe('Live Timing (mocked snapshot)', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.route('**/api/v1/live/state', (route) => await page.route('**/api/v1/live/state', (route) =>
@@ -166,12 +216,6 @@ test.describe('Live Timing (mocked snapshot)', () => {
test('renders timing tower with drivers from the snapshot', async ({ page }) => { test('renders timing tower with drivers from the snapshot', async ({ page }) => {
await expect(page.getByTestId('live-page')).toBeVisible() await expect(page.getByTestId('live-page')).toBeVisible()
// The hermetic SSE mock ends after a heartbeat, so transport may drop to
// disconnected while the session snapshot remains active — both phases keep
// the LIVE SESSION chrome and timing tower.
await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', /^(live|disconnected)$/)
await expect(page.getByTestId('live-session-flag')).toContainText('LIVE SESSION')
await expect(page.getByTestId('live-feed-health')).toBeVisible()
const tower = page.locator('.live-tower') const tower = page.locator('.live-tower')
await expect(tower).toBeVisible() await expect(tower).toBeVisible()
await expect(tower).toContainText('VER') await expect(tower).toContainText('VER')
@@ -241,266 +285,133 @@ test.describe('Live Timing (mocked Sprint Qualifying)', () => {
}) })
}) })
const weekendContext = (localAnalysis: string) => ({ test.describe('Live Timing (mocked Practice)', () => {
temporal_state: 'session_settling', test.beforeEach(async ({ page }) => {
focus_meeting: { await page.route('**/api/v1/live/state', (route) =>
meeting_key: 1, route.fulfill({ contentType: 'application/json', body: JSON.stringify(practiceSnapshot) }),
meeting_name: 'Testonia Grand Prix', )
meeting_official_name: 'Testonia Grand Prix', await page.route('**/api/v1/live/stream', (route) =>
location: 'Testring', route.fulfill({ contentType: 'text/event-stream', body: 'event: heartbeat\ndata: {}\n\n' }),
country_name: 'Testonia', )
country_code: 'TS', await page.goto('/live')
country_flag: '', })
circuit_short_name: 'Testring',
date_start: '2026-07-03T09:00:00Z', test('derives a gap to P1 from best laps and marks the leader', async ({ page }) => {
date_end: '2026-07-05T16:00:00Z', const tower = page.locator('.live-tower')
year: 2026, await expect(tower).toBeVisible()
}, // P1 shows a leader marker, not a fabricated gap.
// Just-finished session lives in previous_completed_session (canonical contract). await expect(tower.locator('tbody tr').first()).toContainText('—')
previous_completed_session: { // P2 shows the computed +0.200 delta.
session: { await expect(tower).toContainText('+0.200')
session_key: 9472, })
session_name: 'Race',
session_type: 'Race', test('omits the race lap counter and keeps the session clock for practice', async ({ page }) => {
meeting_key: 1, await expect(page.getByTestId('live-lap-counter')).toHaveCount(0)
date_start: '2026-07-05T14:00:00Z', await expect(page.getByTestId('live-clock')).toContainText('00:45:00')
date_end: '2026-07-05T16:00:00Z', await expect(page.locator('.live-banner')).not.toContainText('L-/-')
gmt_offset: '', })
},
availability: { test('collapses the tyre panel by default so the tower is above the fold', async ({ page }) => {
schedule: 'available', await expect(page.getByTestId('tyredeg-panel')).toBeVisible()
live_transport: 'unknown', await expect(page.getByTestId('tyredeg-row')).toHaveCount(0)
live_session: 'inactive', await expect(page.locator('.live-tower')).toBeVisible()
archive: 'available', })
local_analysis: localAnalysis,
freshness: 'fresh', test('labels Race Control times as UTC', async ({ page }) => {
limitations: [], await expect(page.getByTestId('rc-timezone')).toContainText('UTC')
}, })
},
championship_round: 1, test('marks Live as active in the primary navigation', async ({ page }) => {
total_championship_rounds: 1, await expect(page.getByTestId('nav-live')).toHaveAttribute('data-live-active', 'true')
await expect(page.getByTestId('nav-live').locator('.nav-live-dot')).toBeVisible()
})
}) })
/** Archive-only just-finished Race + older already-ready Practice default. */ test.describe('Live Timing (390px practice viewport)', () => {
const archiveOnlySettlingContext = (previousAnalysis: string) => ({ test.use({ viewport: { width: 390, height: 844 } })
...weekendContext(previousAnalysis),
default_analysis_session: {
session: {
session_key: 9001,
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: [],
},
},
})
const heartbeatStream = (route: import('@playwright/test').Route) => test.beforeEach(async ({ page }) => {
route.fulfill({ contentType: 'text/event-stream', body: 'event: heartbeat\ndata: {}\n\n' }) await page.route('**/api/v1/live/state', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(practiceSnapshot) }),
)
await page.route('**/api/v1/live/stream', (route) =>
route.fulfill({ contentType: 'text/event-stream', body: 'event: heartbeat\ndata: {}\n\n' }),
)
await page.goto('/live')
})
test('shows the five core fields with no horizontal overflow', async ({ page }) => {
const tower = page.locator('.live-tower')
await expect(tower).toBeVisible()
// Core comparison fields are present in the header.
await expect(tower.locator('thead')).toContainText('Pos')
await expect(tower.locator('thead')).toContainText('Driver')
await expect(tower.locator('thead')).toContainText('Tyre')
await expect(tower.locator('thead')).toContainText('Best')
await expect(tower.locator('thead')).toContainText('Gap to P1')
// Sectors are hidden from the initial mobile tower (reachable via expand).
const firstSector = tower.locator('thead th', { hasText: 'S1' })
await expect(firstSector).toBeHidden()
// The document must not scroll horizontally.
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
)
expect(overflow).toBeLessThanOrEqual(0)
})
test('exposes sectors through the row detail interaction', async ({ page }) => {
// Sector columns are not in the initial mobile tower...
await expect(page.locator('.live-tower thead th', { hasText: 'S1' })).toBeHidden()
// ...but the P1 row's sectors are reachable by expanding the row.
await page.locator('.live-tower tbody tr', { hasText: 'D1' }).first().click()
const sectors = page.getByTestId('expanded-sectors')
await expect(sectors).toBeVisible()
await expect(sectors).toContainText('28.500')
})
})
test.describe('Live Timing (no session)', () => { test.describe('Live Timing (no session)', () => {
test('shows the inactive weekend-context handoff when the feed has no snapshot', async ({ page }) => { test('shows the empty state when the feed has no snapshot', async ({ page }) => {
await page.route('**/api/v1/live/stream', heartbeatStream)
await page.goto('/live') await page.goto('/live')
// The e2e server (BOXBOX_DISABLE_LIVE=1) exposes a real /api/v1/weekend-context. await expect(page.getByTestId('live-empty')).toBeVisible()
await expect(page.getByTestId('live-inactive')).toBeVisible() await expect(page.getByTestId('live-page')).toContainText('No live session active')
await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', 'inactive')
await expect(page.getByText('NO LIVE SESSION')).toBeVisible()
}) })
test('enters settling (not archive) when a finished session is retained', async ({ page }) => { test('renders an archived snapshot only after View Last Session', async ({ page }) => {
await page.route('**/api/v1/live/state', (route) => await page.route('**/api/v1/live/state', (route) =>
route.fulfill({ route.fulfill({
contentType: 'application/json', contentType: 'application/json',
body: JSON.stringify({ body: JSON.stringify({
is_live: false, is_live: false,
data: null, data: null,
last_snapshot: { ...raceSnapshot.data, SessionStatus: 'Finished' }, last_snapshot: {
last_positions: { '1': { x: 100, y: -50, z: 2, status: 'OnTrack' } }, ...raceSnapshot.data,
SessionStatus: 'Finished',
},
last_positions: {
'1': { x: 100, y: -50, z: 2, status: 'OnTrack' },
},
last_snapshot_at: '2026-07-04T14:00:00Z', last_snapshot_at: '2026-07-04T14:00:00Z',
}), }),
}), }),
) )
await page.route('**/api/v1/weekend-context', (route) => await page.route('**/api/v1/live/stream', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(weekendContext('complete')) }), route.fulfill({
contentType: 'text/event-stream',
body: 'event: heartbeat\ndata: {}\n\n',
}),
) )
await page.route('**/api/v1/live/stream', heartbeatStream)
await page.goto('/live') await page.goto('/live')
await expect(page.getByTestId('live-settling')).toBeVisible() await expect(page.getByTestId('live-empty')).toContainText('No live session active')
await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', 'settling')
await expect(page.getByText('SESSION SETTLING')).toBeVisible()
await expect(page.getByText('Timing Tower')).toHaveCount(0) await expect(page.getByText('Timing Tower')).toHaveCount(0)
// Canonical weekend-context drives the analysis target + readiness.
const analysis = page.getByTestId('live-handoff-analysis')
await expect(analysis).toHaveAttribute('href', '/race-hub?session_key=9472')
await expect(analysis).toHaveAttribute('data-ready', 'true')
// Opening the read-only archive reveals the frozen tower, never LIVE chrome. await page.getByRole('button', { name: 'View Last Session' }).click()
await page.getByTestId('live-handoff-archive').click() await expect(page.getByTestId('live-archive-strip')).toContainText('Archived snapshot')
await expect(page.getByTestId('live-archive-strip')).toContainText('read-only')
await expect(page.getByText('Timing Tower')).toBeVisible() await expect(page.getByText('Timing Tower')).toBeVisible()
await expect(page.locator('.live-state')).toContainText('archive') await expect(page.locator('.live-state')).toContainText('archive')
await expect(page.getByText('LIVE SESSION')).toHaveCount(0)
})
test('settling shows pending analysis until weekend-context reports complete', async ({ page }) => {
await page.route('**/api/v1/live/state', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
is_live: false,
data: null,
last_snapshot: { ...raceSnapshot.data, SessionStatus: 'Finished' },
last_snapshot_at: '2026-07-04T14:00:00Z',
}),
}),
)
await page.route('**/api/v1/weekend-context', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(weekendContext('pending')) }),
)
await page.route('**/api/v1/live/stream', heartbeatStream)
await page.goto('/live')
await expect(page.getByTestId('live-settling')).toBeVisible()
const analysis = page.getByTestId('live-handoff-analysis')
await expect(analysis).toHaveAttribute('data-ready', 'false')
await expect(analysis).toContainText(/analysis will fill in as data ingests/i)
})
test('settling targets archive-only previous_completed_session over older default', async ({ page }) => {
await page.route('**/api/v1/live/state', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
is_live: false,
data: null,
last_snapshot: { ...raceSnapshot.data, SessionStatus: 'Finished' },
last_snapshot_at: '2026-07-04T14:00:00Z',
}),
}),
)
await page.route('**/api/v1/weekend-context', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify(archiveOnlySettlingContext('pending')),
}),
)
await page.route('**/api/v1/live/stream', heartbeatStream)
await page.goto('/live')
await expect(page.getByTestId('live-settling')).toBeVisible()
const analysis = page.getByTestId('live-handoff-analysis')
await expect(analysis).toHaveAttribute('href', '/race-hub?session_key=9472')
await expect(analysis).toHaveAttribute('data-ready', 'false')
await expect(analysis).toContainText('Open Race analysis')
await expect(analysis).not.toContainText('Practice 1')
})
test('retains the last live snapshot with a disconnected warning on a feed drop', async ({ page }) => {
// is_live=false but SessionStatus is still "Started": the FIA feed dropped
// mid-session. The page must warn + retain the live tower, never settle.
// Keep the browser SSE open via a long-lived stream so transport stays connected
// while phase is disconnected — health must still say Reconnecting.
await page.route('**/api/v1/live/state', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
is_live: false,
data: null,
last_snapshot: { ...raceSnapshot.data, SessionStatus: 'Started' },
last_snapshot_at: '2026-07-04T13:30:00Z',
}),
}),
)
await page.route('**/api/v1/weekend-context', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(weekendContext('pending')) }),
)
await page.route('**/api/v1/live/stream', async (route) => {
const body = [
'event: heartbeat',
'data: {}',
'',
'event: heartbeat',
'data: {}',
'',
].join('\n')
await route.fulfill({
contentType: 'text/event-stream',
headers: { 'Cache-Control': 'no-cache' },
body,
})
})
await page.goto('/live')
await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', 'disconnected')
await expect(page.getByTestId('live-disconnected-strip')).toContainText('last live data')
await expect(page.getByText('Timing Tower')).toBeVisible()
await expect(page.getByTestId('live-settling')).toHaveCount(0)
await expect(page.getByTestId('live-archive-strip')).toHaveCount(0)
await expect(page.getByTestId('live-feed-health')).toContainText(/reconnecting/i)
await expect(page.getByTestId('live-feed-health')).not.toContainText(/feed healthy/i)
})
test('clears fatal initial error once SSE supplies a live snapshot', async ({ page }) => {
await page.route('**/api/v1/live/state', (route) =>
route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: 'API 503: live state unavailable' }),
}),
)
await page.route('**/api/v1/live/stream', (route) =>
route.fulfill({
contentType: 'text/event-stream',
headers: { 'Cache-Control': 'no-cache' },
body: `event: snapshot\ndata: ${JSON.stringify(raceSnapshot)}\n\n`,
}),
)
await page.goto('/live')
await expect(page.getByText('Timing Tower')).toBeVisible({ timeout: 15_000 })
await expect(page.getByTestId('live-initial-error')).toHaveCount(0)
await expect(page.getByText(/Live timing unavailable/i)).toHaveCount(0)
await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', /^(live|disconnected)$/)
await expect(page.locator('.live-tower')).toContainText('VER')
})
test('clears fatal initial error once SSE supplies an inactive null state', async ({ page }) => {
await page.route('**/api/v1/live/state', (route) =>
route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: 'API 503: live state unavailable' }),
}),
)
await page.route('**/api/v1/live/stream', (route) =>
route.fulfill({
contentType: 'text/event-stream',
headers: { 'Cache-Control': 'no-cache' },
body: `event: snapshot\ndata: ${JSON.stringify({
is_live: false,
data: null,
last_snapshot: null,
})}\n\n`,
}),
)
await page.goto('/live')
await expect(page.getByTestId('live-inactive')).toBeVisible({ timeout: 15_000 })
await expect(page.getByTestId('live-initial-error')).toHaveCount(0)
await expect(page.getByText(/Live timing unavailable/i)).toHaveCount(0)
await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', 'inactive')
}) })
}) })

View File

@@ -3,11 +3,11 @@ import { test, expect } from '@playwright/test'
const FULL_SESSION = 9472 const FULL_SESSION = 9472
test.describe('Production serving (Go + built React)', () => { test.describe('Production serving (Go + built React)', () => {
test('serves the Weekend home as default route', async ({ page }) => { test('serves command center as default route', async ({ page }) => {
await page.goto('/') await page.goto('/')
await expect(page.getByTestId('weekend-page')).toBeVisible() await expect(page.getByTestId('command-center')).toBeVisible()
await expect(page.getByTestId('weekend-between-races')).toBeVisible() await expect(page.getByTestId('cc-session-9472')).toBeVisible()
}) })
test('serves race hub workspace from built assets', async ({ page }) => { test('serves race hub workspace from built assets', async ({ page }) => {
@@ -18,15 +18,8 @@ test.describe('Production serving (Go + built React)', () => {
await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible() await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible()
await page.getByRole('tab', { name: 'Race Story' }).click() await page.getByRole('tab', { name: 'Race Story' }).click()
await expect(page.getByText('Final Classification')).toBeVisible()
const outcome = page.getByRole('region', { name: 'Final running order' }) await expect(page.locator('.drv-code', { hasText: 'VER' })).toBeVisible()
await expect(outcome).toBeVisible()
await expect(outcome).toHaveAttribute('data-testid', 'race-story-outcome')
const verRow = page.locator('.rs-driver-row', {
has: page.locator('.rs-driver-name', { hasText: 'VER' }),
})
await expect(verRow.locator('.rs-pos-col')).toHaveText('1')
}) })
test('serves admin / data health route', async ({ page }) => { test('serves admin / data health route', async ({ page }) => {
@@ -44,28 +37,23 @@ test.describe('Production serving (Go + built React)', () => {
await expect(page.getByTestId('dl-meeting-1229')).toBeVisible() await expect(page.getByTestId('dl-meeting-1229')).toBeVisible()
}) })
test('serves live route with the inactive handoff when live is disabled', async ({ page }) => { test('serves live route with empty state when live is disabled', async ({ page }) => {
await page.goto('/live') await page.goto('/live')
await expect(page.getByTestId('live-inactive')).toBeVisible() await expect(page.getByTestId('live-empty')).toBeVisible()
await expect(page.getByText('NO LIVE SESSION')).toBeVisible() await expect(page.getByText('No live session active')).toBeVisible()
}) })
test('primary nav links and Admin utility work from built SPA', async ({ page }) => { test('nav links work from built SPA', async ({ page }) => {
await page.goto('/') await page.goto('/')
const primary = page.getByRole('navigation', { name: 'Primary' }).first() await page.locator('.app-nav').getByRole('link', { name: 'Race Hub' }).click()
await expect(page).toHaveURL(/\/race-hub/)
await primary.getByRole('link', { name: 'Championship', exact: true }).click() await page.locator('.app-nav').getByRole('link', { name: 'Admin', exact: true }).click()
await expect(page).toHaveURL(/\/championship/)
await primary.getByRole('link', { name: 'Explore', exact: true }).click()
await expect(page).toHaveURL(/\/explore/)
await primary.getByRole('link', { name: 'Weekend', exact: true }).click()
await expect(page).toHaveURL('/')
// Admin is an operator utility outside the Primary landmark.
await page.getByRole('toolbar', { name: 'Operator utilities' }).getByRole('link', { name: 'Admin' }).click()
await expect(page).toHaveURL(/\/admin/) await expect(page).toHaveURL(/\/admin/)
await page.getByRole('link', { name: 'Live', exact: true }).click()
await expect(page).toHaveURL(/\/live/)
await expect(page.getByTestId('live-empty')).toBeVisible()
}) })
}) })

View File

@@ -1,8 +1,43 @@
import { test, expect } from '@playwright/test' import { test, expect } from '@playwright/test'
import { FUTURE_SESSION, mockFutureRaceHubSession } from './fixtures/future-session'
const FULL_SESSION = 9472 const FULL_SESSION = 9472
const CORE_ONLY_SESSION = 9000 const CORE_ONLY_SESSION = 9000
const CONTEXT_MEETING = {
meeting_key: 1229,
meeting_name: 'Monaco',
country_code: 'MON',
}
const CONTEXT_AVAILABILITY = {
source: 'local', schedule: 'available', live_transport: 'unknown', live_session: 'inactive',
archive: 'unavailable', local_analysis: 'complete', freshness: 'local', limitations: [],
}
function completedContext(refreshAt?: string) {
return {
temporal_state: 'between_weekends',
race_hub_default_session: {
session: { session_key: FULL_SESSION }, meeting: CONTEXT_MEETING, availability: CONTEXT_AVAILABILITY,
},
race_hub_pre_session: false,
race_hub_refresh_at: refreshAt,
}
}
function pendingContext(refreshAt: string) {
return {
temporal_state: 'pre_session',
race_hub_default_session: {
session: {
session_key: 9473, meeting_key: 1229, session_name: 'Practice 1', session_type: 'Practice',
date_start: '2030-01-01T00:00:01Z', date_end: '2030-01-01T01:00:01Z', gmt_offset: '00:00:00',
},
meeting: CONTEXT_MEETING,
availability: { ...CONTEXT_AVAILABILITY, local_analysis: 'not_applicable' },
},
race_hub_pre_session: true,
race_hub_refresh_at: refreshAt,
}
}
test.describe('Race Hub Weekend Workspace', () => { test.describe('Race Hub Weekend Workspace', () => {
test('lands on the Overview tab with workspace identity', async ({ page }) => { test('lands on the Overview tab with workspace identity', async ({ page }) => {
@@ -16,7 +51,6 @@ test.describe('Race Hub Weekend Workspace', () => {
'aria-selected', 'aria-selected',
'true', 'true',
) )
await expect(page.getByText('Local Coverage')).toHaveCount(0)
}) })
test('shows final running order when switching to Race Story', async ({ page }) => { test('shows final running order when switching to Race Story', async ({ page }) => {
@@ -89,110 +123,102 @@ test.describe('Race Hub Weekend Workspace', () => {
await page.getByTestId('rh-switch-weekend').click() await page.getByTestId('rh-switch-weekend').click()
await expect(page.getByTestId('rh-switcher')).toBeVisible() await expect(page.getByTestId('rh-switcher')).toBeVisible()
// Active session is already loaded; just confirm a session button is reachable
await expect(page.getByTestId(`rh-switcher-session-${FULL_SESSION}`)).toBeVisible() await expect(page.getByTestId(`rh-switcher-session-${FULL_SESSION}`)).toBeVisible()
}) })
test('Diagnostics is a secondary action and points at admin, not inline CLI hints', async ({ page }) => { test('Data Status tab points at admin instead of inline CLI hints', async ({ page }) => {
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`) await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
await page.getByRole('tab', { name: 'Diagnostics' }).click() await page.getByRole('tab', { name: 'Data Status' }).click()
await expect(page.getByTestId('rh-data-status')).toBeVisible() await expect(page.getByTestId('rh-data-status')).toBeVisible()
await expect(page.getByRole('link', { name: /manage ingestion/i })).toHaveAttribute( await expect(page.getByRole('link', { name: /manage ingestion/i })).toHaveAttribute(
'href', 'href',
'/admin', '/admin',
) )
await expect(page.getByTestId('rh-dataset-strip')).toHaveCount(0)
await page.getByTestId('rh-diagnostics-toggle').click()
await expect(page.getByTestId('rh-dataset-strip')).toBeVisible()
}) })
test('groups analysis navigation into Story, Analysis, and Data & Context', async ({ page }) => { test('bare /race-hub shows server-selected completed analysis without changing the URL', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`) await page.route('**/api/v1/weekend-context', (route) =>
await expect(page.getByTestId('rh-tabgroup-story')).toBeVisible() route.fulfill({ contentType: 'application/json', body: JSON.stringify(completedContext()) }),
await expect(page.getByTestId('rh-tabgroup-analysis')).toBeVisible()
await expect(page.getByTestId('rh-tabgroup-context')).toBeVisible()
})
test('bare /race-hub resolves to a completed session via Weekend Context', async ({ page }) => {
await page.goto('/race-hub')
await expect(page).toHaveURL(/session_key=\d+/)
await expect(page.getByTestId('race-hub')).toBeVisible()
await expect(page).toHaveURL(new RegExp(`session_key=${FULL_SESSION}`))
await expect(page.getByTestId('rh-identity')).toContainText('Monaco')
})
test('explicit completed session deep link stays stable and shows analysis', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await expect(page).toHaveURL(new RegExp(`session_key=${FULL_SESSION}`))
await expect(page.getByTestId('rh-overview')).toBeVisible()
})
test('explicit future session renders the pre-session view, not empty analysis', async ({
page,
}) => {
await mockFutureRaceHubSession(page)
await page.goto(`/race-hub?session_key=${FUTURE_SESSION}`)
await expect(page.getByTestId('race-hub')).toBeVisible()
await expect(page.getByTestId('rh-presession')).toBeVisible()
await expect(page.getByTestId('rh-overview')).toHaveCount(0)
})
test('returning to Weekend from analysis preserves meeting and session context', async ({
page,
}) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await expect(page.getByTestId('rh-identity')).toContainText('Monaco')
await page.getByRole('tab', { name: 'Strategy' }).click()
await expect(page.locator('[data-testid="strategy-chart"]')).toBeVisible()
// Navigate to the sibling core-only session within the same weekend.
await page.getByTestId(`rh-session-${CORE_ONLY_SESSION}`).click()
await expect(page).toHaveURL(new RegExp(`session_key=${CORE_ONLY_SESSION}`))
await expect(page.getByTestId('rh-identity')).toContainText('Monaco')
// Explicit Race Hub → Weekend return carries meeting/session in the URL.
const back = page.getByRole('link', { name: 'Back to Weekend' })
await expect(back).toBeVisible()
await expect(back).toHaveAttribute('href', /meeting_key=1229/)
await expect(back).toHaveAttribute('href', /session_key=9000/)
await back.click()
await expect(page).toHaveURL(/\/\?.*meeting_key=1229/)
await expect(page).toHaveURL(/session_key=9000/)
await expect(page.getByTestId('weekend-page')).toBeVisible()
await expect(page.getByTestId('weekend-page')).toHaveAttribute('data-meeting-key', '1229')
await expect(page.getByTestId('weekend-page')).toHaveAttribute('data-session-key', '9000')
await expect(page.getByTestId('wk-focus-context')).toBeVisible()
await expect(page.getByTestId('wk-focus-meeting')).toContainText('Monaco')
await expect(page.getByTestId('wk-focus-session')).toContainText('Core Only')
// Contextual action returns to the exact selected analysis session.
const continueAnalysis = page.getByRole('link', { name: /Continue analysis/i })
await expect(continueAnalysis).toHaveAttribute(
'href',
new RegExp(`session_key=${CORE_ONLY_SESSION}`),
) )
await continueAnalysis.click() await page.goto('/race-hub')
await expect(page).toHaveURL(new RegExp(`session_key=${CORE_ONLY_SESSION}`)) await expect(page).toHaveURL(/\/race-hub$/)
await expect(page.getByTestId('race-hub')).toBeVisible() await expect(page.getByTestId('race-hub')).toBeVisible()
await expect(page.getByTestId('rh-identity')).toContainText('Monaco')
}) })
test('Back to Weekend is keyboard-focusable and works at mobile width', async ({ page }) => { test('bare /race-hub hands off to pending pre-session state at the refresh deadline', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 }) await page.clock.install({ time: new Date('2030-01-01T00:00:00Z') })
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`) let requests = 0
const raceHubRequests: number[] = []
page.on('request', (request) => {
const url = new URL(request.url())
if (url.pathname === '/api/v1/race-hub') {
raceHubRequests.push(Number(url.searchParams.get('session_key')))
}
})
await page.route('**/api/v1/weekend-context', (route) => {
requests += 1
const body = requests === 1
? completedContext('2030-01-01T00:00:01Z')
: pendingContext('2030-01-01T00:00:16Z')
return route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
})
await page.goto('/race-hub')
await expect(page.getByTestId('race-hub')).toBeVisible() await expect(page.getByTestId('race-hub')).toBeVisible()
await page.clock.fastForward(1_000)
const back = page.getByRole('link', { name: 'Back to Weekend' }) await expect(page.getByTestId('race-hub-pre-session')).toBeVisible()
await expect(back).toBeVisible() await expect(page).toHaveURL(/\/race-hub$/)
await back.focus() expect(raceHubRequests).toContain(FULL_SESSION)
await expect(back).toBeFocused() expect(raceHubRequests).not.toContain(9473)
await page.keyboard.press('Enter') })
await expect(page.getByTestId('weekend-page')).toBeVisible() test('pre-session state opens the weekend switcher and navigates to an explicit session', async ({ page }) => {
await expect(page.getByTestId('weekend-page')).toHaveAttribute('data-meeting-key', '1229') await page.route('**/api/v1/weekend-context', (route) =>
await expect(page.getByTestId('weekend-page')).toHaveAttribute('data-session-key', '9000') route.fulfill({
await expect(page.getByTestId('wk-focus-context')).toBeVisible() contentType: 'application/json',
await expect(page.getByRole('link', { name: /Continue analysis/i })).toBeVisible() body: JSON.stringify(pendingContext('2030-01-01T00:00:16Z')),
}),
)
await page.goto('/race-hub')
await expect(page.getByTestId('race-hub-pre-session')).toBeVisible()
const switchWeekend = page.getByTestId('rh-switch-weekend')
await expect(switchWeekend).toHaveAttribute('aria-expanded', 'false')
await switchWeekend.click()
await expect(page.getByTestId('rh-switcher')).toBeVisible()
await expect(switchWeekend).toHaveAttribute('aria-expanded', 'true')
await page.getByTestId(`rh-switcher-session-${FULL_SESSION}`).click()
await expect(page).toHaveURL(new RegExp(`/race-hub\\?session_key=${FULL_SESSION}`))
await expect(page.getByTestId('race-hub')).toBeVisible()
})
test('bare /race-hub recovers when no completed local analysis exists', async ({ page }) => {
await page.route('**/api/v1/weekend-context', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ temporal_state: 'between_weekends', race_hub_pre_session: false }),
}),
)
await page.goto('/race-hub')
await expect(page.getByTestId('race-hub-empty')).toContainText('No completed local analysis yet')
})
test('an explicit session URL remains stable when canonical context would refresh', async ({ page }) => {
let contextRequested = false
await page.route('**/api/v1/weekend-context', (route) => {
contextRequested = true
return route.fulfill({ contentType: 'application/json', body: JSON.stringify(pendingContext('2030-01-01T00:00:01Z')) })
})
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await expect(page.getByTestId('race-hub')).toBeVisible()
await expect(page).toHaveURL(new RegExp(`session_key=${FULL_SESSION}`))
expect(contextRequested).toBe(false)
}) })
}) })

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