Compare commits

..

17 Commits

Author SHA1 Message Date
AmanTahiliani
81deed4c75 fix(#98): rearm capped race hub refresh 2026-07-29 03:56:22 -04:00
AmanTahiliani
d6d0558c72 fix(#98): expose weekend switcher before sessions 2026-07-29 03:52:51 -04:00
AmanTahiliani
b884ba8885 fix(#98): preserve bare race hub handoff 2026-07-29 03:46:57 -04:00
AmanTahiliani
56ea860101 feat(#98): Keep completed analysis as the Race Hub default until the next Grand Prix is imminent
Implemented by opencode via .agents/dev dispatch.
2026-07-29 03:28:42 -04:00
Aman Tahiliani
7c7886e6c6 Merge pull request #95 from AmanTahiliani/fix/live-stint-history-merge
fix(live): merge stint deltas instead of replacing stint history
2026-07-29 01:25:12 -04:00
Aman Tahiliani
71f81924ee Merge pull request #94 from AmanTahiliani/feat/frontend-design-uxpass
Live page design pass: give the timing tower the fold back
2026-07-29 01:25:09 -04:00
Aman Tahiliani
7b86698b22 Merge pull request #93 from AmanTahiliani/feat/issue-76-availability-truth-backend
Harvest availability and freshness truth, backend only (re-cut of #76)
2026-07-29 01:25:06 -04:00
Aman Tahiliani
e88e0885ec Merge pull request #92 from AmanTahiliani/feat/issue-72-canonical-weekend-context
Harvest canonical Weekend Context API onto main (re-cut of #72)
2026-07-29 01:24:54 -04:00
AmanTahiliani
255b296ecc fix(live): merge stint deltas instead of replacing stint history
The F1 feed sends TimingAppData stints as sparse deltas keyed by stint
index — a mid-stint update is just {"1": {"TotalLaps": 14}}. The parser
replaced the whole stint slice with whatever a delta carried, so pit
history collapsed to a single entry and tyre age was pinned near zero for
the entire race. A partial delta was also dropped outright, because the
parser required a Compound field that mid-stint updates do not send.

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import type { RaceHub } from '../types'
import { compareFinishPosition, formatDuration, formatGap, formatLapTime } from '../utils'
import { countRaceHubDatasets } from '../lib/coverage'
import { Thermometer, Map, Droplets, Wind, CloudRain } from 'lucide-react'
interface Props {
@@ -16,6 +17,7 @@ export function OverviewView({ data }: Props) {
const fastest = pickFastestLap(data)
const latestWeather = data.weather.length > 0 ? data.weather[data.weather.length - 1] : null
const rcHighlights = data.race_control.slice(-3).reverse()
const coverage = countRaceHubDatasets(data.datasets)
const sessionType = (data.session?.session_type ?? '').toLowerCase()
const isRace = sessionType.includes('race')
@@ -127,6 +129,28 @@ export function OverviewView({ data }: Props) {
</ul>
)}
</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>
)

View File

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

View File

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

View File

@@ -1,166 +0,0 @@
import type { ReactNode } from 'react'
import { isTimeoutError, userFacingError } from '../lib/fetch'
/** Weekend Context terminology for coverage / availability indicators. */
export type DataAvailability = 'local' | 'partial' | 'stale' | 'archive' | 'limited' | 'missing'
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
testId?: string
}
/** Inline notice when a successful payload is limited/stale/partial. */
export function DataNotice({
availability = 'stale',
message,
onRetry,
testId = 'data-notice',
}: StaleNoticeProps) {
const defaultMessage =
availability === 'stale'
? 'Showing stale cached data. Retry to refresh.'
: availability === 'limited'
? 'Some optional details are unavailable. Core local data is shown.'
: availability === 'partial'
? 'Coverage is partial for this weekend.'
: availability === 'archive'
? 'Showing an archived snapshot.'
: 'Data availability is limited.'
return (
<div className="data-notice" data-testid={testId} role="status">
<AvailabilityBadge kind={availability} />
<span className="data-notice-text">{message ?? defaultMessage}</span>
{onRetry && (
<button type="button" className="route-state-retry data-notice-retry" onClick={onRetry}>
Retry
</button>
)}
</div>
)
}

View File

@@ -8,46 +8,15 @@ export type Tab =
| 'race_control'
| 'data_status'
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: [
const TABS: { id: Tab; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'race_story', label: 'Race Story' },
],
},
{
id: 'analysis',
label: 'Analysis',
tabs: [
{ id: 'strategy', label: 'Strategy' },
{ id: 'compare', label: 'Compare' },
{ id: 'lap_data', label: 'Lap Data' },
],
},
{
id: 'context',
label: 'Data & Context',
tabs: [
{ id: 'conditions', label: 'Conditions' },
{ id: 'race_control', label: 'Race Control' },
{ id: 'data_status', label: 'Diagnostics' },
],
},
{ id: 'data_status', label: 'Data Status' },
]
interface Props {
@@ -57,14 +26,8 @@ interface Props {
export function TabBar({ active, onChange }: Props) {
return (
<div className="tab-bar tab-bar-grouped" role="tablist" data-testid="rh-tabbar">
{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) => (
<div className="tab-bar" role="tablist">
{TABS.map((t) => (
<button
key={t.id}
role="tab"
@@ -76,8 +39,5 @@ export function TabBar({ active, onChange }: Props) {
</button>
))}
</div>
</div>
))}
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,7 +52,9 @@ function StintSparkline({ seconds }: { seconds: number[] }) {
}
export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
const [collapsed, setCollapsed] = useState(false)
const isRace = isRaceSession(sessionType)
const [collapsed, setCollapsed] = useState(true)
const [readerChose, setReaderChose] = useState(false)
const [stints, setStints] = useState<StintHistoryMap>({})
// One lap-history update per received snapshot (rows is rebuilt per snapshot).
@@ -61,8 +63,6 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
setStints((prev) => recordStintSamples(prev, rows.map(stintInputFromRow)))
}, [rows])
const isRace = isRaceSession(sessionType)
const visible = useMemo(
() =>
rows.filter(
@@ -74,6 +74,30 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
[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
return (
@@ -81,18 +105,25 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
<button
type="button"
className="sec-header tyredeg-toggle"
onClick={() => setCollapsed((prev) => !prev)}
onClick={() => {
setReaderChose(true)
setCollapsed((prev) => !prev)
}}
aria-expanded={!collapsed}
>
<span className="sec-title">Tyre Deg &amp; Pit Window</span>
{isRace && <span className="sec-meta">rejoin assumes ~{PIT_LOSS_SECONDS}s pit loss</span>}
{!hasSignal ? (
<span className="sec-meta">collecting clean laps</span>
) : (
isRace && <span className="sec-meta">rejoin assumes ~{PIT_LOSS_SECONDS}s pit loss</span>
)}
<span className="tyredeg-chevron" aria-hidden="true">{collapsed ? '▸' : '▾'}</span>
</button>
{!collapsed && (
<div className="tyredeg-rows">
{visible.map((row) => {
const model = degradationModel(stints[row.RacingNumber]?.samples ?? [])
const model = models[row.RacingNumber]
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age)
return (

View File

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

View File

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

View File

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

View File

@@ -1,56 +0,0 @@
import { Link } from '@tanstack/react-router'
import { ChevronRight } from 'lucide-react'
import type { WeekendContext } from '../../types'
import { RacePreviewPage } from '../../pages/RacePreviewPage'
import { ChampionshipRoundStrip, CountdownDisplay, Flag, SessionRail, railNodes } from './shared'
import { meetingIdentity } from '../../lib/weekendContext'
/**
* 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.
*/
export function PreSessionView({ context, now }: { context: WeekendContext; now: Date }) {
const meeting = meetingIdentity(context.next_meeting ?? context.focus_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>
{meeting && (
<header className="wk-pre-head" data-testid="wk-pre-head">
<div className="wk-event-id">
<Flag code={meeting.country_code} flag={meeting.country_flag} />
<h1 className="wk-sessions-title">{meeting.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 />
</div>
<ChampionshipRoundStrip
round={context.championship_round}
total={context.total_championship_rounds}
/>
</div>
)
}

View File

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

View File

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

View File

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

View File

@@ -1,97 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchChampionshipHub, fetchNews, fetchWeekendContext } from '../api'
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[]
now: Date
/** Refetch the canonical weekend-context read. */
refetch: () => void
/** True while a canonical 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])
let loadState: WeekendLoadState = 'loading'
if (contextQuery.isError) loadState = 'error'
else if (canonicalReady) loadState = 'ready'
return {
context: canonical,
loadState,
error: contextQuery.error instanceof Error ? contextQuery.error : undefined,
championship,
briefing,
now: nowDate,
refetch: () => {
if (!contextQuery.isFetching) void contextQuery.refetch()
},
isFetching: contextQuery.isFetching,
}
}

View File

@@ -1,267 +0,0 @@
/** Bounded fetch helpers for primary-route resilience. */
export const DEFAULT_FETCH_TIMEOUT_MS = 15_000
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'),
})
}
return (await res.json()) as T
} finally {
clearTimeout(timer)
}
}
/**
* Attach a caller to a shared in-flight request.
* One caller's abort rejects only that caller; the shared fetch continues
* while other subscribers remain, and is cancelled only when the last one leaves.
*/
function subscribeToInflight<T>(
key: string,
entry: InflightEntry,
callerSignal?: AbortSignal,
): Promise<T> {
if (callerSignal?.aborted) {
return Promise.reject(abortError())
}
entry.subscribers += 1
return new Promise<T>((resolve, reject) => {
let settled = false
const leave = () => {
entry.subscribers -= 1
if (entry.subscribers <= 0) {
if (!entry.controller.signal.aborted) {
entry.controller.abort()
}
if (inflight.get(key) === entry) {
inflight.delete(key)
}
}
}
const onCallerAbort = () => {
if (settled) return
settled = true
leave()
reject(abortError())
}
if (callerSignal) {
callerSignal.addEventListener('abort', onCallerAbort, { once: true })
}
entry.promise.then(
(value) => {
if (settled) return
settled = true
callerSignal?.removeEventListener('abort', onCallerAbort)
resolve(value as T)
},
(err) => {
if (settled) return
settled = true
callerSignal?.removeEventListener('abort', onCallerAbort)
reject(err)
},
)
})
}
/**
* Fetch JSON with a bounded timeout, abort propagation, typed errors,
* and optional in-flight deduplication (same key shares one underlying request).
*
* Deduped requests are subscriber-safe: aborting one consumer does not cancel
* or poison other consumers that still hold the same key (React Strict Mode remounts).
*/
export function apiFetch<T>(url: string, options: ApiFetchOptions = {}): Promise<T> {
const key = options.dedupeKey
if (!key) {
return rawApiFetch<T>(url, options)
}
if (options.signal?.aborted) {
return Promise.reject(abortError())
}
let entry = inflight.get(key)
if (!entry) {
const controller = new AbortController()
// Shared fetch only listens to the ref-counted controller + timeout —
// never to any single caller's AbortSignal.
const promise = rawApiFetch<T>(url, {
...options,
signal: controller.signal,
}).finally(() => {
if (inflight.get(key)?.promise === promise) {
inflight.delete(key)
}
})
entry = { promise, controller, subscribers: 0 }
inflight.set(key, entry)
}
return subscribeToInflight<T>(key, entry, options.signal)
}
/** Test helper — clears the in-flight dedupe map. */
export function clearApiFetchInflight(): void {
inflight.clear()
}

View File

@@ -268,6 +268,63 @@ export function driverCode(row: LiveTimingRow): string {
return row.Info?.Tla || row.RacingNumber
}
/**
* 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 {
return TRACK_STATUS_LABELS[status] || status || 'UNKNOWN'
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,8 +9,7 @@ const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
// Explicit Retry on RouteState — avoid automatic retry storms.
retry: false,
retry: 1,
},
},
})

View File

@@ -24,7 +24,6 @@ import {
} from '../lib/digest'
import { stripHtml, timeAgo } from '../utils'
import type { ArticleContent, NewsItem } from '../types'
import { RouteState } from '../components/RouteState'
import '../styles/digest.css'
type Category = 'all' | 'official' | 'news' | 'video'
@@ -407,27 +406,27 @@ export function BriefingPage() {
const queryClient = useQueryClient()
const now = useMemo(() => new Date(), [])
const { data: allNews = [], isLoading, isError, error, refetch, isFetching } = useQuery({
const { data: allNews = [], isLoading, isError } = useQuery({
queryKey: ['news'],
queryFn: ({ signal }) => fetchNews(100, undefined, signal),
queryFn: () => fetchNews(100),
staleTime: 60_000,
})
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: ({ signal }) => fetchSeasons(signal),
queryFn: fetchSeasons,
})
const latestSeason = seasonsQuery.data?.[0] ?? null
const meetingsQuery = useQuery({
queryKey: ['season-meetings', latestSeason],
queryFn: ({ signal }) => fetchSeasonMeetings(latestSeason!, signal),
queryFn: () => fetchSeasonMeetings(latestSeason!),
enabled: latestSeason != null,
})
const hubQuery = useQuery({
queryKey: ['championship-hub', latestSeason],
queryFn: ({ signal }) => fetchChampionshipHub(latestSeason!, signal),
queryFn: () => fetchChampionshipHub(latestSeason!),
enabled: latestSeason != null,
})
@@ -525,21 +524,8 @@ export function BriefingPage() {
</span>
</div>
{isLoading && (
<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 && <div className="loading-state">loading briefing</div>}
{isError && <div className="error-box">Failed to load paddock briefing.</div>}
{!isLoading && !isError && (
<>

View File

@@ -7,7 +7,6 @@ import type { ChampHubDriver, ChampionshipHub } from '../types'
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
import { RivalryCompare } from '../components/RivalryCompare'
import { Meaning } from '../components/Meaning'
import { RouteState } from '../components/RouteState'
import { TeammateH2H } from '../components/TeammateH2H'
import { teammatePairs } from '../lib/h2h'
import { pointsGapMeaning } from '../lib/meaning'
@@ -82,55 +81,23 @@ function teamSplit(teamName: string, drivers: ChampHubDriver[]): TeamSplit {
export function ChampionshipPage() {
const [view, setView] = useState<View>('drivers')
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: ({ signal }) => fetchSeasons(signal),
})
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons })
const latestSeason = seasonsQuery.data?.[0] ?? null
const hubQuery = useQuery({
queryKey: ['championship-hub', latestSeason],
queryFn: ({ signal }) => fetchChampionshipHub(latestSeason ?? undefined, signal),
queryFn: () => fetchChampionshipHub(latestSeason ?? undefined),
enabled: latestSeason != null,
staleTime: 5 * 60_000,
})
if (seasonsQuery.isLoading || hubQuery.isLoading) {
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>
)
return <div className="page loading-state">loading championship</div>
}
if (hubQuery.isError) {
return (
<div className="page">
<RouteState
kind="error"
title="Championship unavailable"
error={hubQuery.error}
onRetry={() => {
if (!hubQuery.isFetching) void hubQuery.refetch()
}}
retrying={hubQuery.isFetching}
testId="championship-error"
/>
<div className="page error-box">
{hubQuery.error instanceof Error ? hubQuery.error.message : 'Failed to load championship'}
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchLiveState, fetchWeekendContext } from '../api'
import { fetchLiveState } from '../api'
import type { LivePosition, LiveStreamData } from '../types'
import {
loadPinnedDrivers,
@@ -17,15 +17,6 @@ import { recordGapSamples } from '../lib/gapHistory'
import { battleNumbers, detectBattles } from '../lib/battles'
import type { LiveEvent } 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 { TrackStatusBanner } from '../components/live/TrackStatusBanner'
import { TimingTower } from '../components/live/TimingTower'
@@ -35,13 +26,9 @@ import { RaceControlFeed } from '../components/live/RaceControlFeed'
import { EventRail } from '../components/live/EventRail'
import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
import { TyreDegPanel } from '../components/live/TyreDegPanel'
import { LiveHandoff } from '../components/live/LiveHandoff'
import { RouteState } from '../components/RouteState'
import '../styles/live-state.css'
import { Archive, Radio } from 'lucide-react'
/** 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
type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
export function LiveTimingPage() {
const [activeSnapshot, setActiveSnapshot] = useState<LiveStreamData | null>(null)
@@ -50,87 +37,31 @@ export function LiveTimingPage() {
const [archiveSnapshotAt, setArchiveSnapshotAt] = useState<string | null>(null)
const [archiveMode, setArchiveMode] = useState(false)
const [isLive, setIsLive] = useState(false)
const [streamStatus, setStreamStatus] = useState<TransportHealth>('connecting')
const [streamStatus, setStreamStatus] = useState<StreamStatus>('connecting')
const [now, setNow] = useState(Date.now())
const [gapHistory, setGapHistory] = useState<GapHistoryMap>({})
const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers())
const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({})
const [, setPositions] = useState<Record<string, LivePosition>>({})
const [positions, setPositions] = useState<Record<string, LivePosition>>({})
const [events, setEvents] = useState<LiveEvent[]>([])
const prevSnapshotRef = useRef<LiveStreamData | null>(null)
const sessionSigRef = useRef('')
const isLiveRef = useRef(false)
const archiveModeRef = useRef(false)
// 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 snapshot = isLive ? activeSnapshot : archiveMode ? archiveSnapshot : null
const hasArchive = Boolean(archiveSnapshot)
const {
data,
isLoading,
isError,
error,
refetch,
isFetching,
isFetched: liveStateFetched,
} = useQuery({
const { data, isLoading, isError, error } = useQuery({
queryKey: ['live-state'],
queryFn: ({ signal }) => fetchLiveState(signal),
queryFn: fetchLiveState,
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
useEffect(() => {
if (!data) return
const nextLive = data.is_live && Boolean(data.data)
setIsLive(nextLive)
isLiveRef.current = nextLive
if (nextLive) wasLiveRef.current = true
if (nextLive && data.data) {
setActiveSnapshot(data.data)
setArchiveMode(false)
@@ -180,7 +111,6 @@ export function LiveTimingPage() {
setPositions({})
}
isLiveRef.current = true
wasLiveRef.current = true
setActiveSnapshot(state.data)
setArchiveMode(false)
} else {
@@ -237,8 +167,6 @@ export function LiveTimingPage() {
}, [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(() => {
if (rawRows.length === 0) {
@@ -282,86 +210,68 @@ export function LiveTimingPage() {
setPositions(archivePositions)
}
const handleExitArchive = () => {
setArchiveMode(false)
}
const archiveTimestamp = archiveSnapshotAt ? new Date(archiveSnapshotAt) : null
const archiveLabel =
archiveTimestamp && !Number.isNaN(archiveTimestamp.getTime())
? `Archived snapshot from ${archiveTimestamp.toLocaleString()}`
: '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)
return (
<div className="page live-page" data-testid="live-page" data-phase={phase}>
<div className="page live-page" data-testid="live-page">
{isError && (
<RouteState
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 className="error-box">
{error instanceof Error ? error.message : 'Failed to load live timing state'}
</div>
)}
{phase === 'archive' && (
{streamStatus === 'disconnected' && snapshot && !archiveMode && (
<div className="live-status-strip live-status-warn">
Stream disconnected showing last received snapshot
</div>
)}
{archiveMode && snapshot && (
<div className="live-status-strip live-status-archive" data-testid="live-archive-strip">
<span>{archiveLabel} read-only, live updates are paused</span>
<button type="button" className="live-archive-exit" onClick={handleExitArchive}>
Exit archive
</button>
{archiveLabel} live updates are paused for this archive view
</div>
)}
{phase === 'connecting' && (isLoading || !liveStateFetched) && (
<RouteState
kind="loading"
title="connecting to live timing…"
testId="live-initial-loading"
/>
{isLoading && !snapshot && (
<div className="loading-state">connecting to live timing</div>
)}
{(phase === 'settling' || phase === 'inactive') && (
<LiveHandoff
phase={phase}
transport={feedHealth}
context={weekendContext}
rows={settlingRows}
capturedAt={archiveSnapshotAt}
hasArchive={hasArchive}
onViewArchive={handleViewArchive}
/>
{!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>
)}
</div>
)}
{snapshot && rendersSnapshot(phase) && (
{snapshot && (
<>
<SessionBanner
phase={phase}
isLive={isLive}
isArchive={archiveMode}
snapshot={snapshot}
rows={rows}
transport={feedHealth}
connection={streamStatus}
now={now}
capturedAt={phase === 'archive' ? archiveSnapshotAt : null}
/>
<TrackStatusBanner status={snapshot.TrackStatus} />
<PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} />
{showLiveInterpretations && (
<TyreDegPanel rows={rows} sessionType={snapshot.Session?.SessionType} pinned={pinned} />
)}
<div className="live-columns">
<div className="live-tower-col">
<div className="sec-header">
@@ -379,14 +289,16 @@ export function LiveTimingPage() {
session={snapshot.Session}
/>
</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">
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
<TeamRadioTicker
captures={snapshot.TeamRadio ?? []}
driverInfo={snapshot.DriverInfo}
session={snapshot.Session}
/>
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
</div>
</div>
</>

View File

@@ -1,8 +1,7 @@
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link, useNavigate } from '@tanstack/react-router'
import { useNavigate } from '@tanstack/react-router'
import { fetchRaceHub, fetchWeekend, fetchWeekendContext } from '../api'
import { weekendFocusSearch } from '../lib/routeSearch'
import { DatasetStrip } from '../components/DatasetStrip'
import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
import { TabBar, type Tab } from '../components/TabBar'
@@ -13,28 +12,18 @@ import { CompareView } from '../components/CompareView'
import { RaceControlView } from '../components/RaceControlView'
import { WeatherView } from '../components/WeatherView'
import { OverviewView } from '../components/OverviewView'
import {
PartialAnalysisBanner,
PreSessionView,
SessionPhaseView,
} from '../components/PreSessionView'
import { WeekendSwitcher } from '../components/WeekendSwitcher'
import { SourceBadge } from '../components/SourceBadge'
import { RouteState } from '../components/RouteState'
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
import { sessionTypeAbbrev } from '../lib/coverage'
import { formatSessionScheduleTime, sortSessionsByStart } from '../lib/schedule'
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
import {
defaultAnalysisSessionKey,
isPartialAnalysis,
isPreSession,
isPreparing,
isUnavailable,
resolveSessionState,
sessionStateDotClass,
sessionStateLabel,
} from '../lib/sessionState'
import type { WeekendSession } from '../types'
MAX_BROWSER_TIMEOUT,
formatCountdown,
formatSessionScheduleTime,
refreshDeadlineDelay,
sortSessionsByStart,
} from '../lib/schedule'
import type { ContextSession, Weekend } from '../types'
interface Props {
sessionKey: number
@@ -44,45 +33,63 @@ export function RaceHubPage({ sessionKey }: Props) {
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<Tab>('overview')
const [switcherOpen, setSwitcherOpen] = useState(false)
const [showDiagnostics, setShowDiagnostics] = useState(false)
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)
}, [])
const [now, setNow] = useState(() => Date.now())
const [refreshGeneration, setRefreshGeneration] = useState(0)
// The server owns bare Race Hub selection so every open tab crosses the
// one-hour handoff at the same instant.
const contextQuery = useQuery({
queryKey: ['weekend-context'],
queryFn: ({ signal }) => fetchWeekendContext(signal),
staleTime: 15_000,
refetchInterval: 30_000,
queryFn: fetchWeekendContext,
enabled: sessionKey === 0,
})
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(() => {
if (sessionKey !== 0) return
if (!contextQuery.isSuccess || !context) return
const target = defaultAnalysisSessionKey(context)
if (target) {
navigate({ to: '/race-hub', search: { session_key: target }, replace: true })
}
}, [sessionKey, contextQuery.isSuccess, context, navigate])
const delay = refreshDeadlineDelay(context?.race_hub_refresh_at)
if (delay == null) return
const rearmAfterRefetch = delay === MAX_BROWSER_TIMEOUT
const timer = window.setTimeout(() => {
void refetchContext().finally(() => {
if (rearmAfterRefetch) setRefreshGeneration((generation) => generation + 1)
})
}, delay)
return () => window.clearTimeout(timer)
}, [sessionKey, context?.race_hub_refresh_at, refetchContext, refreshGeneration])
useEffect(() => {
if (!preSession) return
const timer = window.setInterval(() => setNow(Date.now()), 1_000)
return () => window.clearInterval(timer)
}, [preSession])
// A bare route retains canonical context ownership while rendering its
// completed analysis selection. Explicit URLs remain user-owned.
const selectedSessionKey = sessionKey || context?.race_hub_default_session?.session.session_key || 0
// ─── Active session payload ───
const raceHubQuery = useQuery({
queryKey: ['race-hub', sessionKey],
queryFn: ({ signal }) => fetchRaceHub(sessionKey, signal),
enabled: sessionKey > 0,
queryKey: ['race-hub', selectedSessionKey],
queryFn: () => fetchRaceHub(selectedSessionKey),
enabled: selectedSessionKey > 0 && (sessionKey > 0 || !preSession),
staleTime: 30_000,
})
const meetingKey = raceHubQuery.data?.meeting?.meeting_key
const weekendQuery = useQuery({
queryKey: ['weekend', meetingKey],
queryFn: ({ signal }) => fetchWeekend(meetingKey!, signal),
queryFn: () => fetchWeekend(meetingKey!),
enabled: meetingKey != null && meetingKey > 0,
staleTime: 60_000,
})
@@ -92,142 +99,63 @@ export function RaceHubPage({ sessionKey }: Props) {
const accent = countryAccent(data?.meeting ?? null)
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
const openDiagnostics = () => {
setActiveTab('data_status')
setShowDiagnostics(true)
setPhaseDiagnostics(true)
}
// ─── No session_key: resolve via Weekend Context ───
// ─── No session_key: resolve exclusively through canonical Weekend Context ───
if (sessionKey === 0) {
if (contextQuery.isLoading) {
if (contextQuery.isLoading || (preSession && preSessionWeekendQuery.isLoading)) {
return (
<div className="rh-page" style={accentStyle}>
<RouteState
kind="loading"
title="resolving weekend context…"
testId="race-hub-loading"
<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)}
/>
</div>
)
}
if (contextQuery.isError) {
if (!selectedSessionKey) {
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-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 session to analyse yet</h1>
<h1 className="rh-empty-title">No completed local analysis 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.
Race Hub opens completed local analysis between weekends. Check Data Health
to ingest a completed session.
</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>
<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>
{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 ───
// ─── Loading / error for the selected session ───
if (raceHubQuery.isLoading) {
return (
<div className="rh-page" style={accentStyle}>
<RouteState
kind="loading"
title={`loading session ${sessionKey}`}
testId="race-hub-loading"
/>
<div className="loading-state">loading session {selectedSessionKey}</div>
</div>
)
}
if (raceHubQuery.isError || !data) {
const backMeeting =
context?.focus_meeting?.meeting_key ??
context?.previous_completed_session?.meeting?.meeting_key
const backSearch = weekendFocusSearch(backMeeting, sessionKey)
return (
<div className="rh-page" data-testid="race-hub-error" style={accentStyle}>
<RouteState
kind="error"
title="Session unavailable"
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 className="rh-page" style={accentStyle}>
<div className="error-box">
{raceHubQuery.error instanceof Error
? raceHubQuery.error.message
: `Failed to load session ${selectedSessionKey}.`}
</div>
</RouteState>
</div>
)
}
@@ -237,30 +165,12 @@ export function RaceHubPage({ sessionKey }: Props) {
const sessionMeta = weekend
? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w]))
: {}
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)
const activeSessionMeta = sessionMeta[selectedSessionKey]
return (
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
{/* 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">
box-box · race hub
{data.meeting?.year ? ` · ${data.meeting.year}` : ''}
@@ -281,13 +191,12 @@ export function RaceHubPage({ sessionKey }: Props) {
{switcherOpen && (
<WeekendSwitcher
currentMeetingKey={meetingKey}
currentSessionKey={sessionKey}
context={context}
now={now}
currentSessionKey={selectedSessionKey}
onClose={() => setSwitcherOpen(false)}
/>
)}
{/* GP Identity band */}
{data.meeting && (
<section className="rh-identity" data-testid="rh-identity">
<div className="rh-identity-accent" aria-hidden="true" />
@@ -308,16 +217,12 @@ export function RaceHubPage({ sessionKey }: Props) {
</section>
)}
{/* Session rail */}
{sessions.length > 0 && (
<nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail">
{sessions.map((session) => {
const meta = sessionMeta[session.session_key]
const active = session.session_key === sessionKey
const state = resolveSessionState({
weekendSession: meta,
context,
now,
})
const active = session.session_key === selectedSessionKey
return (
<button
key={session.session_key}
@@ -339,70 +244,43 @@ export function RaceHubPage({ sessionKey }: Props) {
<span className="rh-session-time mono">
{formatSessionScheduleTime(session.date_start)}
</span>
{meta && (
<span className="rh-session-cov mono">
<span
className={`cc-cov-dot ${sessionStateDotClass(state)}`}
className={`cc-cov-dot cc-cov-${meta.source}`}
aria-hidden="true"
/>
{sessionStateLabel(state)}
{formatCoverageHint(meta.datasets)}
</span>
)}
</button>
)
})}
</nav>
)}
{/* Active session sub-bar */}
{data.session && (
<div className="rh-active-bar" data-testid="rh-active-bar">
<span className="rh-active-name">{data.session.session_name}</span>
<span className="rh-active-meta mono">
{formatSessionScheduleTime(data.session.date_start)}
</span>
<span className="rh-active-cov mono" data-testid="rh-active-state">
{activeSessionMeta && (
<span className="rh-active-cov mono">
<span
className={`cc-cov-dot ${sessionStateDotClass(activeState)}`}
className={`cc-cov-dot cc-cov-${activeSessionMeta.source}`}
aria-hidden="true"
/>
{sessionStateLabel(activeState)}
{formatCoverageHint(activeSessionMeta.datasets)} datasets local
</span>
<span className="rh-active-key mono">key {sessionKey}</span>
)}
<span className="rh-active-key mono">key {selectedSessionKey}</span>
</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} />
</div>
</div>
)}
</>
) : (
<>
{partial && <PartialAnalysisBanner onOpenDiagnostics={openDiagnostics} />}
<TabBar active={activeTab} onChange={setActiveTab} />
{activeTab === 'overview' && <OverviewView data={data} />}
@@ -433,7 +311,7 @@ export function RaceHubPage({ sessionKey }: Props) {
<span className="sec-title">Driver Compare</span>
</div>
<CompareView
sessionKey={sessionKey}
sessionKey={selectedSessionKey}
results={data.results}
drivers={data.drivers}
/>
@@ -479,27 +357,80 @@ export function RaceHubPage({ sessionKey }: Props) {
{activeTab === 'data_status' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Diagnostics</span>
<button
type="button"
className={`rh-diagnostics-toggle${showDiagnostics ? ' active' : ''}`}
onClick={() => setShowDiagnostics((v) => !v)}
aria-expanded={showDiagnostics}
data-testid="rh-diagnostics-toggle"
>
{showDiagnostics ? 'Hide dataset coverage' : 'Show dataset coverage'}
</button>
<span className="sec-title">Data Status</span>
</div>
<DatasetStatusView datasets={data.datasets} />
{showDiagnostics && (
<div style={{ marginTop: 'var(--s5)' }} data-testid="rh-dataset-strip">
<DatasetStrip datasets={data.datasets} />
</div>
)}
</div>
)}
</>
)}
</div>
)
}
function RaceHubPreSession({
session,
weekend,
now,
switcherOpen,
onToggleSwitcher,
onCloseSwitcher,
}: {
session: ContextSession
weekend?: Weekend
now: number
switcherOpen: boolean
onToggleSwitcher: () => void
onCloseSwitcher: () => void
}) {
const meeting = session.meeting
const sessions = sortSessionsByStart((weekend?.sessions ?? []).map((entry) => entry.session))
const target = new Date(session.session.date_start)
const accent = countryAccent(meeting ?? null)
const pendingLiveEvidence = target.getTime() <= now
return (
<div className="rh-page rh-empty" data-testid="race-hub-pre-session" style={{ '--gp-accent': accent } as React.CSSProperties}>
<div className="rh-topbar">
<span className="rh-topbar-label mono">box-box · race hub</span>
<span className="rh-topbar-spacer" />
<button
type="button"
className={`rh-switcher-toggle${switcherOpen ? ' active' : ''}`}
onClick={onToggleSwitcher}
aria-expanded={switcherOpen}
aria-controls="rh-weekend-switcher"
data-testid="rh-switch-weekend"
>
{switcherOpen ? 'Close' : 'Switch Weekend'}
</button>
</div>
{switcherOpen && (
<WeekendSwitcher
currentMeetingKey={meeting?.meeting_key}
currentSessionKey={session.session.session_key}
onClose={onCloseSwitcher}
/>
)}
<section className="rh-empty-band">
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
<h1 className="rh-empty-title">{meeting?.meeting_name ?? 'Next race weekend'}</h1>
<p className="rh-empty-sub">
{pendingLiveEvidence
? `${session.session.session_name} is scheduled; awaiting live timing.`
: <>{session.session.session_name} begins in <span className="mono">{formatCountdown(target, new Date(now))}</span></>}
</p>
{sessions.length > 0 && (
<div className="preview-schedule" data-testid="rh-pre-session-schedule">
{sessions.map((scheduled) => (
<div key={scheduled.session_key} className="preview-schedule-item">
<span className="preview-schedule-name">{scheduled.session_name}</span>
<span className="preview-schedule-time">{formatSessionScheduleTime(scheduled.date_start)}</span>
</div>
))}
</div>
)}
</section>
</div>
)
}

View File

@@ -271,7 +271,7 @@ export function RacePreviewPage() {
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: ({ signal }) => fetchSeasons(signal),
queryFn: fetchSeasons,
})
const latestSeason = seasonsQuery.data?.[0] ?? null

View File

@@ -1,114 +0,0 @@
import { useWeekendContext } from '../hooks/useWeekendContext'
import { BetweenRacesView } from '../components/weekend/BetweenRacesView'
import { BetweenSessionsView } from '../components/weekend/BetweenSessionsView'
import { LiveHandoffView } from '../components/weekend/LiveHandoffView'
import { PreSessionView } from '../components/weekend/PreSessionView'
import { WeekendError, WeekendLimited, WeekendLoading } from '../components/weekend/StatusViews'
import { WeekendFocusBanner } from '../components/weekend/WeekendFocusBanner'
import { 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
}
function renderState(view: WeekendViewState, args: RenderArgs) {
const { context, now, championship, briefing, preview } = 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} />
}
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} />
default:
return <WeekendLimited season={context.season} />
}
}
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, 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)
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} />
)}
{renderState(view, { context, now, championship, briefing, preview })}
</main>
)
}

View File

@@ -1,14 +1,17 @@
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router'
import { Nav } from './components/Nav'
import { WeekendPage } from './pages/WeekendPage'
import { ExplorePage } from './pages/ExplorePage'
import { CommandCenterPage } from './pages/CommandCenterPage'
import { RaceHubPage } from './pages/RaceHubPage'
import { DataLibraryPage } from './pages/DataLibraryPage'
import { LiveTimingPage } from './pages/LiveTimingPage'
import { BriefingPage } from './pages/BriefingPage'
import { ChampionshipPage } from './pages/ChampionshipPage'
import { DriverProfilePage } from './pages/DriverProfilePage'
import { parseRaceHubSearch, parseWeekendSearch } from './lib/routeSearch'
import { RacePreviewPage } from './pages/RacePreviewPage'
type RaceHubSearch = {
session_key?: number
}
type DriverProfileSearch = {
year?: number
@@ -23,31 +26,19 @@ const rootRoute = createRootRoute({
),
})
// 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({
export const commandCenterRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
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,
component: CommandCenterPage,
})
export const raceHubRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/race-hub',
validateSearch: (search: Record<string, unknown>) => parseRaceHubSearch(search),
validateSearch: (search: Record<string, unknown>): RaceHubSearch => {
const sessionKey = Number(search.session_key)
return Number.isFinite(sessionKey) && sessionKey > 0 ? { session_key: sessionKey } : {}
},
component: function RaceHubRoute() {
const { session_key } = raceHubRoute.useSearch()
return <RaceHubPage sessionKey={session_key ?? 0} />
@@ -99,21 +90,14 @@ export const briefingRoute = createRoute({
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({
getParentRoute: () => rootRoute,
path: '/preview',
component: function PreviewRoute() {
return <WeekendPage preview />
},
component: RacePreviewPage,
})
const routeTree = rootRoute.addChildren([
weekendRoute,
exploreRoute,
commandCenterRoute,
raceHubRoute,
adminRoute,
dataLibraryRoute,

View File

@@ -89,6 +89,16 @@ a { color: inherit; text-decoration: 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 {
font-family: var(--f-mono);
font-size: 15px;
@@ -112,6 +122,30 @@ a { color: inherit; text-decoration: none; }
.nav-links a:hover { color: var(--text); background: var(--surface-h); }
.nav-links a.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 {
margin-left: auto;
display: flex;
@@ -142,53 +176,6 @@ a { color: inherit; text-decoration: none; }
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 {
max-width: 1040px;
@@ -410,9 +397,6 @@ a { color: inherit; text-decoration: none; }
.badge-partial { background: rgba(255,214,0,.12); color: var(--yellow); border: 1px solid rgba(255,214,0,.25); }
.badge-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-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 {
@@ -588,82 +572,6 @@ a { color: inherit; text-decoration: none; }
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-page { max-width: 1320px; }
@@ -1899,163 +1807,6 @@ a { color: inherit; text-decoration: none; }
.tab-btn:hover { color: var(--text-2); }
.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 ── */
.ds-legend {
display: flex;
@@ -2905,31 +2656,6 @@ a { color: inherit; text-decoration: none; }
.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 {
padding: 4px 10px;
font-size: 10px;
@@ -3505,10 +3231,7 @@ a { color: inherit; text-decoration: none; }
.rh-overview-row { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
.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-page { padding: var(--s4); gap: var(--s4); }
.rh-identity-body { padding: var(--s3) var(--s4); gap: var(--s3); }
.rh-identity-decal { font-size: 28px; }
.rh-identity-name { font-size: 17px; }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -209,6 +209,32 @@ describe('TimingTower', () => {
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', () => {
const sprintRows = Array.from({ length: 22 }, (_, index) =>
makeRow(String(index + 1), index + 1, `D${index + 1}`),

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { act, render, screen, waitFor, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
Outlet,
@@ -9,14 +9,8 @@ import {
createRoute,
} from '@tanstack/react-router'
import { RaceHubPage } from '../pages/RaceHubPage'
import type {
DatasetInfo,
Meeting,
RaceHub,
Session,
Weekend,
WeekendContext,
} from '../types'
import { MAX_BROWSER_TIMEOUT } from '../lib/schedule'
import type { ContextAvailability, DatasetInfo, Meeting, RaceHub, Session, Weekend, WeekendContext } from '../types'
vi.mock('../api', () => ({
fetchRaceHub: vi.fn(),
@@ -26,13 +20,7 @@ vi.mock('../api', () => ({
fetchWeekendContext: vi.fn(),
}))
import {
fetchRaceHub,
fetchSeasons,
fetchLocalMeetings,
fetchWeekend,
fetchWeekendContext,
} from '../api'
import { fetchLocalMeetings, fetchRaceHub, fetchSeasons, fetchWeekend, fetchWeekendContext } from '../api'
const mockFetchRaceHub = vi.mocked(fetchRaceHub)
const mockFetchSeasons = vi.mocked(fetchSeasons)
@@ -40,8 +28,6 @@ const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
const mockFetchWeekend = vi.mocked(fetchWeekend)
const mockFetchWeekendContext = vi.mocked(fetchWeekendContext)
const NOW = new Date('2025-06-01T00:00:00Z')
const meeting: Meeting = {
meeting_key: 1229,
meeting_name: 'Monaco Grand Prix',
@@ -76,16 +62,6 @@ const qualSession: Session = {
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> = {
meeting: { status: 'available', source: 'local', count: 1 },
session: { status: 'available', source: 'local', count: 1 },
@@ -196,38 +172,23 @@ const weekend: Weekend = {
],
}
const weekendContext: WeekendContext = {
season: 2025,
temporal_state: 'post_weekend',
focus_meeting: meeting,
previous_meeting: meeting,
default_analysis_session: {
session: raceSession,
meeting,
availability: {
const availability: ContextAvailability = {
source: 'local',
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'complete',
freshness: 'fresh',
freshness: 'local',
limitations: [],
},
},
previous_completed_session: {
session: raceSession,
meeting,
availability: {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'complete',
freshness: 'fresh',
limitations: [],
},
},
championship_round: 8,
}
const analysisContext: WeekendContext = {
temporal_state: 'between_weekends',
default_analysis_session: { session: raceSession, meeting, availability },
race_hub_default_session: { session: raceSession, meeting, availability },
race_hub_pre_session: false,
championship_round: 1,
total_championship_rounds: 24,
}
@@ -243,19 +204,6 @@ function renderRaceHub(sessionKey: number) {
</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({
getParentRoute: () => rootRoute,
path: '/race-hub',
@@ -268,25 +216,24 @@ function renderRaceHub(sessionKey: number) {
return <RaceHubPage sessionKey={session_key ?? 0} />
},
})
window.history.pushState({}, '', sessionKey ? `/race-hub?session_key=${sessionKey}` : '/race-hub')
const router = createRouter({
routeTree: rootRoute.addChildren([weekendRoute, raceHubRoute]),
routeTree: rootRoute.addChildren([raceHubRoute]),
history: undefined,
})
router.navigate({ to: '/race-hub', search: sessionKey ? { session_key: sessionKey } : {} })
return render(<RouterProvider router={router} />)
return { queryClient, ...render(<RouterProvider router={router} />) }
}
describe('RaceHubPage', () => {
beforeEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
vi.useFakeTimers({ shouldAdvanceTime: true })
vi.setSystemTime(NOW)
mockFetchSeasons.mockResolvedValue([2025])
mockFetchLocalMeetings.mockResolvedValue([meeting])
mockFetchWeekend.mockResolvedValue(weekend)
mockFetchRaceHub.mockResolvedValue(raceHub)
mockFetchWeekendContext.mockResolvedValue(weekendContext)
mockFetchWeekendContext.mockResolvedValue(analysisContext)
})
afterEach(() => {
@@ -305,9 +252,9 @@ describe('RaceHubPage', () => {
)
expect(screen.getByTestId('rh-session-9471')).toBeInTheDocument()
// Overview is default
expect(screen.getByTestId('rh-overview')).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 () => {
@@ -317,43 +264,17 @@ describe('RaceHubPage', () => {
fireEvent.click(screen.getByRole('tab', { name: 'Race Story' }))
expect(screen.getByText('VER')).toBeInTheDocument()
})
it('keeps Diagnostics accessible behind a secondary action, free of inline CLI guidance', async () => {
it('keeps Data Status accessible and free of inline CLI guidance', async () => {
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
fireEvent.click(screen.getByRole('tab', { name: 'Diagnostics' }))
fireEvent.click(screen.getByRole('tab', { name: 'Data Status' }))
expect(screen.getByTestId('rh-data-status')).toBeInTheDocument()
expect(screen.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 () => {
@@ -364,213 +285,121 @@ describe('RaceHubPage', () => {
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
})
it('resolves bare /race-hub through canonical Weekend Context default analysis', async () => {
it('uses the server-selected completed analysis session for bare Race Hub without changing the URL', async () => {
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({
...weekendContext,
default_analysis_session: {
session: qualSession,
...analysisContext,
race_hub_default_session: {
session: { ...raceSession, session_key: 9473, session_name: 'Practice 1', session_type: 'Practice', date_start: '2099-05-23T13:00:00Z' },
meeting,
availability: {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'complete',
freshness: 'fresh',
limitations: [],
},
availability,
},
race_hub_pre_session: true,
race_hub_refresh_at: '2099-05-23T13:00:00Z',
})
renderRaceHub(0)
await waitFor(() => expect(mockFetchRaceHub).toHaveBeenCalledWith(9471, expect.any(AbortSignal)))
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9600, expect.anything())
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9600)
})
it('shows no-analysis fallback when Weekend Context has no default analysis', async () => {
mockFetchWeekendContext.mockResolvedValue({
...weekendContext,
default_analysis_session: undefined,
previous_completed_session: undefined,
})
renderRaceHub(0)
await waitFor(() => expect(screen.getByTestId('race-hub-no-analysis')).toBeInTheDocument())
expect(await screen.findByTestId('race-hub-pre-session')).toBeInTheDocument()
expect(screen.getByTestId('rh-pre-session-schedule')).toBeInTheDocument()
expect(screen.queryByText('Winner')).not.toBeInTheDocument()
expect(mockFetchRaceHub).not.toHaveBeenCalled()
})
it('renders a pre-session view instead of empty analysis for a future session', async () => {
mockFetchRaceHub.mockResolvedValue({
...raceHub,
session_key: 9600,
source: 'none',
session: futureSession,
meeting: { ...meeting, meeting_key: 1300 },
results: [],
starting_grid: [],
datasets: {},
})
mockFetchWeekend.mockResolvedValue({
source: 'none',
meeting_key: 1300,
meeting: { ...meeting, meeting_key: 1300 },
sessions: [{ session: futureSession, source: 'none', datasets: {} }],
})
renderRaceHub(9600)
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
expect(await screen.findByTestId('rh-presession')).toBeInTheDocument()
expect(screen.queryByTestId('rh-overview')).not.toBeInTheDocument()
expect(screen.queryByText('Winner')).not.toBeInTheDocument()
})
it('renders a preparing view for a settling session with no local analysis', async () => {
mockFetchWeekend.mockResolvedValue({
...weekend,
sessions: [
{ session: raceSession, source: 'none', datasets: {} },
],
})
it('opens the weekend switcher from pre-session and navigates to the selected explicit session', async () => {
mockFetchWeekendContext.mockResolvedValue({
...weekendContext,
temporal_state: 'session_settling',
default_analysis_session: undefined,
previous_completed_session: {
session: raceSession,
...analysisContext,
race_hub_default_session: {
session: { ...raceSession, session_key: 9473, session_name: 'Practice 1', session_type: 'Practice', date_start: '2099-05-23T13:00:00Z' },
meeting,
availability: {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'pending',
freshness: 'fresh',
limitations: [],
availability,
},
},
})
mockFetchRaceHub.mockResolvedValue({
...raceHub,
source: 'none',
results: [],
starting_grid: [],
datasets: {},
race_hub_pre_session: true,
race_hub_refresh_at: '2099-05-23T13:00:00Z',
})
renderRaceHub(9472)
renderRaceHub(0)
await waitFor(() => expect(screen.getByTestId('rh-preparing')).toBeInTheDocument())
expect(screen.queryByTestId('rh-overview')).not.toBeInTheDocument()
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('renders unavailable distinctly from request errors', async () => {
mockFetchWeekend.mockResolvedValue({
...weekend,
sessions: [{ session: raceSession, source: 'none', datasets: {} }],
})
it('shows recovery instead of selecting an empty future session', async () => {
mockFetchWeekendContext.mockResolvedValue({
...weekendContext,
previous_completed_session: {
session: raceSession,
...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: {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'unavailable',
freshness: 'stale',
limitations: ['analysis_blocked'],
availability,
},
},
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: {},
race_hub_pre_session: true,
race_hub_refresh_at: new Date(Date.now() + 16_000).toISOString(),
}
mockFetchWeekendContext
.mockResolvedValueOnce({ ...analysisContext, race_hub_refresh_at: handoff })
.mockResolvedValueOnce(pendingContext)
renderRaceHub(0)
await screen.findByTestId('race-hub')
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
expect(await screen.findByTestId('race-hub-pre-session')).toBeInTheDocument()
expect(mockFetchWeekendContext).toHaveBeenCalledTimes(2)
expect(mockFetchRaceHub).toHaveBeenCalledWith(9472)
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9473)
})
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 } },
],
})
it('re-arms a bare route refresh after a capped browser timer', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
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: [],
},
},
...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)
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()
})
await act(async () => { await vi.advanceTimersByTimeAsync(60_000) })
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/))
expect(mockFetchWeekendContext).not.toHaveBeenCalled()
expect(mockFetchRaceHub).toHaveBeenCalledWith(9472)
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9473)
})
})

View File

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

View File

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

View File

@@ -47,9 +47,20 @@ function snapshotRows(lap: number, lastLapTime: string): LiveTimingRow[] {
}
describe('TyreDegPanel', () => {
it('shows a warming-up placeholder until enough clean laps accumulate', () => {
it('stays collapsed while every stint is still warming up', () => {
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
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('M +5')
expect(panel).toHaveTextContent('fresh')
@@ -61,6 +72,7 @@ describe('TyreDegPanel', () => {
makeRow('1', 1, 'VER', { NumberOfLaps: 10, LastLapTime: '1:30.000' }, { Compound: 'MEDIUM', Age: 12 }),
]
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={[]} />)
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
expect(screen.getByText('mid-life')).toBeInTheDocument()
})
@@ -81,13 +93,46 @@ describe('TyreDegPanel', () => {
expect(panel).toHaveTextContent('→ ~P2')
})
it('hides the rejoin estimate outside race sessions and collapses on toggle', () => {
it('collapses by default outside race sessions and hides the rejoin estimate when expanded', () => {
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Qualifying" pinned={[]} />)
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')
})
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)
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)
})
@@ -96,6 +141,7 @@ describe('TyreDegPanel', () => {
makeRow(String(index + 1), index + 1, `D${index + 1}`),
)
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.getByText('D14')).toBeInTheDocument()
expect(screen.queryByText('D12')).not.toBeInTheDocument()

View File

@@ -1,389 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
Outlet,
RouterProvider,
createRouter,
createRootRoute,
createRoute,
createMemoryHistory,
} from '@tanstack/react-router'
import { WeekendPage } from '../pages/WeekendPage'
import type {
ChampionshipHub,
ContextAvailability,
ContextSession,
Meeting,
RaceHub,
Session,
TemporalState,
WeekendContext,
} from '../types'
import { parseWeekendSearch } from '../lib/routeSearch'
vi.mock('../api', () => ({
fetchWeekendContext: vi.fn(),
fetchChampionshipHub: vi.fn(),
fetchNews: vi.fn(),
fetchRaceHub: vi.fn(),
fetchWeekend: vi.fn(),
// Consumed transitively by RacePreviewPage (folded into PreSessionView):
fetchSeasons: vi.fn(),
fetchMeetings: vi.fn(),
fetchSessions: vi.fn(),
fetchResults: vi.fn(),
fetchStartingGrid: vi.fn(),
fetchTrackOutline: vi.fn(),
}))
import {
fetchWeekendContext,
fetchChampionshipHub,
fetchNews,
fetchRaceHub,
fetchWeekend,
fetchSeasons,
fetchMeetings,
fetchSessions,
fetchResults,
fetchStartingGrid,
fetchTrackOutline,
} from '../api'
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('restores Race Hub meeting/session focus from the Weekend URL search contract', async () => {
mockContext.mockResolvedValue(
context({
temporal_state: 'season_complete',
previous_completed_session: ctxSession({ session: session({ session_key: 11, session_name: 'Race' }) }),
default_analysis_session: ctxSession({ session: session({ session_key: 11 }) }),
}),
)
renderAt('/?meeting_key=1&session_key=11')
await waitFor(() => expect(screen.getByTestId('wk-focus-context')).toBeInTheDocument())
expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-meeting-key', '1')
expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-session-key', '11')
await waitFor(() =>
expect(screen.getByTestId('wk-focus-meeting')).toHaveTextContent('British Grand Prix'),
)
await waitFor(() =>
expect(screen.getByTestId('wk-focus-session')).toHaveTextContent('Race'),
)
const continueAnalysis = screen.getByRole('link', { name: /Continue analysis/i })
expect(continueAnalysis).toHaveAttribute('href', expect.stringContaining('session_key=11'))
expect(mockWeekend).toHaveBeenCalledWith(1)
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -217,6 +217,41 @@ export interface Weekend {
default_session_key?: number
}
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 {
is_live: boolean
data: LiveStreamData | null
@@ -446,111 +481,6 @@ export interface DriverSummary {
cumulative: number[]
round_labels: string[]
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 {
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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ const (
preSessionWindow = 48 * time.Hour
postWeekendWindow = 48 * time.Hour
raceHubPendingPollInterval = 15 * time.Second
)
// LiveEvidence is the small, transport-independent subset of FIA state needed
@@ -40,6 +41,7 @@ type LiveEvidence struct {
// ContextAvailability is structured source state for a referenced session.
type ContextAvailability struct {
Source string `json:"source"`
Schedule string `json:"schedule"`
LiveTransport string `json:"live_transport"`
LiveSession string `json:"live_session"`
@@ -68,6 +70,9 @@ type WeekendContext struct {
ActiveSession *ContextSession `json:"active_session,omitempty"`
NextSession *ContextSession `json:"next_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"`
TotalChampionshipRounds int `json:"total_championship_rounds"`
}
@@ -149,7 +154,7 @@ func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext,
}
}
var previous, next, defaultAnalysis *contextCandidate
var previous, next, defaultAnalysis, pending *contextCandidate
for i := range candidates {
c := &candidates[i]
isActive := active != nil && active.session.SessionKey != 0 && c.session.SessionKey == active.session.SessionKey
@@ -163,6 +168,10 @@ func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext,
if !isActive && !c.start.IsZero() && !c.start.Before(now) && (next == nil || c.start.Before(next.start)) {
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 {
@@ -187,9 +196,40 @@ func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext,
if out.FocusMeeting != nil {
out.ChampionshipRound = championshipRound(champMeetings, int(out.FocusMeeting.MeetingKey))
}
applyRaceHubDefault(&out, active, defaultAnalysis, next, pending, now)
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 {
for _, year := range years {
if year == current {
@@ -298,21 +338,28 @@ func meetingModelByKey(meetings []store.Meeting, key int) *models.Meeting {
func sessionRef(c contextCandidate, evidence LiveEvidence, now time.Time) *ContextSession {
session := sessionToModel(c.session)
meeting := meetingToModel(c.meeting)
availability := ContextAvailability{Schedule: "available", LiveSession: "inactive", Archive: "unavailable", Freshness: "fresh", Limitations: []string{}}
// Schedule and analysis are domain-store facts. Without an ingestion
// timestamp the resolver cannot honestly call them network-fresh, so local
// is the baseline freshness vocabulary exposed to clients.
availability := ContextAvailability{Source: "local", Schedule: "available", LiveSession: "inactive", Archive: "unavailable", Freshness: "local", Limitations: []string{}}
availability.LiveTransport = "unknown"
if c.session.SessionKey == 0 {
availability.Schedule = "unavailable"
availability.Limitations = append(availability.Limitations, "schedule_identity_unmatched")
}
if evidence.Active && liveMatches(evidence, c.meeting, c.session) {
availability.Source = "mixed"
availability.LiveTransport = "connected"
availability.LiveSession = "active"
availability.Freshness = "live"
if !evidence.ObservedAt.IsZero() {
availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339)
}
}
if c.archived {
availability.Source = "mixed"
availability.Archive = "available"
availability.Freshness = "archive"
if !evidence.ObservedAt.IsZero() {
availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339)
}
@@ -326,6 +373,12 @@ func sessionRef(c contextCandidate, evidence LiveEvidence, now time.Time) *Conte
} else {
availability.LocalAnalysis = "pending"
}
if availability.LocalAnalysis == "partial" && availability.Freshness == "local" {
availability.Freshness = "partial"
}
if c.session.SessionKey == 0 && availability.LiveSession == "active" {
availability.Source = "fia"
}
return &ContextSession{Session: session, Meeting: &meeting, Availability: availability}
}

View File

@@ -160,6 +160,54 @@ func TestResolveWeekendContextPassedTimeDoesNotCompleteSession(t *testing.T) {
}
}
func TestResolveWeekendContextAvailabilityUsesTruthfulSources(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-05T18:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T00:00:00Z", "2026-07-05T16:00:00Z", false)
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
// Results without laps/stints/positions are meaningful but incomplete local
// analysis, so they must not be labelled universally fresh.
completeContextSession(t, svc, 11, 1)
addContextMeeting(t, svc, 2, "Belgian Grand Prix", "2026-07-17T00:00:00Z", "2026-07-19T16:00:00Z", false)
addContextSession(t, svc, 21, 2, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
addContextSession(t, svc, 22, 2, "Race", "2026-07-19T14:00:00Z", "2026-07-19T16:00:00Z", false)
local, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got := local.PreviousCompletedSession.Availability; got.Source != "local" || got.Freshness != "partial" || got.LocalAnalysis != "partial" {
t.Fatalf("partial local availability = %+v", got)
}
if got := local.NextSession.Availability; got.Source != "local" || got.Freshness != "local" {
t.Fatalf("future local availability = %+v", got)
}
liveContext, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "Belgian Grand Prix", CircuitName: "Belgian Grand Prix", SessionName: "Practice 1", SessionType: "Practice 1", ObservedAt: now})
if err != nil {
t.Fatal(err)
}
if got := liveContext.ActiveSession.Availability; got.Source != "mixed" || got.Freshness != "live" || got.LiveSession != "active" {
t.Fatalf("FIA + local availability = %+v", got)
}
archiveContext, err := svc.ResolveWeekendContext(LiveEvidence{Final: true, MeetingName: "British Grand Prix", CircuitName: "British Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
if err != nil {
t.Fatal(err)
}
if got := archiveContext.PreviousCompletedSession.Availability; got.Source != "mixed" || got.Freshness != "archive" || got.Archive != "available" {
t.Fatalf("FIA archive + local availability = %+v", got)
}
synthetic, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "Unscheduled Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
if err != nil {
t.Fatal(err)
}
if got := synthetic.ActiveSession.Availability; got.Source != "fia" || got.Freshness != "live" || got.Schedule != "unavailable" {
t.Fatalf("synthetic FIA availability = %+v", got)
}
}
func TestResolveWeekendContextNeverUsesFutureAnalysis(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z")
svc := contextService(t, now)
@@ -175,6 +223,38 @@ func TestResolveWeekendContextNeverUsesFutureAnalysis(t *testing.T) {
}
}
// Regression for the v0.4.0 release blocker (#90). Production held a run
// British race (11326) and an unrun Belgian race (11334) two weekends apart.
// Analysis must resolve to the race that actually happened, even when the
// later meeting carries preloaded rows.
func TestResolveWeekendContextPrefersRunRaceOverLaterUnrunRace(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-13T12:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1289, "British Grand Prix", "2026-07-03T11:30:00Z", "2026-07-05T16:00:00Z", false)
addContextSession(t, svc, 11326, 1289, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
completeContextSession(t, svc, 11326, 1289)
addContextMeeting(t, svc, 1290, "Belgian Grand Prix", "2026-07-17T11:30:00Z", "2026-07-19T15:00:00Z", false)
addContextSession(t, svc, 11334, 1290, "Race", "2026-07-19T13:00:00Z", "2026-07-19T15:00:00Z", false)
completeContextSession(t, svc, 11334, 1290)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.DefaultAnalysisSession == nil {
t.Fatal("no analysis session resolved")
}
if key := got.DefaultAnalysisSession.Session.SessionKey; key != 11326 {
t.Fatalf("analysis session = %d, want 11326 (British); 11334 is the unrun Belgian race", key)
}
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Session.SessionKey != 11326 {
t.Fatalf("previous completed = %+v, want 11326", got.PreviousCompletedSession)
}
if got.NextSession == nil || got.NextSession.Session.SessionKey != 11334 {
t.Fatalf("next session = %+v, want 11334", got.NextSession)
}
}
func TestResolveWeekendContextSprintWeekendHandoff(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-04T12:00:00Z")
svc := contextService(t, now)
@@ -319,3 +399,88 @@ func TestResolveWeekendContextMissingScheduleDoesNotClaimSeasonComplete(t *testi
t.Fatalf("total rounds = %d, want scheduled round retained", got.TotalChampionshipRounds)
}
}
func TestResolveWeekendContextRaceHubDefault(t *testing.T) {
seed := func(t *testing.T, svc *Service) {
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
completeContextSession(t, svc, 11, 1)
addContextMeeting(t, svc, 2, "Belgian Grand Prix", "2026-07-17T09:00:00Z", "2026-07-19T16:00:00Z", false)
addContextSession(t, svc, 21, 2, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
}
t.Run("keeps completed analysis before handoff", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-16T07:59:59Z")
svc := contextService(t, now)
seed(t, svc)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 11 || got.RaceHubPreSession {
t.Fatalf("race hub default = %+v, pre-session = %t", got.RaceHubDefaultSession, got.RaceHubPreSession)
}
if got.RaceHubRefreshAt != "2026-07-17T08:00:00Z" {
t.Fatalf("refresh = %q", got.RaceHubRefreshAt)
}
})
t.Run("hands off exactly one hour before first session", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-17T08:00:00Z")
svc := contextService(t, now)
seed(t, svc)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 21 || !got.RaceHubPreSession {
t.Fatalf("race hub handoff = %+v, pre-session = %t", got.RaceHubDefaultSession, got.RaceHubPreSession)
}
if got.RaceHubRefreshAt != "2026-07-17T09:00:00Z" {
t.Fatalf("refresh = %q", got.RaceHubRefreshAt)
}
})
t.Run("keeps the scheduled session pending after its start without live evidence", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-17T09:00:00Z")
svc := contextService(t, now)
seed(t, svc)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 21 || !got.RaceHubPreSession {
t.Fatalf("scheduled race hub default = %+v, pre-session = %t", got.RaceHubDefaultSession, got.RaceHubPreSession)
}
if got.RaceHubRefreshAt != "2026-07-17T09:00:15Z" {
t.Fatalf("refresh = %q", got.RaceHubRefreshAt)
}
})
t.Run("active live session wins", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-17T08:30:00Z")
svc := contextService(t, now)
seed(t, svc)
got, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "Belgian Grand Prix", SessionName: "Practice 1", SessionType: "Practice 1"})
if err != nil {
t.Fatal(err)
}
if got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 21 || got.RaceHubPreSession {
t.Fatalf("live default = %+v, pre-session = %t", got.RaceHubDefaultSession, got.RaceHubPreSession)
}
})
t.Run("does not select an empty future session before handoff", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-16T12:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 2, "Belgian Grand Prix", "2026-07-17T09:00:00Z", "2026-07-19T16:00:00Z", false)
addContextSession(t, svc, 21, 2, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.RaceHubDefaultSession != nil || got.RaceHubPreSession {
t.Fatalf("unexpected empty future default: %+v", got)
}
})
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import (
func (s *Server) handleWeekendContext(w http.ResponseWriter, _ *http.Request) {
if !s.hasLocalQuery() {
markDataResponse(w, "none", "limited")
writeJSON(w, query.WeekendContext{TemporalState: query.TemporalNoSeason})
return
}
@@ -29,9 +30,33 @@ func (s *Server) handleWeekendContext(w http.ResponseWriter, _ *http.Request) {
writeError(w, err, http.StatusInternalServerError, false)
return
}
if focus := focusedContextSession(context); focus != nil {
markDataResponse(w, focus.Availability.Source, focus.Availability.Freshness)
} else {
markDataResponse(w, "none", "limited")
}
writeJSON(w, context)
}
// focusedContextSession selects the session whose state the Weekend shell is
// presenting. An older terminal/default session must never override an
// upcoming focus session's metadata.
func focusedContextSession(context query.WeekendContext) *query.ContextSession {
if context.ActiveSession != nil {
return context.ActiveSession
}
if context.FocusMeeting == nil {
return nil
}
focusKey := context.FocusMeeting.MeetingKey
for _, ref := range []*query.ContextSession{context.NextSession, context.PreviousCompletedSession, context.DefaultAnalysisSession} {
if ref != nil && ref.Meeting != nil && ref.Meeting.MeetingKey == focusKey {
return ref
}
}
return nil
}
func liveEvidence(data *live.LiveStreamData, active, final bool) query.LiveEvidence {
return query.LiveEvidence{Active: active, Final: final, MeetingName: data.Session.MeetingName, CircuitName: data.Session.CircuitName, SessionName: data.Session.SessionName, SessionType: data.Session.SessionType}
}

View File

@@ -47,6 +47,19 @@ func TestWeekendContextHandlerWithoutStoreReturnsNoSeason(t *testing.T) {
if got.TemporalState != query.TemporalNoSeason {
t.Fatalf("state = %s", got.TemporalState)
}
if rr.Header().Get(dataSourceHeader) != "none" || rr.Header().Get(dataFreshnessHeader) != "limited" {
t.Fatalf("missing context metadata = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
}
}
func TestWeekendContextHandlerEmptyStoreReportsLimited(t *testing.T) {
st := openContextStore(t)
s := NewServer(nil, 0, st)
rr := httptest.NewRecorder()
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
if rr.Code != http.StatusOK || rr.Header().Get(dataSourceHeader) != "none" || rr.Header().Get(dataFreshnessHeader) != "limited" {
t.Fatalf("empty context = %d %q/%q body=%s", rr.Code, rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader), rr.Body.String())
}
}
func TestWeekendContextHandlerUsesLiveHubIdentityWithoutOpenF1(t *testing.T) {
@@ -76,6 +89,12 @@ func TestWeekendContextHandlerUsesLiveHubIdentityWithoutOpenF1(t *testing.T) {
if got.ActiveSession.Availability.LiveTransport != "connected" || got.ActiveSession.Availability.LiveSession != "active" {
t.Fatalf("availability = %+v", got.ActiveSession.Availability)
}
if got.ActiveSession.Availability.Source != "mixed" || got.ActiveSession.Availability.Freshness != "live" {
t.Fatalf("live source/freshness = %+v", got.ActiveSession.Availability)
}
if rr.Header().Get(dataSourceHeader) != "mixed" || rr.Header().Get(dataFreshnessHeader) != "live" {
t.Fatalf("response source/freshness = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
}
}
func TestWeekendContextHandlerUsesTerminalArchiveAsCompletionEvidence(t *testing.T) {
@@ -94,11 +113,48 @@ func TestWeekendContextHandlerUsesTerminalArchiveAsCompletionEvidence(t *testing
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Availability.Archive != "available" {
t.Fatalf("archive context = %+v", got)
}
if got.PreviousCompletedSession.Availability.Source != "mixed" || got.PreviousCompletedSession.Availability.Freshness != "archive" {
t.Fatalf("archive source/freshness = %+v", got.PreviousCompletedSession.Availability)
}
if rr.Header().Get(dataSourceHeader) != "mixed" || rr.Header().Get(dataFreshnessHeader) != "archive" {
t.Fatalf("archive response source/freshness = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
}
if got.DefaultAnalysisSession != nil {
t.Fatal("archive-only session must not become local default analysis")
}
}
func TestWeekendContextMetadataFollowsUpcomingFocusNotTerminalPrevious(t *testing.T) {
st := openContextStore(t)
seedContextHandler(t, st)
if err := st.UpsertMeeting(store.Meeting{MeetingKey: 2, MeetingName: "Belgian Grand Prix", CircuitShortName: "Spa", Year: 2026, DateStart: "2026-07-10T09:00:00Z", DateEnd: "2026-07-12T16:00:00Z"}); err != nil {
t.Fatal(err)
}
if err := st.UpsertSession(store.Session{SessionKey: 21, MeetingKey: 2, SessionName: "Practice 1", SessionType: "Practice", DateStart: "2026-07-10T09:00:00Z", DateEnd: "2026-07-10T10:00:00Z"}); err != nil {
t.Fatal(err)
}
now, _ := time.Parse(time.RFC3339, "2026-07-05T16:05:00Z")
s := NewServer(nil, 0, st)
s.query = query.NewServiceWithClock(st, func() time.Time { return now })
s.hub.applySnapshot(live.LiveStreamData{SessionStatus: "Finished", Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", CircuitName: "Silverstone", SessionName: "Race", SessionType: "Race"}}, now)
rr := httptest.NewRecorder()
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
var got query.WeekendContext
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.FocusMeeting == nil || got.FocusMeeting.MeetingKey != 2 || got.NextSession == nil {
t.Fatalf("focus context = %+v", got)
}
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Availability.Freshness != "archive" {
t.Fatalf("terminal previous missing = %+v", got.PreviousCompletedSession)
}
if rr.Header().Get(dataSourceHeader) != "local" || rr.Header().Get(dataFreshnessHeader) != "local" {
t.Fatalf("focus metadata was overridden by archive = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
}
}
func TestTerminalSessionStatus(t *testing.T) {
for _, status := range []string{"Finished", "Finalised", "ENDED", "Aborted"} {
if !terminalSessionStatus(status) {
@@ -111,3 +167,30 @@ func TestTerminalSessionStatus(t *testing.T) {
}
}
}
func TestWeekendContextHandlerSerializesRaceHubRefreshDeadline(t *testing.T) {
st := openContextStore(t)
seedContextHandler(t, st)
if err := st.UpsertSessionResult(store.SessionResult{SessionKey: 11, MeetingKey: 1, DriverNumber: 1, Position: 1}); err != nil {
t.Fatal(err)
}
if err := st.UpsertMeeting(store.Meeting{MeetingKey: 2, MeetingName: "Belgian Grand Prix", CircuitShortName: "Spa", Year: 2026, DateStart: "2026-07-17T09:00:00Z", DateEnd: "2026-07-19T16:00:00Z"}); err != nil {
t.Fatal(err)
}
if err := st.UpsertSession(store.Session{SessionKey: 21, MeetingKey: 2, SessionName: "Practice 1", SessionType: "Practice", DateStart: "2026-07-17T09:00:00Z", DateEnd: "2026-07-17T10:00:00Z"}); err != nil {
t.Fatal(err)
}
s := NewServer(nil, 0, st)
s.query = query.NewServiceWithClock(st, func() time.Time {
return time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)
})
rr := httptest.NewRecorder()
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
var got query.WeekendContext
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !got.RaceHubPreSession || got.RaceHubRefreshAt != "2026-07-17T09:00:00Z" || got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 21 {
t.Fatalf("race hub context = %+v", got)
}
}

View File

@@ -1,11 +1,13 @@
package web
import (
"context"
"fmt"
"net/http"
"strconv"
"time"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models"
)
@@ -78,6 +80,7 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("source") == "" {
mode = sourceAuto
}
client := s.client.Scoped()
if mode == sourceLocal || mode == sourceAuto {
resp, sessionKey, ok, lerr := s.localDriverSummary(year, driverNumber)
@@ -87,7 +90,15 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
}
if ok {
if mode != sourceLocal {
s.tryEnrichDriverSummary(&resp, sessionKey)
tryEnrichDriverSummary(r.Context(), client, &resp, sessionKey)
}
switch resp.Enrichment {
case "full":
markMixedResponse(w, client, false)
case "limited":
markDataResponse(w, "local", "limited")
default:
markLocalResponse(w, false)
}
writeJSON(w, resp)
return
@@ -98,15 +109,16 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
}
}
resp, err := s.openF1DriverSummary(year, driverNumber)
resp, incomplete, err := openF1DriverSummary(client, year, driverNumber)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
if resp == nil {
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
return
}
markOpenF1AggregateResponse(w, client, incomplete)
writeJSON(w, resp)
}
@@ -152,10 +164,10 @@ func (s *Server) localDriverSummary(year, driverNumber int) (driverSummaryRespon
return resp, sessionKey, true, nil
}
func (s *Server) openF1DriverSummary(year, driverNumber int) (*driverSummaryResponse, error) {
champ, err := s.client.GetDriverChampionshipForYear(year)
func openF1DriverSummary(client *api.OpenF1Client, year, driverNumber int) (*driverSummaryResponse, bool, error) {
champ, err := client.GetDriverChampionshipForYear(year)
if err != nil {
return nil, err
return nil, false, err
}
var entry *models.ChampionshipDriver
for i := range champ {
@@ -165,65 +177,65 @@ func (s *Server) openF1DriverSummary(year, driverNumber int) (*driverSummaryResp
}
}
if entry == nil {
return nil, nil
return nil, false, nil
}
driverInfo := map[int]models.Driver{}
sessionKey := champ[0].SessionKey
if ds, derr := s.client.GetDriversForSession(sessionKey); derr == nil {
if ds, derr := client.GetDriversForSession(sessionKey); derr == nil {
driverInfo = buildDriverMapFirst(ds)
}
if d, ok := s.championshipDriverInfo(entry.SessionKey, driverNumber, driverInfo); ok {
driverInfo[driverNumber] = d
d, directErr := client.GetDriver(entry.SessionKey, driverNumber)
if directErr == nil && d != nil {
driverInfo[driverNumber] = *d
} else if fallback, ok := driverInfo[driverNumber]; ok {
driverInfo[driverNumber] = fallback
}
races, _, err := s.fetchSeasonRaces(year)
identityIncomplete := !hasDriverPresentation(driverInfo[driverNumber])
races, racesIncomplete, err := fetchSeasonRaces(client, year)
if err != nil {
return nil, err
return nil, false, err
}
resp, ok := aggregateDriverSummary(year, driverNumber, races, champ, driverInfo)
if !ok {
return nil, nil
return nil, false, nil
}
incomplete := identityIncomplete || racesIncomplete
resp.Source = "openf1"
if identityIncomplete {
resp.Enrichment = "limited"
} else {
resp.Enrichment = "full"
return &resp, nil
}
return &resp, incomplete, nil
}
func hasDriverPresentation(driver models.Driver) bool {
hasName := driver.FullName != "" || driver.NameAcronym != "" || driver.BroadcastName != ""
return hasName && driver.TeamName != "" && driver.TeamColour != ""
}
// tryEnrichDriverSummary optionally fills headshot / polished identity from
// OpenF1. It never blocks longer than driverEnrichmentTimeout — on timeout or
// failure the local profile remains intact with enrichment=limited.
func (s *Server) tryEnrichDriverSummary(resp *driverSummaryResponse, sessionKey int) {
if resp == nil || s.client == nil || sessionKey <= 0 {
func tryEnrichDriverSummary(parent context.Context, client *api.OpenF1Client, resp *driverSummaryResponse, sessionKey int) {
if resp == nil || client == nil || sessionKey <= 0 {
if resp != nil && resp.Enrichment == "none" {
// No session to enrich from — leave as none (local identity only).
}
return
}
type enrichResult struct {
driver models.Driver
ok bool
}
done := make(chan enrichResult, 1)
go func() {
d, ok := s.championshipDriverInfo(sessionKey, resp.DriverNumber, nil)
done <- enrichResult{driver: d, ok: ok}
}()
select {
case result := <-done:
if !result.ok {
ctx, cancel := context.WithTimeout(parent, driverEnrichmentTimeout)
defer cancel()
driver, err := client.GetDriverContext(ctx, sessionKey, resp.DriverNumber)
if err != nil || driver == nil {
resp.Enrichment = "limited"
return
}
applyDriverEnrichment(resp, result.driver)
applyDriverEnrichment(resp, *driver)
resp.Enrichment = "full"
case <-time.After(driverEnrichmentTimeout):
resp.Enrichment = "limited"
}
}
func applyDriverEnrichment(resp *driverSummaryResponse, d models.Driver) {

View File

@@ -2,6 +2,7 @@ package web
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
@@ -212,10 +213,12 @@ func TestHandleDriverSummaryLocalFirstIgnoresHangingEnrichment(t *testing.T) {
// Enrichment seam: OpenF1 hangs until released. Local summary must still return.
release := make(chan struct{})
cancelObserved := make(chan struct{})
hang := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-release:
case <-r.Context().Done():
close(cancelObserved)
}
}))
t.Cleanup(func() {
@@ -239,6 +242,11 @@ func TestHandleDriverSummaryLocalFirstIgnoresHangingEnrichment(t *testing.T) {
if elapsed > 500*time.Millisecond {
t.Fatalf("handler blocked on enrichment for %v", elapsed)
}
select {
case <-cancelObserved:
case <-time.After(250 * time.Millisecond):
t.Fatal("timed-out enrichment did not cancel its upstream request")
}
var resp driverSummaryResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
@@ -250,6 +258,9 @@ func TestHandleDriverSummaryLocalFirstIgnoresHangingEnrichment(t *testing.T) {
if resp.Enrichment != "limited" {
t.Errorf("enrichment = %q, want limited", resp.Enrichment)
}
if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "limited" {
t.Errorf("limited metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
}
if resp.DriverNumber != 1 || resp.NameAcronym != "VER" || resp.Points != 25 {
t.Errorf("local identity/results missing: %+v", resp)
}
@@ -289,6 +300,9 @@ func TestHandleDriverSummaryLocalFirstWithFailingEnrichment(t *testing.T) {
if resp.Enrichment != "limited" {
t.Errorf("enrichment = %q, want limited", resp.Enrichment)
}
if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "limited" {
t.Errorf("limited metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
}
if resp.FullName != "Max Verstappen" {
t.Errorf("full_name = %q, want local identity", resp.FullName)
}
@@ -313,4 +327,46 @@ func TestHandleDriverSummarySourceLocalOnly(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "local" {
t.Fatalf("local metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
}
}
func TestHandleRemoteDriverSummaryReportsIdentityAndRoundLimitations(t *testing.T) {
tests := []struct {
name string
driversOK bool
meetingHasRace bool
wantEnrichment string
}{
{name: "missing identity", driversOK: false, meetingHasRace: true, wantEnrichment: "limited"},
{name: "missing race round", driversOK: true, meetingHasRace: false, wantEnrichment: "full"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
upstream := championshipTestUpstream(t, tt.driversOK, tt.meetingHasRace)
defer upstream.Close()
client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
defer client.Close()
server := NewServer(client, 0, nil)
year := time.Now().Year()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/driver/summary?year=%d&driver_number=1&source=openf1", year), nil)
server.handleDriverSummary(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String())
}
var response driverSummaryResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Enrichment != tt.wantEnrichment {
t.Fatalf("enrichment = %q, want %q", response.Enrichment, tt.wantEnrichment)
}
if recorder.Header().Get(dataSourceHeader) != "openf1" || recorder.Header().Get(dataFreshnessHeader) != "partial" {
t.Fatalf("remote limitation metadata = %q/%q", recorder.Header().Get(dataSourceHeader), recorder.Header().Get(dataFreshnessHeader))
}
})
}
}

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

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

View File

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

View File

@@ -134,7 +134,7 @@ func (h *SSEHub) applySnapshot(data live.LiveStreamData, now time.Time) liveStat
h.mu.Lock()
defer h.mu.Unlock()
if live.SessionStatusIsActive(data.SessionStatus) {
if snapshotIsLive(data) {
h.isLive = true
h.activeSnapshot = &data
if data.PositionUpdated && len(data.Positions) > 0 {
@@ -208,6 +208,40 @@ func (h *SSEHub) stateLocked() liveStatePayload {
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 {
return len(data.Drivers) > 0 ||
len(data.DriverInfo) > 0 ||
@@ -330,7 +364,15 @@ func cloneLivePositions(in map[string]live.LivePositionData) map[string]live.Liv
// handleLiveState returns the current live data snapshot as JSON.
func (s *Server) handleLiveState(w http.ResponseWriter, r *http.Request) {
writeJSON(w, s.hub.State())
state := s.hub.State()
freshness := "limited"
if state.IsLive {
freshness = "live"
} else if state.LastSnapshot != nil {
freshness = "archive"
}
markDataResponse(w, "fia", freshness)
writeJSON(w, state)
}
// handleSSEStream is the persistent SSE endpoint for live data.

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ func (s *Server) handleRaceHub(w http.ResponseWriter, r *http.Request) {
}
if !s.hasLocalQuery() {
markDataResponse(w, "none", "limited")
writeJSON(w, emptyRaceHub(sessionKey))
return
}
@@ -26,6 +27,14 @@ func (s *Server) handleRaceHub(w http.ResponseWriter, r *http.Request) {
writeError(w, err, http.StatusInternalServerError, false)
return
}
switch hub.Source {
case query.ResponseSourceNone:
markDataResponse(w, "none", "limited")
case query.ResponseSourcePartial:
markLocalResponse(w, true)
default:
markLocalResponse(w, false)
}
writeJSON(w, hub)
}

View File

@@ -86,11 +86,29 @@ func TestHandleRaceHubWithoutStore(t *testing.T) {
if hub.Source != query.ResponseSourceNone {
t.Fatalf("source = %q, want %q", hub.Source, query.ResponseSourceNone)
}
if rec.Header().Get(dataSourceHeader) != "none" || rec.Header().Get(dataFreshnessHeader) != "limited" {
t.Fatalf("missing hub metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
}
if hub.Datasets["session"].Status != query.DatasetStatusMissing {
t.Fatalf("session dataset = %+v, want missing", hub.Datasets["session"])
}
}
func TestHandleRaceHubUnknownSessionWithStoreReportsLimited(t *testing.T) {
st := openTestStore(t)
srv := testServer(t, st)
rec := httptest.NewRecorder()
srv.handleRaceHub(rec, httptest.NewRequest(http.MethodGet, "/api/v1/race-hub?session_key=999999", nil))
var hub query.RaceHub
if err := json.Unmarshal(rec.Body.Bytes(), &hub); err != nil {
t.Fatal(err)
}
if hub.Source != query.ResponseSourceNone || rec.Header().Get(dataSourceHeader) != "none" || rec.Header().Get(dataFreshnessHeader) != "limited" {
t.Fatalf("unknown session = source %q, metadata %q/%q", hub.Source, rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
}
}
func TestHandleRaceHubWithLocalData(t *testing.T) {
st := openTestStore(t)
seedRaceHubStore(t, st)
@@ -117,6 +135,12 @@ func TestHandleRaceHubWithLocalData(t *testing.T) {
if hub.Datasets["results"].Status != query.DatasetStatusMissing {
t.Fatalf("results dataset = %+v, want missing", hub.Datasets["results"])
}
if hub.Source != query.ResponseSourcePartial {
t.Fatalf("source = %q, want partial", hub.Source)
}
if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "partial" {
t.Fatalf("partial hub metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
}
}
func TestHandleRaceHubIncludesChapters(t *testing.T) {

View File

@@ -61,15 +61,27 @@ func (s *Server) handleReplayFrames(w http.ResponseWriter, r *http.Request) {
}
}
resp, err := assembleReplayFrames(r.Context(), s.client, sessionKey, intervalMS)
client := s.client.Scoped()
resp, incomplete, err := assembleReplayFrames(r.Context(), client, sessionKey, intervalMS)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
markOpenF1Availability(w, client, replayResponseFreshness(resp, incomplete))
writeJSON(w, resp)
}
func assembleReplayFrames(ctx context.Context, client replayDataClient, sessionKey, intervalMS int) (replayFramesResponse, error) {
func replayResponseFreshness(resp replayFramesResponse, incomplete bool) string {
if !incomplete {
return "fresh"
}
if len(resp.Frames) == 0 {
return "limited"
}
return "partial"
}
func assembleReplayFrames(ctx context.Context, client replayDataClient, sessionKey, intervalMS int) (replayFramesResponse, bool, error) {
if intervalMS < defaultReplayIntervalMS {
intervalMS = defaultReplayIntervalMS
}
@@ -82,26 +94,26 @@ func assembleReplayFrames(ctx context.Context, client replayDataClient, sessionK
drivers, err := client.GetDriversForSession(sessionKey)
if err != nil {
return resp, err
return resp, false, err
}
driverNumbers := uniqueDriverNumbers(drivers)
if len(driverNumbers) == 0 {
return resp, nil
return resp, true, nil
}
series, err := fetchReplayLocationSeries(ctx, client, sessionKey, driverNumbers)
if err != nil && len(series) == 0 {
return resp, err
return resp, false, err
}
start, ok := earliestReplayLocationTime(series)
if !ok {
return resp, nil
return resp, true, nil
}
resp.StartTime = start.Format(time.RFC3339Nano)
resp.Frames = snapReplayFrames(series, start, intervalMS)
return resp, nil
return resp, err != nil || len(series) < len(driverNumbers) || len(resp.Frames) == 0, nil
}
func uniqueDriverNumbers(drivers []models.Driver) []int {

View File

@@ -3,6 +3,7 @@ package web
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync"
@@ -16,6 +17,7 @@ type fakeReplayClient struct {
drivers []models.Driver
locs map[int][]models.Location
err error
locErrs map[int]error
mu sync.Mutex
inFlight int
@@ -46,7 +48,38 @@ func (f *fakeReplayClient) GetLocation(sessionKey, driverNumber int) ([]models.L
f.inFlight--
f.mu.Unlock()
return f.locs[driverNumber], nil
return f.locs[driverNumber], f.locErrs[driverNumber]
}
func TestAssembleReplayFramesReportsPartialDriverSeries(t *testing.T) {
start := time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC)
client := &fakeReplayClient{
drivers: []models.Driver{{DriverNumber: 1}, {DriverNumber: 4}},
locs: map[int][]models.Location{
1: {{Date: start.Format(time.RFC3339Nano), X: 1, Y: 2}},
},
locErrs: map[int]error{4: errors.New("location unavailable")},
}
resp, incomplete, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS)
if err != nil {
t.Fatalf("partial replay should remain usable: %v", err)
}
if !incomplete || len(resp.Frames) != 1 {
t.Fatalf("partial replay = incomplete %v, frames %+v", incomplete, resp.Frames)
}
if got := replayResponseFreshness(resp, incomplete); got != "partial" {
t.Fatalf("partial replay freshness = %q", got)
}
}
func TestAssembleReplayFramesEmptyDriverSetIsLimited(t *testing.T) {
resp, incomplete, err := assembleReplayFrames(context.Background(), &fakeReplayClient{}, 99, defaultReplayIntervalMS)
if err != nil {
t.Fatal(err)
}
if !incomplete || replayResponseFreshness(resp, incomplete) != "limited" {
t.Fatalf("empty replay = incomplete %v, freshness %q", incomplete, replayResponseFreshness(resp, incomplete))
}
}
func TestAssembleReplayFramesSnapsNearestSamplesAndOmitsEmptyDrivers(t *testing.T) {
@@ -70,10 +103,16 @@ func TestAssembleReplayFramesSnapsNearestSamplesAndOmitsEmptyDrivers(t *testing.
},
}
resp, err := assembleReplayFrames(context.Background(), client, 99, 5000)
resp, incomplete, err := assembleReplayFrames(context.Background(), client, 99, 5000)
if err != nil {
t.Fatalf("assembleReplayFrames() error = %v", err)
}
if !incomplete {
t.Fatal("empty entrant location series was labelled complete")
}
if got := replayResponseFreshness(resp, incomplete); got != "partial" {
t.Fatalf("empty entrant freshness = %q", got)
}
if resp.SessionKey != 99 || resp.Interval != 5000 {
t.Fatalf("response metadata = %+v", resp)
}
@@ -112,7 +151,7 @@ func TestAssembleReplayFramesCapsFrameCount(t *testing.T) {
locs: map[int][]models.Location{1: locs},
}
resp, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS)
resp, _, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS)
if err != nil {
t.Fatalf("assembleReplayFrames() error = %v", err)
}
@@ -139,7 +178,7 @@ func TestAssembleReplayFramesBoundsLocationFanOut(t *testing.T) {
delay: 5 * time.Millisecond,
}
if _, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS); err != nil {
if _, _, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS); err != nil {
t.Fatalf("assembleReplayFrames() error = %v", err)
}
if client.maxInFlight > replayFetchConcurrency {
@@ -167,7 +206,7 @@ func TestHandleReplayFramesValidatesParamsAndFloorsInterval(t *testing.T) {
client := &fakeReplayClient{drivers: []models.Driver{{DriverNumber: 1}}, locs: map[int][]models.Location{
1: {{Date: time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC).Format(time.RFC3339Nano), X: 1, Y: 2}},
}}
resp, err := assembleReplayFrames(context.Background(), client, 99, 1000)
resp, _, err := assembleReplayFrames(context.Background(), client, 99, 1000)
if err != nil {
t.Fatalf("assembleReplayFrames() error = %v", err)
}

View File

@@ -159,6 +159,7 @@ func withCORS(next http.Handler) http.Handler {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Access-Control-Expose-Headers", dataSourceHeader+", "+dataFreshnessHeader)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return

View File

@@ -84,20 +84,14 @@ func seedMeeting(st *store.Store, meetingKey int) error {
}
func seedSession(st *store.Store, sessionKey, meetingKey int, name string) error {
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{
SessionKey: sessionKey,
MeetingKey: meetingKey,
SessionName: name,
SessionType: "Race",
CircuitKey: 10,
DateStart: start,
DateEnd: end,
DateStart: "2025-05-25T13:00:00+00:00",
DateEnd: "2025-05-25T15:00:00+00:00",
})
}

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