mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
Compare commits
31 Commits
main
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0ff8fa5a7 | ||
|
|
c0d1cf2892 | ||
|
|
0bba213652 | ||
|
|
01d291507d | ||
|
|
eabf87a2b8 | ||
|
|
5534ebf82c | ||
|
|
dafb9b6dc5 | ||
|
|
1a9e08c0ab | ||
|
|
14d9bd3dd4 | ||
|
|
15d81c8eab | ||
|
|
2651569453 | ||
|
|
ad0eb26e05 | ||
|
|
5ea8a4a9c4 | ||
|
|
b3d1730324 | ||
|
|
e8a1f15f8b | ||
|
|
e66a874755 | ||
|
|
ad870c040c | ||
|
|
dbe3ac644d | ||
|
|
1543a09ff1 | ||
|
|
408de2da0c | ||
|
|
469028b07a | ||
|
|
5d5987cc7e | ||
|
|
7c98489b91 | ||
|
|
84a9a3579f | ||
|
|
07e5857760 | ||
|
|
06223d992f | ||
|
|
ba55945b6f | ||
|
|
643af2355a | ||
|
|
45084558cd | ||
|
|
90c24ad6aa | ||
|
|
e028e3e3fe |
@@ -25,7 +25,8 @@ EOF
|
|||||||
|
|
||||||
harness_claude() { # Claude Code — print mode, auto-accept edits
|
harness_claude() { # Claude Code — print mode, auto-accept edits
|
||||||
local dir="$1" prompt="$2"
|
local dir="$1" prompt="$2"
|
||||||
( cd "$dir" && claude -p "$(cat "$prompt")" --permission-mode acceptEdits )
|
local model="${BOXBOX_CLAUDE_MODEL:-opus}"
|
||||||
|
( cd "$dir" && claude -p "$(cat "$prompt")" --model "$model" --effort high --permission-mode acceptEdits )
|
||||||
}
|
}
|
||||||
|
|
||||||
harness_codex() { # OpenAI Codex CLI — non-interactive exec, full auto
|
harness_codex() { # OpenAI Codex CLI — non-interactive exec, full auto
|
||||||
@@ -40,7 +41,8 @@ harness_opencode() { # opencode — non-interactive run
|
|||||||
|
|
||||||
harness_cursor() { # Cursor CLI agent — composer-2.5, headless full-auto
|
harness_cursor() { # Cursor CLI agent — composer-2.5, headless full-auto
|
||||||
local dir="$1" prompt="$2"
|
local dir="$1" prompt="$2"
|
||||||
( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --model composer-2.5 --force --trust )
|
local model="${BOXBOX_CURSOR_MODEL:-composer-2.5}"
|
||||||
|
( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --model "$model" --force --trust )
|
||||||
}
|
}
|
||||||
|
|
||||||
harness_agy() { # Antigravity CLI — disabled until headless is verified
|
harness_agy() { # Antigravity CLI — disabled until headless is verified
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -30,7 +30,6 @@ node_modules/
|
|||||||
/test-results/
|
/test-results/
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
/release-fidelity/
|
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
/playwright/.auth/
|
/playwright/.auth/
|
||||||
|
|
||||||
|
|||||||
@@ -57,58 +57,3 @@ 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.
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from './lib/fetch'
|
||||||
import type {
|
import type {
|
||||||
ArticleContent,
|
ArticleContent,
|
||||||
CarDataSample,
|
CarDataSample,
|
||||||
@@ -18,158 +19,184 @@ import type {
|
|||||||
WeekendContext,
|
WeekendContext,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
|
export async function fetchRaceHub(sessionKey: number, signal?: AbortSignal): Promise<RaceHub> {
|
||||||
const res = await fetch(`/api/v1/race-hub?session_key=${sessionKey}`)
|
return apiFetch<RaceHub>(`/api/v1/race-hub?session_key=${sessionKey}`, {
|
||||||
if (!res.ok) {
|
signal,
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
dedupeKey: `race-hub:${sessionKey}`,
|
||||||
}
|
})
|
||||||
return res.json()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSeasons(): Promise<number[]> {
|
export async function fetchSeasons(signal?: AbortSignal): Promise<number[]> {
|
||||||
const res = await fetch('/api/v1/seasons')
|
const years = await apiFetch<number[]>('/api/v1/seasons', {
|
||||||
if (!res.ok) {
|
signal,
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
dedupeKey: 'seasons',
|
||||||
}
|
})
|
||||||
const years = await res.json()
|
|
||||||
return Array.isArray(years) ? years : []
|
return Array.isArray(years) ? years : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchLocalMeetings(year: number): Promise<Meeting[]> {
|
export async function fetchLocalMeetings(year: number, signal?: AbortSignal): Promise<Meeting[]> {
|
||||||
const res = await fetch(`/api/v1/meetings?year=${year}&source=local`)
|
const meetings = await apiFetch<Meeting[]>(`/api/v1/meetings?year=${year}&source=local`, {
|
||||||
if (!res.ok) {
|
signal,
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
dedupeKey: `meetings:local:${year}`,
|
||||||
}
|
})
|
||||||
const meetings = await res.json()
|
|
||||||
return Array.isArray(meetings) ? meetings : []
|
return Array.isArray(meetings) ? meetings : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
|
export async function fetchSeasonMeetings(year: number, signal?: AbortSignal): Promise<Meeting[]> {
|
||||||
return fetchMeetings(year, 'openf1')
|
return fetchMeetings(year, 'openf1', signal)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchMeetings(year: number, source = 'auto'): Promise<Meeting[]> {
|
export async function fetchMeetings(
|
||||||
const res = await fetch(`/api/v1/meetings?year=${year}&source=${source}`)
|
year: number,
|
||||||
if (!res.ok) {
|
source = 'auto',
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
signal?: AbortSignal,
|
||||||
}
|
): Promise<Meeting[]> {
|
||||||
const meetings = await res.json()
|
const meetings = await apiFetch<Meeting[]>(`/api/v1/meetings?year=${year}&source=${source}`, {
|
||||||
|
signal,
|
||||||
|
dedupeKey: `meetings:${source}:${year}`,
|
||||||
|
})
|
||||||
return Array.isArray(meetings) ? meetings : []
|
return Array.isArray(meetings) ? meetings : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchResults(sessionKey: number, source = 'auto'): Promise<EnrichedResult[]> {
|
export async function fetchResults(
|
||||||
const res = await fetch(`/api/v1/results?session_key=${sessionKey}&source=${source}`)
|
sessionKey: number,
|
||||||
if (!res.ok) {
|
source = 'auto',
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
signal?: AbortSignal,
|
||||||
}
|
): Promise<EnrichedResult[]> {
|
||||||
const results = await res.json()
|
const results = await apiFetch<EnrichedResult[]>(
|
||||||
|
`/api/v1/results?session_key=${sessionKey}&source=${source}`,
|
||||||
|
{ signal, dedupeKey: `results:${source}:${sessionKey}` },
|
||||||
|
)
|
||||||
return Array.isArray(results) ? results : []
|
return Array.isArray(results) ? results : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchStartingGrid(sessionKey: number, source = 'auto'): Promise<EnrichedGrid[]> {
|
export async function fetchStartingGrid(
|
||||||
const res = await fetch(`/api/v1/grid?session_key=${sessionKey}&source=${source}`)
|
sessionKey: number,
|
||||||
if (!res.ok) {
|
source = 'auto',
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
signal?: AbortSignal,
|
||||||
}
|
): Promise<EnrichedGrid[]> {
|
||||||
const grid = await res.json()
|
const grid = await apiFetch<EnrichedGrid[]>(
|
||||||
|
`/api/v1/grid?session_key=${sessionKey}&source=${source}`,
|
||||||
|
{ signal, dedupeKey: `grid:${source}:${sessionKey}` },
|
||||||
|
)
|
||||||
return Array.isArray(grid) ? grid : []
|
return Array.isArray(grid) ? grid : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchTrackOutline(circuitKey: number, year: number): Promise<TrackOutline | null> {
|
export async function fetchTrackOutline(
|
||||||
const res = await fetch(`/api/v1/track-outline?circuit_key=${circuitKey}&year=${year}`)
|
circuitKey: number,
|
||||||
if (!res.ok) return null
|
year: number,
|
||||||
const data = await res.json()
|
signal?: AbortSignal,
|
||||||
|
): Promise<TrackOutline | null> {
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<TrackOutline & { error?: string }>(
|
||||||
|
`/api/v1/track-outline?circuit_key=${circuitKey}&year=${year}`,
|
||||||
|
{ signal, dedupeKey: `track-outline:${circuitKey}:${year}` },
|
||||||
|
)
|
||||||
if (data?.error || !Array.isArray(data?.points) || data.points.length < 2) return null
|
if (data?.error || !Array.isArray(data?.points) || data.points.length < 2) return null
|
||||||
return data as TrackOutline
|
return data as TrackOutline
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchReplayFrames(
|
export async function fetchReplayFrames(
|
||||||
sessionKey: number,
|
sessionKey: number,
|
||||||
intervalMs = 5000,
|
intervalMs = 5000,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<ReplayFramesResponse> {
|
): Promise<ReplayFramesResponse> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
session_key: String(sessionKey),
|
session_key: String(sessionKey),
|
||||||
interval_ms: String(intervalMs),
|
interval_ms: String(intervalMs),
|
||||||
})
|
})
|
||||||
const res = await fetch(`/api/v1/replay/frames?${params}`)
|
return apiFetch<ReplayFramesResponse>(`/api/v1/replay/frames?${params}`, {
|
||||||
if (!res.ok) {
|
signal,
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
dedupeKey: `replay-frames:${sessionKey}:${intervalMs}`,
|
||||||
}
|
})
|
||||||
return res.json()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise<Session[]> {
|
export async function fetchSessions(
|
||||||
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
|
meetingKey: number,
|
||||||
if (!res.ok) {
|
source = 'openf1',
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
signal?: AbortSignal,
|
||||||
}
|
): Promise<Session[]> {
|
||||||
const sessions = await res.json()
|
const sessions = await apiFetch<Session[]>(
|
||||||
|
`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`,
|
||||||
|
{ signal, dedupeKey: `sessions:${source}:${meetingKey}` },
|
||||||
|
)
|
||||||
return Array.isArray(sessions) ? sessions : []
|
return Array.isArray(sessions) ? sessions : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
|
export async function fetchWeekend(meetingKey: number, signal?: AbortSignal): Promise<Weekend> {
|
||||||
const res = await fetch(`/api/v1/weekend?meeting_key=${meetingKey}`)
|
return apiFetch<Weekend>(`/api/v1/weekend?meeting_key=${meetingKey}`, {
|
||||||
if (!res.ok) {
|
signal,
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
dedupeKey: `weekend:${meetingKey}`,
|
||||||
}
|
})
|
||||||
return res.json()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchWeekendContext(): Promise<WeekendContext> {
|
// fetchWeekendContext consumes the canonical /api/v1/weekend-context endpoint
|
||||||
const res = await fetch('/api/v1/weekend-context')
|
// (backend story #72). The response is the authoritative WeekendContext shape and
|
||||||
if (!res.ok) {
|
// is used verbatim as the Weekend home's source of truth. Any HTTP error throws
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
// so the hook can surface an explicit error state; there is no client-side
|
||||||
}
|
// re-derivation of the contract. Race Hub bare-default landing also reads this
|
||||||
return res.json()
|
// for `default_analysis_session` (#75).
|
||||||
|
export async function fetchWeekendContext(signal?: AbortSignal): Promise<WeekendContext> {
|
||||||
|
return apiFetch<WeekendContext>('/api/v1/weekend-context', {
|
||||||
|
signal,
|
||||||
|
dedupeKey: 'weekend-context',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchChampionshipHub(year?: number): Promise<ChampionshipHub> {
|
export async function fetchChampionshipHub(
|
||||||
|
year?: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ChampionshipHub> {
|
||||||
const params = new URLSearchParams({ source: 'auto' })
|
const params = new URLSearchParams({ source: 'auto' })
|
||||||
if (year) params.set('year', year.toString())
|
if (year) params.set('year', year.toString())
|
||||||
const url = `/api/v1/championship/hub?${params.toString()}`
|
return apiFetch<ChampionshipHub>(`/api/v1/championship/hub?${params.toString()}`, {
|
||||||
const res = await fetch(url)
|
signal,
|
||||||
if (!res.ok) {
|
dedupeKey: `championship-hub:${year ?? 'latest'}`,
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
})
|
||||||
}
|
|
||||||
return res.json()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchDriverSummary(
|
export async function fetchDriverSummary(
|
||||||
driverNumber: number,
|
driverNumber: number,
|
||||||
year?: number,
|
year?: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<DriverSummary> {
|
): Promise<DriverSummary> {
|
||||||
const params = new URLSearchParams({ driver_number: String(driverNumber) })
|
const params = new URLSearchParams({ driver_number: String(driverNumber), source: 'auto' })
|
||||||
if (year) params.set('year', String(year))
|
if (year) params.set('year', String(year))
|
||||||
const res = await fetch(`/api/v1/driver/summary?${params.toString()}`)
|
return apiFetch<DriverSummary>(`/api/v1/driver/summary?${params.toString()}`, {
|
||||||
if (!res.ok) {
|
signal,
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
dedupeKey: `driver-summary:${driverNumber}:${year ?? 'latest'}`,
|
||||||
}
|
})
|
||||||
return res.json()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchLiveState(): Promise<LiveStateResponse> {
|
export async function fetchLiveState(signal?: AbortSignal): Promise<LiveStateResponse> {
|
||||||
const res = await fetch('/api/v1/live/state')
|
return apiFetch<LiveStateResponse>('/api/v1/live/state', {
|
||||||
if (!res.ok) {
|
signal,
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
dedupeKey: 'live-state',
|
||||||
}
|
})
|
||||||
return res.json()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchLiveTrackOutline(
|
export async function fetchLiveTrackOutline(
|
||||||
session: LiveSessionMeta,
|
session: LiveSessionMeta,
|
||||||
year = new Date().getFullYear(),
|
year = new Date().getFullYear(),
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<TrackOutline> {
|
): Promise<TrackOutline> {
|
||||||
const params = new URLSearchParams({ year: year.toString() })
|
const params = new URLSearchParams({ year: year.toString() })
|
||||||
if (session.MeetingName) params.set('meeting_name', session.MeetingName)
|
if (session.MeetingName) params.set('meeting_name', session.MeetingName)
|
||||||
if (session.CircuitName) params.set('circuit_name', session.CircuitName)
|
if (session.CircuitName) params.set('circuit_name', session.CircuitName)
|
||||||
const res = await fetch(`/api/v1/track-outline?${params.toString()}`)
|
return apiFetch<TrackOutline>(`/api/v1/track-outline?${params.toString()}`, {
|
||||||
if (!res.ok) {
|
signal,
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
dedupeKey: `live-track-outline:${year}:${session.MeetingName ?? ''}:${session.CircuitName ?? ''}`,
|
||||||
}
|
})
|
||||||
return res.json()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchNews(limit?: number, source?: string): Promise<NewsItem[]> {
|
export async function fetchNews(
|
||||||
|
limit?: number,
|
||||||
|
source?: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<NewsItem[]> {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (limit) params.set('limit', limit.toString())
|
if (limit) params.set('limit', limit.toString())
|
||||||
if (source) params.set('source', source)
|
if (source) params.set('source', source)
|
||||||
@@ -177,19 +204,20 @@ export async function fetchNews(limit?: number, source?: string): Promise<NewsIt
|
|||||||
const query = params.toString()
|
const query = params.toString()
|
||||||
const url = query ? `/api/v1/news?${query}` : '/api/v1/news'
|
const url = query ? `/api/v1/news?${query}` : '/api/v1/news'
|
||||||
|
|
||||||
const res = await fetch(url)
|
return apiFetch<NewsItem[]>(url, {
|
||||||
if (!res.ok) {
|
signal,
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
dedupeKey: `news:${limit ?? 'all'}:${source ?? 'all'}`,
|
||||||
}
|
})
|
||||||
return res.json()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchNewsArticle(articleUrl: string): Promise<ArticleContent> {
|
export async function fetchNewsArticle(
|
||||||
const res = await fetch(`/api/v1/news/article?url=${encodeURIComponent(articleUrl)}`)
|
articleUrl: string,
|
||||||
if (!res.ok) {
|
signal?: AbortSignal,
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
): Promise<ArticleContent> {
|
||||||
}
|
return apiFetch<ArticleContent>(
|
||||||
return res.json()
|
`/api/v1/news/article?url=${encodeURIComponent(articleUrl)}`,
|
||||||
|
{ signal },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markNewsRead(articleUrl: string): Promise<void> {
|
export async function markNewsRead(articleUrl: string): Promise<void> {
|
||||||
@@ -203,28 +231,26 @@ export async function markNewsRead(articleUrl: string): Promise<void> {
|
|||||||
export async function fetchTelemetry(
|
export async function fetchTelemetry(
|
||||||
sessionKey: number,
|
sessionKey: number,
|
||||||
driverNumber: number,
|
driverNumber: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<CarDataSample[]> {
|
): Promise<CarDataSample[]> {
|
||||||
const res = await fetch(
|
const data = await apiFetch<CarDataSample[]>(
|
||||||
`/api/v1/telemetry?session_key=${sessionKey}&driver_number=${driverNumber}`,
|
`/api/v1/telemetry?session_key=${sessionKey}&driver_number=${driverNumber}`,
|
||||||
|
{ signal, dedupeKey: `telemetry:${sessionKey}:${driverNumber}` },
|
||||||
)
|
)
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
|
||||||
}
|
|
||||||
const data = await res.json()
|
|
||||||
return Array.isArray(data) ? data : []
|
return Array.isArray(data) ? data : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchLapsComparison(
|
export async function fetchLapsComparison(
|
||||||
sessionKey: number,
|
sessionKey: number,
|
||||||
drivers?: number[],
|
drivers?: number[],
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<LapsComparisonResponse> {
|
): Promise<LapsComparisonResponse> {
|
||||||
const params = new URLSearchParams({ session_key: String(sessionKey) })
|
const params = new URLSearchParams({ session_key: String(sessionKey) })
|
||||||
if (drivers?.length) {
|
if (drivers?.length) {
|
||||||
params.set('drivers', drivers.join(','))
|
params.set('drivers', drivers.join(','))
|
||||||
}
|
}
|
||||||
const res = await fetch(`/api/v1/laps/comparison?${params}`)
|
return apiFetch<LapsComparisonResponse>(`/api/v1/laps/comparison?${params}`, {
|
||||||
if (!res.ok) {
|
signal,
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
dedupeKey: `laps-comparison:${sessionKey}:${drivers?.join(',') ?? 'all'}`,
|
||||||
}
|
})
|
||||||
return res.json()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
|
|||||||
import { formatCoverageHint } from '../lib/coverage'
|
import { formatCoverageHint } from '../lib/coverage'
|
||||||
import { SourceBadge } from './SourceBadge'
|
import { SourceBadge } from './SourceBadge'
|
||||||
import { SessionCoverageDots } from './SessionCoverageDots'
|
import { SessionCoverageDots } from './SessionCoverageDots'
|
||||||
|
import { RouteState } from './RouteState'
|
||||||
import type { Meeting, WeekendSession } from '../types'
|
import type { Meeting, WeekendSession } from '../types'
|
||||||
|
|
||||||
function formatMeetingDates(meeting: Meeting): string {
|
function formatMeetingDates(meeting: Meeting): string {
|
||||||
@@ -29,18 +30,18 @@ export function LocalDataNavigator({ onSelectSession }: Props) {
|
|||||||
|
|
||||||
const seasonsQuery = useQuery({
|
const seasonsQuery = useQuery({
|
||||||
queryKey: ['seasons'],
|
queryKey: ['seasons'],
|
||||||
queryFn: fetchSeasons,
|
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||||
})
|
})
|
||||||
|
|
||||||
const meetingsQuery = useQuery({
|
const meetingsQuery = useQuery({
|
||||||
queryKey: ['meetings', selectedYear],
|
queryKey: ['meetings', selectedYear],
|
||||||
queryFn: () => fetchLocalMeetings(selectedYear!),
|
queryFn: ({ signal }) => fetchLocalMeetings(selectedYear!, signal),
|
||||||
enabled: selectedYear != null,
|
enabled: selectedYear != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const weekendQuery = useQuery({
|
const weekendQuery = useQuery({
|
||||||
queryKey: ['weekend', selectedMeetingKey],
|
queryKey: ['weekend', selectedMeetingKey],
|
||||||
queryFn: () => fetchWeekend(selectedMeetingKey!),
|
queryFn: ({ signal }) => fetchWeekend(selectedMeetingKey!, signal),
|
||||||
enabled: selectedMeetingKey != null,
|
enabled: selectedMeetingKey != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -68,14 +69,22 @@ export function LocalDataNavigator({ onSelectSession }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (seasonsQuery.isLoading) {
|
if (seasonsQuery.isLoading) {
|
||||||
return <div className="nav-panel loading-state">loading local seasons…</div>
|
return <RouteState kind="loading" title="loading local seasons…" className="nav-panel" />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seasonsQuery.isError) {
|
if (seasonsQuery.isError) {
|
||||||
return (
|
return (
|
||||||
<div className="nav-panel error-box">
|
<RouteState
|
||||||
{seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'}
|
kind="error"
|
||||||
</div>
|
className="nav-panel"
|
||||||
|
title="Local seasons unavailable"
|
||||||
|
error={seasonsQuery.error}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!seasonsQuery.isFetching) void seasonsQuery.refetch()
|
||||||
|
}}
|
||||||
|
retrying={seasonsQuery.isFetching}
|
||||||
|
testId="weekend-error"
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,9 +133,16 @@ export function LocalDataNavigator({ onSelectSession }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{meetingsQuery.isError && (
|
{meetingsQuery.isError && (
|
||||||
<div className="error-box" style={{ marginTop: 'var(--s4)' }}>
|
<RouteState
|
||||||
{meetingsQuery.error instanceof Error ? meetingsQuery.error.message : 'Failed to load meetings'}
|
kind="error"
|
||||||
</div>
|
title="Meetings unavailable"
|
||||||
|
error={meetingsQuery.error}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!meetingsQuery.isFetching) void meetingsQuery.refetch()
|
||||||
|
}}
|
||||||
|
retrying={meetingsQuery.isFetching}
|
||||||
|
testId="weekend-meetings-error"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && (
|
{!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && (
|
||||||
@@ -194,9 +210,16 @@ export function LocalDataNavigator({ onSelectSession }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedMeetingKey != null && weekendQuery.isError && (
|
{selectedMeetingKey != null && weekendQuery.isError && (
|
||||||
<div className="error-box" style={{ marginTop: 'var(--s4)' }}>
|
<RouteState
|
||||||
{weekendQuery.error instanceof Error ? weekendQuery.error.message : 'Failed to load weekend'}
|
kind="error"
|
||||||
</div>
|
title="Weekend unavailable"
|
||||||
|
error={weekendQuery.error}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!weekendQuery.isFetching) void weekendQuery.refetch()
|
||||||
|
}}
|
||||||
|
retrying={weekendQuery.isFetching}
|
||||||
|
testId="weekend-error"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{weekend && (
|
{weekend && (
|
||||||
|
|||||||
@@ -1,55 +1,66 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { Compass, FileText, Home, Trophy } from 'lucide-react'
|
||||||
import { fetchLiveState } from '../api'
|
|
||||||
import { isLiveSessionActive } from '../lib/live'
|
|
||||||
|
|
||||||
|
const PRIMARY = [
|
||||||
|
{ to: '/', label: 'Weekend', icon: Home, exact: true },
|
||||||
|
{ to: '/championship', label: 'Championship', icon: Trophy, exact: false },
|
||||||
|
{ to: '/briefing', label: 'Briefing', icon: FileText, exact: false },
|
||||||
|
{ to: '/explore', label: 'Explore', icon: Compass, exact: false },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nav renders one primary navigation system per breakpoint:
|
||||||
|
* - Desktop/tablet: the top bar's `aria-label="Primary"` links.
|
||||||
|
* - Mobile (≤640px): the bottom `aria-label="Primary"` bar; the top bar's links
|
||||||
|
* are hidden via CSS so the two are never both active at once.
|
||||||
|
*
|
||||||
|
* Admin is an operator utility, deliberately outside every Primary landmark — it
|
||||||
|
* lives in a plain toolbar slot and never appears in the mobile bottom nav.
|
||||||
|
*/
|
||||||
export function Nav() {
|
export function Nav() {
|
||||||
const { data: liveState } = useQuery({
|
|
||||||
queryKey: ['live-state'],
|
|
||||||
queryFn: fetchLiveState,
|
|
||||||
staleTime: 5_000,
|
|
||||||
refetchInterval: 30_000,
|
|
||||||
})
|
|
||||||
const liveActive = isLiveSessionActive(liveState)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="app-nav">
|
<>
|
||||||
|
<header className="app-nav">
|
||||||
<Link to="/" className="nav-logo">
|
<Link to="/" className="nav-logo">
|
||||||
box<em>-</em>box
|
box<em>-</em>box
|
||||||
</Link>
|
</Link>
|
||||||
<div className="nav-links">
|
<nav className="nav-links" aria-label="Primary">
|
||||||
<Link to="/" activeProps={{ className: 'active' }} activeOptions={{ exact: true }}>
|
{PRIMARY.map(({ to, label, exact }) => (
|
||||||
Command
|
|
||||||
</Link>
|
|
||||||
<Link
|
<Link
|
||||||
to="/live"
|
key={to}
|
||||||
className={liveActive ? 'nav-live nav-live-on' : 'nav-live'}
|
to={to}
|
||||||
activeProps={{ className: 'active' }}
|
activeProps={{ className: 'active' }}
|
||||||
data-testid="nav-live"
|
activeOptions={exact ? { exact: true } : undefined}
|
||||||
data-live-active={liveActive ? 'true' : 'false'}
|
|
||||||
>
|
>
|
||||||
{liveActive && <span className="nav-live-dot" aria-hidden="true" />}
|
{label}
|
||||||
Live
|
|
||||||
{liveActive && <span className="sr-only"> session active</span>}
|
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
))}
|
||||||
Race Hub
|
</nav>
|
||||||
</Link>
|
<div className="nav-utility" role="toolbar" aria-label="Operator utilities">
|
||||||
<Link to="/preview" activeProps={{ className: 'active' }}>
|
<Link
|
||||||
Preview
|
to="/admin"
|
||||||
</Link>
|
className="nav-utility-link"
|
||||||
<Link to="/championship" activeProps={{ className: 'active' }}>
|
activeProps={{ className: 'nav-utility-link active' }}
|
||||||
Championship
|
>
|
||||||
</Link>
|
|
||||||
<Link to="/briefing" activeProps={{ className: 'active' }}>
|
|
||||||
Briefing
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="nav-utility">
|
|
||||||
<Link to="/admin" className="nav-utility-link" activeProps={{ className: 'nav-utility-link active' }}>
|
|
||||||
Admin
|
Admin
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav className="app-bottom-nav" aria-label="Primary">
|
||||||
|
{PRIMARY.map(({ to, label, icon: Icon, exact }) => (
|
||||||
|
<Link
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className="bottom-nav-link"
|
||||||
|
activeProps={{ className: 'bottom-nav-link active' }}
|
||||||
|
activeOptions={exact ? { exact: true } : undefined}
|
||||||
|
>
|
||||||
|
<Icon size={18} aria-hidden="true" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { RaceHub } from '../types'
|
import type { RaceHub } from '../types'
|
||||||
import { compareFinishPosition, formatDuration, formatGap, formatLapTime } from '../utils'
|
import { compareFinishPosition, formatDuration, formatGap, formatLapTime } from '../utils'
|
||||||
import { countRaceHubDatasets } from '../lib/coverage'
|
|
||||||
import { Thermometer, Map, Droplets, Wind, CloudRain } from 'lucide-react'
|
import { Thermometer, Map, Droplets, Wind, CloudRain } from 'lucide-react'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -17,7 +16,6 @@ export function OverviewView({ data }: Props) {
|
|||||||
const fastest = pickFastestLap(data)
|
const fastest = pickFastestLap(data)
|
||||||
const latestWeather = data.weather.length > 0 ? data.weather[data.weather.length - 1] : null
|
const latestWeather = data.weather.length > 0 ? data.weather[data.weather.length - 1] : null
|
||||||
const rcHighlights = data.race_control.slice(-3).reverse()
|
const rcHighlights = data.race_control.slice(-3).reverse()
|
||||||
const coverage = countRaceHubDatasets(data.datasets)
|
|
||||||
|
|
||||||
const sessionType = (data.session?.session_type ?? '').toLowerCase()
|
const sessionType = (data.session?.session_type ?? '').toLowerCase()
|
||||||
const isRace = sessionType.includes('race')
|
const isRace = sessionType.includes('race')
|
||||||
@@ -129,28 +127,6 @@ export function OverviewView({ data }: Props) {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rh-panel ui-card">
|
|
||||||
<div className="sec-header">
|
|
||||||
<span className="sec-title">Local Coverage</span>
|
|
||||||
<span className="sec-meta mono">
|
|
||||||
{coverage.available}/{coverage.total}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="rh-coverage-meter" aria-hidden="true">
|
|
||||||
<div
|
|
||||||
className="rh-coverage-fill"
|
|
||||||
style={{ width: `${(coverage.available / coverage.total) * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="rh-empty-line" style={{ marginTop: 'var(--s2)' }}>
|
|
||||||
{coverage.available === coverage.total
|
|
||||||
? 'Every Race Hub dataset is local for this session.'
|
|
||||||
: `${coverage.total - coverage.available} dataset${
|
|
||||||
coverage.total - coverage.available === 1 ? '' : 's'
|
|
||||||
} not ingested yet — see Data Status tab.`}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -34,25 +34,25 @@ export function PaddockBriefing() {
|
|||||||
|
|
||||||
const { data: news, isLoading, isError } = useQuery({
|
const { data: news, isLoading, isError } = useQuery({
|
||||||
queryKey: ['news'],
|
queryKey: ['news'],
|
||||||
queryFn: () => fetchNews(100),
|
queryFn: ({ signal }) => fetchNews(100, undefined, signal),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const seasonsQuery = useQuery({
|
const seasonsQuery = useQuery({
|
||||||
queryKey: ['seasons'],
|
queryKey: ['seasons'],
|
||||||
queryFn: fetchSeasons,
|
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||||
})
|
})
|
||||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||||
|
|
||||||
const meetingsQuery = useQuery({
|
const meetingsQuery = useQuery({
|
||||||
queryKey: ['season-meetings', latestSeason],
|
queryKey: ['season-meetings', latestSeason],
|
||||||
queryFn: () => fetchSeasonMeetings(latestSeason!),
|
queryFn: ({ signal }) => fetchSeasonMeetings(latestSeason!, signal),
|
||||||
enabled: latestSeason != null,
|
enabled: latestSeason != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const hubQuery = useQuery({
|
const hubQuery = useQuery({
|
||||||
queryKey: ['championship-hub', latestSeason],
|
queryKey: ['championship-hub', latestSeason],
|
||||||
queryFn: () => fetchChampionshipHub(latestSeason!),
|
queryFn: ({ signal }) => fetchChampionshipHub(latestSeason!, signal),
|
||||||
enabled: latestSeason != null,
|
enabled: latestSeason != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
141
frontend/src/components/PreSessionView.tsx
Normal file
141
frontend/src/components/PreSessionView.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import type { Session } from '../types'
|
||||||
|
import { RACE_HUB_DATASETS } from '../lib/coverage'
|
||||||
|
import { formatCountdown, formatSessionScheduleTime, sessionStartTime } from '../lib/schedule'
|
||||||
|
import {
|
||||||
|
sessionStateDescription,
|
||||||
|
type SessionState,
|
||||||
|
} from '../lib/sessionState'
|
||||||
|
|
||||||
|
const EXPECTED_LABELS: Record<string, string> = {
|
||||||
|
results: 'Final results',
|
||||||
|
starting_grid: 'Starting grid',
|
||||||
|
stints: 'Tyre strategy',
|
||||||
|
pit_stops: 'Pit stops',
|
||||||
|
positions: 'Position changes',
|
||||||
|
laps: 'Lap times',
|
||||||
|
race_control: 'Race control',
|
||||||
|
weather: 'Track conditions',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreSessionProps {
|
||||||
|
session: Session
|
||||||
|
sessionName: string
|
||||||
|
now: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purpose-built view for a session that has not run yet. Instead of rendering
|
||||||
|
* empty Winner / Podium / Pole / Strategy / Compare cards, it explains that the
|
||||||
|
* session is upcoming and previews the analysis that will appear once the data
|
||||||
|
* is ingested.
|
||||||
|
*/
|
||||||
|
export function PreSessionView({ session, sessionName, now }: PreSessionProps) {
|
||||||
|
const start = sessionStartTime(session)
|
||||||
|
const expected = RACE_HUB_DATASETS.filter((key) => EXPECTED_LABELS[key])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rh-presession" data-testid="rh-presession">
|
||||||
|
<section className="rh-presession-band">
|
||||||
|
<span className="rh-presession-eyebrow mono">Upcoming session</span>
|
||||||
|
<h2 className="rh-presession-title">{sessionName}</h2>
|
||||||
|
<p className="rh-presession-sub">
|
||||||
|
This session hasn’t run yet, so there’s 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -704,7 +704,12 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{displayResults.length > 0 && (
|
{displayResults.length > 0 && (
|
||||||
<div className="rs-field-list">
|
<div
|
||||||
|
className="rs-field-list"
|
||||||
|
role="region"
|
||||||
|
aria-label="Final running order"
|
||||||
|
data-testid="race-story-outcome"
|
||||||
|
>
|
||||||
{displayResults.map((r, i) => {
|
{displayResults.map((r, i) => {
|
||||||
const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
|
const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
|
||||||
const currentPos = scrubTime !== null ? i + 1 : r.position
|
const currentPos = scrubTime !== null ? i + 1 : r.position
|
||||||
|
|||||||
163
frontend/src/components/RouteState.tsx
Normal file
163
frontend/src/components/RouteState.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { isTimeoutError, userFacingError } from '../lib/fetch'
|
||||||
|
import { noticeMessage, type DataAvailability } from '../lib/availability'
|
||||||
|
|
||||||
|
export type { DataAvailability }
|
||||||
|
|
||||||
|
export function availabilityLabel(kind: DataAvailability): string {
|
||||||
|
switch (kind) {
|
||||||
|
case 'local':
|
||||||
|
return 'Local'
|
||||||
|
case 'partial':
|
||||||
|
return 'Partial'
|
||||||
|
case 'stale':
|
||||||
|
return 'Stale'
|
||||||
|
case 'archive':
|
||||||
|
return 'Archive'
|
||||||
|
case 'limited':
|
||||||
|
return 'Limited'
|
||||||
|
case 'missing':
|
||||||
|
return 'Missing'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AvailabilityBadge({ kind, label }: { kind: DataAvailability; label?: string }) {
|
||||||
|
return (
|
||||||
|
<span className={`badge badge-${kind === 'archive' ? 'none' : kind}`} data-testid={`availability-${kind}`}>
|
||||||
|
{label ?? availabilityLabel(kind)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RouteStateKind = 'loading' | 'empty' | 'error' | 'timeout'
|
||||||
|
|
||||||
|
interface RouteStateProps {
|
||||||
|
kind: RouteStateKind
|
||||||
|
title?: string
|
||||||
|
message?: ReactNode
|
||||||
|
error?: unknown
|
||||||
|
onRetry?: () => void
|
||||||
|
retrying?: boolean
|
||||||
|
testId?: string
|
||||||
|
/** Optional override for the Retry button's data-testid (defaults to none). */
|
||||||
|
retryTestId?: string
|
||||||
|
className?: string
|
||||||
|
/** Optional availability strip (stale/limited/partial) above the state body. */
|
||||||
|
availability?: DataAvailability
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TITLES: Record<RouteStateKind, string> = {
|
||||||
|
loading: 'Loading…',
|
||||||
|
empty: 'Nothing here yet',
|
||||||
|
error: 'Could not load this view',
|
||||||
|
timeout: 'Request timed out',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MESSAGES: Record<RouteStateKind, string> = {
|
||||||
|
loading: 'Fetching the latest local data.',
|
||||||
|
empty: 'No data is available for this view yet.',
|
||||||
|
error: 'Something went wrong. Retry to try again.',
|
||||||
|
timeout: 'This request took too long. Check your connection, then retry.',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared primary-route state surface: loading, empty, timeout/error + retry.
|
||||||
|
* Retry is a real <button> (keyboard accessible) and callers should gate
|
||||||
|
* concurrent refetches via React Query / deduped apiFetch.
|
||||||
|
*/
|
||||||
|
export function RouteState({
|
||||||
|
kind,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
retrying = false,
|
||||||
|
testId,
|
||||||
|
retryTestId,
|
||||||
|
className = '',
|
||||||
|
availability,
|
||||||
|
children,
|
||||||
|
}: RouteStateProps) {
|
||||||
|
const resolvedKind: RouteStateKind =
|
||||||
|
kind === 'error' && isTimeoutError(error) ? 'timeout' : kind
|
||||||
|
|
||||||
|
const resolvedMessage =
|
||||||
|
message ??
|
||||||
|
(error != null && (resolvedKind === 'error' || resolvedKind === 'timeout')
|
||||||
|
? userFacingError(error)
|
||||||
|
: DEFAULT_MESSAGES[resolvedKind])
|
||||||
|
|
||||||
|
const showRetry =
|
||||||
|
(resolvedKind === 'error' || resolvedKind === 'timeout') && typeof onRetry === 'function'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`route-state route-state-${resolvedKind} ${className}`.trim()}
|
||||||
|
data-testid={testId ?? `route-state-${resolvedKind}`}
|
||||||
|
role={resolvedKind === 'error' || resolvedKind === 'timeout' ? 'alert' : undefined}
|
||||||
|
>
|
||||||
|
{availability && (
|
||||||
|
<div className="route-state-availability">
|
||||||
|
<AvailabilityBadge kind={availability} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{resolvedKind === 'loading' ? (
|
||||||
|
<div className="loading-state">{title ?? 'loading…'}</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="route-state-title">{title ?? DEFAULT_TITLES[resolvedKind]}</div>
|
||||||
|
<div className="route-state-message">{resolvedMessage}</div>
|
||||||
|
{children}
|
||||||
|
{showRetry && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="route-state-retry"
|
||||||
|
onClick={onRetry}
|
||||||
|
disabled={retrying}
|
||||||
|
aria-busy={retrying || undefined}
|
||||||
|
data-testid={retryTestId}
|
||||||
|
>
|
||||||
|
{retrying ? 'Retrying…' : 'Retry'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StaleNoticeProps {
|
||||||
|
availability?: DataAvailability
|
||||||
|
message?: string
|
||||||
|
onRetry?: () => void
|
||||||
|
retrying?: boolean
|
||||||
|
testId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inline notice when a successful payload is limited/stale/partial. */
|
||||||
|
export function DataNotice({
|
||||||
|
availability = 'stale',
|
||||||
|
message,
|
||||||
|
onRetry,
|
||||||
|
retrying = false,
|
||||||
|
testId = 'data-notice',
|
||||||
|
}: StaleNoticeProps) {
|
||||||
|
return (
|
||||||
|
<div className="data-notice" data-testid={testId} role="status">
|
||||||
|
<AvailabilityBadge kind={availability} />
|
||||||
|
<span className="data-notice-text">{message ?? noticeMessage(availability)}</span>
|
||||||
|
{onRetry && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="route-state-retry data-notice-retry"
|
||||||
|
onClick={onRetry}
|
||||||
|
disabled={retrying}
|
||||||
|
aria-busy={retrying || undefined}
|
||||||
|
>
|
||||||
|
{retrying ? 'Retrying…' : 'Retry'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,15 +8,46 @@ export type Tab =
|
|||||||
| 'race_control'
|
| 'race_control'
|
||||||
| 'data_status'
|
| 'data_status'
|
||||||
|
|
||||||
const TABS: { id: Tab; label: string }[] = [
|
interface TabDef {
|
||||||
|
id: Tab
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabGroup {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
tabs: TabDef[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fan-facing hierarchy: Story first, then Analysis, then Data/Context. Every
|
||||||
|
// existing capability is preserved — only the grouping and ordering change.
|
||||||
|
const TAB_GROUPS: TabGroup[] = [
|
||||||
|
{
|
||||||
|
id: 'story',
|
||||||
|
label: 'Story',
|
||||||
|
tabs: [
|
||||||
{ id: 'overview', label: 'Overview' },
|
{ id: 'overview', label: 'Overview' },
|
||||||
{ id: 'race_story', label: 'Race Story' },
|
{ id: 'race_story', label: 'Race Story' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'analysis',
|
||||||
|
label: 'Analysis',
|
||||||
|
tabs: [
|
||||||
{ id: 'strategy', label: 'Strategy' },
|
{ id: 'strategy', label: 'Strategy' },
|
||||||
{ id: 'compare', label: 'Compare' },
|
{ id: 'compare', label: 'Compare' },
|
||||||
{ id: 'lap_data', label: 'Lap Data' },
|
{ id: 'lap_data', label: 'Lap Data' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'context',
|
||||||
|
label: 'Data & Context',
|
||||||
|
tabs: [
|
||||||
{ id: 'conditions', label: 'Conditions' },
|
{ id: 'conditions', label: 'Conditions' },
|
||||||
{ id: 'race_control', label: 'Race Control' },
|
{ id: 'race_control', label: 'Race Control' },
|
||||||
{ id: 'data_status', label: 'Data Status' },
|
{ id: 'data_status', label: 'Diagnostics' },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -26,8 +57,14 @@ interface Props {
|
|||||||
|
|
||||||
export function TabBar({ active, onChange }: Props) {
|
export function TabBar({ active, onChange }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="tab-bar" role="tablist">
|
<div className="tab-bar tab-bar-grouped" role="tablist" data-testid="rh-tabbar">
|
||||||
{TABS.map((t) => (
|
{TAB_GROUPS.map((group) => (
|
||||||
|
<div key={group.id} className="tab-group" data-testid={`rh-tabgroup-${group.id}`}>
|
||||||
|
<span className="tab-group-label mono" aria-hidden="true">
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
<div className="tab-group-btns">
|
||||||
|
{group.tabs.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
role="tab"
|
role="tab"
|
||||||
@@ -39,5 +76,8 @@ export function TabBar({ active, onChange }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,41 +2,60 @@ import { useEffect, useState } from 'react'
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
|
import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
|
||||||
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
|
import { sessionTypeAbbrev } from '../lib/coverage'
|
||||||
|
import {
|
||||||
|
resolveSessionState,
|
||||||
|
sessionStateDotClass,
|
||||||
|
sessionStateLabel,
|
||||||
|
} from '../lib/sessionState'
|
||||||
import { countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
import { countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
||||||
|
import type { WeekendContext } from '../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
currentMeetingKey?: number
|
currentMeetingKey?: number
|
||||||
currentSessionKey?: number
|
currentSessionKey?: number
|
||||||
|
context?: WeekendContext | null
|
||||||
|
now?: Date
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose }: Props) {
|
export function WeekendSwitcher({
|
||||||
|
currentMeetingKey,
|
||||||
|
currentSessionKey,
|
||||||
|
context,
|
||||||
|
now: nowProp,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons })
|
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: ({ signal }) => fetchSeasons(signal) })
|
||||||
const [year, setYear] = useState<number | null>(null)
|
const [year, setYear] = useState<number | null>(null)
|
||||||
const [openMeetingKey, setOpenMeetingKey] = useState<number | null>(
|
const [openMeetingKey, setOpenMeetingKey] = useState<number | null>(
|
||||||
currentMeetingKey ?? null,
|
currentMeetingKey ?? null,
|
||||||
)
|
)
|
||||||
|
const now = nowProp ?? new Date()
|
||||||
useEffect(() => {
|
|
||||||
if (year == null && seasonsQuery.data?.length) {
|
|
||||||
setYear(seasonsQuery.data[0])
|
|
||||||
}
|
|
||||||
}, [seasonsQuery.data, year])
|
|
||||||
|
|
||||||
const meetingsQuery = useQuery({
|
|
||||||
queryKey: ['meetings', year],
|
|
||||||
queryFn: () => fetchLocalMeetings(year!),
|
|
||||||
enabled: year != null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const weekendQuery = useQuery({
|
const weekendQuery = useQuery({
|
||||||
queryKey: ['weekend', openMeetingKey],
|
queryKey: ['weekend', openMeetingKey],
|
||||||
queryFn: () => fetchWeekend(openMeetingKey!),
|
queryFn: ({ signal }) => fetchWeekend(openMeetingKey!, signal),
|
||||||
enabled: openMeetingKey != null,
|
enabled: openMeetingKey != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (year != null) return
|
||||||
|
const currentYear = weekendQuery.data?.meeting?.year
|
||||||
|
if (currentYear) {
|
||||||
|
setYear(currentYear)
|
||||||
|
} else if (seasonsQuery.data?.length) {
|
||||||
|
setYear(seasonsQuery.data[0])
|
||||||
|
}
|
||||||
|
}, [seasonsQuery.data, weekendQuery.data, year])
|
||||||
|
|
||||||
|
const meetingsQuery = useQuery({
|
||||||
|
queryKey: ['meetings', year],
|
||||||
|
queryFn: ({ signal }) => fetchLocalMeetings(year!, signal),
|
||||||
|
enabled: year != null,
|
||||||
|
})
|
||||||
|
|
||||||
const seasons = seasonsQuery.data ?? []
|
const seasons = seasonsQuery.data ?? []
|
||||||
const meetings = meetingsQuery.data ?? []
|
const meetings = meetingsQuery.data ?? []
|
||||||
const weekend = weekendQuery.data
|
const weekend = weekendQuery.data
|
||||||
@@ -47,7 +66,7 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="rh-weekend-switcher" className="rh-switcher" data-testid="rh-switcher">
|
<div 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">
|
||||||
@@ -106,9 +125,16 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
|
|||||||
{weekendQuery.isLoading && (
|
{weekendQuery.isLoading && (
|
||||||
<div className="rh-switcher-empty">loading sessions…</div>
|
<div className="rh-switcher-empty">loading sessions…</div>
|
||||||
)}
|
)}
|
||||||
{weekend && weekend.meeting_key === m.meeting_key &&
|
{weekend &&
|
||||||
weekend.sessions.map(({ session, source, datasets }) => {
|
weekend.meeting_key === m.meeting_key &&
|
||||||
|
weekend.sessions.map((weekendSession) => {
|
||||||
|
const { session } = weekendSession
|
||||||
const active = session.session_key === currentSessionKey
|
const active = session.session_key === currentSessionKey
|
||||||
|
const state = resolveSessionState({
|
||||||
|
weekendSession,
|
||||||
|
context,
|
||||||
|
now,
|
||||||
|
})
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={session.session_key}
|
key={session.session_key}
|
||||||
@@ -122,8 +148,11 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
|
|||||||
</span>
|
</span>
|
||||||
<span className="rh-switcher-sess-name">{session.session_name}</span>
|
<span className="rh-switcher-sess-name">{session.session_name}</span>
|
||||||
<span className="rh-switcher-sess-cov mono">
|
<span className="rh-switcher-sess-cov mono">
|
||||||
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
|
<span
|
||||||
{formatCoverageHint(datasets)}
|
className={`cc-cov-dot ${sessionStateDotClass(state)}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{sessionStateLabel(state)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
263
frontend/src/components/live/LiveHandoff.tsx
Normal file
263
frontend/src/components/live/LiveHandoff.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { Archive, ChevronRight, Flag, Radio } from 'lucide-react'
|
||||||
|
import type { LiveTimingRow } from '../../lib/live'
|
||||||
|
import { driverCode } from '../../lib/live'
|
||||||
|
import type { TransportHealth } from '../../lib/liveState'
|
||||||
|
import { feedHealthLabel } from '../../lib/liveState'
|
||||||
|
import type { ContextSession, WeekendContext } from '../../types'
|
||||||
|
import { analysisSessionKey } from '../../lib/weekendContext'
|
||||||
|
import { formatSessionScheduleTime } from '../../lib/schedule'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 'settling' immediately after a session; 'inactive' when nothing is live. */
|
||||||
|
phase: 'settling' | 'inactive'
|
||||||
|
transport: TransportHealth
|
||||||
|
/** Canonical weekend context (issue #72). May be undefined before it loads. */
|
||||||
|
context: WeekendContext | undefined
|
||||||
|
/** Top rows of the final snapshot (settling only), already position-sorted. */
|
||||||
|
rows: LiveTimingRow[]
|
||||||
|
capturedAt?: string | null
|
||||||
|
hasArchive: boolean
|
||||||
|
onViewArchive: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCapturedAt(capturedAt: string | null | undefined): string {
|
||||||
|
if (!capturedAt) return ''
|
||||||
|
const date = new Date(capturedAt)
|
||||||
|
if (Number.isNaN(date.getTime())) return ''
|
||||||
|
return date.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionKeyOf(session: ContextSession | undefined): number | undefined {
|
||||||
|
const key = session?.session.session_key
|
||||||
|
return key && key > 0 ? key : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fully ingested analysis (local_analysis === complete). Stricter than
|
||||||
|
* hasLocalAnalysis, which also treats partial as link-worthy — Live polling
|
||||||
|
* and "ready" chrome wait for the complete state.
|
||||||
|
*/
|
||||||
|
export function analysisIsReady(session: ContextSession | undefined): boolean {
|
||||||
|
return session?.availability?.local_analysis === 'complete'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analysis target for the Live handoff surface.
|
||||||
|
*
|
||||||
|
* Settling must follow the just-finished session (`previous_completed_session`).
|
||||||
|
* The canonical backend can mark that session archive-complete while leaving
|
||||||
|
* `default_analysis_session` on an older already-ingested practice/qualifying —
|
||||||
|
* preferring default here would link/poll/label the wrong race.
|
||||||
|
*
|
||||||
|
* Inactive keeps the shared default-first preference via analysisSessionKey.
|
||||||
|
*/
|
||||||
|
export function handoffAnalysisSession(
|
||||||
|
context: WeekendContext | undefined,
|
||||||
|
phase: 'settling' | 'inactive',
|
||||||
|
): ContextSession | undefined {
|
||||||
|
if (!context) return undefined
|
||||||
|
if (phase === 'settling') {
|
||||||
|
if (sessionKeyOf(context.previous_completed_session)) {
|
||||||
|
return context.previous_completed_session
|
||||||
|
}
|
||||||
|
return sessionKeyOf(context.default_analysis_session)
|
||||||
|
? context.default_analysis_session
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
const key = analysisSessionKey(context)
|
||||||
|
if (!key) return undefined
|
||||||
|
if (context.default_analysis_session?.session.session_key === key) {
|
||||||
|
return context.default_analysis_session
|
||||||
|
}
|
||||||
|
return context.previous_completed_session
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep polling weekend-context while the just-finished session (or, absent
|
||||||
|
* that, the default analysis session) is still ingesting. An older ready
|
||||||
|
* default must not stop the settle→ready transition.
|
||||||
|
*/
|
||||||
|
export function shouldPollHandoffAnalysis(context: WeekendContext | undefined): boolean {
|
||||||
|
if (!context) return true
|
||||||
|
const previous = context.previous_completed_session
|
||||||
|
if (sessionKeyOf(previous) && !analysisIsReady(previous)) return true
|
||||||
|
const fallback = context.default_analysis_session
|
||||||
|
if (sessionKeyOf(fallback) && !analysisIsReady(fallback)) return true
|
||||||
|
return !sessionKeyOf(previous) && !sessionKeyOf(fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LiveHandoff({
|
||||||
|
phase,
|
||||||
|
transport,
|
||||||
|
context,
|
||||||
|
rows,
|
||||||
|
capturedAt,
|
||||||
|
hasArchive,
|
||||||
|
onViewArchive,
|
||||||
|
}: Props) {
|
||||||
|
const isSettling = phase === 'settling'
|
||||||
|
const testid = isSettling ? 'live-settling' : 'live-inactive'
|
||||||
|
const capturedLabel = formatCapturedAt(capturedAt)
|
||||||
|
|
||||||
|
const focusName = context?.focus_meeting?.meeting_name
|
||||||
|
const activeName = context?.active_session?.meeting?.meeting_name
|
||||||
|
const title = focusName || activeName || 'Live Timing'
|
||||||
|
const topRows = rows.slice(0, 3)
|
||||||
|
|
||||||
|
const analysis = handoffAnalysisSession(context, phase)
|
||||||
|
const analysisKey = sessionKeyOf(analysis)
|
||||||
|
const analysisName = analysis?.session.session_name || 'session'
|
||||||
|
const analysisReady = analysisIsReady(analysis)
|
||||||
|
|
||||||
|
const next = context?.next_session
|
||||||
|
const previous = context?.previous_completed_session
|
||||||
|
// Avoid a duplicate recap card when previous == the analysis target.
|
||||||
|
const showRecap =
|
||||||
|
previous && previous.session.session_key !== analysisKey && previous.session.session_key !== 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={`live-handoff live-handoff-${phase}`} data-testid={testid}>
|
||||||
|
<header className="live-handoff-head">
|
||||||
|
<span className="live-handoff-eyebrow mono">
|
||||||
|
{isSettling ? (
|
||||||
|
<>
|
||||||
|
<Flag size={13} /> SESSION SETTLING
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Radio size={13} /> NO LIVE SESSION
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<h1 className="live-handoff-title">{title}</h1>
|
||||||
|
{isSettling && capturedLabel && (
|
||||||
|
<p className="live-handoff-captured mono" data-testid="live-handoff-captured">
|
||||||
|
Final feed snapshot captured {capturedLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!isSettling && (
|
||||||
|
<p className="live-handoff-sub">
|
||||||
|
The timing feed is quiet between sessions. Here'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 & standings</span>
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={18} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -80,9 +80,7 @@ export function RaceControlFeed({ messages, driverInfo }: Props) {
|
|||||||
<section className="live-rc panel-glass">
|
<section className="live-rc panel-glass">
|
||||||
<div className="sec-header sticky-header">
|
<div className="sec-header sticky-header">
|
||||||
<span className="sec-title">Race Control</span>
|
<span className="sec-title">Race Control</span>
|
||||||
<span className="sec-meta" data-testid="rc-timezone">
|
{messages.length > 0 && <span className="sec-meta">{messages.length} messages</span>}
|
||||||
{messages.length > 0 ? `${messages.length} messages · ` : ''}times UTC
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{latest.length === 0 ? (
|
{latest.length === 0 ? (
|
||||||
<div className="missing-notice">No race control messages in the current live snapshot.</div>
|
<div className="missing-notice">No race control messages in the current live snapshot.</div>
|
||||||
@@ -104,7 +102,7 @@ export function RaceControlFeed({ messages, driverInfo }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`live-rc-row${flashClass}`} key={key}>
|
<div className={`live-rc-row${flashClass}`} key={key}>
|
||||||
<span className="rc-time" title="UTC">{message.Time || '--:--'}</span>
|
<span className="rc-time">{message.Time || '--:--'}</span>
|
||||||
{message.Lap > 0 && <span className="rc-lap">L{message.Lap}</span>}
|
{message.Lap > 0 && <span className="rc-lap">L{message.Lap}</span>}
|
||||||
{message.Flag
|
{message.Flag
|
||||||
? <span className={`rc-flag ${rcFlagClass(message.Flag)}`}>{message.Flag}</span>
|
? <span className={`rc-flag ${rcFlagClass(message.Flag)}`}>{message.Flag}</span>
|
||||||
|
|||||||
@@ -1,30 +1,60 @@
|
|||||||
import type { LiveStreamData } from '../../types'
|
import type { LiveStreamData } from '../../types'
|
||||||
import type { LiveTimingRow } from '../../lib/live'
|
import type { LiveTimingRow } from '../../lib/live'
|
||||||
import { extrapolateClock, liveSessionDisplay } from '../../lib/live'
|
import { extrapolateClock, liveSessionDisplay } from '../../lib/live'
|
||||||
|
import type { LivePhase, TransportHealth } from '../../lib/liveState'
|
||||||
|
import { feedHealthLabel } from '../../lib/liveState'
|
||||||
import { WeatherStrip } from './WeatherStrip'
|
import { WeatherStrip } from './WeatherStrip'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isLive: boolean
|
/** Which of the live phases we are rendering: 'live' | 'disconnected' | 'archive'. */
|
||||||
isArchive?: boolean
|
phase: LivePhase
|
||||||
snapshot: LiveStreamData
|
snapshot: LiveStreamData
|
||||||
rows: LiveTimingRow[]
|
rows: LiveTimingRow[]
|
||||||
connection: 'connected' | 'connecting' | 'disconnected' | 'error'
|
transport: TransportHealth
|
||||||
now: number
|
now: number
|
||||||
|
/** ISO capture time — required for archive, shown as the timestamp of record. */
|
||||||
|
capturedAt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionBanner({ isLive, isArchive = false, snapshot, rows, connection, now }: Props) {
|
function formatCapturedAt(capturedAt: string | null | undefined): string {
|
||||||
|
if (!capturedAt) return ''
|
||||||
|
const date = new Date(capturedAt)
|
||||||
|
if (Number.isNaN(date.getTime())) return ''
|
||||||
|
return date.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionBanner({ phase, snapshot, rows, transport, now, capturedAt }: Props) {
|
||||||
const session = snapshot.Session
|
const session = snapshot.Session
|
||||||
const clock = extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now)
|
const isArchive = phase === 'archive'
|
||||||
|
const isLiveSession = phase === 'live' || phase === 'disconnected'
|
||||||
|
// Archive is a frozen single frame: never extrapolate a running clock for it.
|
||||||
|
const clock = isArchive
|
||||||
|
? ''
|
||||||
|
: extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now)
|
||||||
const display = liveSessionDisplay(session, rows)
|
const display = liveSessionDisplay(session, rows)
|
||||||
const atRiskLabel =
|
const atRiskLabel =
|
||||||
display.atRiskStart && display.atRiskEnd ? `P${display.atRiskStart}-P${display.atRiskEnd} at risk` : ''
|
display.atRiskStart && display.atRiskEnd ? `P${display.atRiskStart}-P${display.atRiskEnd} at risk` : ''
|
||||||
const stateLabel = isLive ? 'live' : isArchive ? 'archive' : 'stale'
|
const capturedLabel = formatCapturedAt(capturedAt)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="live-banner">
|
<section className={`live-banner${isArchive ? ' live-banner-archive' : ''}`} data-testid="live-banner">
|
||||||
<div className="live-banner-row">
|
<div className="live-banner-row">
|
||||||
<div className="live-banner-main">
|
<div className="live-banner-main">
|
||||||
<span className={`live-conn live-conn-${connection}`}>{connection}</span>
|
{isArchive ? (
|
||||||
|
<span className="live-session-flag live-session-flag-archive" data-testid="live-archive-flag">
|
||||||
|
ARCHIVE
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={`live-session-flag live-session-flag-live${
|
||||||
|
phase === 'disconnected' ? ' is-stale' : ''
|
||||||
|
}`}
|
||||||
|
data-testid="live-session-flag"
|
||||||
|
>
|
||||||
|
<span className="live-session-dot" aria-hidden="true" />
|
||||||
|
LIVE SESSION
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<h1>{session?.MeetingName || 'Live Timing'}</h1>
|
<h1>{session?.MeetingName || 'Live Timing'}</h1>
|
||||||
<p>
|
<p>
|
||||||
@@ -33,17 +63,40 @@ export function SessionBanner({ isLive, isArchive = false, snapshot, rows, conne
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="live-session-board">
|
<div className="live-session-board">
|
||||||
{display.phaseLabel && <span className="live-phase-pill mono">{display.phaseLabel}</span>}
|
{/* Feed health is strictly secondary and only present for a live session. */}
|
||||||
<div className="live-clock mono" data-testid="live-clock">{clock || '--:--:--'}</div>
|
{isLiveSession && (
|
||||||
<div className="live-banner-meta">
|
<span
|
||||||
{display.advanceCount && <span>{display.advanceCount} advance</span>}
|
className={`live-feed-health live-feed-${transport}`}
|
||||||
{atRiskLabel && <span>{atRiskLabel}</span>}
|
data-testid="live-feed-health"
|
||||||
{display.isRace && (
|
title="Transport health — independent of session state"
|
||||||
<span data-testid="live-lap-counter">
|
>
|
||||||
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
|
<span className="live-feed-dot" aria-hidden="true" />
|
||||||
|
{feedHealthLabel(transport)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{stateLabel}</span>
|
{isArchive ? (
|
||||||
|
<div className="live-archive-stamp" data-testid="live-archive-stamp">
|
||||||
|
<span className="live-archive-readonly mono">READ-ONLY</span>
|
||||||
|
{capturedLabel && <span className="live-archive-captured mono">captured {capturedLabel}</span>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{display.phaseLabel && <span className="live-phase-pill mono">{display.phaseLabel}</span>}
|
||||||
|
<div className="live-clock mono" data-testid="live-clock">{clock || '--:--:--'}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="live-banner-meta">
|
||||||
|
{!isArchive && display.advanceCount && <span>{display.advanceCount} advance</span>}
|
||||||
|
{!isArchive && atRiskLabel && <span>{atRiskLabel}</span>}
|
||||||
|
<span>
|
||||||
|
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
|
||||||
|
</span>
|
||||||
|
{!isArchive && (
|
||||||
|
<span className={phase === 'live' ? 'live-state live-state-on' : 'live-state'}>
|
||||||
|
{phase === 'live' ? 'live' : 'stale'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isArchive && <span className="live-state live-state-archive">archive</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useState, useMemo, Fragment } from 'react'
|
import { useState, Fragment } from 'react'
|
||||||
import { teamColor } from '../../utils'
|
import { teamColor } from '../../utils'
|
||||||
import { useAutoAnimate } from '@formkit/auto-animate/react'
|
import { useAutoAnimate } from '@formkit/auto-animate/react'
|
||||||
import type { LiveSessionMeta, LiveStintData } from '../../types'
|
import type { LiveSessionMeta, LiveStintData } from '../../types'
|
||||||
import type { LiveTimingRow } from '../../lib/live'
|
import type { LiveTimingRow } from '../../lib/live'
|
||||||
import {
|
import {
|
||||||
bestLapGaps,
|
|
||||||
driverCode,
|
driverCode,
|
||||||
liveSessionDisplay,
|
liveSessionDisplay,
|
||||||
positionDelta,
|
positionDelta,
|
||||||
@@ -63,10 +62,6 @@ export function TimingTower({
|
|||||||
const isQuali = sessionDisplay.isQualifying || !isRace
|
const isQuali = sessionDisplay.isQualifying || !isRace
|
||||||
const columnCount = 7 + (isRace ? 3 : 0) + (isQuali ? 3 : 0)
|
const columnCount = 7 + (isRace ? 3 : 0) + (isQuali ? 3 : 0)
|
||||||
|
|
||||||
// Practice/qualifying: derive a display-only gap to P1 from valid best laps
|
|
||||||
// when the upstream feed omits GapToLeader. Empty for races (feed is truth).
|
|
||||||
const practiceGaps = useMemo(() => bestLapGaps(isRace ? [] : rows), [isRace, rows])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="scroll-x">
|
<div className="scroll-x">
|
||||||
<table className="data-table live-tower" style={{ minWidth: 760 }}>
|
<table className="data-table live-tower" style={{ minWidth: 760 }}>
|
||||||
@@ -76,7 +71,7 @@ export function TimingTower({
|
|||||||
{isRace && <th>Δ</th>}
|
{isRace && <th>Δ</th>}
|
||||||
<th>Driver</th>
|
<th>Driver</th>
|
||||||
<th>Tyre</th>
|
<th>Tyre</th>
|
||||||
<th className={isRace ? undefined : 'hide-mobile'}>Last Lap</th>
|
<th>Last Lap</th>
|
||||||
<th
|
<th
|
||||||
className="interactive"
|
className="interactive"
|
||||||
onClick={() => setGapMode(g => g === 'interval' ? 'leader' : 'interval')}
|
onClick={() => setGapMode(g => g === 'interval' ? 'leader' : 'interval')}
|
||||||
@@ -86,10 +81,10 @@ export function TimingTower({
|
|||||||
{gapMode === 'interval' && isRace ? 'Interval' : 'Gap to P1'}
|
{gapMode === 'interval' && isRace ? 'Interval' : 'Gap to P1'}
|
||||||
</th>
|
</th>
|
||||||
{isRace && <th>Trend</th>}
|
{isRace && <th>Trend</th>}
|
||||||
{isQuali && <th className="hide-mobile">S1</th>}
|
{isQuali && <th>S1</th>}
|
||||||
{isQuali && <th className="hide-mobile">S2</th>}
|
{isQuali && <th>S2</th>}
|
||||||
{isQuali && <th className="hide-mobile">S3</th>}
|
{isQuali && <th>S3</th>}
|
||||||
<th className={isRace ? 'hide-mobile' : undefined}>Best</th>
|
<th className="hide-mobile">Best</th>
|
||||||
{isRace && <th className="hide-mobile r">Laps</th>}
|
{isRace && <th className="hide-mobile r">Laps</th>}
|
||||||
<th className="r"></th>
|
<th className="r"></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -107,15 +102,7 @@ export function TimingTower({
|
|||||||
driver.Cutoff
|
driver.Cutoff
|
||||||
const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition
|
const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition
|
||||||
|
|
||||||
let gapText: string
|
const gapText = gapMode === 'interval' && isRace ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader
|
||||||
if (isRace) {
|
|
||||||
gapText = gapMode === 'interval' ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader
|
|
||||||
} else if (driver.GapToLeader) {
|
|
||||||
gapText = driver.GapToLeader
|
|
||||||
} else {
|
|
||||||
const computed = practiceGaps[row.RacingNumber]
|
|
||||||
gapText = computed ? (computed.isLeader ? '—' : computed.gap) : ''
|
|
||||||
}
|
|
||||||
const intervalAnnotation =
|
const intervalAnnotation =
|
||||||
gapMode === 'interval' && isRace && row.Position > 1
|
gapMode === 'interval' && isRace && row.Position > 1
|
||||||
? intervalMeaning(parseIntervalSeconds(gapText))
|
? intervalMeaning(parseIntervalSeconds(gapText))
|
||||||
@@ -166,10 +153,7 @@ export function TimingTower({
|
|||||||
<td>
|
<td>
|
||||||
<span className={`tyre-badge ${tyreClass(row.Tyre)}`}>{tyreLabel(row.Tyre)}</span>
|
<span className={`tyre-badge ${tyreClass(row.Tyre)}`}>{tyreLabel(row.Tyre)}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className={[
|
<td className={driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono'}>
|
||||||
isRace ? '' : 'hide-mobile',
|
|
||||||
driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono',
|
|
||||||
].filter(Boolean).join(' ')}>
|
|
||||||
{driver.LastLapTime || '-'}
|
{driver.LastLapTime || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="mono">
|
<td className="mono">
|
||||||
@@ -187,14 +171,11 @@ export function TimingTower({
|
|||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isQuali && <td className="hide-mobile">{renderSector(0)}</td>}
|
{isQuali && <td>{renderSector(0)}</td>}
|
||||||
{isQuali && <td className="hide-mobile">{renderSector(1)}</td>}
|
{isQuali && <td>{renderSector(1)}</td>}
|
||||||
{isQuali && <td className="hide-mobile">{renderSector(2)}</td>}
|
{isQuali && <td>{renderSector(2)}</td>}
|
||||||
|
|
||||||
<td className={[
|
<td className={`hide-mobile ${driver.BestLapOB ? 'mono lap-ob' : 'mono'}`}>
|
||||||
isRace ? 'hide-mobile' : '',
|
|
||||||
driver.BestLapOB ? 'mono lap-ob' : 'mono',
|
|
||||||
].filter(Boolean).join(' ')}>
|
|
||||||
{driver.BestLapTime || '-'}
|
{driver.BestLapTime || '-'}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@@ -227,16 +208,6 @@ export function TimingTower({
|
|||||||
<div className="mono">{driver.NumberOfLaps || 0}</div>
|
<div className="mono">{driver.NumberOfLaps || 0}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isRace && driver.Sectors?.some((sec) => sec?.Value) && (
|
|
||||||
<div data-testid="expanded-sectors">
|
|
||||||
<div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>SECTORS</div>
|
|
||||||
<div className="mono" style={{ display: 'flex', gap: '10px' }}>
|
|
||||||
{renderSector(0)}
|
|
||||||
{renderSector(1)}
|
|
||||||
{renderSector(2)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{driver.SpeedTrap && (
|
{driver.SpeedTrap && (
|
||||||
<div>
|
<div>
|
||||||
<div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>SPEED TRAP</div>
|
<div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>SPEED TRAP</div>
|
||||||
|
|||||||
@@ -52,9 +52,7 @@ function StintSparkline({ seconds }: { seconds: number[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
||||||
const isRace = isRaceSession(sessionType)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
const [collapsed, setCollapsed] = useState(true)
|
|
||||||
const [readerChose, setReaderChose] = useState(false)
|
|
||||||
const [stints, setStints] = useState<StintHistoryMap>({})
|
const [stints, setStints] = useState<StintHistoryMap>({})
|
||||||
|
|
||||||
// One lap-history update per received snapshot (rows is rebuilt per snapshot).
|
// One lap-history update per received snapshot (rows is rebuilt per snapshot).
|
||||||
@@ -63,6 +61,8 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
|||||||
setStints((prev) => recordStintSamples(prev, rows.map(stintInputFromRow)))
|
setStints((prev) => recordStintSamples(prev, rows.map(stintInputFromRow)))
|
||||||
}, [rows])
|
}, [rows])
|
||||||
|
|
||||||
|
const isRace = isRaceSession(sessionType)
|
||||||
|
|
||||||
const visible = useMemo(
|
const visible = useMemo(
|
||||||
() =>
|
() =>
|
||||||
rows.filter(
|
rows.filter(
|
||||||
@@ -74,30 +74,6 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
|||||||
[rows, pinned],
|
[rows, pinned],
|
||||||
)
|
)
|
||||||
|
|
||||||
// One linear fit per driver per snapshot, shared by the readiness check and
|
|
||||||
// the rows below. degradationModel is O(laps) and this runs at feed rate.
|
|
||||||
const models = useMemo(() => {
|
|
||||||
const out: Record<string, ReturnType<typeof degradationModel>> = {}
|
|
||||||
for (const row of visible) {
|
|
||||||
out[row.RacingNumber] = degradationModel(stints[row.RacingNumber]?.samples ?? [])
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}, [visible, stints])
|
|
||||||
|
|
||||||
// Before any stint has enough clean laps to fit, every row reads "warming
|
|
||||||
// up" — a full-height panel of placeholders that pushed the Timing Tower off
|
|
||||||
// the fold for the first third of a race. Stay collapsed until there is
|
|
||||||
// something to say, then open. A reader who has toggled it keeps their choice.
|
|
||||||
const hasSignal = useMemo(
|
|
||||||
() => visible.some((row) => models[row.RacingNumber] != null),
|
|
||||||
[visible, models],
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (readerChose) return
|
|
||||||
setCollapsed(!(isRace && hasSignal))
|
|
||||||
}, [isRace, hasSignal, readerChose])
|
|
||||||
|
|
||||||
if (visible.length === 0) return null
|
if (visible.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -105,25 +81,18 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="sec-header tyredeg-toggle"
|
className="sec-header tyredeg-toggle"
|
||||||
onClick={() => {
|
onClick={() => setCollapsed((prev) => !prev)}
|
||||||
setReaderChose(true)
|
|
||||||
setCollapsed((prev) => !prev)
|
|
||||||
}}
|
|
||||||
aria-expanded={!collapsed}
|
aria-expanded={!collapsed}
|
||||||
>
|
>
|
||||||
<span className="sec-title">Tyre Deg & Pit Window</span>
|
<span className="sec-title">Tyre Deg & Pit Window</span>
|
||||||
{!hasSignal ? (
|
{isRace && <span className="sec-meta">rejoin assumes ~{PIT_LOSS_SECONDS}s pit loss</span>}
|
||||||
<span className="sec-meta">collecting clean laps</span>
|
|
||||||
) : (
|
|
||||||
isRace && <span className="sec-meta">rejoin assumes ~{PIT_LOSS_SECONDS}s pit loss</span>
|
|
||||||
)}
|
|
||||||
<span className="tyredeg-chevron" aria-hidden="true">{collapsed ? '▸' : '▾'}</span>
|
<span className="tyredeg-chevron" aria-hidden="true">{collapsed ? '▸' : '▾'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="tyredeg-rows">
|
<div className="tyredeg-rows">
|
||||||
{visible.map((row) => {
|
{visible.map((row) => {
|
||||||
const model = models[row.RacingNumber]
|
const model = degradationModel(stints[row.RacingNumber]?.samples ?? [])
|
||||||
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
|
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
|
||||||
const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age)
|
const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age)
|
||||||
return (
|
return (
|
||||||
|
|||||||
152
frontend/src/components/weekend/BetweenRacesView.tsx
Normal file
152
frontend/src/components/weekend/BetweenRacesView.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { ChevronRight, Flag as FlagIcon } from 'lucide-react'
|
||||||
|
import type {
|
||||||
|
WeekendBriefingItem,
|
||||||
|
WeekendChampionshipImpact,
|
||||||
|
WeekendContext,
|
||||||
|
WeekendViewState,
|
||||||
|
} from '../../types'
|
||||||
|
import {
|
||||||
|
BriefingStrip,
|
||||||
|
ChampionshipImpactCard,
|
||||||
|
ChampionshipRoundStrip,
|
||||||
|
CountdownDisplay,
|
||||||
|
EventPodium,
|
||||||
|
Flag,
|
||||||
|
} from './shared'
|
||||||
|
import { analysisSessionKey, meetingIdentity } from '../../lib/weekendContext'
|
||||||
|
import { parseScheduleTime } from '../../lib/schedule'
|
||||||
|
|
||||||
|
const EYEBROW: Record<string, string> = {
|
||||||
|
between_weekends: 'Between races',
|
||||||
|
post_weekend: 'Post-weekend',
|
||||||
|
season_complete: 'Season complete',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSessionLine(name: string | undefined, start: string | undefined): string {
|
||||||
|
if (!name) return 'Schedule to be confirmed'
|
||||||
|
const date = start ? parseScheduleTime(start) : null
|
||||||
|
if (!date) return name
|
||||||
|
const when = date.toLocaleString('en-GB', {
|
||||||
|
weekday: 'long',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
return `${name} · ${when}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BetweenRacesView({
|
||||||
|
context,
|
||||||
|
now,
|
||||||
|
view,
|
||||||
|
championship,
|
||||||
|
briefing,
|
||||||
|
}: {
|
||||||
|
context: WeekendContext
|
||||||
|
now: Date
|
||||||
|
view: WeekendViewState
|
||||||
|
championship?: WeekendChampionshipImpact
|
||||||
|
briefing: WeekendBriefingItem[]
|
||||||
|
}) {
|
||||||
|
const eyebrow = EYEBROW[view] ?? 'Between races'
|
||||||
|
|
||||||
|
const previous = context.previous_completed_session
|
||||||
|
const previousMeeting = meetingIdentity(previous?.meeting ?? context.previous_meeting)
|
||||||
|
const nextMeeting = meetingIdentity(context.next_meeting)
|
||||||
|
const nextSession = context.next_session?.session
|
||||||
|
const analysisKey = analysisSessionKey(context)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wk-between" data-testid="weekend-between-races" data-state={view}>
|
||||||
|
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">{eyebrow}</div>
|
||||||
|
|
||||||
|
<div className="wk-top-grid">
|
||||||
|
{previousMeeting && (
|
||||||
|
<section className="wk-event-card wk-event-last" data-testid="wk-last-event">
|
||||||
|
<header className="wk-event-head">
|
||||||
|
<div className="wk-event-id">
|
||||||
|
<Flag code={previousMeeting.country_code} flag={previousMeeting.country_flag} />
|
||||||
|
<h2 className="wk-event-name">{previousMeeting.meeting_name}</h2>
|
||||||
|
</div>
|
||||||
|
<span className="wk-event-tag wk-tag-done mono">
|
||||||
|
<FlagIcon size={13} aria-hidden="true" /> Completed
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div className="wk-event-body">
|
||||||
|
<EventPodium sessionKey={analysisKey} />
|
||||||
|
<div className="wk-story-card">
|
||||||
|
<span className="wk-story-label">What decided it?</span>
|
||||||
|
{analysisKey ? (
|
||||||
|
<Link
|
||||||
|
to="/race-hub"
|
||||||
|
search={{ session_key: analysisKey }}
|
||||||
|
className="wk-cta wk-cta-primary"
|
||||||
|
data-testid="wk-explore-race-story"
|
||||||
|
>
|
||||||
|
Explore Race Story <ChevronRight size={15} aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<p className="wk-story-text" data-testid="wk-no-analysis">
|
||||||
|
Analysis for this session is not available locally yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nextMeeting ? (
|
||||||
|
<section className="wk-event-card wk-event-next" data-testid="wk-next-event">
|
||||||
|
<header className="wk-event-head">
|
||||||
|
<div className="wk-event-id">
|
||||||
|
<Flag code={nextMeeting.country_code} flag={nextMeeting.country_flag} />
|
||||||
|
<h2 className="wk-event-name">{nextMeeting.meeting_name}</h2>
|
||||||
|
</div>
|
||||||
|
<span className="wk-event-tag wk-tag-next mono">Next event</span>
|
||||||
|
</header>
|
||||||
|
<div className="wk-next-body">
|
||||||
|
<CountdownDisplay target={nextSession?.date_start ?? nextMeeting.date_start} now={now} />
|
||||||
|
<div className="wk-next-session">
|
||||||
|
<span className="wk-next-label mono">Next session</span>
|
||||||
|
<span className="wk-next-value">
|
||||||
|
{formatSessionLine(nextSession?.session_name, nextSession?.date_start)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Link to="/preview" className="wk-cta wk-cta-primary wk-cta-wide" data-testid="wk-prepare">
|
||||||
|
Prepare for {nextMeeting.short_name}
|
||||||
|
<ChevronRight size={15} aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="wk-event-card wk-event-next" data-testid="wk-season-complete-card">
|
||||||
|
<header className="wk-event-head">
|
||||||
|
<h2 className="wk-event-name">That'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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
frontend/src/components/weekend/BetweenSessionsView.tsx
Normal file
86
frontend/src/components/weekend/BetweenSessionsView.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { ChevronRight } from 'lucide-react'
|
||||||
|
import type { WeekendBriefingItem, WeekendContext, WeekendViewState } from '../../types'
|
||||||
|
import { BriefingStrip, CountdownDisplay, EventPodium, SessionRail, railNodes } from './shared'
|
||||||
|
import { analysisSessionKey, meetingIdentity } from '../../lib/weekendContext'
|
||||||
|
import { parseScheduleTime } from '../../lib/schedule'
|
||||||
|
|
||||||
|
function nextSessionWhen(start: string | undefined): string {
|
||||||
|
const date = start ? parseScheduleTime(start) : null
|
||||||
|
if (!date) return 'Time to be confirmed'
|
||||||
|
return date.toLocaleString('en-GB', {
|
||||||
|
weekday: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BetweenSessionsView({
|
||||||
|
context,
|
||||||
|
now,
|
||||||
|
view,
|
||||||
|
briefing,
|
||||||
|
}: {
|
||||||
|
context: WeekendContext
|
||||||
|
now: Date
|
||||||
|
view: WeekendViewState
|
||||||
|
briefing: WeekendBriefingItem[]
|
||||||
|
}) {
|
||||||
|
const settling = view === 'session_settling'
|
||||||
|
const focus = meetingIdentity(context.focus_meeting)
|
||||||
|
const previous = context.previous_completed_session
|
||||||
|
const previousName = previous?.session.session_name ?? 'Last session'
|
||||||
|
const next = context.next_session?.session
|
||||||
|
// Settling recap must open the just-finished session, not an older default.
|
||||||
|
const previousKey = previous?.session.session_key
|
||||||
|
const analysisKey =
|
||||||
|
settling && previousKey && previousKey > 0 ? previousKey : analysisSessionKey(context)
|
||||||
|
const nodes = railNodes(previous, context.active_session, context.next_session)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wk-sessions" data-testid="weekend-between-sessions" data-state={view}>
|
||||||
|
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">
|
||||||
|
{settling ? 'Session settling' : 'Between sessions'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header className="wk-sessions-head">
|
||||||
|
<h1 className="wk-sessions-title">{focus?.meeting_name ?? 'Race weekend'}</h1>
|
||||||
|
{focus?.circuit_short_name && <p className="wk-sessions-circuit mono">{focus.circuit_short_name}</p>}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<SessionRail nodes={nodes} />
|
||||||
|
|
||||||
|
{previous && (
|
||||||
|
<section className="wk-recap-card" data-testid="wk-last-session">
|
||||||
|
<span className="wk-recap-eyebrow mono">
|
||||||
|
{previousName} · {settling ? 'Settling' : 'Complete'}
|
||||||
|
</span>
|
||||||
|
<h2 className="wk-recap-title">What happened in {previousName}</h2>
|
||||||
|
<EventPodium sessionKey={analysisKey} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{next && (
|
||||||
|
<section className="wk-upnext-card" data-testid="wk-next-session">
|
||||||
|
<span className="wk-upnext-eyebrow mono">Up next</span>
|
||||||
|
<h2 className="wk-upnext-title">{next.session_name}</h2>
|
||||||
|
<CountdownDisplay target={next.date_start} now={now} compact />
|
||||||
|
<p className="wk-upnext-when mono">{nextSessionWhen(next.date_start)}</p>
|
||||||
|
{analysisKey && (
|
||||||
|
<Link
|
||||||
|
to="/race-hub"
|
||||||
|
search={{ session_key: analysisKey }}
|
||||||
|
className="wk-cta wk-cta-primary wk-cta-wide"
|
||||||
|
data-testid="wk-view-recap"
|
||||||
|
>
|
||||||
|
View {previousName} recap <ChevronRight size={15} aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{briefing.length > 0 && <BriefingStrip items={briefing} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
frontend/src/components/weekend/LiveHandoffView.tsx
Normal file
47
frontend/src/components/weekend/LiveHandoffView.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { ChevronRight, Radio } from 'lucide-react'
|
||||||
|
import type { WeekendContext } from '../../types'
|
||||||
|
import { EventPodium, Flag, SessionRail, railNodes } from './shared'
|
||||||
|
import { analysisSessionKey, meetingIdentity } from '../../lib/weekendContext'
|
||||||
|
|
||||||
|
export function LiveHandoffView({ context }: { context: WeekendContext }) {
|
||||||
|
const focus = meetingIdentity(context.focus_meeting ?? context.active_session?.meeting)
|
||||||
|
const active = context.active_session?.session
|
||||||
|
const previous = context.previous_completed_session
|
||||||
|
const previousName = previous?.session.session_name ?? 'Last session'
|
||||||
|
const analysisKey = analysisSessionKey(context)
|
||||||
|
const nodes = railNodes(previous, context.active_session, context.next_session)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wk-live" data-testid="weekend-live" data-state="session_live">
|
||||||
|
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">Session live</div>
|
||||||
|
|
||||||
|
<section className="wk-live-card" data-testid="wk-live-card">
|
||||||
|
<div className="wk-live-head">
|
||||||
|
<div className="wk-event-id">
|
||||||
|
<Flag code={focus?.country_code} flag={focus?.country_flag} />
|
||||||
|
<h1 className="wk-sessions-title">{focus?.meeting_name ?? 'Live session'}</h1>
|
||||||
|
</div>
|
||||||
|
<span className="wk-live-pulse" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
{focus?.circuit_short_name && <p className="wk-sessions-circuit mono">{focus.circuit_short_name}</p>}
|
||||||
|
<p className="wk-live-lead">
|
||||||
|
{active?.session_name ? `${active.session_name} is on track now.` : 'A session is running now.'}
|
||||||
|
</p>
|
||||||
|
<Link to="/live" className="wk-cta wk-cta-primary wk-cta-wide" data-testid="wk-watch-live">
|
||||||
|
<Radio size={15} aria-hidden="true" /> Watch live timing <ChevronRight size={15} aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<SessionRail nodes={nodes} />
|
||||||
|
|
||||||
|
{previous && (
|
||||||
|
<section className="wk-recap-card" data-testid="wk-last-session">
|
||||||
|
<span className="wk-recap-eyebrow mono">{previousName} · Complete</span>
|
||||||
|
<h2 className="wk-recap-title">What happened in {previousName}</h2>
|
||||||
|
<EventPodium sessionKey={analysisKey} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
frontend/src/components/weekend/PreSessionView.tsx
Normal file
75
frontend/src/components/weekend/PreSessionView.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { ChevronRight } from 'lucide-react'
|
||||||
|
import type { WeekendContext } from '../../types'
|
||||||
|
import { RacePreviewPage } from '../../pages/RacePreviewPage'
|
||||||
|
import { ChampionshipRoundStrip, CountdownDisplay, Flag, SessionRail, railNodes } from './shared'
|
||||||
|
import { meetingIdentity } from '../../lib/weekendContext'
|
||||||
|
import type { DataAvailability } from '../../lib/availability'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PreSessionView folds the race preview surface into the Weekend home so there is
|
||||||
|
* no separate Preview destination. It reuses the existing preview page content and
|
||||||
|
* frames it with the next-session countdown and compact season navigation. It also
|
||||||
|
* backs the /preview alias, so saved preview links resolve here instead of looping.
|
||||||
|
*
|
||||||
|
* Canonical meeting/session identity is passed into Preview so the nested surface
|
||||||
|
* cannot independently re-resolve a different current weekend.
|
||||||
|
*/
|
||||||
|
export function PreSessionView({
|
||||||
|
context,
|
||||||
|
now,
|
||||||
|
shellAvailability = null,
|
||||||
|
}: {
|
||||||
|
context: WeekendContext
|
||||||
|
now: Date
|
||||||
|
/** Weekend shell notice already shown — Preview dedupes only an equivalent kind. */
|
||||||
|
shellAvailability?: DataAvailability | null
|
||||||
|
}) {
|
||||||
|
const meeting = context.next_meeting ?? context.focus_meeting
|
||||||
|
const identity = meetingIdentity(meeting)
|
||||||
|
const next = context.next_session?.session
|
||||||
|
const nextStart = next?.date_start ?? meeting?.date_start
|
||||||
|
const nodes = railNodes(
|
||||||
|
context.previous_completed_session,
|
||||||
|
context.active_session,
|
||||||
|
context.next_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wk-pre" data-testid="weekend-pre-session" data-state="pre_session">
|
||||||
|
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">Pre-session</div>
|
||||||
|
|
||||||
|
{identity && (
|
||||||
|
<header className="wk-pre-head" data-testid="wk-pre-head">
|
||||||
|
<div className="wk-event-id">
|
||||||
|
<Flag code={identity.country_code} flag={identity.country_flag} />
|
||||||
|
<h1 className="wk-sessions-title">{identity.meeting_name}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="wk-pre-countdown">
|
||||||
|
<span className="wk-next-label mono">{next?.session_name ?? 'Next session'}</span>
|
||||||
|
<CountdownDisplay target={nextStart} now={now} />
|
||||||
|
</div>
|
||||||
|
<Link to="/live" className="wk-cta wk-cta-ghost" data-testid="wk-pre-live">
|
||||||
|
Live timing <ChevronRight size={14} aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nodes.length > 0 && <SessionRail nodes={nodes} />}
|
||||||
|
|
||||||
|
<div className="wk-pre-preview" data-testid="wk-pre-preview">
|
||||||
|
<RacePreviewPage
|
||||||
|
embedded
|
||||||
|
meeting={meeting}
|
||||||
|
season={context.season ?? meeting?.year}
|
||||||
|
shellAvailability={shellAvailability}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChampionshipRoundStrip
|
||||||
|
round={context.championship_round}
|
||||||
|
total={context.total_championship_rounds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
frontend/src/components/weekend/StatusViews.tsx
Normal file
65
frontend/src/components/weekend/StatusViews.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { userFacingError } from '../../lib/fetch'
|
||||||
|
|
||||||
|
export function WeekendLoading() {
|
||||||
|
return (
|
||||||
|
<div className="wk-status wk-status-loading" data-testid="weekend-loading" aria-busy="true">
|
||||||
|
<span className="wk-status-eyebrow mono">box-box · weekend</span>
|
||||||
|
<p className="wk-status-title">Reading the weekend…</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeekendError({
|
||||||
|
error,
|
||||||
|
message,
|
||||||
|
onRetry,
|
||||||
|
retrying = false,
|
||||||
|
}: {
|
||||||
|
error?: unknown
|
||||||
|
message?: string
|
||||||
|
onRetry?: () => void
|
||||||
|
retrying?: boolean
|
||||||
|
}) {
|
||||||
|
const resolved =
|
||||||
|
message ??
|
||||||
|
(error != null ? userFacingError(error) : 'Something went wrong loading the weekend context.')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wk-status wk-status-error" data-testid="weekend-error" role="alert">
|
||||||
|
<span className="wk-status-eyebrow mono">box-box · weekend</span>
|
||||||
|
<p className="wk-status-title">Weekend unavailable</p>
|
||||||
|
<p className="wk-status-sub">{resolved}</p>
|
||||||
|
{typeof onRetry === 'function' && (
|
||||||
|
<div className="wk-status-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="wk-cta wk-cta-primary"
|
||||||
|
onClick={onRetry}
|
||||||
|
disabled={retrying}
|
||||||
|
aria-busy={retrying || undefined}
|
||||||
|
data-testid="weekend-retry"
|
||||||
|
>
|
||||||
|
{retrying ? 'Retrying…' : 'Retry'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeekendLimited({ message, season }: { message?: string; season?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="wk-status wk-status-limited" data-testid="weekend-limited">
|
||||||
|
<span className="wk-status-eyebrow mono">box-box · weekend</span>
|
||||||
|
<p className="wk-status-title">Nothing to show yet</p>
|
||||||
|
<p className="wk-status-sub">
|
||||||
|
{message ?? `No weekend data for the ${season || 'current'} season is available right now.`}
|
||||||
|
</p>
|
||||||
|
<div className="wk-status-actions">
|
||||||
|
<Link to="/live" className="wk-cta wk-cta-ghost">Live timing</Link>
|
||||||
|
<Link to="/explore" className="wk-cta wk-cta-ghost">Explore</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
frontend/src/components/weekend/WeekendFocusBanner.tsx
Normal file
65
frontend/src/components/weekend/WeekendFocusBanner.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { ChevronRight } from 'lucide-react'
|
||||||
|
import { fetchWeekend } from '../../api'
|
||||||
|
import { meetingIdentity } from '../../lib/weekendContext'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores Race Hub → Weekend meeting/session context from the URL search
|
||||||
|
* contract. Deep-linkable and reload-safe — no hidden component memory.
|
||||||
|
*/
|
||||||
|
export function WeekendFocusBanner({
|
||||||
|
meetingKey,
|
||||||
|
sessionKey,
|
||||||
|
}: {
|
||||||
|
meetingKey?: number
|
||||||
|
sessionKey?: number
|
||||||
|
}) {
|
||||||
|
const hasFocus = (meetingKey != null && meetingKey > 0) || (sessionKey != null && sessionKey > 0)
|
||||||
|
const weekendQuery = useQuery({
|
||||||
|
queryKey: ['weekend', meetingKey],
|
||||||
|
queryFn: () => fetchWeekend(meetingKey!),
|
||||||
|
enabled: meetingKey != null && meetingKey > 0,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!hasFocus) return null
|
||||||
|
|
||||||
|
const weekend = weekendQuery.data
|
||||||
|
const meeting = meetingIdentity(weekend?.meeting)
|
||||||
|
const session = weekend?.sessions.find((s) => s.session.session_key === sessionKey)?.session
|
||||||
|
const meetingLabel = meeting?.meeting_name ?? (meetingKey ? `Meeting ${meetingKey}` : 'Selected weekend')
|
||||||
|
const sessionLabel = session?.session_name ?? (sessionKey ? `Session ${sessionKey}` : null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="wk-focus"
|
||||||
|
data-testid="wk-focus-context"
|
||||||
|
data-meeting-key={meetingKey ?? undefined}
|
||||||
|
data-session-key={sessionKey ?? undefined}
|
||||||
|
aria-label="Selected weekend context"
|
||||||
|
>
|
||||||
|
<div className="wk-focus-copy">
|
||||||
|
<span className="wk-focus-eyebrow mono">From Race Hub</span>
|
||||||
|
<h2 className="wk-focus-title" data-testid="wk-focus-meeting">
|
||||||
|
{meetingLabel}
|
||||||
|
</h2>
|
||||||
|
{sessionLabel && (
|
||||||
|
<p className="wk-focus-session" data-testid="wk-focus-session">
|
||||||
|
Selected session · {sessionLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{sessionKey != null && sessionKey > 0 && (
|
||||||
|
<Link
|
||||||
|
to="/race-hub"
|
||||||
|
search={{ session_key: sessionKey }}
|
||||||
|
className="wk-cta wk-cta-primary wk-focus-cta"
|
||||||
|
data-testid="wk-return-analysis"
|
||||||
|
>
|
||||||
|
Continue analysis <ChevronRight size={15} aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
277
frontend/src/components/weekend/shared.tsx
Normal file
277
frontend/src/components/weekend/shared.tsx
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { fetchRaceHub } from '../../api'
|
||||||
|
import { countryFlag } from '../../lib/gpIdentity'
|
||||||
|
import { parseScheduleTime } from '../../lib/schedule'
|
||||||
|
import { podiumFromResults } from '../../lib/weekendContext'
|
||||||
|
import { teamColor } from '../../utils'
|
||||||
|
import type {
|
||||||
|
ContextSession,
|
||||||
|
WeekendChampionshipImpact,
|
||||||
|
WeekendBriefingItem,
|
||||||
|
WeekendPodiumEntry,
|
||||||
|
} from '../../types'
|
||||||
|
|
||||||
|
export function Flag({ code, flag }: { code?: string; flag?: string }) {
|
||||||
|
const glyph = countryFlag({ country_code: code ?? '', country_flag: flag ?? '' })
|
||||||
|
if (!glyph) return null
|
||||||
|
return <span className="wk-flag" aria-hidden="true">{glyph}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CountdownParts {
|
||||||
|
days: number
|
||||||
|
hours: number
|
||||||
|
minutes: number
|
||||||
|
seconds: number
|
||||||
|
reached: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countdownParts(target: string | undefined, now: Date): CountdownParts | null {
|
||||||
|
const date = target ? parseScheduleTime(target) : null
|
||||||
|
if (!date) return null
|
||||||
|
const diff = date.getTime() - now.getTime()
|
||||||
|
const clamped = Math.max(0, diff)
|
||||||
|
const totalSeconds = Math.floor(clamped / 1000)
|
||||||
|
return {
|
||||||
|
days: Math.floor(totalSeconds / 86400),
|
||||||
|
hours: Math.floor((totalSeconds % 86400) / 3600),
|
||||||
|
minutes: Math.floor((totalSeconds % 3600) / 60),
|
||||||
|
seconds: totalSeconds % 60,
|
||||||
|
reached: diff <= 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CountdownDisplay({
|
||||||
|
target,
|
||||||
|
now,
|
||||||
|
compact,
|
||||||
|
}: {
|
||||||
|
target: string | undefined
|
||||||
|
now: Date
|
||||||
|
compact?: boolean
|
||||||
|
}) {
|
||||||
|
const parts = countdownParts(target, now)
|
||||||
|
if (!parts) {
|
||||||
|
return (
|
||||||
|
<span className="wk-countdown-tbc mono" data-testid="wk-countdown">
|
||||||
|
Schedule TBC
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (parts.reached) {
|
||||||
|
return <span className="wk-countdown-live" data-testid="wk-countdown">Starting now</span>
|
||||||
|
}
|
||||||
|
if (compact || parts.days === 0) {
|
||||||
|
const hh = String(parts.days * 24 + parts.hours).padStart(2, '0')
|
||||||
|
return (
|
||||||
|
<span className="wk-countdown-clock mono" data-testid="wk-countdown">
|
||||||
|
{hh}:{String(parts.minutes).padStart(2, '0')}:{String(parts.seconds).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="wk-countdown" data-testid="wk-countdown">
|
||||||
|
<span className="wk-count-unit"><b>{parts.days}</b>D</span>
|
||||||
|
<span className="wk-count-unit"><b>{String(parts.hours).padStart(2, '0')}</b>H</span>
|
||||||
|
<span className="wk-count-unit"><b>{String(parts.minutes).padStart(2, '0')}</b>M</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EventPodium fetches and renders the podium for a completed analysis session.
|
||||||
|
* When no analysable session key is available (e.g. an archived result with no
|
||||||
|
* local analysis), it renders an explicit empty state rather than fetching.
|
||||||
|
*/
|
||||||
|
export function EventPodium({ sessionKey }: { sessionKey?: number }) {
|
||||||
|
const enabled = typeof sessionKey === 'number' && sessionKey > 0
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['race-hub', sessionKey, 'podium'],
|
||||||
|
queryFn: () => fetchRaceHub(sessionKey as number),
|
||||||
|
enabled,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const podium: WeekendPodiumEntry[] = podiumFromResults(query.data?.results ?? [])
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return (
|
||||||
|
<div className="wk-podium-empty" data-testid="wk-podium-empty">
|
||||||
|
Result not available yet.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (podium.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="wk-podium-empty" data-testid="wk-podium-empty">
|
||||||
|
{query.isLoading ? 'Loading result…' : 'Result not available yet.'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ol className="wk-podium" data-testid="wk-podium">
|
||||||
|
{podium.map((p) => (
|
||||||
|
<li key={p.driver_number} className="wk-podium-row" style={{ ['--wk-team' as string]: teamColor(p.team_colour) }}>
|
||||||
|
<span className="wk-podium-pos mono">{p.position}</span>
|
||||||
|
<span className="wk-podium-code">{p.name_acronym}</span>
|
||||||
|
<span className="wk-podium-gap mono">{p.gap}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChampionshipImpactCard({ impact }: { impact: WeekendChampionshipImpact }) {
|
||||||
|
const leader = impact.leaders[0]
|
||||||
|
return (
|
||||||
|
<section className="wk-champ" data-testid="wk-champ-impact">
|
||||||
|
<header className="wk-card-head">
|
||||||
|
<span className="wk-card-title">Championship impact</span>
|
||||||
|
<Link to="/championship" className="wk-card-link">Standings →</Link>
|
||||||
|
</header>
|
||||||
|
<ol className="wk-champ-list">
|
||||||
|
{impact.leaders.map((d) => {
|
||||||
|
const gap = leader && d.position !== 1 ? leader.points - d.points : 0
|
||||||
|
return (
|
||||||
|
<li key={d.driver_number} className="wk-champ-row" style={{ ['--wk-team' as string]: teamColor(d.team_colour) }}>
|
||||||
|
<span className="wk-champ-pos mono">{d.position}</span>
|
||||||
|
<span className="wk-champ-code">{d.name_acronym}</span>
|
||||||
|
<span className="wk-champ-pts mono">{d.points}</span>
|
||||||
|
<span className="wk-champ-gap mono">{d.position === 1 ? '—' : `-${gap}`}</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
{impact.note && <p className="wk-champ-note">{impact.note}</p>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BriefingStrip({ items }: { items: WeekendBriefingItem[] }) {
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<section className="wk-briefing" data-testid="wk-briefing">
|
||||||
|
<header className="wk-card-head">
|
||||||
|
<span className="wk-card-title">Since the chequered flag</span>
|
||||||
|
<Link to="/briefing" className="wk-card-link">All briefing →</Link>
|
||||||
|
</header>
|
||||||
|
<ul className="wk-briefing-list">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.url} className="wk-briefing-item">
|
||||||
|
<a href={item.url} target="_blank" rel="noreferrer" className="wk-briefing-link">
|
||||||
|
{item.category && <span className="wk-briefing-cat mono">{item.category}</span>}
|
||||||
|
<span className="wk-briefing-title">{item.title}</span>
|
||||||
|
<span className="wk-briefing-source mono">{item.source}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ChampionshipRoundStrip is a compact, progressively-disclosed round indicator.
|
||||||
|
* The canonical contract exposes only the current round and total, so the strip
|
||||||
|
* shows "Round N of M" and links out to Explore for the full calendar rather than
|
||||||
|
* reintroducing the whole calendar on the Weekend home.
|
||||||
|
*/
|
||||||
|
export function ChampionshipRoundStrip({ round, total }: { round: number; total: number }) {
|
||||||
|
if (round <= 0 || total <= 0) return null
|
||||||
|
const pct = Math.min(100, Math.round((round / total) * 100))
|
||||||
|
return (
|
||||||
|
<section className="wk-season-nav" data-testid="wk-season-nav" aria-label="Season progress">
|
||||||
|
<div className="wk-season-progress">
|
||||||
|
<div className="wk-season-progress-head">
|
||||||
|
<span className="wk-card-title">Season progress</span>
|
||||||
|
<Link to="/explore" className="wk-card-link" data-testid="wk-season-explore">
|
||||||
|
Full calendar →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="wk-season-bar"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={round}
|
||||||
|
aria-valuemin={1}
|
||||||
|
aria-valuemax={total}
|
||||||
|
aria-label={`Round ${round} of ${total}`}
|
||||||
|
>
|
||||||
|
<span className="wk-season-bar-fill" style={{ width: `${pct}%` }} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<p className="wk-season-progress-label mono">
|
||||||
|
Round {round} of {total}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimelineNode {
|
||||||
|
key: string
|
||||||
|
session_name: string
|
||||||
|
status: 'done' | 'live' | 'next'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SessionRail renders the previous/active/next sessions the canonical context
|
||||||
|
* exposes as a compact three-node rail. It is intentionally derived only from the
|
||||||
|
* canonical refs — the contract does not enumerate a full weekend session list.
|
||||||
|
*/
|
||||||
|
export function SessionRail({ nodes }: { nodes: TimelineNode[] }) {
|
||||||
|
if (nodes.length === 0) return null
|
||||||
|
return (
|
||||||
|
<ol className="wk-timeline" data-testid="wk-timeline" role="list">
|
||||||
|
{nodes.map((n) => (
|
||||||
|
<li key={n.key} className={`wk-timeline-node wk-timeline-${n.status}`} role="listitem">
|
||||||
|
<span className="wk-timeline-dot" aria-hidden="true" />
|
||||||
|
<span className="wk-timeline-name">{shortSessionName(n.session_name)}</span>
|
||||||
|
<span className="wk-timeline-state mono">{stateLabel(n.status)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the compact session rail from the canonical previous/active/next refs. */
|
||||||
|
export function railNodes(
|
||||||
|
previous: ContextSession | undefined,
|
||||||
|
active: ContextSession | undefined,
|
||||||
|
next: ContextSession | undefined,
|
||||||
|
): TimelineNode[] {
|
||||||
|
const nodes: TimelineNode[] = []
|
||||||
|
if (previous?.session.session_name) {
|
||||||
|
nodes.push({ key: `prev-${previous.session.session_key}`, session_name: previous.session.session_name, status: 'done' })
|
||||||
|
}
|
||||||
|
if (active?.session.session_name) {
|
||||||
|
nodes.push({ key: `live-${active.session.session_key}`, session_name: active.session.session_name, status: 'live' })
|
||||||
|
}
|
||||||
|
if (next?.session.session_name) {
|
||||||
|
nodes.push({ key: `next-${next.session.session_key}`, session_name: next.session.session_name, status: 'next' })
|
||||||
|
}
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortSessionName(name: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'Practice 1': 'FP1',
|
||||||
|
'Practice 2': 'FP2',
|
||||||
|
'Practice 3': 'FP3',
|
||||||
|
Qualifying: 'Quali',
|
||||||
|
'Sprint Qualifying': 'SQ',
|
||||||
|
'Sprint Shootout': 'SQ',
|
||||||
|
}
|
||||||
|
return map[name] ?? name
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateLabel(status: TimelineNode['status']): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'done':
|
||||||
|
return 'Complete'
|
||||||
|
case 'live':
|
||||||
|
return 'Live'
|
||||||
|
default:
|
||||||
|
return 'Next'
|
||||||
|
}
|
||||||
|
}
|
||||||
140
frontend/src/hooks/useWeekendContext.ts
Normal file
140
frontend/src/hooks/useWeekendContext.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { fetchChampionshipHub, fetchNews, fetchWeekendContext } from '../api'
|
||||||
|
import { weekendContextNotice, type DataAvailability } from '../lib/availability'
|
||||||
|
import { championshipImpact, briefingItems } from '../lib/weekendContext'
|
||||||
|
import type {
|
||||||
|
WeekendChampionshipImpact,
|
||||||
|
WeekendBriefingItem,
|
||||||
|
WeekendContext,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
export type WeekendLoadState = 'loading' | 'error' | 'ready'
|
||||||
|
|
||||||
|
export interface UseWeekendContextResult {
|
||||||
|
/** Canonical context, present only when loadState === 'ready'. */
|
||||||
|
context: WeekendContext | null
|
||||||
|
loadState: WeekendLoadState
|
||||||
|
error?: Error
|
||||||
|
/** Supplementary championship movers (not part of the #72 contract). */
|
||||||
|
championship?: WeekendChampionshipImpact
|
||||||
|
/** Supplementary briefing items (not part of the #72 contract). */
|
||||||
|
briefing: WeekendBriefingItem[]
|
||||||
|
/** Non-blocking availability notice from reported context freshness/headers. */
|
||||||
|
availabilityNotice: DataAvailability | null
|
||||||
|
/** True when championship or news supplements failed while context succeeded. */
|
||||||
|
supplementsLimited: boolean
|
||||||
|
now: Date
|
||||||
|
/** Refetch canonical context and/or failed supplements as appropriate. */
|
||||||
|
refetch: () => void
|
||||||
|
/** True while a relevant refetch is in flight. */
|
||||||
|
isFetching: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useWeekendContext reads the canonical /api/v1/weekend-context endpoint as the
|
||||||
|
* single source of truth for the Weekend home. When the canonical read succeeds
|
||||||
|
* it layers on two pieces of supplementary data that the contract intentionally
|
||||||
|
* omits — championship movers and the paddock briefing — and nothing else.
|
||||||
|
*
|
||||||
|
* It deliberately does NOT fan out to season / meetings / per-weekend / OpenF1
|
||||||
|
* session / live-state queries: those would defeat the local-first canonical read
|
||||||
|
* model and can hit the rate-limited OpenF1 REST surface during active sessions.
|
||||||
|
* Supplementary queries are gated on a successful canonical read so a failing or
|
||||||
|
* pending endpoint issues no extra requests.
|
||||||
|
*/
|
||||||
|
export function useWeekendContext(): UseWeekendContextResult {
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [])
|
||||||
|
const nowDate = useMemo(() => new Date(now), [now])
|
||||||
|
|
||||||
|
const contextQuery = useQuery({
|
||||||
|
queryKey: ['weekend-context'],
|
||||||
|
queryFn: ({ signal }) => fetchWeekendContext(signal),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const canonical = contextQuery.data ?? null
|
||||||
|
const canonicalReady = canonical != null
|
||||||
|
const season = canonical?.season
|
||||||
|
|
||||||
|
// Supplementary data — only fetched once the canonical context has resolved,
|
||||||
|
// so a pending/failed canonical read never triggers a request fan-out.
|
||||||
|
const championshipQuery = useQuery({
|
||||||
|
queryKey: ['championship-hub', season ?? 'current'],
|
||||||
|
queryFn: ({ signal }) => fetchChampionshipHub(season, signal),
|
||||||
|
enabled: canonicalReady,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const newsQuery = useQuery({
|
||||||
|
queryKey: ['news', 6],
|
||||||
|
queryFn: ({ signal }) => fetchNews(6, undefined, signal),
|
||||||
|
enabled: canonicalReady,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const championship = useMemo(
|
||||||
|
() => championshipImpact(championshipQuery.data),
|
||||||
|
[championshipQuery.data],
|
||||||
|
)
|
||||||
|
const briefing = useMemo(() => briefingItems(newsQuery.data ?? []), [newsQuery.data])
|
||||||
|
|
||||||
|
const availabilityNotice = useMemo(
|
||||||
|
() => (canonical ? weekendContextNotice(canonical) : null),
|
||||||
|
[canonical],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Keep Limited visible while a failed supplement is refetching so Retry can
|
||||||
|
// expose disabled/aria-busy. React Query v5 clears isError during that refetch.
|
||||||
|
const supplementDegraded = (q: {
|
||||||
|
isError: boolean
|
||||||
|
isFetching: boolean
|
||||||
|
isFetched: boolean
|
||||||
|
data: unknown
|
||||||
|
}) => q.isError || (q.isFetching && q.isFetched && q.data == null)
|
||||||
|
|
||||||
|
const supplementsLimited =
|
||||||
|
canonicalReady &&
|
||||||
|
(supplementDegraded(championshipQuery) || supplementDegraded(newsQuery))
|
||||||
|
|
||||||
|
let loadState: WeekendLoadState = 'loading'
|
||||||
|
if (contextQuery.isError) loadState = 'error'
|
||||||
|
else if (canonicalReady) loadState = 'ready'
|
||||||
|
|
||||||
|
const refetch = () => {
|
||||||
|
if (!canonicalReady) {
|
||||||
|
if (!contextQuery.isFetching) void contextQuery.refetch()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Freshness notice Retry refreshes the canonical context.
|
||||||
|
if (availabilityNotice && !contextQuery.isFetching) {
|
||||||
|
void contextQuery.refetch()
|
||||||
|
}
|
||||||
|
// Limited Retry must hit the failed supplements — not merely context.
|
||||||
|
// Gate on degraded (error or in-flight post-error refetch), not isError alone.
|
||||||
|
if (championshipQuery.isError && !championshipQuery.isFetching) {
|
||||||
|
void championshipQuery.refetch()
|
||||||
|
}
|
||||||
|
if (newsQuery.isError && !newsQuery.isFetching) {
|
||||||
|
void newsQuery.refetch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
context: canonical,
|
||||||
|
loadState,
|
||||||
|
error: contextQuery.error instanceof Error ? contextQuery.error : undefined,
|
||||||
|
championship,
|
||||||
|
briefing,
|
||||||
|
availabilityNotice,
|
||||||
|
supplementsLimited,
|
||||||
|
now: nowDate,
|
||||||
|
refetch,
|
||||||
|
isFetching:
|
||||||
|
contextQuery.isFetching || championshipQuery.isFetching || newsQuery.isFetching,
|
||||||
|
}
|
||||||
|
}
|
||||||
135
frontend/src/lib/availability.ts
Normal file
135
frontend/src/lib/availability.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { getResponseAvailability, type ResponseAvailability } from './fetch'
|
||||||
|
import type { ContextSession, WeekendContext } from '../types'
|
||||||
|
|
||||||
|
/** Shared Weekend Context terminology for coverage / availability indicators. */
|
||||||
|
export type DataAvailability = 'local' | 'partial' | 'stale' | 'archive' | 'limited' | 'missing'
|
||||||
|
|
||||||
|
/** Freshness values that warrant a non-blocking DataNotice. */
|
||||||
|
const NOTICE_FRESHNESS = new Set<string>(['stale', 'partial', 'local', 'limited', 'archive'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Product-impact severity for aggregating conflicting reported freshness.
|
||||||
|
* Higher wins so routine Local cannot mask Limited/Partial/Stale/Archive.
|
||||||
|
*/
|
||||||
|
const NOTICE_SEVERITY: Record<DataAvailability, number> = {
|
||||||
|
local: 1,
|
||||||
|
archive: 2,
|
||||||
|
stale: 3,
|
||||||
|
partial: 4,
|
||||||
|
limited: 5,
|
||||||
|
missing: 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a reported freshness string onto shared DataNotice vocabulary.
|
||||||
|
* Returns null for unreported / routine-success values (`fresh`, `live`, …).
|
||||||
|
* Never invents stale from React Query staleTime.
|
||||||
|
*/
|
||||||
|
export function noticeFromFreshness(
|
||||||
|
freshness: string | undefined | null,
|
||||||
|
opts?: { includeLocal?: boolean },
|
||||||
|
): DataAvailability | null {
|
||||||
|
if (!freshness) return null
|
||||||
|
const includeLocal = opts?.includeLocal ?? true
|
||||||
|
if (freshness === 'local' && !includeLocal) return null
|
||||||
|
if (!NOTICE_FRESHNESS.has(freshness)) return null
|
||||||
|
return freshness as DataAvailability
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prefer header freshness, then optional typed payload freshness. */
|
||||||
|
export function noticeFromResponse(
|
||||||
|
data: unknown,
|
||||||
|
opts?: { includeLocal?: boolean; fallbackFreshness?: string },
|
||||||
|
): DataAvailability | null {
|
||||||
|
const meta = getResponseAvailability(data)
|
||||||
|
return (
|
||||||
|
noticeFromFreshness(meta?.freshness, opts) ??
|
||||||
|
noticeFromFreshness(opts?.fallbackFreshness, opts)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the worst (highest product-impact) reported notice among sources.
|
||||||
|
* Equivalent kinds collapse to one; Local never masks a stronger disclosure.
|
||||||
|
*/
|
||||||
|
export function aggregateNotices(
|
||||||
|
notices: Array<DataAvailability | null | undefined>,
|
||||||
|
): DataAvailability | null {
|
||||||
|
let worst: DataAvailability | null = null
|
||||||
|
for (const notice of notices) {
|
||||||
|
if (!notice) continue
|
||||||
|
if (!worst || NOTICE_SEVERITY[notice] > NOTICE_SEVERITY[worst]) {
|
||||||
|
worst = notice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return worst
|
||||||
|
}
|
||||||
|
|
||||||
|
export function noticeMessage(kind: DataAvailability): string {
|
||||||
|
switch (kind) {
|
||||||
|
case 'stale':
|
||||||
|
return 'Showing stale cached data. Retry to refresh.'
|
||||||
|
case 'partial':
|
||||||
|
return 'Coverage is partial for this view.'
|
||||||
|
case 'local':
|
||||||
|
return 'Showing local season data.'
|
||||||
|
case 'limited':
|
||||||
|
return 'Some optional details are unavailable. Core data is still shown.'
|
||||||
|
case 'archive':
|
||||||
|
return 'Showing an archived snapshot.'
|
||||||
|
case 'missing':
|
||||||
|
return 'Some expected data is missing.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session whose state the Weekend shell is presenting — mirrors backend
|
||||||
|
* focusedContextSession so an older terminal session never overrides focus.
|
||||||
|
*/
|
||||||
|
export function focusedContextSession(context: WeekendContext): ContextSession | undefined {
|
||||||
|
if (context.active_session) return context.active_session
|
||||||
|
if (!context.focus_meeting) return undefined
|
||||||
|
const focusKey = context.focus_meeting.meeting_key
|
||||||
|
for (const ref of [
|
||||||
|
context.next_session,
|
||||||
|
context.previous_completed_session,
|
||||||
|
context.default_analysis_session,
|
||||||
|
]) {
|
||||||
|
if (ref?.meeting?.meeting_key === focusKey) return ref
|
||||||
|
// Session.meeting_key is always present even when meeting identity is sparse.
|
||||||
|
if (ref && ref.session.meeting_key === focusKey) return ref
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weekend shell notice from authoritative response headers when present.
|
||||||
|
* A header that intentionally maps to no notice (e.g. focused local/local with
|
||||||
|
* Local suppressed) must not fall through to older previous-session archive/partial.
|
||||||
|
* When headers are absent, typed focus-session freshness may be used.
|
||||||
|
*/
|
||||||
|
export function weekendContextNotice(
|
||||||
|
context: WeekendContext,
|
||||||
|
responseMeta?: ResponseAvailability,
|
||||||
|
): DataAvailability | null {
|
||||||
|
const headerMeta = responseMeta ?? getResponseAvailability(context)
|
||||||
|
if (headerMeta) {
|
||||||
|
return noticeFromFreshness(headerMeta.freshness, { includeLocal: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const focus = focusedContextSession(context)
|
||||||
|
return noticeFromFreshness(focus?.availability.freshness, { includeLocal: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embedded Preview should disclose its own freshness unless the Weekend shell
|
||||||
|
* already shows an equivalent notice (same DataAvailability kind).
|
||||||
|
*/
|
||||||
|
export function shouldShowEmbeddedNotice(
|
||||||
|
notice: DataAvailability | null | undefined,
|
||||||
|
shellNotice: DataAvailability | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (!notice) return false
|
||||||
|
if (!shellNotice) return true
|
||||||
|
return notice !== shellNotice
|
||||||
|
}
|
||||||
353
frontend/src/lib/fetch.ts
Normal file
353
frontend/src/lib/fetch.ts
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
/** Bounded fetch helpers for primary-route resilience. */
|
||||||
|
|
||||||
|
import { replaceEqualDeep } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
export const DEFAULT_FETCH_TIMEOUT_MS = 15_000
|
||||||
|
|
||||||
|
/** CORS-readable success provenance headers from the Go API. */
|
||||||
|
export const DATA_SOURCE_HEADER = 'X-BoxBox-Data-Source'
|
||||||
|
export const DATA_FRESHNESS_HEADER = 'X-BoxBox-Data-Freshness'
|
||||||
|
|
||||||
|
/** Reported response source values (`openf1|local|mixed`, plus `fia` for live). */
|
||||||
|
export type DataSourceHeader = 'openf1' | 'local' | 'mixed' | 'fia' | string
|
||||||
|
|
||||||
|
/** Reported freshness (`fresh|stale|local|partial`, plus live/archive/limited). */
|
||||||
|
export type DataFreshnessHeader =
|
||||||
|
| 'fresh'
|
||||||
|
| 'stale'
|
||||||
|
| 'local'
|
||||||
|
| 'partial'
|
||||||
|
| 'live'
|
||||||
|
| 'archive'
|
||||||
|
| 'limited'
|
||||||
|
| string
|
||||||
|
|
||||||
|
/** Additive success-response availability metadata (never inferred from React Query). */
|
||||||
|
export interface ResponseAvailability {
|
||||||
|
source?: DataSourceHeader
|
||||||
|
freshness?: DataFreshnessHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseAvailability = new WeakMap<object, ResponseAvailability>()
|
||||||
|
|
||||||
|
function readHeader(headers: Headers, name: string): string | undefined {
|
||||||
|
const value = headers.get(name)?.trim()
|
||||||
|
return value || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureResponseAvailability(data: unknown, headers: Headers): void {
|
||||||
|
if (data === null || (typeof data !== 'object' && typeof data !== 'function')) return
|
||||||
|
const source = readHeader(headers, DATA_SOURCE_HEADER)
|
||||||
|
const freshness = readHeader(headers, DATA_FRESHNESS_HEADER)
|
||||||
|
if (!source && !freshness) return
|
||||||
|
responseAvailability.set(data as object, { source, freshness })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read availability metadata captured from the last successful fetch of this payload. */
|
||||||
|
export function getResponseAvailability(data: unknown): ResponseAvailability | undefined {
|
||||||
|
if (data === null || (typeof data !== 'object' && typeof data !== 'function')) return undefined
|
||||||
|
return responseAvailability.get(data as object)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test / manual helper — attach reported availability to a payload object. */
|
||||||
|
export function rememberResponseAvailability(
|
||||||
|
data: object,
|
||||||
|
meta: ResponseAvailability,
|
||||||
|
): void {
|
||||||
|
responseAvailability.set(data, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop availability metadata from a payload (e.g. after a header-less refetch). */
|
||||||
|
export function clearResponseAvailability(data: object): void {
|
||||||
|
responseAvailability.delete(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React Query structural sharing that keeps header availability in sync.
|
||||||
|
*
|
||||||
|
* Default `replaceEqualDeep` reuses the previous object when JSON is equal, which
|
||||||
|
* would leave stale WeakMap metadata attached after a stale→fresh (or fresh→partial)
|
||||||
|
* refetch of an identical body. Always re-bind the latest fetch's metadata onto the
|
||||||
|
* object that lands in the query cache.
|
||||||
|
*/
|
||||||
|
export function availabilityAwareStructuralSharing<T>(
|
||||||
|
oldData: T | undefined,
|
||||||
|
newData: T,
|
||||||
|
): T {
|
||||||
|
const meta = getResponseAvailability(newData)
|
||||||
|
const shared = replaceEqualDeep(oldData, newData) as T
|
||||||
|
if (shared !== null && typeof shared === 'object') {
|
||||||
|
if (meta) {
|
||||||
|
rememberResponseAvailability(shared as object, meta)
|
||||||
|
} else {
|
||||||
|
clearResponseAvailability(shared as object)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return shared
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiErrorKind = 'http' | 'timeout' | 'abort' | 'network'
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly kind: ApiErrorKind
|
||||||
|
readonly status?: number
|
||||||
|
readonly stale: boolean
|
||||||
|
readonly userMessage: string
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
kind: ApiErrorKind,
|
||||||
|
message: string,
|
||||||
|
opts?: { status?: number; stale?: boolean; userMessage?: string },
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.kind = kind
|
||||||
|
this.status = opts?.status
|
||||||
|
this.stale = opts?.stale ?? false
|
||||||
|
this.userMessage = opts?.userMessage ?? defaultUserMessage(kind, opts?.stale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultUserMessage(kind: ApiErrorKind, stale?: boolean): string {
|
||||||
|
if (stale) {
|
||||||
|
return 'Showing cached data because a fresh update did not arrive. Retry to refresh.'
|
||||||
|
}
|
||||||
|
switch (kind) {
|
||||||
|
case 'timeout':
|
||||||
|
return 'This request took too long. Check your connection, then retry.'
|
||||||
|
case 'abort':
|
||||||
|
return 'This request was cancelled. Retry when you are ready.'
|
||||||
|
case 'network':
|
||||||
|
return 'Could not reach box-box. Check your connection, then retry.'
|
||||||
|
case 'http':
|
||||||
|
default:
|
||||||
|
return 'Something went wrong loading this view. Retry to try again.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isApiError(err: unknown): err is ApiError {
|
||||||
|
return err instanceof ApiError
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTimeoutError(err: unknown): boolean {
|
||||||
|
return isApiError(err) && err.kind === 'timeout'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userFacingError(err: unknown): string {
|
||||||
|
if (isApiError(err)) return err.userMessage
|
||||||
|
if (err instanceof Error && err.message) {
|
||||||
|
// Never surface raw "API 500: Internal Server Error" style strings.
|
||||||
|
if (/^API\s+\d+/i.test(err.message) || /status\s+\d+/i.test(err.message)) {
|
||||||
|
return defaultUserMessage('http')
|
||||||
|
}
|
||||||
|
return err.message
|
||||||
|
}
|
||||||
|
return defaultUserMessage('http')
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiFetchOptions {
|
||||||
|
timeoutMs?: number
|
||||||
|
signal?: AbortSignal
|
||||||
|
/** When set, concurrent identical in-flight requests share one promise. */
|
||||||
|
dedupeKey?: string
|
||||||
|
method?: string
|
||||||
|
headers?: HeadersInit
|
||||||
|
body?: BodyInit | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InflightEntry {
|
||||||
|
/** Shared underlying fetch — not tied to any single caller's AbortSignal. */
|
||||||
|
promise: Promise<unknown>
|
||||||
|
/** Aborts the shared request only when the last subscriber cancels. */
|
||||||
|
controller: AbortController
|
||||||
|
subscribers: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const inflight = new Map<string, InflightEntry>()
|
||||||
|
|
||||||
|
function abortError(): ApiError {
|
||||||
|
return new ApiError('abort', 'Request aborted', {
|
||||||
|
userMessage: defaultUserMessage('abort'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function combineAbortSignals(signals: AbortSignal[]): AbortSignal {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const onAbort = () => {
|
||||||
|
const reason = signals.find((s) => s.aborted)?.reason
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
controller.abort(reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const signal of signals) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
onAbort()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
signal.addEventListener('abort', onAbort, { once: true })
|
||||||
|
}
|
||||||
|
return controller.signal
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseErrorBody(res: Response): Promise<{ error?: string; stale?: boolean }> {
|
||||||
|
try {
|
||||||
|
const data = (await res.json()) as { error?: string; stale?: boolean }
|
||||||
|
return data ?? {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rawApiFetch<T>(url: string, options: ApiFetchOptions = {}): Promise<T> {
|
||||||
|
const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS
|
||||||
|
const timeoutController = new AbortController()
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timeoutController.abort(new DOMException('Request timed out', 'TimeoutError'))
|
||||||
|
}, timeoutMs)
|
||||||
|
|
||||||
|
const signals = [timeoutController.signal]
|
||||||
|
if (options.signal) signals.push(options.signal)
|
||||||
|
const signal = combineAbortSignals(signals)
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res: Response
|
||||||
|
try {
|
||||||
|
res = await fetch(url, {
|
||||||
|
method: options.method,
|
||||||
|
headers: options.headers,
|
||||||
|
body: options.body,
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (timeoutController.signal.aborted && !options.signal?.aborted) {
|
||||||
|
throw new ApiError('timeout', 'Request timed out', {
|
||||||
|
userMessage: defaultUserMessage('timeout'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (options.signal?.aborted || (err instanceof DOMException && err.name === 'AbortError')) {
|
||||||
|
throw abortError()
|
||||||
|
}
|
||||||
|
throw new ApiError('network', err instanceof Error ? err.message : 'Network error', {
|
||||||
|
userMessage: defaultUserMessage('network'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await parseErrorBody(res)
|
||||||
|
throw new ApiError('http', body.error || `HTTP ${res.status}`, {
|
||||||
|
status: res.status,
|
||||||
|
stale: Boolean(body.stale),
|
||||||
|
userMessage: body.stale
|
||||||
|
? defaultUserMessage('http', true)
|
||||||
|
: defaultUserMessage('http'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as T
|
||||||
|
captureResponseAvailability(data, res.headers)
|
||||||
|
return data
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach a caller to a shared in-flight request.
|
||||||
|
* One caller's abort rejects only that caller; the shared fetch continues
|
||||||
|
* while other subscribers remain, and is cancelled only when the last one leaves.
|
||||||
|
*/
|
||||||
|
function subscribeToInflight<T>(
|
||||||
|
key: string,
|
||||||
|
entry: InflightEntry,
|
||||||
|
callerSignal?: AbortSignal,
|
||||||
|
): Promise<T> {
|
||||||
|
if (callerSignal?.aborted) {
|
||||||
|
return Promise.reject(abortError())
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.subscribers += 1
|
||||||
|
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
let settled = false
|
||||||
|
|
||||||
|
const leave = () => {
|
||||||
|
entry.subscribers -= 1
|
||||||
|
if (entry.subscribers <= 0) {
|
||||||
|
if (!entry.controller.signal.aborted) {
|
||||||
|
entry.controller.abort()
|
||||||
|
}
|
||||||
|
if (inflight.get(key) === entry) {
|
||||||
|
inflight.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCallerAbort = () => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
leave()
|
||||||
|
reject(abortError())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callerSignal) {
|
||||||
|
callerSignal.addEventListener('abort', onCallerAbort, { once: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.promise.then(
|
||||||
|
(value) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
callerSignal?.removeEventListener('abort', onCallerAbort)
|
||||||
|
resolve(value as T)
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
callerSignal?.removeEventListener('abort', onCallerAbort)
|
||||||
|
reject(err)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch JSON with a bounded timeout, abort propagation, typed errors,
|
||||||
|
* and optional in-flight deduplication (same key shares one underlying request).
|
||||||
|
*
|
||||||
|
* Deduped requests are subscriber-safe: aborting one consumer does not cancel
|
||||||
|
* or poison other consumers that still hold the same key (React Strict Mode remounts).
|
||||||
|
*/
|
||||||
|
export function apiFetch<T>(url: string, options: ApiFetchOptions = {}): Promise<T> {
|
||||||
|
const key = options.dedupeKey
|
||||||
|
if (!key) {
|
||||||
|
return rawApiFetch<T>(url, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.signal?.aborted) {
|
||||||
|
return Promise.reject(abortError())
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry = inflight.get(key)
|
||||||
|
if (!entry) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
// Shared fetch only listens to the ref-counted controller + timeout —
|
||||||
|
// never to any single caller's AbortSignal.
|
||||||
|
const promise = rawApiFetch<T>(url, {
|
||||||
|
...options,
|
||||||
|
signal: controller.signal,
|
||||||
|
}).finally(() => {
|
||||||
|
if (inflight.get(key)?.promise === promise) {
|
||||||
|
inflight.delete(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
entry = { promise, controller, subscribers: 0 }
|
||||||
|
inflight.set(key, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return subscribeToInflight<T>(key, entry, options.signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test helper — clears the in-flight dedupe map. */
|
||||||
|
export function clearApiFetchInflight(): void {
|
||||||
|
inflight.clear()
|
||||||
|
}
|
||||||
@@ -268,63 +268,6 @@ export function driverCode(row: LiveTimingRow): string {
|
|||||||
return row.Info?.Tla || row.RacingNumber
|
return row.Info?.Tla || row.RacingNumber
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse an F1 lap-time string ("1:45.944", "45.944") into total seconds.
|
|
||||||
* Returns null for empty/invalid values so callers never invent a gap.
|
|
||||||
*/
|
|
||||||
export function parseLapTimeSeconds(value: string | null | undefined): number | null {
|
|
||||||
if (!value) return null
|
|
||||||
const match = value.trim().match(/^(?:(\d+):)?([0-5]?\d(?:\.\d+)?)$/)
|
|
||||||
if (!match) return null
|
|
||||||
const minutes = match[1] ? Number(match[1]) : 0
|
|
||||||
const seconds = Number(match[2])
|
|
||||||
if (!Number.isFinite(minutes) || !Number.isFinite(seconds)) return null
|
|
||||||
return minutes * 60 + seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BestLapGap {
|
|
||||||
isLeader: boolean
|
|
||||||
gap: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display-only gap-to-P1 for practice/qualifying, derived from each driver's
|
|
||||||
* valid best lap. Only drivers with a parseable best lap get an entry, and the
|
|
||||||
* fastest is flagged as the leader. Never fabricates a gap from a missing or
|
|
||||||
* invalid lap — the upstream interval remains the source of truth for races.
|
|
||||||
*/
|
|
||||||
export function bestLapGaps(rows: ReadonlyArray<LiveTimingRow>): Record<string, BestLapGap> {
|
|
||||||
let leaderNumber = ''
|
|
||||||
let best = Infinity
|
|
||||||
for (const row of rows) {
|
|
||||||
const seconds = parseLapTimeSeconds(row.Driver.BestLapTime)
|
|
||||||
if (seconds === null) continue
|
|
||||||
if (seconds < best) {
|
|
||||||
best = seconds
|
|
||||||
leaderNumber = row.RacingNumber
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const out: Record<string, BestLapGap> = {}
|
|
||||||
if (!Number.isFinite(best)) return out
|
|
||||||
for (const row of rows) {
|
|
||||||
const seconds = parseLapTimeSeconds(row.Driver.BestLapTime)
|
|
||||||
if (seconds === null) continue
|
|
||||||
out[row.RacingNumber] =
|
|
||||||
row.RacingNumber === leaderNumber
|
|
||||||
? { isLeader: true, gap: '' }
|
|
||||||
: { isLeader: false, gap: `+${(seconds - best).toFixed(3)}` }
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when the live feed reports an in-progress session with timing data. */
|
|
||||||
export function isLiveSessionActive(
|
|
||||||
state: { is_live?: boolean; data?: unknown } | null | undefined,
|
|
||||||
): boolean {
|
|
||||||
return Boolean(state?.is_live && state.data)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function trackStatusLabel(status: string): string {
|
export function trackStatusLabel(status: string): string {
|
||||||
return TRACK_STATUS_LABELS[status] || status || 'UNKNOWN'
|
return TRACK_STATUS_LABELS[status] || status || 'UNKNOWN'
|
||||||
}
|
}
|
||||||
|
|||||||
168
frontend/src/lib/liveState.ts
Normal file
168
frontend/src/lib/liveState.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
// Live timing state model.
|
||||||
|
//
|
||||||
|
// The Live page has to hold four *orthogonal* concepts that used to be
|
||||||
|
// conflated into a single "connected / offline" flag:
|
||||||
|
//
|
||||||
|
// 1. transport health — is the SSE stream up? (connecting/connected/…)
|
||||||
|
// 2. active session — is a session actually running right now?
|
||||||
|
// 3. archive availability — do we retain a final snapshot to inspect?
|
||||||
|
// 4. analysis readiness — is the completed session ingested for Race Hub?
|
||||||
|
//
|
||||||
|
// `deriveLivePhase` collapses the first three inputs into a single UI phase so
|
||||||
|
// the page never, for example, calls a finished session "offline" or lets a
|
||||||
|
// dropped socket masquerade as "archive mode".
|
||||||
|
//
|
||||||
|
// Critical distinction (issue #74 P0): the server's `deactivate()` path moves a
|
||||||
|
// STILL-ACTIVE snapshot into `last_snapshot` whenever the upstream FIA SignalR
|
||||||
|
// connection ends or idles — WITHOUT changing SessionStatus. So `is_live=false`
|
||||||
|
// alone is ambiguous. We rely on the retained snapshot's terminal SessionStatus
|
||||||
|
// (Finished/Ended/…) as the only trustworthy "the session really ended" signal.
|
||||||
|
// A non-terminal retained snapshot means the feed dropped, not that the session
|
||||||
|
// finished, and must render as `disconnected` (retain snapshot, warn, recover),
|
||||||
|
// never `settling`/archive.
|
||||||
|
|
||||||
|
export type TransportHealth = 'connecting' | 'connected' | 'disconnected' | 'error'
|
||||||
|
|
||||||
|
export type LivePhase =
|
||||||
|
| 'connecting' // cold start: no snapshot yet, still opening the feed
|
||||||
|
| 'live' // a session is running and streaming
|
||||||
|
| 'disconnected' // was live, transport/feed dropped — keep the last snapshot, warn
|
||||||
|
| 'settling' // session truly ended; a final snapshot is retained, analysis pending
|
||||||
|
| 'archive' // user opened the retained snapshot as an explicit read-only view
|
||||||
|
| 'inactive' // no session and nothing retained — show weekend context instead
|
||||||
|
|
||||||
|
export interface LiveStateInputs {
|
||||||
|
transport: TransportHealth
|
||||||
|
/** The feed reports an active session AND carries a snapshot for it. */
|
||||||
|
isLive: boolean
|
||||||
|
hasActiveSnapshot: boolean
|
||||||
|
/** A retained snapshot from the last stream is held (may be a dropped feed). */
|
||||||
|
hasArchive: boolean
|
||||||
|
/** The user explicitly opened the archive as a read-only timing view. */
|
||||||
|
archiveMode: boolean
|
||||||
|
/**
|
||||||
|
* The retained snapshot ended on a *terminal* FIA SessionStatus. Only true
|
||||||
|
* here means the session genuinely finished (vs. the transport dropping).
|
||||||
|
*/
|
||||||
|
sessionEndedCleanly: boolean
|
||||||
|
/** We have observed this session live at least once in this page session. */
|
||||||
|
wasLive: boolean
|
||||||
|
/**
|
||||||
|
* The initial `/api/v1/live/state` query has resolved. `connecting` is a
|
||||||
|
* cold-start-only phase: once we know the current state (even if the SSE
|
||||||
|
* transport handshake is still pending), an idle feed is `inactive`, not a
|
||||||
|
* perpetual "connecting…" spinner.
|
||||||
|
*/
|
||||||
|
stateLoaded: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw FIA SessionStatus values that mean the session is genuinely over. Mirrors
|
||||||
|
// the backend `terminalSessionStatus` used by the weekend-context resolver so
|
||||||
|
// the client and server agree on "finished".
|
||||||
|
const TERMINAL_STATUSES = new Set(['finished', 'finalised', 'finalized', 'ended', 'aborted'])
|
||||||
|
|
||||||
|
function normalizeStatus(status: string | undefined | null): string {
|
||||||
|
if (!status) return ''
|
||||||
|
return status.replace(/[^a-zA-Z]/g, '').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a raw FIA SessionStatus represents a genuinely completed session. */
|
||||||
|
export function terminalSessionStatus(status: string | undefined | null): boolean {
|
||||||
|
return TERMINAL_STATUSES.has(normalizeStatus(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transportDown(transport: TransportHealth): boolean {
|
||||||
|
return transport === 'disconnected' || transport === 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map transport + session + archive inputs to one UI phase. Session lifecycle
|
||||||
|
* and transport health are deliberately orthogonal: a live session with a
|
||||||
|
* dropped socket is `disconnected` (snapshot retained), never `archive` or
|
||||||
|
* `settling`.
|
||||||
|
*/
|
||||||
|
export function deriveLivePhase(input: LiveStateInputs): LivePhase {
|
||||||
|
const {
|
||||||
|
transport,
|
||||||
|
isLive,
|
||||||
|
hasActiveSnapshot,
|
||||||
|
hasArchive,
|
||||||
|
archiveMode,
|
||||||
|
sessionEndedCleanly,
|
||||||
|
stateLoaded,
|
||||||
|
} = input
|
||||||
|
|
||||||
|
// Explicit read-only archive wins — it is a user-chosen mode.
|
||||||
|
if (archiveMode && hasArchive) return 'archive'
|
||||||
|
|
||||||
|
// An active session: transport health only downgrades the *presentation*,
|
||||||
|
// it never removes the session.
|
||||||
|
if (isLive && hasActiveSnapshot) {
|
||||||
|
return transportDown(transport) ? 'disconnected' : 'live'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed reports no active session but a snapshot is retained. Distinguish a
|
||||||
|
// genuine session end from an upstream feed drop using the *only* trustworthy
|
||||||
|
// signal: whether the retained snapshot carries a terminal SessionStatus.
|
||||||
|
if (hasArchive) {
|
||||||
|
if (sessionEndedCleanly) return 'settling'
|
||||||
|
// Non-terminal retained snapshot means the upstream feed dropped, not that
|
||||||
|
// the session finished. Hold the last frame and warn — never settle/archive.
|
||||||
|
return 'disconnected'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cold start only: still opening the feed and we have not yet learned the
|
||||||
|
// current state. Once the initial state query resolves, an idle feed is
|
||||||
|
// `inactive`, not a perpetual "connecting…".
|
||||||
|
if (transport === 'connecting' && !stateLoaded) return 'connecting'
|
||||||
|
|
||||||
|
return 'inactive'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Phases that render the live timing tower / snapshot surface. */
|
||||||
|
export function rendersSnapshot(phase: LivePhase): boolean {
|
||||||
|
return phase === 'live' || phase === 'disconnected' || phase === 'archive'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether live-only interpretations (pit-window rejoin, tyre-deg slope, "what
|
||||||
|
* just happened" deltas) are meaningful. They require a moving session, so an
|
||||||
|
* archived single frame must never present them as current insight.
|
||||||
|
*/
|
||||||
|
export function allowsLiveInterpretations(phase: LivePhase): boolean {
|
||||||
|
return phase === 'live' || phase === 'disconnected'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only, timestamped phases that must not show live/connected chrome. */
|
||||||
|
export function isReadOnlyPhase(phase: LivePhase): boolean {
|
||||||
|
return phase === 'archive'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fan-facing feed health. Browser SSE can stay `connected` after an upstream
|
||||||
|
* FIA drop leaves us in `disconnected` with a retained non-terminal snapshot —
|
||||||
|
* present that as reconnecting, never "Feed healthy" beside "Connection lost".
|
||||||
|
*/
|
||||||
|
export function effectiveFeedHealth(
|
||||||
|
transport: TransportHealth,
|
||||||
|
phase: LivePhase,
|
||||||
|
): TransportHealth {
|
||||||
|
if (phase === 'disconnected' && !transportDown(transport)) {
|
||||||
|
return 'disconnected'
|
||||||
|
}
|
||||||
|
return transport
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short, human transport-health label — always secondary to session state. */
|
||||||
|
export function feedHealthLabel(transport: TransportHealth): string {
|
||||||
|
switch (transport) {
|
||||||
|
case 'connected':
|
||||||
|
return 'Feed healthy'
|
||||||
|
case 'connecting':
|
||||||
|
return 'Connecting'
|
||||||
|
case 'disconnected':
|
||||||
|
return 'Reconnecting'
|
||||||
|
case 'error':
|
||||||
|
return 'Feed unavailable'
|
||||||
|
}
|
||||||
|
}
|
||||||
16
frontend/src/lib/queryClient.ts
Normal file
16
frontend/src/lib/queryClient.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
import { availabilityAwareStructuralSharing } from './fetch'
|
||||||
|
|
||||||
|
/** Shared QueryClient defaults — availability-aware structural sharing for header metadata. */
|
||||||
|
export function createAppQueryClient(): QueryClient {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
// Explicit Retry on RouteState — avoid automatic retry storms.
|
||||||
|
retry: false,
|
||||||
|
structuralSharing: availabilityAwareStructuralSharing,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
40
frontend/src/lib/routeSearch.ts
Normal file
40
frontend/src/lib/routeSearch.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/** Shared URL search contracts for deep-linkable Weekend ↔ Race Hub navigation. */
|
||||||
|
|
||||||
|
export type WeekendSearch = {
|
||||||
|
meeting_key?: number
|
||||||
|
session_key?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RaceHubSearch = {
|
||||||
|
session_key?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveInt(value: unknown): number | undefined {
|
||||||
|
const n = Number(value)
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWeekendSearch(search: Record<string, unknown>): WeekendSearch {
|
||||||
|
const meeting_key = parsePositiveInt(search.meeting_key)
|
||||||
|
const session_key = parsePositiveInt(search.session_key)
|
||||||
|
return {
|
||||||
|
...(meeting_key ? { meeting_key } : {}),
|
||||||
|
...(session_key ? { session_key } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRaceHubSearch(search: Record<string, unknown>): RaceHubSearch {
|
||||||
|
const session_key = parsePositiveInt(search.session_key)
|
||||||
|
return session_key ? { session_key } : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build Weekend `/` search so a Race Hub return is reload-safe and deep-linkable. */
|
||||||
|
export function weekendFocusSearch(
|
||||||
|
meetingKey?: number | null,
|
||||||
|
sessionKey?: number | null,
|
||||||
|
): WeekendSearch {
|
||||||
|
return {
|
||||||
|
...(meetingKey && meetingKey > 0 ? { meeting_key: meetingKey } : {}),
|
||||||
|
...(sessionKey && sessionKey > 0 ? { session_key: sessionKey } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,15 +145,6 @@ 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 {
|
||||||
|
|||||||
194
frontend/src/lib/sessionState.ts
Normal file
194
frontend/src/lib/sessionState.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import type { ContextSession, WeekendContext, WeekendSession } from '../types'
|
||||||
|
import { isSessionComplete } from './coverage'
|
||||||
|
import { sessionStartTime } from './schedule'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User-facing lifecycle state for a weekend session.
|
||||||
|
*
|
||||||
|
* Live comes only from Weekend Context's FIA-backed active identity — never from
|
||||||
|
* the scheduled start/end window alone. Preparing / partial / ready / unavailable
|
||||||
|
* come from structured `availability.local_analysis` when a context ref exists,
|
||||||
|
* otherwise from local dataset coverage after the scheduled start.
|
||||||
|
*/
|
||||||
|
export type SessionState =
|
||||||
|
| 'upcoming'
|
||||||
|
| 'live'
|
||||||
|
| 'preparing'
|
||||||
|
| 'partial'
|
||||||
|
| 'ready'
|
||||||
|
| 'unavailable'
|
||||||
|
| 'cancelled'
|
||||||
|
|
||||||
|
export interface SessionStateInput {
|
||||||
|
weekendSession?: WeekendSession
|
||||||
|
context?: WeekendContext | null
|
||||||
|
now: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextRefFor(
|
||||||
|
context: WeekendContext | null | undefined,
|
||||||
|
sessionKey: number | undefined,
|
||||||
|
): ContextSession | undefined {
|
||||||
|
if (!context || !sessionKey) return undefined
|
||||||
|
const refs = [
|
||||||
|
context.active_session,
|
||||||
|
context.default_analysis_session,
|
||||||
|
context.previous_completed_session,
|
||||||
|
context.next_session,
|
||||||
|
]
|
||||||
|
return refs.find((ref) => ref?.session.session_key === sessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromAvailability(ref: ContextSession): SessionState | undefined {
|
||||||
|
const { schedule, live_session, local_analysis } = ref.availability
|
||||||
|
if (live_session === 'active') return 'live'
|
||||||
|
if (schedule === 'unavailable' || local_analysis === 'unavailable') return 'unavailable'
|
||||||
|
if (local_analysis === 'not_applicable') return 'upcoming'
|
||||||
|
if (local_analysis === 'pending') return 'preparing'
|
||||||
|
if (local_analysis === 'partial') return 'partial'
|
||||||
|
if (local_analysis === 'complete') return 'ready'
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve fan-facing session state. Prefer Weekend Context availability; never
|
||||||
|
* assert Live from wall-clock schedule alone.
|
||||||
|
*/
|
||||||
|
export function resolveSessionState({
|
||||||
|
weekendSession,
|
||||||
|
context,
|
||||||
|
now,
|
||||||
|
}: SessionStateInput): SessionState {
|
||||||
|
if (weekendSession?.source === 'cancelled') return 'cancelled'
|
||||||
|
|
||||||
|
const sessionKey = weekendSession?.session.session_key
|
||||||
|
const active = context?.active_session
|
||||||
|
if (
|
||||||
|
active &&
|
||||||
|
sessionKey &&
|
||||||
|
active.session.session_key === sessionKey &&
|
||||||
|
active.availability.live_session === 'active'
|
||||||
|
) {
|
||||||
|
return 'live'
|
||||||
|
}
|
||||||
|
|
||||||
|
const ref = contextRefFor(context, sessionKey)
|
||||||
|
if (ref) {
|
||||||
|
const fromCtx = fromAvailability(ref)
|
||||||
|
if (fromCtx) return fromCtx
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settling temporal state with no analysis yet — even without a matching ref.
|
||||||
|
if (
|
||||||
|
context?.temporal_state === 'session_settling' &&
|
||||||
|
weekendSession &&
|
||||||
|
weekendSession.source === 'none'
|
||||||
|
) {
|
||||||
|
return 'preparing'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!weekendSession) return 'unavailable'
|
||||||
|
|
||||||
|
const start = sessionStartTime(weekendSession.session)
|
||||||
|
if (start && start > now) return 'upcoming'
|
||||||
|
|
||||||
|
if (isSessionComplete(weekendSession.datasets)) return 'ready'
|
||||||
|
if (weekendSession.source === 'none') return 'preparing'
|
||||||
|
if (weekendSession.source === 'partial') return 'partial'
|
||||||
|
return 'ready'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Prefer resolveSessionState with Weekend Context. */
|
||||||
|
export function sessionState(session: WeekendSession, now: Date): SessionState {
|
||||||
|
return resolveSessionState({ weekendSession: session, now })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short label suitable for chips and the session switcher. */
|
||||||
|
export function sessionStateLabel(state: SessionState): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'upcoming':
|
||||||
|
return 'Upcoming'
|
||||||
|
case 'live':
|
||||||
|
return 'Live'
|
||||||
|
case 'preparing':
|
||||||
|
return 'Preparing'
|
||||||
|
case 'partial':
|
||||||
|
return 'Partial'
|
||||||
|
case 'ready':
|
||||||
|
return 'Ready'
|
||||||
|
case 'unavailable':
|
||||||
|
return 'Unavailable'
|
||||||
|
case 'cancelled':
|
||||||
|
return 'Cancelled'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Longer, sentence-style description for headers and empty states. */
|
||||||
|
export function sessionStateDescription(state: SessionState): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'upcoming':
|
||||||
|
return 'Session has not started yet.'
|
||||||
|
case 'live':
|
||||||
|
return 'Session is running now.'
|
||||||
|
case 'preparing':
|
||||||
|
return 'Analysis is being prepared — local data is still settling.'
|
||||||
|
case 'partial':
|
||||||
|
return 'Partial analysis available — some datasets are still missing.'
|
||||||
|
case 'ready':
|
||||||
|
return 'Full analysis is ready.'
|
||||||
|
case 'unavailable':
|
||||||
|
return 'Analysis is not available for this session.'
|
||||||
|
case 'cancelled':
|
||||||
|
return 'This session was cancelled.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class suffix used for the coverage dot, so the rail can colour a session by
|
||||||
|
* its lifecycle state rather than only by data source.
|
||||||
|
*/
|
||||||
|
export function sessionStateDotClass(state: SessionState): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'ready':
|
||||||
|
return 'rh-state-ready'
|
||||||
|
case 'partial':
|
||||||
|
return 'rh-state-partial'
|
||||||
|
case 'live':
|
||||||
|
return 'rh-state-live'
|
||||||
|
case 'upcoming':
|
||||||
|
return 'rh-state-upcoming'
|
||||||
|
case 'unavailable':
|
||||||
|
return 'rh-state-unavailable'
|
||||||
|
case 'cancelled':
|
||||||
|
return 'rh-state-cancelled'
|
||||||
|
default:
|
||||||
|
return 'rh-state-preparing'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a session should render the pre-session (expected availability) view. */
|
||||||
|
export function isPreSession(state: SessionState): boolean {
|
||||||
|
return state === 'upcoming'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settling / empty post-session — not yet analysable. */
|
||||||
|
export function isPreparing(state: SessionState): boolean {
|
||||||
|
return state === 'preparing'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUnavailable(state: SessionState): boolean {
|
||||||
|
return state === 'unavailable' || state === 'cancelled'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show fan analysis with a partial banner; keep available capabilities. */
|
||||||
|
export function isPartialAnalysis(state: SessionState): boolean {
|
||||||
|
return state === 'partial'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical default analysis session key from Weekend Context, if any. */
|
||||||
|
export function defaultAnalysisSessionKey(
|
||||||
|
context: WeekendContext | null | undefined,
|
||||||
|
): number | undefined {
|
||||||
|
const key = context?.default_analysis_session?.session.session_key
|
||||||
|
return key && key > 0 ? key : undefined
|
||||||
|
}
|
||||||
181
frontend/src/lib/weekendContext.ts
Normal file
181
frontend/src/lib/weekendContext.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import type {
|
||||||
|
ChampionshipHub,
|
||||||
|
ContextSession,
|
||||||
|
EnrichedResult,
|
||||||
|
Meeting,
|
||||||
|
NewsItem,
|
||||||
|
Session,
|
||||||
|
WeekendBriefingItem,
|
||||||
|
WeekendChampionshipImpact,
|
||||||
|
WeekendChampionshipMover,
|
||||||
|
WeekendContext,
|
||||||
|
WeekendPodiumEntry,
|
||||||
|
WeekendViewState,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resolveViewState maps the canonical `temporal_state` onto the rendered Weekend
|
||||||
|
* view. It is a total function: every valid canonical `temporal_state` resolves
|
||||||
|
* to a concrete view, so a well-formed payload never falls through to a
|
||||||
|
* limited/empty placeholder. Loading and error are hook-level states that are
|
||||||
|
* not part of the #72 contract.
|
||||||
|
*/
|
||||||
|
export function resolveViewState(context: WeekendContext): WeekendViewState {
|
||||||
|
switch (context.temporal_state) {
|
||||||
|
case 'no_season':
|
||||||
|
return 'no_season'
|
||||||
|
case 'between_weekends':
|
||||||
|
return 'between_weekends'
|
||||||
|
case 'pre_session':
|
||||||
|
return 'pre_session'
|
||||||
|
case 'session_live':
|
||||||
|
return 'session_live'
|
||||||
|
case 'session_settling':
|
||||||
|
return 'session_settling'
|
||||||
|
case 'between_sessions':
|
||||||
|
return 'between_sessions'
|
||||||
|
case 'post_weekend':
|
||||||
|
return 'post_weekend'
|
||||||
|
case 'season_complete':
|
||||||
|
return 'season_complete'
|
||||||
|
default:
|
||||||
|
// Unknown/absent temporal_state is an invalid payload — treat as no_season
|
||||||
|
// rather than inventing a state. The hook still shows an explicit surface.
|
||||||
|
return 'no_season'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeetingIdentity {
|
||||||
|
meeting_key: number
|
||||||
|
meeting_name: string
|
||||||
|
short_name: string
|
||||||
|
country_code: string
|
||||||
|
country_flag: string
|
||||||
|
circuit_short_name: string
|
||||||
|
circuit_key?: number
|
||||||
|
date_start?: string
|
||||||
|
date_end?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Presentation identity for a meeting, tolerant of partial/absent fields. */
|
||||||
|
export function meetingIdentity(meeting: Meeting | undefined): MeetingIdentity | undefined {
|
||||||
|
if (!meeting) return undefined
|
||||||
|
return {
|
||||||
|
meeting_key: meeting.meeting_key,
|
||||||
|
meeting_name: meeting.meeting_name || meeting.location || 'Grand Prix',
|
||||||
|
short_name: (meeting.meeting_name || meeting.location || 'Grand Prix').replace(/ Grand Prix$/i, ''),
|
||||||
|
country_code: meeting.country_code,
|
||||||
|
country_flag: meeting.country_flag,
|
||||||
|
circuit_short_name: meeting.circuit_short_name || meeting.location || '',
|
||||||
|
circuit_key: meeting.circuit_key,
|
||||||
|
date_start: meeting.date_start || undefined,
|
||||||
|
date_end: meeting.date_end || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Session identity paired with its resolved meeting for a canonical ContextSession. */
|
||||||
|
export function sessionMeeting(ref: ContextSession | undefined): Meeting | undefined {
|
||||||
|
return ref?.meeting
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionOf(ref: ContextSession | undefined): Session | undefined {
|
||||||
|
return ref?.session
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* analysisSessionKey resolves the session a user should open to read analysis for
|
||||||
|
* a completed event. It prefers the canonical default analysis session, falling
|
||||||
|
* back to the previous completed session. Returns undefined when nothing analysable
|
||||||
|
* is available (e.g. an archived result with no local analysis).
|
||||||
|
*/
|
||||||
|
export function analysisSessionKey(context: WeekendContext): number | undefined {
|
||||||
|
const key =
|
||||||
|
context.default_analysis_session?.session.session_key ??
|
||||||
|
context.previous_completed_session?.session.session_key
|
||||||
|
return key && key > 0 ? key : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a ContextSession has local analysis worth linking to. */
|
||||||
|
export function hasLocalAnalysis(ref: ContextSession | undefined): boolean {
|
||||||
|
const status = ref?.availability.local_analysis
|
||||||
|
return status === 'complete' || status === 'partial'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Countdown target for a session reference — its scheduled start, if known. */
|
||||||
|
export function sessionStart(ref: ContextSession | undefined): string | undefined {
|
||||||
|
const start = ref?.session.date_start
|
||||||
|
return start ? start : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function podiumFromResults(results: EnrichedResult[]): WeekendPodiumEntry[] {
|
||||||
|
return [...results]
|
||||||
|
.filter((r) => r.position >= 1)
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((r) => ({
|
||||||
|
position: r.position,
|
||||||
|
driver_number: r.driver_number,
|
||||||
|
name_acronym: r.name_acronym,
|
||||||
|
team_name: r.team_name,
|
||||||
|
team_colour: r.team_colour,
|
||||||
|
gap: formatResultGap(r),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResultGap(r: EnrichedResult): string {
|
||||||
|
if (r.dnf) return 'DNF'
|
||||||
|
if (r.dns) return 'DNS'
|
||||||
|
if (r.dsq) return 'DSQ'
|
||||||
|
if (r.position === 1) {
|
||||||
|
if (typeof r.duration === 'number' && r.duration > 0) return formatDuration(r.duration)
|
||||||
|
return 'Winner'
|
||||||
|
}
|
||||||
|
const gap = Array.isArray(r.gap_to_leader) ? r.gap_to_leader[r.gap_to_leader.length - 1] : r.gap_to_leader
|
||||||
|
if (typeof gap === 'number') return `+${gap.toFixed(3)}`
|
||||||
|
if (typeof gap === 'string' && gap) return gap.startsWith('+') ? gap : `+${gap}`
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
const h = Math.floor(seconds / 3600)
|
||||||
|
const m = Math.floor((seconds % 3600) / 60)
|
||||||
|
const s = seconds % 60
|
||||||
|
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${s.toFixed(3).padStart(6, '0')}`
|
||||||
|
return `${m}:${s.toFixed(3).padStart(6, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* championshipImpact derives the top-of-standings movers from the championship
|
||||||
|
* hub. This is supplementary data layered onto the canonical context, not part of
|
||||||
|
* the #72 contract.
|
||||||
|
*/
|
||||||
|
export function championshipImpact(hub: ChampionshipHub | undefined): WeekendChampionshipImpact | undefined {
|
||||||
|
if (!hub || hub.drivers.length === 0) return undefined
|
||||||
|
const leaders: WeekendChampionshipMover[] = hub.drivers.slice(0, 3).map((d) => {
|
||||||
|
const cumulative = d.cumulative ?? []
|
||||||
|
const delta =
|
||||||
|
cumulative.length >= 2 ? cumulative[cumulative.length - 1] - cumulative[cumulative.length - 2] : undefined
|
||||||
|
return {
|
||||||
|
position: d.position,
|
||||||
|
driver_number: d.driver_number,
|
||||||
|
name_acronym: d.name_acronym,
|
||||||
|
team_colour: d.team_colour,
|
||||||
|
points: d.points,
|
||||||
|
delta,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const note = hub.last_race ? `Standings after ${hub.last_race}.` : undefined
|
||||||
|
return { leaders, note }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** At most three briefing items, mapped from the news feed. */
|
||||||
|
export function briefingItems(news: NewsItem[]): WeekendBriefingItem[] {
|
||||||
|
return news.slice(0, 3).map((item) => ({
|
||||||
|
category: item.category,
|
||||||
|
title: item.title,
|
||||||
|
url: item.url,
|
||||||
|
source: item.source,
|
||||||
|
published_at: item.published_at,
|
||||||
|
image_url: item.og_image_url,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -1,18 +1,12 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import { RouterProvider } from '@tanstack/react-router'
|
import { RouterProvider } from '@tanstack/react-router'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { createAppQueryClient } from './lib/queryClient'
|
||||||
import { router } from './router'
|
import { router } from './router'
|
||||||
import './styles/app.css'
|
import './styles/app.css'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = createAppQueryClient()
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
staleTime: 30_000,
|
|
||||||
retry: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
} from '../lib/digest'
|
} from '../lib/digest'
|
||||||
import { stripHtml, timeAgo } from '../utils'
|
import { stripHtml, timeAgo } from '../utils'
|
||||||
import type { ArticleContent, NewsItem } from '../types'
|
import type { ArticleContent, NewsItem } from '../types'
|
||||||
|
import { DataNotice, RouteState } from '../components/RouteState'
|
||||||
|
import { aggregateNotices, noticeFromResponse, noticeMessage } from '../lib/availability'
|
||||||
import '../styles/digest.css'
|
import '../styles/digest.css'
|
||||||
|
|
||||||
type Category = 'all' | 'official' | 'news' | 'video'
|
type Category = 'all' | 'official' | 'news' | 'video'
|
||||||
@@ -406,27 +408,27 @@ export function BriefingPage() {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const now = useMemo(() => new Date(), [])
|
const now = useMemo(() => new Date(), [])
|
||||||
|
|
||||||
const { data: allNews = [], isLoading, isError } = useQuery({
|
const { data: allNews = [], isLoading, isError, error, refetch, isFetching } = useQuery({
|
||||||
queryKey: ['news'],
|
queryKey: ['news'],
|
||||||
queryFn: () => fetchNews(100),
|
queryFn: ({ signal }) => fetchNews(100, undefined, signal),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const seasonsQuery = useQuery({
|
const seasonsQuery = useQuery({
|
||||||
queryKey: ['seasons'],
|
queryKey: ['seasons'],
|
||||||
queryFn: fetchSeasons,
|
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||||
})
|
})
|
||||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||||
|
|
||||||
const meetingsQuery = useQuery({
|
const meetingsQuery = useQuery({
|
||||||
queryKey: ['season-meetings', latestSeason],
|
queryKey: ['season-meetings', latestSeason],
|
||||||
queryFn: () => fetchSeasonMeetings(latestSeason!),
|
queryFn: ({ signal }) => fetchSeasonMeetings(latestSeason!, signal),
|
||||||
enabled: latestSeason != null,
|
enabled: latestSeason != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const hubQuery = useQuery({
|
const hubQuery = useQuery({
|
||||||
queryKey: ['championship-hub', latestSeason],
|
queryKey: ['championship-hub', latestSeason],
|
||||||
queryFn: () => fetchChampionshipHub(latestSeason!),
|
queryFn: ({ signal }) => fetchChampionshipHub(latestSeason!, signal),
|
||||||
enabled: latestSeason != null,
|
enabled: latestSeason != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -515,6 +517,43 @@ export function BriefingPage() {
|
|||||||
const hasDigest = meetings.length > 0
|
const hasDigest = meetings.length > 0
|
||||||
const showEmpty = tagFiltered.length === 0 && grouped.recent.length === 0
|
const showEmpty = tagFiltered.length === 0 && grouped.recent.length === 0
|
||||||
|
|
||||||
|
const supplementsFetching =
|
||||||
|
meetingsQuery.isFetching || hubQuery.isFetching || seasonsQuery.isFetching
|
||||||
|
// React Query v5 clears isError while a post-error refetch is in flight
|
||||||
|
// (status → pending). Keep Limited mounted so Retry can show aria-busy.
|
||||||
|
const supplementDegraded = (q: {
|
||||||
|
isError: boolean
|
||||||
|
isFetching: boolean
|
||||||
|
isFetched: boolean
|
||||||
|
data: unknown
|
||||||
|
}) => q.isError || (q.isFetching && q.isFetched && q.data == null)
|
||||||
|
const supplementsLimited =
|
||||||
|
!isLoading &&
|
||||||
|
!isError &&
|
||||||
|
(supplementDegraded(meetingsQuery) ||
|
||||||
|
supplementDegraded(hubQuery) ||
|
||||||
|
supplementDegraded(seasonsQuery))
|
||||||
|
const supplementsRetrying = supplementsLimited && supplementsFetching
|
||||||
|
|
||||||
|
const retrySupplements = () => {
|
||||||
|
if (supplementDegraded(meetingsQuery) && !meetingsQuery.isFetching) {
|
||||||
|
void meetingsQuery.refetch()
|
||||||
|
}
|
||||||
|
if (supplementDegraded(hubQuery) && !hubQuery.isFetching) {
|
||||||
|
void hubQuery.refetch()
|
||||||
|
}
|
||||||
|
if (supplementDegraded(seasonsQuery) && !seasonsQuery.isFetching) {
|
||||||
|
void seasonsQuery.refetch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate by severity so routine local News cannot mask stale hub/meetings.
|
||||||
|
const availability = aggregateNotices([
|
||||||
|
noticeFromResponse(allNews, { includeLocal: true }),
|
||||||
|
noticeFromResponse(hub, { includeLocal: false }),
|
||||||
|
noticeFromResponse(meetings, { includeLocal: false }),
|
||||||
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bp-page" data-testid="briefing-page">
|
<div className="bp-page" data-testid="briefing-page">
|
||||||
<div className="bp-topbar">
|
<div className="bp-topbar">
|
||||||
@@ -524,11 +563,45 @@ export function BriefingPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && <div className="loading-state">loading briefing…</div>}
|
{isLoading && (
|
||||||
{isError && <div className="error-box">Failed to load paddock briefing.</div>}
|
<RouteState kind="loading" title="loading briefing…" testId="briefing-loading" />
|
||||||
|
)}
|
||||||
|
{isError && (
|
||||||
|
<RouteState
|
||||||
|
kind="error"
|
||||||
|
title="Briefing unavailable"
|
||||||
|
error={error}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!isFetching) void refetch()
|
||||||
|
}}
|
||||||
|
retrying={isFetching}
|
||||||
|
testId="briefing-error"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isLoading && !isError && (
|
{!isLoading && !isError && (
|
||||||
<>
|
<>
|
||||||
|
{supplementsLimited && (
|
||||||
|
<DataNotice
|
||||||
|
availability="limited"
|
||||||
|
message="Weekend grouping or driver tags are limited. Articles are still available."
|
||||||
|
onRetry={retrySupplements}
|
||||||
|
retrying={supplementsRetrying}
|
||||||
|
testId="briefing-data-notice"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!supplementsLimited && availability && (
|
||||||
|
<DataNotice
|
||||||
|
availability={availability}
|
||||||
|
message={noticeMessage(availability)}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!isFetching) void refetch()
|
||||||
|
}}
|
||||||
|
retrying={isFetching}
|
||||||
|
testId="briefing-data-notice"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<CategoryTabs
|
<CategoryTabs
|
||||||
active={activeCategory}
|
active={activeCategory}
|
||||||
counts={counts}
|
counts={counts}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import type { ChampHubDriver, ChampionshipHub } from '../types'
|
|||||||
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
|
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
|
||||||
import { RivalryCompare } from '../components/RivalryCompare'
|
import { RivalryCompare } from '../components/RivalryCompare'
|
||||||
import { Meaning } from '../components/Meaning'
|
import { Meaning } from '../components/Meaning'
|
||||||
|
import { DataNotice, RouteState } from '../components/RouteState'
|
||||||
import { TeammateH2H } from '../components/TeammateH2H'
|
import { TeammateH2H } from '../components/TeammateH2H'
|
||||||
|
import { noticeFromResponse, noticeMessage } from '../lib/availability'
|
||||||
import { teammatePairs } from '../lib/h2h'
|
import { teammatePairs } from '../lib/h2h'
|
||||||
import { pointsGapMeaning } from '../lib/meaning'
|
import { pointsGapMeaning } from '../lib/meaning'
|
||||||
|
|
||||||
@@ -81,23 +83,55 @@ function teamSplit(teamName: string, drivers: ChampHubDriver[]): TeamSplit {
|
|||||||
export function ChampionshipPage() {
|
export function ChampionshipPage() {
|
||||||
const [view, setView] = useState<View>('drivers')
|
const [view, setView] = useState<View>('drivers')
|
||||||
|
|
||||||
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons })
|
const seasonsQuery = useQuery({
|
||||||
|
queryKey: ['seasons'],
|
||||||
|
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||||
|
})
|
||||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||||
|
|
||||||
const hubQuery = useQuery({
|
const hubQuery = useQuery({
|
||||||
queryKey: ['championship-hub', latestSeason],
|
queryKey: ['championship-hub', latestSeason],
|
||||||
queryFn: () => fetchChampionshipHub(latestSeason ?? undefined),
|
queryFn: ({ signal }) => fetchChampionshipHub(latestSeason ?? undefined, signal),
|
||||||
enabled: latestSeason != null,
|
enabled: latestSeason != null,
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (seasonsQuery.isLoading || hubQuery.isLoading) {
|
if (seasonsQuery.isLoading || hubQuery.isLoading) {
|
||||||
return <div className="page loading-state">loading championship…</div>
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<RouteState kind="loading" title="loading championship…" testId="championship-loading" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (seasonsQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<RouteState
|
||||||
|
kind="error"
|
||||||
|
title="Championship unavailable"
|
||||||
|
error={seasonsQuery.error}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!seasonsQuery.isFetching) void seasonsQuery.refetch()
|
||||||
|
}}
|
||||||
|
retrying={seasonsQuery.isFetching}
|
||||||
|
testId="championship-error"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (hubQuery.isError) {
|
if (hubQuery.isError) {
|
||||||
return (
|
return (
|
||||||
<div className="page error-box">
|
<div className="page">
|
||||||
{hubQuery.error instanceof Error ? hubQuery.error.message : 'Failed to load championship'}
|
<RouteState
|
||||||
|
kind="error"
|
||||||
|
title="Championship unavailable"
|
||||||
|
error={hubQuery.error}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!hubQuery.isFetching) void hubQuery.refetch()
|
||||||
|
}}
|
||||||
|
retrying={hubQuery.isFetching}
|
||||||
|
testId="championship-error"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -118,16 +152,32 @@ export function ChampionshipPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ChampionshipBody hub={hub} view={view} setView={setView} />
|
const availability = noticeFromResponse(hub, { includeLocal: true })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChampionshipBody
|
||||||
|
hub={hub}
|
||||||
|
view={view}
|
||||||
|
setView={setView}
|
||||||
|
availability={availability}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!hubQuery.isFetching) void hubQuery.refetch()
|
||||||
|
}}
|
||||||
|
retrying={hubQuery.isFetching}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BodyProps {
|
interface BodyProps {
|
||||||
hub: ChampionshipHub
|
hub: ChampionshipHub
|
||||||
view: View
|
view: View
|
||||||
setView: (v: View) => void
|
setView: (v: View) => void
|
||||||
|
availability?: ReturnType<typeof noticeFromResponse>
|
||||||
|
onRetry?: () => void
|
||||||
|
retrying?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
function ChampionshipBody({ hub, view, setView, availability, onRetry, retrying }: BodyProps) {
|
||||||
const { drivers, teams } = hub
|
const { drivers, teams } = hub
|
||||||
const leader = drivers[0]
|
const leader = drivers[0]
|
||||||
const remaining = hub.rounds_left * 25
|
const remaining = hub.rounds_left * 25
|
||||||
@@ -183,6 +233,15 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="champ-page" data-testid="championship">
|
<div className="champ-page" data-testid="championship">
|
||||||
|
{availability && (
|
||||||
|
<DataNotice
|
||||||
|
availability={availability}
|
||||||
|
message={noticeMessage(availability)}
|
||||||
|
onRetry={onRetry}
|
||||||
|
retrying={retrying}
|
||||||
|
testId="championship-data-notice"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="champ-header">
|
<div className="champ-header">
|
||||||
<div className="champ-title-row">
|
<div className="champ-title-row">
|
||||||
<span className="champ-accent" aria-hidden="true" />
|
<span className="champ-accent" aria-hidden="true" />
|
||||||
|
|||||||
@@ -1,495 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
import { useQueries, useQuery } from '@tanstack/react-query'
|
|
||||||
import { Link } from '@tanstack/react-router'
|
|
||||||
import {
|
|
||||||
fetchChampionshipHub,
|
|
||||||
fetchLiveState,
|
|
||||||
fetchLocalMeetings,
|
|
||||||
fetchRaceHub,
|
|
||||||
fetchSeasonMeetings,
|
|
||||||
fetchSeasons,
|
|
||||||
fetchSessions,
|
|
||||||
fetchWeekend,
|
|
||||||
} from '../api'
|
|
||||||
import { CommandCenterHero } from '../components/CommandCenterHero'
|
|
||||||
import { PaddockBriefing } from '../components/PaddockBriefing'
|
|
||||||
import { RACE_HUB_DATASETS, countWeekendStats, formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
|
|
||||||
import { countryAccent, countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
|
|
||||||
import { classifySessionStatus, heroState } from '../lib/hero'
|
|
||||||
import {
|
|
||||||
currentAndNextSession,
|
|
||||||
focusMeetingKind,
|
|
||||||
formatSessionScheduleTime,
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { SourceBadge, weekendStatusLabel } from '../components/SourceBadge'
|
import { SourceBadge, weekendStatusLabel } from '../components/SourceBadge'
|
||||||
import { CliCommands, ingestYearCommands } from '../components/CliCommands'
|
import { CliCommands, ingestYearCommands } from '../components/CliCommands'
|
||||||
import { MeetingDetailPanel } from '../components/MeetingDetailPanel'
|
import { MeetingDetailPanel } from '../components/MeetingDetailPanel'
|
||||||
|
import { RouteState } from '../components/RouteState'
|
||||||
import type { Meeting, Weekend } from '../types'
|
import type { Meeting, Weekend } from '../types'
|
||||||
|
|
||||||
function formatMeetingDate(meeting: Meeting): string {
|
function formatMeetingDate(meeting: Meeting): string {
|
||||||
@@ -27,12 +28,12 @@ export function DataLibraryPage() {
|
|||||||
|
|
||||||
const seasonsQuery = useQuery({
|
const seasonsQuery = useQuery({
|
||||||
queryKey: ['seasons'],
|
queryKey: ['seasons'],
|
||||||
queryFn: fetchSeasons,
|
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||||
})
|
})
|
||||||
|
|
||||||
const meetingsQuery = useQuery({
|
const meetingsQuery = useQuery({
|
||||||
queryKey: ['meetings', selectedYear],
|
queryKey: ['meetings', selectedYear],
|
||||||
queryFn: () => fetchLocalMeetings(selectedYear!),
|
queryFn: ({ signal }) => fetchLocalMeetings(selectedYear!, signal),
|
||||||
enabled: selectedYear != null,
|
enabled: selectedYear != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ export function DataLibraryPage() {
|
|||||||
const weekendQueries = useQueries({
|
const weekendQueries = useQueries({
|
||||||
queries: meetings.map((meeting) => ({
|
queries: meetings.map((meeting) => ({
|
||||||
queryKey: ['weekend', meeting.meeting_key],
|
queryKey: ['weekend', meeting.meeting_key],
|
||||||
queryFn: () => fetchWeekend(meeting.meeting_key),
|
queryFn: ({ signal }: { signal: AbortSignal }) => fetchWeekend(meeting.meeting_key, signal),
|
||||||
enabled: meetings.length > 0,
|
enabled: meetings.length > 0,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})),
|
})),
|
||||||
@@ -78,13 +79,26 @@ export function DataLibraryPage() {
|
|||||||
const weekendsLoading = weekendQueries.some((q) => q.isLoading)
|
const weekendsLoading = weekendQueries.some((q) => q.isLoading)
|
||||||
|
|
||||||
if (seasonsQuery.isLoading) {
|
if (seasonsQuery.isLoading) {
|
||||||
return <div className="page loading-state">loading local data library…</div>
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<RouteState kind="loading" title="loading local data library…" testId="weekend-loading" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seasonsQuery.isError) {
|
if (seasonsQuery.isError) {
|
||||||
return (
|
return (
|
||||||
<div className="page error-box">
|
<div className="page">
|
||||||
{seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'}
|
<RouteState
|
||||||
|
kind="error"
|
||||||
|
title="Weekend data unavailable"
|
||||||
|
error={seasonsQuery.error}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!seasonsQuery.isFetching) void seasonsQuery.refetch()
|
||||||
|
}}
|
||||||
|
retrying={seasonsQuery.isFetching}
|
||||||
|
testId="weekend-error"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -222,15 +236,20 @@ export function DataLibraryPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{meetingsQuery.isLoading && (
|
{meetingsQuery.isLoading && (
|
||||||
<div className="loading-state">loading meetings…</div>
|
<RouteState kind="loading" title="loading meetings…" testId="weekend-meetings-loading" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{meetingsQuery.isError && (
|
{meetingsQuery.isError && (
|
||||||
<div className="error-box">
|
<RouteState
|
||||||
{meetingsQuery.error instanceof Error
|
kind="error"
|
||||||
? meetingsQuery.error.message
|
title="Weekend meetings unavailable"
|
||||||
: 'Failed to load meetings'}
|
error={meetingsQuery.error}
|
||||||
</div>
|
onRetry={() => {
|
||||||
|
if (!meetingsQuery.isFetching) void meetingsQuery.refetch()
|
||||||
|
}}
|
||||||
|
retrying={meetingsQuery.isFetching}
|
||||||
|
testId="weekend-error"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && (
|
{!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && (
|
||||||
@@ -316,11 +335,16 @@ export function DataLibraryPage() {
|
|||||||
|
|
||||||
<div className="dl-detail-wrap">
|
<div className="dl-detail-wrap">
|
||||||
{weekendsLoading && selectedWeekend == null && (
|
{weekendsLoading && selectedWeekend == null && (
|
||||||
<div className="loading-state">loading weekend details…</div>
|
<RouteState kind="loading" title="loading weekend details…" testId="weekend-detail-loading" />
|
||||||
)}
|
)}
|
||||||
{selectedWeekend && <MeetingDetailPanel weekend={selectedWeekend} />}
|
{selectedWeekend && <MeetingDetailPanel weekend={selectedWeekend} />}
|
||||||
{selectedMeetingKey != null && !weekendsLoading && selectedWeekend == null && (
|
{selectedMeetingKey != null && !weekendsLoading && selectedWeekend == null && (
|
||||||
<div className="missing-notice">Could not load weekend details.</div>
|
<RouteState
|
||||||
|
kind="empty"
|
||||||
|
title="Weekend details missing"
|
||||||
|
message="Could not load weekend details for this meeting."
|
||||||
|
testId="weekend-detail-empty"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import { fetchDriverSummary, fetchSeasons } from '../api'
|
import { fetchDriverSummary, fetchSeasons } from '../api'
|
||||||
|
import { DataNotice, RouteState } from '../components/RouteState'
|
||||||
|
import { noticeFromResponse, noticeMessage } from '../lib/availability'
|
||||||
import { teamColor } from '../utils'
|
import { teamColor } from '../utils'
|
||||||
import { countryFlag } from '../lib/gpIdentity'
|
import { countryFlag } from '../lib/gpIdentity'
|
||||||
import {
|
import {
|
||||||
@@ -26,7 +28,7 @@ export function DriverProfilePage({ driverNumber, year }: Props) {
|
|||||||
// seasons list is empty/unavailable the backend falls back to the current year.
|
// seasons list is empty/unavailable the backend falls back to the current year.
|
||||||
const seasonsQuery = useQuery({
|
const seasonsQuery = useQuery({
|
||||||
queryKey: ['seasons'],
|
queryKey: ['seasons'],
|
||||||
queryFn: fetchSeasons,
|
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||||
enabled: year == null,
|
enabled: year == null,
|
||||||
})
|
})
|
||||||
const resolvedYear = year ?? seasonsQuery.data?.[0]
|
const resolvedYear = year ?? seasonsQuery.data?.[0]
|
||||||
@@ -34,39 +36,102 @@ export function DriverProfilePage({ driverNumber, year }: Props) {
|
|||||||
|
|
||||||
const summaryQuery = useQuery({
|
const summaryQuery = useQuery({
|
||||||
queryKey: ['driver-summary', driverNumber, resolvedYear ?? 'latest'],
|
queryKey: ['driver-summary', driverNumber, resolvedYear ?? 'latest'],
|
||||||
queryFn: () => fetchDriverSummary(driverNumber, resolvedYear),
|
queryFn: ({ signal }) => fetchDriverSummary(driverNumber, resolvedYear, signal),
|
||||||
enabled: seasonsSettled && driverNumber > 0,
|
enabled: seasonsSettled && driverNumber > 0,
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (driverNumber <= 0) {
|
if (driverNumber <= 0) {
|
||||||
return <div className="page error-box">Invalid driver number</div>
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<RouteState kind="empty" title="Invalid driver number" message="Pick a driver from the championship standings." />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (!seasonsSettled || summaryQuery.isLoading) {
|
if (!seasonsSettled || summaryQuery.isLoading) {
|
||||||
return <div className="page loading-state">loading driver profile…</div>
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<RouteState kind="loading" title="loading driver profile…" testId="driver-profile-loading" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (summaryQuery.isError) {
|
if (summaryQuery.isError) {
|
||||||
return (
|
return (
|
||||||
<div className="page error-box">
|
<div className="page">
|
||||||
{summaryQuery.error instanceof Error
|
<RouteState
|
||||||
? summaryQuery.error.message
|
kind="error"
|
||||||
: 'Failed to load driver profile'}
|
title="Driver profile unavailable"
|
||||||
|
error={summaryQuery.error}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!summaryQuery.isFetching) void summaryQuery.refetch()
|
||||||
|
}}
|
||||||
|
retrying={summaryQuery.isFetching}
|
||||||
|
testId="driver-profile-error"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const summary = summaryQuery.data
|
const summary = summaryQuery.data
|
||||||
if (!summary) {
|
if (!summary) {
|
||||||
return <div className="page error-box">No driver data</div>
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<RouteState
|
||||||
|
kind="empty"
|
||||||
|
title="No driver data"
|
||||||
|
message="This driver is not in the local season standings yet."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return <DriverProfileBody summary={summary} />
|
return (
|
||||||
|
<DriverProfileBody
|
||||||
|
summary={summary}
|
||||||
|
availability={noticeFromResponse(summary, { includeLocal: false })}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!summaryQuery.isFetching) void summaryQuery.refetch()
|
||||||
|
}}
|
||||||
|
retrying={summaryQuery.isFetching}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DriverProfileBody({ summary }: { summary: DriverSummary }) {
|
function DriverProfileBody({
|
||||||
|
summary,
|
||||||
|
availability,
|
||||||
|
onRetry,
|
||||||
|
retrying,
|
||||||
|
}: {
|
||||||
|
summary: DriverSummary
|
||||||
|
availability?: ReturnType<typeof noticeFromResponse>
|
||||||
|
onRetry?: () => void
|
||||||
|
retrying?: boolean
|
||||||
|
}) {
|
||||||
const color = teamColor(summary.team_colour)
|
const color = teamColor(summary.team_colour)
|
||||||
const deltas = gridFinishDeltas(summary.rounds)
|
const deltas = gridFinishDeltas(summary.rounds)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dp-page" data-testid="driver-profile">
|
<div className="dp-page" data-testid="driver-profile">
|
||||||
|
{summary.enrichment === 'limited' && (
|
||||||
|
<DataNotice
|
||||||
|
availability="limited"
|
||||||
|
message="Optional remote details are unavailable. Showing local season identity and results."
|
||||||
|
onRetry={onRetry}
|
||||||
|
retrying={retrying}
|
||||||
|
testId="driver-profile-limited"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{summary.source === 'local' && summary.enrichment !== 'limited' && (
|
||||||
|
<DataNotice availability="local" message="Loaded from local season data." testId="driver-profile-local" />
|
||||||
|
)}
|
||||||
|
{availability === 'stale' && summary.enrichment !== 'limited' && (
|
||||||
|
<DataNotice
|
||||||
|
availability="stale"
|
||||||
|
message={noticeMessage('stale')}
|
||||||
|
onRetry={onRetry}
|
||||||
|
retrying={retrying}
|
||||||
|
testId="driver-profile-stale"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<header className="dp-header" data-testid="dp-header" style={{ borderLeftColor: color }}>
|
<header className="dp-header" data-testid="dp-header" style={{ borderLeftColor: color }}>
|
||||||
<div className="dp-identity">
|
<div className="dp-identity">
|
||||||
<div className="dp-title-row">
|
<div className="dp-title-row">
|
||||||
|
|||||||
159
frontend/src/pages/ExplorePage.tsx
Normal file
159
frontend/src/pages/ExplorePage.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import {
|
||||||
|
fetchChampionshipHub,
|
||||||
|
fetchLocalMeetings,
|
||||||
|
fetchSeasonMeetings,
|
||||||
|
fetchSeasons,
|
||||||
|
fetchWeekend,
|
||||||
|
} from '../api'
|
||||||
|
import { countryFlag, formatGpDateRange } from '../lib/gpIdentity'
|
||||||
|
import { meetingHasStarted } from '../lib/schedule'
|
||||||
|
import { teamColor } from '../utils'
|
||||||
|
import type { Weekend } from '../types'
|
||||||
|
import '../styles/weekend.css'
|
||||||
|
|
||||||
|
function analysisKey(weekend: Weekend | undefined): number | undefined {
|
||||||
|
if (!weekend) return undefined
|
||||||
|
const race = weekend.sessions.find((s) => s.session.session_type?.toLowerCase().includes('race'))
|
||||||
|
return race?.session.session_key ?? weekend.default_session_key
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExplorePage() {
|
||||||
|
const now = useMemo(() => new Date(), [])
|
||||||
|
|
||||||
|
const seasonsQuery = useQuery({
|
||||||
|
queryKey: ['seasons'],
|
||||||
|
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||||
|
})
|
||||||
|
const season = seasonsQuery.data?.[0] ?? null
|
||||||
|
|
||||||
|
const localMeetingsQuery = useQuery({
|
||||||
|
queryKey: ['meetings', season, 'local'],
|
||||||
|
queryFn: ({ signal }) => fetchLocalMeetings(season!, signal),
|
||||||
|
enabled: season != null,
|
||||||
|
})
|
||||||
|
const seasonMeetingsQuery = useQuery({
|
||||||
|
queryKey: ['season-meetings', season],
|
||||||
|
queryFn: ({ signal }) => fetchSeasonMeetings(season!, signal),
|
||||||
|
enabled: season != null,
|
||||||
|
})
|
||||||
|
const championshipQuery = useQuery({
|
||||||
|
queryKey: ['championship-hub', season],
|
||||||
|
queryFn: ({ signal }) => fetchChampionshipHub(season!, signal),
|
||||||
|
enabled: season != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const localMeetings = localMeetingsQuery.data ?? []
|
||||||
|
const meetings = seasonMeetingsQuery.data?.length ? seasonMeetingsQuery.data : localMeetings
|
||||||
|
|
||||||
|
const weekendQueries = useQueries({
|
||||||
|
queries: localMeetings.map((m) => ({
|
||||||
|
queryKey: ['weekend', m.meeting_key],
|
||||||
|
queryFn: ({ signal }: { signal: AbortSignal }) => fetchWeekend(m.meeting_key, signal),
|
||||||
|
enabled: localMeetings.length > 0,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
const weekendsByKey = useMemo(() => {
|
||||||
|
const map = new Map<number, Weekend>()
|
||||||
|
localMeetings.forEach((m, i) => {
|
||||||
|
const data = weekendQueries[i]?.data
|
||||||
|
if (data) map.set(m.meeting_key, data)
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
}, [localMeetings, weekendQueries])
|
||||||
|
|
||||||
|
const drivers = championshipQuery.data?.drivers ?? []
|
||||||
|
const activeMeetings = meetings.filter((m) => !m.is_cancelled)
|
||||||
|
|
||||||
|
if (seasonsQuery.isLoading) {
|
||||||
|
return <div className="wk-status" data-testid="explore-loading"><p className="wk-status-title">Loading…</p></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="wk-page wk-explore" data-testid="explore-page">
|
||||||
|
<div className="wk-eyebrow mono">Explore · {season ?? 'season'}</div>
|
||||||
|
|
||||||
|
<section className="wk-explore-section" aria-labelledby="explore-calendar-title">
|
||||||
|
<header className="wk-card-head">
|
||||||
|
<span className="wk-card-title" id="explore-calendar-title">Season races</span>
|
||||||
|
<span className="wk-card-link mono">{activeMeetings.length} rounds</span>
|
||||||
|
</header>
|
||||||
|
{activeMeetings.length === 0 ? (
|
||||||
|
<p className="wk-status-sub" data-testid="explore-calendar-empty">No season calendar available yet.</p>
|
||||||
|
) : (
|
||||||
|
<ol className="wk-explore-grid" role="list">
|
||||||
|
{activeMeetings.map((meeting, index) => {
|
||||||
|
const weekend = weekendsByKey.get(meeting.meeting_key)
|
||||||
|
const target = analysisKey(weekend)
|
||||||
|
const done = meetingHasStarted(meeting, now)
|
||||||
|
const card = (
|
||||||
|
<>
|
||||||
|
<div className="wk-explore-top mono">
|
||||||
|
<span>R{String(index + 1).padStart(2, '0')}</span>
|
||||||
|
<span className="wk-explore-flag">
|
||||||
|
{countryFlag(meeting) || meeting.country_code}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="wk-explore-name">{meeting.meeting_name}</div>
|
||||||
|
<div className="wk-explore-meta mono">{meeting.circuit_short_name || meeting.location}</div>
|
||||||
|
<div className="wk-explore-meta mono">{formatGpDateRange(meeting)}</div>
|
||||||
|
<span className={`wk-explore-state ${done ? 'is-done' : 'is-upcoming'}`}>
|
||||||
|
{done ? 'Completed' : 'Upcoming'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<li key={meeting.meeting_key} className="wk-explore-item" role="listitem">
|
||||||
|
{target ? (
|
||||||
|
<Link
|
||||||
|
to="/race-hub"
|
||||||
|
search={{ session_key: target }}
|
||||||
|
className="wk-explore-card is-link"
|
||||||
|
data-testid={`explore-race-${meeting.meeting_key}`}
|
||||||
|
>
|
||||||
|
{card}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="wk-explore-card" data-testid={`explore-race-${meeting.meeting_key}`}>{card}</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="wk-explore-section" aria-labelledby="explore-drivers-title">
|
||||||
|
<header className="wk-card-head">
|
||||||
|
<span className="wk-card-title" id="explore-drivers-title">Drivers</span>
|
||||||
|
<Link to="/championship" className="wk-card-link">Championship →</Link>
|
||||||
|
</header>
|
||||||
|
{drivers.length === 0 ? (
|
||||||
|
<p className="wk-status-sub" data-testid="explore-drivers-empty">Driver data not available yet.</p>
|
||||||
|
) : (
|
||||||
|
<ol className="wk-explore-drivers" role="list">
|
||||||
|
{drivers.map((d) => (
|
||||||
|
<li key={d.driver_number} role="listitem">
|
||||||
|
<Link
|
||||||
|
to="/drivers/$driverNumber"
|
||||||
|
params={{ driverNumber: String(d.driver_number) }}
|
||||||
|
search={season ? { year: season } : {}}
|
||||||
|
className="wk-driver-chip"
|
||||||
|
style={{ ['--wk-team' as string]: teamColor(d.team_colour) }}
|
||||||
|
data-testid={`explore-driver-${d.driver_number}`}
|
||||||
|
>
|
||||||
|
<span className="wk-driver-pos mono">{d.position}</span>
|
||||||
|
<span className="wk-driver-code">{d.name_acronym}</span>
|
||||||
|
<span className="wk-driver-pts mono">{d.points}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { fetchLiveState } from '../api'
|
import { fetchLiveState, fetchWeekendContext } from '../api'
|
||||||
import type { LivePosition, LiveStreamData } from '../types'
|
import type { LivePosition, LiveStreamData } from '../types'
|
||||||
import {
|
import {
|
||||||
loadPinnedDrivers,
|
loadPinnedDrivers,
|
||||||
@@ -17,6 +17,15 @@ import { recordGapSamples } from '../lib/gapHistory'
|
|||||||
import { battleNumbers, detectBattles } from '../lib/battles'
|
import { battleNumbers, detectBattles } from '../lib/battles'
|
||||||
import type { LiveEvent } from '../lib/events'
|
import type { LiveEvent } from '../lib/events'
|
||||||
import { appendEvents, diffSnapshots, sessionSignature } from '../lib/events'
|
import { appendEvents, diffSnapshots, sessionSignature } from '../lib/events'
|
||||||
|
import {
|
||||||
|
allowsLiveInterpretations,
|
||||||
|
deriveLivePhase,
|
||||||
|
effectiveFeedHealth,
|
||||||
|
rendersSnapshot,
|
||||||
|
terminalSessionStatus,
|
||||||
|
} from '../lib/liveState'
|
||||||
|
import type { TransportHealth } from '../lib/liveState'
|
||||||
|
import { shouldPollHandoffAnalysis } from '../components/live/LiveHandoff'
|
||||||
import { SessionBanner } from '../components/live/SessionBanner'
|
import { SessionBanner } from '../components/live/SessionBanner'
|
||||||
import { TrackStatusBanner } from '../components/live/TrackStatusBanner'
|
import { TrackStatusBanner } from '../components/live/TrackStatusBanner'
|
||||||
import { TimingTower } from '../components/live/TimingTower'
|
import { TimingTower } from '../components/live/TimingTower'
|
||||||
@@ -26,9 +35,14 @@ import { RaceControlFeed } from '../components/live/RaceControlFeed'
|
|||||||
import { EventRail } from '../components/live/EventRail'
|
import { EventRail } from '../components/live/EventRail'
|
||||||
import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
|
import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
|
||||||
import { TyreDegPanel } from '../components/live/TyreDegPanel'
|
import { TyreDegPanel } from '../components/live/TyreDegPanel'
|
||||||
import { Archive, Radio } from 'lucide-react'
|
import { LiveHandoff } from '../components/live/LiveHandoff'
|
||||||
|
import { DataNotice, RouteState } from '../components/RouteState'
|
||||||
|
import { weekendContextNotice } from '../lib/availability'
|
||||||
|
import '../styles/live-state.css'
|
||||||
|
|
||||||
type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
|
/** How often to re-check weekend-context while analysis is still ingesting. */
|
||||||
|
export const WEEKEND_CONTEXT_POLL_MS = 15_000
|
||||||
|
const WEEKEND_CONTEXT_STALE_MS = 15_000
|
||||||
|
|
||||||
export function LiveTimingPage() {
|
export function LiveTimingPage() {
|
||||||
const [activeSnapshot, setActiveSnapshot] = useState<LiveStreamData | null>(null)
|
const [activeSnapshot, setActiveSnapshot] = useState<LiveStreamData | null>(null)
|
||||||
@@ -37,31 +51,96 @@ export function LiveTimingPage() {
|
|||||||
const [archiveSnapshotAt, setArchiveSnapshotAt] = useState<string | null>(null)
|
const [archiveSnapshotAt, setArchiveSnapshotAt] = useState<string | null>(null)
|
||||||
const [archiveMode, setArchiveMode] = useState(false)
|
const [archiveMode, setArchiveMode] = useState(false)
|
||||||
const [isLive, setIsLive] = useState(false)
|
const [isLive, setIsLive] = useState(false)
|
||||||
const [streamStatus, setStreamStatus] = useState<StreamStatus>('connecting')
|
const [streamStatus, setStreamStatus] = useState<TransportHealth>('connecting')
|
||||||
const [now, setNow] = useState(Date.now())
|
const [now, setNow] = useState(Date.now())
|
||||||
const [gapHistory, setGapHistory] = useState<GapHistoryMap>({})
|
const [gapHistory, setGapHistory] = useState<GapHistoryMap>({})
|
||||||
const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers())
|
const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers())
|
||||||
const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({})
|
const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({})
|
||||||
const [positions, setPositions] = useState<Record<string, LivePosition>>({})
|
const [, setPositions] = useState<Record<string, LivePosition>>({})
|
||||||
const [events, setEvents] = useState<LiveEvent[]>([])
|
const [events, setEvents] = useState<LiveEvent[]>([])
|
||||||
|
// Authoritative SSE snapshot/event receipt — distinct from non-null timing rows.
|
||||||
|
// An inactive `is_live:false,data:null,last_snapshot:null` event is still valid.
|
||||||
|
const [authoritativeSseReceived, setAuthoritativeSseReceived] = useState(false)
|
||||||
const prevSnapshotRef = useRef<LiveStreamData | null>(null)
|
const prevSnapshotRef = useRef<LiveStreamData | null>(null)
|
||||||
const sessionSigRef = useRef('')
|
const sessionSigRef = useRef('')
|
||||||
const isLiveRef = useRef(false)
|
const isLiveRef = useRef(false)
|
||||||
const archiveModeRef = useRef(false)
|
const archiveModeRef = useRef(false)
|
||||||
const snapshot = isLive ? activeSnapshot : archiveMode ? archiveSnapshot : null
|
// Whether we ever observed this session as live — used to reason about a
|
||||||
|
// dropped feed vs. a session that was never live in this page session.
|
||||||
|
const wasLiveRef = useRef(false)
|
||||||
const hasArchive = Boolean(archiveSnapshot)
|
const hasArchive = Boolean(archiveSnapshot)
|
||||||
|
|
||||||
const { data, isLoading, isError, error } = useQuery({
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
isFetching,
|
||||||
|
isFetched: liveStateFetched,
|
||||||
|
} = useQuery({
|
||||||
queryKey: ['live-state'],
|
queryKey: ['live-state'],
|
||||||
queryFn: fetchLiveState,
|
queryFn: ({ signal }) => fetchLiveState(signal),
|
||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A retained snapshot is only "settled" (session genuinely finished) if it
|
||||||
|
// carries a terminal FIA SessionStatus. Otherwise the upstream feed dropped
|
||||||
|
// while the session was still active and we must stay in `disconnected`.
|
||||||
|
const sessionEndedCleanly = terminalSessionStatus(archiveSnapshot?.SessionStatus)
|
||||||
|
|
||||||
|
// Transport health, active-session state, and archive availability are
|
||||||
|
// orthogonal inputs; deriveLivePhase collapses them into one UI phase.
|
||||||
|
const phase = deriveLivePhase({
|
||||||
|
transport: streamStatus,
|
||||||
|
isLive,
|
||||||
|
hasActiveSnapshot: Boolean(activeSnapshot),
|
||||||
|
hasArchive,
|
||||||
|
archiveMode,
|
||||||
|
sessionEndedCleanly,
|
||||||
|
wasLive: wasLiveRef.current,
|
||||||
|
stateLoaded: liveStateFetched,
|
||||||
|
})
|
||||||
|
const snapshot =
|
||||||
|
phase === 'archive'
|
||||||
|
? archiveSnapshot
|
||||||
|
: isLive
|
||||||
|
? activeSnapshot
|
||||||
|
: phase === 'disconnected'
|
||||||
|
? archiveSnapshot
|
||||||
|
: null
|
||||||
|
|
||||||
|
// Canonical weekend context (issue #72) — the single source of truth for
|
||||||
|
// previous/next/default-analysis identity and temporal state. Only needed
|
||||||
|
// when no session is streaming; keep it idle during a live session. Poll
|
||||||
|
// while a completed session is still ingesting so `settling` can flip to
|
||||||
|
// analysis-ready without a manual refresh.
|
||||||
|
const contextQuery = useQuery({
|
||||||
|
queryKey: ['weekend-context'],
|
||||||
|
queryFn: ({ signal }) => fetchWeekendContext(signal),
|
||||||
|
enabled: !isLive,
|
||||||
|
staleTime: WEEKEND_CONTEXT_STALE_MS,
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
// Poll until the just-finished previous_completed_session is ready — an
|
||||||
|
// older already-complete default_analysis_session must not stop us.
|
||||||
|
return shouldPollHandoffAnalysis(query.state.data) ? WEEKEND_CONTEXT_POLL_MS : false
|
||||||
|
},
|
||||||
|
refetchIntervalInBackground: false,
|
||||||
|
})
|
||||||
|
const weekendContext = contextQuery.data
|
||||||
|
// Context freshness belongs only on inactive/settling handoff — never relabel
|
||||||
|
// active FIA timing because an optional REST supplement reported stale/limited.
|
||||||
|
const contextAvailabilityNotice =
|
||||||
|
(phase === 'settling' || phase === 'inactive') && weekendContext
|
||||||
|
? weekendContextNotice(weekendContext)
|
||||||
|
: null
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data) return
|
if (!data) return
|
||||||
const nextLive = data.is_live && Boolean(data.data)
|
const nextLive = data.is_live && Boolean(data.data)
|
||||||
setIsLive(nextLive)
|
setIsLive(nextLive)
|
||||||
isLiveRef.current = nextLive
|
isLiveRef.current = nextLive
|
||||||
|
if (nextLive) wasLiveRef.current = true
|
||||||
if (nextLive && data.data) {
|
if (nextLive && data.data) {
|
||||||
setActiveSnapshot(data.data)
|
setActiveSnapshot(data.data)
|
||||||
setArchiveMode(false)
|
setArchiveMode(false)
|
||||||
@@ -104,6 +183,9 @@ export function LiveTimingPage() {
|
|||||||
events.addEventListener('snapshot', (event) => {
|
events.addEventListener('snapshot', (event) => {
|
||||||
const state = parseLiveStateEvent(event.data)
|
const state = parseLiveStateEvent(event.data)
|
||||||
if (!state || cancelled) return
|
if (!state || cancelled) return
|
||||||
|
// Valid SSE state clears a false fatal REST error even when all snapshots
|
||||||
|
// are null (inactive handoff) — do not require non-null timing rows.
|
||||||
|
setAuthoritativeSseReceived(true)
|
||||||
const nextLive = state.is_live && Boolean(state.data)
|
const nextLive = state.is_live && Boolean(state.data)
|
||||||
setIsLive(nextLive)
|
setIsLive(nextLive)
|
||||||
if (nextLive && state.data) {
|
if (nextLive && state.data) {
|
||||||
@@ -111,6 +193,7 @@ export function LiveTimingPage() {
|
|||||||
setPositions({})
|
setPositions({})
|
||||||
}
|
}
|
||||||
isLiveRef.current = true
|
isLiveRef.current = true
|
||||||
|
wasLiveRef.current = true
|
||||||
setActiveSnapshot(state.data)
|
setActiveSnapshot(state.data)
|
||||||
setArchiveMode(false)
|
setArchiveMode(false)
|
||||||
} else {
|
} else {
|
||||||
@@ -167,6 +250,8 @@ export function LiveTimingPage() {
|
|||||||
}, [snapshot])
|
}, [snapshot])
|
||||||
|
|
||||||
const rawRows = useMemo(() => sortLiveTimingRows(snapshot), [snapshot])
|
const rawRows = useMemo(() => sortLiveTimingRows(snapshot), [snapshot])
|
||||||
|
// Final-snapshot rows for the settling handoff (independent of live rows).
|
||||||
|
const settlingRows = useMemo(() => sortLiveTimingRows(archiveSnapshot), [archiveSnapshot])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rawRows.length === 0) {
|
if (rawRows.length === 0) {
|
||||||
@@ -210,68 +295,100 @@ export function LiveTimingPage() {
|
|||||||
setPositions(archivePositions)
|
setPositions(archivePositions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleExitArchive = () => {
|
||||||
|
setArchiveMode(false)
|
||||||
|
}
|
||||||
|
|
||||||
const archiveTimestamp = archiveSnapshotAt ? new Date(archiveSnapshotAt) : null
|
const archiveTimestamp = archiveSnapshotAt ? new Date(archiveSnapshotAt) : null
|
||||||
const archiveLabel =
|
const archiveLabel =
|
||||||
archiveTimestamp && !Number.isNaN(archiveTimestamp.getTime())
|
archiveTimestamp && !Number.isNaN(archiveTimestamp.getTime())
|
||||||
? `Archived snapshot from ${archiveTimestamp.toLocaleString()}`
|
? `Archived snapshot from ${archiveTimestamp.toLocaleString()}`
|
||||||
: 'Archived live timing snapshot'
|
: 'Archived live timing snapshot'
|
||||||
|
|
||||||
|
const showLiveInterpretations = allowsLiveInterpretations(phase)
|
||||||
|
// Upstream FIA loss can leave the browser SSE open; present one coherent
|
||||||
|
// feed-health truth rather than "Connection lost" + "Feed healthy".
|
||||||
|
const feedHealth = effectiveFeedHealth(streamStatus, phase)
|
||||||
|
|
||||||
|
// Usable timing rows OR an authoritative SSE state event (including inactive
|
||||||
|
// null payloads) clear the fatal initial REST error. Preserve error+Retry only
|
||||||
|
// when neither REST nor SSE yields a valid state.
|
||||||
|
const hasUsableStreamData = Boolean(activeSnapshot) || Boolean(archiveSnapshot)
|
||||||
|
const showInitialError = isError && !hasUsableStreamData && !authoritativeSseReceived
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page live-page" data-testid="live-page">
|
<div className="page live-page" data-testid="live-page" data-phase={phase}>
|
||||||
{isError && (
|
{showInitialError && (
|
||||||
<div className="error-box">
|
<RouteState
|
||||||
{error instanceof Error ? error.message : 'Failed to load live timing state'}
|
kind="error"
|
||||||
|
title="Live timing unavailable"
|
||||||
|
error={error}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!isFetching) void refetch()
|
||||||
|
}}
|
||||||
|
retrying={isFetching}
|
||||||
|
testId="live-initial-error"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'disconnected' && (
|
||||||
|
<div className="live-status-strip live-status-warn" data-testid="live-disconnected-strip">
|
||||||
|
Connection lost — showing the last live data while we reconnect. This is not an archive.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{streamStatus === 'disconnected' && snapshot && !archiveMode && (
|
{phase === 'archive' && (
|
||||||
<div className="live-status-strip live-status-warn">
|
|
||||||
Stream disconnected — showing last received snapshot
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{archiveMode && snapshot && (
|
|
||||||
<div className="live-status-strip live-status-archive" data-testid="live-archive-strip">
|
<div className="live-status-strip live-status-archive" data-testid="live-archive-strip">
|
||||||
{archiveLabel} — live updates are paused for this archive view
|
<span>{archiveLabel} — read-only, live updates are paused</span>
|
||||||
</div>
|
<button type="button" className="live-archive-exit" onClick={handleExitArchive}>
|
||||||
)}
|
Exit archive
|
||||||
|
|
||||||
{isLoading && !snapshot && (
|
|
||||||
<div className="loading-state">connecting to live timing…</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoading && !snapshot && (
|
|
||||||
<div className="empty-state ui-card glass-panel" style={{ padding: '40px', textAlign: 'center', marginTop: '20vh', maxWidth: '400px', marginLeft: 'auto', marginRight: 'auto' }} data-testid="live-empty">
|
|
||||||
<div className="live-empty-status" style={{ marginBottom: '16px' }}>
|
|
||||||
<span className={`live-conn live-conn-${streamStatus}`}>{streamStatus}</span>
|
|
||||||
</div>
|
|
||||||
<Radio size={48} style={{ color: 'var(--text-3)', margin: '0 auto 16px auto', display: 'block' }} />
|
|
||||||
<h2 className="empty-state-title" style={{ fontSize: '20px', marginBottom: '8px' }}>No live session active</h2>
|
|
||||||
<p className="empty-state-desc" style={{ color: 'var(--text-2)' }}>
|
|
||||||
The telemetry feed is currently offline. <br /><br /> Check the <a href="/" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Command Center</a> for the weekend schedule or explore historical data in the <a href="/race-hub" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Race Hub</a>.
|
|
||||||
</p>
|
|
||||||
{hasArchive && (
|
|
||||||
<button type="button" className="live-archive-btn" onClick={handleViewArchive}>
|
|
||||||
<Archive size={15} />
|
|
||||||
View Last Session
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{snapshot && (
|
{phase === 'connecting' && (isLoading || !liveStateFetched) && !hasUsableStreamData && (
|
||||||
|
<RouteState
|
||||||
|
kind="loading"
|
||||||
|
title="connecting to live timing…"
|
||||||
|
testId="live-initial-loading"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(phase === 'settling' || phase === 'inactive') && !showInitialError && (
|
||||||
|
<>
|
||||||
|
{contextAvailabilityNotice && (
|
||||||
|
<DataNotice
|
||||||
|
availability={contextAvailabilityNotice}
|
||||||
|
testId="live-context-data-notice"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<LiveHandoff
|
||||||
|
phase={phase}
|
||||||
|
transport={feedHealth}
|
||||||
|
context={weekendContext}
|
||||||
|
rows={settlingRows}
|
||||||
|
capturedAt={archiveSnapshotAt}
|
||||||
|
hasArchive={hasArchive}
|
||||||
|
onViewArchive={handleViewArchive}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{snapshot && rendersSnapshot(phase) && (
|
||||||
<>
|
<>
|
||||||
<SessionBanner
|
<SessionBanner
|
||||||
isLive={isLive}
|
phase={phase}
|
||||||
isArchive={archiveMode}
|
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
connection={streamStatus}
|
transport={feedHealth}
|
||||||
now={now}
|
now={now}
|
||||||
|
capturedAt={phase === 'archive' ? archiveSnapshotAt : null}
|
||||||
/>
|
/>
|
||||||
<TrackStatusBanner status={snapshot.TrackStatus} />
|
<TrackStatusBanner status={snapshot.TrackStatus} />
|
||||||
<PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} />
|
<PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} />
|
||||||
|
{showLiveInterpretations && (
|
||||||
<TyreDegPanel rows={rows} sessionType={snapshot.Session?.SessionType} pinned={pinned} />
|
<TyreDegPanel rows={rows} sessionType={snapshot.Session?.SessionType} pinned={pinned} />
|
||||||
|
)}
|
||||||
<div className="live-columns">
|
<div className="live-columns">
|
||||||
<div className="live-tower-col">
|
<div className="live-tower-col">
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
@@ -289,16 +406,14 @@ export function LiveTimingPage() {
|
|||||||
session={snapshot.Session}
|
session={snapshot.Session}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Rail runs most-synthesized to most-raw: a reader arriving
|
|
||||||
mid-session wants "what did I miss" before the regulatory log. */}
|
|
||||||
<div className="live-rc-col">
|
<div className="live-rc-col">
|
||||||
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
|
|
||||||
<TeamRadioTicker
|
<TeamRadioTicker
|
||||||
captures={snapshot.TeamRadio ?? []}
|
captures={snapshot.TeamRadio ?? []}
|
||||||
driverInfo={snapshot.DriverInfo}
|
driverInfo={snapshot.DriverInfo}
|
||||||
session={snapshot.Session}
|
session={snapshot.Session}
|
||||||
/>
|
/>
|
||||||
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
|
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
|
||||||
|
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { Link, useNavigate } from '@tanstack/react-router'
|
||||||
import { 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'
|
||||||
@@ -12,18 +13,29 @@ import { CompareView } from '../components/CompareView'
|
|||||||
import { RaceControlView } from '../components/RaceControlView'
|
import { RaceControlView } from '../components/RaceControlView'
|
||||||
import { WeatherView } from '../components/WeatherView'
|
import { WeatherView } from '../components/WeatherView'
|
||||||
import { OverviewView } from '../components/OverviewView'
|
import { OverviewView } from '../components/OverviewView'
|
||||||
|
import {
|
||||||
|
PartialAnalysisBanner,
|
||||||
|
PreSessionView,
|
||||||
|
SessionPhaseView,
|
||||||
|
} from '../components/PreSessionView'
|
||||||
import { WeekendSwitcher } from '../components/WeekendSwitcher'
|
import { WeekendSwitcher } from '../components/WeekendSwitcher'
|
||||||
import { SourceBadge } from '../components/SourceBadge'
|
import { SourceBadge } from '../components/SourceBadge'
|
||||||
|
import { DataNotice, RouteState } from '../components/RouteState'
|
||||||
|
import { noticeFromResponse, noticeMessage } from '../lib/availability'
|
||||||
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
||||||
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
|
import { sessionTypeAbbrev } from '../lib/coverage'
|
||||||
|
import { formatSessionScheduleTime, sortSessionsByStart } from '../lib/schedule'
|
||||||
import {
|
import {
|
||||||
MAX_BROWSER_TIMEOUT,
|
defaultAnalysisSessionKey,
|
||||||
formatCountdown,
|
isPartialAnalysis,
|
||||||
formatSessionScheduleTime,
|
isPreSession,
|
||||||
refreshDeadlineDelay,
|
isPreparing,
|
||||||
sortSessionsByStart,
|
isUnavailable,
|
||||||
} from '../lib/schedule'
|
resolveSessionState,
|
||||||
import type { ContextSession, Weekend } from '../types'
|
sessionStateDotClass,
|
||||||
|
sessionStateLabel,
|
||||||
|
} from '../lib/sessionState'
|
||||||
|
import type { WeekendSession } from '../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionKey: number
|
sessionKey: number
|
||||||
@@ -33,63 +45,45 @@ 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 [now, setNow] = useState(() => Date.now())
|
const [showDiagnostics, setShowDiagnostics] = useState(false)
|
||||||
const [refreshGeneration, setRefreshGeneration] = useState(0)
|
const [phaseDiagnostics, setPhaseDiagnostics] = useState(false)
|
||||||
|
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: fetchWeekendContext,
|
queryFn: ({ signal }) => fetchWeekendContext(signal),
|
||||||
enabled: sessionKey === 0,
|
staleTime: 15_000,
|
||||||
})
|
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
|
||||||
const delay = refreshDeadlineDelay(context?.race_hub_refresh_at)
|
if (!contextQuery.isSuccess || !context) return
|
||||||
if (delay == null) return
|
const target = defaultAnalysisSessionKey(context)
|
||||||
const rearmAfterRefetch = delay === MAX_BROWSER_TIMEOUT
|
if (target) {
|
||||||
const timer = window.setTimeout(() => {
|
navigate({ to: '/race-hub', search: { session_key: target }, replace: true })
|
||||||
void refetchContext().finally(() => {
|
}
|
||||||
if (rearmAfterRefetch) setRefreshGeneration((generation) => generation + 1)
|
}, [sessionKey, contextQuery.isSuccess, context, navigate])
|
||||||
})
|
|
||||||
}, 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', selectedSessionKey],
|
queryKey: ['race-hub', sessionKey],
|
||||||
queryFn: () => fetchRaceHub(selectedSessionKey),
|
queryFn: ({ signal }) => fetchRaceHub(sessionKey, signal),
|
||||||
enabled: selectedSessionKey > 0 && (sessionKey > 0 || !preSession),
|
enabled: sessionKey > 0,
|
||||||
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: () => fetchWeekend(meetingKey!),
|
queryFn: ({ signal }) => fetchWeekend(meetingKey!, signal),
|
||||||
enabled: meetingKey != null && meetingKey > 0,
|
enabled: meetingKey != null && meetingKey > 0,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
@@ -99,63 +93,142 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
const accent = countryAccent(data?.meeting ?? null)
|
const accent = countryAccent(data?.meeting ?? null)
|
||||||
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
|
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
|
||||||
|
|
||||||
// ─── No session_key: resolve exclusively through canonical Weekend Context ───
|
const openDiagnostics = () => {
|
||||||
if (sessionKey === 0) {
|
setActiveTab('data_status')
|
||||||
if (contextQuery.isLoading || (preSession && preSessionWeekendQuery.isLoading)) {
|
setShowDiagnostics(true)
|
||||||
return (
|
setPhaseDiagnostics(true)
|
||||||
<div className="rh-page" style={accentStyle}>
|
|
||||||
<div className="loading-state">resolving latest local weekend…</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (preSession && preSessionRef) {
|
|
||||||
return (
|
|
||||||
<RaceHubPreSession
|
|
||||||
session={preSessionRef}
|
|
||||||
weekend={preSessionWeekendQuery.data}
|
|
||||||
now={now}
|
|
||||||
switcherOpen={switcherOpen}
|
|
||||||
onToggleSwitcher={() => setSwitcherOpen((open) => !open)}
|
|
||||||
onCloseSwitcher={() => setSwitcherOpen(false)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!selectedSessionKey) {
|
|
||||||
return (
|
|
||||||
<div className="rh-page rh-empty" data-testid="race-hub-empty" style={accentStyle}>
|
|
||||||
<div className="rh-empty-band">
|
|
||||||
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
|
|
||||||
<h1 className="rh-empty-title">No completed local analysis yet</h1>
|
|
||||||
<p className="rh-empty-sub">
|
|
||||||
Race Hub opens completed local analysis between weekends. Check Data Health
|
|
||||||
to ingest a completed session.
|
|
||||||
</p>
|
|
||||||
<div className="rh-empty-actions">
|
|
||||||
<a href="/admin" className="rh-empty-action">Open Admin · Data Health</a>
|
|
||||||
<a href="/" className="rh-empty-action">Back to Command Center</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Loading / error for the selected session ───
|
// ─── No session_key: resolve via Weekend Context ───
|
||||||
|
if (sessionKey === 0) {
|
||||||
|
if (contextQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="rh-page" style={accentStyle}>
|
||||||
|
<RouteState
|
||||||
|
kind="loading"
|
||||||
|
title="resolving weekend context…"
|
||||||
|
testId="race-hub-loading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (contextQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="rh-page" data-testid="race-hub-error" style={accentStyle}>
|
||||||
|
<RouteState
|
||||||
|
kind="error"
|
||||||
|
title="Weekend unavailable"
|
||||||
|
error={contextQuery.error}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!contextQuery.isFetching) void contextQuery.refetch()
|
||||||
|
}}
|
||||||
|
retrying={contextQuery.isFetching}
|
||||||
|
retryTestId="rh-retry"
|
||||||
|
>
|
||||||
|
<div className="rh-recover-actions">
|
||||||
|
<Link to="/" search={{}} className="rh-recover-btn" data-testid="rh-back-weekend">
|
||||||
|
Back to Weekend
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</RouteState>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context && !defaultAnalysisSessionKey(context)) {
|
||||||
|
return (
|
||||||
|
<div className="rh-page rh-empty" data-testid="race-hub-no-analysis" style={accentStyle}>
|
||||||
|
<div className="rh-empty-band">
|
||||||
|
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
|
||||||
|
<h1 className="rh-empty-title">No completed session to analyse yet</h1>
|
||||||
|
<p className="rh-empty-sub">
|
||||||
|
Weekend Context has no default analysis session. Pick a past session
|
||||||
|
to review, or check back once a session completes with local analysis.
|
||||||
|
</p>
|
||||||
|
<div className="rh-empty-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rh-empty-action"
|
||||||
|
onClick={() => setSwitcherOpen(true)}
|
||||||
|
>
|
||||||
|
Browse Weekends
|
||||||
|
</button>
|
||||||
|
<Link to="/" search={{}} className="rh-empty-action" data-testid="rh-back-weekend">
|
||||||
|
Back to Weekend
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{switcherOpen && (
|
||||||
|
<WeekendSwitcher
|
||||||
|
currentMeetingKey={context.focus_meeting?.meeting_key}
|
||||||
|
context={context}
|
||||||
|
now={now}
|
||||||
|
onClose={() => setSwitcherOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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 ───
|
||||||
if (raceHubQuery.isLoading) {
|
if (raceHubQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="rh-page" style={accentStyle}>
|
<div className="rh-page" style={accentStyle}>
|
||||||
<div className="loading-state">loading session {selectedSessionKey}…</div>
|
<RouteState
|
||||||
|
kind="loading"
|
||||||
|
title={`loading session ${sessionKey}…`}
|
||||||
|
testId="race-hub-loading"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (raceHubQuery.isError || !data) {
|
if (raceHubQuery.isError || !data) {
|
||||||
|
const backMeeting =
|
||||||
|
context?.focus_meeting?.meeting_key ??
|
||||||
|
context?.previous_completed_session?.meeting?.meeting_key
|
||||||
|
const backSearch = weekendFocusSearch(backMeeting, sessionKey)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rh-page" style={accentStyle}>
|
<div className="rh-page" data-testid="race-hub-error" style={accentStyle}>
|
||||||
<div className="error-box">
|
<RouteState
|
||||||
{raceHubQuery.error instanceof Error
|
kind="error"
|
||||||
? raceHubQuery.error.message
|
title="Session unavailable"
|
||||||
: `Failed to load session ${selectedSessionKey}.`}
|
error={raceHubQuery.error}
|
||||||
|
message={
|
||||||
|
raceHubQuery.error
|
||||||
|
? undefined
|
||||||
|
: `Session ${sessionKey} could not be loaded from local data.`
|
||||||
|
}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!raceHubQuery.isFetching) void raceHubQuery.refetch()
|
||||||
|
}}
|
||||||
|
retrying={raceHubQuery.isFetching}
|
||||||
|
retryTestId="rh-retry"
|
||||||
|
>
|
||||||
|
<div className="rh-recover-actions">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
search={backSearch}
|
||||||
|
className="rh-recover-btn"
|
||||||
|
data-testid="rh-back-weekend"
|
||||||
|
data-meeting-key={backMeeting ?? undefined}
|
||||||
|
data-session-key={sessionKey}
|
||||||
|
>
|
||||||
|
Back to Weekend
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</RouteState>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -165,12 +238,35 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
const sessionMeta = weekend
|
const sessionMeta = weekend
|
||||||
? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w]))
|
? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w]))
|
||||||
: {}
|
: {}
|
||||||
const activeSessionMeta = sessionMeta[selectedSessionKey]
|
const activeSessionMeta: WeekendSession | undefined = sessionMeta[sessionKey]
|
||||||
|
const activeState = resolveSessionState({
|
||||||
|
weekendSession: activeSessionMeta,
|
||||||
|
context,
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
const preSession = isPreSession(activeState)
|
||||||
|
const preparing = isPreparing(activeState)
|
||||||
|
const unavailable = isUnavailable(activeState)
|
||||||
|
const partial = isPartialAnalysis(activeState)
|
||||||
|
// Distinct stale disclosure from response headers — skip routine local (already
|
||||||
|
// covered by SourceBadge) and skip partial when the readiness banner is shown.
|
||||||
|
const freshnessNotice = noticeFromResponse(data, { includeLocal: false })
|
||||||
|
const showFreshnessNotice =
|
||||||
|
freshnessNotice != null && !(partial && freshnessNotice === 'partial')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
|
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
|
||||||
{/* Topbar */}
|
|
||||||
<div className="rh-topbar">
|
<div className="rh-topbar">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
search={weekendFocusSearch(meetingKey, sessionKey)}
|
||||||
|
className="rh-back-weekend"
|
||||||
|
data-testid="rh-back-weekend"
|
||||||
|
data-meeting-key={meetingKey ?? undefined}
|
||||||
|
data-session-key={sessionKey}
|
||||||
|
>
|
||||||
|
Back to Weekend
|
||||||
|
</Link>
|
||||||
<span className="rh-topbar-label mono">
|
<span className="rh-topbar-label mono">
|
||||||
box-box · race hub
|
box-box · race hub
|
||||||
{data.meeting?.year ? ` · ${data.meeting.year}` : ''}
|
{data.meeting?.year ? ` · ${data.meeting.year}` : ''}
|
||||||
@@ -188,15 +284,28 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showFreshnessNotice && freshnessNotice && (
|
||||||
|
<DataNotice
|
||||||
|
availability={freshnessNotice}
|
||||||
|
message={noticeMessage(freshnessNotice)}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!raceHubQuery.isFetching) void raceHubQuery.refetch()
|
||||||
|
}}
|
||||||
|
retrying={raceHubQuery.isFetching}
|
||||||
|
testId="race-hub-data-notice"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{switcherOpen && (
|
{switcherOpen && (
|
||||||
<WeekendSwitcher
|
<WeekendSwitcher
|
||||||
currentMeetingKey={meetingKey}
|
currentMeetingKey={meetingKey}
|
||||||
currentSessionKey={selectedSessionKey}
|
currentSessionKey={sessionKey}
|
||||||
|
context={context}
|
||||||
|
now={now}
|
||||||
onClose={() => setSwitcherOpen(false)}
|
onClose={() => setSwitcherOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* GP Identity band */}
|
|
||||||
{data.meeting && (
|
{data.meeting && (
|
||||||
<section className="rh-identity" data-testid="rh-identity">
|
<section className="rh-identity" data-testid="rh-identity">
|
||||||
<div className="rh-identity-accent" aria-hidden="true" />
|
<div className="rh-identity-accent" aria-hidden="true" />
|
||||||
@@ -217,12 +326,16 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Session rail */}
|
|
||||||
{sessions.length > 0 && (
|
{sessions.length > 0 && (
|
||||||
<nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail">
|
<nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail">
|
||||||
{sessions.map((session) => {
|
{sessions.map((session) => {
|
||||||
const meta = sessionMeta[session.session_key]
|
const meta = sessionMeta[session.session_key]
|
||||||
const active = session.session_key === selectedSessionKey
|
const active = session.session_key === sessionKey
|
||||||
|
const state = resolveSessionState({
|
||||||
|
weekendSession: meta,
|
||||||
|
context,
|
||||||
|
now,
|
||||||
|
})
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={session.session_key}
|
key={session.session_key}
|
||||||
@@ -244,43 +357,70 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
<span className="rh-session-time mono">
|
<span className="rh-session-time mono">
|
||||||
{formatSessionScheduleTime(session.date_start)}
|
{formatSessionScheduleTime(session.date_start)}
|
||||||
</span>
|
</span>
|
||||||
{meta && (
|
|
||||||
<span className="rh-session-cov mono">
|
<span className="rh-session-cov mono">
|
||||||
<span
|
<span
|
||||||
className={`cc-cov-dot cc-cov-${meta.source}`}
|
className={`cc-cov-dot ${sessionStateDotClass(state)}`}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{formatCoverageHint(meta.datasets)}
|
{sessionStateLabel(state)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Active session sub-bar */}
|
|
||||||
{data.session && (
|
{data.session && (
|
||||||
<div className="rh-active-bar" data-testid="rh-active-bar">
|
<div className="rh-active-bar" data-testid="rh-active-bar">
|
||||||
<span className="rh-active-name">{data.session.session_name}</span>
|
<span className="rh-active-name">{data.session.session_name}</span>
|
||||||
<span className="rh-active-meta mono">
|
<span className="rh-active-meta mono">
|
||||||
{formatSessionScheduleTime(data.session.date_start)}
|
{formatSessionScheduleTime(data.session.date_start)}
|
||||||
</span>
|
</span>
|
||||||
{activeSessionMeta && (
|
<span className="rh-active-cov mono" data-testid="rh-active-state">
|
||||||
<span className="rh-active-cov mono">
|
|
||||||
<span
|
<span
|
||||||
className={`cc-cov-dot cc-cov-${activeSessionMeta.source}`}
|
className={`cc-cov-dot ${sessionStateDotClass(activeState)}`}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{formatCoverageHint(activeSessionMeta.datasets)} datasets local
|
{sessionStateLabel(activeState)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
<span className="rh-active-key mono">key {sessionKey}</span>
|
||||||
<span className="rh-active-key mono">key {selectedSessionKey}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{preSession && data.session ? (
|
||||||
|
<PreSessionView
|
||||||
|
session={data.session}
|
||||||
|
sessionName={data.session.session_name}
|
||||||
|
now={now}
|
||||||
|
/>
|
||||||
|
) : preparing || unavailable ? (
|
||||||
|
<>
|
||||||
|
<SessionPhaseView
|
||||||
|
state={
|
||||||
|
unavailable
|
||||||
|
? activeState === 'cancelled'
|
||||||
|
? 'cancelled'
|
||||||
|
: 'unavailable'
|
||||||
|
: 'preparing'
|
||||||
|
}
|
||||||
|
sessionName={data.session?.session_name ?? `Session ${sessionKey}`}
|
||||||
|
onOpenDiagnostics={openDiagnostics}
|
||||||
|
/>
|
||||||
|
{phaseDiagnostics && (
|
||||||
|
<div className="data-section" data-testid="rh-phase-diagnostics">
|
||||||
|
<div className="sec-header">
|
||||||
|
<span className="sec-title">Diagnostics</span>
|
||||||
|
</div>
|
||||||
|
<DatasetStatusView datasets={data.datasets} />
|
||||||
|
<div style={{ marginTop: 'var(--s5)' }} data-testid="rh-dataset-strip">
|
||||||
<DatasetStrip datasets={data.datasets} />
|
<DatasetStrip datasets={data.datasets} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{partial && <PartialAnalysisBanner onOpenDiagnostics={openDiagnostics} />}
|
||||||
<TabBar active={activeTab} onChange={setActiveTab} />
|
<TabBar active={activeTab} onChange={setActiveTab} />
|
||||||
|
|
||||||
{activeTab === 'overview' && <OverviewView data={data} />}
|
{activeTab === 'overview' && <OverviewView data={data} />}
|
||||||
@@ -311,7 +451,7 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
<span className="sec-title">Driver Compare</span>
|
<span className="sec-title">Driver Compare</span>
|
||||||
</div>
|
</div>
|
||||||
<CompareView
|
<CompareView
|
||||||
sessionKey={selectedSessionKey}
|
sessionKey={sessionKey}
|
||||||
results={data.results}
|
results={data.results}
|
||||||
drivers={data.drivers}
|
drivers={data.drivers}
|
||||||
/>
|
/>
|
||||||
@@ -357,80 +497,27 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
{activeTab === 'data_status' && (
|
{activeTab === 'data_status' && (
|
||||||
<div className="data-section">
|
<div className="data-section">
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
<span className="sec-title">Data Status</span>
|
<span className="sec-title">Diagnostics</span>
|
||||||
</div>
|
|
||||||
<DatasetStatusView datasets={data.datasets} />
|
|
||||||
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`rh-switcher-toggle${switcherOpen ? ' active' : ''}`}
|
className={`rh-diagnostics-toggle${showDiagnostics ? ' active' : ''}`}
|
||||||
onClick={onToggleSwitcher}
|
onClick={() => setShowDiagnostics((v) => !v)}
|
||||||
aria-expanded={switcherOpen}
|
aria-expanded={showDiagnostics}
|
||||||
aria-controls="rh-weekend-switcher"
|
data-testid="rh-diagnostics-toggle"
|
||||||
data-testid="rh-switch-weekend"
|
|
||||||
>
|
>
|
||||||
{switcherOpen ? 'Close' : 'Switch Weekend'}
|
{showDiagnostics ? 'Hide dataset coverage' : 'Show dataset coverage'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<DatasetStatusView datasets={data.datasets} />
|
||||||
{switcherOpen && (
|
{showDiagnostics && (
|
||||||
<WeekendSwitcher
|
<div style={{ marginTop: 'var(--s5)' }} data-testid="rh-dataset-strip">
|
||||||
currentMeetingKey={meeting?.meeting_key}
|
<DatasetStrip datasets={data.datasets} />
|
||||||
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ import {
|
|||||||
fetchStartingGrid,
|
fetchStartingGrid,
|
||||||
fetchTrackOutline,
|
fetchTrackOutline,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
|
import { DataNotice, RouteState } from '../components/RouteState'
|
||||||
|
import {
|
||||||
|
aggregateNotices,
|
||||||
|
noticeFromResponse,
|
||||||
|
noticeMessage,
|
||||||
|
shouldShowEmbeddedNotice,
|
||||||
|
type DataAvailability,
|
||||||
|
} from '../lib/availability'
|
||||||
|
import { userFacingError } from '../lib/fetch'
|
||||||
import { countryAccent, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
|
import { countryAccent, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
|
||||||
import {
|
import {
|
||||||
buildTitleFightContext,
|
buildTitleFightContext,
|
||||||
@@ -37,7 +46,7 @@ function SectionState({
|
|||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
error?: Error | null
|
error?: unknown
|
||||||
empty?: boolean
|
empty?: boolean
|
||||||
emptyMessage?: string
|
emptyMessage?: string
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
@@ -47,8 +56,8 @@ function SectionState({
|
|||||||
}
|
}
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="preview-section-state error">
|
<div className="preview-section-state error" role="status">
|
||||||
{error instanceof Error ? error.message : 'Failed to load'}
|
{userFacingError(error)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -66,7 +75,7 @@ function TrackOutlineCard({
|
|||||||
}: {
|
}: {
|
||||||
outline: TrackOutline | null | undefined
|
outline: TrackOutline | null | undefined
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: Error | null
|
error: unknown
|
||||||
accent: string
|
accent: string
|
||||||
}) {
|
}) {
|
||||||
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
|
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
|
||||||
@@ -101,7 +110,7 @@ function LastYearCard({
|
|||||||
}: {
|
}: {
|
||||||
year: number | null
|
year: number | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: Error | null
|
error: unknown
|
||||||
podium: ReturnType<typeof extractPodium>
|
podium: ReturnType<typeof extractPodium>
|
||||||
pole: ReturnType<typeof extractPole>
|
pole: ReturnType<typeof extractPole>
|
||||||
isFirstTime: boolean
|
isFirstTime: boolean
|
||||||
@@ -150,7 +159,7 @@ function TitleFightCard({
|
|||||||
season,
|
season,
|
||||||
}: {
|
}: {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: Error | null
|
error: unknown
|
||||||
drivers: ReturnType<typeof buildTitleFightContext>
|
drivers: ReturnType<typeof buildTitleFightContext>
|
||||||
sprintWeekend: boolean
|
sprintWeekend: boolean
|
||||||
season: number | null
|
season: number | null
|
||||||
@@ -259,7 +268,29 @@ function PreviewHeader({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RacePreviewPage() {
|
export interface RacePreviewPageProps {
|
||||||
|
/**
|
||||||
|
* Canonical meeting identity from Weekend Context. When set, Preview must not
|
||||||
|
* independently re-select another current meeting/session.
|
||||||
|
*/
|
||||||
|
meeting?: Meeting
|
||||||
|
/** Season for championship supplement; defaults to meeting.year. */
|
||||||
|
season?: number
|
||||||
|
/** Embedded under Weekend — identity failures stay non-blocking. */
|
||||||
|
embedded?: boolean
|
||||||
|
/**
|
||||||
|
* Availability already disclosed by the Weekend shell. Embedded Preview
|
||||||
|
* suppresses only an equivalent notice kind; distinct truth stays visible.
|
||||||
|
*/
|
||||||
|
shellAvailability?: DataAvailability | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RacePreviewPage({
|
||||||
|
meeting: canonicalMeeting,
|
||||||
|
season: canonicalSeason,
|
||||||
|
embedded = false,
|
||||||
|
shellAvailability = null,
|
||||||
|
}: RacePreviewPageProps = {}) {
|
||||||
const [now, setNow] = useState(() => Date.now())
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -268,28 +299,34 @@ export function RacePreviewPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const nowDate = useMemo(() => new Date(now), [now])
|
const nowDate = useMemo(() => new Date(now), [now])
|
||||||
|
const hasCanonicalMeeting = canonicalMeeting != null && canonicalMeeting.meeting_key > 0
|
||||||
|
|
||||||
const seasonsQuery = useQuery({
|
const seasonsQuery = useQuery({
|
||||||
queryKey: ['seasons'],
|
queryKey: ['seasons'],
|
||||||
queryFn: fetchSeasons,
|
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||||
|
enabled: !hasCanonicalMeeting,
|
||||||
})
|
})
|
||||||
|
|
||||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
const latestSeason =
|
||||||
|
canonicalSeason ??
|
||||||
|
canonicalMeeting?.year ??
|
||||||
|
seasonsQuery.data?.[0] ??
|
||||||
|
null
|
||||||
|
|
||||||
const meetingsQuery = useQuery({
|
const meetingsQuery = useQuery({
|
||||||
queryKey: ['meetings', latestSeason, 'auto'],
|
queryKey: ['meetings', latestSeason, 'auto'],
|
||||||
queryFn: () => fetchMeetings(latestSeason!, 'auto'),
|
queryFn: ({ signal }) => fetchMeetings(latestSeason!, 'auto', signal),
|
||||||
enabled: latestSeason != null,
|
enabled: !hasCanonicalMeeting && latestSeason != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const previewMeeting = useMemo(
|
const previewMeeting = useMemo(() => {
|
||||||
() => pickPreviewMeeting(meetingsQuery.data ?? [], nowDate),
|
if (hasCanonicalMeeting) return canonicalMeeting
|
||||||
[meetingsQuery.data, nowDate],
|
return pickPreviewMeeting(meetingsQuery.data ?? [], nowDate)
|
||||||
)
|
}, [hasCanonicalMeeting, canonicalMeeting, meetingsQuery.data, nowDate])
|
||||||
|
|
||||||
const sessionsQuery = useQuery({
|
const sessionsQuery = useQuery({
|
||||||
queryKey: ['sessions', previewMeeting?.meeting_key, 'auto'],
|
queryKey: ['sessions', previewMeeting?.meeting_key, 'auto'],
|
||||||
queryFn: () => fetchSessions(previewMeeting!.meeting_key, 'auto'),
|
queryFn: ({ signal }) => fetchSessions(previewMeeting!.meeting_key, 'auto', signal),
|
||||||
enabled: previewMeeting != null,
|
enabled: previewMeeting != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -305,7 +342,7 @@ export function RacePreviewPage() {
|
|||||||
|
|
||||||
const priorMeetingsQuery = useQuery({
|
const priorMeetingsQuery = useQuery({
|
||||||
queryKey: ['meetings', priorYear, 'auto'],
|
queryKey: ['meetings', priorYear, 'auto'],
|
||||||
queryFn: () => fetchMeetings(priorYear!, 'auto'),
|
queryFn: ({ signal }) => fetchMeetings(priorYear!, 'auto', signal),
|
||||||
enabled: priorYear != null && previewMeeting != null,
|
enabled: priorYear != null && previewMeeting != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -316,7 +353,7 @@ export function RacePreviewPage() {
|
|||||||
|
|
||||||
const priorSessionsQuery = useQuery({
|
const priorSessionsQuery = useQuery({
|
||||||
queryKey: ['sessions', priorMeeting?.meeting_key, 'auto'],
|
queryKey: ['sessions', priorMeeting?.meeting_key, 'auto'],
|
||||||
queryFn: () => fetchSessions(priorMeeting!.meeting_key, 'auto'),
|
queryFn: ({ signal }) => fetchSessions(priorMeeting!.meeting_key, 'auto', signal),
|
||||||
enabled: priorMeeting != null,
|
enabled: priorMeeting != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -327,25 +364,25 @@ export function RacePreviewPage() {
|
|||||||
|
|
||||||
const priorResultsQuery = useQuery({
|
const priorResultsQuery = useQuery({
|
||||||
queryKey: ['results', priorRaceSession?.session_key, 'auto'],
|
queryKey: ['results', priorRaceSession?.session_key, 'auto'],
|
||||||
queryFn: () => fetchResults(priorRaceSession!.session_key, 'auto'),
|
queryFn: ({ signal }) => fetchResults(priorRaceSession!.session_key, 'auto', signal),
|
||||||
enabled: priorRaceSession != null,
|
enabled: priorRaceSession != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const priorGridQuery = useQuery({
|
const priorGridQuery = useQuery({
|
||||||
queryKey: ['grid', priorRaceSession?.session_key, 'auto'],
|
queryKey: ['grid', priorRaceSession?.session_key, 'auto'],
|
||||||
queryFn: () => fetchStartingGrid(priorRaceSession!.session_key, 'auto'),
|
queryFn: ({ signal }) => fetchStartingGrid(priorRaceSession!.session_key, 'auto', signal),
|
||||||
enabled: priorRaceSession != null,
|
enabled: priorRaceSession != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const trackOutlineQuery = useQuery({
|
const trackOutlineQuery = useQuery({
|
||||||
queryKey: ['track-outline', previewMeeting?.circuit_key, previewMeeting?.year],
|
queryKey: ['track-outline', previewMeeting?.circuit_key, previewMeeting?.year],
|
||||||
queryFn: () => fetchTrackOutline(previewMeeting!.circuit_key!, previewMeeting!.year),
|
queryFn: ({ signal }) => fetchTrackOutline(previewMeeting!.circuit_key!, previewMeeting!.year, signal),
|
||||||
enabled: previewMeeting?.circuit_key != null && previewMeeting.circuit_key > 0,
|
enabled: previewMeeting?.circuit_key != null && previewMeeting.circuit_key > 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const championshipQuery = useQuery({
|
const championshipQuery = useQuery({
|
||||||
queryKey: ['championship-hub', latestSeason],
|
queryKey: ['championship-hub', latestSeason],
|
||||||
queryFn: () => fetchChampionshipHub(latestSeason!),
|
queryFn: ({ signal }) => fetchChampionshipHub(latestSeason!, signal),
|
||||||
enabled: latestSeason != null,
|
enabled: latestSeason != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -359,15 +396,56 @@ export function RacePreviewPage() {
|
|||||||
const accent = countryAccent(previewMeeting)
|
const accent = countryAccent(previewMeeting)
|
||||||
const isFirstTimeCircuit = priorMeeting == null && priorYear != null && !priorMeetingsQuery.isLoading
|
const isFirstTimeCircuit = priorMeeting == null && priorYear != null && !priorMeetingsQuery.isLoading
|
||||||
|
|
||||||
if (seasonsQuery.isLoading || meetingsQuery.isLoading) {
|
const identityLoading = !hasCanonicalMeeting && (seasonsQuery.isLoading || meetingsQuery.isLoading)
|
||||||
return <div className="page loading-state" data-testid="preview-loading">loading preview…</div>
|
const identityError = !hasCanonicalMeeting && (seasonsQuery.isError || meetingsQuery.isError)
|
||||||
|
const identityRetrying = seasonsQuery.isFetching || meetingsQuery.isFetching
|
||||||
|
|
||||||
|
const retryIdentity = () => {
|
||||||
|
if (seasonsQuery.isError && !seasonsQuery.isFetching) void seasonsQuery.refetch()
|
||||||
|
if (meetingsQuery.isError && !meetingsQuery.isFetching) void meetingsQuery.refetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seasonsQuery.isError || meetingsQuery.isError) {
|
// Worst reported truth across primary + supplements — Local must not mask
|
||||||
|
// Sessions/Prior Results Stale/Partial/Limited/Archive.
|
||||||
|
const dataNotice = aggregateNotices([
|
||||||
|
noticeFromResponse(championshipQuery.data, { includeLocal: true }),
|
||||||
|
noticeFromResponse(sessionsQuery.data, { includeLocal: false }),
|
||||||
|
noticeFromResponse(priorResultsQuery.data, { includeLocal: false }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const showFreshnessNotice =
|
||||||
|
Boolean(dataNotice) &&
|
||||||
|
(!embedded || shouldShowEmbeddedNotice(dataNotice, shellAvailability))
|
||||||
|
|
||||||
|
const sessionsFailed = sessionsQuery.isError
|
||||||
|
const sessionsRetrying = sessionsQuery.isFetching
|
||||||
|
|
||||||
|
const retrySessions = () => {
|
||||||
|
if (!sessionsQuery.isFetching) void sessionsQuery.refetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (identityLoading) {
|
||||||
|
return (
|
||||||
|
<div className={embedded ? 'preview-embedded' : 'page'} data-testid="preview-loading">
|
||||||
|
<RouteState kind="loading" title="loading preview…" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (identityError) {
|
||||||
const err = seasonsQuery.error ?? meetingsQuery.error
|
const err = seasonsQuery.error ?? meetingsQuery.error
|
||||||
return (
|
return (
|
||||||
<div className="page error-box" data-testid="preview-error">
|
<div className={embedded ? 'preview-embedded' : 'page'} data-testid="preview-error">
|
||||||
{err instanceof Error ? err.message : 'Failed to load preview'}
|
<RouteState
|
||||||
|
kind="error"
|
||||||
|
title="Preview details unavailable"
|
||||||
|
error={err}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!identityRetrying) retryIdentity()
|
||||||
|
}}
|
||||||
|
retrying={identityRetrying}
|
||||||
|
retryTestId="preview-retry"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -386,7 +464,7 @@ export function RacePreviewPage() {
|
|||||||
{titleFight.length > 0 && (
|
{titleFight.length > 0 && (
|
||||||
<TitleFightCard
|
<TitleFightCard
|
||||||
loading={championshipQuery.isLoading}
|
loading={championshipQuery.isLoading}
|
||||||
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
|
error={championshipQuery.isError ? championshipQuery.error : null}
|
||||||
drivers={titleFight}
|
drivers={titleFight}
|
||||||
sprintWeekend={false}
|
sprintWeekend={false}
|
||||||
season={latestSeason}
|
season={latestSeason}
|
||||||
@@ -397,7 +475,34 @@ export function RacePreviewPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="preview-page" data-testid="preview-page">
|
<div
|
||||||
|
className="preview-page"
|
||||||
|
data-testid="preview-page"
|
||||||
|
data-meeting-key={previewMeeting.meeting_key}
|
||||||
|
data-embedded={embedded ? 'true' : undefined}
|
||||||
|
>
|
||||||
|
{showFreshnessNotice && dataNotice && (
|
||||||
|
<DataNotice
|
||||||
|
availability={dataNotice}
|
||||||
|
message={noticeMessage(dataNotice)}
|
||||||
|
testId="preview-data-notice"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sessionsFailed && (
|
||||||
|
<RouteState
|
||||||
|
kind="error"
|
||||||
|
title="Session schedule unavailable"
|
||||||
|
error={sessionsQuery.error}
|
||||||
|
onRetry={retrySessions}
|
||||||
|
retrying={sessionsRetrying}
|
||||||
|
testId="preview-sessions-error"
|
||||||
|
retryTestId="preview-sessions-retry"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Embedded Weekend already shows the canonical countdown header — skip the duplicate. */}
|
||||||
|
{!embedded && (
|
||||||
<PreviewHeader
|
<PreviewHeader
|
||||||
meeting={previewMeeting}
|
meeting={previewMeeting}
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
@@ -405,12 +510,27 @@ export function RacePreviewPage() {
|
|||||||
now={nowDate}
|
now={nowDate}
|
||||||
accent={accent}
|
accent={accent}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Embedded: still surface recovered schedule once sessions load. */}
|
||||||
|
{embedded && sessions.length > 0 && (
|
||||||
|
<div className="preview-schedule" data-testid="preview-schedule">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<div key={session.session_key} className="preview-schedule-item">
|
||||||
|
<span className="preview-schedule-name">{session.session_name}</span>
|
||||||
|
<span className="preview-schedule-time">
|
||||||
|
{formatSessionScheduleTime(session.date_start)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="preview-grid">
|
<div className="preview-grid">
|
||||||
<TrackOutlineCard
|
<TrackOutlineCard
|
||||||
outline={trackOutlineQuery.data}
|
outline={trackOutlineQuery.data}
|
||||||
loading={trackOutlineQuery.isLoading}
|
loading={trackOutlineQuery.isLoading}
|
||||||
error={trackOutlineQuery.isError ? (trackOutlineQuery.error as Error) : null}
|
error={trackOutlineQuery.isError ? trackOutlineQuery.error : null}
|
||||||
accent={accent}
|
accent={accent}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -419,9 +539,9 @@ export function RacePreviewPage() {
|
|||||||
loading={priorMeetingsQuery.isLoading || priorSessionsQuery.isLoading || priorResultsQuery.isLoading}
|
loading={priorMeetingsQuery.isLoading || priorSessionsQuery.isLoading || priorResultsQuery.isLoading}
|
||||||
error={
|
error={
|
||||||
priorMeetingsQuery.isError
|
priorMeetingsQuery.isError
|
||||||
? (priorMeetingsQuery.error as Error)
|
? priorMeetingsQuery.error
|
||||||
: priorResultsQuery.isError
|
: priorResultsQuery.isError
|
||||||
? (priorResultsQuery.error as Error)
|
? priorResultsQuery.error
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
podium={podium}
|
podium={podium}
|
||||||
@@ -432,7 +552,7 @@ export function RacePreviewPage() {
|
|||||||
|
|
||||||
<TitleFightCard
|
<TitleFightCard
|
||||||
loading={championshipQuery.isLoading}
|
loading={championshipQuery.isLoading}
|
||||||
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
|
error={championshipQuery.isError ? championshipQuery.error : null}
|
||||||
drivers={titleFight}
|
drivers={titleFight}
|
||||||
sprintWeekend={sprintWeekend}
|
sprintWeekend={sprintWeekend}
|
||||||
season={latestSeason}
|
season={latestSeason}
|
||||||
|
|||||||
180
frontend/src/pages/WeekendPage.tsx
Normal file
180
frontend/src/pages/WeekendPage.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { useWeekendContext } from '../hooks/useWeekendContext'
|
||||||
|
import { BetweenRacesView } from '../components/weekend/BetweenRacesView'
|
||||||
|
import { BetweenSessionsView } from '../components/weekend/BetweenSessionsView'
|
||||||
|
import { LiveHandoffView } from '../components/weekend/LiveHandoffView'
|
||||||
|
import { PreSessionView } from '../components/weekend/PreSessionView'
|
||||||
|
import { WeekendError, WeekendLimited, WeekendLoading } from '../components/weekend/StatusViews'
|
||||||
|
import { WeekendFocusBanner } from '../components/weekend/WeekendFocusBanner'
|
||||||
|
import { DataNotice } from '../components/RouteState'
|
||||||
|
import { noticeMessage, type DataAvailability } from '../lib/availability'
|
||||||
|
import { resolveViewState } from '../lib/weekendContext'
|
||||||
|
import type {
|
||||||
|
WeekendBriefingItem,
|
||||||
|
WeekendChampionshipImpact,
|
||||||
|
WeekendContext,
|
||||||
|
WeekendViewState,
|
||||||
|
} from '../types'
|
||||||
|
import '../styles/weekend.css'
|
||||||
|
|
||||||
|
interface RenderArgs {
|
||||||
|
context: WeekendContext
|
||||||
|
now: Date
|
||||||
|
championship?: WeekendChampionshipImpact
|
||||||
|
briefing: WeekendBriefingItem[]
|
||||||
|
/** When true (the /preview alias), foreground the preparation surface. */
|
||||||
|
preview: boolean
|
||||||
|
/** Availability already disclosed by the Weekend shell (for Preview dedupe). */
|
||||||
|
shellAvailability: DataAvailability | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderState(view: WeekendViewState, args: RenderArgs) {
|
||||||
|
const { context, now, championship, briefing, preview, shellAvailability } = args
|
||||||
|
// The /preview alias always resolves to the preparation surface as long as
|
||||||
|
// there is a next event to prepare for, regardless of the temporal state. This
|
||||||
|
// keeps saved /preview links and the "Prepare for …" CTA meaningful instead of
|
||||||
|
// redirecting straight back to the same between-races screen.
|
||||||
|
if (preview && context.next_meeting) {
|
||||||
|
return <PreSessionView context={context} now={now} shellAvailability={shellAvailability} />
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (view) {
|
||||||
|
case 'no_season':
|
||||||
|
return <WeekendLimited season={context.season} />
|
||||||
|
case 'between_weekends':
|
||||||
|
case 'post_weekend':
|
||||||
|
case 'season_complete':
|
||||||
|
return (
|
||||||
|
<BetweenRacesView
|
||||||
|
context={context}
|
||||||
|
now={now}
|
||||||
|
view={view}
|
||||||
|
championship={championship}
|
||||||
|
briefing={briefing}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 'between_sessions':
|
||||||
|
case 'session_settling':
|
||||||
|
return <BetweenSessionsView context={context} now={now} view={view} briefing={briefing} />
|
||||||
|
case 'session_live':
|
||||||
|
return <LiveHandoffView context={context} />
|
||||||
|
case 'pre_session':
|
||||||
|
return <PreSessionView context={context} now={now} shellAvailability={shellAvailability} />
|
||||||
|
default:
|
||||||
|
return <WeekendLimited season={context.season} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function WeekendAvailabilityNotices({
|
||||||
|
availabilityNotice,
|
||||||
|
supplementsLimited,
|
||||||
|
onRetry,
|
||||||
|
retrying,
|
||||||
|
}: {
|
||||||
|
availabilityNotice: DataAvailability | null
|
||||||
|
supplementsLimited: boolean
|
||||||
|
onRetry: () => void
|
||||||
|
retrying: boolean
|
||||||
|
}) {
|
||||||
|
// Prefer a single reported freshness notice; Limited for failed supplements
|
||||||
|
// only when freshness itself is not already disclosing a stronger state.
|
||||||
|
if (availabilityNotice) {
|
||||||
|
return (
|
||||||
|
<DataNotice
|
||||||
|
availability={availabilityNotice}
|
||||||
|
message={noticeMessage(availabilityNotice)}
|
||||||
|
onRetry={onRetry}
|
||||||
|
retrying={retrying}
|
||||||
|
testId="weekend-data-notice"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (supplementsLimited) {
|
||||||
|
return (
|
||||||
|
<DataNotice
|
||||||
|
availability="limited"
|
||||||
|
message="Championship or briefing supplements are unavailable. Weekend schedule context is still shown."
|
||||||
|
onRetry={onRetry}
|
||||||
|
retrying={retrying}
|
||||||
|
testId="weekend-data-notice"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeekendPage({
|
||||||
|
preview = false,
|
||||||
|
focusMeetingKey,
|
||||||
|
focusSessionKey,
|
||||||
|
}: {
|
||||||
|
preview?: boolean
|
||||||
|
/** Restored from `/?meeting_key=` when returning from Race Hub analysis. */
|
||||||
|
focusMeetingKey?: number
|
||||||
|
/** Restored from `/?session_key=` when returning from Race Hub analysis. */
|
||||||
|
focusSessionKey?: number
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
context,
|
||||||
|
loadState,
|
||||||
|
error,
|
||||||
|
championship,
|
||||||
|
briefing,
|
||||||
|
availabilityNotice,
|
||||||
|
supplementsLimited,
|
||||||
|
now,
|
||||||
|
refetch,
|
||||||
|
isFetching,
|
||||||
|
} = useWeekendContext()
|
||||||
|
const hasFocus =
|
||||||
|
(focusMeetingKey != null && focusMeetingKey > 0) ||
|
||||||
|
(focusSessionKey != null && focusSessionKey > 0)
|
||||||
|
|
||||||
|
if (loadState === 'loading') {
|
||||||
|
return (
|
||||||
|
<main className="wk-page" data-testid="weekend-page" data-state="loading">
|
||||||
|
<WeekendLoading />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadState === 'error' || context == null) {
|
||||||
|
return (
|
||||||
|
<main className="wk-page" data-testid="weekend-page" data-state="error">
|
||||||
|
<WeekendError error={error} onRetry={refetch} retrying={isFetching} />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = resolveViewState(context)
|
||||||
|
const shellAvailability = availabilityNotice ?? (supplementsLimited ? 'limited' : null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className="wk-page"
|
||||||
|
data-testid="weekend-page"
|
||||||
|
data-state={view}
|
||||||
|
data-temporal-state={context.temporal_state}
|
||||||
|
data-preview={preview ? 'true' : undefined}
|
||||||
|
data-meeting-key={focusMeetingKey ?? undefined}
|
||||||
|
data-session-key={focusSessionKey ?? undefined}
|
||||||
|
>
|
||||||
|
{hasFocus && (
|
||||||
|
<WeekendFocusBanner meetingKey={focusMeetingKey} sessionKey={focusSessionKey} />
|
||||||
|
)}
|
||||||
|
<WeekendAvailabilityNotices
|
||||||
|
availabilityNotice={availabilityNotice}
|
||||||
|
supplementsLimited={supplementsLimited}
|
||||||
|
onRetry={refetch}
|
||||||
|
retrying={isFetching}
|
||||||
|
/>
|
||||||
|
{renderState(view, {
|
||||||
|
context,
|
||||||
|
now,
|
||||||
|
championship,
|
||||||
|
briefing,
|
||||||
|
preview,
|
||||||
|
shellAvailability,
|
||||||
|
})}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,17 +1,14 @@
|
|||||||
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router'
|
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router'
|
||||||
import { Nav } from './components/Nav'
|
import { Nav } from './components/Nav'
|
||||||
import { CommandCenterPage } from './pages/CommandCenterPage'
|
import { WeekendPage } from './pages/WeekendPage'
|
||||||
|
import { ExplorePage } from './pages/ExplorePage'
|
||||||
import { RaceHubPage } from './pages/RaceHubPage'
|
import { RaceHubPage } from './pages/RaceHubPage'
|
||||||
import { DataLibraryPage } from './pages/DataLibraryPage'
|
import { DataLibraryPage } from './pages/DataLibraryPage'
|
||||||
import { LiveTimingPage } from './pages/LiveTimingPage'
|
import { LiveTimingPage } from './pages/LiveTimingPage'
|
||||||
import { BriefingPage } from './pages/BriefingPage'
|
import { BriefingPage } from './pages/BriefingPage'
|
||||||
import { ChampionshipPage } from './pages/ChampionshipPage'
|
import { ChampionshipPage } from './pages/ChampionshipPage'
|
||||||
import { DriverProfilePage } from './pages/DriverProfilePage'
|
import { DriverProfilePage } from './pages/DriverProfilePage'
|
||||||
import { RacePreviewPage } from './pages/RacePreviewPage'
|
import { parseRaceHubSearch, parseWeekendSearch } from './lib/routeSearch'
|
||||||
|
|
||||||
type RaceHubSearch = {
|
|
||||||
session_key?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type DriverProfileSearch = {
|
type DriverProfileSearch = {
|
||||||
year?: number
|
year?: number
|
||||||
@@ -26,19 +23,31 @@ const rootRoute = createRootRoute({
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const commandCenterRoute = createRoute({
|
// Weekend is the adaptive home. `/` renders the current temporal state.
|
||||||
|
// Optional meeting_key/session_key search restores Race Hub return context
|
||||||
|
// (deep-linkable, reload-safe — no hidden component memory).
|
||||||
|
export const weekendRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/',
|
path: '/',
|
||||||
component: CommandCenterPage,
|
validateSearch: (search: Record<string, unknown>) => parseWeekendSearch(search),
|
||||||
|
component: function WeekendRoute() {
|
||||||
|
const { meeting_key, session_key } = weekendRoute.useSearch()
|
||||||
|
return (
|
||||||
|
<WeekendPage focusMeetingKey={meeting_key} focusSessionKey={session_key} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const exploreRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/explore',
|
||||||
|
component: ExplorePage,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const raceHubRoute = createRoute({
|
export const raceHubRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/race-hub',
|
path: '/race-hub',
|
||||||
validateSearch: (search: Record<string, unknown>): RaceHubSearch => {
|
validateSearch: (search: Record<string, unknown>) => parseRaceHubSearch(search),
|
||||||
const sessionKey = Number(search.session_key)
|
|
||||||
return Number.isFinite(sessionKey) && sessionKey > 0 ? { session_key: sessionKey } : {}
|
|
||||||
},
|
|
||||||
component: function RaceHubRoute() {
|
component: function RaceHubRoute() {
|
||||||
const { session_key } = raceHubRoute.useSearch()
|
const { session_key } = raceHubRoute.useSearch()
|
||||||
return <RaceHubPage sessionKey={session_key ?? 0} />
|
return <RaceHubPage sessionKey={session_key ?? 0} />
|
||||||
@@ -90,14 +99,21 @@ export const briefingRoute = createRoute({
|
|||||||
component: BriefingPage,
|
component: BriefingPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Preview folds into the Weekend home. /preview is a stable alias that renders the
|
||||||
|
// Weekend page in its preparation surface (PreSessionView) whenever there is a next
|
||||||
|
// event — so saved preview links and the "Prepare for …" CTA reach real preview
|
||||||
|
// content instead of redirecting back to the same between-races screen.
|
||||||
export const previewRoute = createRoute({
|
export const previewRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/preview',
|
path: '/preview',
|
||||||
component: RacePreviewPage,
|
component: function PreviewRoute() {
|
||||||
|
return <WeekendPage preview />
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
commandCenterRoute,
|
weekendRoute,
|
||||||
|
exploreRoute,
|
||||||
raceHubRoute,
|
raceHubRoute,
|
||||||
adminRoute,
|
adminRoute,
|
||||||
dataLibraryRoute,
|
dataLibraryRoute,
|
||||||
|
|||||||
@@ -89,16 +89,6 @@ a { color: inherit; text-decoration: none; }
|
|||||||
}
|
}
|
||||||
.app-nav::-webkit-scrollbar { display: none; }
|
.app-nav::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
/* The nav scrolls horizontally with its scrollbar hidden. Below the width
|
|
||||||
where the links stop fitting, fade the trailing edge so the cut-off item
|
|
||||||
reads as "scroll for more" instead of as a clipping bug. */
|
|
||||||
@media (max-width: 560px) {
|
|
||||||
.app-nav {
|
|
||||||
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent 100%);
|
|
||||||
mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent 100%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-logo {
|
.nav-logo {
|
||||||
font-family: var(--f-mono);
|
font-family: var(--f-mono);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -122,30 +112,6 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.nav-links a:hover { color: var(--text); background: var(--surface-h); }
|
.nav-links a:hover { color: var(--text); background: var(--surface-h); }
|
||||||
.nav-links a.active { color: var(--text); background: var(--surface-2); }
|
.nav-links a.active { color: var(--text); background: var(--surface-2); }
|
||||||
|
|
||||||
/* Live nav gets a pulsing marker only while a session is on air. */
|
|
||||||
.nav-live { display: inline-flex; align-items: center; gap: 6px; }
|
|
||||||
.nav-links a.nav-live-on { color: var(--text); font-weight: 600; }
|
|
||||||
.nav-live-dot {
|
|
||||||
width: 7px;
|
|
||||||
height: 7px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--red);
|
|
||||||
box-shadow: 0 0 0 0 rgba(225, 6, 0, 0.4);
|
|
||||||
animation: pulse-live 1.6s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
white-space: nowrap;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-utility {
|
.nav-utility {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -176,6 +142,53 @@ a { color: inherit; text-decoration: none; }
|
|||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Mobile bottom nav ── */
|
||||||
|
.app-bottom-nav {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
height: 60px;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||||
|
background: rgba(18, 18, 18, 0.92);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.bottom-nav-link {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
.bottom-nav-link.active { color: var(--red); }
|
||||||
|
.bottom-nav-link:hover { color: var(--text-2); }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
/* On mobile the bottom bar is the single primary navigation system. The top
|
||||||
|
bar's Primary links are hidden so the two are never both shown; the top bar
|
||||||
|
keeps only the logo and the Admin operator utility. */
|
||||||
|
.app-bottom-nav { display: flex; }
|
||||||
|
.app-nav .nav-links { display: none; }
|
||||||
|
.app-nav { justify-content: space-between; }
|
||||||
|
/* Keep page content clear of the fixed bottom bar. */
|
||||||
|
.wk-page { padding-bottom: calc(60px + env(safe-area-inset-bottom, 0) + var(--s4)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Focus visibility (accessibility) ── */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--red);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Page ── */
|
/* ── Page ── */
|
||||||
.page {
|
.page {
|
||||||
max-width: 1040px;
|
max-width: 1040px;
|
||||||
@@ -397,6 +410,9 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.badge-partial { background: rgba(255,214,0,.12); color: var(--yellow); border: 1px solid rgba(255,214,0,.25); }
|
.badge-partial { background: rgba(255,214,0,.12); color: var(--yellow); border: 1px solid rgba(255,214,0,.25); }
|
||||||
.badge-cancelled { background: rgba(255,107,53,.12); color: #ff8a5c; border: 1px solid rgba(255,107,53,.28); }
|
.badge-cancelled { background: rgba(255,107,53,.12); color: #ff8a5c; border: 1px solid rgba(255,107,53,.28); }
|
||||||
.badge-none { background: rgba(80,80,80,.12); color: var(--text-3); border: 1px solid var(--border); }
|
.badge-none { background: rgba(80,80,80,.12); color: var(--text-3); border: 1px solid var(--border); }
|
||||||
|
.badge-stale { background: rgba(255,214,0,.10); color: var(--yellow); border: 1px solid rgba(255,214,0,.22); }
|
||||||
|
.badge-limited { background: rgba(120,160,220,.12); color: #9bb7e0; border: 1px solid rgba(120,160,220,.28); }
|
||||||
|
.badge-missing { background: rgba(80,80,80,.12); color: var(--text-3); border: 1px solid var(--border); }
|
||||||
|
|
||||||
/* ── Dataset strip ── */
|
/* ── Dataset strip ── */
|
||||||
.dataset-strip {
|
.dataset-strip {
|
||||||
@@ -572,6 +588,82 @@ a { color: inherit; text-decoration: none; }
|
|||||||
margin-bottom: var(--s5);
|
margin-bottom: var(--s5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.route-state {
|
||||||
|
padding: var(--s7) var(--s5);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-state-availability {
|
||||||
|
margin-bottom: var(--s4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-state-title {
|
||||||
|
font-family: var(--f-display, var(--f-mono));
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-state-message {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-2);
|
||||||
|
margin-bottom: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-state-retry {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-state-retry:hover:not(:disabled) {
|
||||||
|
border-color: rgba(225, 6, 0, 0.45);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-state-retry:focus-visible {
|
||||||
|
outline: 2px solid var(--red);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-state-retry:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-notice {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
padding: var(--s3) var(--s4);
|
||||||
|
margin-bottom: var(--s4);
|
||||||
|
background: rgba(255, 214, 0, 0.06);
|
||||||
|
border: 1px solid rgba(255, 214, 0, 0.2);
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-notice-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-notice-retry {
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Live timing ── */
|
/* ── Live timing ── */
|
||||||
.live-page { max-width: 1320px; }
|
.live-page { max-width: 1320px; }
|
||||||
|
|
||||||
@@ -1807,6 +1899,163 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.tab-btn:hover { color: var(--text-2); }
|
.tab-btn:hover { color: var(--text-2); }
|
||||||
.tab-btn.active { color: var(--text); border-bottom-color: var(--red); }
|
.tab-btn.active { color: var(--text); border-bottom-color: var(--red); }
|
||||||
|
|
||||||
|
/* ── Grouped analysis navigation ── */
|
||||||
|
.tab-bar-grouped {
|
||||||
|
gap: var(--s5);
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.tab-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.tab-group + .tab-group {
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
padding-left: var(--s5);
|
||||||
|
}
|
||||||
|
.tab-group-label {
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
opacity: 0.7;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
.tab-group-btns {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Session lifecycle state dots ── */
|
||||||
|
.rh-state-ready { background: var(--green); }
|
||||||
|
.rh-state-partial { background: var(--yellow); }
|
||||||
|
.rh-state-live { background: var(--red); }
|
||||||
|
.rh-state-upcoming { background: var(--text-3); }
|
||||||
|
.rh-state-preparing { background: var(--text-3); opacity: 0.5; }
|
||||||
|
.rh-state-unavailable { background: var(--text-3); opacity: 0.4; }
|
||||||
|
.rh-state-cancelled { background: var(--text-3); opacity: 0.35; }
|
||||||
|
|
||||||
|
/* ── Pre-session (expected availability) view ── */
|
||||||
|
.rh-presession {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s5);
|
||||||
|
}
|
||||||
|
.rh-presession-band {
|
||||||
|
padding: var(--s6) var(--s5);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--gp-accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
.rh-presession-eyebrow {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
.rh-presession-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.rh-presession-sub { color: var(--text-2); max-width: 60ch; }
|
||||||
|
.rh-presession-countdown {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text);
|
||||||
|
margin-top: var(--s3);
|
||||||
|
}
|
||||||
|
.rh-partial-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s3);
|
||||||
|
margin: 0 var(--s5) var(--s3);
|
||||||
|
padding: var(--s3) var(--s4);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--yellow) 45%, var(--border));
|
||||||
|
background: color-mix(in srgb, var(--yellow) 12%, transparent);
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.rh-partial-banner-link {
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.rh-expected-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: var(--s4);
|
||||||
|
}
|
||||||
|
.rh-expected-card {
|
||||||
|
padding: var(--s4);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.rh-expected-card .rh-expected-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-3);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading / error recovery ── */
|
||||||
|
.rh-recover {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--s4);
|
||||||
|
padding: var(--s6) var(--s5);
|
||||||
|
}
|
||||||
|
.rh-recover-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.rh-recover-btn {
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.rh-recover-btn:hover { border-color: var(--gp-accent); }
|
||||||
|
.rh-recover-btn.primary { border-color: var(--red); color: var(--text); }
|
||||||
|
|
||||||
|
/* ── Diagnostics (secondary) action ── */
|
||||||
|
.rh-diagnostics-toggle {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-3);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.rh-diagnostics-toggle:hover { color: var(--text-2); border-color: var(--gp-accent); }
|
||||||
|
.rh-diagnostics-toggle.active { color: var(--text); }
|
||||||
|
|
||||||
/* ── Dataset status view ── */
|
/* ── Dataset status view ── */
|
||||||
.ds-legend {
|
.ds-legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2656,6 +2905,31 @@ a { color: inherit; text-decoration: none; }
|
|||||||
|
|
||||||
.rh-topbar-spacer { flex: 1; }
|
.rh-topbar-spacer { flex: 1; }
|
||||||
|
|
||||||
|
.rh-back-weekend {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-2);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: color 0.1s, border-color 0.1s, background 0.1s;
|
||||||
|
}
|
||||||
|
.rh-back-weekend:hover,
|
||||||
|
.rh-back-weekend:focus-visible {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--gp-accent);
|
||||||
|
background: var(--surface-h);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.rh-back-weekend:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--gp-accent) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.rh-switcher-toggle {
|
.rh-switcher-toggle {
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@@ -3231,7 +3505,10 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.rh-overview-row { grid-template-columns: 1fr; }
|
.rh-overview-row { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.rh-page { padding: var(--s4); gap: var(--s4); }
|
.rh-page { padding: var(--s4); gap: var(--s4); padding-bottom: 88px; }
|
||||||
|
.rh-topbar { flex-wrap: wrap; gap: var(--s3); }
|
||||||
|
.rh-topbar-spacer { display: none; }
|
||||||
|
.rh-back-weekend { order: -1; }
|
||||||
.rh-identity-body { padding: var(--s3) var(--s4); gap: var(--s3); }
|
.rh-identity-body { padding: var(--s3) var(--s4); gap: var(--s3); }
|
||||||
.rh-identity-decal { font-size: 28px; }
|
.rh-identity-decal { font-size: 28px; }
|
||||||
.rh-identity-name { font-size: 17px; }
|
.rh-identity-name { font-size: 17px; }
|
||||||
@@ -4764,137 +5041,3 @@ 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; }
|
|
||||||
}
|
|
||||||
|
|||||||
414
frontend/src/styles/live-state.css
Normal file
414
frontend/src/styles/live-state.css
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
/* ── Live → settling → analysis handoff (issue #74) ──
|
||||||
|
*
|
||||||
|
* State-transition presentation for Live: session/flag chrome that replaces
|
||||||
|
* the old connection-as-session conflation, the connection-loss warning, the
|
||||||
|
* archive read-only strip, and the settling/inactive handoff surface.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Session flag: primary session state. Feed health is secondary. */
|
||||||
|
.live-session-flag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding: 5px 9px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-session-flag-live {
|
||||||
|
background: rgba(225, 6, 0, 0.14);
|
||||||
|
border: 1px solid rgba(225, 6, 0, 0.35);
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-session-flag-live.is-stale {
|
||||||
|
background: rgba(255, 214, 0, 0.1);
|
||||||
|
border-color: rgba(255, 214, 0, 0.35);
|
||||||
|
color: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-session-flag-archive {
|
||||||
|
background: rgba(70, 140, 255, 0.12);
|
||||||
|
border: 1px solid rgba(70, 140, 255, 0.35);
|
||||||
|
color: #8bb7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-session-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
box-shadow: 0 0 0 0 currentColor;
|
||||||
|
animation: live-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-session-flag-live.is-stale .live-session-dot {
|
||||||
|
animation: none;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-feed-health {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding: 4px 7px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-feed-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-feed-connected {
|
||||||
|
color: var(--green);
|
||||||
|
border-color: rgba(57, 199, 58, 0.25);
|
||||||
|
background: rgba(57, 199, 58, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-feed-connecting {
|
||||||
|
color: var(--yellow);
|
||||||
|
border-color: rgba(255, 214, 0, 0.25);
|
||||||
|
background: rgba(255, 214, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-feed-disconnected,
|
||||||
|
.live-feed-error {
|
||||||
|
color: #ff6b6b;
|
||||||
|
border-color: rgba(225, 6, 0, 0.24);
|
||||||
|
background: rgba(225, 6, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-banner-archive {
|
||||||
|
border-color: rgba(70, 140, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-archive-stamp {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-archive-readonly {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #8bb7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-archive-captured {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-state-archive {
|
||||||
|
background: rgba(70, 140, 255, 0.12);
|
||||||
|
color: #8bb7ff;
|
||||||
|
border: 1px solid rgba(70, 140, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disconnected warning: a live session whose feed dropped. Distinct from the
|
||||||
|
* blue archive strip so a dropped feed is never mistaken for a read-only
|
||||||
|
* archive. The retained tower stays visible beneath it. */
|
||||||
|
.live-status-strip.live-status-warn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-status-strip.live-status-warn::before {
|
||||||
|
content: '';
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--yellow);
|
||||||
|
animation: live-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes live-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-status-strip.live-status-archive {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s4);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-archive-exit {
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 var(--s4);
|
||||||
|
border: 1px solid rgba(70, 140, 255, 0.4);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(70, 140, 255, 0.12);
|
||||||
|
color: #8bb7ff;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-archive-exit:hover {
|
||||||
|
border-color: rgba(70, 140, 255, 0.7);
|
||||||
|
background: rgba(70, 140, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Handoff surface (settling + inactive) ── */
|
||||||
|
.live-handoff {
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: var(--s6) var(--s5);
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 120% at 0% 0%, rgba(255, 255, 255, 0.04), transparent 55%),
|
||||||
|
var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-settling {
|
||||||
|
border-left: 3px solid var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-inactive {
|
||||||
|
border-left: 3px solid var(--border-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s3);
|
||||||
|
margin-bottom: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 4px var(--s3);
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-settling .live-handoff-eyebrow {
|
||||||
|
color: var(--yellow);
|
||||||
|
background: rgba(245, 158, 11, 0.14);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-inactive .live-handoff-eyebrow {
|
||||||
|
color: var(--text-2);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-title {
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.15;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-captured {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-sub {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-2);
|
||||||
|
max-width: 62ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-feed {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Provisional final-order snapshot chips (settling only). */
|
||||||
|
.live-handoff-snapshot {
|
||||||
|
margin-bottom: var(--s5);
|
||||||
|
padding: var(--s4);
|
||||||
|
border: 1px dashed var(--border-2);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-snapshot-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--s3);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-order {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s3);
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-order li {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-order li span:first-child {
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-code {
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Action cards ── */
|
||||||
|
.live-handoff-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s4);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--s4) var(--s5);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: var(--text);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
background 0.15s ease,
|
||||||
|
transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.live-handoff-action {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-action:hover {
|
||||||
|
border-color: var(--border-glow);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-action-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-action-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-action-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The primary "open analysis" action is the strongest affordance. */
|
||||||
|
.live-handoff-primary {
|
||||||
|
border-color: rgba(230, 36, 41, 0.4);
|
||||||
|
background: linear-gradient(90deg, rgba(230, 36, 41, 0.14), transparent 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-primary:hover {
|
||||||
|
border-color: rgba(230, 36, 41, 0.7);
|
||||||
|
background: linear-gradient(90deg, rgba(230, 36, 41, 0.22), transparent 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pending-analysis states read as "waiting", not actionable links. */
|
||||||
|
.live-handoff-primary[data-ready='false'] {
|
||||||
|
border-color: rgba(245, 158, 11, 0.4);
|
||||||
|
background: linear-gradient(90deg, rgba(245, 158, 11, 0.1), transparent 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-pending {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-pending:hover {
|
||||||
|
transform: none;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-color: var(--border-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-next {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-next:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive: keep the handoff usable at tablet / mobile widths ── */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.live-handoff {
|
||||||
|
padding: var(--s5) var(--s4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-title {
|
||||||
|
font-size: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-action {
|
||||||
|
padding: var(--s4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-action-label {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-status-strip.live-status-archive {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
419
frontend/src/styles/weekend.css
Normal file
419
frontend/src/styles/weekend.css
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
/* ── Weekend adaptive home ── */
|
||||||
|
.wk-page {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--s6) var(--s6) 64px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wk-focus {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s5);
|
||||||
|
margin-bottom: var(--s5);
|
||||||
|
padding: var(--s5);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--red);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.wk-focus-copy { display: flex; flex-direction: column; gap: var(--s2); min-width: 0; }
|
||||||
|
.wk-focus-eyebrow {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
.wk-focus-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.wk-focus-session {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.wk-focus-cta { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.wk-eyebrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s4);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-2);
|
||||||
|
margin-bottom: var(--s5);
|
||||||
|
}
|
||||||
|
.wk-eyebrow::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, var(--red), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wk-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s4);
|
||||||
|
margin-bottom: var(--s4);
|
||||||
|
}
|
||||||
|
.wk-card-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.wk-card-link {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.wk-card-link:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.wk-flag { font-size: 22px; line-height: 1; }
|
||||||
|
|
||||||
|
/* ── CTAs ── */
|
||||||
|
.wk-cta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
padding: var(--s4) var(--s5);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.12s, border-color 0.12s, transform 0.12s;
|
||||||
|
}
|
||||||
|
.wk-cta-primary { background: var(--red); color: #fff; }
|
||||||
|
.wk-cta-primary:hover { background: #ff3b3f; }
|
||||||
|
.wk-cta:disabled,
|
||||||
|
.wk-cta[aria-busy='true'] {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.wk-cta-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-2);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
}
|
||||||
|
.wk-cta-ghost:hover { color: var(--text); border-color: var(--text-3); }
|
||||||
|
.wk-cta-wide { width: 100%; }
|
||||||
|
|
||||||
|
/* ── Between-races top grid ── */
|
||||||
|
.wk-top-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--s5);
|
||||||
|
margin-bottom: var(--s5);
|
||||||
|
}
|
||||||
|
.wk-mid-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 340px) minmax(0, 1fr);
|
||||||
|
gap: var(--s5);
|
||||||
|
margin-bottom: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wk-event-card {
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: var(--s5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s5);
|
||||||
|
}
|
||||||
|
.wk-event-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s4);
|
||||||
|
}
|
||||||
|
.wk-event-id { display: flex; align-items: center; gap: var(--s4); min-width: 0; }
|
||||||
|
.wk-event-name {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.wk-event-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.wk-tag-done { color: var(--text-2); }
|
||||||
|
.wk-tag-next { color: var(--red); }
|
||||||
|
|
||||||
|
.wk-event-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 200px);
|
||||||
|
gap: var(--s5);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Podium ── */
|
||||||
|
.wk-podium { list-style: none; display: flex; flex-direction: column; gap: var(--s3); }
|
||||||
|
.wk-podium-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 20px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s4);
|
||||||
|
padding: var(--s3) var(--s4);
|
||||||
|
background: var(--surface-h);
|
||||||
|
border-left: 3px solid var(--wk-team, var(--border-2));
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.wk-podium-pos { font-weight: 700; color: var(--text-2); }
|
||||||
|
.wk-podium-code { font-weight: 700; color: var(--wk-team, var(--text)); overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.wk-podium-gap { color: var(--text-2); font-size: 12px; }
|
||||||
|
.wk-podium-empty { color: var(--text-3); font-size: 12px; padding: var(--s4); }
|
||||||
|
|
||||||
|
.wk-story-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s3);
|
||||||
|
padding: var(--s4);
|
||||||
|
background: rgba(230, 36, 41, 0.05);
|
||||||
|
border: 1px solid rgba(230, 36, 41, 0.25);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.wk-story-label { font-size: 13px; font-weight: 700; color: var(--red); }
|
||||||
|
.wk-story-text { font-size: 12px; color: var(--text-2); flex: 1; }
|
||||||
|
|
||||||
|
/* ── Next event ── */
|
||||||
|
.wk-next-body { display: flex; flex-direction: column; gap: var(--s5); }
|
||||||
|
.wk-countdown { display: flex; gap: var(--s5); align-items: baseline; }
|
||||||
|
.wk-count-unit { font-size: 13px; color: var(--text-2); font-family: var(--f-mono); }
|
||||||
|
.wk-count-unit b { font-size: 40px; color: var(--red); font-weight: 700; margin-right: 2px; }
|
||||||
|
.wk-countdown-clock { font-size: 32px; font-weight: 700; color: var(--blue); }
|
||||||
|
.wk-countdown-live { font-size: 20px; font-weight: 700; color: var(--red); }
|
||||||
|
.wk-next-session { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.wk-next-label { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-3); }
|
||||||
|
.wk-next-value { font-size: 15px; font-weight: 600; }
|
||||||
|
.wk-season-complete-copy { font-size: 13px; color: var(--text-2); }
|
||||||
|
|
||||||
|
/* ── Championship impact ── */
|
||||||
|
.wk-champ, .wk-briefing, .wk-season-nav {
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: var(--s5);
|
||||||
|
}
|
||||||
|
.wk-champ-list { list-style: none; display: flex; flex-direction: column; gap: var(--s3); }
|
||||||
|
.wk-champ-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 18px minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s4);
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
border-left: 3px solid var(--wk-team, var(--border-2));
|
||||||
|
}
|
||||||
|
.wk-champ-pos { color: var(--text-3); }
|
||||||
|
.wk-champ-code { font-weight: 700; color: var(--wk-team, var(--text)); }
|
||||||
|
.wk-champ-pts { font-weight: 700; }
|
||||||
|
.wk-champ-gap { color: var(--text-3); font-size: 11px; min-width: 34px; text-align: right; }
|
||||||
|
.wk-champ-note { margin-top: var(--s4); font-size: 11px; color: var(--text-3); }
|
||||||
|
|
||||||
|
/* ── Season progress strip ── */
|
||||||
|
.wk-countdown-tbc { font-size: 14px; color: var(--text-3); }
|
||||||
|
.wk-season-progress { display: flex; flex-direction: column; gap: var(--s3); }
|
||||||
|
.wk-season-progress-head { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); }
|
||||||
|
.wk-season-bar {
|
||||||
|
position: relative;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.wk-season-bar-fill {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
background: var(--red);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.wk-season-progress-label { font-size: 11px; color: var(--text-3); }
|
||||||
|
|
||||||
|
/* ── Briefing ── */
|
||||||
|
.wk-briefing-list { list-style: none; display: flex; flex-direction: column; }
|
||||||
|
.wk-briefing-item { border-top: 1px solid var(--border); }
|
||||||
|
.wk-briefing-item:first-child { border-top: none; }
|
||||||
|
.wk-briefing-link {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, auto) minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s4);
|
||||||
|
padding: var(--s4) 0;
|
||||||
|
}
|
||||||
|
.wk-briefing-link:hover .wk-briefing-title { color: var(--red); }
|
||||||
|
.wk-briefing-cat { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--red); white-space: nowrap; }
|
||||||
|
.wk-briefing-title { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.wk-briefing-source { font-size: 11px; color: var(--text-3); white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ── Between-sessions / live ── */
|
||||||
|
.wk-sessions, .wk-live, .wk-pre { display: flex; flex-direction: column; gap: var(--s5); }
|
||||||
|
.wk-sessions-head { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.wk-sessions-title { font-size: 28px; font-weight: 800; letter-spacing: -0.01em; }
|
||||||
|
.wk-sessions-circuit { font-size: 12px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; }
|
||||||
|
|
||||||
|
.wk-timeline {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s3);
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: var(--s3);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.wk-timeline-node {
|
||||||
|
flex: 1 0 auto;
|
||||||
|
min-width: 72px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding: var(--s3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.wk-timeline-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--surface-3); }
|
||||||
|
.wk-timeline-done .wk-timeline-dot { background: var(--green); }
|
||||||
|
.wk-timeline-live .wk-timeline-dot { background: var(--red); box-shadow: 0 0 0 3px rgba(230,36,41,0.25); }
|
||||||
|
.wk-timeline-next { border-color: var(--red); }
|
||||||
|
.wk-timeline-next .wk-timeline-dot { border: 2px solid var(--red); }
|
||||||
|
.wk-timeline-name { font-size: 12px; font-weight: 600; }
|
||||||
|
.wk-timeline-state { font-size: 9px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-3); }
|
||||||
|
.wk-timeline-done .wk-timeline-state { color: var(--green); }
|
||||||
|
.wk-timeline-live .wk-timeline-state, .wk-timeline-next .wk-timeline-state { color: var(--red); }
|
||||||
|
|
||||||
|
.wk-recap-card, .wk-upnext-card, .wk-live-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: var(--s5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s4);
|
||||||
|
}
|
||||||
|
.wk-recap-eyebrow, .wk-upnext-eyebrow { font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--green); }
|
||||||
|
.wk-recap-title, .wk-upnext-title { font-size: 20px; font-weight: 700; }
|
||||||
|
.wk-upnext-when { font-size: 12px; color: var(--text-3); }
|
||||||
|
.wk-upnext-card { border-color: rgba(230,36,41,0.3); }
|
||||||
|
|
||||||
|
.wk-live-card { border-color: rgba(230,36,41,0.35); }
|
||||||
|
.wk-live-head { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.wk-live-pulse { width: 12px; height: 12px; border-radius: 50%; background: var(--red); box-shadow: 0 0 0 4px rgba(230,36,41,0.2); }
|
||||||
|
.wk-live-lead { font-size: 14px; color: var(--text-2); }
|
||||||
|
|
||||||
|
/* ── Pre-session ── */
|
||||||
|
.wk-pre-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s5);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.wk-pre-countdown { display: flex; flex-direction: column; gap: 2px; margin-left: auto; }
|
||||||
|
.wk-pre-preview { min-width: 0; }
|
||||||
|
/* The folded preview page ships its own max-width page; reset it inside Weekend. */
|
||||||
|
.wk-pre-preview .preview-page { max-width: 100%; padding: 0; }
|
||||||
|
|
||||||
|
/* ── Status views ── */
|
||||||
|
.wk-status {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 64px auto;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s4);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.wk-status-eyebrow { font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--text-3); }
|
||||||
|
.wk-status-title { font-size: 22px; font-weight: 700; }
|
||||||
|
.wk-status-sub { font-size: 13px; color: var(--text-2); }
|
||||||
|
.wk-status-actions { display: flex; gap: var(--s4); margin-top: var(--s4); }
|
||||||
|
.wk-status-error .wk-status-title { color: var(--red); }
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.wk-top-grid, .wk-mid-grid { grid-template-columns: 1fr; }
|
||||||
|
.wk-event-body { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.wk-page { padding: var(--s5) var(--s5) 88px; }
|
||||||
|
.wk-focus {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.wk-focus-cta { width: 100%; justify-content: center; }
|
||||||
|
.wk-event-name { font-size: 20px; }
|
||||||
|
.wk-sessions-title { font-size: 22px; }
|
||||||
|
.wk-count-unit b { font-size: 32px; }
|
||||||
|
.wk-pre-head { flex-direction: column; align-items: flex-start; }
|
||||||
|
.wk-pre-countdown { margin-left: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Explore ── */
|
||||||
|
.wk-explore { display: flex; flex-direction: column; gap: var(--s6); }
|
||||||
|
.wk-explore-section {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: var(--s5);
|
||||||
|
}
|
||||||
|
.wk-explore-grid {
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: var(--s4);
|
||||||
|
}
|
||||||
|
.wk-explore-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding: var(--s4);
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--surface-h);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.wk-explore-card.is-link { transition: border-color 0.12s, background 0.12s; }
|
||||||
|
.wk-explore-card.is-link:hover { border-color: var(--text-3); background: var(--surface-2); }
|
||||||
|
.wk-explore-top { display: flex; justify-content: space-between; align-items: center; font-size: 11px; color: var(--text-3); }
|
||||||
|
.wk-explore-flag { font-size: 18px; }
|
||||||
|
.wk-explore-name { font-size: 14px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.wk-explore-meta { font-size: 11px; color: var(--text-3); }
|
||||||
|
.wk-explore-state { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; margin-top: var(--s2); }
|
||||||
|
.wk-explore-state.is-done { color: var(--green); }
|
||||||
|
.wk-explore-state.is-upcoming { color: var(--text-3); }
|
||||||
|
|
||||||
|
.wk-explore-drivers { list-style: none; display: flex; flex-wrap: wrap; gap: var(--s3); }
|
||||||
|
.wk-driver-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
padding: var(--s3) var(--s4);
|
||||||
|
background: var(--surface-h);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--wk-team, var(--border-2));
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: border-color 0.12s;
|
||||||
|
}
|
||||||
|
.wk-driver-chip:hover { border-color: var(--text-3); }
|
||||||
|
.wk-driver-pos { color: var(--text-3); font-size: 11px; }
|
||||||
|
.wk-driver-code { font-weight: 700; color: var(--wk-team, var(--text)); }
|
||||||
|
.wk-driver-pts { color: var(--text-2); font-size: 12px; }
|
||||||
|
|
||||||
|
/* ── Reduced motion ── */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.wk-cta, .wk-briefing-title, .wk-live-pulse, .wk-timeline-live .wk-timeline-dot { transition: none; }
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'
|
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { BriefingPage } from '../pages/BriefingPage'
|
import { BriefingPage } from '../pages/BriefingPage'
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
fetchChampionshipHub,
|
fetchChampionshipHub,
|
||||||
markNewsRead,
|
markNewsRead,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
|
import { rememberResponseAvailability } from '../lib/fetch'
|
||||||
|
|
||||||
const mockFetchNews = vi.mocked(fetchNews)
|
const mockFetchNews = vi.mocked(fetchNews)
|
||||||
const mockFetchNewsArticle = vi.mocked(fetchNewsArticle)
|
const mockFetchNewsArticle = vi.mocked(fetchNewsArticle)
|
||||||
@@ -208,4 +209,62 @@ describe('BriefingPage digest layout', () => {
|
|||||||
})
|
})
|
||||||
expect(screen.getByRole('tab', { name: /News/i })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: /News/i })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps articles usable and shows Limited when grouping supplements fail', async () => {
|
||||||
|
mockFetchSeasonMeetings.mockRejectedValue(new Error('API 503: meetings failed'))
|
||||||
|
mockFetchHub.mockRejectedValue(new Error('API 503: hub failed'))
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('briefing-data-notice')).toHaveTextContent(/Limited/i)
|
||||||
|
})
|
||||||
|
expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('briefing-error')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
let meetingsCalls = 1
|
||||||
|
let hubCalls = 1
|
||||||
|
let resolveMeetings!: (value: Meeting[]) => void
|
||||||
|
let resolveHub!: (value: ChampionshipHub) => void
|
||||||
|
mockFetchSeasonMeetings.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<Meeting[]>((resolve) => {
|
||||||
|
meetingsCalls += 1
|
||||||
|
resolveMeetings = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
mockFetchHub.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<ChampionshipHub>((resolve) => {
|
||||||
|
hubCalls += 1
|
||||||
|
resolveHub = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Retrying…' })).toBeDisabled())
|
||||||
|
expect(screen.getByRole('button', { name: 'Retrying…' })).toHaveAttribute('aria-busy', 'true')
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Retrying…' }))
|
||||||
|
expect(meetingsCalls).toBe(2)
|
||||||
|
expect(hubCalls).toBe(2)
|
||||||
|
|
||||||
|
resolveMeetings([bahrain, monaco])
|
||||||
|
resolveHub(hub)
|
||||||
|
await waitFor(() => expect(screen.queryByTestId('briefing-data-notice')).not.toBeInTheDocument())
|
||||||
|
expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves Stale hub over routine local News when sources conflict', async () => {
|
||||||
|
const localNews = [...newsItems]
|
||||||
|
rememberResponseAvailability(localNews, { source: 'local', freshness: 'local' })
|
||||||
|
const staleHub = { ...hub }
|
||||||
|
rememberResponseAvailability(staleHub, { source: 'openf1', freshness: 'stale' })
|
||||||
|
mockFetchNews.mockResolvedValue(localNews)
|
||||||
|
mockFetchHub.mockResolvedValue(staleHub)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('briefing-data-notice')).toHaveTextContent(/Stale/i))
|
||||||
|
expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('briefing-error')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|||||||
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
|
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
|
||||||
import { ChampionshipPage } from '../pages/ChampionshipPage'
|
import { ChampionshipPage } from '../pages/ChampionshipPage'
|
||||||
import type { ChampHubDriver, ChampHubTeam, ChampionshipHub } from '../types'
|
import type { ChampHubDriver, ChampHubTeam, ChampionshipHub } from '../types'
|
||||||
|
import { rememberResponseAvailability } from '../lib/fetch'
|
||||||
|
|
||||||
vi.mock('../api', () => ({
|
vi.mock('../api', () => ({
|
||||||
fetchSeasons: vi.fn(),
|
fetchSeasons: vi.fn(),
|
||||||
@@ -226,4 +227,36 @@ describe('ChampionshipPage', () => {
|
|||||||
expect(rows[1]).toHaveTextContent('Red Bull')
|
expect(rows[1]).toHaveTextContent('Red Bull')
|
||||||
expect(rows[1]).toHaveTextContent('9–1')
|
expect(rows[1]).toHaveTextContent('9–1')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,280 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
||||||
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
|
|
||||||
import { CommandCenterPage } from '../pages/CommandCenterPage'
|
|
||||||
import type { DatasetInfo, Meeting, Weekend } from '../types'
|
|
||||||
|
|
||||||
vi.mock('../api', () => ({
|
|
||||||
fetchSeasons: vi.fn(),
|
|
||||||
fetchLocalMeetings: vi.fn(),
|
|
||||||
fetchSeasonMeetings: vi.fn(),
|
|
||||||
fetchSessions: vi.fn(),
|
|
||||||
fetchWeekend: vi.fn(),
|
|
||||||
fetchLiveState: vi.fn(),
|
|
||||||
fetchChampionshipHub: vi.fn(),
|
|
||||||
fetchRaceHub: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import {
|
|
||||||
fetchSeasons,
|
|
||||||
fetchLocalMeetings,
|
|
||||||
fetchSeasonMeetings,
|
|
||||||
fetchSessions,
|
|
||||||
fetchWeekend,
|
|
||||||
fetchLiveState,
|
|
||||||
fetchChampionshipHub,
|
|
||||||
fetchRaceHub,
|
|
||||||
} from '../api'
|
|
||||||
|
|
||||||
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
|
||||||
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
|
|
||||||
const mockFetchSeasonMeetings = vi.mocked(fetchSeasonMeetings)
|
|
||||||
const mockFetchSessions = vi.mocked(fetchSessions)
|
|
||||||
const mockFetchWeekend = vi.mocked(fetchWeekend)
|
|
||||||
const mockFetchLiveState = vi.mocked(fetchLiveState)
|
|
||||||
const mockFetchChampionshipHub = vi.mocked(fetchChampionshipHub)
|
|
||||||
const mockFetchRaceHub = vi.mocked(fetchRaceHub)
|
|
||||||
|
|
||||||
const meeting: Meeting = {
|
|
||||||
meeting_key: 1229,
|
|
||||||
meeting_name: 'Monaco',
|
|
||||||
meeting_official_name: 'FORMULA 1 GRAND PRIX DE MONACO 2025',
|
|
||||||
location: 'Monaco',
|
|
||||||
country_name: 'Monaco',
|
|
||||||
country_code: 'MON',
|
|
||||||
country_flag: '',
|
|
||||||
circuit_short_name: 'Monaco',
|
|
||||||
date_start: '2025-05-23T00:00:00+00:00',
|
|
||||||
date_end: '2025-05-25T00:00:00+00:00',
|
|
||||||
year: 2025,
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullDatasets: Record<string, DatasetInfo> = {
|
|
||||||
meeting: { status: 'available', source: 'local', count: 1 },
|
|
||||||
session: { status: 'available', source: 'local', count: 1 },
|
|
||||||
drivers: { status: 'available', source: 'local', count: 20 },
|
|
||||||
results: { status: 'available', source: 'local', count: 20 },
|
|
||||||
starting_grid: { status: 'available', source: 'local', count: 20 },
|
|
||||||
stints: { status: 'available', source: 'local', count: 2 },
|
|
||||||
pit_stops: { status: 'available', source: 'local', count: 1 },
|
|
||||||
positions: { status: 'available', source: 'local', count: 3 },
|
|
||||||
race_control: { status: 'available', source: 'local', count: 1 },
|
|
||||||
weather: { status: 'available', source: 'local', count: 1 },
|
|
||||||
laps: { status: 'available', source: 'local', count: 1 },
|
|
||||||
}
|
|
||||||
|
|
||||||
const weekend: Weekend = {
|
|
||||||
source: 'local',
|
|
||||||
meeting_key: 1229,
|
|
||||||
meeting,
|
|
||||||
default_session_key: 9472,
|
|
||||||
sessions: [
|
|
||||||
{
|
|
||||||
session: {
|
|
||||||
session_key: 9472,
|
|
||||||
session_name: 'Race',
|
|
||||||
session_type: 'Race',
|
|
||||||
meeting_key: 1229,
|
|
||||||
date_start: '2025-05-25T13:00:00+00:00',
|
|
||||||
date_end: '2025-05-25T15:00:00+00:00',
|
|
||||||
gmt_offset: '02:00:00',
|
|
||||||
},
|
|
||||||
source: 'local',
|
|
||||||
datasets: fullDatasets,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPage() {
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: { queries: { retry: false } },
|
|
||||||
})
|
|
||||||
|
|
||||||
const rootRoute = createRootRoute({
|
|
||||||
component: () => (
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<CommandCenterPage />
|
|
||||||
</QueryClientProvider>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
const indexRoute = createRoute({
|
|
||||||
getParentRoute: () => rootRoute,
|
|
||||||
path: '/',
|
|
||||||
component: CommandCenterPage,
|
|
||||||
})
|
|
||||||
|
|
||||||
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
|
|
||||||
|
|
||||||
return render(<RouterProvider router={router} />)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('CommandCenterPage', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockFetchLiveState.mockResolvedValue({ is_live: false, data: null })
|
|
||||||
mockFetchSessions.mockResolvedValue([])
|
|
||||||
mockFetchChampionshipHub.mockResolvedValue({
|
|
||||||
season: 2025,
|
|
||||||
round: 1,
|
|
||||||
total_rounds: 1,
|
|
||||||
rounds_left: 0,
|
|
||||||
last_race: 'Monaco',
|
|
||||||
round_labels: ['R1'],
|
|
||||||
drivers: [],
|
|
||||||
teams: [],
|
|
||||||
})
|
|
||||||
mockFetchRaceHub.mockResolvedValue({
|
|
||||||
source: 'local',
|
|
||||||
session_key: 9472,
|
|
||||||
datasets: fullDatasets,
|
|
||||||
results: [
|
|
||||||
{
|
|
||||||
driver_number: 1,
|
|
||||||
position: 1,
|
|
||||||
name_acronym: 'VER',
|
|
||||||
full_name: 'Max Verstappen',
|
|
||||||
team_name: 'Red Bull Racing',
|
|
||||||
team_colour: '3671c6',
|
|
||||||
dnf: false,
|
|
||||||
dns: false,
|
|
||||||
dsq: false,
|
|
||||||
duration: 7200,
|
|
||||||
gap_to_leader: null,
|
|
||||||
number_of_laps: 78,
|
|
||||||
points: 25,
|
|
||||||
session_key: 9472,
|
|
||||||
meeting_key: 1229,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
starting_grid: [],
|
|
||||||
drivers: [],
|
|
||||||
stints: [],
|
|
||||||
pit_stops: [],
|
|
||||||
positions: [],
|
|
||||||
race_control: [],
|
|
||||||
weather: [],
|
|
||||||
laps: [],
|
|
||||||
chapters: [],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows empty state when no seasons are ingested', async () => {
|
|
||||||
mockFetchSeasons.mockResolvedValue([])
|
|
||||||
|
|
||||||
renderPage()
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('command-center-empty')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
expect(screen.getByText('No local data yet')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows weekend identity band and schedule when data exists', async () => {
|
|
||||||
mockFetchSeasons.mockResolvedValue([2025])
|
|
||||||
mockFetchLocalMeetings.mockResolvedValue([meeting])
|
|
||||||
mockFetchSeasonMeetings.mockResolvedValue([meeting])
|
|
||||||
mockFetchWeekend.mockResolvedValue(weekend)
|
|
||||||
|
|
||||||
renderPage()
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('command-center')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('cc-session-9472')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
|
|
||||||
expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar')
|
|
||||||
expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01')
|
|
||||||
expect(screen.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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -116,7 +116,7 @@ describe('DataLibraryPage', () => {
|
|||||||
|
|
||||||
expect(await screen.findByTestId('data-library')).toBeInTheDocument()
|
expect(await screen.findByTestId('data-library')).toBeInTheDocument()
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025)
|
expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025, expect.any(AbortSignal))
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(await screen.findByTestId('meeting-detail')).toBeInTheDocument()
|
expect(await screen.findByTestId('meeting-detail')).toBeInTheDocument()
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ describe('DriverProfilePage', () => {
|
|||||||
|
|
||||||
// With an explicit year, the seasons list is not needed.
|
// With an explicit year, the seasons list is not needed.
|
||||||
expect(mockFetchSeasons).not.toHaveBeenCalled()
|
expect(mockFetchSeasons).not.toHaveBeenCalled()
|
||||||
expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025)
|
expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025, expect.any(AbortSignal))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('defaults to the latest season when no year is given', async () => {
|
it('defaults to the latest season when no year is given', async () => {
|
||||||
@@ -129,16 +129,19 @@ describe('DriverProfilePage', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('driver-profile')).toBeInTheDocument()
|
expect(screen.getByTestId('driver-profile')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025)
|
expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025, expect.any(AbortSignal))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows the API error message on failure', async () => {
|
it('shows a user-facing error with retry instead of raw API jargon', async () => {
|
||||||
mockFetchSummary.mockRejectedValue(new Error('API 404: Not Found'))
|
mockFetchSummary.mockRejectedValue(new Error('API 404: Not Found'))
|
||||||
renderPage({ driverNumber: 99, year: 2025 })
|
renderPage({ driverNumber: 99, year: 2025 })
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('API 404: Not Found')).toBeInTheDocument()
|
expect(screen.getByTestId('driver-profile-error')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
expect(screen.queryByText(/API 404/i)).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('driver-profile-error')).toHaveTextContent(/Something went wrong/i)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows empty states when no rounds are completed', async () => {
|
it('shows empty states when no rounds are completed', async () => {
|
||||||
|
|||||||
@@ -209,32 +209,6 @@ describe('TimingTower', () => {
|
|||||||
expect(screen.getByText('DRS range')).toBeInTheDocument()
|
expect(screen.getByText('DRS range')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('derives a gap to P1 from best laps when the practice feed omits GapToLeader', () => {
|
|
||||||
const practiceRows = [
|
|
||||||
makeRow('1', 1, 'VER', { BestLapTime: '1:45.944', GapToLeader: '' }),
|
|
||||||
makeRow('4', 2, 'NOR', { BestLapTime: '1:46.134', GapToLeader: '' }),
|
|
||||||
makeRow('16', 3, 'LEC', { BestLapTime: '', GapToLeader: '' }),
|
|
||||||
]
|
|
||||||
render(
|
|
||||||
<TimingTower
|
|
||||||
rows={practiceRows}
|
|
||||||
session={{
|
|
||||||
MeetingName: 'Belgian Grand Prix',
|
|
||||||
CircuitName: 'Spa',
|
|
||||||
SessionType: 'Practice',
|
|
||||||
SessionName: 'Practice 2',
|
|
||||||
Path: '',
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
// Leader shows a clear leader marker, not a fabricated gap.
|
|
||||||
expect(screen.getByText('VER').closest('tr')).toHaveTextContent('—')
|
|
||||||
expect(screen.getByText('+0.190')).toBeInTheDocument()
|
|
||||||
// A driver without a valid best lap gets no invented gap.
|
|
||||||
const lecRow = screen.getByText('LEC').closest('tr')!
|
|
||||||
expect(lecRow).not.toHaveTextContent('+')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the SQ1 cutoff after P17 and marks rows below as at risk', () => {
|
it('renders the SQ1 cutoff after P17 and marks rows below as at risk', () => {
|
||||||
const sprintRows = Array.from({ length: 22 }, (_, index) =>
|
const sprintRows = Array.from({ length: 22 }, (_, index) =>
|
||||||
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
||||||
|
|||||||
289
frontend/src/test/LiveHandoff.test.tsx
Normal file
289
frontend/src/test/LiveHandoff.test.tsx
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
LiveHandoff,
|
||||||
|
analysisIsReady,
|
||||||
|
handoffAnalysisSession,
|
||||||
|
shouldPollHandoffAnalysis,
|
||||||
|
} from '../components/live/LiveHandoff'
|
||||||
|
import type { LiveTimingRow } from '../lib/live'
|
||||||
|
import type { ContextSession, WeekendContext } from '../types'
|
||||||
|
|
||||||
|
function session(key: number, name: string, type = name): ContextSession['session'] {
|
||||||
|
return {
|
||||||
|
session_key: key,
|
||||||
|
session_name: name,
|
||||||
|
session_type: type,
|
||||||
|
meeting_key: 1,
|
||||||
|
date_start: '2026-07-05T14:00:00Z',
|
||||||
|
date_end: '2026-07-05T16:00:00Z',
|
||||||
|
gmt_offset: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextSession(
|
||||||
|
key: number,
|
||||||
|
name: string,
|
||||||
|
localAnalysis: string,
|
||||||
|
): ContextSession {
|
||||||
|
return {
|
||||||
|
session: session(key, name),
|
||||||
|
availability: {
|
||||||
|
schedule: 'available',
|
||||||
|
live_transport: 'unknown',
|
||||||
|
live_session: 'inactive',
|
||||||
|
archive: 'unavailable',
|
||||||
|
local_analysis: localAnalysis,
|
||||||
|
freshness: 'fresh',
|
||||||
|
limitations: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseContext: WeekendContext = {
|
||||||
|
temporal_state: 'session_settling',
|
||||||
|
focus_meeting: {
|
||||||
|
meeting_key: 1,
|
||||||
|
meeting_name: 'British Grand Prix',
|
||||||
|
meeting_official_name: 'British Grand Prix',
|
||||||
|
location: 'Silverstone',
|
||||||
|
country_name: 'UK',
|
||||||
|
country_code: 'GB',
|
||||||
|
country_flag: '',
|
||||||
|
circuit_short_name: 'Silverstone',
|
||||||
|
date_start: '2026-07-03T09:00:00Z',
|
||||||
|
date_end: '2026-07-05T16:00:00Z',
|
||||||
|
year: 2026,
|
||||||
|
},
|
||||||
|
championship_round: 1,
|
||||||
|
total_championship_rounds: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical contract: archive-only just-finished Race vs older ready Practice. */
|
||||||
|
function archiveOnlySettlingContext(previousAnalysis: string): WeekendContext {
|
||||||
|
return {
|
||||||
|
...baseContext,
|
||||||
|
// Older already-ingested session remains the default analysis target.
|
||||||
|
default_analysis_session: contextSession(10, 'Practice 1', 'complete'),
|
||||||
|
// Just-finished Race is archive-complete but not yet analysis-ready.
|
||||||
|
previous_completed_session: contextSession(99, 'Race', previousAnalysis),
|
||||||
|
next_session: contextSession(12, 'Qualifying', 'not_applicable'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: LiveTimingRow[] = [
|
||||||
|
{ RacingNumber: '1', Position: 1, Driver: { RacingNumber: '1', Position: 1 } as never, Info: { Tla: 'VER' } as never },
|
||||||
|
{ RacingNumber: '4', Position: 2, Driver: { RacingNumber: '4', Position: 2 } as never, Info: { Tla: 'NOR' } as never },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('analysisIsReady', () => {
|
||||||
|
it('is true only when local analysis is complete', () => {
|
||||||
|
expect(analysisIsReady(contextSession(11, 'Race', 'complete'))).toBe(true)
|
||||||
|
expect(analysisIsReady(contextSession(11, 'Race', 'pending'))).toBe(false)
|
||||||
|
expect(analysisIsReady(contextSession(11, 'Race', 'partial'))).toBe(false)
|
||||||
|
expect(analysisIsReady(undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('handoffAnalysisSession', () => {
|
||||||
|
it('settling prefers previous_completed_session over an older default', () => {
|
||||||
|
const ctx = archiveOnlySettlingContext('pending')
|
||||||
|
const analysis = handoffAnalysisSession(ctx, 'settling')
|
||||||
|
expect(analysis?.session.session_key).toBe(99)
|
||||||
|
expect(analysis?.session.session_name).toBe('Race')
|
||||||
|
expect(analysisIsReady(analysis)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inactive prefers default_analysis_session (shared analysisSessionKey)', () => {
|
||||||
|
const ctx = archiveOnlySettlingContext('pending')
|
||||||
|
const analysis = handoffAnalysisSession(ctx, 'inactive')
|
||||||
|
expect(analysis?.session.session_key).toBe(10)
|
||||||
|
expect(analysis?.session.session_name).toBe('Practice 1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('settling falls back to default when previous is absent', () => {
|
||||||
|
const ctx = {
|
||||||
|
...baseContext,
|
||||||
|
default_analysis_session: contextSession(11, 'Race', 'pending'),
|
||||||
|
}
|
||||||
|
expect(handoffAnalysisSession(ctx, 'settling')?.session.session_key).toBe(11)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('shouldPollHandoffAnalysis', () => {
|
||||||
|
it('keeps polling when previous is pending even if default is already complete', () => {
|
||||||
|
expect(shouldPollHandoffAnalysis(archiveOnlySettlingContext('pending'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops polling once the just-finished previous session is ready', () => {
|
||||||
|
expect(shouldPollHandoffAnalysis(archiveOnlySettlingContext('complete'))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('LiveHandoff settling', () => {
|
||||||
|
it('shows SESSION SETTLING with a pending analysis action while ingesting', () => {
|
||||||
|
render(
|
||||||
|
<LiveHandoff
|
||||||
|
phase="settling"
|
||||||
|
transport="connected"
|
||||||
|
context={{
|
||||||
|
...baseContext,
|
||||||
|
previous_completed_session: contextSession(11, 'Race', 'pending'),
|
||||||
|
}}
|
||||||
|
rows={rows}
|
||||||
|
capturedAt="2026-07-05T16:02:00Z"
|
||||||
|
hasArchive
|
||||||
|
onViewArchive={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('live-settling')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('SESSION SETTLING')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('live-handoff-captured')).toHaveTextContent('Final feed snapshot captured')
|
||||||
|
const action = screen.getByTestId('live-handoff-analysis')
|
||||||
|
expect(action).toHaveAttribute('href', '/race-hub?session_key=11')
|
||||||
|
expect(action).toHaveAttribute('data-ready', 'false')
|
||||||
|
expect(action).toHaveTextContent('Open Race analysis')
|
||||||
|
expect(action).toHaveTextContent(/analysis will fill in as data ingests/i)
|
||||||
|
// Provisional final order from the retained snapshot.
|
||||||
|
expect(screen.getByTestId('live-handoff-snapshot')).toHaveTextContent('VER')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('links/labels/readiness follow archive-only previous, not an older ready default', () => {
|
||||||
|
render(
|
||||||
|
<LiveHandoff
|
||||||
|
phase="settling"
|
||||||
|
transport="connected"
|
||||||
|
context={archiveOnlySettlingContext('pending')}
|
||||||
|
rows={rows}
|
||||||
|
capturedAt="2026-07-05T16:02:00Z"
|
||||||
|
hasArchive
|
||||||
|
onViewArchive={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const action = screen.getByTestId('live-handoff-analysis')
|
||||||
|
expect(action).toHaveAttribute('href', '/race-hub?session_key=99')
|
||||||
|
expect(action).toHaveAttribute('data-ready', 'false')
|
||||||
|
expect(action).toHaveTextContent('Open Race analysis')
|
||||||
|
expect(action).toHaveTextContent(/Settling — analysis will fill in as data ingests/i)
|
||||||
|
expect(action).not.toHaveTextContent('Practice 1')
|
||||||
|
expect(action).not.toHaveTextContent(/full timing, strategy & story ready/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flips to analysis-ready once local ingestion completes', () => {
|
||||||
|
render(
|
||||||
|
<LiveHandoff
|
||||||
|
phase="settling"
|
||||||
|
transport="connected"
|
||||||
|
context={{
|
||||||
|
...baseContext,
|
||||||
|
previous_completed_session: contextSession(11, 'Race', 'complete'),
|
||||||
|
}}
|
||||||
|
rows={rows}
|
||||||
|
capturedAt="2026-07-05T16:02:00Z"
|
||||||
|
hasArchive={false}
|
||||||
|
onViewArchive={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const action = screen.getByTestId('live-handoff-analysis')
|
||||||
|
expect(action).toHaveAttribute('data-ready', 'true')
|
||||||
|
expect(action).toHaveTextContent(/full timing, strategy & story ready/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never renders live/connected chrome or a countdown in settling', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<LiveHandoff
|
||||||
|
phase="settling"
|
||||||
|
transport="connected"
|
||||||
|
context={{
|
||||||
|
...baseContext,
|
||||||
|
previous_completed_session: contextSession(11, 'Race', 'complete'),
|
||||||
|
}}
|
||||||
|
rows={rows}
|
||||||
|
capturedAt="2026-07-05T16:02:00Z"
|
||||||
|
hasArchive
|
||||||
|
onViewArchive={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.queryByText('LIVE SESSION')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('live-clock')).not.toBeInTheDocument()
|
||||||
|
expect(container.querySelector('.live-session-flag-live')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses non-settling readiness copy when inactive and analysis is still ingesting', () => {
|
||||||
|
render(
|
||||||
|
<LiveHandoff
|
||||||
|
phase="inactive"
|
||||||
|
transport="connected"
|
||||||
|
context={{ ...baseContext, default_analysis_session: contextSession(11, 'Race', 'pending') }}
|
||||||
|
rows={[]}
|
||||||
|
hasArchive={false}
|
||||||
|
onViewArchive={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const action = screen.getByTestId('live-handoff-analysis')
|
||||||
|
expect(action).toHaveAttribute('data-ready', 'false')
|
||||||
|
expect(action).toHaveTextContent(/analysis will fill in as data ingests/i)
|
||||||
|
expect(action).not.toHaveTextContent(/^Settling/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('LiveHandoff inactive', () => {
|
||||||
|
it('uses shared next + recap context instead of a telemetry-offline dead end', () => {
|
||||||
|
render(
|
||||||
|
<LiveHandoff
|
||||||
|
phase="inactive"
|
||||||
|
transport="connected"
|
||||||
|
context={{
|
||||||
|
...baseContext,
|
||||||
|
temporal_state: 'between_sessions',
|
||||||
|
default_analysis_session: contextSession(11, 'Practice 1', 'complete'),
|
||||||
|
next_session: contextSession(12, 'Qualifying', 'not_applicable'),
|
||||||
|
previous_completed_session: contextSession(10, 'Practice 2', 'complete'),
|
||||||
|
}}
|
||||||
|
rows={[]}
|
||||||
|
hasArchive={false}
|
||||||
|
onViewArchive={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByTestId('live-inactive')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('NO LIVE SESSION')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('live-handoff-next')).toHaveTextContent('Qualifying')
|
||||||
|
expect(screen.getByTestId('live-handoff-recap')).toHaveTextContent('Practice 2')
|
||||||
|
// No provisional-order snapshot when inactive.
|
||||||
|
expect(screen.queryByTestId('live-handoff-snapshot')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not duplicate the recap card when previous equals the analysis target', () => {
|
||||||
|
render(
|
||||||
|
<LiveHandoff
|
||||||
|
phase="inactive"
|
||||||
|
transport="connected"
|
||||||
|
context={{
|
||||||
|
...baseContext,
|
||||||
|
default_analysis_session: contextSession(11, 'Race', 'complete'),
|
||||||
|
previous_completed_session: contextSession(11, 'Race', 'complete'),
|
||||||
|
}}
|
||||||
|
rows={[]}
|
||||||
|
hasArchive={false}
|
||||||
|
onViewArchive={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.queryByTestId('live-handoff-recap')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to Weekend when the context has nothing to offer', () => {
|
||||||
|
render(
|
||||||
|
<LiveHandoff
|
||||||
|
phase="inactive"
|
||||||
|
transport="error"
|
||||||
|
context={{ temporal_state: 'no_season', championship_round: 0, total_championship_rounds: 0 }}
|
||||||
|
rows={[]}
|
||||||
|
hasArchive={false}
|
||||||
|
onViewArchive={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByTestId('live-handoff-fallback')).toHaveTextContent('Weekend')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,32 +1,34 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { LiveTimingPage } from '../pages/LiveTimingPage'
|
import { LiveTimingPage, WEEKEND_CONTEXT_POLL_MS } from '../pages/LiveTimingPage'
|
||||||
import type { LiveStateResponse, LiveStreamData } from '../types'
|
import type { LiveStateResponse, LiveStreamData, WeekendContext } from '../types'
|
||||||
|
|
||||||
vi.mock('../api', () => ({
|
vi.mock('../api', () => ({
|
||||||
fetchLiveState: vi.fn(),
|
fetchLiveState: vi.fn(),
|
||||||
fetchLiveTrackOutline: vi.fn(),
|
fetchLiveTrackOutline: vi.fn(),
|
||||||
|
fetchWeekendContext: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import { fetchLiveState, fetchLiveTrackOutline } from '../api'
|
import { fetchLiveState, fetchLiveTrackOutline, fetchWeekendContext } from '../api'
|
||||||
|
|
||||||
const mockFetchLiveState = vi.mocked(fetchLiveState)
|
const mockFetchLiveState = vi.mocked(fetchLiveState)
|
||||||
const mockFetchLiveTrackOutline = vi.mocked(fetchLiveTrackOutline)
|
const mockFetchLiveTrackOutline = vi.mocked(fetchLiveTrackOutline)
|
||||||
|
const mockFetchWeekendContext = vi.mocked(fetchWeekendContext)
|
||||||
|
|
||||||
|
// A MockEventSource that opens on construct but never emits a snapshot, so the
|
||||||
|
// initial /api/v1/live/state query drives the rendered phase.
|
||||||
class MockEventSource {
|
class MockEventSource {
|
||||||
onopen: (() => void) | null = null
|
onopen: (() => void) | null = null
|
||||||
onerror: (() => void) | null = null
|
onerror: (() => void) | null = null
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
setTimeout(() => this.onopen?.(), 0)
|
setTimeout(() => this.onopen?.(), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
addEventListener() {}
|
addEventListener() {}
|
||||||
close() {}
|
close() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const archivedSnapshot: LiveStreamData = {
|
const raceSnapshot: LiveStreamData = {
|
||||||
Drivers: {
|
Drivers: {
|
||||||
'1': {
|
'1': {
|
||||||
RacingNumber: '1',
|
RacingNumber: '1',
|
||||||
@@ -63,19 +65,10 @@ const archivedSnapshot: LiveStreamData = {
|
|||||||
LastName: 'Verstappen',
|
LastName: 'Verstappen',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Tyres: {
|
Tyres: { '1': { Compound: 'HARD', New: false, Age: 12 } },
|
||||||
'1': { Compound: 'HARD', New: false, Age: 12 },
|
|
||||||
},
|
|
||||||
Telemetry: {},
|
Telemetry: {},
|
||||||
RCMessages: [],
|
RCMessages: [],
|
||||||
Weather: {
|
Weather: { AirTemp: 22, TrackTemp: 41, Humidity: 58, WindSpeed: 3, WindDir: 180, Rainfall: false },
|
||||||
AirTemp: 22,
|
|
||||||
TrackTemp: 41,
|
|
||||||
Humidity: 58,
|
|
||||||
WindSpeed: 3,
|
|
||||||
WindDir: 180,
|
|
||||||
Rainfall: false,
|
|
||||||
},
|
|
||||||
Session: {
|
Session: {
|
||||||
MeetingName: 'Testonia Grand Prix',
|
MeetingName: 'Testonia Grand Prix',
|
||||||
CircuitName: 'Testring',
|
CircuitName: 'Testring',
|
||||||
@@ -84,9 +77,9 @@ const archivedSnapshot: LiveStreamData = {
|
|||||||
Path: '',
|
Path: '',
|
||||||
},
|
},
|
||||||
TeamRadio: [],
|
TeamRadio: [],
|
||||||
SessionStatus: 'Finished',
|
SessionStatus: 'Started',
|
||||||
TrackStatus: '1',
|
TrackStatus: '1',
|
||||||
CurrentLap: 57,
|
CurrentLap: 30,
|
||||||
TotalLaps: 57,
|
TotalLaps: 57,
|
||||||
Clock: '',
|
Clock: '',
|
||||||
ClockRefTime: '',
|
ClockRefTime: '',
|
||||||
@@ -94,17 +87,93 @@ const archivedSnapshot: LiveStreamData = {
|
|||||||
Stints: {},
|
Stints: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPage(response: LiveStateResponse) {
|
function weekendContext(localAnalysis: string): WeekendContext {
|
||||||
const queryClient = new QueryClient({
|
return {
|
||||||
defaultOptions: { queries: { retry: false } },
|
temporal_state: 'session_settling',
|
||||||
})
|
focus_meeting: {
|
||||||
|
meeting_key: 1,
|
||||||
|
meeting_name: 'Testonia Grand Prix',
|
||||||
|
meeting_official_name: 'Testonia Grand Prix',
|
||||||
|
location: 'Testring',
|
||||||
|
country_name: 'Testonia',
|
||||||
|
country_code: 'TS',
|
||||||
|
country_flag: '',
|
||||||
|
circuit_short_name: 'Testring',
|
||||||
|
date_start: '2026-07-03T09:00:00Z',
|
||||||
|
date_end: '2026-07-05T16:00:00Z',
|
||||||
|
year: 2026,
|
||||||
|
},
|
||||||
|
previous_completed_session: {
|
||||||
|
session: {
|
||||||
|
session_key: 99,
|
||||||
|
session_name: 'Race',
|
||||||
|
session_type: 'Race',
|
||||||
|
meeting_key: 1,
|
||||||
|
date_start: '2026-07-05T14:00:00Z',
|
||||||
|
date_end: '2026-07-05T16:00:00Z',
|
||||||
|
gmt_offset: '',
|
||||||
|
},
|
||||||
|
availability: {
|
||||||
|
schedule: 'available',
|
||||||
|
live_transport: 'unknown',
|
||||||
|
live_session: 'inactive',
|
||||||
|
archive: 'available',
|
||||||
|
local_analysis: localAnalysis,
|
||||||
|
freshness: 'fresh',
|
||||||
|
limitations: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
championship_round: 1,
|
||||||
|
total_championship_rounds: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Just-finished archive-only Race + older already-ready Practice default. */
|
||||||
|
function archiveOnlySettlingContext(previousAnalysis: string): WeekendContext {
|
||||||
|
return {
|
||||||
|
...weekendContext(previousAnalysis),
|
||||||
|
default_analysis_session: {
|
||||||
|
session: {
|
||||||
|
session_key: 10,
|
||||||
|
session_name: 'Practice 1',
|
||||||
|
session_type: 'Practice',
|
||||||
|
meeting_key: 1,
|
||||||
|
date_start: '2026-07-04T12:00:00Z',
|
||||||
|
date_end: '2026-07-04T13:00:00Z',
|
||||||
|
gmt_offset: '',
|
||||||
|
},
|
||||||
|
availability: {
|
||||||
|
schedule: 'available',
|
||||||
|
live_transport: 'unknown',
|
||||||
|
live_session: 'inactive',
|
||||||
|
archive: 'available',
|
||||||
|
local_analysis: 'complete',
|
||||||
|
freshness: 'fresh',
|
||||||
|
limitations: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage(
|
||||||
|
response: LiveStateResponse,
|
||||||
|
context?: WeekendContext,
|
||||||
|
options: { setWeekendContext?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
mockFetchLiveState.mockResolvedValue(response)
|
mockFetchLiveState.mockResolvedValue(response)
|
||||||
mockFetchLiveTrackOutline.mockResolvedValue({
|
mockFetchLiveTrackOutline.mockResolvedValue({
|
||||||
circuit_key: 1,
|
circuit_key: 1,
|
||||||
points: [],
|
points: [],
|
||||||
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
|
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
|
||||||
})
|
})
|
||||||
|
// Allow callers to pre-configure the weekend-context mock (e.g. pending→complete
|
||||||
|
// polling) without this helper overwriting the chain.
|
||||||
|
if (options.setWeekendContext !== false) {
|
||||||
|
mockFetchWeekendContext.mockResolvedValue(
|
||||||
|
context ?? { temporal_state: 'no_season', championship_round: 0, total_championship_rounds: 0 },
|
||||||
|
)
|
||||||
|
}
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<LiveTimingPage />
|
<LiveTimingPage />
|
||||||
@@ -112,7 +181,7 @@ function renderPage(response: LiveStateResponse) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('LiveTimingPage archive mode', () => {
|
describe('LiveTimingPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
Object.defineProperty(window, 'EventSource', {
|
Object.defineProperty(window, 'EventSource', {
|
||||||
@@ -122,68 +191,365 @@ describe('LiveTimingPage archive mode', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps archived snapshots behind the View Last Session action', async () => {
|
afterEach(() => {
|
||||||
renderPage({
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the live timing tower for an active session', async () => {
|
||||||
|
renderPage({ is_live: true, data: raceSnapshot })
|
||||||
|
expect(await screen.findByText('Timing Tower')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'live')
|
||||||
|
expect(screen.getByTestId('live-session-flag')).toHaveTextContent('LIVE SESSION')
|
||||||
|
expect(screen.getByTestId('live-feed-health')).toBeInTheDocument()
|
||||||
|
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enters the settling handoff (not archive) when the session ends cleanly', async () => {
|
||||||
|
renderPage(
|
||||||
|
{
|
||||||
is_live: false,
|
is_live: false,
|
||||||
data: null,
|
data: null,
|
||||||
last_snapshot: archivedSnapshot,
|
last_snapshot: { ...raceSnapshot, SessionStatus: 'Finished' },
|
||||||
last_positions: {
|
last_snapshot_at: '2026-07-05T16:02:00Z',
|
||||||
'1': { x: 10, y: 20, z: 0, status: 'OnTrack' },
|
|
||||||
},
|
},
|
||||||
last_snapshot_at: '2026-07-04T14:00:00Z',
|
weekendContext('pending'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const settling = await screen.findByTestId('live-settling')
|
||||||
|
expect(settling).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'settling')
|
||||||
|
// Just-finished previous_completed_session drives the primary action target.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId('live-handoff-analysis')).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/race-hub?session_key=99',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// The timing tower is not shown while settling — it is a handoff surface.
|
||||||
|
expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active')
|
it('settles against archive-only previous, not an older ready default_analysis_session', async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||||
|
mockFetchWeekendContext
|
||||||
|
.mockResolvedValueOnce(archiveOnlySettlingContext('pending'))
|
||||||
|
.mockResolvedValue(archiveOnlySettlingContext('complete'))
|
||||||
|
|
||||||
|
renderPage(
|
||||||
|
{
|
||||||
|
is_live: false,
|
||||||
|
data: null,
|
||||||
|
last_snapshot: { ...raceSnapshot, SessionStatus: 'Finished' },
|
||||||
|
last_snapshot_at: '2026-07-05T16:02:00Z',
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ setWeekendContext: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
const action = await screen.findByTestId('live-handoff-analysis')
|
||||||
|
expect(action).toHaveAttribute('href', '/race-hub?session_key=99')
|
||||||
|
expect(action).toHaveAttribute('data-ready', 'false')
|
||||||
|
expect(action).toHaveTextContent('Open Race analysis')
|
||||||
|
expect(action).not.toHaveTextContent('Practice 1')
|
||||||
|
|
||||||
|
// Older default is already complete — polling must continue for previous.
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(WEEKEND_CONTEXT_POLL_MS + 500)
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId('live-handoff-analysis')).toHaveAttribute('data-ready', 'true'),
|
||||||
|
)
|
||||||
|
expect(screen.getByTestId('live-handoff-analysis')).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/race-hub?session_key=99',
|
||||||
|
)
|
||||||
|
expect(mockFetchWeekendContext.mock.calls.length).toBeGreaterThan(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flips settling → analysis-ready when polling sees ingestion complete', async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||||
|
// First fetch: pending. Subsequent polls: complete.
|
||||||
|
mockFetchWeekendContext
|
||||||
|
.mockResolvedValueOnce(weekendContext('pending'))
|
||||||
|
.mockResolvedValue(weekendContext('complete'))
|
||||||
|
|
||||||
|
renderPage(
|
||||||
|
{
|
||||||
|
is_live: false,
|
||||||
|
data: null,
|
||||||
|
last_snapshot: { ...raceSnapshot, SessionStatus: 'Finished' },
|
||||||
|
last_snapshot_at: '2026-07-05T16:02:00Z',
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ setWeekendContext: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
const action = await screen.findByTestId('live-handoff-analysis')
|
||||||
|
expect(action).toHaveAttribute('data-ready', 'false')
|
||||||
|
expect(action).toHaveTextContent(/analysis will fill in as data ingests/i)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(WEEKEND_CONTEXT_POLL_MS + 500)
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId('live-handoff-analysis')).toHaveAttribute('data-ready', 'true'),
|
||||||
|
)
|
||||||
|
expect(screen.getByTestId('live-handoff-analysis')).toHaveTextContent(
|
||||||
|
/full timing, strategy & story ready/i,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retains the last snapshot with a disconnected warning (not settling) on a feed drop', async () => {
|
||||||
|
// is_live=false but the retained snapshot has a NON-terminal SessionStatus:
|
||||||
|
// the FIA feed dropped while the session was still active.
|
||||||
|
renderPage(
|
||||||
|
{
|
||||||
|
is_live: false,
|
||||||
|
data: null,
|
||||||
|
last_snapshot: { ...raceSnapshot, SessionStatus: 'Started' },
|
||||||
|
last_snapshot_at: '2026-07-05T15:30:00Z',
|
||||||
|
},
|
||||||
|
weekendContext('pending'),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('live-disconnected-strip')).toHaveTextContent(
|
||||||
|
/showing the last live data/i,
|
||||||
|
)
|
||||||
|
expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'disconnected')
|
||||||
|
// The retained live tower stays visible; we never fell into settling/archive.
|
||||||
|
expect(screen.getByText('Timing Tower')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('live-settling')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('live-archive-strip')).not.toBeInTheDocument()
|
||||||
|
// SSE may still be open (MockEventSource opens) — health must not say healthy.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId('live-feed-health')).toHaveTextContent(/reconnecting/i),
|
||||||
|
)
|
||||||
|
expect(screen.getByTestId('live-feed-health')).not.toHaveTextContent(/feed healthy/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the settled snapshot behind an explicit read-only archive action', async () => {
|
||||||
|
renderPage(
|
||||||
|
{
|
||||||
|
is_live: false,
|
||||||
|
data: null,
|
||||||
|
last_snapshot: { ...raceSnapshot, SessionStatus: 'Finished' },
|
||||||
|
last_positions: { '1': { x: 10, y: 20, z: 0, status: 'OnTrack' } },
|
||||||
|
last_snapshot_at: '2026-07-05T16:02:00Z',
|
||||||
|
},
|
||||||
|
weekendContext('complete'),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Settling first — the tower is hidden until the user opens the archive.
|
||||||
|
await screen.findByTestId('live-settling')
|
||||||
expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument()
|
expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument()
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /view last session/i }))
|
fireEvent.click(screen.getByTestId('live-handoff-archive'))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() =>
|
||||||
expect(screen.getByTestId('live-archive-strip')).toHaveTextContent('Archived snapshot')
|
expect(screen.getByTestId('live-archive-strip')).toHaveTextContent(/read-only/i),
|
||||||
})
|
)
|
||||||
expect(screen.getByText('Timing Tower')).toBeInTheDocument()
|
expect(screen.getByText('Timing Tower')).toBeInTheDocument()
|
||||||
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
|
// Archive presents its read-only, timestamped chrome — never LIVE.
|
||||||
expect(screen.getByText('archive')).toBeInTheDocument()
|
expect(screen.getByText('archive')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('LIVE SESSION')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not show the archive action when no snapshot is retained', async () => {
|
it('shows inactive handoff when nothing is live or retained', async () => {
|
||||||
renderPage({ is_live: false, data: null })
|
renderPage(
|
||||||
|
{ is_live: false, data: null },
|
||||||
expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active')
|
{
|
||||||
expect(screen.queryByRole('button', { name: /view last session/i })).not.toBeInTheDocument()
|
temporal_state: 'between_weekends',
|
||||||
})
|
next_session: {
|
||||||
|
session: {
|
||||||
it('renders the live session for a red-flag paused snapshot instead of the inactive empty state', async () => {
|
session_key: 21,
|
||||||
renderPage({
|
session_name: 'Practice 1',
|
||||||
is_live: true,
|
session_type: 'Practice 1',
|
||||||
data: {
|
meeting_key: 2,
|
||||||
...archivedSnapshot,
|
date_start: '2026-07-17T09:00:00Z',
|
||||||
SessionStatus: 'Inactive',
|
date_end: '2026-07-17T10:00:00Z',
|
||||||
TrackStatus: '2',
|
gmt_offset: '',
|
||||||
Clock: '00:03:27',
|
},
|
||||||
Session: {
|
availability: {
|
||||||
MeetingName: 'Belgian Grand Prix',
|
schedule: 'available',
|
||||||
CircuitName: 'Spa-Francorchamps',
|
live_transport: 'unknown',
|
||||||
SessionType: 'Practice',
|
live_session: 'inactive',
|
||||||
SessionName: 'Practice 2',
|
archive: 'unavailable',
|
||||||
Path: '',
|
local_analysis: 'not_applicable',
|
||||||
|
freshness: 'fresh',
|
||||||
|
limitations: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
championship_round: 2,
|
||||||
|
total_championship_rounds: 24,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('live-inactive')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'inactive')
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId('live-handoff-next')).toHaveTextContent('Practice 1'),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(await screen.findByText('Timing Tower')).toBeInTheDocument()
|
it('clears fatal initial error once SSE supplies a live snapshot', async () => {
|
||||||
expect(screen.queryByTestId('live-empty')).not.toBeInTheDocument()
|
type Listener = (event: { data: string }) => void
|
||||||
expect(screen.queryByTestId('live-archive-strip')).not.toBeInTheDocument()
|
class RecoveringEventSource {
|
||||||
|
onopen: (() => void) | null = null
|
||||||
|
onerror: (() => void) | null = null
|
||||||
|
private listeners = new Map<string, Listener[]>()
|
||||||
|
static latest: RecoveringEventSource | null = null
|
||||||
|
constructor() {
|
||||||
|
RecoveringEventSource.latest = this
|
||||||
|
setTimeout(() => this.onopen?.(), 0)
|
||||||
|
}
|
||||||
|
addEventListener(type: string, listener: Listener) {
|
||||||
|
const list = this.listeners.get(type) ?? []
|
||||||
|
list.push(listener)
|
||||||
|
this.listeners.set(type, list)
|
||||||
|
}
|
||||||
|
emit(type: string, data: unknown) {
|
||||||
|
for (const listener of this.listeners.get(type) ?? []) {
|
||||||
|
listener({ data: JSON.stringify(data) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'EventSource', {
|
||||||
|
value: RecoveringEventSource,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
it('temporarily omits the track map while live GPS is unavailable', async () => {
|
mockFetchLiveState.mockRejectedValue(new Error('API 503: live state unavailable'))
|
||||||
renderPage({
|
mockFetchLiveTrackOutline.mockResolvedValue({
|
||||||
|
circuit_key: 1,
|
||||||
|
points: [],
|
||||||
|
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
|
||||||
|
})
|
||||||
|
mockFetchWeekendContext.mockResolvedValue({
|
||||||
|
temporal_state: 'no_season',
|
||||||
|
championship_round: 0,
|
||||||
|
total_championship_rounds: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider
|
||||||
|
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||||
|
>
|
||||||
|
<LiveTimingPage />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('live-initial-error')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Live timing unavailable/i)).toBeInTheDocument()
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
RecoveringEventSource.latest?.emit('snapshot', {
|
||||||
is_live: true,
|
is_live: true,
|
||||||
data: { ...archivedSnapshot, SessionStatus: 'Started' },
|
data: raceSnapshot,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(await screen.findByText('Timing Tower')).toBeInTheDocument()
|
await waitFor(() => expect(screen.getByText('Timing Tower')).toBeInTheDocument())
|
||||||
expect(screen.queryByText('Track Map')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('live-initial-error')).not.toBeInTheDocument()
|
||||||
expect(mockFetchLiveTrackOutline).not.toHaveBeenCalled()
|
expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'live')
|
||||||
|
expect(screen.queryByText(/Live timing unavailable/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears fatal initial error when SSE reports authoritative inactive null state', async () => {
|
||||||
|
type Listener = (event: { data: string }) => void
|
||||||
|
class InactiveEventSource {
|
||||||
|
onopen: (() => void) | null = null
|
||||||
|
onerror: (() => void) | null = null
|
||||||
|
private listeners = new Map<string, Listener[]>()
|
||||||
|
static latest: InactiveEventSource | null = null
|
||||||
|
constructor() {
|
||||||
|
InactiveEventSource.latest = this
|
||||||
|
setTimeout(() => this.onopen?.(), 0)
|
||||||
|
}
|
||||||
|
addEventListener(type: string, listener: Listener) {
|
||||||
|
const list = this.listeners.get(type) ?? []
|
||||||
|
list.push(listener)
|
||||||
|
this.listeners.set(type, list)
|
||||||
|
}
|
||||||
|
emit(type: string, data: unknown) {
|
||||||
|
for (const listener of this.listeners.get(type) ?? []) {
|
||||||
|
listener({ data: JSON.stringify(data) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'EventSource', {
|
||||||
|
value: InactiveEventSource,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
mockFetchLiveState.mockRejectedValue(new Error('API 503: live state unavailable'))
|
||||||
|
mockFetchLiveTrackOutline.mockResolvedValue({
|
||||||
|
circuit_key: 1,
|
||||||
|
points: [],
|
||||||
|
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
|
||||||
|
})
|
||||||
|
mockFetchWeekendContext.mockResolvedValue({
|
||||||
|
temporal_state: 'between_weekends',
|
||||||
|
championship_round: 5,
|
||||||
|
total_championship_rounds: 24,
|
||||||
|
season: 2026,
|
||||||
|
})
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider
|
||||||
|
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||||
|
>
|
||||||
|
<LiveTimingPage />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('live-initial-error')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
InactiveEventSource.latest?.emit('snapshot', {
|
||||||
|
is_live: false,
|
||||||
|
data: null,
|
||||||
|
last_snapshot: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('live-inactive')).toBeInTheDocument())
|
||||||
|
expect(screen.queryByTestId('live-initial-error')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(/Live timing unavailable/i)).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'inactive')
|
||||||
|
expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps bounded initial error + Retry when no stream data arrives', async () => {
|
||||||
|
mockFetchLiveState.mockRejectedValue(new Error('API 503: live state unavailable'))
|
||||||
|
mockFetchLiveTrackOutline.mockResolvedValue({
|
||||||
|
circuit_key: 1,
|
||||||
|
points: [],
|
||||||
|
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
|
||||||
|
})
|
||||||
|
mockFetchWeekendContext.mockResolvedValue({
|
||||||
|
temporal_state: 'no_season',
|
||||||
|
championship_round: 0,
|
||||||
|
total_championship_rounds: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider
|
||||||
|
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||||
|
>
|
||||||
|
<LiveTimingPage />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('live-initial-error')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled()
|
||||||
|
expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ describe('LocalDataNavigator', () => {
|
|||||||
|
|
||||||
expect(await screen.findByTestId('local-nav')).toBeInTheDocument()
|
expect(await screen.findByTestId('local-nav')).toBeInTheDocument()
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025)
|
expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025, expect.any(AbortSignal))
|
||||||
})
|
})
|
||||||
expect(screen.getByText('Monaco')).toBeInTheDocument()
|
expect(screen.getByText('Monaco')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|||||||
67
frontend/src/test/Nav.test.tsx
Normal file
67
frontend/src/test/Nav.test.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||||
|
import {
|
||||||
|
Outlet,
|
||||||
|
RouterProvider,
|
||||||
|
createRouter,
|
||||||
|
createRootRoute,
|
||||||
|
createRoute,
|
||||||
|
createMemoryHistory,
|
||||||
|
} from '@tanstack/react-router'
|
||||||
|
import { Nav } from '../components/Nav'
|
||||||
|
|
||||||
|
function renderNav() {
|
||||||
|
const rootRoute = createRootRoute({
|
||||||
|
component: () => (
|
||||||
|
<>
|
||||||
|
<Nav />
|
||||||
|
<Outlet />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
const stub = (p: string, id: string) =>
|
||||||
|
createRoute({ getParentRoute: () => rootRoute, path: p, component: () => <div data-testid={id} /> })
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree: rootRoute.addChildren([
|
||||||
|
stub('/', 'home'),
|
||||||
|
stub('/championship', 'championship'),
|
||||||
|
stub('/briefing', 'briefing'),
|
||||||
|
stub('/explore', 'explore'),
|
||||||
|
stub('/admin', 'admin'),
|
||||||
|
]),
|
||||||
|
history: createMemoryHistory({ initialEntries: ['/'] }),
|
||||||
|
})
|
||||||
|
return render(<RouterProvider router={router} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DESTINATIONS = ['Weekend', 'Championship', 'Briefing', 'Explore']
|
||||||
|
|
||||||
|
describe('Nav — one primary navigation system per breakpoint', () => {
|
||||||
|
it('exposes exactly two Primary landmarks (one per breakpoint), never more', async () => {
|
||||||
|
renderNav()
|
||||||
|
await waitFor(() => expect(screen.getAllByRole('navigation', { name: 'Primary' })).toHaveLength(2))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every Primary landmark contains all four destinations', async () => {
|
||||||
|
renderNav()
|
||||||
|
await waitFor(() => expect(screen.getAllByRole('navigation', { name: 'Primary' }).length).toBe(2))
|
||||||
|
const primaries = screen.getAllByRole('navigation', { name: 'Primary' })
|
||||||
|
for (const nav of primaries) {
|
||||||
|
for (const label of DESTINATIONS) {
|
||||||
|
expect(within(nav).getByRole('link', { name: new RegExp(`^${label}$`) })).toBeInTheDocument()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps Admin out of every Primary landmark (operator utility only)', async () => {
|
||||||
|
renderNav()
|
||||||
|
await waitFor(() => expect(screen.getAllByRole('navigation', { name: 'Primary' }).length).toBe(2))
|
||||||
|
const primaries = screen.getAllByRole('navigation', { name: 'Primary' })
|
||||||
|
for (const nav of primaries) {
|
||||||
|
expect(within(nav).queryByRole('link', { name: /Admin/i })).not.toBeInTheDocument()
|
||||||
|
}
|
||||||
|
// Admin lives in the operator utilities toolbar.
|
||||||
|
const toolbar = screen.getByRole('toolbar', { name: 'Operator utilities' })
|
||||||
|
expect(within(toolbar).getByRole('link', { name: /Admin/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { act, render, screen, waitFor, fireEvent } from '@testing-library/react'
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
Outlet,
|
Outlet,
|
||||||
@@ -9,8 +9,14 @@ import {
|
|||||||
createRoute,
|
createRoute,
|
||||||
} from '@tanstack/react-router'
|
} from '@tanstack/react-router'
|
||||||
import { RaceHubPage } from '../pages/RaceHubPage'
|
import { RaceHubPage } from '../pages/RaceHubPage'
|
||||||
import { MAX_BROWSER_TIMEOUT } from '../lib/schedule'
|
import type {
|
||||||
import type { ContextAvailability, DatasetInfo, Meeting, RaceHub, Session, Weekend, WeekendContext } from '../types'
|
DatasetInfo,
|
||||||
|
Meeting,
|
||||||
|
RaceHub,
|
||||||
|
Session,
|
||||||
|
Weekend,
|
||||||
|
WeekendContext,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
vi.mock('../api', () => ({
|
vi.mock('../api', () => ({
|
||||||
fetchRaceHub: vi.fn(),
|
fetchRaceHub: vi.fn(),
|
||||||
@@ -20,7 +26,13 @@ vi.mock('../api', () => ({
|
|||||||
fetchWeekendContext: vi.fn(),
|
fetchWeekendContext: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import { fetchLocalMeetings, fetchRaceHub, fetchSeasons, fetchWeekend, fetchWeekendContext } from '../api'
|
import {
|
||||||
|
fetchRaceHub,
|
||||||
|
fetchSeasons,
|
||||||
|
fetchLocalMeetings,
|
||||||
|
fetchWeekend,
|
||||||
|
fetchWeekendContext,
|
||||||
|
} from '../api'
|
||||||
|
|
||||||
const mockFetchRaceHub = vi.mocked(fetchRaceHub)
|
const mockFetchRaceHub = vi.mocked(fetchRaceHub)
|
||||||
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
||||||
@@ -28,6 +40,8 @@ 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',
|
||||||
@@ -62,6 +76,16 @@ const qualSession: Session = {
|
|||||||
gmt_offset: '02:00:00',
|
gmt_offset: '02:00:00',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const futureSession: Session = {
|
||||||
|
session_key: 9600,
|
||||||
|
session_name: 'Race',
|
||||||
|
session_type: 'Race',
|
||||||
|
meeting_key: 1300,
|
||||||
|
date_start: '2099-05-25T13:00:00+00:00',
|
||||||
|
date_end: '2099-05-25T15:00:00+00:00',
|
||||||
|
gmt_offset: '02:00:00',
|
||||||
|
}
|
||||||
|
|
||||||
const fullDatasets: Record<string, DatasetInfo> = {
|
const fullDatasets: Record<string, DatasetInfo> = {
|
||||||
meeting: { status: 'available', source: 'local', count: 1 },
|
meeting: { status: 'available', source: 'local', count: 1 },
|
||||||
session: { status: 'available', source: 'local', count: 1 },
|
session: { status: 'available', source: 'local', count: 1 },
|
||||||
@@ -172,23 +196,38 @@ const weekend: Weekend = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
const availability: ContextAvailability = {
|
const weekendContext: WeekendContext = {
|
||||||
source: 'local',
|
season: 2025,
|
||||||
|
temporal_state: 'post_weekend',
|
||||||
|
focus_meeting: meeting,
|
||||||
|
previous_meeting: meeting,
|
||||||
|
default_analysis_session: {
|
||||||
|
session: raceSession,
|
||||||
|
meeting,
|
||||||
|
availability: {
|
||||||
schedule: 'available',
|
schedule: 'available',
|
||||||
live_transport: 'unknown',
|
live_transport: 'unknown',
|
||||||
live_session: 'inactive',
|
live_session: 'inactive',
|
||||||
archive: 'unavailable',
|
archive: 'unavailable',
|
||||||
local_analysis: 'complete',
|
local_analysis: 'complete',
|
||||||
freshness: 'local',
|
freshness: 'fresh',
|
||||||
limitations: [],
|
limitations: [],
|
||||||
}
|
},
|
||||||
|
},
|
||||||
const analysisContext: WeekendContext = {
|
previous_completed_session: {
|
||||||
temporal_state: 'between_weekends',
|
session: raceSession,
|
||||||
default_analysis_session: { session: raceSession, meeting, availability },
|
meeting,
|
||||||
race_hub_default_session: { session: raceSession, meeting, availability },
|
availability: {
|
||||||
race_hub_pre_session: false,
|
schedule: 'available',
|
||||||
championship_round: 1,
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +243,19 @@ function renderRaceHub(sessionKey: number) {
|
|||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
const weekendRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/',
|
||||||
|
validateSearch: (search: Record<string, unknown>) => {
|
||||||
|
const meetingKey = Number(search.meeting_key)
|
||||||
|
const sk = Number(search.session_key)
|
||||||
|
return {
|
||||||
|
...(Number.isFinite(meetingKey) && meetingKey > 0 ? { meeting_key: meetingKey } : {}),
|
||||||
|
...(Number.isFinite(sk) && sk > 0 ? { session_key: sk } : {}),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: () => <div data-testid="weekend-stub" />,
|
||||||
|
})
|
||||||
const raceHubRoute = createRoute({
|
const raceHubRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/race-hub',
|
path: '/race-hub',
|
||||||
@@ -216,24 +268,25 @@ 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([raceHubRoute]),
|
routeTree: rootRoute.addChildren([weekendRoute, raceHubRoute]),
|
||||||
history: undefined,
|
history: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
return { queryClient, ...render(<RouterProvider router={router} />) }
|
router.navigate({ to: '/race-hub', search: sessionKey ? { session_key: sessionKey } : {} })
|
||||||
|
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(analysisContext)
|
mockFetchWeekendContext.mockResolvedValue(weekendContext)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -252,9 +305,9 @@ describe('RaceHubPage', () => {
|
|||||||
)
|
)
|
||||||
expect(screen.getByTestId('rh-session-9471')).toBeInTheDocument()
|
expect(screen.getByTestId('rh-session-9471')).toBeInTheDocument()
|
||||||
|
|
||||||
// Overview is default
|
|
||||||
expect(screen.getByTestId('rh-overview')).toBeInTheDocument()
|
expect(screen.getByTestId('rh-overview')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Winner')).toBeInTheDocument()
|
expect(screen.getByText('Winner')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Local Coverage')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('exposes Race Story sub-controls for classification, grid, and positions', async () => {
|
it('exposes Race Story sub-controls for classification, grid, and positions', async () => {
|
||||||
@@ -264,17 +317,43 @@ describe('RaceHubPage', () => {
|
|||||||
fireEvent.click(screen.getByRole('tab', { name: 'Race Story' }))
|
fireEvent.click(screen.getByRole('tab', { name: 'Race Story' }))
|
||||||
|
|
||||||
expect(screen.getByText('VER')).toBeInTheDocument()
|
expect(screen.getByText('VER')).toBeInTheDocument()
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps Data Status accessible and free of inline CLI guidance', async () => {
|
it('keeps Diagnostics accessible behind a secondary action, free of inline CLI guidance', async () => {
|
||||||
renderRaceHub(9472)
|
renderRaceHub(9472)
|
||||||
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('tab', { name: 'Data Status' }))
|
fireEvent.click(screen.getByRole('tab', { name: 'Diagnostics' }))
|
||||||
|
|
||||||
expect(screen.getByTestId('rh-data-status')).toBeInTheDocument()
|
expect(screen.getByTestId('rh-data-status')).toBeInTheDocument()
|
||||||
expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument()
|
expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('rh-dataset-strip')).not.toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByTestId('rh-diagnostics-toggle'))
|
||||||
|
expect(screen.getByTestId('rh-dataset-strip')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render the raw dataset strip before fan-facing content', async () => {
|
||||||
|
renderRaceHub(9472)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
||||||
|
|
||||||
|
expect(screen.getByTestId('rh-overview')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('rh-dataset-strip')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('groups analysis navigation into Story, Analysis, and Data & Context', async () => {
|
||||||
|
renderRaceHub(9472)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
||||||
|
|
||||||
|
expect(screen.getByTestId('rh-tabgroup-story')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('rh-tabgroup-analysis')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('rh-tabgroup-context')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('tab', { name: 'Strategy' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('tab', { name: 'Compare' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('tab', { name: 'Diagnostics' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('toggles the inline weekend switcher', async () => {
|
it('toggles the inline weekend switcher', async () => {
|
||||||
@@ -285,121 +364,213 @@ describe('RaceHubPage', () => {
|
|||||||
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
|
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses the server-selected completed analysis session for bare Race Hub without changing the URL', async () => {
|
it('resolves bare /race-hub through canonical Weekend Context default analysis', async () => {
|
||||||
renderRaceHub(0)
|
|
||||||
|
|
||||||
await waitFor(() => expect(mockFetchRaceHub).toHaveBeenCalledWith(9472))
|
|
||||||
expect(mockFetchWeekendContext).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the intentional pre-session state without analysis cards', async () => {
|
|
||||||
mockFetchWeekendContext.mockResolvedValue({
|
mockFetchWeekendContext.mockResolvedValue({
|
||||||
...analysisContext,
|
...weekendContext,
|
||||||
race_hub_default_session: {
|
default_analysis_session: {
|
||||||
session: { ...raceSession, session_key: 9473, session_name: 'Practice 1', session_type: 'Practice', date_start: '2099-05-23T13:00:00Z' },
|
session: qualSession,
|
||||||
meeting,
|
meeting,
|
||||||
availability,
|
availability: {
|
||||||
|
schedule: 'available',
|
||||||
|
live_transport: 'unknown',
|
||||||
|
live_session: 'inactive',
|
||||||
|
archive: 'unavailable',
|
||||||
|
local_analysis: 'complete',
|
||||||
|
freshness: 'fresh',
|
||||||
|
limitations: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
race_hub_pre_session: true,
|
|
||||||
race_hub_refresh_at: '2099-05-23T13:00:00Z',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
renderRaceHub(0)
|
renderRaceHub(0)
|
||||||
|
|
||||||
expect(await screen.findByTestId('race-hub-pre-session')).toBeInTheDocument()
|
await waitFor(() => expect(mockFetchRaceHub).toHaveBeenCalledWith(9471, expect.any(AbortSignal)))
|
||||||
expect(screen.getByTestId('rh-pre-session-schedule')).toBeInTheDocument()
|
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9600, expect.anything())
|
||||||
|
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9600)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows no-analysis fallback when Weekend Context has no default analysis', async () => {
|
||||||
|
mockFetchWeekendContext.mockResolvedValue({
|
||||||
|
...weekendContext,
|
||||||
|
default_analysis_session: undefined,
|
||||||
|
previous_completed_session: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
renderRaceHub(0)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('race-hub-no-analysis')).toBeInTheDocument())
|
||||||
|
expect(mockFetchRaceHub).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a pre-session view instead of empty analysis for a future session', async () => {
|
||||||
|
mockFetchRaceHub.mockResolvedValue({
|
||||||
|
...raceHub,
|
||||||
|
session_key: 9600,
|
||||||
|
source: 'none',
|
||||||
|
session: futureSession,
|
||||||
|
meeting: { ...meeting, meeting_key: 1300 },
|
||||||
|
results: [],
|
||||||
|
starting_grid: [],
|
||||||
|
datasets: {},
|
||||||
|
})
|
||||||
|
mockFetchWeekend.mockResolvedValue({
|
||||||
|
source: 'none',
|
||||||
|
meeting_key: 1300,
|
||||||
|
meeting: { ...meeting, meeting_key: 1300 },
|
||||||
|
sessions: [{ session: futureSession, source: 'none', datasets: {} }],
|
||||||
|
})
|
||||||
|
|
||||||
|
renderRaceHub(9600)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
||||||
|
expect(await screen.findByTestId('rh-presession')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('rh-overview')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByText('Winner')).not.toBeInTheDocument()
|
expect(screen.queryByText('Winner')).not.toBeInTheDocument()
|
||||||
expect(mockFetchRaceHub).not.toHaveBeenCalled()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens the weekend switcher from pre-session and navigates to the selected explicit session', async () => {
|
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({
|
||||||
...analysisContext,
|
...weekendContext,
|
||||||
race_hub_default_session: {
|
temporal_state: 'session_settling',
|
||||||
session: { ...raceSession, session_key: 9473, session_name: 'Practice 1', session_type: 'Practice', date_start: '2099-05-23T13:00:00Z' },
|
default_analysis_session: undefined,
|
||||||
|
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,
|
|
||||||
race_hub_refresh_at: '2099-05-23T13:00:00Z',
|
|
||||||
})
|
|
||||||
|
|
||||||
renderRaceHub(0)
|
|
||||||
|
|
||||||
const switchWeekend = await screen.findByTestId('rh-switch-weekend')
|
|
||||||
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('shows recovery instead of selecting an empty future session', async () => {
|
|
||||||
mockFetchWeekendContext.mockResolvedValue({
|
|
||||||
...analysisContext,
|
|
||||||
race_hub_default_session: undefined,
|
|
||||||
race_hub_pre_session: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
renderRaceHub(0)
|
|
||||||
|
|
||||||
expect(await screen.findByTestId('race-hub-empty')).toHaveTextContent('No completed local analysis yet')
|
|
||||||
expect(mockFetchRaceHub).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hands a bare route from completed analysis to pre-session at the supplied refresh boundary', async () => {
|
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
|
||||||
const handoff = new Date(Date.now() + 10_000).toISOString()
|
|
||||||
const pendingContext: WeekendContext = {
|
|
||||||
...analysisContext,
|
|
||||||
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(),
|
mockFetchRaceHub.mockResolvedValue({
|
||||||
}
|
...raceHub,
|
||||||
mockFetchWeekendContext
|
source: 'none',
|
||||||
.mockResolvedValueOnce({ ...analysisContext, race_hub_refresh_at: handoff })
|
results: [],
|
||||||
.mockResolvedValueOnce(pendingContext)
|
starting_grid: [],
|
||||||
|
datasets: {},
|
||||||
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({
|
|
||||||
...analysisContext,
|
|
||||||
race_hub_refresh_at: new Date(Date.now() + MAX_BROWSER_TIMEOUT + 1_000).toISOString(),
|
|
||||||
})
|
|
||||||
|
|
||||||
renderRaceHub(0)
|
|
||||||
await screen.findByTestId('race-hub')
|
|
||||||
await act(async () => { await vi.advanceTimersByTimeAsync(MAX_BROWSER_TIMEOUT) })
|
|
||||||
|
|
||||||
await waitFor(() => expect(mockFetchWeekendContext).toHaveBeenCalledTimes(2))
|
|
||||||
await act(async () => { await vi.advanceTimersByTimeAsync(1_000) })
|
|
||||||
await waitFor(() => expect(mockFetchWeekendContext).toHaveBeenCalledTimes(3))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps an explicit session URL stable across the canonical refresh boundary', async () => {
|
|
||||||
vi.useFakeTimers()
|
|
||||||
renderRaceHub(9472)
|
renderRaceHub(9472)
|
||||||
|
|
||||||
await act(async () => { await vi.advanceTimersByTimeAsync(60_000) })
|
await waitFor(() => expect(screen.getByTestId('rh-preparing')).toBeInTheDocument())
|
||||||
|
expect(screen.queryByTestId('rh-overview')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
expect(mockFetchWeekendContext).not.toHaveBeenCalled()
|
it('renders unavailable distinctly from request errors', async () => {
|
||||||
expect(mockFetchRaceHub).toHaveBeenCalledWith(9472)
|
mockFetchWeekend.mockResolvedValue({
|
||||||
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9473)
|
...weekend,
|
||||||
|
sessions: [{ session: raceSession, source: 'none', datasets: {} }],
|
||||||
|
})
|
||||||
|
mockFetchWeekendContext.mockResolvedValue({
|
||||||
|
...weekendContext,
|
||||||
|
previous_completed_session: {
|
||||||
|
session: raceSession,
|
||||||
|
meeting,
|
||||||
|
availability: {
|
||||||
|
schedule: 'available',
|
||||||
|
live_transport: 'unknown',
|
||||||
|
live_session: 'inactive',
|
||||||
|
archive: 'unavailable',
|
||||||
|
local_analysis: 'unavailable',
|
||||||
|
freshness: 'stale',
|
||||||
|
limitations: ['analysis_blocked'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default_analysis_session: {
|
||||||
|
session: raceSession,
|
||||||
|
meeting,
|
||||||
|
availability: {
|
||||||
|
schedule: 'available',
|
||||||
|
live_transport: 'unknown',
|
||||||
|
live_session: 'inactive',
|
||||||
|
archive: 'unavailable',
|
||||||
|
local_analysis: 'unavailable',
|
||||||
|
freshness: 'stale',
|
||||||
|
limitations: ['analysis_blocked'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
mockFetchRaceHub.mockResolvedValue({
|
||||||
|
...raceHub,
|
||||||
|
source: 'none',
|
||||||
|
results: [],
|
||||||
|
datasets: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
renderRaceHub(9472)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('rh-unavailable')).toBeInTheDocument())
|
||||||
|
expect(screen.queryByTestId('race-hub-error')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('labels a completed but partial session as Partial and keeps analysis', async () => {
|
||||||
|
mockFetchWeekend.mockResolvedValue({
|
||||||
|
...weekend,
|
||||||
|
sessions: [
|
||||||
|
{ session: qualSession, source: 'local', datasets: fullDatasets },
|
||||||
|
{ session: raceSession, source: 'partial', datasets: { drivers: fullDatasets.drivers } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
mockFetchWeekendContext.mockResolvedValue({
|
||||||
|
...weekendContext,
|
||||||
|
default_analysis_session: {
|
||||||
|
session: raceSession,
|
||||||
|
meeting,
|
||||||
|
availability: {
|
||||||
|
schedule: 'available',
|
||||||
|
live_transport: 'unknown',
|
||||||
|
live_session: 'inactive',
|
||||||
|
archive: 'unavailable',
|
||||||
|
local_analysis: 'partial',
|
||||||
|
freshness: 'fresh',
|
||||||
|
limitations: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
renderRaceHub(9472)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('rh-active-state')).toBeInTheDocument())
|
||||||
|
expect(screen.getByTestId('rh-active-state')).toHaveTextContent('Partial')
|
||||||
|
expect(screen.getByTestId('rh-partial-banner')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('rh-overview')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers retry and back-to-Weekend on an error', async () => {
|
||||||
|
mockFetchRaceHub.mockRejectedValue(new Error('boom'))
|
||||||
|
|
||||||
|
renderRaceHub(9472)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('race-hub-error')).toBeInTheDocument())
|
||||||
|
expect(screen.getByTestId('rh-retry')).toBeInTheDocument()
|
||||||
|
const back = screen.getByTestId('rh-back-weekend')
|
||||||
|
expect(back).toHaveAttribute('href', expect.stringMatching(/meeting_key=1229/))
|
||||||
|
expect(back).toHaveAttribute('href', expect.stringMatching(/session_key=9472/))
|
||||||
|
expect(back.getAttribute('href')).toMatch(/^\//)
|
||||||
|
|
||||||
|
mockFetchRaceHub.mockResolvedValue(raceHub)
|
||||||
|
fireEvent.click(screen.getByTestId('rh-retry'))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exposes a Back to Weekend link that carries meeting and session context', async () => {
|
||||||
|
renderRaceHub(9472)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
||||||
|
const back = screen.getByRole('link', { name: 'Back to Weekend' })
|
||||||
|
expect(back).toHaveAttribute('data-testid', 'rh-back-weekend')
|
||||||
|
expect(back).toHaveAttribute('href', expect.stringMatching(/meeting_key=1229/))
|
||||||
|
expect(back).toHaveAttribute('href', expect.stringMatching(/session_key=9472/))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
|
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
|
||||||
|
import { rememberResponseAvailability } from '../lib/fetch'
|
||||||
import { RacePreviewPage } from '../pages/RacePreviewPage'
|
import { RacePreviewPage } from '../pages/RacePreviewPage'
|
||||||
|
import { ApiError } from '../lib/fetch'
|
||||||
import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session, TrackOutline } from '../types'
|
import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session, TrackOutline } from '../types'
|
||||||
|
|
||||||
vi.mock('../api', () => ({
|
vi.mock('../api', () => ({
|
||||||
@@ -174,16 +176,22 @@ const outline: TrackOutline = {
|
|||||||
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
|
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPage() {
|
function renderPage(props?: {
|
||||||
|
meeting?: Meeting
|
||||||
|
season?: number
|
||||||
|
embedded?: boolean
|
||||||
|
shellAvailability?: 'local' | 'partial' | 'stale' | 'archive' | 'limited' | 'missing' | null
|
||||||
|
}) {
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
const Page = () => <RacePreviewPage {...props} />
|
||||||
const rootRoute = createRootRoute({
|
const rootRoute = createRootRoute({
|
||||||
component: () => (
|
component: () => (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<RacePreviewPage />
|
<Page />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: RacePreviewPage })
|
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: Page })
|
||||||
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
|
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
|
||||||
return render(<RouterProvider router={router} />)
|
return render(<RouterProvider router={router} />)
|
||||||
}
|
}
|
||||||
@@ -274,4 +282,145 @@ describe('RacePreviewPage', () => {
|
|||||||
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('First time on the calendar')
|
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('First time on the calendar')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses canonical meeting identity when embedded and skips seasons/meetings selection', async () => {
|
||||||
|
mockFetchSessions.mockImplementation(async (meetingKey: number) => {
|
||||||
|
if (meetingKey === 100) return sessions
|
||||||
|
if (meetingKey === 90) return [priorRaceSession]
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
mockFetchMeetings.mockImplementation(async (year: number) => {
|
||||||
|
if (year === 2098) return [priorMeeting]
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
renderPage({ meeting: upcomingMeeting, season: 2099, embedded: true, shellAvailability: 'partial' })
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('preview-page')).toBeInTheDocument())
|
||||||
|
expect(screen.getByTestId('preview-page')).toHaveAttribute('data-meeting-key', '100')
|
||||||
|
expect(screen.getByTestId('preview-page')).toHaveAttribute('data-embedded', 'true')
|
||||||
|
expect(mockFetchSeasons).not.toHaveBeenCalled()
|
||||||
|
// Current-year meetings selection is skipped; prior-year lookup may still run.
|
||||||
|
expect(mockFetchMeetings).not.toHaveBeenCalledWith(2099, expect.anything(), expect.anything())
|
||||||
|
expect(mockFetchSessions).toHaveBeenCalledWith(100, 'auto', expect.anything())
|
||||||
|
expect(screen.queryByTestId('preview-header')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dedupes an equivalent shell notice but shows distinct Preview freshness', async () => {
|
||||||
|
mockFetchSessions.mockImplementation(async (meetingKey: number) => {
|
||||||
|
if (meetingKey === 100) return sessions
|
||||||
|
if (meetingKey === 90) return [priorRaceSession]
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
mockFetchMeetings.mockImplementation(async (year: number) => {
|
||||||
|
if (year === 2098) return [priorMeeting]
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const staleHub = { ...hub }
|
||||||
|
rememberResponseAvailability(staleHub, { source: 'openf1', freshness: 'stale' })
|
||||||
|
mockFetchChampionshipHub.mockResolvedValue(staleHub)
|
||||||
|
|
||||||
|
const distinct = renderPage({
|
||||||
|
meeting: upcomingMeeting,
|
||||||
|
season: 2099,
|
||||||
|
embedded: true,
|
||||||
|
shellAvailability: 'partial',
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(screen.getByTestId('preview-data-notice')).toHaveTextContent(/Stale/i))
|
||||||
|
distinct.unmount()
|
||||||
|
|
||||||
|
const partialHub = { ...hub }
|
||||||
|
rememberResponseAvailability(partialHub, { source: 'openf1', freshness: 'partial' })
|
||||||
|
mockFetchChampionshipHub.mockResolvedValue(partialHub)
|
||||||
|
|
||||||
|
renderPage({
|
||||||
|
meeting: upcomingMeeting,
|
||||||
|
season: 2099,
|
||||||
|
embedded: true,
|
||||||
|
shellAvailability: 'partial',
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(screen.getByTestId('preview-page')).toBeInTheDocument())
|
||||||
|
expect(screen.queryByTestId('preview-data-notice')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves Stale/Partial over routine Championship Local from conflicting sources', async () => {
|
||||||
|
mockFetchSessions.mockImplementation(async (meetingKey: number) => {
|
||||||
|
if (meetingKey === 100) {
|
||||||
|
const payload = [...sessions]
|
||||||
|
rememberResponseAvailability(payload, { source: 'openf1', freshness: 'stale' })
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
if (meetingKey === 90) return [priorRaceSession]
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
mockFetchMeetings.mockImplementation(async (year: number) => {
|
||||||
|
if (year === 2098) return [priorMeeting]
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const localHub = { ...hub }
|
||||||
|
rememberResponseAvailability(localHub, { source: 'local', freshness: 'local' })
|
||||||
|
mockFetchChampionshipHub.mockResolvedValue(localHub)
|
||||||
|
|
||||||
|
renderPage({ meeting: upcomingMeeting, season: 2099 })
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('preview-data-notice')).toHaveTextContent(/Stale/i))
|
||||||
|
expect(screen.getByTestId('preview-title-fight-card')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('preview-schedule')).toHaveTextContent('FP1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows sanitized sessions failure with Retry that refetches once', async () => {
|
||||||
|
let sessionCalls = 0
|
||||||
|
mockFetchSessions.mockImplementation(async (meetingKey: number) => {
|
||||||
|
if (meetingKey !== 100) return [priorRaceSession]
|
||||||
|
sessionCalls += 1
|
||||||
|
if (sessionCalls === 1) {
|
||||||
|
throw new ApiError('http', 'API 500: sessions boom', { status: 500 })
|
||||||
|
}
|
||||||
|
return sessions
|
||||||
|
})
|
||||||
|
mockFetchMeetings.mockImplementation(async (year: number) => {
|
||||||
|
if (year === 2098) return [priorMeeting]
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
renderPage({ meeting: upcomingMeeting, season: 2099, embedded: true })
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('preview-sessions-error')).toBeInTheDocument())
|
||||||
|
expect(screen.getByTestId('preview-sessions-error')).not.toHaveTextContent(/API 500|sessions boom/i)
|
||||||
|
expect(screen.getByTestId('preview-page')).toBeInTheDocument()
|
||||||
|
|
||||||
|
const retry = screen.getByTestId('preview-sessions-retry')
|
||||||
|
fireEvent.click(retry)
|
||||||
|
fireEvent.click(retry)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('preview-schedule')).toHaveTextContent('FP1'))
|
||||||
|
expect(sessionCalls).toBe(2)
|
||||||
|
expect(screen.queryByTestId('preview-sessions-error')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sanitizes raw HTTP errors and offers a guarded keyboard Retry', async () => {
|
||||||
|
mockFetchSeasons.mockRejectedValue(
|
||||||
|
new ApiError('http', 'API 500: Internal Server Error', { status: 500 }),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('preview-error')).toBeInTheDocument())
|
||||||
|
expect(screen.getByTestId('preview-error')).not.toHaveTextContent(/API 500|Internal Server Error/i)
|
||||||
|
const retry = screen.getByRole('button', { name: 'Retry' })
|
||||||
|
expect(retry).toBeEnabled()
|
||||||
|
|
||||||
|
mockFetchSeasons.mockResolvedValue([2099])
|
||||||
|
mockFetchMeetings.mockResolvedValue([upcomingMeeting])
|
||||||
|
mockFetchSessions.mockResolvedValue(sessions)
|
||||||
|
|
||||||
|
fireEvent.click(retry)
|
||||||
|
fireEvent.click(retry)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('preview-page')).toBeInTheDocument())
|
||||||
|
// Guarded: one in-flight refetch despite double click while fetching.
|
||||||
|
expect(mockFetchSeasons.mock.calls.length).toBeGreaterThanOrEqual(2)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -278,6 +278,15 @@ describe('RaceStoryCanvas replay map', () => {
|
|||||||
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
|
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('exposes the final running order outcome region with winner position', () => {
|
||||||
|
renderCanvas()
|
||||||
|
|
||||||
|
const outcome = screen.getByTestId('race-story-outcome')
|
||||||
|
expect(outcome).toHaveAttribute('aria-label', 'Final running order')
|
||||||
|
expect(outcome).toHaveTextContent('VER')
|
||||||
|
expect(outcome.querySelector('.rs-pos-col')).toHaveTextContent('1')
|
||||||
|
})
|
||||||
|
|
||||||
it('renders the empty-state card when positions are unavailable', () => {
|
it('renders the empty-state card when positions are unavailable', () => {
|
||||||
renderCanvas({
|
renderCanvas({
|
||||||
datasets: { positions: { status: 'missing', source: 'local', count: 0 } },
|
datasets: { positions: { status: 'missing', source: 'local', count: 0 } },
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { render, screen } from '@testing-library/react'
|
|
||||||
import { SessionBanner } from '../components/live/SessionBanner'
|
|
||||||
import type { LiveStreamData } from '../types'
|
|
||||||
|
|
||||||
function makeSnapshot(sessionType: string, sessionName: string): LiveStreamData {
|
|
||||||
return {
|
|
||||||
Drivers: {},
|
|
||||||
DriverInfo: {},
|
|
||||||
Tyres: {},
|
|
||||||
RCMessages: [],
|
|
||||||
Weather: { AirTemp: 0, TrackTemp: 0, Humidity: 0, WindSpeed: 0, WindDir: 0, Rainfall: false },
|
|
||||||
Session: {
|
|
||||||
MeetingName: 'Belgian Grand Prix',
|
|
||||||
CircuitName: 'Spa-Francorchamps',
|
|
||||||
SessionType: sessionType,
|
|
||||||
SessionName: sessionName,
|
|
||||||
Path: '',
|
|
||||||
},
|
|
||||||
TeamRadio: [],
|
|
||||||
TrackStatus: '1',
|
|
||||||
CurrentLap: 0,
|
|
||||||
TotalLaps: 0,
|
|
||||||
Clock: '00:45:00',
|
|
||||||
ClockRefTime: '',
|
|
||||||
ClockExtrapolating: false,
|
|
||||||
Stints: {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('SessionBanner', () => {
|
|
||||||
it('never renders a race lap counter for a practice session', () => {
|
|
||||||
render(
|
|
||||||
<SessionBanner
|
|
||||||
isLive
|
|
||||||
snapshot={makeSnapshot('Practice', 'Practice 2')}
|
|
||||||
rows={[]}
|
|
||||||
connection="connected"
|
|
||||||
now={0}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
expect(screen.queryByTestId('live-lap-counter')).not.toBeInTheDocument()
|
|
||||||
// Session identity and clock stay intact.
|
|
||||||
expect(screen.getByText('Belgian Grand Prix')).toBeInTheDocument()
|
|
||||||
expect(screen.getByTestId('live-clock')).toHaveTextContent('00:45:00')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the lap counter for a race session', () => {
|
|
||||||
const snapshot = { ...makeSnapshot('Race', 'Race'), CurrentLap: 12, TotalLaps: 44 }
|
|
||||||
render(
|
|
||||||
<SessionBanner
|
|
||||||
isLive
|
|
||||||
snapshot={snapshot}
|
|
||||||
rows={[]}
|
|
||||||
connection="connected"
|
|
||||||
now={0}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
expect(screen.getByTestId('live-lap-counter')).toHaveTextContent('L12/44')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -3,7 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react'
|
|||||||
import { TabBar } from '../components/TabBar'
|
import { TabBar } from '../components/TabBar'
|
||||||
|
|
||||||
describe('TabBar', () => {
|
describe('TabBar', () => {
|
||||||
it('renders all Race Hub workspace tabs', () => {
|
it('renders all Race Hub workspace tabs grouped into a hierarchy', () => {
|
||||||
render(<TabBar active="overview" onChange={() => {}} />)
|
render(<TabBar active="overview" onChange={() => {}} />)
|
||||||
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Race Story' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Race Story' })).toBeInTheDocument()
|
||||||
@@ -12,7 +12,11 @@ describe('TabBar', () => {
|
|||||||
expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Conditions' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Conditions' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Data Status' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Diagnostics' })).toBeInTheDocument()
|
||||||
|
|
||||||
|
expect(screen.getByTestId('rh-tabgroup-story')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('rh-tabgroup-analysis')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('rh-tabgroup-context')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('marks the active tab with aria-selected', () => {
|
it('marks the active tab with aria-selected', () => {
|
||||||
|
|||||||
@@ -47,20 +47,9 @@ function snapshotRows(lap: number, lastLapTime: string): LiveTimingRow[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('TyreDegPanel', () => {
|
describe('TyreDegPanel', () => {
|
||||||
it('stays collapsed while every stint is still warming up', () => {
|
it('shows a warming-up placeholder until enough clean laps accumulate', () => {
|
||||||
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
|
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
|
||||||
const panel = screen.getByTestId('tyredeg-panel')
|
const panel = screen.getByTestId('tyredeg-panel')
|
||||||
|
|
||||||
// A panel of "warming up" placeholders carries no information and used to
|
|
||||||
// push the Timing Tower off the fold for the first third of a race.
|
|
||||||
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
|
||||||
expect(panel).toHaveTextContent('collecting clean laps')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows a warming-up placeholder on each row once expanded', () => {
|
|
||||||
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
|
||||||
const panel = screen.getByTestId('tyredeg-panel')
|
|
||||||
expect(panel).toHaveTextContent('VER')
|
expect(panel).toHaveTextContent('VER')
|
||||||
expect(panel).toHaveTextContent('M +5')
|
expect(panel).toHaveTextContent('M +5')
|
||||||
expect(panel).toHaveTextContent('fresh')
|
expect(panel).toHaveTextContent('fresh')
|
||||||
@@ -72,7 +61,6 @@ describe('TyreDegPanel', () => {
|
|||||||
makeRow('1', 1, 'VER', { NumberOfLaps: 10, LastLapTime: '1:30.000' }, { Compound: 'MEDIUM', Age: 12 }),
|
makeRow('1', 1, 'VER', { NumberOfLaps: 10, LastLapTime: '1:30.000' }, { Compound: 'MEDIUM', Age: 12 }),
|
||||||
]
|
]
|
||||||
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={[]} />)
|
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={[]} />)
|
||||||
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
|
||||||
expect(screen.getByText('mid-life')).toBeInTheDocument()
|
expect(screen.getByText('mid-life')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -93,46 +81,13 @@ describe('TyreDegPanel', () => {
|
|||||||
expect(panel).toHaveTextContent('→ ~P2')
|
expect(panel).toHaveTextContent('→ ~P2')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('collapses by default outside race sessions and hides the rejoin estimate when expanded', () => {
|
it('hides the rejoin estimate outside race sessions and collapses on toggle', () => {
|
||||||
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Qualifying" pinned={[]} />)
|
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Qualifying" pinned={[]} />)
|
||||||
const panel = screen.getByTestId('tyredeg-panel')
|
const panel = screen.getByTestId('tyredeg-panel')
|
||||||
|
|
||||||
// Practice/qualifying starts collapsed so the Timing Tower stays above the fold.
|
|
||||||
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
|
||||||
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
|
|
||||||
expect(panel).not.toHaveTextContent('~P')
|
expect(panel).not.toHaveTextContent('~P')
|
||||||
})
|
|
||||||
|
|
||||||
it('opens itself during a race as soon as a stint has signal', () => {
|
|
||||||
const { rerender } = render(
|
|
||||||
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
|
|
||||||
)
|
|
||||||
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
|
||||||
|
|
||||||
for (let lap = 2; lap <= 6; lap++) {
|
|
||||||
const time = `1:30.${String((lap - 1) * 100).padStart(3, '0')}`
|
|
||||||
rerender(<TyreDegPanel rows={snapshotRows(lap, time)} sessionType="Race" pinned={[]} />)
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
|
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
|
||||||
})
|
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
||||||
|
|
||||||
it('keeps the reader\'s own collapse choice when signal arrives', () => {
|
|
||||||
const { rerender } = render(
|
|
||||||
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
|
|
||||||
)
|
|
||||||
// Reader opens it early, then closes it again — that decision must stick
|
|
||||||
// even once the panel would otherwise auto-open.
|
|
||||||
const toggle = screen.getByRole('button', { name: /tyre deg/i })
|
|
||||||
fireEvent.click(toggle)
|
|
||||||
fireEvent.click(toggle)
|
|
||||||
|
|
||||||
for (let lap = 2; lap <= 6; lap++) {
|
|
||||||
const time = `1:30.${String((lap - 1) * 100).padStart(3, '0')}`
|
|
||||||
rerender(<TyreDegPanel rows={snapshotRows(lap, time)} sessionType="Race" pinned={[]} />)
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -141,7 +96,6 @@ describe('TyreDegPanel', () => {
|
|||||||
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
||||||
)
|
)
|
||||||
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={['14']} />)
|
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={['14']} />)
|
||||||
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
|
||||||
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(11)
|
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(11)
|
||||||
expect(screen.getByText('D14')).toBeInTheDocument()
|
expect(screen.getByText('D14')).toBeInTheDocument()
|
||||||
expect(screen.queryByText('D12')).not.toBeInTheDocument()
|
expect(screen.queryByText('D12')).not.toBeInTheDocument()
|
||||||
|
|||||||
651
frontend/src/test/WeekendPage.test.tsx
Normal file
651
frontend/src/test/WeekendPage.test.tsx
Normal file
@@ -0,0 +1,651 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Outlet,
|
||||||
|
RouterProvider,
|
||||||
|
createRouter,
|
||||||
|
createRootRoute,
|
||||||
|
createRoute,
|
||||||
|
createMemoryHistory,
|
||||||
|
} from '@tanstack/react-router'
|
||||||
|
import { WeekendPage } from '../pages/WeekendPage'
|
||||||
|
import type {
|
||||||
|
ChampionshipHub,
|
||||||
|
ContextAvailability,
|
||||||
|
ContextSession,
|
||||||
|
Meeting,
|
||||||
|
RaceHub,
|
||||||
|
Session,
|
||||||
|
TemporalState,
|
||||||
|
WeekendContext,
|
||||||
|
} from '../types'
|
||||||
|
import { parseWeekendSearch } from '../lib/routeSearch'
|
||||||
|
|
||||||
|
vi.mock('../api', () => ({
|
||||||
|
fetchWeekendContext: vi.fn(),
|
||||||
|
fetchChampionshipHub: vi.fn(),
|
||||||
|
fetchNews: vi.fn(),
|
||||||
|
fetchRaceHub: vi.fn(),
|
||||||
|
fetchWeekend: vi.fn(),
|
||||||
|
// Consumed transitively by RacePreviewPage (folded into PreSessionView):
|
||||||
|
fetchSeasons: vi.fn(),
|
||||||
|
fetchMeetings: vi.fn(),
|
||||||
|
fetchSessions: vi.fn(),
|
||||||
|
fetchResults: vi.fn(),
|
||||||
|
fetchStartingGrid: vi.fn(),
|
||||||
|
fetchTrackOutline: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchWeekendContext,
|
||||||
|
fetchChampionshipHub,
|
||||||
|
fetchNews,
|
||||||
|
fetchRaceHub,
|
||||||
|
fetchWeekend,
|
||||||
|
fetchSeasons,
|
||||||
|
fetchMeetings,
|
||||||
|
fetchSessions,
|
||||||
|
fetchResults,
|
||||||
|
fetchStartingGrid,
|
||||||
|
fetchTrackOutline,
|
||||||
|
} from '../api'
|
||||||
|
import { rememberResponseAvailability } from '../lib/fetch'
|
||||||
|
|
||||||
|
const mockContext = vi.mocked(fetchWeekendContext)
|
||||||
|
const mockHub = vi.mocked(fetchChampionshipHub)
|
||||||
|
const mockNews = vi.mocked(fetchNews)
|
||||||
|
const mockRaceHub = vi.mocked(fetchRaceHub)
|
||||||
|
const mockWeekend = vi.mocked(fetchWeekend)
|
||||||
|
const mockSeasons = vi.mocked(fetchSeasons)
|
||||||
|
const mockMeetings = vi.mocked(fetchMeetings)
|
||||||
|
const mockSessions = vi.mocked(fetchSessions)
|
||||||
|
const mockResults = vi.mocked(fetchResults)
|
||||||
|
const mockGrid = vi.mocked(fetchStartingGrid)
|
||||||
|
const mockTrack = vi.mocked(fetchTrackOutline)
|
||||||
|
|
||||||
|
function availability(overrides: Partial<ContextAvailability> = {}): ContextAvailability {
|
||||||
|
return {
|
||||||
|
schedule: 'available',
|
||||||
|
live_transport: 'unknown',
|
||||||
|
live_session: 'inactive',
|
||||||
|
archive: 'unavailable',
|
||||||
|
local_analysis: 'complete',
|
||||||
|
freshness: 'fresh',
|
||||||
|
limitations: [],
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function meeting(overrides: Partial<Meeting> = {}): Meeting {
|
||||||
|
return {
|
||||||
|
meeting_key: 1,
|
||||||
|
meeting_name: 'British Grand Prix',
|
||||||
|
meeting_official_name: 'FORMULA 1 BRITISH GRAND PRIX',
|
||||||
|
location: 'Silverstone',
|
||||||
|
country_name: 'United Kingdom',
|
||||||
|
country_code: 'GBR',
|
||||||
|
country_flag: '',
|
||||||
|
circuit_key: 2,
|
||||||
|
circuit_short_name: 'Silverstone',
|
||||||
|
date_start: '2026-07-03T09:00:00Z',
|
||||||
|
date_end: '2026-07-05T16:00:00Z',
|
||||||
|
year: 2026,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function session(overrides: Partial<Session> = {}): Session {
|
||||||
|
return {
|
||||||
|
session_key: 11,
|
||||||
|
session_name: 'Race',
|
||||||
|
session_type: 'Race',
|
||||||
|
meeting_key: 1,
|
||||||
|
date_start: '2026-07-05T14:00:00Z',
|
||||||
|
date_end: '2026-07-05T16:00:00Z',
|
||||||
|
gmt_offset: '',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ctxSession(overrides: Partial<ContextSession> = {}): ContextSession {
|
||||||
|
return {
|
||||||
|
session: session(),
|
||||||
|
meeting: meeting(),
|
||||||
|
availability: availability(),
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function context(overrides: Partial<WeekendContext> = {}): WeekendContext {
|
||||||
|
return {
|
||||||
|
season: 2026,
|
||||||
|
temporal_state: 'between_weekends',
|
||||||
|
championship_round: 5,
|
||||||
|
total_championship_rounds: 24,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hub: ChampionshipHub = {
|
||||||
|
season: 2026,
|
||||||
|
round: 5,
|
||||||
|
total_rounds: 24,
|
||||||
|
rounds_left: 19,
|
||||||
|
last_race: 'British GP',
|
||||||
|
round_labels: [],
|
||||||
|
drivers: [
|
||||||
|
{ driver_number: 1, name_acronym: 'VER', full_name: 'Max', team_name: 'RB', team_colour: '3671c6', points: 120, position: 1, wins: 4, podiums: 5, poles: 3, form: [], cumulative: [95, 120], teammate_wins: 0, teammate_losses: 0, round_positions: [] },
|
||||||
|
],
|
||||||
|
teams: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const raceHub = {
|
||||||
|
session_key: 11,
|
||||||
|
results: [
|
||||||
|
{ driver_number: 1, position: 1, name_acronym: 'VER', full_name: 'Max', team_name: 'RB', team_colour: '3671c6', dnf: false, dns: false, dsq: false, duration: 5400, gap_to_leader: null },
|
||||||
|
],
|
||||||
|
} as unknown as RaceHub
|
||||||
|
|
||||||
|
function renderAt(path: string) {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
|
||||||
|
const rootRoute = createRootRoute({
|
||||||
|
component: () => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Outlet />
|
||||||
|
</QueryClientProvider>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
const homeRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/',
|
||||||
|
validateSearch: (s: Record<string, unknown>) => parseWeekendSearch(s),
|
||||||
|
component: function HomeRoute() {
|
||||||
|
const { meeting_key, session_key } = homeRoute.useSearch()
|
||||||
|
return <WeekendPage focusMeetingKey={meeting_key} focusSessionKey={session_key} />
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const previewRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/preview',
|
||||||
|
component: () => <WeekendPage preview />,
|
||||||
|
})
|
||||||
|
const raceHubRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/race-hub',
|
||||||
|
validateSearch: (s: Record<string, unknown>) => {
|
||||||
|
const sk = Number(s.session_key)
|
||||||
|
return Number.isFinite(sk) && sk > 0 ? { session_key: sk } : {}
|
||||||
|
},
|
||||||
|
component: () => <div data-testid="race-hub-stub" />,
|
||||||
|
})
|
||||||
|
const stub = (p: string, id: string) =>
|
||||||
|
createRoute({ getParentRoute: () => rootRoute, path: p, component: () => <div data-testid={id} /> })
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree: rootRoute.addChildren([
|
||||||
|
homeRoute,
|
||||||
|
previewRoute,
|
||||||
|
raceHubRoute,
|
||||||
|
stub('/live', 'live-stub'),
|
||||||
|
stub('/explore', 'explore-stub'),
|
||||||
|
stub('/championship', 'championship-stub'),
|
||||||
|
stub('/briefing', 'briefing-stub'),
|
||||||
|
]),
|
||||||
|
history: createMemoryHistory({ initialEntries: [path] }),
|
||||||
|
})
|
||||||
|
return render(<RouterProvider router={router} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WeekendPage canonical contract rendering', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.setSystemTime(new Date('2026-07-08T12:00:00Z'))
|
||||||
|
mockHub.mockResolvedValue(hub)
|
||||||
|
mockNews.mockResolvedValue([])
|
||||||
|
mockRaceHub.mockResolvedValue(raceHub)
|
||||||
|
mockWeekend.mockResolvedValue({
|
||||||
|
source: 'local',
|
||||||
|
meeting_key: 1,
|
||||||
|
meeting: meeting(),
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
session: session({ session_key: 11, session_name: 'Race' }),
|
||||||
|
source: 'local',
|
||||||
|
datasets: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
default_session_key: 11,
|
||||||
|
})
|
||||||
|
mockSeasons.mockResolvedValue([2026])
|
||||||
|
mockMeetings.mockResolvedValue([meeting()])
|
||||||
|
mockSessions.mockResolvedValue([session()])
|
||||||
|
mockResults.mockResolvedValue([])
|
||||||
|
mockGrid.mockResolvedValue([])
|
||||||
|
mockTrack.mockResolvedValue(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the loading surface before the canonical context resolves', async () => {
|
||||||
|
mockContext.mockReturnValue(new Promise(() => {}))
|
||||||
|
renderAt('/')
|
||||||
|
await waitFor(() => expect(screen.getByTestId('weekend-loading')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an explicit error surface when the canonical endpoint fails', async () => {
|
||||||
|
mockContext.mockRejectedValue(new Error('API 500: boom'))
|
||||||
|
renderAt('/')
|
||||||
|
await waitFor(() => expect(screen.getByTestId('weekend-error')).toBeInTheDocument())
|
||||||
|
// Sanitized — never leak raw HTTP status/body into the Weekend surface.
|
||||||
|
expect(screen.getByTestId('weekend-error')).not.toHaveTextContent(/API 500|boom/i)
|
||||||
|
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retries the canonical read and recovers into a temporal state', async () => {
|
||||||
|
let calls = 0
|
||||||
|
mockContext.mockImplementation(async () => {
|
||||||
|
calls += 1
|
||||||
|
if (calls === 1) throw new Error('API 503: unavailable')
|
||||||
|
return context({ temporal_state: 'between_weekends' })
|
||||||
|
})
|
||||||
|
renderAt('/')
|
||||||
|
await waitFor(() => expect(screen.getByTestId('weekend-error')).toBeInTheDocument())
|
||||||
|
const retry = screen.getByRole('button', { name: 'Retry' })
|
||||||
|
fireEvent.click(retry)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('weekend-between-races')).toBeInTheDocument())
|
||||||
|
expect(calls).toBe(2)
|
||||||
|
expect(screen.getByTestId('weekend-page')).toHaveAttribute(
|
||||||
|
'data-temporal-state',
|
||||||
|
'between_weekends',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('failed canonical read creates zero seasons/meetings/weekend/live-state fanout', async () => {
|
||||||
|
mockContext.mockRejectedValue(new Error('API 503: unavailable'))
|
||||||
|
renderAt('/')
|
||||||
|
await waitFor(() => expect(screen.getByTestId('weekend-error')).toBeInTheDocument())
|
||||||
|
expect(mockSeasons).not.toHaveBeenCalled()
|
||||||
|
expect(mockMeetings).not.toHaveBeenCalled()
|
||||||
|
expect(mockWeekend).not.toHaveBeenCalled()
|
||||||
|
expect(mockHub).not.toHaveBeenCalled()
|
||||||
|
expect(mockNews).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
const stateCases: Array<[TemporalState, string]> = [
|
||||||
|
['between_weekends', 'weekend-between-races'],
|
||||||
|
['post_weekend', 'weekend-between-races'],
|
||||||
|
['season_complete', 'weekend-between-races'],
|
||||||
|
['pre_session', 'weekend-pre-session'],
|
||||||
|
['between_sessions', 'weekend-between-sessions'],
|
||||||
|
['session_settling', 'weekend-between-sessions'],
|
||||||
|
['session_live', 'weekend-live'],
|
||||||
|
]
|
||||||
|
|
||||||
|
it.each(stateCases)('renders a designed surface for canonical temporal_state %s', async (temporal, testid) => {
|
||||||
|
mockContext.mockResolvedValue(
|
||||||
|
context({
|
||||||
|
temporal_state: temporal,
|
||||||
|
previous_completed_session: ctxSession({ session: session({ session_key: 11, session_name: 'Race' }) }),
|
||||||
|
default_analysis_session: ctxSession({ session: session({ session_key: 11 }) }),
|
||||||
|
next_meeting: meeting({ meeting_key: 2, meeting_name: 'Hungarian Grand Prix', date_start: '2026-07-24T09:00:00Z' }),
|
||||||
|
next_session: ctxSession({ session: session({ session_key: 21, session_name: 'Practice 1', date_start: '2026-07-24T09:00:00Z' }) }),
|
||||||
|
active_session: temporal === 'session_live'
|
||||||
|
? ctxSession({ session: session({ session_key: 12, session_name: 'Race' }), availability: availability({ live_session: 'active' }) })
|
||||||
|
: undefined,
|
||||||
|
focus_meeting: meeting(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
renderAt('/')
|
||||||
|
await waitFor(() => expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-temporal-state', temporal))
|
||||||
|
expect(screen.getByTestId(testid)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the limited surface for no_season and never falls through to it for a valid payload', async () => {
|
||||||
|
mockContext.mockResolvedValue(context({ temporal_state: 'no_season', championship_round: 0, total_championship_rounds: 0 }))
|
||||||
|
renderAt('/')
|
||||||
|
await waitFor(() => expect(screen.getByTestId('weekend-limited')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('between-races pairs the completed analysis CTA with the next-event countdown and championship impact', async () => {
|
||||||
|
mockContext.mockResolvedValue(
|
||||||
|
context({
|
||||||
|
previous_completed_session: ctxSession({ session: session({ session_key: 11, session_name: 'Race' }) }),
|
||||||
|
default_analysis_session: ctxSession({ session: session({ session_key: 11 }) }),
|
||||||
|
next_meeting: meeting({ meeting_key: 2, meeting_name: 'Hungarian Grand Prix', date_start: '2026-07-24T09:00:00Z' }),
|
||||||
|
next_session: ctxSession({ session: session({ session_key: 21, session_name: 'Practice 1', date_start: '2026-07-24T09:00:00Z' }) }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
renderAt('/')
|
||||||
|
await waitFor(() => expect(screen.getByTestId('wk-last-event')).toBeInTheDocument())
|
||||||
|
expect(screen.getByTestId('wk-next-event')).toBeInTheDocument()
|
||||||
|
const story = screen.getByTestId('wk-explore-race-story')
|
||||||
|
expect(story).toHaveAttribute('href', expect.stringContaining('session_key=11'))
|
||||||
|
expect(screen.getByTestId('wk-prepare')).toHaveAttribute('href', '/preview')
|
||||||
|
await waitFor(() => expect(screen.getByTestId('wk-champ-impact')).toBeInTheDocument())
|
||||||
|
expect(screen.getByTestId('wk-season-nav')).toHaveTextContent('Round 5 of 24')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fan out to season/meeting/OpenF1/live queries when the canonical context succeeds', async () => {
|
||||||
|
mockContext.mockResolvedValue(context({ temporal_state: 'between_weekends' }))
|
||||||
|
renderAt('/')
|
||||||
|
await waitFor(() => expect(screen.getByTestId('weekend-between-races')).toBeInTheDocument())
|
||||||
|
// Only supplementary championship + news reads are allowed; no season /
|
||||||
|
// meetings / sessions / live fan-out from the Weekend home hook.
|
||||||
|
expect(mockSeasons).not.toHaveBeenCalled()
|
||||||
|
expect(mockSessions).not.toHaveBeenCalled()
|
||||||
|
// fetchMeetings may still be reached only through the folded preview surface,
|
||||||
|
// which is not mounted in the between-races state.
|
||||||
|
expect(mockMeetings).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supplementary reads stay dormant while the canonical context is pending', async () => {
|
||||||
|
mockContext.mockReturnValue(new Promise(() => {}))
|
||||||
|
renderAt('/')
|
||||||
|
await waitFor(() => expect(screen.getByTestId('weekend-loading')).toBeInTheDocument())
|
||||||
|
expect(mockHub).not.toHaveBeenCalled()
|
||||||
|
expect(mockNews).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the /preview alias renders the preparation surface instead of looping back', async () => {
|
||||||
|
mockContext.mockResolvedValue(
|
||||||
|
context({
|
||||||
|
temporal_state: 'between_weekends',
|
||||||
|
next_meeting: meeting({ meeting_key: 2, meeting_name: 'Hungarian Grand Prix', date_start: '2026-07-24T09:00:00Z' }),
|
||||||
|
next_session: ctxSession({ session: session({ session_key: 21, session_name: 'Practice 1', date_start: '2026-07-24T09:00:00Z' }) }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
renderAt('/preview')
|
||||||
|
await waitFor(() => expect(screen.getByTestId('weekend-pre-session')).toBeInTheDocument())
|
||||||
|
expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-preview', 'true')
|
||||||
|
// The between-races surface must NOT be what /preview renders.
|
||||||
|
expect(screen.queryByTestId('weekend-between-races')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre_session embeds Preview with canonical meeting identity (no seasons/meetings re-resolve)', async () => {
|
||||||
|
const next = meeting({
|
||||||
|
meeting_key: 2,
|
||||||
|
meeting_name: 'Hungarian Grand Prix',
|
||||||
|
date_start: '2026-07-24T09:00:00Z',
|
||||||
|
})
|
||||||
|
mockContext.mockResolvedValue(
|
||||||
|
context({
|
||||||
|
temporal_state: 'pre_session',
|
||||||
|
next_meeting: next,
|
||||||
|
focus_meeting: next,
|
||||||
|
next_session: ctxSession({
|
||||||
|
session: session({
|
||||||
|
session_key: 21,
|
||||||
|
session_name: 'Practice 1',
|
||||||
|
meeting_key: 2,
|
||||||
|
date_start: '2026-07-24T09:00:00Z',
|
||||||
|
}),
|
||||||
|
meeting: next,
|
||||||
|
availability: availability({ freshness: 'partial', local_analysis: 'partial' }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
mockSessions.mockResolvedValue([
|
||||||
|
session({
|
||||||
|
session_key: 21,
|
||||||
|
session_name: 'Practice 1',
|
||||||
|
meeting_key: 2,
|
||||||
|
date_start: '2026-07-24T09:00:00Z',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
renderAt('/')
|
||||||
|
await waitFor(() => expect(screen.getByTestId('weekend-pre-session')).toBeInTheDocument())
|
||||||
|
expect(screen.getByTestId('wk-pre-head')).toHaveTextContent('Hungarian Grand Prix')
|
||||||
|
expect(screen.getByTestId('weekend-data-notice')).toHaveTextContent(/Partial/i)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('preview-page')).toBeInTheDocument())
|
||||||
|
expect(screen.getByTestId('preview-page')).toHaveAttribute('data-meeting-key', '2')
|
||||||
|
expect(screen.getByTestId('preview-page')).toHaveAttribute('data-embedded', 'true')
|
||||||
|
// Single shell notice — Preview must not stack a second freshness banner.
|
||||||
|
expect(screen.getByTestId('weekend-data-notice')).toHaveTextContent(/Partial/i)
|
||||||
|
expect(screen.queryByTestId('preview-data-notice')).not.toBeInTheDocument()
|
||||||
|
// Canonical identity was passed — Preview must not fan out to seasons /
|
||||||
|
// current-season meetings selection. Prior-year lookup for "Last year here"
|
||||||
|
// remains an intentional supplement.
|
||||||
|
expect(mockSeasons).not.toHaveBeenCalled()
|
||||||
|
expect(mockMeetings).not.toHaveBeenCalledWith(2026, expect.anything(), expect.anything())
|
||||||
|
expect(mockSessions).toHaveBeenCalledWith(2, 'auto', expect.anything())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preview supplement failure keeps the Weekend shell usable with sanitized Retry', async () => {
|
||||||
|
const next = meeting({
|
||||||
|
meeting_key: 2,
|
||||||
|
meeting_name: 'Hungarian Grand Prix',
|
||||||
|
date_start: '2026-07-24T09:00:00Z',
|
||||||
|
})
|
||||||
|
mockContext.mockResolvedValue(
|
||||||
|
context({
|
||||||
|
temporal_state: 'pre_session',
|
||||||
|
next_meeting: next,
|
||||||
|
focus_meeting: next,
|
||||||
|
next_session: ctxSession({
|
||||||
|
session: session({ session_key: 21, session_name: 'Practice 1', meeting_key: 2 }),
|
||||||
|
meeting: next,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
let sessionCalls = 0
|
||||||
|
mockSessions.mockImplementation(async (meetingKey: number) => {
|
||||||
|
if (meetingKey !== 2) return []
|
||||||
|
sessionCalls += 1
|
||||||
|
if (sessionCalls === 1) throw new Error('API 500: sessions boom')
|
||||||
|
return [
|
||||||
|
session({
|
||||||
|
session_key: 21,
|
||||||
|
session_name: 'Practice 1',
|
||||||
|
meeting_key: 2,
|
||||||
|
date_start: '2026-07-24T09:00:00Z',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
renderAt('/')
|
||||||
|
await waitFor(() => expect(screen.getByTestId('wk-pre-head')).toBeInTheDocument())
|
||||||
|
expect(screen.getByTestId('wk-pre-head')).toHaveTextContent('Hungarian Grand Prix')
|
||||||
|
await waitFor(() => expect(screen.getByTestId('preview-sessions-error')).toBeInTheDocument())
|
||||||
|
expect(screen.queryByText(/API 500|sessions boom/i)).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('preview-sessions-retry'))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('preview-schedule')).toHaveTextContent('Practice 1'))
|
||||||
|
expect(sessionCalls).toBe(2)
|
||||||
|
expect(screen.queryByTestId('preview-sessions-error')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Limited Retry refetches failed championship/news supplements once', async () => {
|
||||||
|
mockContext.mockResolvedValue(context({ temporal_state: 'between_weekends' }))
|
||||||
|
let hubCalls = 0
|
||||||
|
let newsCalls = 0
|
||||||
|
mockHub.mockImplementation(async () => {
|
||||||
|
hubCalls += 1
|
||||||
|
if (hubCalls === 1) throw new Error('API 503: hub')
|
||||||
|
return hub
|
||||||
|
})
|
||||||
|
mockNews.mockImplementation(async () => {
|
||||||
|
newsCalls += 1
|
||||||
|
if (newsCalls === 1) throw new Error('API 503: news')
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
renderAt('/')
|
||||||
|
await waitFor(() => expect(screen.getByTestId('weekend-data-notice')).toHaveTextContent(/Limited/i))
|
||||||
|
const retry = screen.getByTestId('weekend-data-notice').querySelector('button')!
|
||||||
|
expect(retry).toBeEnabled()
|
||||||
|
fireEvent.click(retry)
|
||||||
|
fireEvent.click(retry)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByTestId('weekend-data-notice')).not.toBeInTheDocument())
|
||||||
|
expect(hubCalls).toBe(2)
|
||||||
|
expect(newsCalls).toBe(2)
|
||||||
|
// Canonical context is not the failed resource — only one initial fetch.
|
||||||
|
expect(mockContext).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Limited Retry exposes disabled Retrying… aria-busy while deferred supplements recover', async () => {
|
||||||
|
mockContext.mockResolvedValue(context({ temporal_state: 'between_weekends' }))
|
||||||
|
let hubCalls = 0
|
||||||
|
let newsCalls = 0
|
||||||
|
let resolveHub!: (value: ChampionshipHub) => void
|
||||||
|
let resolveNews!: (value: []) => void
|
||||||
|
mockHub.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<ChampionshipHub>((resolve, reject) => {
|
||||||
|
hubCalls += 1
|
||||||
|
if (hubCalls === 1) {
|
||||||
|
reject(new Error('API 503: hub'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolveHub = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
mockNews.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<[]>((resolve, reject) => {
|
||||||
|
newsCalls += 1
|
||||||
|
if (newsCalls === 1) {
|
||||||
|
reject(new Error('API 503: news'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolveNews = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderAt('/')
|
||||||
|
await waitFor(() => expect(screen.getByTestId('weekend-data-notice')).toHaveTextContent(/Limited/i))
|
||||||
|
expect(mockContext).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Retrying…' })).toBeDisabled())
|
||||||
|
expect(screen.getByRole('button', { name: 'Retrying…' })).toHaveAttribute('aria-busy', 'true')
|
||||||
|
// Double-click while pending must not issue extra supplement or context requests.
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Retrying…' }))
|
||||||
|
expect(hubCalls).toBe(2)
|
||||||
|
expect(newsCalls).toBe(2)
|
||||||
|
expect(mockContext).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
resolveHub(hub)
|
||||||
|
resolveNews([])
|
||||||
|
await waitFor(() => expect(screen.queryByTestId('weekend-data-notice')).not.toBeInTheDocument())
|
||||||
|
expect(hubCalls).toBe(2)
|
||||||
|
expect(newsCalls).toBe(2)
|
||||||
|
expect(mockContext).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('between-sessions header local/local suppresses Archive from terminal previous', async () => {
|
||||||
|
const focus = meeting({
|
||||||
|
meeting_key: 2,
|
||||||
|
meeting_name: 'Belgian Grand Prix',
|
||||||
|
date_start: '2026-07-10T09:00:00Z',
|
||||||
|
})
|
||||||
|
const previous = meeting({
|
||||||
|
meeting_key: 1,
|
||||||
|
meeting_name: 'British Grand Prix',
|
||||||
|
circuit_short_name: 'Silverstone',
|
||||||
|
date_start: '2026-07-05T09:00:00Z',
|
||||||
|
})
|
||||||
|
const payload = context({
|
||||||
|
temporal_state: 'between_sessions',
|
||||||
|
focus_meeting: focus,
|
||||||
|
next_meeting: focus,
|
||||||
|
previous_meeting: previous,
|
||||||
|
next_session: ctxSession({
|
||||||
|
session: session({
|
||||||
|
session_key: 21,
|
||||||
|
session_name: 'Practice 1',
|
||||||
|
meeting_key: 2,
|
||||||
|
date_start: '2026-07-10T09:00:00Z',
|
||||||
|
}),
|
||||||
|
meeting: focus,
|
||||||
|
availability: availability({ source: 'local', freshness: 'local' }),
|
||||||
|
}),
|
||||||
|
previous_completed_session: ctxSession({
|
||||||
|
session: session({
|
||||||
|
session_key: 99,
|
||||||
|
session_name: 'Race',
|
||||||
|
session_type: 'Race',
|
||||||
|
meeting_key: 1,
|
||||||
|
date_start: '2026-07-05T14:00:00Z',
|
||||||
|
}),
|
||||||
|
meeting: previous,
|
||||||
|
availability: availability({ source: 'fia', freshness: 'archive', archive: 'available' }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
rememberResponseAvailability(payload, { source: 'local', freshness: 'local' })
|
||||||
|
mockContext.mockResolvedValue(payload)
|
||||||
|
|
||||||
|
renderAt('/')
|
||||||
|
await waitFor(() => expect(screen.getByTestId('weekend-between-sessions')).toBeInTheDocument())
|
||||||
|
expect(screen.queryByTestId('weekend-data-notice')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(/archived snapshot/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre_session keeps shell Partial while disclosing distinct Preview stale', async () => {
|
||||||
|
const next = meeting({
|
||||||
|
meeting_key: 2,
|
||||||
|
meeting_name: 'Hungarian Grand Prix',
|
||||||
|
date_start: '2026-07-24T09:00:00Z',
|
||||||
|
})
|
||||||
|
mockContext.mockResolvedValue(
|
||||||
|
context({
|
||||||
|
temporal_state: 'pre_session',
|
||||||
|
next_meeting: next,
|
||||||
|
focus_meeting: next,
|
||||||
|
next_session: ctxSession({
|
||||||
|
session: session({
|
||||||
|
session_key: 21,
|
||||||
|
session_name: 'Practice 1',
|
||||||
|
meeting_key: 2,
|
||||||
|
date_start: '2026-07-24T09:00:00Z',
|
||||||
|
}),
|
||||||
|
meeting: next,
|
||||||
|
availability: availability({ freshness: 'partial', local_analysis: 'partial' }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
mockSessions.mockResolvedValue([
|
||||||
|
session({
|
||||||
|
session_key: 21,
|
||||||
|
session_name: 'Practice 1',
|
||||||
|
meeting_key: 2,
|
||||||
|
date_start: '2026-07-24T09:00:00Z',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
rememberResponseAvailability(hub, { source: 'openf1', freshness: 'stale' })
|
||||||
|
mockHub.mockResolvedValue(hub)
|
||||||
|
|
||||||
|
renderAt('/')
|
||||||
|
await waitFor(() => expect(screen.getByTestId('weekend-data-notice')).toHaveTextContent(/Partial/i))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('preview-data-notice')).toHaveTextContent(/Stale/i))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores Race Hub meeting/session focus from the Weekend URL search contract', async () => {
|
||||||
|
mockContext.mockResolvedValue(
|
||||||
|
context({
|
||||||
|
temporal_state: 'season_complete',
|
||||||
|
previous_completed_session: ctxSession({ session: session({ session_key: 11, session_name: 'Race' }) }),
|
||||||
|
default_analysis_session: ctxSession({ session: session({ session_key: 11 }) }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
renderAt('/?meeting_key=1&session_key=11')
|
||||||
|
await waitFor(() => expect(screen.getByTestId('wk-focus-context')).toBeInTheDocument())
|
||||||
|
expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-meeting-key', '1')
|
||||||
|
expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-session-key', '11')
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId('wk-focus-meeting')).toHaveTextContent('British Grand Prix'),
|
||||||
|
)
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId('wk-focus-session')).toHaveTextContent('Race'),
|
||||||
|
)
|
||||||
|
const continueAnalysis = screen.getByRole('link', { name: /Continue analysis/i })
|
||||||
|
expect(continueAnalysis).toHaveAttribute('href', expect.stringContaining('session_key=11'))
|
||||||
|
expect(mockWeekend).toHaveBeenCalledWith(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
344
frontend/src/test/availability.test.ts
Normal file
344
frontend/src/test/availability.test.ts
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { QueryClient, QueryObserver } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
apiFetch,
|
||||||
|
availabilityAwareStructuralSharing,
|
||||||
|
clearApiFetchInflight,
|
||||||
|
DATA_FRESHNESS_HEADER,
|
||||||
|
DATA_SOURCE_HEADER,
|
||||||
|
getResponseAvailability,
|
||||||
|
rememberResponseAvailability,
|
||||||
|
} from '../lib/fetch'
|
||||||
|
import {
|
||||||
|
aggregateNotices,
|
||||||
|
noticeFromFreshness,
|
||||||
|
noticeFromResponse,
|
||||||
|
shouldShowEmbeddedNotice,
|
||||||
|
weekendContextNotice,
|
||||||
|
} from '../lib/availability'
|
||||||
|
import type { WeekendContext } from '../types'
|
||||||
|
|
||||||
|
describe('response availability metadata', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearApiFetchInflight()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
clearApiFetchInflight()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('captures CORS-readable source/freshness headers without changing the payload shape', async () => {
|
||||||
|
const body = { season: 2026, drivers: [{ points: 1 }] }
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async () =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
[DATA_SOURCE_HEADER]: 'openf1',
|
||||||
|
[DATA_FRESHNESS_HEADER]: 'stale',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const data = await apiFetch<typeof body>('/api/v1/championship/hub')
|
||||||
|
expect(data).toEqual(body)
|
||||||
|
expect(getResponseAvailability(data)).toEqual({ source: 'openf1', freshness: 'stale' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves metadata through deduped in-flight subscribers', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async () =>
|
||||||
|
new Response(JSON.stringify({ ok: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
[DATA_SOURCE_HEADER]: 'local',
|
||||||
|
[DATA_FRESHNESS_HEADER]: 'partial',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const a = apiFetch<{ ok: boolean }>('/api/v1/shared', { dedupeKey: 'meta-shared' })
|
||||||
|
const b = apiFetch<{ ok: boolean }>('/api/v1/shared', { dedupeKey: 'meta-shared' })
|
||||||
|
const [ra, rb] = await Promise.all([a, b])
|
||||||
|
expect(ra).toBe(rb)
|
||||||
|
expect(getResponseAvailability(ra)).toEqual({ source: 'local', freshness: 'partial' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps only reported freshness values and never invents stale', () => {
|
||||||
|
expect(noticeFromFreshness('stale')).toBe('stale')
|
||||||
|
expect(noticeFromFreshness('partial')).toBe('partial')
|
||||||
|
expect(noticeFromFreshness('local')).toBe('local')
|
||||||
|
expect(noticeFromFreshness('limited')).toBe('limited')
|
||||||
|
expect(noticeFromFreshness('archive')).toBe('archive')
|
||||||
|
expect(noticeFromFreshness('fresh')).toBeNull()
|
||||||
|
expect(noticeFromFreshness('live')).toBeNull()
|
||||||
|
expect(noticeFromFreshness(undefined)).toBeNull()
|
||||||
|
expect(noticeFromFreshness('local', { includeLocal: false })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads notice from WeakMap metadata attached to a successful payload', () => {
|
||||||
|
const hub = { season: 2025, drivers: [] as unknown[] }
|
||||||
|
rememberResponseAvailability(hub, { source: 'openf1', freshness: 'stale' })
|
||||||
|
expect(noticeFromResponse(hub)).toBe('stale')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dedupes only equivalent embedded Preview notices', () => {
|
||||||
|
expect(shouldShowEmbeddedNotice('stale', 'partial')).toBe(true)
|
||||||
|
expect(shouldShowEmbeddedNotice('partial', 'partial')).toBe(false)
|
||||||
|
expect(shouldShowEmbeddedNotice('stale', null)).toBe(true)
|
||||||
|
expect(shouldShowEmbeddedNotice(null, 'partial')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aggregates conflicting sources by severity so Local cannot mask worse truth', () => {
|
||||||
|
expect(aggregateNotices(['local', 'stale'])).toBe('stale')
|
||||||
|
expect(aggregateNotices(['local', 'partial'])).toBe('partial')
|
||||||
|
expect(aggregateNotices(['local', 'limited'])).toBe('limited')
|
||||||
|
expect(aggregateNotices(['local', 'archive'])).toBe('archive')
|
||||||
|
expect(aggregateNotices(['stale', 'partial', 'local'])).toBe('partial')
|
||||||
|
expect(aggregateNotices(['stale', 'stale', null])).toBe('stale')
|
||||||
|
expect(aggregateNotices([null, undefined])).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('derives Weekend Context notices from typed session freshness, skipping routine local', () => {
|
||||||
|
const context: WeekendContext = {
|
||||||
|
season: 2026,
|
||||||
|
temporal_state: 'pre_session',
|
||||||
|
championship_round: 1,
|
||||||
|
total_championship_rounds: 24,
|
||||||
|
focus_meeting: {
|
||||||
|
meeting_key: 2,
|
||||||
|
meeting_name: 'Belgian Grand Prix',
|
||||||
|
meeting_official_name: '',
|
||||||
|
location: 'Spa',
|
||||||
|
country_name: 'Belgium',
|
||||||
|
country_code: 'BEL',
|
||||||
|
country_flag: '',
|
||||||
|
circuit_key: 7,
|
||||||
|
circuit_short_name: 'Spa',
|
||||||
|
date_start: '2026-07-24T09:00:00Z',
|
||||||
|
date_end: '2026-07-26T16:00:00Z',
|
||||||
|
year: 2026,
|
||||||
|
},
|
||||||
|
next_session: {
|
||||||
|
session: {
|
||||||
|
session_key: 21,
|
||||||
|
session_name: 'FP1',
|
||||||
|
session_type: 'Practice',
|
||||||
|
meeting_key: 2,
|
||||||
|
date_start: '2026-07-24T09:00:00Z',
|
||||||
|
date_end: '2026-07-24T10:00:00Z',
|
||||||
|
gmt_offset: '',
|
||||||
|
},
|
||||||
|
meeting: {
|
||||||
|
meeting_key: 2,
|
||||||
|
meeting_name: 'Belgian Grand Prix',
|
||||||
|
meeting_official_name: '',
|
||||||
|
location: 'Spa',
|
||||||
|
country_name: 'Belgium',
|
||||||
|
country_code: 'BEL',
|
||||||
|
country_flag: '',
|
||||||
|
circuit_key: 7,
|
||||||
|
circuit_short_name: 'Spa',
|
||||||
|
date_start: '2026-07-24T09:00:00Z',
|
||||||
|
date_end: '2026-07-26T16:00:00Z',
|
||||||
|
year: 2026,
|
||||||
|
},
|
||||||
|
availability: {
|
||||||
|
source: 'local',
|
||||||
|
schedule: 'available',
|
||||||
|
live_transport: 'unknown',
|
||||||
|
live_session: 'inactive',
|
||||||
|
archive: 'unavailable',
|
||||||
|
local_analysis: 'partial',
|
||||||
|
freshness: 'partial',
|
||||||
|
limitations: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
expect(weekendContextNotice(context)).toBe('partial')
|
||||||
|
|
||||||
|
const localOnly: WeekendContext = {
|
||||||
|
...context,
|
||||||
|
next_session: {
|
||||||
|
...context.next_session!,
|
||||||
|
availability: {
|
||||||
|
...context.next_session!.availability,
|
||||||
|
local_analysis: 'pending',
|
||||||
|
freshness: 'local',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
expect(weekendContextNotice(localOnly)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('authoritative Weekend header local/local does not fall through to previous archive', () => {
|
||||||
|
const focusMeeting = {
|
||||||
|
meeting_key: 2,
|
||||||
|
meeting_name: 'Belgian Grand Prix',
|
||||||
|
meeting_official_name: '',
|
||||||
|
location: 'Spa',
|
||||||
|
country_name: 'Belgium',
|
||||||
|
country_code: 'BEL',
|
||||||
|
country_flag: '',
|
||||||
|
circuit_key: 7,
|
||||||
|
circuit_short_name: 'Spa',
|
||||||
|
date_start: '2026-07-10T09:00:00Z',
|
||||||
|
date_end: '2026-07-12T16:00:00Z',
|
||||||
|
year: 2026,
|
||||||
|
}
|
||||||
|
const previousMeeting = {
|
||||||
|
...focusMeeting,
|
||||||
|
meeting_key: 1,
|
||||||
|
meeting_name: 'British Grand Prix',
|
||||||
|
circuit_short_name: 'Silverstone',
|
||||||
|
location: 'Silverstone',
|
||||||
|
country_name: 'United Kingdom',
|
||||||
|
country_code: 'GBR',
|
||||||
|
}
|
||||||
|
const context: WeekendContext = {
|
||||||
|
season: 2026,
|
||||||
|
temporal_state: 'between_sessions',
|
||||||
|
championship_round: 12,
|
||||||
|
total_championship_rounds: 24,
|
||||||
|
focus_meeting: focusMeeting,
|
||||||
|
next_session: {
|
||||||
|
session: {
|
||||||
|
session_key: 21,
|
||||||
|
session_name: 'Practice 1',
|
||||||
|
session_type: 'Practice',
|
||||||
|
meeting_key: 2,
|
||||||
|
date_start: '2026-07-10T09:00:00Z',
|
||||||
|
date_end: '2026-07-10T10:00:00Z',
|
||||||
|
gmt_offset: '',
|
||||||
|
},
|
||||||
|
meeting: focusMeeting,
|
||||||
|
availability: {
|
||||||
|
source: 'local',
|
||||||
|
schedule: 'available',
|
||||||
|
live_transport: 'unknown',
|
||||||
|
live_session: 'inactive',
|
||||||
|
archive: 'unavailable',
|
||||||
|
local_analysis: 'pending',
|
||||||
|
freshness: 'local',
|
||||||
|
limitations: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
previous_completed_session: {
|
||||||
|
session: {
|
||||||
|
session_key: 99,
|
||||||
|
session_name: 'Race',
|
||||||
|
session_type: 'Race',
|
||||||
|
meeting_key: 1,
|
||||||
|
date_start: '2026-07-05T14:00:00Z',
|
||||||
|
date_end: '2026-07-05T16:00:00Z',
|
||||||
|
gmt_offset: '',
|
||||||
|
},
|
||||||
|
meeting: previousMeeting,
|
||||||
|
availability: {
|
||||||
|
source: 'fia',
|
||||||
|
schedule: 'available',
|
||||||
|
live_transport: 'unknown',
|
||||||
|
live_session: 'inactive',
|
||||||
|
archive: 'available',
|
||||||
|
local_analysis: 'complete',
|
||||||
|
freshness: 'archive',
|
||||||
|
limitations: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rememberResponseAvailability(context, { source: 'local', freshness: 'local' })
|
||||||
|
expect(weekendContextNotice(context)).toBeNull()
|
||||||
|
|
||||||
|
// Without an authoritative header, typed focus (upcoming local) still suppresses Local.
|
||||||
|
const noHeader: WeekendContext = { ...context }
|
||||||
|
expect(weekendContextNotice(noHeader)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates metadata when structural sharing reuses an equal JSON object', () => {
|
||||||
|
const body = { season: 2026, drivers: [{ points: 1 }] }
|
||||||
|
const first = structuredClone(body)
|
||||||
|
const second = structuredClone(body)
|
||||||
|
rememberResponseAvailability(first, { source: 'openf1', freshness: 'stale' })
|
||||||
|
rememberResponseAvailability(second, { source: 'openf1', freshness: 'fresh' })
|
||||||
|
|
||||||
|
const shared = availabilityAwareStructuralSharing(first, second)
|
||||||
|
expect(shared).toBe(first)
|
||||||
|
expect(getResponseAvailability(shared)).toEqual({ source: 'openf1', freshness: 'fresh' })
|
||||||
|
expect(noticeFromResponse(shared)).toBeNull()
|
||||||
|
|
||||||
|
const third = structuredClone(body)
|
||||||
|
rememberResponseAvailability(third, { source: 'openf1', freshness: 'partial' })
|
||||||
|
const sharedAgain = availabilityAwareStructuralSharing(shared, third)
|
||||||
|
expect(sharedAgain).toBe(first)
|
||||||
|
expect(noticeFromResponse(sharedAgain)).toBe('partial')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('QueryObserver stale→fresh and fresh→partial refetches update metadata and notices', async () => {
|
||||||
|
const body = { season: 2026, drivers: [{ points: 42 }] }
|
||||||
|
const sequence: Array<'stale' | 'fresh' | 'partial'> = ['stale', 'fresh', 'partial']
|
||||||
|
let call = 0
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async () => {
|
||||||
|
const freshness = sequence[Math.min(call, sequence.length - 1)]
|
||||||
|
call += 1
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
[DATA_SOURCE_HEADER]: 'openf1',
|
||||||
|
[DATA_FRESHNESS_HEADER]: freshness,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const client = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
structuralSharing: availabilityAwareStructuralSharing,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const observer = new QueryObserver(client, {
|
||||||
|
queryKey: ['availability-observer'],
|
||||||
|
queryFn: () => apiFetch<typeof body>('/api/v1/championship/hub'),
|
||||||
|
staleTime: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const notices: Array<string | null> = []
|
||||||
|
const unsub = observer.subscribe((result) => {
|
||||||
|
if (result.data) notices.push(noticeFromResponse(result.data))
|
||||||
|
})
|
||||||
|
|
||||||
|
await observer.refetch()
|
||||||
|
expect(noticeFromResponse(observer.getCurrentResult().data)).toBe('stale')
|
||||||
|
expect(notices[notices.length - 1]).toBe('stale')
|
||||||
|
|
||||||
|
await observer.refetch()
|
||||||
|
expect(observer.getCurrentResult().data).toEqual(body)
|
||||||
|
expect(getResponseAvailability(observer.getCurrentResult().data!)).toEqual({
|
||||||
|
source: 'openf1',
|
||||||
|
freshness: 'fresh',
|
||||||
|
})
|
||||||
|
expect(noticeFromResponse(observer.getCurrentResult().data)).toBeNull()
|
||||||
|
expect(notices[notices.length - 1]).toBeNull()
|
||||||
|
|
||||||
|
await observer.refetch()
|
||||||
|
expect(noticeFromResponse(observer.getCurrentResult().data)).toBe('partial')
|
||||||
|
expect(notices[notices.length - 1]).toBe('partial')
|
||||||
|
expect(call).toBe(3)
|
||||||
|
|
||||||
|
unsub()
|
||||||
|
client.clear()
|
||||||
|
})
|
||||||
|
})
|
||||||
291
frontend/src/test/fetch.test.ts
Normal file
291
frontend/src/test/fetch.test.ts
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
apiFetch,
|
||||||
|
clearApiFetchInflight,
|
||||||
|
DEFAULT_FETCH_TIMEOUT_MS,
|
||||||
|
isTimeoutError,
|
||||||
|
userFacingError,
|
||||||
|
} from '../lib/fetch'
|
||||||
|
|
||||||
|
describe('apiFetch', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearApiFetchInflight()
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
clearApiFetchInflight()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves JSON within the timeout boundary', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async () =>
|
||||||
|
new Response(JSON.stringify({ ok: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(apiFetch<{ ok: boolean }>('/api/v1/ping')).resolves.toEqual({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('times out and leaves no hanging promise after the deadline', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((_url: string, init?: RequestInit) => {
|
||||||
|
return new Promise<Response>((_resolve, reject) => {
|
||||||
|
const signal = init?.signal
|
||||||
|
if (!signal) return
|
||||||
|
signal.addEventListener('abort', () => {
|
||||||
|
reject(new DOMException('Aborted', 'AbortError'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const pending = apiFetch('/api/v1/slow', { timeoutMs: 1000 })
|
||||||
|
const assertion = expect(pending).rejects.toSatisfy(
|
||||||
|
(err: unknown) => isTimeoutError(err) && userFacingError(err).includes('too long'),
|
||||||
|
)
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1000)
|
||||||
|
await assertion
|
||||||
|
})
|
||||||
|
|
||||||
|
it('propagates caller abort as an ApiError', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((_url: string, init?: RequestInit) => {
|
||||||
|
return new Promise<Response>((_resolve, reject) => {
|
||||||
|
init?.signal?.addEventListener('abort', () => {
|
||||||
|
reject(new DOMException('Aborted', 'AbortError'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const pending = apiFetch('/api/v1/abort-me', {
|
||||||
|
signal: controller.signal,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
})
|
||||||
|
const assertion = expect(pending).rejects.toMatchObject({ kind: 'abort' })
|
||||||
|
controller.abort()
|
||||||
|
await assertion
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deduplicates concurrent in-flight requests for the same key', async () => {
|
||||||
|
let starts = 0
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async () => {
|
||||||
|
starts += 1
|
||||||
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
return new Response(JSON.stringify({ n: starts }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const a = apiFetch<{ n: number }>('/api/v1/shared', { dedupeKey: 'shared' })
|
||||||
|
const b = apiFetch<{ n: number }>('/api/v1/shared', { dedupeKey: 'shared' })
|
||||||
|
await vi.advanceTimersByTimeAsync(50)
|
||||||
|
const [ra, rb] = await Promise.all([a, b])
|
||||||
|
expect(starts).toBe(1)
|
||||||
|
expect(ra).toEqual(rb)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not poison a second caller when the first aborts the same dedupe key', async () => {
|
||||||
|
let starts = 0
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((_url: string, init?: RequestInit) => {
|
||||||
|
starts += 1
|
||||||
|
return new Promise<Response>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
resolve(
|
||||||
|
new Response(JSON.stringify({ ok: true, starts }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}, 100)
|
||||||
|
init?.signal?.addEventListener(
|
||||||
|
'abort',
|
||||||
|
() => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
reject(new DOMException('Aborted', 'AbortError'))
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const first = new AbortController()
|
||||||
|
const second = new AbortController()
|
||||||
|
const a = apiFetch<{ ok: boolean }>('/api/v1/shared', {
|
||||||
|
dedupeKey: 'shared-abort-safe',
|
||||||
|
signal: first.signal,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
})
|
||||||
|
const b = apiFetch<{ ok: boolean }>('/api/v1/shared', {
|
||||||
|
dedupeKey: 'shared-abort-safe',
|
||||||
|
signal: second.signal,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
first.abort()
|
||||||
|
await expect(a).rejects.toMatchObject({ kind: 'abort' })
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100)
|
||||||
|
await expect(b).resolves.toEqual({ ok: true, starts: 1 })
|
||||||
|
expect(starts).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancels the shared request only after the last subscriber aborts', async () => {
|
||||||
|
let aborted = false
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((_url: string, init?: RequestInit) => {
|
||||||
|
return new Promise<Response>((_resolve, reject) => {
|
||||||
|
init?.signal?.addEventListener(
|
||||||
|
'abort',
|
||||||
|
() => {
|
||||||
|
aborted = true
|
||||||
|
reject(new DOMException('Aborted', 'AbortError'))
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const first = new AbortController()
|
||||||
|
const second = new AbortController()
|
||||||
|
const a = apiFetch('/api/v1/shared', {
|
||||||
|
dedupeKey: 'shared-last-abort',
|
||||||
|
signal: first.signal,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
})
|
||||||
|
const b = apiFetch('/api/v1/shared', {
|
||||||
|
dedupeKey: 'shared-last-abort',
|
||||||
|
signal: second.signal,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
first.abort()
|
||||||
|
await expect(a).rejects.toMatchObject({ kind: 'abort' })
|
||||||
|
expect(aborted).toBe(false)
|
||||||
|
|
||||||
|
second.abort()
|
||||||
|
await expect(b).rejects.toMatchObject({ kind: 'abort' })
|
||||||
|
expect(aborted).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('survives a Strict Mode style remount: aborted first consumer, fresh second consumer', async () => {
|
||||||
|
let starts = 0
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((_url: string, init?: RequestInit) => {
|
||||||
|
starts += 1
|
||||||
|
return new Promise<Response>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
resolve(
|
||||||
|
new Response(JSON.stringify({ starts }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}, 50)
|
||||||
|
init?.signal?.addEventListener(
|
||||||
|
'abort',
|
||||||
|
() => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
reject(new DOMException('Aborted', 'AbortError'))
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mount #1
|
||||||
|
const first = new AbortController()
|
||||||
|
const pendingFirst = apiFetch<{ starts: number }>('/api/v1/seasons', {
|
||||||
|
dedupeKey: 'seasons',
|
||||||
|
signal: first.signal,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Strict Mode unmount cancels the first subscription (sole subscriber → shared abort)
|
||||||
|
first.abort()
|
||||||
|
await expect(pendingFirst).rejects.toMatchObject({ kind: 'abort' })
|
||||||
|
|
||||||
|
// Remount starts a fresh consumer — must not inherit the aborted request
|
||||||
|
const second = new AbortController()
|
||||||
|
const pendingSecond = apiFetch<{ starts: number }>('/api/v1/seasons', {
|
||||||
|
dedupeKey: 'seasons',
|
||||||
|
signal: second.signal,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(50)
|
||||||
|
await expect(pendingSecond).resolves.toEqual({ starts: 2 })
|
||||||
|
expect(starts).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows an explicit retry after the prior request settles', async () => {
|
||||||
|
let starts = 0
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async () => {
|
||||||
|
starts += 1
|
||||||
|
return new Response(JSON.stringify({ starts }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(apiFetch('/api/v1/retry', { dedupeKey: 'retry-once' })).resolves.toEqual({
|
||||||
|
starts: 1,
|
||||||
|
})
|
||||||
|
await expect(apiFetch('/api/v1/retry', { dedupeKey: 'retry-once' })).resolves.toEqual({
|
||||||
|
starts: 2,
|
||||||
|
})
|
||||||
|
expect(starts).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps HTTP errors without raw status jargon in userFacingError', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async () =>
|
||||||
|
new Response(JSON.stringify({ error: 'upstream failed', stale: true }), {
|
||||||
|
status: 502,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiFetch('/api/v1/fail')
|
||||||
|
expect.unreachable('should throw')
|
||||||
|
} catch (err) {
|
||||||
|
expect(err).toBeInstanceOf(ApiError)
|
||||||
|
expect((err as ApiError).stale).toBe(true)
|
||||||
|
expect(userFacingError(err)).not.toMatch(/502|API /)
|
||||||
|
expect(userFacingError(err)).toMatch(/cached|Retry/i)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the default primary-route timeout', () => {
|
||||||
|
expect(DEFAULT_FETCH_TIMEOUT_MS).toBe(15_000)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
bestLapGaps,
|
|
||||||
compoundClass,
|
compoundClass,
|
||||||
compoundLetter,
|
compoundLetter,
|
||||||
extrapolateClock,
|
extrapolateClock,
|
||||||
isLiveSessionActive,
|
|
||||||
latestRaceControl,
|
latestRaceControl,
|
||||||
liveSessionDisplay,
|
liveSessionDisplay,
|
||||||
loadPinnedDrivers,
|
loadPinnedDrivers,
|
||||||
mergeVisibleSectors,
|
mergeVisibleSectors,
|
||||||
parseLapTimeSeconds,
|
|
||||||
positionDeltaClass,
|
positionDeltaClass,
|
||||||
parseLiveStateEvent,
|
parseLiveStateEvent,
|
||||||
rcFlagClass,
|
rcFlagClass,
|
||||||
@@ -282,43 +279,6 @@ describe('live qualifying display', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('practice/qualifying computed gaps', () => {
|
|
||||||
it('parses lap-time strings into seconds and rejects invalid input', () => {
|
|
||||||
expect(parseLapTimeSeconds('1:45.944')).toBeCloseTo(105.944, 3)
|
|
||||||
expect(parseLapTimeSeconds('45.944')).toBeCloseTo(45.944, 3)
|
|
||||||
expect(parseLapTimeSeconds('')).toBeNull()
|
|
||||||
expect(parseLapTimeSeconds(undefined)).toBeNull()
|
|
||||||
expect(parseLapTimeSeconds('-')).toBeNull()
|
|
||||||
expect(parseLapTimeSeconds('nope')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('derives a gap to P1 from valid best laps only, flagging the leader', () => {
|
|
||||||
const gaps = bestLapGaps([
|
|
||||||
timingRow('1', 1, { BestLapTime: '1:45.944' }),
|
|
||||||
timingRow('4', 2, { BestLapTime: '1:46.134' }),
|
|
||||||
timingRow('16', 3, { BestLapTime: '' }),
|
|
||||||
])
|
|
||||||
expect(gaps['1']).toEqual({ isLeader: true, gap: '' })
|
|
||||||
expect(gaps['4']).toEqual({ isLeader: false, gap: '+0.190' })
|
|
||||||
// No valid best lap → no fabricated gap.
|
|
||||||
expect(gaps['16']).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns no gaps when nobody has set a lap', () => {
|
|
||||||
expect(bestLapGaps([timingRow('1', 1, { BestLapTime: '' })])).toEqual({})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('live session activity', () => {
|
|
||||||
it('is active only when the feed reports a live session with data', () => {
|
|
||||||
expect(isLiveSessionActive({ is_live: true, data: snapshot })).toBe(true)
|
|
||||||
expect(isLiveSessionActive({ is_live: true, data: null })).toBe(false)
|
|
||||||
expect(isLiveSessionActive({ is_live: false, data: snapshot })).toBe(false)
|
|
||||||
expect(isLiveSessionActive(null)).toBe(false)
|
|
||||||
expect(isLiveSessionActive(undefined)).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('visible sector display', () => {
|
describe('visible sector display', () => {
|
||||||
it('holds S1 and S2 through temporary blanks while a flying lap is active', () => {
|
it('holds S1 and S2 through temporary blanks while a flying lap is active', () => {
|
||||||
const first = [timingRow('4', 1, {
|
const first = [timingRow('4', 1, {
|
||||||
|
|||||||
183
frontend/src/test/liveState.test.ts
Normal file
183
frontend/src/test/liveState.test.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
allowsLiveInterpretations,
|
||||||
|
deriveLivePhase,
|
||||||
|
effectiveFeedHealth,
|
||||||
|
isReadOnlyPhase,
|
||||||
|
rendersSnapshot,
|
||||||
|
terminalSessionStatus,
|
||||||
|
type LiveStateInputs,
|
||||||
|
} from '../lib/liveState'
|
||||||
|
|
||||||
|
const base: LiveStateInputs = {
|
||||||
|
transport: 'connecting',
|
||||||
|
isLive: false,
|
||||||
|
hasActiveSnapshot: false,
|
||||||
|
hasArchive: false,
|
||||||
|
archiveMode: false,
|
||||||
|
sessionEndedCleanly: false,
|
||||||
|
wasLive: false,
|
||||||
|
stateLoaded: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('terminalSessionStatus', () => {
|
||||||
|
it('recognizes genuine session-end statuses', () => {
|
||||||
|
for (const s of ['Finished', 'Finalised', 'Finalized', 'ENDED', 'Aborted']) {
|
||||||
|
expect(terminalSessionStatus(s)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats still-running / unknown statuses as non-terminal', () => {
|
||||||
|
for (const s of ['Started', 'Resumed', 'Inactive', '', undefined, null]) {
|
||||||
|
expect(terminalSessionStatus(s)).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deriveLivePhase transitions', () => {
|
||||||
|
it('connecting: cold start with the feed still opening', () => {
|
||||||
|
expect(deriveLivePhase({ ...base, transport: 'connecting' })).toBe('connecting')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('connecting → live once a session snapshot arrives', () => {
|
||||||
|
expect(
|
||||||
|
deriveLivePhase({
|
||||||
|
...base,
|
||||||
|
transport: 'connected',
|
||||||
|
isLive: true,
|
||||||
|
hasActiveSnapshot: true,
|
||||||
|
}),
|
||||||
|
).toBe('live')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('live → disconnected when the SSE transport drops mid-session', () => {
|
||||||
|
expect(
|
||||||
|
deriveLivePhase({
|
||||||
|
...base,
|
||||||
|
transport: 'disconnected',
|
||||||
|
isLive: true,
|
||||||
|
hasActiveSnapshot: true,
|
||||||
|
wasLive: true,
|
||||||
|
}),
|
||||||
|
).toBe('disconnected')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('live → settling when the session ends with a terminal status', () => {
|
||||||
|
// Feed reports is_live=false, transport healthy, retained snapshot carries a
|
||||||
|
// terminal SessionStatus → the session genuinely finished.
|
||||||
|
expect(
|
||||||
|
deriveLivePhase({
|
||||||
|
...base,
|
||||||
|
transport: 'connected',
|
||||||
|
isLive: false,
|
||||||
|
hasArchive: true,
|
||||||
|
sessionEndedCleanly: true,
|
||||||
|
wasLive: true,
|
||||||
|
}),
|
||||||
|
).toBe('settling')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('live → disconnected (NOT settling) when the FIA feed drops but status is non-terminal', () => {
|
||||||
|
// This is the P0 case: server deactivate() moved a still-active snapshot to
|
||||||
|
// last_snapshot with is_live=false but a non-terminal SessionStatus. The
|
||||||
|
// browser transport is healthy. We must warn + retain, never settle/archive.
|
||||||
|
expect(
|
||||||
|
deriveLivePhase({
|
||||||
|
...base,
|
||||||
|
transport: 'connected',
|
||||||
|
isLive: false,
|
||||||
|
hasArchive: true,
|
||||||
|
sessionEndedCleanly: false,
|
||||||
|
wasLive: true,
|
||||||
|
}),
|
||||||
|
).toBe('disconnected')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a retained non-terminal snapshot on cold load is disconnected, not settling', () => {
|
||||||
|
expect(
|
||||||
|
deriveLivePhase({
|
||||||
|
...base,
|
||||||
|
transport: 'connected',
|
||||||
|
isLive: false,
|
||||||
|
hasArchive: true,
|
||||||
|
sessionEndedCleanly: false,
|
||||||
|
wasLive: false,
|
||||||
|
}),
|
||||||
|
).toBe('disconnected')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('settling → archive when the user explicitly opens the read-only snapshot', () => {
|
||||||
|
expect(
|
||||||
|
deriveLivePhase({
|
||||||
|
...base,
|
||||||
|
transport: 'connected',
|
||||||
|
isLive: false,
|
||||||
|
hasArchive: true,
|
||||||
|
sessionEndedCleanly: true,
|
||||||
|
archiveMode: true,
|
||||||
|
}),
|
||||||
|
).toBe('archive')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('archive mode wins over an active session (user-chosen read-only view)', () => {
|
||||||
|
expect(
|
||||||
|
deriveLivePhase({
|
||||||
|
...base,
|
||||||
|
transport: 'connected',
|
||||||
|
isLive: true,
|
||||||
|
hasActiveSnapshot: true,
|
||||||
|
hasArchive: true,
|
||||||
|
archiveMode: true,
|
||||||
|
}),
|
||||||
|
).toBe('archive')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inactive when there is no session and nothing retained', () => {
|
||||||
|
expect(deriveLivePhase({ ...base, transport: 'connected', stateLoaded: true })).toBe('inactive')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves connecting for inactive once the initial state resolves, even mid-handshake', () => {
|
||||||
|
// connecting → inactive: the SSE transport handshake is still pending
|
||||||
|
// (transport === "connecting") but the initial live-state query resolved as
|
||||||
|
// idle, so we must not spin on "connecting…" forever.
|
||||||
|
expect(deriveLivePhase({ ...base, transport: 'connecting', stateLoaded: true })).toBe('inactive')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('phase capability helpers', () => {
|
||||||
|
it('renders the snapshot surface only for live/disconnected/archive', () => {
|
||||||
|
expect(rendersSnapshot('live')).toBe(true)
|
||||||
|
expect(rendersSnapshot('disconnected')).toBe(true)
|
||||||
|
expect(rendersSnapshot('archive')).toBe(true)
|
||||||
|
expect(rendersSnapshot('settling')).toBe(false)
|
||||||
|
expect(rendersSnapshot('inactive')).toBe(false)
|
||||||
|
expect(rendersSnapshot('connecting')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows live-only interpretations only while the session is moving', () => {
|
||||||
|
expect(allowsLiveInterpretations('live')).toBe(true)
|
||||||
|
expect(allowsLiveInterpretations('disconnected')).toBe(true)
|
||||||
|
// A frozen archive frame must never present live-only interpretations.
|
||||||
|
expect(allowsLiveInterpretations('archive')).toBe(false)
|
||||||
|
expect(allowsLiveInterpretations('settling')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks archive as the read-only phase', () => {
|
||||||
|
expect(isReadOnlyPhase('archive')).toBe(true)
|
||||||
|
expect(isReadOnlyPhase('live')).toBe(false)
|
||||||
|
expect(isReadOnlyPhase('disconnected')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('effectiveFeedHealth', () => {
|
||||||
|
it('downgrades a still-open SSE to reconnecting while phase is disconnected', () => {
|
||||||
|
expect(effectiveFeedHealth('connected', 'disconnected')).toBe('disconnected')
|
||||||
|
expect(effectiveFeedHealth('connecting', 'disconnected')).toBe('disconnected')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves transport when the session phase is live or settling', () => {
|
||||||
|
expect(effectiveFeedHealth('connected', 'live')).toBe('connected')
|
||||||
|
expect(effectiveFeedHealth('connected', 'settling')).toBe('connected')
|
||||||
|
expect(effectiveFeedHealth('error', 'disconnected')).toBe('error')
|
||||||
|
})
|
||||||
|
})
|
||||||
102
frontend/src/test/route-resilience.test.tsx
Normal file
102
frontend/src/test/route-resilience.test.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
|
||||||
|
import { DriverProfilePage } from '../pages/DriverProfilePage'
|
||||||
|
import { ApiError } from '../lib/fetch'
|
||||||
|
import type { DriverSummary } from '../types'
|
||||||
|
|
||||||
|
vi.mock('../api', () => ({
|
||||||
|
fetchSeasons: vi.fn(),
|
||||||
|
fetchDriverSummary: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { fetchSeasons, fetchDriverSummary } from '../api'
|
||||||
|
|
||||||
|
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
||||||
|
const mockFetchSummary = vi.mocked(fetchDriverSummary)
|
||||||
|
|
||||||
|
const summary: DriverSummary = {
|
||||||
|
season: 2025,
|
||||||
|
driver_number: 1,
|
||||||
|
name_acronym: 'VER',
|
||||||
|
full_name: 'Max Verstappen',
|
||||||
|
team_name: 'Red Bull',
|
||||||
|
team_colour: '3671c6',
|
||||||
|
headshot_url: '',
|
||||||
|
points: 50,
|
||||||
|
position: 1,
|
||||||
|
wins: 2,
|
||||||
|
podiums: 2,
|
||||||
|
poles: 1,
|
||||||
|
form: [25, 25],
|
||||||
|
cumulative: [25, 50],
|
||||||
|
round_labels: ['R1', 'R2'],
|
||||||
|
rounds: [],
|
||||||
|
source: 'local',
|
||||||
|
enrichment: 'limited',
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage(props: { driverNumber: number; year?: number }) {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
const rootRoute = createRootRoute({
|
||||||
|
component: () => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<DriverProfilePage driverNumber={props.driverNumber} year={props.year} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
const indexRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/',
|
||||||
|
component: () => null,
|
||||||
|
})
|
||||||
|
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
|
||||||
|
return render(<RouterProvider router={router} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DriverProfilePage resilience', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockFetchSeasons.mockResolvedValue([2025])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves loading after a timeout error and offers retry', async () => {
|
||||||
|
mockFetchSummary.mockRejectedValue(
|
||||||
|
new ApiError('timeout', 'Request timed out', {
|
||||||
|
userMessage: 'This request took too long. Check your connection, then retry.',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage({ driverNumber: 1, year: 2025 })
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('driver-profile-error')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.queryByTestId('driver-profile-loading')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('driver-profile-error')).toHaveTextContent(/took too long/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dedupes concurrent retries via a single refetch gate', async () => {
|
||||||
|
let calls = 0
|
||||||
|
mockFetchSummary.mockImplementation(async () => {
|
||||||
|
calls += 1
|
||||||
|
if (calls === 1) {
|
||||||
|
throw new ApiError('http', 'boom', { status: 500 })
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
})
|
||||||
|
|
||||||
|
renderPage({ driverNumber: 1, year: 2025 })
|
||||||
|
await waitFor(() => expect(screen.getByTestId('driver-profile-error')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const retry = screen.getByRole('button', { name: 'Retry' })
|
||||||
|
fireEvent.click(retry)
|
||||||
|
fireEvent.click(retry)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('driver-profile')).toBeInTheDocument())
|
||||||
|
expect(calls).toBe(2)
|
||||||
|
expect(screen.getByTestId('driver-profile-limited')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
24
frontend/src/test/routeSearch.test.ts
Normal file
24
frontend/src/test/routeSearch.test.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { parseRaceHubSearch, parseWeekendSearch, weekendFocusSearch } from '../lib/routeSearch'
|
||||||
|
|
||||||
|
describe('routeSearch', () => {
|
||||||
|
it('parses positive weekend meeting/session keys and drops invalid values', () => {
|
||||||
|
expect(parseWeekendSearch({ meeting_key: '1229', session_key: '9000' })).toEqual({
|
||||||
|
meeting_key: 1229,
|
||||||
|
session_key: 9000,
|
||||||
|
})
|
||||||
|
expect(parseWeekendSearch({ meeting_key: '0', session_key: '-1', foo: 'bar' })).toEqual({})
|
||||||
|
expect(parseWeekendSearch({})).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses race-hub session keys', () => {
|
||||||
|
expect(parseRaceHubSearch({ session_key: '9472' })).toEqual({ session_key: 9472 })
|
||||||
|
expect(parseRaceHubSearch({ session_key: 'nope' })).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds Weekend focus search for Race Hub returns', () => {
|
||||||
|
expect(weekendFocusSearch(1229, 9000)).toEqual({ meeting_key: 1229, session_key: 9000 })
|
||||||
|
expect(weekendFocusSearch(null, 9000)).toEqual({ session_key: 9000 })
|
||||||
|
expect(weekendFocusSearch(1229, undefined)).toEqual({ meeting_key: 1229 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,10 +5,8 @@ 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'
|
||||||
|
|
||||||
@@ -80,15 +78,4 @@ 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)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
146
frontend/src/test/sessionState.test.ts
Normal file
146
frontend/src/test/sessionState.test.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
resolveSessionState,
|
||||||
|
sessionState,
|
||||||
|
sessionStateLabel,
|
||||||
|
} from '../lib/sessionState'
|
||||||
|
import type {
|
||||||
|
ContextAvailability,
|
||||||
|
ContextSession,
|
||||||
|
DatasetInfo,
|
||||||
|
Session,
|
||||||
|
WeekendContext,
|
||||||
|
WeekendSession,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
const NOW = new Date('2025-06-01T00:00:00Z')
|
||||||
|
|
||||||
|
function mk(
|
||||||
|
overrides: Partial<Session>,
|
||||||
|
source: WeekendSession['source'],
|
||||||
|
datasets: Record<string, DatasetInfo> = {},
|
||||||
|
): WeekendSession {
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
session_key: 1,
|
||||||
|
session_name: 'Race',
|
||||||
|
session_type: 'Race',
|
||||||
|
meeting_key: 1,
|
||||||
|
date_start: '2025-05-25T13:00:00+00:00',
|
||||||
|
date_end: '2025-05-25T15:00:00+00:00',
|
||||||
|
gmt_offset: '00:00:00',
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
source,
|
||||||
|
datasets,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FULL: Record<string, DatasetInfo> = Object.fromEntries(
|
||||||
|
[
|
||||||
|
'meeting',
|
||||||
|
'session',
|
||||||
|
'drivers',
|
||||||
|
'results',
|
||||||
|
'starting_grid',
|
||||||
|
'stints',
|
||||||
|
'pit_stops',
|
||||||
|
'positions',
|
||||||
|
'race_control',
|
||||||
|
'weather',
|
||||||
|
'laps',
|
||||||
|
].map((k) => [k, { status: 'available', source: 'local', count: 1 }]),
|
||||||
|
)
|
||||||
|
|
||||||
|
function availability(overrides: Partial<ContextAvailability> = {}): ContextAvailability {
|
||||||
|
return {
|
||||||
|
schedule: 'available',
|
||||||
|
live_transport: 'unknown',
|
||||||
|
live_session: 'inactive',
|
||||||
|
archive: 'unavailable',
|
||||||
|
local_analysis: 'complete',
|
||||||
|
freshness: 'fresh',
|
||||||
|
limitations: [],
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextSession(
|
||||||
|
session: Session,
|
||||||
|
avail: Partial<ContextAvailability> = {},
|
||||||
|
): ContextSession {
|
||||||
|
return { session, availability: availability(avail) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function context(overrides: Partial<WeekendContext> = {}): WeekendContext {
|
||||||
|
return {
|
||||||
|
temporal_state: 'post_weekend',
|
||||||
|
championship_round: 1,
|
||||||
|
total_championship_rounds: 1,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('sessionState', () => {
|
||||||
|
it('marks a future session as upcoming', () => {
|
||||||
|
const s = mk({ date_start: '2099-05-25T13:00:00+00:00', date_end: '2099-05-25T15:00:00+00:00' }, 'none')
|
||||||
|
expect(sessionState(s, NOW)).toBe('upcoming')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not mark Live from schedule alone', () => {
|
||||||
|
const start = new Date(NOW.getTime() - 60_000).toISOString()
|
||||||
|
const end = new Date(NOW.getTime() + 60_000).toISOString()
|
||||||
|
const s = mk({ date_start: start, date_end: end }, 'none')
|
||||||
|
expect(sessionState(s, NOW)).toBe('preparing')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks Live only when Weekend Context active identity matches', () => {
|
||||||
|
const start = new Date(NOW.getTime() - 60_000).toISOString()
|
||||||
|
const end = new Date(NOW.getTime() + 60_000).toISOString()
|
||||||
|
const s = mk({ session_key: 42, date_start: start, date_end: end }, 'none')
|
||||||
|
const ctx = context({
|
||||||
|
temporal_state: 'session_live',
|
||||||
|
active_session: contextSession(s.session, { live_session: 'active' }),
|
||||||
|
})
|
||||||
|
expect(resolveSessionState({ weekendSession: s, context: ctx, now: NOW })).toBe('live')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a finished session with full local data as ready', () => {
|
||||||
|
const s = mk({}, 'local', FULL)
|
||||||
|
expect(sessionState(s, NOW)).toBe('ready')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a finished session with no data as preparing', () => {
|
||||||
|
const s = mk({}, 'none', {})
|
||||||
|
expect(sessionState(s, NOW)).toBe('preparing')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a finished session with partial data as partial', () => {
|
||||||
|
const s = mk({}, 'partial', { drivers: { status: 'available', source: 'local', count: 20 } })
|
||||||
|
expect(sessionState(s, NOW)).toBe('partial')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks unavailable from context availability', () => {
|
||||||
|
const s = mk({ session_key: 7 }, 'none')
|
||||||
|
const ctx = context({
|
||||||
|
previous_completed_session: contextSession(s.session, {
|
||||||
|
local_analysis: 'unavailable',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
expect(resolveSessionState({ weekendSession: s, context: ctx, now: NOW })).toBe(
|
||||||
|
'unavailable',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a cancelled session as cancelled', () => {
|
||||||
|
const s = mk({}, 'cancelled')
|
||||||
|
expect(sessionState(s, NOW)).toBe('cancelled')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses user language labels rather than coverage counts', () => {
|
||||||
|
expect(sessionStateLabel('ready')).toBe('Ready')
|
||||||
|
expect(sessionStateLabel('upcoming')).toBe('Upcoming')
|
||||||
|
expect(sessionStateLabel('partial')).toBe('Partial')
|
||||||
|
expect(sessionStateLabel('unavailable')).toBe('Unavailable')
|
||||||
|
})
|
||||||
|
})
|
||||||
223
frontend/src/test/weekendContext.test.ts
Normal file
223
frontend/src/test/weekendContext.test.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
analysisSessionKey,
|
||||||
|
briefingItems,
|
||||||
|
championshipImpact,
|
||||||
|
hasLocalAnalysis,
|
||||||
|
meetingIdentity,
|
||||||
|
podiumFromResults,
|
||||||
|
resolveViewState,
|
||||||
|
} from '../lib/weekendContext'
|
||||||
|
import type {
|
||||||
|
ChampionshipHub,
|
||||||
|
ContextAvailability,
|
||||||
|
ContextSession,
|
||||||
|
EnrichedResult,
|
||||||
|
Meeting,
|
||||||
|
NewsItem,
|
||||||
|
Session,
|
||||||
|
TemporalState,
|
||||||
|
WeekendContext,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
function availability(overrides: Partial<ContextAvailability> = {}): ContextAvailability {
|
||||||
|
return {
|
||||||
|
schedule: 'available',
|
||||||
|
live_transport: 'unknown',
|
||||||
|
live_session: 'inactive',
|
||||||
|
archive: 'unavailable',
|
||||||
|
local_analysis: 'complete',
|
||||||
|
freshness: 'fresh',
|
||||||
|
limitations: [],
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function meeting(overrides: Partial<Meeting> = {}): Meeting {
|
||||||
|
return {
|
||||||
|
meeting_key: 1,
|
||||||
|
meeting_name: 'British Grand Prix',
|
||||||
|
meeting_official_name: 'FORMULA 1 BRITISH GRAND PRIX',
|
||||||
|
location: 'Silverstone',
|
||||||
|
country_name: 'United Kingdom',
|
||||||
|
country_code: 'GBR',
|
||||||
|
country_flag: '',
|
||||||
|
circuit_key: 2,
|
||||||
|
circuit_short_name: 'Silverstone',
|
||||||
|
date_start: '2026-07-03T09:00:00Z',
|
||||||
|
date_end: '2026-07-05T16:00:00Z',
|
||||||
|
year: 2026,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function session(overrides: Partial<Session> = {}): Session {
|
||||||
|
return {
|
||||||
|
session_key: 11,
|
||||||
|
session_name: 'Race',
|
||||||
|
session_type: 'Race',
|
||||||
|
meeting_key: 1,
|
||||||
|
date_start: '2026-07-05T14:00:00Z',
|
||||||
|
date_end: '2026-07-05T16:00:00Z',
|
||||||
|
gmt_offset: '',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ctxSession(overrides: Partial<ContextSession> = {}): ContextSession {
|
||||||
|
return {
|
||||||
|
session: session(),
|
||||||
|
meeting: meeting(),
|
||||||
|
availability: availability(),
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function context(overrides: Partial<WeekendContext> = {}): WeekendContext {
|
||||||
|
return {
|
||||||
|
season: 2026,
|
||||||
|
temporal_state: 'between_weekends',
|
||||||
|
championship_round: 1,
|
||||||
|
total_championship_rounds: 24,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resolveViewState', () => {
|
||||||
|
const cases: Array<[TemporalState, string]> = [
|
||||||
|
['no_season', 'no_season'],
|
||||||
|
['between_weekends', 'between_weekends'],
|
||||||
|
['pre_session', 'pre_session'],
|
||||||
|
['session_live', 'session_live'],
|
||||||
|
['session_settling', 'session_settling'],
|
||||||
|
['between_sessions', 'between_sessions'],
|
||||||
|
['post_weekend', 'post_weekend'],
|
||||||
|
['season_complete', 'season_complete'],
|
||||||
|
]
|
||||||
|
|
||||||
|
it.each(cases)('maps canonical temporal_state %s to view %s', (temporal, expected) => {
|
||||||
|
expect(resolveViewState(context({ temporal_state: temporal }))).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats an unknown/absent temporal_state as no_season rather than inventing a state', () => {
|
||||||
|
expect(resolveViewState(context({ temporal_state: 'garbage' as TemporalState }))).toBe('no_season')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('analysisSessionKey', () => {
|
||||||
|
it('prefers the default analysis session', () => {
|
||||||
|
const ctx = context({
|
||||||
|
default_analysis_session: ctxSession({ session: session({ session_key: 32 }) }),
|
||||||
|
previous_completed_session: ctxSession({ session: session({ session_key: 11 }) }),
|
||||||
|
})
|
||||||
|
expect(analysisSessionKey(ctx)).toBe(32)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the previous completed session', () => {
|
||||||
|
const ctx = context({
|
||||||
|
previous_completed_session: ctxSession({ session: session({ session_key: 11 }) }),
|
||||||
|
})
|
||||||
|
expect(analysisSessionKey(ctx)).toBe(11)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined when there is no analysable session', () => {
|
||||||
|
expect(analysisSessionKey(context())).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined for a zero/synthetic session key', () => {
|
||||||
|
const ctx = context({
|
||||||
|
previous_completed_session: ctxSession({ session: session({ session_key: 0 }) }),
|
||||||
|
})
|
||||||
|
expect(analysisSessionKey(ctx)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('hasLocalAnalysis', () => {
|
||||||
|
it('is true for complete or partial local analysis', () => {
|
||||||
|
expect(hasLocalAnalysis(ctxSession({ availability: availability({ local_analysis: 'complete' }) }))).toBe(true)
|
||||||
|
expect(hasLocalAnalysis(ctxSession({ availability: availability({ local_analysis: 'partial' }) }))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is false for pending/not_applicable/absent', () => {
|
||||||
|
expect(hasLocalAnalysis(ctxSession({ availability: availability({ local_analysis: 'pending' }) }))).toBe(false)
|
||||||
|
expect(hasLocalAnalysis(ctxSession({ availability: availability({ local_analysis: 'not_applicable' }) }))).toBe(false)
|
||||||
|
expect(hasLocalAnalysis(undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('meetingIdentity', () => {
|
||||||
|
it('derives a short name and tolerates partial fields', () => {
|
||||||
|
const id = meetingIdentity(meeting({ meeting_name: 'Monaco Grand Prix', circuit_short_name: '', location: 'Monaco' }))
|
||||||
|
expect(id?.short_name).toBe('Monaco')
|
||||||
|
expect(id?.circuit_short_name).toBe('Monaco')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined for an absent meeting', () => {
|
||||||
|
expect(meetingIdentity(undefined)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops empty date strings', () => {
|
||||||
|
const id = meetingIdentity(meeting({ date_start: '', date_end: '' }))
|
||||||
|
expect(id?.date_start).toBeUndefined()
|
||||||
|
expect(id?.date_end).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('championshipImpact', () => {
|
||||||
|
const hub: ChampionshipHub = {
|
||||||
|
season: 2026,
|
||||||
|
round: 2,
|
||||||
|
total_rounds: 24,
|
||||||
|
rounds_left: 22,
|
||||||
|
last_race: 'British GP',
|
||||||
|
round_labels: ['R1', 'R2'],
|
||||||
|
drivers: [
|
||||||
|
{ driver_number: 1, name_acronym: 'VER', full_name: 'Max', team_name: 'RB', team_colour: '3671c6', points: 50, position: 1, wins: 2, podiums: 2, poles: 1, form: [], cumulative: [25, 50], teammate_wins: 0, teammate_losses: 0, round_positions: [] },
|
||||||
|
{ driver_number: 4, name_acronym: 'NOR', full_name: 'Lando', team_name: 'McL', team_colour: 'ff8000', points: 40, position: 2, wins: 1, podiums: 2, poles: 0, form: [], cumulative: [18, 40], teammate_wins: 0, teammate_losses: 0, round_positions: [] },
|
||||||
|
],
|
||||||
|
teams: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
it('maps top-3 movers with deltas and a note', () => {
|
||||||
|
const impact = championshipImpact(hub)
|
||||||
|
expect(impact?.leaders).toHaveLength(2)
|
||||||
|
expect(impact?.leaders[0].delta).toBe(25)
|
||||||
|
expect(impact?.note).toContain('British GP')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined for an empty hub', () => {
|
||||||
|
expect(championshipImpact(undefined)).toBeUndefined()
|
||||||
|
expect(championshipImpact({ ...hub, drivers: [] })).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('briefingItems', () => {
|
||||||
|
it('caps at three items and maps fields', () => {
|
||||||
|
const news: NewsItem[] = Array.from({ length: 5 }, (_, i) => ({
|
||||||
|
source: 'src',
|
||||||
|
title: `Item ${i}`,
|
||||||
|
url: `https://x/${i}`,
|
||||||
|
fetched_at: '2026-07-06T00:00:00Z',
|
||||||
|
category: 'news',
|
||||||
|
og_image_url: 'img',
|
||||||
|
}))
|
||||||
|
const items = briefingItems(news)
|
||||||
|
expect(items).toHaveLength(3)
|
||||||
|
expect(items[0]).toMatchObject({ title: 'Item 0', image_url: 'img' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('podiumFromResults', () => {
|
||||||
|
it('sorts by position, caps at three, and formats gaps', () => {
|
||||||
|
const results: EnrichedResult[] = [
|
||||||
|
{ driver_number: 44, position: 2, name_acronym: 'HAM', full_name: 'Lewis', team_name: 'Ferrari', team_colour: 'e8002d', dnf: false, dns: false, dsq: false, duration: null, gap_to_leader: 5.123 } as EnrichedResult,
|
||||||
|
{ driver_number: 1, position: 1, name_acronym: 'VER', full_name: 'Max', team_name: 'RB', team_colour: '3671c6', dnf: false, dns: false, dsq: false, duration: 5400, gap_to_leader: null } as EnrichedResult,
|
||||||
|
{ driver_number: 16, position: 3, name_acronym: 'LEC', full_name: 'Charles', team_name: 'Ferrari', team_colour: 'e8002d', dnf: false, dns: false, dsq: false, duration: null, gap_to_leader: '+10.5' } as EnrichedResult,
|
||||||
|
{ driver_number: 55, position: 4, name_acronym: 'SAI', full_name: 'Carlos', team_name: 'W', team_colour: 'fff', dnf: false, dns: false, dsq: false, duration: null, gap_to_leader: 20 } as EnrichedResult,
|
||||||
|
]
|
||||||
|
const podium = podiumFromResults(results)
|
||||||
|
expect(podium.map((p) => p.name_acronym)).toEqual(['VER', 'HAM', 'LEC'])
|
||||||
|
expect(podium[1].gap).toBe('+5.123')
|
||||||
|
expect(podium[2].gap).toBe('+10.5')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -217,41 +217,6 @@ 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
|
||||||
@@ -481,6 +446,113 @@ export interface DriverSummary {
|
|||||||
cumulative: number[]
|
cumulative: number[]
|
||||||
round_labels: string[]
|
round_labels: string[]
|
||||||
rounds: DriverSummaryRound[]
|
rounds: DriverSummaryRound[]
|
||||||
|
/** Data origin: local domain DB or OpenF1 fallback. */
|
||||||
|
source?: 'local' | 'openf1'
|
||||||
|
/** Optional remote enrichment status — limited means profile still renders. */
|
||||||
|
enrichment?: 'full' | 'limited' | 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Weekend Context ──
|
||||||
|
// Canonical local-first contract for the adaptive Weekend home, served verbatim
|
||||||
|
// by /api/v1/weekend-context (backend story #72, internal/query/context.go).
|
||||||
|
// The frontend consumes this shape as the single source of truth; view-model
|
||||||
|
// derivation lives in lib/weekendContext.ts.
|
||||||
|
|
||||||
|
// TemporalState mirrors query.TemporalState exactly (the JSON `temporal_state`).
|
||||||
|
export type TemporalState =
|
||||||
|
| 'no_season'
|
||||||
|
| 'between_weekends'
|
||||||
|
| 'pre_session'
|
||||||
|
| 'session_live'
|
||||||
|
| 'session_settling'
|
||||||
|
| 'between_sessions'
|
||||||
|
| 'post_weekend'
|
||||||
|
| 'season_complete'
|
||||||
|
|
||||||
|
// ContextAvailability mirrors query.ContextAvailability. Every field is present
|
||||||
|
// in a canonical payload except the optional `observed_at`.
|
||||||
|
export interface ContextAvailability {
|
||||||
|
/** Domain / FIA provenance: local | mixed | fia (additive; may be absent on older payloads). */
|
||||||
|
source?: string
|
||||||
|
schedule: string
|
||||||
|
live_transport: string
|
||||||
|
live_session: string
|
||||||
|
archive: string
|
||||||
|
local_analysis: string
|
||||||
|
freshness: string
|
||||||
|
observed_at?: string
|
||||||
|
limitations: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContextSession mirrors query.ContextSession: a session identity coupled with
|
||||||
|
// its meeting and structured availability contract.
|
||||||
|
export interface ContextSession {
|
||||||
|
session: Session
|
||||||
|
meeting?: Meeting
|
||||||
|
availability: ContextAvailability
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeekendContext mirrors query.WeekendContext (the canonical endpoint body).
|
||||||
|
export interface WeekendContext {
|
||||||
|
season?: number
|
||||||
|
temporal_state: TemporalState
|
||||||
|
previous_meeting?: Meeting
|
||||||
|
focus_meeting?: Meeting
|
||||||
|
next_meeting?: Meeting
|
||||||
|
previous_completed_session?: ContextSession
|
||||||
|
active_session?: ContextSession
|
||||||
|
next_session?: ContextSession
|
||||||
|
default_analysis_session?: ContextSession
|
||||||
|
championship_round: number
|
||||||
|
total_championship_rounds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Weekend view model (client-only) ──
|
||||||
|
// The rendered Weekend home layers a small set of non-canonical UI states
|
||||||
|
// (loading/error) plus supplementary data (championship movers, briefing) on top
|
||||||
|
// of the canonical context. None of these are part of the #72 contract.
|
||||||
|
export type WeekendViewState =
|
||||||
|
| 'loading'
|
||||||
|
| 'error'
|
||||||
|
| 'no_season'
|
||||||
|
| 'between_weekends'
|
||||||
|
| 'pre_session'
|
||||||
|
| 'session_live'
|
||||||
|
| 'session_settling'
|
||||||
|
| 'between_sessions'
|
||||||
|
| 'post_weekend'
|
||||||
|
| 'season_complete'
|
||||||
|
|
||||||
|
export interface WeekendChampionshipMover {
|
||||||
|
position: number
|
||||||
|
driver_number: number
|
||||||
|
name_acronym: string
|
||||||
|
team_colour: string
|
||||||
|
points: number
|
||||||
|
delta?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeekendChampionshipImpact {
|
||||||
|
leaders: WeekendChampionshipMover[]
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeekendBriefingItem {
|
||||||
|
category?: string
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
source: string
|
||||||
|
published_at?: string
|
||||||
|
image_url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeekendPodiumEntry {
|
||||||
|
position: number
|
||||||
|
driver_number: number
|
||||||
|
name_acronym: string
|
||||||
|
team_name: string
|
||||||
|
team_colour: string
|
||||||
|
gap: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NewsItem {
|
export interface NewsItem {
|
||||||
|
|||||||
@@ -307,28 +307,6 @@ func TestSessionStatusIsActive(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSessionStatusIsTerminal(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
status string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"Finished", true},
|
|
||||||
{"Finalised", true},
|
|
||||||
{"Finalized", true},
|
|
||||||
{"Ends", true},
|
|
||||||
{"Aborted", true},
|
|
||||||
{"Started", false},
|
|
||||||
{"Resumed", false},
|
|
||||||
{"Inactive", false},
|
|
||||||
{"", false},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
if got := live.SessionStatusIsTerminal(tt.status); got != tt.want {
|
|
||||||
t.Errorf("SessionStatusIsTerminal(%q) = %v, want %v", tt.status, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessTopicRaceControlMessages(t *testing.T) {
|
func TestProcessTopicRaceControlMessages(t *testing.T) {
|
||||||
state := live.NewState()
|
state := live.NewState()
|
||||||
data := json.RawMessage(`{
|
data := json.RawMessage(`{
|
||||||
@@ -472,62 +450,6 @@ 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"}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -405,42 +404,22 @@ 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 {
|
||||||
// The feed sends stints as sparse deltas keyed by stint index:
|
var driverStints []LiveStintData
|
||||||
// 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 {
|
if json.Unmarshal(sRaw.Raw, &st) == nil && st.Compound != "" {
|
||||||
continue
|
driverStints = append(driverStints, LiveStintData{
|
||||||
|
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 {
|
if len(driverStints) > 0 {
|
||||||
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 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]
|
||||||
@@ -895,13 +874,8 @@ 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 {
|
||||||
// Keys are array indices in the feed's delta form. Non-numeric keys
|
i := 0
|
||||||
// are feed metadata — "_kf" (key frame) is the common one — and must
|
fmt.Sscanf(k, "%d", &i)
|
||||||
// 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 {
|
||||||
|
|||||||
@@ -186,19 +186,6 @@ func SessionStatusIsActive(status string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionStatusIsTerminal reports whether a raw F1 live timing SessionStatus
|
|
||||||
// value represents a session that has ended and will not resume. A temporarily
|
|
||||||
// inactive session (e.g. a red-flag pause reported as "Inactive") is neither
|
|
||||||
// active nor terminal.
|
|
||||||
func SessionStatusIsTerminal(status string) bool {
|
|
||||||
switch normalizeSessionStatus(status) {
|
|
||||||
case "finished", "finalised", "finalized", "ends", "aborted":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeSessionStatus(status string) string {
|
func normalizeSessionStatus(status string) string {
|
||||||
out := make([]rune, 0, len(status))
|
out := make([]rune, 0, len(status))
|
||||||
for _, r := range status {
|
for _, r := range status {
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ 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
|
||||||
@@ -70,9 +69,6 @@ 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"`
|
||||||
}
|
}
|
||||||
@@ -154,7 +150,7 @@ func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var previous, next, defaultAnalysis, pending *contextCandidate
|
var previous, next, defaultAnalysis *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
|
||||||
@@ -168,10 +164,6 @@ 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 {
|
||||||
@@ -196,40 +188,9 @@ 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 {
|
||||||
|
|||||||
@@ -223,38 +223,6 @@ 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)
|
||||||
@@ -399,88 +367,3 @@ 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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ type WeekendSession struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Weekend is the local-first read model for one race weekend.
|
// Weekend is the local-first read model for one race weekend.
|
||||||
|
// Fan-facing default analysis resolution lives on /api/v1/weekend-context
|
||||||
|
// (DefaultAnalysisSession); this payload only supplies meeting rail + coverage.
|
||||||
type Weekend struct {
|
type Weekend struct {
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
MeetingKey int `json:"meeting_key"`
|
MeetingKey int `json:"meeting_key"`
|
||||||
|
|||||||
@@ -167,30 +167,3 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ func (h *SSEHub) applySnapshot(data live.LiveStreamData, now time.Time) liveStat
|
|||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
if snapshotIsLive(data) {
|
if live.SessionStatusIsActive(data.SessionStatus) {
|
||||||
h.isLive = true
|
h.isLive = true
|
||||||
h.activeSnapshot = &data
|
h.activeSnapshot = &data
|
||||||
if data.PositionUpdated && len(data.Positions) > 0 {
|
if data.PositionUpdated && len(data.Positions) > 0 {
|
||||||
@@ -208,40 +208,6 @@ func (h *SSEHub) stateLocked() liveStatePayload {
|
|||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
// snapshotIsLive reports whether the current live-timing snapshot represents an
|
|
||||||
// ongoing session that should be surfaced as live.
|
|
||||||
//
|
|
||||||
// An actively running session (Started/Resumed) is always live. A non-terminal
|
|
||||||
// but temporarily inactive session — e.g. a red-flag pause where SessionStatus
|
|
||||||
// drops to "Inactive" while the session is still in progress — is also live when
|
|
||||||
// the snapshot itself carries live evidence: a session clock with time remaining
|
|
||||||
// plus session metadata. This keeps the paused track state visible instead of
|
|
||||||
// collapsing to the inactive empty state.
|
|
||||||
//
|
|
||||||
// Terminal sessions (Finished/Finalised/Ends/Aborted) are never live. A generic
|
|
||||||
// inactive/no-session stale snapshot is not live either: the predicate keys off
|
|
||||||
// the current snapshot's clock and session, so old metadata alone is not enough.
|
|
||||||
func snapshotIsLive(data live.LiveStreamData) bool {
|
|
||||||
if live.SessionStatusIsActive(data.SessionStatus) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if live.SessionStatusIsTerminal(data.SessionStatus) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return clockHasTimeRemaining(data.Clock) && data.Session.SessionName != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// clockHasTimeRemaining reports whether an "HH:MM:SS" session clock has any time
|
|
||||||
// left. Empty or all-zero clocks (a spent or absent session) return false.
|
|
||||||
func clockHasTimeRemaining(clock string) bool {
|
|
||||||
for _, r := range clock {
|
|
||||||
if r >= '1' && r <= '9' {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasLiveSnapshotData(data live.LiveStreamData) bool {
|
func hasLiveSnapshotData(data live.LiveStreamData) bool {
|
||||||
return len(data.Drivers) > 0 ||
|
return len(data.Drivers) > 0 ||
|
||||||
len(data.DriverInfo) > 0 ||
|
len(data.DriverInfo) > 0 ||
|
||||||
|
|||||||
@@ -51,101 +51,6 @@ func TestSSEHubArchivesTerminalSessionSnapshot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSnapshotIsLive(t *testing.T) {
|
|
||||||
redFlag := live.LiveStreamData{
|
|
||||||
SessionStatus: "Inactive",
|
|
||||||
TrackStatus: "2",
|
|
||||||
Clock: "00:03:27",
|
|
||||||
Session: live.LiveSessionMeta{MeetingName: "Belgian Grand Prix", SessionName: "Practice 2", SessionType: "Practice"},
|
|
||||||
}
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
data live.LiveStreamData
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"started", live.LiveStreamData{SessionStatus: "Started"}, true},
|
|
||||||
{"resumed", live.LiveStreamData{SessionStatus: "Resumed"}, true},
|
|
||||||
{"red flag inactive with clock and session", redFlag, true},
|
|
||||||
{"terminal finished with clock", func() live.LiveStreamData {
|
|
||||||
d := redFlag
|
|
||||||
d.SessionStatus = "Finished"
|
|
||||||
return d
|
|
||||||
}(), false},
|
|
||||||
{"inactive spent clock", func() live.LiveStreamData {
|
|
||||||
d := redFlag
|
|
||||||
d.Clock = "00:00:00"
|
|
||||||
return d
|
|
||||||
}(), false},
|
|
||||||
{"inactive no clock", func() live.LiveStreamData {
|
|
||||||
d := redFlag
|
|
||||||
d.Clock = ""
|
|
||||||
return d
|
|
||||||
}(), false},
|
|
||||||
{"inactive no session metadata", func() live.LiveStreamData {
|
|
||||||
d := redFlag
|
|
||||||
d.Session = live.LiveSessionMeta{}
|
|
||||||
return d
|
|
||||||
}(), false},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
if got := snapshotIsLive(tt.data); got != tt.want {
|
|
||||||
t.Errorf("%s: snapshotIsLive = %v, want %v", tt.name, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSSEHubKeepsRedFlagSessionLive(t *testing.T) {
|
|
||||||
hub := newSSEHub()
|
|
||||||
now := time.Date(2026, 7, 26, 14, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
redFlag := live.LiveStreamData{
|
|
||||||
SessionStatus: "Inactive",
|
|
||||||
TrackStatus: "2",
|
|
||||||
Clock: "00:03:27",
|
|
||||||
Session: live.LiveSessionMeta{MeetingName: "Belgian Grand Prix", SessionName: "Practice 2", SessionType: "Practice"},
|
|
||||||
SnapshotUpdated: true,
|
|
||||||
}
|
|
||||||
state := hub.applySnapshot(redFlag, now)
|
|
||||||
|
|
||||||
if !state.IsLive {
|
|
||||||
t.Fatal("red-flag paused session should report is_live=true")
|
|
||||||
}
|
|
||||||
if state.Data == nil {
|
|
||||||
t.Fatal("red-flag paused session must still send the active snapshot")
|
|
||||||
}
|
|
||||||
if state.Data.TrackStatus != "2" {
|
|
||||||
t.Fatalf("track status = %q, want the paused state \"2\"", state.Data.TrackStatus)
|
|
||||||
}
|
|
||||||
if state.LastSnapshot != nil {
|
|
||||||
t.Fatalf("red-flag session should not be archived: %+v", state.LastSnapshot)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSSEHubStaleInactiveSnapshotNotLive(t *testing.T) {
|
|
||||||
hub := newSSEHub()
|
|
||||||
now := time.Date(2026, 7, 26, 14, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
// Inactive snapshot with session metadata but no remaining clock: this is a
|
|
||||||
// stale/no-live-evidence snapshot and must not be surfaced as live.
|
|
||||||
stale := live.LiveStreamData{
|
|
||||||
SessionStatus: "Inactive",
|
|
||||||
Clock: "00:00:00",
|
|
||||||
Session: live.LiveSessionMeta{MeetingName: "Belgian Grand Prix", SessionName: "Practice 2"},
|
|
||||||
SnapshotUpdated: true,
|
|
||||||
}
|
|
||||||
state := hub.applySnapshot(stale, now)
|
|
||||||
|
|
||||||
if state.IsLive {
|
|
||||||
t.Fatal("stale inactive snapshot should report is_live=false")
|
|
||||||
}
|
|
||||||
if state.Data != nil {
|
|
||||||
t.Fatalf("stale inactive snapshot leaked into active data: %+v", state.Data)
|
|
||||||
}
|
|
||||||
if state.LastSnapshot == nil {
|
|
||||||
t.Fatal("stale inactive snapshot with metadata should be archived")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleLiveStateKeepsArchiveOutOfActiveData(t *testing.T) {
|
func TestHandleLiveStateKeepsArchiveOutOfActiveData(t *testing.T) {
|
||||||
hub := newSSEHub()
|
hub := newSSEHub()
|
||||||
now := time.Date(2026, 7, 4, 14, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 7, 4, 14, 0, 0, 0, time.UTC)
|
||||||
|
|||||||
@@ -11,12 +11,7 @@
|
|||||||
"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": {
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
@@ -7,11 +7,10 @@ const WEB_PORT = process.env.BOXBOX_WEB_PORT ?? '15173'
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests/visual',
|
testDir: './tests/visual',
|
||||||
// All projects share one seeded SQLite database behind the same Go server.
|
fullyParallel: true,
|
||||||
fullyParallel: false,
|
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: 1,
|
workers: process.env.CI ? 1 : undefined,
|
||||||
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: {
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
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 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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/)
|
|
||||||
})
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
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
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -84,14 +84,20 @@ 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: "2025-05-25T13:00:00+00:00",
|
DateStart: start,
|
||||||
DateEnd: "2025-05-25T15:00:00+00:00",
|
DateEnd: end,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
78
tests/fixtures/future-session.ts
vendored
Normal file
78
tests/fixtures/future-session.ts
vendored
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
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),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
385
tests/issue-76-availability.spec.ts
Normal file
385
tests/issue-76-availability.spec.ts
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -93,6 +93,7 @@ const raceSnapshot = {
|
|||||||
SessionType: 'Race',
|
SessionType: 'Race',
|
||||||
SessionName: 'Race',
|
SessionName: 'Race',
|
||||||
},
|
},
|
||||||
|
SessionStatus: 'Started',
|
||||||
TrackStatus: '2',
|
TrackStatus: '2',
|
||||||
CurrentLap: 30,
|
CurrentLap: 30,
|
||||||
TotalLaps: 57,
|
TotalLaps: 57,
|
||||||
@@ -138,6 +139,7 @@ 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,
|
||||||
@@ -147,58 +149,6 @@ 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) =>
|
||||||
@@ -216,6 +166,12 @@ 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')
|
||||||
@@ -285,133 +241,266 @@ test.describe('Live Timing (mocked Sprint Qualifying)', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test.describe('Live Timing (mocked Practice)', () => {
|
const weekendContext = (localAnalysis: string) => ({
|
||||||
test.beforeEach(async ({ page }) => {
|
temporal_state: 'session_settling',
|
||||||
await page.route('**/api/v1/live/state', (route) =>
|
focus_meeting: {
|
||||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify(practiceSnapshot) }),
|
meeting_key: 1,
|
||||||
)
|
meeting_name: 'Testonia Grand Prix',
|
||||||
await page.route('**/api/v1/live/stream', (route) =>
|
meeting_official_name: 'Testonia Grand Prix',
|
||||||
route.fulfill({ contentType: 'text/event-stream', body: 'event: heartbeat\ndata: {}\n\n' }),
|
location: 'Testring',
|
||||||
)
|
country_name: 'Testonia',
|
||||||
await page.goto('/live')
|
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,
|
||||||
|
},
|
||||||
|
// Just-finished session lives in previous_completed_session (canonical contract).
|
||||||
|
previous_completed_session: {
|
||||||
|
session: {
|
||||||
|
session_key: 9472,
|
||||||
|
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,
|
||||||
})
|
})
|
||||||
|
|
||||||
test('derives a gap to P1 from best laps and marks the leader', async ({ page }) => {
|
/** Archive-only just-finished Race + older already-ready Practice default. */
|
||||||
const tower = page.locator('.live-tower')
|
const archiveOnlySettlingContext = (previousAnalysis: string) => ({
|
||||||
await expect(tower).toBeVisible()
|
...weekendContext(previousAnalysis),
|
||||||
// P1 shows a leader marker, not a fabricated gap.
|
default_analysis_session: {
|
||||||
await expect(tower.locator('tbody tr').first()).toContainText('—')
|
session: {
|
||||||
// P2 shows the computed +0.200 delta.
|
session_key: 9001,
|
||||||
await expect(tower).toContainText('+0.200')
|
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: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
test('omits the race lap counter and keeps the session clock for practice', async ({ page }) => {
|
const heartbeatStream = (route: import('@playwright/test').Route) =>
|
||||||
await expect(page.getByTestId('live-lap-counter')).toHaveCount(0)
|
route.fulfill({ contentType: 'text/event-stream', body: 'event: heartbeat\ndata: {}\n\n' })
|
||||||
await expect(page.getByTestId('live-clock')).toContainText('00:45:00')
|
|
||||||
await expect(page.locator('.live-banner')).not.toContainText('L-/-')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('collapses the tyre panel by default so the tower is above the fold', async ({ page }) => {
|
|
||||||
await expect(page.getByTestId('tyredeg-panel')).toBeVisible()
|
|
||||||
await expect(page.getByTestId('tyredeg-row')).toHaveCount(0)
|
|
||||||
await expect(page.locator('.live-tower')).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('labels Race Control times as UTC', async ({ page }) => {
|
|
||||||
await expect(page.getByTestId('rc-timezone')).toContainText('UTC')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('marks Live as active in the primary navigation', async ({ page }) => {
|
|
||||||
await expect(page.getByTestId('nav-live')).toHaveAttribute('data-live-active', 'true')
|
|
||||||
await expect(page.getByTestId('nav-live').locator('.nav-live-dot')).toBeVisible()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test.describe('Live Timing (390px practice viewport)', () => {
|
|
||||||
test.use({ viewport: { width: 390, height: 844 } })
|
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
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 empty state when the feed has no snapshot', async ({ page }) => {
|
test('shows the inactive weekend-context handoff when the feed has no snapshot', async ({ page }) => {
|
||||||
|
await page.route('**/api/v1/live/stream', heartbeatStream)
|
||||||
await page.goto('/live')
|
await page.goto('/live')
|
||||||
await expect(page.getByTestId('live-empty')).toBeVisible()
|
// The e2e server (BOXBOX_DISABLE_LIVE=1) exposes a real /api/v1/weekend-context.
|
||||||
await expect(page.getByTestId('live-page')).toContainText('No live session active')
|
await expect(page.getByTestId('live-inactive')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', 'inactive')
|
||||||
|
await expect(page.getByText('NO LIVE SESSION')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('renders an archived snapshot only after View Last Session', async ({ page }) => {
|
test('enters settling (not archive) when a finished session is retained', 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: {
|
last_snapshot: { ...raceSnapshot.data, SessionStatus: 'Finished' },
|
||||||
...raceSnapshot.data,
|
last_positions: { '1': { x: 100, y: -50, z: 2, status: 'OnTrack' } },
|
||||||
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) =>
|
||||||
|
route.fulfill({ contentType: 'application/json', body: JSON.stringify(weekendContext('complete')) }),
|
||||||
|
)
|
||||||
|
await page.route('**/api/v1/live/stream', heartbeatStream)
|
||||||
|
|
||||||
|
await page.goto('/live')
|
||||||
|
await expect(page.getByTestId('live-settling')).toBeVisible()
|
||||||
|
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)
|
||||||
|
// 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.getByTestId('live-handoff-archive').click()
|
||||||
|
await expect(page.getByTestId('live-archive-strip')).toContainText('read-only')
|
||||||
|
await expect(page.getByText('Timing Tower')).toBeVisible()
|
||||||
|
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) =>
|
await page.route('**/api/v1/live/stream', (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
contentType: 'text/event-stream',
|
contentType: 'text/event-stream',
|
||||||
body: 'event: heartbeat\ndata: {}\n\n',
|
headers: { 'Cache-Control': 'no-cache' },
|
||||||
|
body: `event: snapshot\ndata: ${JSON.stringify(raceSnapshot)}\n\n`,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
await page.goto('/live')
|
await page.goto('/live')
|
||||||
await expect(page.getByTestId('live-empty')).toContainText('No live session active')
|
await expect(page.getByText('Timing Tower')).toBeVisible({ timeout: 15_000 })
|
||||||
await expect(page.getByText('Timing Tower')).toHaveCount(0)
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'View Last Session' }).click()
|
test('clears fatal initial error once SSE supplies an inactive null state', async ({ page }) => {
|
||||||
await expect(page.getByTestId('live-archive-strip')).toContainText('Archived snapshot')
|
await page.route('**/api/v1/live/state', (route) =>
|
||||||
await expect(page.getByText('Timing Tower')).toBeVisible()
|
route.fulfill({
|
||||||
await expect(page.locator('.live-state')).toContainText('archive')
|
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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 command center as default route', async ({ page }) => {
|
test('serves the Weekend home as default route', async ({ page }) => {
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
|
|
||||||
await expect(page.getByTestId('command-center')).toBeVisible()
|
await expect(page.getByTestId('weekend-page')).toBeVisible()
|
||||||
await expect(page.getByTestId('cc-session-9472')).toBeVisible()
|
await expect(page.getByTestId('weekend-between-races')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('serves race hub workspace from built assets', async ({ page }) => {
|
test('serves race hub workspace from built assets', async ({ page }) => {
|
||||||
@@ -18,8 +18,15 @@ 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()
|
|
||||||
await expect(page.locator('.drv-code', { hasText: 'VER' })).toBeVisible()
|
const outcome = page.getByRole('region', { name: 'Final running order' })
|
||||||
|
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 }) => {
|
||||||
@@ -37,23 +44,28 @@ 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 empty state when live is disabled', async ({ page }) => {
|
test('serves live route with the inactive handoff when live is disabled', async ({ page }) => {
|
||||||
await page.goto('/live')
|
await page.goto('/live')
|
||||||
|
|
||||||
await expect(page.getByTestId('live-empty')).toBeVisible()
|
await expect(page.getByTestId('live-inactive')).toBeVisible()
|
||||||
await expect(page.getByText('No live session active')).toBeVisible()
|
await expect(page.getByText('NO LIVE SESSION')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('nav links work from built SPA', async ({ page }) => {
|
test('primary nav links and Admin utility work from built SPA', async ({ page }) => {
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
await page.locator('.app-nav').getByRole('link', { name: 'Race Hub' }).click()
|
const primary = page.getByRole('navigation', { name: 'Primary' }).first()
|
||||||
await expect(page).toHaveURL(/\/race-hub/)
|
|
||||||
|
|
||||||
await page.locator('.app-nav').getByRole('link', { name: 'Admin', exact: true }).click()
|
await primary.getByRole('link', { name: 'Championship', 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()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,43 +1,8 @@
|
|||||||
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 }) => {
|
||||||
@@ -51,6 +16,7 @@ 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 }) => {
|
||||||
@@ -123,102 +89,110 @@ 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('Data Status tab points at admin instead of inline CLI hints', async ({ page }) => {
|
test('Diagnostics is a secondary action and points at admin, not 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: 'Data Status' }).click()
|
await page.getByRole('tab', { name: 'Diagnostics' }).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('bare /race-hub shows server-selected completed analysis without changing the URL', async ({ page }) => {
|
test('groups analysis navigation into Story, Analysis, and Data & Context', async ({ page }) => {
|
||||||
await page.route('**/api/v1/weekend-context', (route) =>
|
|
||||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify(completedContext()) }),
|
|
||||||
)
|
|
||||||
await page.goto('/race-hub')
|
|
||||||
await expect(page).toHaveURL(/\/race-hub$/)
|
|
||||||
await expect(page.getByTestId('race-hub')).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('bare /race-hub hands off to pending pre-session state at the refresh deadline', async ({ page }) => {
|
|
||||||
await page.clock.install({ time: new Date('2030-01-01T00:00:00Z') })
|
|
||||||
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 page.clock.fastForward(1_000)
|
|
||||||
|
|
||||||
await expect(page.getByTestId('race-hub-pre-session')).toBeVisible()
|
|
||||||
await expect(page).toHaveURL(/\/race-hub$/)
|
|
||||||
expect(raceHubRequests).toContain(FULL_SESSION)
|
|
||||||
expect(raceHubRequests).not.toContain(9473)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('pre-session state opens the weekend switcher and navigates to an explicit session', async ({ page }) => {
|
|
||||||
await page.route('**/api/v1/weekend-context', (route) =>
|
|
||||||
route.fulfill({
|
|
||||||
contentType: 'application/json',
|
|
||||||
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 page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||||
|
await expect(page.getByTestId('rh-tabgroup-story')).toBeVisible()
|
||||||
|
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.getByTestId('race-hub')).toBeVisible()
|
||||||
await expect(page).toHaveURL(new RegExp(`session_key=${FULL_SESSION}`))
|
await expect(page).toHaveURL(new RegExp(`session_key=${FULL_SESSION}`))
|
||||||
expect(contextRequested).toBe(false)
|
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 expect(page).toHaveURL(new RegExp(`session_key=${CORE_ONLY_SESSION}`))
|
||||||
|
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 }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 })
|
||||||
|
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
|
||||||
|
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||||
|
|
||||||
|
const back = page.getByRole('link', { name: 'Back to Weekend' })
|
||||||
|
await expect(back).toBeVisible()
|
||||||
|
await back.focus()
|
||||||
|
await expect(back).toBeFocused()
|
||||||
|
await page.keyboard.press('Enter')
|
||||||
|
|
||||||
|
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.getByRole('link', { name: /Continue analysis/i })).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user