Compare commits
12 Commits
feat/issue
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14d9bd3dd4 | ||
|
|
15d81c8eab | ||
|
|
2651569453 | ||
|
|
ad0eb26e05 | ||
|
|
5ea8a4a9c4 | ||
|
|
b3d1730324 | ||
|
|
e8a1f15f8b | ||
|
|
e66a874755 | ||
|
|
ad870c040c | ||
|
|
dbe3ac644d | ||
|
|
1543a09ff1 | ||
|
|
408de2da0c |
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from './lib/fetch'
|
||||||
import type {
|
import type {
|
||||||
ArticleContent,
|
ArticleContent,
|
||||||
CarDataSample,
|
CarDataSample,
|
||||||
@@ -18,101 +19,118 @@ import type {
|
|||||||
WeekendContext,
|
WeekendContext,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
|
export async function fetchRaceHub(sessionKey: number, signal?: AbortSignal): Promise<RaceHub> {
|
||||||
const res = await fetch(`/api/v1/race-hub?session_key=${sessionKey}`)
|
return apiFetch<RaceHub>(`/api/v1/race-hub?session_key=${sessionKey}`, {
|
||||||
if (!res.ok) {
|
signal,
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
dedupeKey: `race-hub:${sessionKey}`,
|
||||||
}
|
})
|
||||||
return res.json()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSeasons(): Promise<number[]> {
|
export async function fetchSeasons(signal?: AbortSignal): Promise<number[]> {
|
||||||
const res = await fetch('/api/v1/seasons')
|
const years = await apiFetch<number[]>('/api/v1/seasons', {
|
||||||
if (!res.ok) {
|
signal,
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
dedupeKey: 'seasons',
|
||||||
}
|
})
|
||||||
const years = await res.json()
|
|
||||||
return Array.isArray(years) ? years : []
|
return Array.isArray(years) ? years : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchLocalMeetings(year: number): Promise<Meeting[]> {
|
export async function fetchLocalMeetings(year: number, signal?: AbortSignal): Promise<Meeting[]> {
|
||||||
const res = await fetch(`/api/v1/meetings?year=${year}&source=local`)
|
const meetings = await apiFetch<Meeting[]>(`/api/v1/meetings?year=${year}&source=local`, {
|
||||||
if (!res.ok) {
|
signal,
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
dedupeKey: `meetings:local:${year}`,
|
||||||
}
|
})
|
||||||
const meetings = await res.json()
|
|
||||||
return Array.isArray(meetings) ? meetings : []
|
return Array.isArray(meetings) ? meetings : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
|
export async function fetchSeasonMeetings(year: number, signal?: AbortSignal): Promise<Meeting[]> {
|
||||||
return fetchMeetings(year, 'openf1')
|
return fetchMeetings(year, 'openf1', signal)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchMeetings(year: number, source = 'auto'): Promise<Meeting[]> {
|
export async function fetchMeetings(
|
||||||
const res = await fetch(`/api/v1/meetings?year=${year}&source=${source}`)
|
year: number,
|
||||||
if (!res.ok) {
|
source = 'auto',
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
signal?: AbortSignal,
|
||||||
}
|
): Promise<Meeting[]> {
|
||||||
const meetings = await res.json()
|
const meetings = await apiFetch<Meeting[]>(`/api/v1/meetings?year=${year}&source=${source}`, {
|
||||||
|
signal,
|
||||||
|
dedupeKey: `meetings:${source}:${year}`,
|
||||||
|
})
|
||||||
return Array.isArray(meetings) ? meetings : []
|
return Array.isArray(meetings) ? meetings : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchResults(sessionKey: number, source = 'auto'): Promise<EnrichedResult[]> {
|
export async function fetchResults(
|
||||||
const res = await fetch(`/api/v1/results?session_key=${sessionKey}&source=${source}`)
|
sessionKey: number,
|
||||||
if (!res.ok) {
|
source = 'auto',
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
signal?: AbortSignal,
|
||||||
}
|
): Promise<EnrichedResult[]> {
|
||||||
const results = await res.json()
|
const results = await apiFetch<EnrichedResult[]>(
|
||||||
|
`/api/v1/results?session_key=${sessionKey}&source=${source}`,
|
||||||
|
{ signal, dedupeKey: `results:${source}:${sessionKey}` },
|
||||||
|
)
|
||||||
return Array.isArray(results) ? results : []
|
return Array.isArray(results) ? results : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchStartingGrid(sessionKey: number, source = 'auto'): Promise<EnrichedGrid[]> {
|
export async function fetchStartingGrid(
|
||||||
const res = await fetch(`/api/v1/grid?session_key=${sessionKey}&source=${source}`)
|
sessionKey: number,
|
||||||
if (!res.ok) {
|
source = 'auto',
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
signal?: AbortSignal,
|
||||||
}
|
): Promise<EnrichedGrid[]> {
|
||||||
const grid = await res.json()
|
const grid = await apiFetch<EnrichedGrid[]>(
|
||||||
|
`/api/v1/grid?session_key=${sessionKey}&source=${source}`,
|
||||||
|
{ signal, dedupeKey: `grid:${source}:${sessionKey}` },
|
||||||
|
)
|
||||||
return Array.isArray(grid) ? grid : []
|
return Array.isArray(grid) ? grid : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchTrackOutline(circuitKey: number, year: number): Promise<TrackOutline | null> {
|
export async function fetchTrackOutline(
|
||||||
const res = await fetch(`/api/v1/track-outline?circuit_key=${circuitKey}&year=${year}`)
|
circuitKey: number,
|
||||||
if (!res.ok) return null
|
year: number,
|
||||||
const data = await res.json()
|
signal?: AbortSignal,
|
||||||
|
): Promise<TrackOutline | null> {
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<TrackOutline & { error?: string }>(
|
||||||
|
`/api/v1/track-outline?circuit_key=${circuitKey}&year=${year}`,
|
||||||
|
{ signal, dedupeKey: `track-outline:${circuitKey}:${year}` },
|
||||||
|
)
|
||||||
if (data?.error || !Array.isArray(data?.points) || data.points.length < 2) return null
|
if (data?.error || !Array.isArray(data?.points) || data.points.length < 2) return null
|
||||||
return data as TrackOutline
|
return data as TrackOutline
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchReplayFrames(
|
export async function fetchReplayFrames(
|
||||||
sessionKey: number,
|
sessionKey: number,
|
||||||
intervalMs = 5000,
|
intervalMs = 5000,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<ReplayFramesResponse> {
|
): Promise<ReplayFramesResponse> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
session_key: String(sessionKey),
|
session_key: String(sessionKey),
|
||||||
interval_ms: String(intervalMs),
|
interval_ms: String(intervalMs),
|
||||||
})
|
})
|
||||||
const res = await fetch(`/api/v1/replay/frames?${params}`)
|
return apiFetch<ReplayFramesResponse>(`/api/v1/replay/frames?${params}`, {
|
||||||
if (!res.ok) {
|
signal,
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
dedupeKey: `replay-frames:${sessionKey}:${intervalMs}`,
|
||||||
}
|
})
|
||||||
return res.json()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise<Session[]> {
|
export async function fetchSessions(
|
||||||
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
|
meetingKey: number,
|
||||||
if (!res.ok) {
|
source = 'openf1',
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
signal?: AbortSignal,
|
||||||
}
|
): Promise<Session[]> {
|
||||||
const sessions = await res.json()
|
const sessions = await apiFetch<Session[]>(
|
||||||
|
`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`,
|
||||||
|
{ signal, dedupeKey: `sessions:${source}:${meetingKey}` },
|
||||||
|
)
|
||||||
return Array.isArray(sessions) ? sessions : []
|
return Array.isArray(sessions) ? sessions : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
|
export async function fetchWeekend(meetingKey: number, signal?: AbortSignal): Promise<Weekend> {
|
||||||
const res = await fetch(`/api/v1/weekend?meeting_key=${meetingKey}`)
|
return apiFetch<Weekend>(`/api/v1/weekend?meeting_key=${meetingKey}`, {
|
||||||
if (!res.ok) {
|
signal,
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
dedupeKey: `weekend:${meetingKey}`,
|
||||||
}
|
})
|
||||||
return res.json()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchWeekendContext consumes the canonical /api/v1/weekend-context endpoint
|
// fetchWeekendContext consumes the canonical /api/v1/weekend-context endpoint
|
||||||
@@ -121,61 +139,64 @@ export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
|
|||||||
// so the hook can surface an explicit error state; there is no client-side
|
// 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
|
// re-derivation of the contract. Race Hub bare-default landing also reads this
|
||||||
// for `default_analysis_session` (#75).
|
// for `default_analysis_session` (#75).
|
||||||
export async function fetchWeekendContext(): Promise<WeekendContext> {
|
export async function fetchWeekendContext(signal?: AbortSignal): Promise<WeekendContext> {
|
||||||
const res = await fetch('/api/v1/weekend-context')
|
return apiFetch<WeekendContext>('/api/v1/weekend-context', {
|
||||||
if (!res.ok) {
|
signal,
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
dedupeKey: 'weekend-context',
|
||||||
}
|
})
|
||||||
return res.json()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchChampionshipHub(year?: number): Promise<ChampionshipHub> {
|
export async function fetchChampionshipHub(
|
||||||
|
year?: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ChampionshipHub> {
|
||||||
const params = new URLSearchParams({ source: 'auto' })
|
const params = new URLSearchParams({ source: 'auto' })
|
||||||
if (year) params.set('year', year.toString())
|
if (year) params.set('year', year.toString())
|
||||||
const url = `/api/v1/championship/hub?${params.toString()}`
|
return apiFetch<ChampionshipHub>(`/api/v1/championship/hub?${params.toString()}`, {
|
||||||
const res = await fetch(url)
|
signal,
|
||||||
if (!res.ok) {
|
dedupeKey: `championship-hub:${year ?? 'latest'}`,
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
})
|
||||||
}
|
|
||||||
return res.json()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchDriverSummary(
|
export async function fetchDriverSummary(
|
||||||
driverNumber: number,
|
driverNumber: number,
|
||||||
year?: number,
|
year?: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<DriverSummary> {
|
): Promise<DriverSummary> {
|
||||||
const params = new URLSearchParams({ driver_number: String(driverNumber) })
|
const params = new URLSearchParams({ driver_number: String(driverNumber), source: 'auto' })
|
||||||
if (year) params.set('year', String(year))
|
if (year) params.set('year', String(year))
|
||||||
const res = await fetch(`/api/v1/driver/summary?${params.toString()}`)
|
return apiFetch<DriverSummary>(`/api/v1/driver/summary?${params.toString()}`, {
|
||||||
if (!res.ok) {
|
signal,
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
dedupeKey: `driver-summary:${driverNumber}:${year ?? 'latest'}`,
|
||||||
}
|
})
|
||||||
return res.json()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchLiveState(): Promise<LiveStateResponse> {
|
export async function fetchLiveState(signal?: AbortSignal): Promise<LiveStateResponse> {
|
||||||
const res = await fetch('/api/v1/live/state')
|
return apiFetch<LiveStateResponse>('/api/v1/live/state', {
|
||||||
if (!res.ok) {
|
signal,
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
dedupeKey: 'live-state',
|
||||||
}
|
})
|
||||||
return res.json()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchLiveTrackOutline(
|
export async function fetchLiveTrackOutline(
|
||||||
session: LiveSessionMeta,
|
session: LiveSessionMeta,
|
||||||
year = new Date().getFullYear(),
|
year = new Date().getFullYear(),
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<TrackOutline> {
|
): Promise<TrackOutline> {
|
||||||
const params = new URLSearchParams({ year: year.toString() })
|
const params = new URLSearchParams({ year: year.toString() })
|
||||||
if (session.MeetingName) params.set('meeting_name', session.MeetingName)
|
if (session.MeetingName) params.set('meeting_name', session.MeetingName)
|
||||||
if (session.CircuitName) params.set('circuit_name', session.CircuitName)
|
if (session.CircuitName) params.set('circuit_name', session.CircuitName)
|
||||||
const res = await fetch(`/api/v1/track-outline?${params.toString()}`)
|
return apiFetch<TrackOutline>(`/api/v1/track-outline?${params.toString()}`, {
|
||||||
if (!res.ok) {
|
signal,
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
dedupeKey: `live-track-outline:${year}:${session.MeetingName ?? ''}:${session.CircuitName ?? ''}`,
|
||||||
}
|
})
|
||||||
return res.json()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchNews(limit?: number, source?: string): Promise<NewsItem[]> {
|
export async function fetchNews(
|
||||||
|
limit?: number,
|
||||||
|
source?: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<NewsItem[]> {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (limit) params.set('limit', limit.toString())
|
if (limit) params.set('limit', limit.toString())
|
||||||
if (source) params.set('source', source)
|
if (source) params.set('source', source)
|
||||||
@@ -183,19 +204,20 @@ export async function fetchNews(limit?: number, source?: string): Promise<NewsIt
|
|||||||
const query = params.toString()
|
const query = params.toString()
|
||||||
const url = query ? `/api/v1/news?${query}` : '/api/v1/news'
|
const url = query ? `/api/v1/news?${query}` : '/api/v1/news'
|
||||||
|
|
||||||
const res = await fetch(url)
|
return apiFetch<NewsItem[]>(url, {
|
||||||
if (!res.ok) {
|
signal,
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
dedupeKey: `news:${limit ?? 'all'}:${source ?? 'all'}`,
|
||||||
}
|
})
|
||||||
return res.json()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchNewsArticle(articleUrl: string): Promise<ArticleContent> {
|
export async function fetchNewsArticle(
|
||||||
const res = await fetch(`/api/v1/news/article?url=${encodeURIComponent(articleUrl)}`)
|
articleUrl: string,
|
||||||
if (!res.ok) {
|
signal?: AbortSignal,
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
): Promise<ArticleContent> {
|
||||||
}
|
return apiFetch<ArticleContent>(
|
||||||
return res.json()
|
`/api/v1/news/article?url=${encodeURIComponent(articleUrl)}`,
|
||||||
|
{ signal },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markNewsRead(articleUrl: string): Promise<void> {
|
export async function markNewsRead(articleUrl: string): Promise<void> {
|
||||||
@@ -209,28 +231,26 @@ export async function markNewsRead(articleUrl: string): Promise<void> {
|
|||||||
export async function fetchTelemetry(
|
export async function fetchTelemetry(
|
||||||
sessionKey: number,
|
sessionKey: number,
|
||||||
driverNumber: number,
|
driverNumber: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<CarDataSample[]> {
|
): Promise<CarDataSample[]> {
|
||||||
const res = await fetch(
|
const data = await apiFetch<CarDataSample[]>(
|
||||||
`/api/v1/telemetry?session_key=${sessionKey}&driver_number=${driverNumber}`,
|
`/api/v1/telemetry?session_key=${sessionKey}&driver_number=${driverNumber}`,
|
||||||
|
{ signal, dedupeKey: `telemetry:${sessionKey}:${driverNumber}` },
|
||||||
)
|
)
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
|
||||||
}
|
|
||||||
const data = await res.json()
|
|
||||||
return Array.isArray(data) ? data : []
|
return Array.isArray(data) ? data : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchLapsComparison(
|
export async function fetchLapsComparison(
|
||||||
sessionKey: number,
|
sessionKey: number,
|
||||||
drivers?: number[],
|
drivers?: number[],
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<LapsComparisonResponse> {
|
): Promise<LapsComparisonResponse> {
|
||||||
const params = new URLSearchParams({ session_key: String(sessionKey) })
|
const params = new URLSearchParams({ session_key: String(sessionKey) })
|
||||||
if (drivers?.length) {
|
if (drivers?.length) {
|
||||||
params.set('drivers', drivers.join(','))
|
params.set('drivers', drivers.join(','))
|
||||||
}
|
}
|
||||||
const res = await fetch(`/api/v1/laps/comparison?${params}`)
|
return apiFetch<LapsComparisonResponse>(`/api/v1/laps/comparison?${params}`, {
|
||||||
if (!res.ok) {
|
signal,
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
dedupeKey: `laps-comparison:${sessionKey}:${drivers?.join(',') ?? 'all'}`,
|
||||||
}
|
})
|
||||||
return res.json()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
|
|||||||
import { formatCoverageHint } from '../lib/coverage'
|
import { formatCoverageHint } from '../lib/coverage'
|
||||||
import { SourceBadge } from './SourceBadge'
|
import { SourceBadge } from './SourceBadge'
|
||||||
import { SessionCoverageDots } from './SessionCoverageDots'
|
import { SessionCoverageDots } from './SessionCoverageDots'
|
||||||
|
import { RouteState } from './RouteState'
|
||||||
import type { Meeting, WeekendSession } from '../types'
|
import type { Meeting, WeekendSession } from '../types'
|
||||||
|
|
||||||
function formatMeetingDates(meeting: Meeting): string {
|
function formatMeetingDates(meeting: Meeting): string {
|
||||||
@@ -29,18 +30,18 @@ export function LocalDataNavigator({ onSelectSession }: Props) {
|
|||||||
|
|
||||||
const seasonsQuery = useQuery({
|
const seasonsQuery = useQuery({
|
||||||
queryKey: ['seasons'],
|
queryKey: ['seasons'],
|
||||||
queryFn: fetchSeasons,
|
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||||
})
|
})
|
||||||
|
|
||||||
const meetingsQuery = useQuery({
|
const meetingsQuery = useQuery({
|
||||||
queryKey: ['meetings', selectedYear],
|
queryKey: ['meetings', selectedYear],
|
||||||
queryFn: () => fetchLocalMeetings(selectedYear!),
|
queryFn: ({ signal }) => fetchLocalMeetings(selectedYear!, signal),
|
||||||
enabled: selectedYear != null,
|
enabled: selectedYear != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const weekendQuery = useQuery({
|
const weekendQuery = useQuery({
|
||||||
queryKey: ['weekend', selectedMeetingKey],
|
queryKey: ['weekend', selectedMeetingKey],
|
||||||
queryFn: () => fetchWeekend(selectedMeetingKey!),
|
queryFn: ({ signal }) => fetchWeekend(selectedMeetingKey!, signal),
|
||||||
enabled: selectedMeetingKey != null,
|
enabled: selectedMeetingKey != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -68,14 +69,22 @@ export function LocalDataNavigator({ onSelectSession }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (seasonsQuery.isLoading) {
|
if (seasonsQuery.isLoading) {
|
||||||
return <div className="nav-panel loading-state">loading local seasons…</div>
|
return <RouteState kind="loading" title="loading local seasons…" className="nav-panel" />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seasonsQuery.isError) {
|
if (seasonsQuery.isError) {
|
||||||
return (
|
return (
|
||||||
<div className="nav-panel error-box">
|
<RouteState
|
||||||
{seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'}
|
kind="error"
|
||||||
</div>
|
className="nav-panel"
|
||||||
|
title="Local seasons unavailable"
|
||||||
|
error={seasonsQuery.error}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!seasonsQuery.isFetching) void seasonsQuery.refetch()
|
||||||
|
}}
|
||||||
|
retrying={seasonsQuery.isFetching}
|
||||||
|
testId="weekend-error"
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,9 +133,16 @@ export function LocalDataNavigator({ onSelectSession }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{meetingsQuery.isError && (
|
{meetingsQuery.isError && (
|
||||||
<div className="error-box" style={{ marginTop: 'var(--s4)' }}>
|
<RouteState
|
||||||
{meetingsQuery.error instanceof Error ? meetingsQuery.error.message : 'Failed to load meetings'}
|
kind="error"
|
||||||
</div>
|
title="Meetings unavailable"
|
||||||
|
error={meetingsQuery.error}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!meetingsQuery.isFetching) void meetingsQuery.refetch()
|
||||||
|
}}
|
||||||
|
retrying={meetingsQuery.isFetching}
|
||||||
|
testId="weekend-meetings-error"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && (
|
{!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && (
|
||||||
@@ -194,9 +210,16 @@ export function LocalDataNavigator({ onSelectSession }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedMeetingKey != null && weekendQuery.isError && (
|
{selectedMeetingKey != null && weekendQuery.isError && (
|
||||||
<div className="error-box" style={{ marginTop: 'var(--s4)' }}>
|
<RouteState
|
||||||
{weekendQuery.error instanceof Error ? weekendQuery.error.message : 'Failed to load weekend'}
|
kind="error"
|
||||||
</div>
|
title="Weekend unavailable"
|
||||||
|
error={weekendQuery.error}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!weekendQuery.isFetching) void weekendQuery.refetch()
|
||||||
|
}}
|
||||||
|
retrying={weekendQuery.isFetching}
|
||||||
|
testId="weekend-error"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{weekend && (
|
{weekend && (
|
||||||
|
|||||||
@@ -34,25 +34,25 @@ export function PaddockBriefing() {
|
|||||||
|
|
||||||
const { data: news, isLoading, isError } = useQuery({
|
const { data: news, isLoading, isError } = useQuery({
|
||||||
queryKey: ['news'],
|
queryKey: ['news'],
|
||||||
queryFn: () => fetchNews(100),
|
queryFn: ({ signal }) => fetchNews(100, undefined, signal),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const seasonsQuery = useQuery({
|
const seasonsQuery = useQuery({
|
||||||
queryKey: ['seasons'],
|
queryKey: ['seasons'],
|
||||||
queryFn: fetchSeasons,
|
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||||
})
|
})
|
||||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||||
|
|
||||||
const meetingsQuery = useQuery({
|
const meetingsQuery = useQuery({
|
||||||
queryKey: ['season-meetings', latestSeason],
|
queryKey: ['season-meetings', latestSeason],
|
||||||
queryFn: () => fetchSeasonMeetings(latestSeason!),
|
queryFn: ({ signal }) => fetchSeasonMeetings(latestSeason!, signal),
|
||||||
enabled: latestSeason != null,
|
enabled: latestSeason != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const hubQuery = useQuery({
|
const hubQuery = useQuery({
|
||||||
queryKey: ['championship-hub', latestSeason],
|
queryKey: ['championship-hub', latestSeason],
|
||||||
queryFn: () => fetchChampionshipHub(latestSeason!),
|
queryFn: ({ signal }) => fetchChampionshipHub(latestSeason!, signal),
|
||||||
enabled: latestSeason != null,
|
enabled: latestSeason != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -704,7 +704,12 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{displayResults.length > 0 && (
|
{displayResults.length > 0 && (
|
||||||
<div className="rs-field-list">
|
<div
|
||||||
|
className="rs-field-list"
|
||||||
|
role="region"
|
||||||
|
aria-label="Final running order"
|
||||||
|
data-testid="race-story-outcome"
|
||||||
|
>
|
||||||
{displayResults.map((r, i) => {
|
{displayResults.map((r, i) => {
|
||||||
const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
|
const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
|
||||||
const currentPos = scrubTime !== null ? i + 1 : r.position
|
const currentPos = scrubTime !== null ? i + 1 : r.position
|
||||||
|
|||||||
166
frontend/src/components/RouteState.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ export function WeekendSwitcher({
|
|||||||
onClose,
|
onClose,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons })
|
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: ({ signal }) => fetchSeasons(signal) })
|
||||||
const [year, setYear] = useState<number | null>(null)
|
const [year, setYear] = useState<number | null>(null)
|
||||||
const [openMeetingKey, setOpenMeetingKey] = useState<number | null>(
|
const [openMeetingKey, setOpenMeetingKey] = useState<number | null>(
|
||||||
currentMeetingKey ?? null,
|
currentMeetingKey ?? null,
|
||||||
@@ -36,7 +36,7 @@ export function WeekendSwitcher({
|
|||||||
|
|
||||||
const weekendQuery = useQuery({
|
const weekendQuery = useQuery({
|
||||||
queryKey: ['weekend', openMeetingKey],
|
queryKey: ['weekend', openMeetingKey],
|
||||||
queryFn: () => fetchWeekend(openMeetingKey!),
|
queryFn: ({ signal }) => fetchWeekend(openMeetingKey!, signal),
|
||||||
enabled: openMeetingKey != null,
|
enabled: openMeetingKey != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ export function WeekendSwitcher({
|
|||||||
|
|
||||||
const meetingsQuery = useQuery({
|
const meetingsQuery = useQuery({
|
||||||
queryKey: ['meetings', year],
|
queryKey: ['meetings', year],
|
||||||
queryFn: () => fetchLocalMeetings(year!),
|
queryFn: ({ signal }) => fetchLocalMeetings(year!, signal),
|
||||||
enabled: year != null,
|
enabled: year != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
263
frontend/src/components/live/LiveHandoff.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { Archive, ChevronRight, Flag, Radio } from 'lucide-react'
|
||||||
|
import type { LiveTimingRow } from '../../lib/live'
|
||||||
|
import { driverCode } from '../../lib/live'
|
||||||
|
import type { TransportHealth } from '../../lib/liveState'
|
||||||
|
import { feedHealthLabel } from '../../lib/liveState'
|
||||||
|
import type { ContextSession, WeekendContext } from '../../types'
|
||||||
|
import { analysisSessionKey } from '../../lib/weekendContext'
|
||||||
|
import { formatSessionScheduleTime } from '../../lib/schedule'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 'settling' immediately after a session; 'inactive' when nothing is live. */
|
||||||
|
phase: 'settling' | 'inactive'
|
||||||
|
transport: TransportHealth
|
||||||
|
/** Canonical weekend context (issue #72). May be undefined before it loads. */
|
||||||
|
context: WeekendContext | undefined
|
||||||
|
/** Top rows of the final snapshot (settling only), already position-sorted. */
|
||||||
|
rows: LiveTimingRow[]
|
||||||
|
capturedAt?: string | null
|
||||||
|
hasArchive: boolean
|
||||||
|
onViewArchive: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCapturedAt(capturedAt: string | null | undefined): string {
|
||||||
|
if (!capturedAt) return ''
|
||||||
|
const date = new Date(capturedAt)
|
||||||
|
if (Number.isNaN(date.getTime())) return ''
|
||||||
|
return date.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionKeyOf(session: ContextSession | undefined): number | undefined {
|
||||||
|
const key = session?.session.session_key
|
||||||
|
return key && key > 0 ? key : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fully ingested analysis (local_analysis === complete). Stricter than
|
||||||
|
* hasLocalAnalysis, which also treats partial as link-worthy — Live polling
|
||||||
|
* and "ready" chrome wait for the complete state.
|
||||||
|
*/
|
||||||
|
export function analysisIsReady(session: ContextSession | undefined): boolean {
|
||||||
|
return session?.availability?.local_analysis === 'complete'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analysis target for the Live handoff surface.
|
||||||
|
*
|
||||||
|
* Settling must follow the just-finished session (`previous_completed_session`).
|
||||||
|
* The canonical backend can mark that session archive-complete while leaving
|
||||||
|
* `default_analysis_session` on an older already-ingested practice/qualifying —
|
||||||
|
* preferring default here would link/poll/label the wrong race.
|
||||||
|
*
|
||||||
|
* Inactive keeps the shared default-first preference via analysisSessionKey.
|
||||||
|
*/
|
||||||
|
export function handoffAnalysisSession(
|
||||||
|
context: WeekendContext | undefined,
|
||||||
|
phase: 'settling' | 'inactive',
|
||||||
|
): ContextSession | undefined {
|
||||||
|
if (!context) return undefined
|
||||||
|
if (phase === 'settling') {
|
||||||
|
if (sessionKeyOf(context.previous_completed_session)) {
|
||||||
|
return context.previous_completed_session
|
||||||
|
}
|
||||||
|
return sessionKeyOf(context.default_analysis_session)
|
||||||
|
? context.default_analysis_session
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
const key = analysisSessionKey(context)
|
||||||
|
if (!key) return undefined
|
||||||
|
if (context.default_analysis_session?.session.session_key === key) {
|
||||||
|
return context.default_analysis_session
|
||||||
|
}
|
||||||
|
return context.previous_completed_session
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep polling weekend-context while the just-finished session (or, absent
|
||||||
|
* that, the default analysis session) is still ingesting. An older ready
|
||||||
|
* default must not stop the settle→ready transition.
|
||||||
|
*/
|
||||||
|
export function shouldPollHandoffAnalysis(context: WeekendContext | undefined): boolean {
|
||||||
|
if (!context) return true
|
||||||
|
const previous = context.previous_completed_session
|
||||||
|
if (sessionKeyOf(previous) && !analysisIsReady(previous)) return true
|
||||||
|
const fallback = context.default_analysis_session
|
||||||
|
if (sessionKeyOf(fallback) && !analysisIsReady(fallback)) return true
|
||||||
|
return !sessionKeyOf(previous) && !sessionKeyOf(fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LiveHandoff({
|
||||||
|
phase,
|
||||||
|
transport,
|
||||||
|
context,
|
||||||
|
rows,
|
||||||
|
capturedAt,
|
||||||
|
hasArchive,
|
||||||
|
onViewArchive,
|
||||||
|
}: Props) {
|
||||||
|
const isSettling = phase === 'settling'
|
||||||
|
const testid = isSettling ? 'live-settling' : 'live-inactive'
|
||||||
|
const capturedLabel = formatCapturedAt(capturedAt)
|
||||||
|
|
||||||
|
const focusName = context?.focus_meeting?.meeting_name
|
||||||
|
const activeName = context?.active_session?.meeting?.meeting_name
|
||||||
|
const title = focusName || activeName || 'Live Timing'
|
||||||
|
const topRows = rows.slice(0, 3)
|
||||||
|
|
||||||
|
const analysis = handoffAnalysisSession(context, phase)
|
||||||
|
const analysisKey = sessionKeyOf(analysis)
|
||||||
|
const analysisName = analysis?.session.session_name || 'session'
|
||||||
|
const analysisReady = analysisIsReady(analysis)
|
||||||
|
|
||||||
|
const next = context?.next_session
|
||||||
|
const previous = context?.previous_completed_session
|
||||||
|
// Avoid a duplicate recap card when previous == the analysis target.
|
||||||
|
const showRecap =
|
||||||
|
previous && previous.session.session_key !== analysisKey && previous.session.session_key !== 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={`live-handoff live-handoff-${phase}`} data-testid={testid}>
|
||||||
|
<header className="live-handoff-head">
|
||||||
|
<span className="live-handoff-eyebrow mono">
|
||||||
|
{isSettling ? (
|
||||||
|
<>
|
||||||
|
<Flag size={13} /> SESSION SETTLING
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Radio size={13} /> NO LIVE SESSION
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<h1 className="live-handoff-title">{title}</h1>
|
||||||
|
{isSettling && capturedLabel && (
|
||||||
|
<p className="live-handoff-captured mono" data-testid="live-handoff-captured">
|
||||||
|
Final feed snapshot captured {capturedLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!isSettling && (
|
||||||
|
<p className="live-handoff-sub">
|
||||||
|
The timing feed is quiet between sessions. Here's where the weekend stands.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<span className={`live-feed-health live-feed-${transport} live-handoff-feed`}>
|
||||||
|
<span className="live-feed-dot" aria-hidden="true" />
|
||||||
|
{feedHealthLabel(transport)}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{isSettling && topRows.length > 0 && (
|
||||||
|
<div className="live-handoff-snapshot" data-testid="live-handoff-snapshot">
|
||||||
|
<span className="live-handoff-snapshot-label mono">Provisional order at chequered</span>
|
||||||
|
<ol className="live-handoff-order">
|
||||||
|
{topRows.map((row) => (
|
||||||
|
<li key={row.RacingNumber}>
|
||||||
|
<span className="mono">P{row.Position}</span>
|
||||||
|
<span className="live-handoff-code">{driverCode(row)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="live-handoff-actions">
|
||||||
|
{analysisKey ? (
|
||||||
|
<a
|
||||||
|
className="live-handoff-action live-handoff-primary"
|
||||||
|
href={`/race-hub?session_key=${analysisKey}`}
|
||||||
|
data-testid="live-handoff-analysis"
|
||||||
|
data-ready={analysisReady ? 'true' : 'false'}
|
||||||
|
>
|
||||||
|
<span className="live-handoff-action-body">
|
||||||
|
<span className="live-handoff-action-label">
|
||||||
|
{isSettling ? `Open ${analysisName} analysis` : `Open ${analysisName} in Race Hub`}
|
||||||
|
</span>
|
||||||
|
<span className="live-handoff-action-meta mono">
|
||||||
|
{analysisReady
|
||||||
|
? 'Full timing, strategy & story ready'
|
||||||
|
: isSettling
|
||||||
|
? 'Settling — analysis will fill in as data ingests'
|
||||||
|
: 'Analysis will fill in as data ingests'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={18} />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="live-handoff-action live-handoff-pending"
|
||||||
|
data-testid="live-handoff-analysis-pending"
|
||||||
|
>
|
||||||
|
<span className="live-handoff-action-body">
|
||||||
|
<span className="live-handoff-action-label">Analysis not ready yet</span>
|
||||||
|
<span className="live-handoff-action-meta mono">
|
||||||
|
The completed session will appear in Race Hub once it is ingested.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{next && (
|
||||||
|
<div className="live-handoff-action live-handoff-next" data-testid="live-handoff-next">
|
||||||
|
<span className="live-handoff-action-body">
|
||||||
|
<span className="live-handoff-action-label">
|
||||||
|
Up next · {next.session.session_name}
|
||||||
|
</span>
|
||||||
|
<span className="live-handoff-action-meta mono">
|
||||||
|
{formatSessionScheduleTime(next.session.date_start)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showRecap && (
|
||||||
|
<a
|
||||||
|
className="live-handoff-action"
|
||||||
|
href={`/race-hub?session_key=${previous!.session.session_key}`}
|
||||||
|
data-testid="live-handoff-recap"
|
||||||
|
>
|
||||||
|
<span className="live-handoff-action-body">
|
||||||
|
<span className="live-handoff-action-label">
|
||||||
|
Recap · {previous!.session.session_name}
|
||||||
|
</span>
|
||||||
|
<span className="live-handoff-action-meta mono">
|
||||||
|
Review the last completed session
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={18} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasArchive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="live-handoff-action live-handoff-archive-btn"
|
||||||
|
onClick={onViewArchive}
|
||||||
|
data-testid="live-handoff-archive"
|
||||||
|
>
|
||||||
|
<span className="live-handoff-action-body">
|
||||||
|
<span className="live-handoff-action-label">
|
||||||
|
<Archive size={14} /> View full timing (read-only)
|
||||||
|
</span>
|
||||||
|
<span className="live-handoff-action-meta mono">
|
||||||
|
Frozen final snapshot — no live updates
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!analysisKey && !next && !showRecap && !hasArchive && (
|
||||||
|
<div className="live-handoff-fallback" data-testid="live-handoff-fallback">
|
||||||
|
<a href="/" className="live-handoff-action">
|
||||||
|
<span className="live-handoff-action-body">
|
||||||
|
<span className="live-handoff-action-label">Weekend</span>
|
||||||
|
<span className="live-handoff-action-meta mono">Weekend schedule & standings</span>
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={18} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,30 +1,60 @@
|
|||||||
import type { LiveStreamData } from '../../types'
|
import type { LiveStreamData } from '../../types'
|
||||||
import type { LiveTimingRow } from '../../lib/live'
|
import type { LiveTimingRow } from '../../lib/live'
|
||||||
import { extrapolateClock, liveSessionDisplay } from '../../lib/live'
|
import { extrapolateClock, liveSessionDisplay } from '../../lib/live'
|
||||||
|
import type { LivePhase, TransportHealth } from '../../lib/liveState'
|
||||||
|
import { feedHealthLabel } from '../../lib/liveState'
|
||||||
import { WeatherStrip } from './WeatherStrip'
|
import { WeatherStrip } from './WeatherStrip'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isLive: boolean
|
/** Which of the live phases we are rendering: 'live' | 'disconnected' | 'archive'. */
|
||||||
isArchive?: boolean
|
phase: LivePhase
|
||||||
snapshot: LiveStreamData
|
snapshot: LiveStreamData
|
||||||
rows: LiveTimingRow[]
|
rows: LiveTimingRow[]
|
||||||
connection: 'connected' | 'connecting' | 'disconnected' | 'error'
|
transport: TransportHealth
|
||||||
now: number
|
now: number
|
||||||
|
/** ISO capture time — required for archive, shown as the timestamp of record. */
|
||||||
|
capturedAt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionBanner({ isLive, isArchive = false, snapshot, rows, connection, now }: Props) {
|
function formatCapturedAt(capturedAt: string | null | undefined): string {
|
||||||
|
if (!capturedAt) return ''
|
||||||
|
const date = new Date(capturedAt)
|
||||||
|
if (Number.isNaN(date.getTime())) return ''
|
||||||
|
return date.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionBanner({ phase, snapshot, rows, transport, now, capturedAt }: Props) {
|
||||||
const session = snapshot.Session
|
const session = snapshot.Session
|
||||||
const clock = extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now)
|
const isArchive = phase === 'archive'
|
||||||
|
const isLiveSession = phase === 'live' || phase === 'disconnected'
|
||||||
|
// Archive is a frozen single frame: never extrapolate a running clock for it.
|
||||||
|
const clock = isArchive
|
||||||
|
? ''
|
||||||
|
: extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now)
|
||||||
const display = liveSessionDisplay(session, rows)
|
const display = liveSessionDisplay(session, rows)
|
||||||
const atRiskLabel =
|
const atRiskLabel =
|
||||||
display.atRiskStart && display.atRiskEnd ? `P${display.atRiskStart}-P${display.atRiskEnd} at risk` : ''
|
display.atRiskStart && display.atRiskEnd ? `P${display.atRiskStart}-P${display.atRiskEnd} at risk` : ''
|
||||||
const stateLabel = isLive ? 'live' : isArchive ? 'archive' : 'stale'
|
const capturedLabel = formatCapturedAt(capturedAt)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="live-banner">
|
<section className={`live-banner${isArchive ? ' live-banner-archive' : ''}`} data-testid="live-banner">
|
||||||
<div className="live-banner-row">
|
<div className="live-banner-row">
|
||||||
<div className="live-banner-main">
|
<div className="live-banner-main">
|
||||||
<span className={`live-conn live-conn-${connection}`}>{connection}</span>
|
{isArchive ? (
|
||||||
|
<span className="live-session-flag live-session-flag-archive" data-testid="live-archive-flag">
|
||||||
|
ARCHIVE
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={`live-session-flag live-session-flag-live${
|
||||||
|
phase === 'disconnected' ? ' is-stale' : ''
|
||||||
|
}`}
|
||||||
|
data-testid="live-session-flag"
|
||||||
|
>
|
||||||
|
<span className="live-session-dot" aria-hidden="true" />
|
||||||
|
LIVE SESSION
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<h1>{session?.MeetingName || 'Live Timing'}</h1>
|
<h1>{session?.MeetingName || 'Live Timing'}</h1>
|
||||||
<p>
|
<p>
|
||||||
@@ -33,15 +63,40 @@ export function SessionBanner({ isLive, isArchive = false, snapshot, rows, conne
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="live-session-board">
|
<div className="live-session-board">
|
||||||
|
{/* 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>}
|
{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-clock mono" data-testid="live-clock">{clock || '--:--:--'}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="live-banner-meta">
|
<div className="live-banner-meta">
|
||||||
{display.advanceCount && <span>{display.advanceCount} advance</span>}
|
{!isArchive && display.advanceCount && <span>{display.advanceCount} advance</span>}
|
||||||
{atRiskLabel && <span>{atRiskLabel}</span>}
|
{!isArchive && atRiskLabel && <span>{atRiskLabel}</span>}
|
||||||
<span>
|
<span>
|
||||||
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
|
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
|
||||||
</span>
|
</span>
|
||||||
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{stateLabel}</span>
|
{!isArchive && (
|
||||||
|
<span className={phase === 'live' ? 'live-state live-state-on' : 'live-state'}>
|
||||||
|
{phase === 'live' ? 'live' : 'stale'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isArchive && <span className="live-state live-state-archive">archive</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ export function BetweenSessionsView({
|
|||||||
const previous = context.previous_completed_session
|
const previous = context.previous_completed_session
|
||||||
const previousName = previous?.session.session_name ?? 'Last session'
|
const previousName = previous?.session.session_name ?? 'Last session'
|
||||||
const next = context.next_session?.session
|
const next = context.next_session?.session
|
||||||
const analysisKey = analysisSessionKey(context)
|
// 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)
|
const nodes = railNodes(previous, context.active_session, context.next_session)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { userFacingError } from '../../lib/fetch'
|
||||||
|
|
||||||
export function WeekendLoading() {
|
export function WeekendLoading() {
|
||||||
return (
|
return (
|
||||||
@@ -9,12 +10,40 @@ export function WeekendLoading() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WeekendError({ message }: { message?: string }) {
|
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 (
|
return (
|
||||||
<div className="wk-status wk-status-error" data-testid="weekend-error" role="alert">
|
<div className="wk-status wk-status-error" data-testid="weekend-error" role="alert">
|
||||||
<span className="wk-status-eyebrow mono">box-box · weekend</span>
|
<span className="wk-status-eyebrow mono">box-box · weekend</span>
|
||||||
<p className="wk-status-title">Weekend unavailable</p>
|
<p className="wk-status-title">Weekend unavailable</p>
|
||||||
<p className="wk-status-sub">{message ?? 'Something went wrong loading the weekend context.'}</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export interface UseWeekendContextResult {
|
|||||||
/** Supplementary briefing items (not part of the #72 contract). */
|
/** Supplementary briefing items (not part of the #72 contract). */
|
||||||
briefing: WeekendBriefingItem[]
|
briefing: WeekendBriefingItem[]
|
||||||
now: Date
|
now: Date
|
||||||
|
/** Refetch the canonical weekend-context read. */
|
||||||
|
refetch: () => void
|
||||||
|
/** True while a canonical refetch is in flight. */
|
||||||
|
isFetching: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,7 +48,7 @@ export function useWeekendContext(): UseWeekendContextResult {
|
|||||||
|
|
||||||
const contextQuery = useQuery({
|
const contextQuery = useQuery({
|
||||||
queryKey: ['weekend-context'],
|
queryKey: ['weekend-context'],
|
||||||
queryFn: fetchWeekendContext,
|
queryFn: ({ signal }) => fetchWeekendContext(signal),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -56,14 +60,14 @@ export function useWeekendContext(): UseWeekendContextResult {
|
|||||||
// so a pending/failed canonical read never triggers a request fan-out.
|
// so a pending/failed canonical read never triggers a request fan-out.
|
||||||
const championshipQuery = useQuery({
|
const championshipQuery = useQuery({
|
||||||
queryKey: ['championship-hub', season ?? 'current'],
|
queryKey: ['championship-hub', season ?? 'current'],
|
||||||
queryFn: () => fetchChampionshipHub(season),
|
queryFn: ({ signal }) => fetchChampionshipHub(season, signal),
|
||||||
enabled: canonicalReady,
|
enabled: canonicalReady,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const newsQuery = useQuery({
|
const newsQuery = useQuery({
|
||||||
queryKey: ['news', 6],
|
queryKey: ['news', 6],
|
||||||
queryFn: () => fetchNews(6),
|
queryFn: ({ signal }) => fetchNews(6, undefined, signal),
|
||||||
enabled: canonicalReady,
|
enabled: canonicalReady,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
@@ -85,5 +89,9 @@ export function useWeekendContext(): UseWeekendContextResult {
|
|||||||
championship,
|
championship,
|
||||||
briefing,
|
briefing,
|
||||||
now: nowDate,
|
now: nowDate,
|
||||||
|
refetch: () => {
|
||||||
|
if (!contextQuery.isFetching) void contextQuery.refetch()
|
||||||
|
},
|
||||||
|
isFetching: contextQuery.isFetching,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
267
frontend/src/lib/fetch.ts
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
/** 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()
|
||||||
|
}
|
||||||
168
frontend/src/lib/liveState.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
// Live timing state model.
|
||||||
|
//
|
||||||
|
// The Live page has to hold four *orthogonal* concepts that used to be
|
||||||
|
// conflated into a single "connected / offline" flag:
|
||||||
|
//
|
||||||
|
// 1. transport health — is the SSE stream up? (connecting/connected/…)
|
||||||
|
// 2. active session — is a session actually running right now?
|
||||||
|
// 3. archive availability — do we retain a final snapshot to inspect?
|
||||||
|
// 4. analysis readiness — is the completed session ingested for Race Hub?
|
||||||
|
//
|
||||||
|
// `deriveLivePhase` collapses the first three inputs into a single UI phase so
|
||||||
|
// the page never, for example, calls a finished session "offline" or lets a
|
||||||
|
// dropped socket masquerade as "archive mode".
|
||||||
|
//
|
||||||
|
// Critical distinction (issue #74 P0): the server's `deactivate()` path moves a
|
||||||
|
// STILL-ACTIVE snapshot into `last_snapshot` whenever the upstream FIA SignalR
|
||||||
|
// connection ends or idles — WITHOUT changing SessionStatus. So `is_live=false`
|
||||||
|
// alone is ambiguous. We rely on the retained snapshot's terminal SessionStatus
|
||||||
|
// (Finished/Ended/…) as the only trustworthy "the session really ended" signal.
|
||||||
|
// A non-terminal retained snapshot means the feed dropped, not that the session
|
||||||
|
// finished, and must render as `disconnected` (retain snapshot, warn, recover),
|
||||||
|
// never `settling`/archive.
|
||||||
|
|
||||||
|
export type TransportHealth = 'connecting' | 'connected' | 'disconnected' | 'error'
|
||||||
|
|
||||||
|
export type LivePhase =
|
||||||
|
| 'connecting' // cold start: no snapshot yet, still opening the feed
|
||||||
|
| 'live' // a session is running and streaming
|
||||||
|
| 'disconnected' // was live, transport/feed dropped — keep the last snapshot, warn
|
||||||
|
| 'settling' // session truly ended; a final snapshot is retained, analysis pending
|
||||||
|
| 'archive' // user opened the retained snapshot as an explicit read-only view
|
||||||
|
| 'inactive' // no session and nothing retained — show weekend context instead
|
||||||
|
|
||||||
|
export interface LiveStateInputs {
|
||||||
|
transport: TransportHealth
|
||||||
|
/** The feed reports an active session AND carries a snapshot for it. */
|
||||||
|
isLive: boolean
|
||||||
|
hasActiveSnapshot: boolean
|
||||||
|
/** A retained snapshot from the last stream is held (may be a dropped feed). */
|
||||||
|
hasArchive: boolean
|
||||||
|
/** The user explicitly opened the archive as a read-only timing view. */
|
||||||
|
archiveMode: boolean
|
||||||
|
/**
|
||||||
|
* The retained snapshot ended on a *terminal* FIA SessionStatus. Only true
|
||||||
|
* here means the session genuinely finished (vs. the transport dropping).
|
||||||
|
*/
|
||||||
|
sessionEndedCleanly: boolean
|
||||||
|
/** We have observed this session live at least once in this page session. */
|
||||||
|
wasLive: boolean
|
||||||
|
/**
|
||||||
|
* The initial `/api/v1/live/state` query has resolved. `connecting` is a
|
||||||
|
* cold-start-only phase: once we know the current state (even if the SSE
|
||||||
|
* transport handshake is still pending), an idle feed is `inactive`, not a
|
||||||
|
* perpetual "connecting…" spinner.
|
||||||
|
*/
|
||||||
|
stateLoaded: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw FIA SessionStatus values that mean the session is genuinely over. Mirrors
|
||||||
|
// the backend `terminalSessionStatus` used by the weekend-context resolver so
|
||||||
|
// the client and server agree on "finished".
|
||||||
|
const TERMINAL_STATUSES = new Set(['finished', 'finalised', 'finalized', 'ended', 'aborted'])
|
||||||
|
|
||||||
|
function normalizeStatus(status: string | undefined | null): string {
|
||||||
|
if (!status) return ''
|
||||||
|
return status.replace(/[^a-zA-Z]/g, '').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a raw FIA SessionStatus represents a genuinely completed session. */
|
||||||
|
export function terminalSessionStatus(status: string | undefined | null): boolean {
|
||||||
|
return TERMINAL_STATUSES.has(normalizeStatus(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transportDown(transport: TransportHealth): boolean {
|
||||||
|
return transport === 'disconnected' || transport === 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map transport + session + archive inputs to one UI phase. Session lifecycle
|
||||||
|
* and transport health are deliberately orthogonal: a live session with a
|
||||||
|
* dropped socket is `disconnected` (snapshot retained), never `archive` or
|
||||||
|
* `settling`.
|
||||||
|
*/
|
||||||
|
export function deriveLivePhase(input: LiveStateInputs): LivePhase {
|
||||||
|
const {
|
||||||
|
transport,
|
||||||
|
isLive,
|
||||||
|
hasActiveSnapshot,
|
||||||
|
hasArchive,
|
||||||
|
archiveMode,
|
||||||
|
sessionEndedCleanly,
|
||||||
|
stateLoaded,
|
||||||
|
} = input
|
||||||
|
|
||||||
|
// Explicit read-only archive wins — it is a user-chosen mode.
|
||||||
|
if (archiveMode && hasArchive) return 'archive'
|
||||||
|
|
||||||
|
// An active session: transport health only downgrades the *presentation*,
|
||||||
|
// it never removes the session.
|
||||||
|
if (isLive && hasActiveSnapshot) {
|
||||||
|
return transportDown(transport) ? 'disconnected' : 'live'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed reports no active session but a snapshot is retained. Distinguish a
|
||||||
|
// genuine session end from an upstream feed drop using the *only* trustworthy
|
||||||
|
// signal: whether the retained snapshot carries a terminal SessionStatus.
|
||||||
|
if (hasArchive) {
|
||||||
|
if (sessionEndedCleanly) return 'settling'
|
||||||
|
// Non-terminal retained snapshot means the upstream feed dropped, not that
|
||||||
|
// the session finished. Hold the last frame and warn — never settle/archive.
|
||||||
|
return 'disconnected'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cold start only: still opening the feed and we have not yet learned the
|
||||||
|
// current state. Once the initial state query resolves, an idle feed is
|
||||||
|
// `inactive`, not a perpetual "connecting…".
|
||||||
|
if (transport === 'connecting' && !stateLoaded) return 'connecting'
|
||||||
|
|
||||||
|
return 'inactive'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Phases that render the live timing tower / snapshot surface. */
|
||||||
|
export function rendersSnapshot(phase: LivePhase): boolean {
|
||||||
|
return phase === 'live' || phase === 'disconnected' || phase === 'archive'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether live-only interpretations (pit-window rejoin, tyre-deg slope, "what
|
||||||
|
* just happened" deltas) are meaningful. They require a moving session, so an
|
||||||
|
* archived single frame must never present them as current insight.
|
||||||
|
*/
|
||||||
|
export function allowsLiveInterpretations(phase: LivePhase): boolean {
|
||||||
|
return phase === 'live' || phase === 'disconnected'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only, timestamped phases that must not show live/connected chrome. */
|
||||||
|
export function isReadOnlyPhase(phase: LivePhase): boolean {
|
||||||
|
return phase === 'archive'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fan-facing feed health. Browser SSE can stay `connected` after an upstream
|
||||||
|
* FIA drop leaves us in `disconnected` with a retained non-terminal snapshot —
|
||||||
|
* present that as reconnecting, never "Feed healthy" beside "Connection lost".
|
||||||
|
*/
|
||||||
|
export function effectiveFeedHealth(
|
||||||
|
transport: TransportHealth,
|
||||||
|
phase: LivePhase,
|
||||||
|
): TransportHealth {
|
||||||
|
if (phase === 'disconnected' && !transportDown(transport)) {
|
||||||
|
return 'disconnected'
|
||||||
|
}
|
||||||
|
return transport
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short, human transport-health label — always secondary to session state. */
|
||||||
|
export function feedHealthLabel(transport: TransportHealth): string {
|
||||||
|
switch (transport) {
|
||||||
|
case 'connected':
|
||||||
|
return 'Feed healthy'
|
||||||
|
case 'connecting':
|
||||||
|
return 'Connecting'
|
||||||
|
case 'disconnected':
|
||||||
|
return 'Reconnecting'
|
||||||
|
case 'error':
|
||||||
|
return 'Feed unavailable'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,8 @@ const queryClient = new QueryClient({
|
|||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
retry: 1,
|
// Explicit Retry on RouteState — avoid automatic retry storms.
|
||||||
|
retry: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
} from '../lib/digest'
|
} from '../lib/digest'
|
||||||
import { stripHtml, timeAgo } from '../utils'
|
import { stripHtml, timeAgo } from '../utils'
|
||||||
import type { ArticleContent, NewsItem } from '../types'
|
import type { ArticleContent, NewsItem } from '../types'
|
||||||
|
import { RouteState } from '../components/RouteState'
|
||||||
import '../styles/digest.css'
|
import '../styles/digest.css'
|
||||||
|
|
||||||
type Category = 'all' | 'official' | 'news' | 'video'
|
type Category = 'all' | 'official' | 'news' | 'video'
|
||||||
@@ -406,27 +407,27 @@ export function BriefingPage() {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const now = useMemo(() => new Date(), [])
|
const now = useMemo(() => new Date(), [])
|
||||||
|
|
||||||
const { data: allNews = [], isLoading, isError } = useQuery({
|
const { data: allNews = [], isLoading, isError, error, refetch, isFetching } = useQuery({
|
||||||
queryKey: ['news'],
|
queryKey: ['news'],
|
||||||
queryFn: () => fetchNews(100),
|
queryFn: ({ signal }) => fetchNews(100, undefined, signal),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const seasonsQuery = useQuery({
|
const seasonsQuery = useQuery({
|
||||||
queryKey: ['seasons'],
|
queryKey: ['seasons'],
|
||||||
queryFn: fetchSeasons,
|
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||||
})
|
})
|
||||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||||
|
|
||||||
const meetingsQuery = useQuery({
|
const meetingsQuery = useQuery({
|
||||||
queryKey: ['season-meetings', latestSeason],
|
queryKey: ['season-meetings', latestSeason],
|
||||||
queryFn: () => fetchSeasonMeetings(latestSeason!),
|
queryFn: ({ signal }) => fetchSeasonMeetings(latestSeason!, signal),
|
||||||
enabled: latestSeason != null,
|
enabled: latestSeason != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const hubQuery = useQuery({
|
const hubQuery = useQuery({
|
||||||
queryKey: ['championship-hub', latestSeason],
|
queryKey: ['championship-hub', latestSeason],
|
||||||
queryFn: () => fetchChampionshipHub(latestSeason!),
|
queryFn: ({ signal }) => fetchChampionshipHub(latestSeason!, signal),
|
||||||
enabled: latestSeason != null,
|
enabled: latestSeason != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -524,8 +525,21 @@ export function BriefingPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && <div className="loading-state">loading briefing…</div>}
|
{isLoading && (
|
||||||
{isError && <div className="error-box">Failed to load paddock briefing.</div>}
|
<RouteState kind="loading" title="loading briefing…" testId="briefing-loading" />
|
||||||
|
)}
|
||||||
|
{isError && (
|
||||||
|
<RouteState
|
||||||
|
kind="error"
|
||||||
|
title="Briefing unavailable"
|
||||||
|
error={error}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!isFetching) void refetch()
|
||||||
|
}}
|
||||||
|
retrying={isFetching}
|
||||||
|
testId="briefing-error"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isLoading && !isError && (
|
{!isLoading && !isError && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { ChampHubDriver, ChampionshipHub } from '../types'
|
|||||||
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
|
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
|
||||||
import { RivalryCompare } from '../components/RivalryCompare'
|
import { RivalryCompare } from '../components/RivalryCompare'
|
||||||
import { Meaning } from '../components/Meaning'
|
import { Meaning } from '../components/Meaning'
|
||||||
|
import { RouteState } from '../components/RouteState'
|
||||||
import { TeammateH2H } from '../components/TeammateH2H'
|
import { TeammateH2H } from '../components/TeammateH2H'
|
||||||
import { teammatePairs } from '../lib/h2h'
|
import { teammatePairs } from '../lib/h2h'
|
||||||
import { pointsGapMeaning } from '../lib/meaning'
|
import { pointsGapMeaning } from '../lib/meaning'
|
||||||
@@ -81,23 +82,55 @@ function teamSplit(teamName: string, drivers: ChampHubDriver[]): TeamSplit {
|
|||||||
export function ChampionshipPage() {
|
export function ChampionshipPage() {
|
||||||
const [view, setView] = useState<View>('drivers')
|
const [view, setView] = useState<View>('drivers')
|
||||||
|
|
||||||
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons })
|
const seasonsQuery = useQuery({
|
||||||
|
queryKey: ['seasons'],
|
||||||
|
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||||
|
})
|
||||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||||
|
|
||||||
const hubQuery = useQuery({
|
const hubQuery = useQuery({
|
||||||
queryKey: ['championship-hub', latestSeason],
|
queryKey: ['championship-hub', latestSeason],
|
||||||
queryFn: () => fetchChampionshipHub(latestSeason ?? undefined),
|
queryFn: ({ signal }) => fetchChampionshipHub(latestSeason ?? undefined, signal),
|
||||||
enabled: latestSeason != null,
|
enabled: latestSeason != null,
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (seasonsQuery.isLoading || hubQuery.isLoading) {
|
if (seasonsQuery.isLoading || hubQuery.isLoading) {
|
||||||
return <div className="page loading-state">loading championship…</div>
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<RouteState kind="loading" title="loading championship…" testId="championship-loading" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (seasonsQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<RouteState
|
||||||
|
kind="error"
|
||||||
|
title="Championship unavailable"
|
||||||
|
error={seasonsQuery.error}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!seasonsQuery.isFetching) void seasonsQuery.refetch()
|
||||||
|
}}
|
||||||
|
retrying={seasonsQuery.isFetching}
|
||||||
|
testId="championship-error"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (hubQuery.isError) {
|
if (hubQuery.isError) {
|
||||||
return (
|
return (
|
||||||
<div className="page error-box">
|
<div className="page">
|
||||||
{hubQuery.error instanceof Error ? hubQuery.error.message : 'Failed to load championship'}
|
<RouteState
|
||||||
|
kind="error"
|
||||||
|
title="Championship unavailable"
|
||||||
|
error={hubQuery.error}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!hubQuery.isFetching) void hubQuery.refetch()
|
||||||
|
}}
|
||||||
|
retrying={hubQuery.isFetching}
|
||||||
|
testId="championship-error"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { SourceBadge, weekendStatusLabel } from '../components/SourceBadge'
|
import { SourceBadge, weekendStatusLabel } from '../components/SourceBadge'
|
||||||
import { CliCommands, ingestYearCommands } from '../components/CliCommands'
|
import { CliCommands, ingestYearCommands } from '../components/CliCommands'
|
||||||
import { MeetingDetailPanel } from '../components/MeetingDetailPanel'
|
import { MeetingDetailPanel } from '../components/MeetingDetailPanel'
|
||||||
|
import { RouteState } from '../components/RouteState'
|
||||||
import type { Meeting, Weekend } from '../types'
|
import type { Meeting, Weekend } from '../types'
|
||||||
|
|
||||||
function formatMeetingDate(meeting: Meeting): string {
|
function formatMeetingDate(meeting: Meeting): string {
|
||||||
@@ -27,12 +28,12 @@ export function DataLibraryPage() {
|
|||||||
|
|
||||||
const seasonsQuery = useQuery({
|
const seasonsQuery = useQuery({
|
||||||
queryKey: ['seasons'],
|
queryKey: ['seasons'],
|
||||||
queryFn: fetchSeasons,
|
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||||
})
|
})
|
||||||
|
|
||||||
const meetingsQuery = useQuery({
|
const meetingsQuery = useQuery({
|
||||||
queryKey: ['meetings', selectedYear],
|
queryKey: ['meetings', selectedYear],
|
||||||
queryFn: () => fetchLocalMeetings(selectedYear!),
|
queryFn: ({ signal }) => fetchLocalMeetings(selectedYear!, signal),
|
||||||
enabled: selectedYear != null,
|
enabled: selectedYear != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ export function DataLibraryPage() {
|
|||||||
const weekendQueries = useQueries({
|
const weekendQueries = useQueries({
|
||||||
queries: meetings.map((meeting) => ({
|
queries: meetings.map((meeting) => ({
|
||||||
queryKey: ['weekend', meeting.meeting_key],
|
queryKey: ['weekend', meeting.meeting_key],
|
||||||
queryFn: () => fetchWeekend(meeting.meeting_key),
|
queryFn: ({ signal }: { signal: AbortSignal }) => fetchWeekend(meeting.meeting_key, signal),
|
||||||
enabled: meetings.length > 0,
|
enabled: meetings.length > 0,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})),
|
})),
|
||||||
@@ -78,13 +79,26 @@ export function DataLibraryPage() {
|
|||||||
const weekendsLoading = weekendQueries.some((q) => q.isLoading)
|
const weekendsLoading = weekendQueries.some((q) => q.isLoading)
|
||||||
|
|
||||||
if (seasonsQuery.isLoading) {
|
if (seasonsQuery.isLoading) {
|
||||||
return <div className="page loading-state">loading local data library…</div>
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<RouteState kind="loading" title="loading local data library…" testId="weekend-loading" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seasonsQuery.isError) {
|
if (seasonsQuery.isError) {
|
||||||
return (
|
return (
|
||||||
<div className="page error-box">
|
<div className="page">
|
||||||
{seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'}
|
<RouteState
|
||||||
|
kind="error"
|
||||||
|
title="Weekend data unavailable"
|
||||||
|
error={seasonsQuery.error}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!seasonsQuery.isFetching) void seasonsQuery.refetch()
|
||||||
|
}}
|
||||||
|
retrying={seasonsQuery.isFetching}
|
||||||
|
testId="weekend-error"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -222,15 +236,20 @@ export function DataLibraryPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{meetingsQuery.isLoading && (
|
{meetingsQuery.isLoading && (
|
||||||
<div className="loading-state">loading meetings…</div>
|
<RouteState kind="loading" title="loading meetings…" testId="weekend-meetings-loading" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{meetingsQuery.isError && (
|
{meetingsQuery.isError && (
|
||||||
<div className="error-box">
|
<RouteState
|
||||||
{meetingsQuery.error instanceof Error
|
kind="error"
|
||||||
? meetingsQuery.error.message
|
title="Weekend meetings unavailable"
|
||||||
: 'Failed to load meetings'}
|
error={meetingsQuery.error}
|
||||||
</div>
|
onRetry={() => {
|
||||||
|
if (!meetingsQuery.isFetching) void meetingsQuery.refetch()
|
||||||
|
}}
|
||||||
|
retrying={meetingsQuery.isFetching}
|
||||||
|
testId="weekend-error"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && (
|
{!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && (
|
||||||
@@ -316,11 +335,16 @@ export function DataLibraryPage() {
|
|||||||
|
|
||||||
<div className="dl-detail-wrap">
|
<div className="dl-detail-wrap">
|
||||||
{weekendsLoading && selectedWeekend == null && (
|
{weekendsLoading && selectedWeekend == null && (
|
||||||
<div className="loading-state">loading weekend details…</div>
|
<RouteState kind="loading" title="loading weekend details…" testId="weekend-detail-loading" />
|
||||||
)}
|
)}
|
||||||
{selectedWeekend && <MeetingDetailPanel weekend={selectedWeekend} />}
|
{selectedWeekend && <MeetingDetailPanel weekend={selectedWeekend} />}
|
||||||
{selectedMeetingKey != null && !weekendsLoading && selectedWeekend == null && (
|
{selectedMeetingKey != null && !weekendsLoading && selectedWeekend == null && (
|
||||||
<div className="missing-notice">Could not load weekend details.</div>
|
<RouteState
|
||||||
|
kind="empty"
|
||||||
|
title="Weekend details missing"
|
||||||
|
message="Could not load weekend details for this meeting."
|
||||||
|
testId="weekend-detail-empty"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import { fetchDriverSummary, fetchSeasons } from '../api'
|
import { fetchDriverSummary, fetchSeasons } from '../api'
|
||||||
|
import { DataNotice, RouteState } from '../components/RouteState'
|
||||||
import { teamColor } from '../utils'
|
import { teamColor } from '../utils'
|
||||||
import { countryFlag } from '../lib/gpIdentity'
|
import { countryFlag } from '../lib/gpIdentity'
|
||||||
import {
|
import {
|
||||||
@@ -26,7 +27,7 @@ export function DriverProfilePage({ driverNumber, year }: Props) {
|
|||||||
// seasons list is empty/unavailable the backend falls back to the current year.
|
// seasons list is empty/unavailable the backend falls back to the current year.
|
||||||
const seasonsQuery = useQuery({
|
const seasonsQuery = useQuery({
|
||||||
queryKey: ['seasons'],
|
queryKey: ['seasons'],
|
||||||
queryFn: fetchSeasons,
|
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||||
enabled: year == null,
|
enabled: year == null,
|
||||||
})
|
})
|
||||||
const resolvedYear = year ?? seasonsQuery.data?.[0]
|
const resolvedYear = year ?? seasonsQuery.data?.[0]
|
||||||
@@ -34,39 +35,86 @@ export function DriverProfilePage({ driverNumber, year }: Props) {
|
|||||||
|
|
||||||
const summaryQuery = useQuery({
|
const summaryQuery = useQuery({
|
||||||
queryKey: ['driver-summary', driverNumber, resolvedYear ?? 'latest'],
|
queryKey: ['driver-summary', driverNumber, resolvedYear ?? 'latest'],
|
||||||
queryFn: () => fetchDriverSummary(driverNumber, resolvedYear),
|
queryFn: ({ signal }) => fetchDriverSummary(driverNumber, resolvedYear, signal),
|
||||||
enabled: seasonsSettled && driverNumber > 0,
|
enabled: seasonsSettled && driverNumber > 0,
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (driverNumber <= 0) {
|
if (driverNumber <= 0) {
|
||||||
return <div className="page error-box">Invalid driver number</div>
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<RouteState kind="empty" title="Invalid driver number" message="Pick a driver from the championship standings." />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (!seasonsSettled || summaryQuery.isLoading) {
|
if (!seasonsSettled || summaryQuery.isLoading) {
|
||||||
return <div className="page loading-state">loading driver profile…</div>
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<RouteState kind="loading" title="loading driver profile…" testId="driver-profile-loading" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (summaryQuery.isError) {
|
if (summaryQuery.isError) {
|
||||||
return (
|
return (
|
||||||
<div className="page error-box">
|
<div className="page">
|
||||||
{summaryQuery.error instanceof Error
|
<RouteState
|
||||||
? summaryQuery.error.message
|
kind="error"
|
||||||
: 'Failed to load driver profile'}
|
title="Driver profile unavailable"
|
||||||
|
error={summaryQuery.error}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!summaryQuery.isFetching) void summaryQuery.refetch()
|
||||||
|
}}
|
||||||
|
retrying={summaryQuery.isFetching}
|
||||||
|
testId="driver-profile-error"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const summary = summaryQuery.data
|
const summary = summaryQuery.data
|
||||||
if (!summary) {
|
if (!summary) {
|
||||||
return <div className="page error-box">No driver data</div>
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<RouteState
|
||||||
|
kind="empty"
|
||||||
|
title="No driver data"
|
||||||
|
message="This driver is not in the local season standings yet."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return <DriverProfileBody summary={summary} />
|
return (
|
||||||
|
<DriverProfileBody
|
||||||
|
summary={summary}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!summaryQuery.isFetching) void summaryQuery.refetch()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DriverProfileBody({ summary }: { summary: DriverSummary }) {
|
function DriverProfileBody({
|
||||||
|
summary,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
summary: DriverSummary
|
||||||
|
onRetry?: () => void
|
||||||
|
}) {
|
||||||
const color = teamColor(summary.team_colour)
|
const color = teamColor(summary.team_colour)
|
||||||
const deltas = gridFinishDeltas(summary.rounds)
|
const deltas = gridFinishDeltas(summary.rounds)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dp-page" data-testid="driver-profile">
|
<div className="dp-page" data-testid="driver-profile">
|
||||||
|
{summary.enrichment === 'limited' && (
|
||||||
|
<DataNotice
|
||||||
|
availability="limited"
|
||||||
|
message="Optional remote details are unavailable. Showing local season identity and results."
|
||||||
|
onRetry={onRetry}
|
||||||
|
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 }}>
|
<header className="dp-header" data-testid="dp-header" style={{ borderLeftColor: color }}>
|
||||||
<div className="dp-identity">
|
<div className="dp-identity">
|
||||||
<div className="dp-title-row">
|
<div className="dp-title-row">
|
||||||
|
|||||||
@@ -23,22 +23,25 @@ function analysisKey(weekend: Weekend | undefined): number | undefined {
|
|||||||
export function ExplorePage() {
|
export function ExplorePage() {
|
||||||
const now = useMemo(() => new Date(), [])
|
const now = useMemo(() => new Date(), [])
|
||||||
|
|
||||||
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons })
|
const seasonsQuery = useQuery({
|
||||||
|
queryKey: ['seasons'],
|
||||||
|
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||||
|
})
|
||||||
const season = seasonsQuery.data?.[0] ?? null
|
const season = seasonsQuery.data?.[0] ?? null
|
||||||
|
|
||||||
const localMeetingsQuery = useQuery({
|
const localMeetingsQuery = useQuery({
|
||||||
queryKey: ['meetings', season, 'local'],
|
queryKey: ['meetings', season, 'local'],
|
||||||
queryFn: () => fetchLocalMeetings(season!),
|
queryFn: ({ signal }) => fetchLocalMeetings(season!, signal),
|
||||||
enabled: season != null,
|
enabled: season != null,
|
||||||
})
|
})
|
||||||
const seasonMeetingsQuery = useQuery({
|
const seasonMeetingsQuery = useQuery({
|
||||||
queryKey: ['season-meetings', season],
|
queryKey: ['season-meetings', season],
|
||||||
queryFn: () => fetchSeasonMeetings(season!),
|
queryFn: ({ signal }) => fetchSeasonMeetings(season!, signal),
|
||||||
enabled: season != null,
|
enabled: season != null,
|
||||||
})
|
})
|
||||||
const championshipQuery = useQuery({
|
const championshipQuery = useQuery({
|
||||||
queryKey: ['championship-hub', season],
|
queryKey: ['championship-hub', season],
|
||||||
queryFn: () => fetchChampionshipHub(season!),
|
queryFn: ({ signal }) => fetchChampionshipHub(season!, signal),
|
||||||
enabled: season != null,
|
enabled: season != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -48,7 +51,7 @@ export function ExplorePage() {
|
|||||||
const weekendQueries = useQueries({
|
const weekendQueries = useQueries({
|
||||||
queries: localMeetings.map((m) => ({
|
queries: localMeetings.map((m) => ({
|
||||||
queryKey: ['weekend', m.meeting_key],
|
queryKey: ['weekend', m.meeting_key],
|
||||||
queryFn: () => fetchWeekend(m.meeting_key),
|
queryFn: ({ signal }: { signal: AbortSignal }) => fetchWeekend(m.meeting_key, signal),
|
||||||
enabled: localMeetings.length > 0,
|
enabled: localMeetings.length > 0,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { fetchLiveState } from '../api'
|
import { fetchLiveState, fetchWeekendContext } from '../api'
|
||||||
import type { LivePosition, LiveStreamData } from '../types'
|
import type { LivePosition, LiveStreamData } from '../types'
|
||||||
import {
|
import {
|
||||||
loadPinnedDrivers,
|
loadPinnedDrivers,
|
||||||
@@ -17,6 +17,15 @@ import { recordGapSamples } from '../lib/gapHistory'
|
|||||||
import { battleNumbers, detectBattles } from '../lib/battles'
|
import { battleNumbers, detectBattles } from '../lib/battles'
|
||||||
import type { LiveEvent } from '../lib/events'
|
import type { LiveEvent } from '../lib/events'
|
||||||
import { appendEvents, diffSnapshots, sessionSignature } from '../lib/events'
|
import { appendEvents, diffSnapshots, sessionSignature } from '../lib/events'
|
||||||
|
import {
|
||||||
|
allowsLiveInterpretations,
|
||||||
|
deriveLivePhase,
|
||||||
|
effectiveFeedHealth,
|
||||||
|
rendersSnapshot,
|
||||||
|
terminalSessionStatus,
|
||||||
|
} from '../lib/liveState'
|
||||||
|
import type { TransportHealth } from '../lib/liveState'
|
||||||
|
import { shouldPollHandoffAnalysis } from '../components/live/LiveHandoff'
|
||||||
import { SessionBanner } from '../components/live/SessionBanner'
|
import { SessionBanner } from '../components/live/SessionBanner'
|
||||||
import { TrackStatusBanner } from '../components/live/TrackStatusBanner'
|
import { TrackStatusBanner } from '../components/live/TrackStatusBanner'
|
||||||
import { TimingTower } from '../components/live/TimingTower'
|
import { TimingTower } from '../components/live/TimingTower'
|
||||||
@@ -26,9 +35,13 @@ import { RaceControlFeed } from '../components/live/RaceControlFeed'
|
|||||||
import { EventRail } from '../components/live/EventRail'
|
import { EventRail } from '../components/live/EventRail'
|
||||||
import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
|
import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
|
||||||
import { TyreDegPanel } from '../components/live/TyreDegPanel'
|
import { TyreDegPanel } from '../components/live/TyreDegPanel'
|
||||||
import { Archive, Radio } from 'lucide-react'
|
import { LiveHandoff } from '../components/live/LiveHandoff'
|
||||||
|
import { RouteState } from '../components/RouteState'
|
||||||
|
import '../styles/live-state.css'
|
||||||
|
|
||||||
type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
|
/** How often to re-check weekend-context while analysis is still ingesting. */
|
||||||
|
export const WEEKEND_CONTEXT_POLL_MS = 15_000
|
||||||
|
const WEEKEND_CONTEXT_STALE_MS = 15_000
|
||||||
|
|
||||||
export function LiveTimingPage() {
|
export function LiveTimingPage() {
|
||||||
const [activeSnapshot, setActiveSnapshot] = useState<LiveStreamData | null>(null)
|
const [activeSnapshot, setActiveSnapshot] = useState<LiveStreamData | null>(null)
|
||||||
@@ -37,31 +50,87 @@ export function LiveTimingPage() {
|
|||||||
const [archiveSnapshotAt, setArchiveSnapshotAt] = useState<string | null>(null)
|
const [archiveSnapshotAt, setArchiveSnapshotAt] = useState<string | null>(null)
|
||||||
const [archiveMode, setArchiveMode] = useState(false)
|
const [archiveMode, setArchiveMode] = useState(false)
|
||||||
const [isLive, setIsLive] = useState(false)
|
const [isLive, setIsLive] = useState(false)
|
||||||
const [streamStatus, setStreamStatus] = useState<StreamStatus>('connecting')
|
const [streamStatus, setStreamStatus] = useState<TransportHealth>('connecting')
|
||||||
const [now, setNow] = useState(Date.now())
|
const [now, setNow] = useState(Date.now())
|
||||||
const [gapHistory, setGapHistory] = useState<GapHistoryMap>({})
|
const [gapHistory, setGapHistory] = useState<GapHistoryMap>({})
|
||||||
const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers())
|
const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers())
|
||||||
const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({})
|
const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({})
|
||||||
const [positions, setPositions] = useState<Record<string, LivePosition>>({})
|
const [, setPositions] = useState<Record<string, LivePosition>>({})
|
||||||
const [events, setEvents] = useState<LiveEvent[]>([])
|
const [events, setEvents] = useState<LiveEvent[]>([])
|
||||||
const prevSnapshotRef = useRef<LiveStreamData | null>(null)
|
const prevSnapshotRef = useRef<LiveStreamData | null>(null)
|
||||||
const sessionSigRef = useRef('')
|
const sessionSigRef = useRef('')
|
||||||
const isLiveRef = useRef(false)
|
const isLiveRef = useRef(false)
|
||||||
const archiveModeRef = useRef(false)
|
const archiveModeRef = useRef(false)
|
||||||
const snapshot = isLive ? activeSnapshot : archiveMode ? archiveSnapshot : null
|
// Whether we ever observed this session as live — used to reason about a
|
||||||
|
// dropped feed vs. a session that was never live in this page session.
|
||||||
|
const wasLiveRef = useRef(false)
|
||||||
const hasArchive = Boolean(archiveSnapshot)
|
const hasArchive = Boolean(archiveSnapshot)
|
||||||
|
|
||||||
const { data, isLoading, isError, error } = useQuery({
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
isFetching,
|
||||||
|
isFetched: liveStateFetched,
|
||||||
|
} = useQuery({
|
||||||
queryKey: ['live-state'],
|
queryKey: ['live-state'],
|
||||||
queryFn: fetchLiveState,
|
queryFn: ({ signal }) => fetchLiveState(signal),
|
||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A retained snapshot is only "settled" (session genuinely finished) if it
|
||||||
|
// carries a terminal FIA SessionStatus. Otherwise the upstream feed dropped
|
||||||
|
// while the session was still active and we must stay in `disconnected`.
|
||||||
|
const sessionEndedCleanly = terminalSessionStatus(archiveSnapshot?.SessionStatus)
|
||||||
|
|
||||||
|
// Transport health, active-session state, and archive availability are
|
||||||
|
// orthogonal inputs; deriveLivePhase collapses them into one UI phase.
|
||||||
|
const phase = deriveLivePhase({
|
||||||
|
transport: streamStatus,
|
||||||
|
isLive,
|
||||||
|
hasActiveSnapshot: Boolean(activeSnapshot),
|
||||||
|
hasArchive,
|
||||||
|
archiveMode,
|
||||||
|
sessionEndedCleanly,
|
||||||
|
wasLive: wasLiveRef.current,
|
||||||
|
stateLoaded: liveStateFetched,
|
||||||
|
})
|
||||||
|
const snapshot =
|
||||||
|
phase === 'archive'
|
||||||
|
? archiveSnapshot
|
||||||
|
: isLive
|
||||||
|
? activeSnapshot
|
||||||
|
: phase === 'disconnected'
|
||||||
|
? archiveSnapshot
|
||||||
|
: null
|
||||||
|
|
||||||
|
// Canonical weekend context (issue #72) — the single source of truth for
|
||||||
|
// previous/next/default-analysis identity and temporal state. Only needed
|
||||||
|
// when no session is streaming; keep it idle during a live session. Poll
|
||||||
|
// while a completed session is still ingesting so `settling` can flip to
|
||||||
|
// analysis-ready without a manual refresh.
|
||||||
|
const contextQuery = useQuery({
|
||||||
|
queryKey: ['weekend-context'],
|
||||||
|
queryFn: ({ signal }) => fetchWeekendContext(signal),
|
||||||
|
enabled: !isLive,
|
||||||
|
staleTime: WEEKEND_CONTEXT_STALE_MS,
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
// Poll until the just-finished previous_completed_session is ready — an
|
||||||
|
// older already-complete default_analysis_session must not stop us.
|
||||||
|
return shouldPollHandoffAnalysis(query.state.data) ? WEEKEND_CONTEXT_POLL_MS : false
|
||||||
|
},
|
||||||
|
refetchIntervalInBackground: false,
|
||||||
|
})
|
||||||
|
const weekendContext = contextQuery.data
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data) return
|
if (!data) return
|
||||||
const nextLive = data.is_live && Boolean(data.data)
|
const nextLive = data.is_live && Boolean(data.data)
|
||||||
setIsLive(nextLive)
|
setIsLive(nextLive)
|
||||||
isLiveRef.current = nextLive
|
isLiveRef.current = nextLive
|
||||||
|
if (nextLive) wasLiveRef.current = true
|
||||||
if (nextLive && data.data) {
|
if (nextLive && data.data) {
|
||||||
setActiveSnapshot(data.data)
|
setActiveSnapshot(data.data)
|
||||||
setArchiveMode(false)
|
setArchiveMode(false)
|
||||||
@@ -111,6 +180,7 @@ export function LiveTimingPage() {
|
|||||||
setPositions({})
|
setPositions({})
|
||||||
}
|
}
|
||||||
isLiveRef.current = true
|
isLiveRef.current = true
|
||||||
|
wasLiveRef.current = true
|
||||||
setActiveSnapshot(state.data)
|
setActiveSnapshot(state.data)
|
||||||
setArchiveMode(false)
|
setArchiveMode(false)
|
||||||
} else {
|
} else {
|
||||||
@@ -167,6 +237,8 @@ export function LiveTimingPage() {
|
|||||||
}, [snapshot])
|
}, [snapshot])
|
||||||
|
|
||||||
const rawRows = useMemo(() => sortLiveTimingRows(snapshot), [snapshot])
|
const rawRows = useMemo(() => sortLiveTimingRows(snapshot), [snapshot])
|
||||||
|
// Final-snapshot rows for the settling handoff (independent of live rows).
|
||||||
|
const settlingRows = useMemo(() => sortLiveTimingRows(archiveSnapshot), [archiveSnapshot])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rawRows.length === 0) {
|
if (rawRows.length === 0) {
|
||||||
@@ -210,68 +282,86 @@ export function LiveTimingPage() {
|
|||||||
setPositions(archivePositions)
|
setPositions(archivePositions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleExitArchive = () => {
|
||||||
|
setArchiveMode(false)
|
||||||
|
}
|
||||||
|
|
||||||
const archiveTimestamp = archiveSnapshotAt ? new Date(archiveSnapshotAt) : null
|
const archiveTimestamp = archiveSnapshotAt ? new Date(archiveSnapshotAt) : null
|
||||||
const archiveLabel =
|
const archiveLabel =
|
||||||
archiveTimestamp && !Number.isNaN(archiveTimestamp.getTime())
|
archiveTimestamp && !Number.isNaN(archiveTimestamp.getTime())
|
||||||
? `Archived snapshot from ${archiveTimestamp.toLocaleString()}`
|
? `Archived snapshot from ${archiveTimestamp.toLocaleString()}`
|
||||||
: 'Archived live timing snapshot'
|
: 'Archived live timing snapshot'
|
||||||
|
|
||||||
|
const showLiveInterpretations = allowsLiveInterpretations(phase)
|
||||||
|
// Upstream FIA loss can leave the browser SSE open; present one coherent
|
||||||
|
// feed-health truth rather than "Connection lost" + "Feed healthy".
|
||||||
|
const feedHealth = effectiveFeedHealth(streamStatus, phase)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page live-page" data-testid="live-page">
|
<div className="page live-page" data-testid="live-page" data-phase={phase}>
|
||||||
{isError && (
|
{isError && (
|
||||||
<div className="error-box">
|
<RouteState
|
||||||
{error instanceof Error ? error.message : 'Failed to load live timing state'}
|
kind="error"
|
||||||
|
title="Live timing unavailable"
|
||||||
|
error={error}
|
||||||
|
onRetry={() => {
|
||||||
|
if (!isFetching) void refetch()
|
||||||
|
}}
|
||||||
|
retrying={isFetching}
|
||||||
|
testId="live-initial-error"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'disconnected' && (
|
||||||
|
<div className="live-status-strip live-status-warn" data-testid="live-disconnected-strip">
|
||||||
|
Connection lost — showing the last live data while we reconnect. This is not an archive.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{streamStatus === 'disconnected' && snapshot && !archiveMode && (
|
{phase === 'archive' && (
|
||||||
<div className="live-status-strip live-status-warn">
|
|
||||||
Stream disconnected — showing last received snapshot
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{archiveMode && snapshot && (
|
|
||||||
<div className="live-status-strip live-status-archive" data-testid="live-archive-strip">
|
<div className="live-status-strip live-status-archive" data-testid="live-archive-strip">
|
||||||
{archiveLabel} — live updates are paused for this archive view
|
<span>{archiveLabel} — read-only, live updates are paused</span>
|
||||||
</div>
|
<button type="button" className="live-archive-exit" onClick={handleExitArchive}>
|
||||||
)}
|
Exit archive
|
||||||
|
|
||||||
{isLoading && !snapshot && (
|
|
||||||
<div className="loading-state">connecting to live timing…</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoading && !snapshot && (
|
|
||||||
<div className="empty-state ui-card glass-panel" style={{ padding: '40px', textAlign: 'center', marginTop: '20vh', maxWidth: '400px', marginLeft: 'auto', marginRight: 'auto' }} data-testid="live-empty">
|
|
||||||
<div className="live-empty-status" style={{ marginBottom: '16px' }}>
|
|
||||||
<span className={`live-conn live-conn-${streamStatus}`}>{streamStatus}</span>
|
|
||||||
</div>
|
|
||||||
<Radio size={48} style={{ color: 'var(--text-3)', margin: '0 auto 16px auto', display: 'block' }} />
|
|
||||||
<h2 className="empty-state-title" style={{ fontSize: '20px', marginBottom: '8px' }}>No live session active</h2>
|
|
||||||
<p className="empty-state-desc" style={{ color: 'var(--text-2)' }}>
|
|
||||||
The telemetry feed is currently offline. <br /><br /> Check the <a href="/" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Command Center</a> for the weekend schedule or explore historical data in the <a href="/race-hub" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Race Hub</a>.
|
|
||||||
</p>
|
|
||||||
{hasArchive && (
|
|
||||||
<button type="button" className="live-archive-btn" onClick={handleViewArchive}>
|
|
||||||
<Archive size={15} />
|
|
||||||
View Last Session
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{snapshot && (
|
{phase === 'connecting' && (isLoading || !liveStateFetched) && (
|
||||||
|
<RouteState
|
||||||
|
kind="loading"
|
||||||
|
title="connecting to live timing…"
|
||||||
|
testId="live-initial-loading"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(phase === 'settling' || phase === 'inactive') && (
|
||||||
|
<LiveHandoff
|
||||||
|
phase={phase}
|
||||||
|
transport={feedHealth}
|
||||||
|
context={weekendContext}
|
||||||
|
rows={settlingRows}
|
||||||
|
capturedAt={archiveSnapshotAt}
|
||||||
|
hasArchive={hasArchive}
|
||||||
|
onViewArchive={handleViewArchive}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{snapshot && rendersSnapshot(phase) && (
|
||||||
<>
|
<>
|
||||||
<SessionBanner
|
<SessionBanner
|
||||||
isLive={isLive}
|
phase={phase}
|
||||||
isArchive={archiveMode}
|
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
connection={streamStatus}
|
transport={feedHealth}
|
||||||
now={now}
|
now={now}
|
||||||
|
capturedAt={phase === 'archive' ? archiveSnapshotAt : null}
|
||||||
/>
|
/>
|
||||||
<TrackStatusBanner status={snapshot.TrackStatus} />
|
<TrackStatusBanner status={snapshot.TrackStatus} />
|
||||||
<PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} />
|
<PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} />
|
||||||
|
{showLiveInterpretations && (
|
||||||
<TyreDegPanel rows={rows} sessionType={snapshot.Session?.SessionType} pinned={pinned} />
|
<TyreDegPanel rows={rows} sessionType={snapshot.Session?.SessionType} pinned={pinned} />
|
||||||
|
)}
|
||||||
<div className="live-columns">
|
<div className="live-columns">
|
||||||
<div className="live-tower-col">
|
<div className="live-tower-col">
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from '../components/PreSessionView'
|
} from '../components/PreSessionView'
|
||||||
import { WeekendSwitcher } from '../components/WeekendSwitcher'
|
import { WeekendSwitcher } from '../components/WeekendSwitcher'
|
||||||
import { SourceBadge } from '../components/SourceBadge'
|
import { SourceBadge } from '../components/SourceBadge'
|
||||||
|
import { RouteState } from '../components/RouteState'
|
||||||
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
||||||
import { sessionTypeAbbrev } from '../lib/coverage'
|
import { sessionTypeAbbrev } from '../lib/coverage'
|
||||||
import { formatSessionScheduleTime, sortSessionsByStart } from '../lib/schedule'
|
import { formatSessionScheduleTime, sortSessionsByStart } from '../lib/schedule'
|
||||||
@@ -55,7 +56,7 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
|
|
||||||
const contextQuery = useQuery({
|
const contextQuery = useQuery({
|
||||||
queryKey: ['weekend-context'],
|
queryKey: ['weekend-context'],
|
||||||
queryFn: fetchWeekendContext,
|
queryFn: ({ signal }) => fetchWeekendContext(signal),
|
||||||
staleTime: 15_000,
|
staleTime: 15_000,
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
})
|
})
|
||||||
@@ -73,7 +74,7 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
|
|
||||||
const raceHubQuery = useQuery({
|
const raceHubQuery = useQuery({
|
||||||
queryKey: ['race-hub', sessionKey],
|
queryKey: ['race-hub', sessionKey],
|
||||||
queryFn: () => fetchRaceHub(sessionKey),
|
queryFn: ({ signal }) => fetchRaceHub(sessionKey, signal),
|
||||||
enabled: sessionKey > 0,
|
enabled: sessionKey > 0,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
})
|
})
|
||||||
@@ -81,7 +82,7 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
const meetingKey = raceHubQuery.data?.meeting?.meeting_key
|
const meetingKey = raceHubQuery.data?.meeting?.meeting_key
|
||||||
const weekendQuery = useQuery({
|
const weekendQuery = useQuery({
|
||||||
queryKey: ['weekend', meetingKey],
|
queryKey: ['weekend', meetingKey],
|
||||||
queryFn: () => fetchWeekend(meetingKey!),
|
queryFn: ({ signal }) => fetchWeekend(meetingKey!, signal),
|
||||||
enabled: meetingKey != null && meetingKey > 0,
|
enabled: meetingKey != null && meetingKey > 0,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
@@ -102,33 +103,33 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
if (contextQuery.isLoading) {
|
if (contextQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="rh-page" style={accentStyle}>
|
<div className="rh-page" style={accentStyle}>
|
||||||
<div className="loading-state">resolving weekend context…</div>
|
<RouteState
|
||||||
|
kind="loading"
|
||||||
|
title="resolving weekend context…"
|
||||||
|
testId="race-hub-loading"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (contextQuery.isError) {
|
if (contextQuery.isError) {
|
||||||
return (
|
return (
|
||||||
<div className="rh-page" data-testid="race-hub-error" style={accentStyle}>
|
<div className="rh-page" data-testid="race-hub-error" style={accentStyle}>
|
||||||
<div className="rh-recover">
|
<RouteState
|
||||||
<div className="error-box">
|
kind="error"
|
||||||
{contextQuery.error instanceof Error
|
title="Weekend unavailable"
|
||||||
? contextQuery.error.message
|
error={contextQuery.error}
|
||||||
: 'Failed to load weekend context.'}
|
onRetry={() => {
|
||||||
</div>
|
if (!contextQuery.isFetching) void contextQuery.refetch()
|
||||||
<div className="rh-recover-actions">
|
}}
|
||||||
<button
|
retrying={contextQuery.isFetching}
|
||||||
type="button"
|
retryTestId="rh-retry"
|
||||||
className="rh-recover-btn primary"
|
|
||||||
onClick={() => contextQuery.refetch()}
|
|
||||||
data-testid="rh-retry"
|
|
||||||
>
|
>
|
||||||
Retry
|
<div className="rh-recover-actions">
|
||||||
</button>
|
|
||||||
<Link to="/" search={{}} className="rh-recover-btn" data-testid="rh-back-weekend">
|
<Link to="/" search={{}} className="rh-recover-btn" data-testid="rh-back-weekend">
|
||||||
Back to Weekend
|
Back to Weekend
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</RouteState>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -170,7 +171,11 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rh-page" style={accentStyle}>
|
<div className="rh-page" style={accentStyle}>
|
||||||
<div className="loading-state">resolving weekend context…</div>
|
<RouteState
|
||||||
|
kind="loading"
|
||||||
|
title="resolving weekend context…"
|
||||||
|
testId="race-hub-loading"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -179,7 +184,11 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
if (raceHubQuery.isLoading) {
|
if (raceHubQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="rh-page" style={accentStyle}>
|
<div className="rh-page" style={accentStyle}>
|
||||||
<div className="loading-state">loading session {sessionKey}…</div>
|
<RouteState
|
||||||
|
kind="loading"
|
||||||
|
title={`loading session ${sessionKey}…`}
|
||||||
|
testId="race-hub-loading"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -191,21 +200,22 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rh-page" data-testid="race-hub-error" style={accentStyle}>
|
<div className="rh-page" data-testid="race-hub-error" style={accentStyle}>
|
||||||
<div className="rh-recover">
|
<RouteState
|
||||||
<div className="error-box">
|
kind="error"
|
||||||
{raceHubQuery.error instanceof Error
|
title="Session unavailable"
|
||||||
? raceHubQuery.error.message
|
error={raceHubQuery.error}
|
||||||
: `Failed to load session ${sessionKey}.`}
|
message={
|
||||||
</div>
|
raceHubQuery.error
|
||||||
<div className="rh-recover-actions">
|
? undefined
|
||||||
<button
|
: `Session ${sessionKey} could not be loaded from local data.`
|
||||||
type="button"
|
}
|
||||||
className="rh-recover-btn primary"
|
onRetry={() => {
|
||||||
onClick={() => raceHubQuery.refetch()}
|
if (!raceHubQuery.isFetching) void raceHubQuery.refetch()
|
||||||
data-testid="rh-retry"
|
}}
|
||||||
|
retrying={raceHubQuery.isFetching}
|
||||||
|
retryTestId="rh-retry"
|
||||||
>
|
>
|
||||||
Retry
|
<div className="rh-recover-actions">
|
||||||
</button>
|
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
search={backSearch}
|
search={backSearch}
|
||||||
@@ -217,7 +227,7 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
Back to Weekend
|
Back to Weekend
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</RouteState>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ export function RacePreviewPage() {
|
|||||||
|
|
||||||
const seasonsQuery = useQuery({
|
const seasonsQuery = useQuery({
|
||||||
queryKey: ['seasons'],
|
queryKey: ['seasons'],
|
||||||
queryFn: fetchSeasons,
|
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||||
})
|
})
|
||||||
|
|
||||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ export function WeekendPage({
|
|||||||
/** Restored from `/?session_key=` when returning from Race Hub analysis. */
|
/** Restored from `/?session_key=` when returning from Race Hub analysis. */
|
||||||
focusSessionKey?: number
|
focusSessionKey?: number
|
||||||
}) {
|
}) {
|
||||||
const { context, loadState, error, championship, briefing, now } = useWeekendContext()
|
const { context, loadState, error, championship, briefing, now, refetch, isFetching } =
|
||||||
|
useWeekendContext()
|
||||||
const hasFocus =
|
const hasFocus =
|
||||||
(focusMeetingKey != null && focusMeetingKey > 0) ||
|
(focusMeetingKey != null && focusMeetingKey > 0) ||
|
||||||
(focusSessionKey != null && focusSessionKey > 0)
|
(focusSessionKey != null && focusSessionKey > 0)
|
||||||
@@ -87,7 +88,7 @@ export function WeekendPage({
|
|||||||
if (loadState === 'error' || context == null) {
|
if (loadState === 'error' || context == null) {
|
||||||
return (
|
return (
|
||||||
<main className="wk-page" data-testid="weekend-page" data-state="error">
|
<main className="wk-page" data-testid="weekend-page" data-state="error">
|
||||||
<WeekendError message={error?.message} />
|
<WeekendError error={error} onRetry={refetch} retrying={isFetching} />
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -410,6 +410,9 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.badge-partial { background: rgba(255,214,0,.12); color: var(--yellow); border: 1px solid rgba(255,214,0,.25); }
|
.badge-partial { background: rgba(255,214,0,.12); color: var(--yellow); border: 1px solid rgba(255,214,0,.25); }
|
||||||
.badge-cancelled { background: rgba(255,107,53,.12); color: #ff8a5c; border: 1px solid rgba(255,107,53,.28); }
|
.badge-cancelled { background: rgba(255,107,53,.12); color: #ff8a5c; border: 1px solid rgba(255,107,53,.28); }
|
||||||
.badge-none { background: rgba(80,80,80,.12); color: var(--text-3); border: 1px solid var(--border); }
|
.badge-none { background: rgba(80,80,80,.12); color: var(--text-3); border: 1px solid var(--border); }
|
||||||
|
.badge-stale { background: rgba(255,214,0,.10); color: var(--yellow); border: 1px solid rgba(255,214,0,.22); }
|
||||||
|
.badge-limited { background: rgba(120,160,220,.12); color: #9bb7e0; border: 1px solid rgba(120,160,220,.28); }
|
||||||
|
.badge-missing { background: rgba(80,80,80,.12); color: var(--text-3); border: 1px solid var(--border); }
|
||||||
|
|
||||||
/* ── Dataset strip ── */
|
/* ── Dataset strip ── */
|
||||||
.dataset-strip {
|
.dataset-strip {
|
||||||
@@ -585,6 +588,82 @@ a { color: inherit; text-decoration: none; }
|
|||||||
margin-bottom: var(--s5);
|
margin-bottom: var(--s5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.route-state {
|
||||||
|
padding: var(--s7) var(--s5);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-state-availability {
|
||||||
|
margin-bottom: var(--s4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-state-title {
|
||||||
|
font-family: var(--f-display, var(--f-mono));
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-state-message {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-2);
|
||||||
|
margin-bottom: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-state-retry {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-state-retry:hover:not(:disabled) {
|
||||||
|
border-color: rgba(225, 6, 0, 0.45);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-state-retry:focus-visible {
|
||||||
|
outline: 2px solid var(--red);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-state-retry:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-notice {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
padding: var(--s3) var(--s4);
|
||||||
|
margin-bottom: var(--s4);
|
||||||
|
background: rgba(255, 214, 0, 0.06);
|
||||||
|
border: 1px solid rgba(255, 214, 0, 0.2);
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-notice-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-notice-retry {
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Live timing ── */
|
/* ── Live timing ── */
|
||||||
.live-page { max-width: 1320px; }
|
.live-page { max-width: 1320px; }
|
||||||
|
|
||||||
|
|||||||
414
frontend/src/styles/live-state.css
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
/* ── Live → settling → analysis handoff (issue #74) ──
|
||||||
|
*
|
||||||
|
* State-transition presentation for Live: session/flag chrome that replaces
|
||||||
|
* the old connection-as-session conflation, the connection-loss warning, the
|
||||||
|
* archive read-only strip, and the settling/inactive handoff surface.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Session flag: primary session state. Feed health is secondary. */
|
||||||
|
.live-session-flag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding: 5px 9px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-session-flag-live {
|
||||||
|
background: rgba(225, 6, 0, 0.14);
|
||||||
|
border: 1px solid rgba(225, 6, 0, 0.35);
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-session-flag-live.is-stale {
|
||||||
|
background: rgba(255, 214, 0, 0.1);
|
||||||
|
border-color: rgba(255, 214, 0, 0.35);
|
||||||
|
color: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-session-flag-archive {
|
||||||
|
background: rgba(70, 140, 255, 0.12);
|
||||||
|
border: 1px solid rgba(70, 140, 255, 0.35);
|
||||||
|
color: #8bb7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-session-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
box-shadow: 0 0 0 0 currentColor;
|
||||||
|
animation: live-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-session-flag-live.is-stale .live-session-dot {
|
||||||
|
animation: none;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-feed-health {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding: 4px 7px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-feed-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-feed-connected {
|
||||||
|
color: var(--green);
|
||||||
|
border-color: rgba(57, 199, 58, 0.25);
|
||||||
|
background: rgba(57, 199, 58, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-feed-connecting {
|
||||||
|
color: var(--yellow);
|
||||||
|
border-color: rgba(255, 214, 0, 0.25);
|
||||||
|
background: rgba(255, 214, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-feed-disconnected,
|
||||||
|
.live-feed-error {
|
||||||
|
color: #ff6b6b;
|
||||||
|
border-color: rgba(225, 6, 0, 0.24);
|
||||||
|
background: rgba(225, 6, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-banner-archive {
|
||||||
|
border-color: rgba(70, 140, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-archive-stamp {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-archive-readonly {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #8bb7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-archive-captured {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-state-archive {
|
||||||
|
background: rgba(70, 140, 255, 0.12);
|
||||||
|
color: #8bb7ff;
|
||||||
|
border: 1px solid rgba(70, 140, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disconnected warning: a live session whose feed dropped. Distinct from the
|
||||||
|
* blue archive strip so a dropped feed is never mistaken for a read-only
|
||||||
|
* archive. The retained tower stays visible beneath it. */
|
||||||
|
.live-status-strip.live-status-warn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-status-strip.live-status-warn::before {
|
||||||
|
content: '';
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--yellow);
|
||||||
|
animation: live-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes live-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-status-strip.live-status-archive {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s4);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-archive-exit {
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 var(--s4);
|
||||||
|
border: 1px solid rgba(70, 140, 255, 0.4);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(70, 140, 255, 0.12);
|
||||||
|
color: #8bb7ff;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-archive-exit:hover {
|
||||||
|
border-color: rgba(70, 140, 255, 0.7);
|
||||||
|
background: rgba(70, 140, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Handoff surface (settling + inactive) ── */
|
||||||
|
.live-handoff {
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: var(--s6) var(--s5);
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 120% at 0% 0%, rgba(255, 255, 255, 0.04), transparent 55%),
|
||||||
|
var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-settling {
|
||||||
|
border-left: 3px solid var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-inactive {
|
||||||
|
border-left: 3px solid var(--border-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s3);
|
||||||
|
margin-bottom: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 4px var(--s3);
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-settling .live-handoff-eyebrow {
|
||||||
|
color: var(--yellow);
|
||||||
|
background: rgba(245, 158, 11, 0.14);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-inactive .live-handoff-eyebrow {
|
||||||
|
color: var(--text-2);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-title {
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.15;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-captured {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-sub {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-2);
|
||||||
|
max-width: 62ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-feed {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Provisional final-order snapshot chips (settling only). */
|
||||||
|
.live-handoff-snapshot {
|
||||||
|
margin-bottom: var(--s5);
|
||||||
|
padding: var(--s4);
|
||||||
|
border: 1px dashed var(--border-2);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-snapshot-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--s3);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-order {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s3);
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-order li {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-order li span:first-child {
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-code {
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Action cards ── */
|
||||||
|
.live-handoff-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s4);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--s4) var(--s5);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: var(--text);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
background 0.15s ease,
|
||||||
|
transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.live-handoff-action {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-action:hover {
|
||||||
|
border-color: var(--border-glow);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-action-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-action-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-action-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The primary "open analysis" action is the strongest affordance. */
|
||||||
|
.live-handoff-primary {
|
||||||
|
border-color: rgba(230, 36, 41, 0.4);
|
||||||
|
background: linear-gradient(90deg, rgba(230, 36, 41, 0.14), transparent 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-primary:hover {
|
||||||
|
border-color: rgba(230, 36, 41, 0.7);
|
||||||
|
background: linear-gradient(90deg, rgba(230, 36, 41, 0.22), transparent 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pending-analysis states read as "waiting", not actionable links. */
|
||||||
|
.live-handoff-primary[data-ready='false'] {
|
||||||
|
border-color: rgba(245, 158, 11, 0.4);
|
||||||
|
background: linear-gradient(90deg, rgba(245, 158, 11, 0.1), transparent 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-pending {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-pending:hover {
|
||||||
|
transform: none;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-color: var(--border-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-next {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-next:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive: keep the handoff usable at tablet / mobile widths ── */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.live-handoff {
|
||||||
|
padding: var(--s5) var(--s4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-title {
|
||||||
|
font-size: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-action {
|
||||||
|
padding: var(--s4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-action-label {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-status-strip.live-status-archive {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,6 +89,12 @@
|
|||||||
}
|
}
|
||||||
.wk-cta-primary { background: var(--red); color: #fff; }
|
.wk-cta-primary { background: var(--red); color: #fff; }
|
||||||
.wk-cta-primary:hover { background: #ff3b3f; }
|
.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 {
|
.wk-cta-ghost {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ describe('DataLibraryPage', () => {
|
|||||||
|
|
||||||
expect(await screen.findByTestId('data-library')).toBeInTheDocument()
|
expect(await screen.findByTestId('data-library')).toBeInTheDocument()
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025)
|
expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025, expect.any(AbortSignal))
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(await screen.findByTestId('meeting-detail')).toBeInTheDocument()
|
expect(await screen.findByTestId('meeting-detail')).toBeInTheDocument()
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ describe('DriverProfilePage', () => {
|
|||||||
|
|
||||||
// With an explicit year, the seasons list is not needed.
|
// With an explicit year, the seasons list is not needed.
|
||||||
expect(mockFetchSeasons).not.toHaveBeenCalled()
|
expect(mockFetchSeasons).not.toHaveBeenCalled()
|
||||||
expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025)
|
expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025, expect.any(AbortSignal))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('defaults to the latest season when no year is given', async () => {
|
it('defaults to the latest season when no year is given', async () => {
|
||||||
@@ -129,16 +129,19 @@ describe('DriverProfilePage', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('driver-profile')).toBeInTheDocument()
|
expect(screen.getByTestId('driver-profile')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025)
|
expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025, expect.any(AbortSignal))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows the API error message on failure', async () => {
|
it('shows a user-facing error with retry instead of raw API jargon', async () => {
|
||||||
mockFetchSummary.mockRejectedValue(new Error('API 404: Not Found'))
|
mockFetchSummary.mockRejectedValue(new Error('API 404: Not Found'))
|
||||||
renderPage({ driverNumber: 99, year: 2025 })
|
renderPage({ driverNumber: 99, year: 2025 })
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('API 404: Not Found')).toBeInTheDocument()
|
expect(screen.getByTestId('driver-profile-error')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
expect(screen.queryByText(/API 404/i)).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('driver-profile-error')).toHaveTextContent(/Something went wrong/i)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows empty states when no rounds are completed', async () => {
|
it('shows empty states when no rounds are completed', async () => {
|
||||||
|
|||||||
289
frontend/src/test/LiveHandoff.test.tsx
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
LiveHandoff,
|
||||||
|
analysisIsReady,
|
||||||
|
handoffAnalysisSession,
|
||||||
|
shouldPollHandoffAnalysis,
|
||||||
|
} from '../components/live/LiveHandoff'
|
||||||
|
import type { LiveTimingRow } from '../lib/live'
|
||||||
|
import type { ContextSession, WeekendContext } from '../types'
|
||||||
|
|
||||||
|
function session(key: number, name: string, type = name): ContextSession['session'] {
|
||||||
|
return {
|
||||||
|
session_key: key,
|
||||||
|
session_name: name,
|
||||||
|
session_type: type,
|
||||||
|
meeting_key: 1,
|
||||||
|
date_start: '2026-07-05T14:00:00Z',
|
||||||
|
date_end: '2026-07-05T16:00:00Z',
|
||||||
|
gmt_offset: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextSession(
|
||||||
|
key: number,
|
||||||
|
name: string,
|
||||||
|
localAnalysis: string,
|
||||||
|
): ContextSession {
|
||||||
|
return {
|
||||||
|
session: session(key, name),
|
||||||
|
availability: {
|
||||||
|
schedule: 'available',
|
||||||
|
live_transport: 'unknown',
|
||||||
|
live_session: 'inactive',
|
||||||
|
archive: 'unavailable',
|
||||||
|
local_analysis: localAnalysis,
|
||||||
|
freshness: 'fresh',
|
||||||
|
limitations: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseContext: WeekendContext = {
|
||||||
|
temporal_state: 'session_settling',
|
||||||
|
focus_meeting: {
|
||||||
|
meeting_key: 1,
|
||||||
|
meeting_name: 'British Grand Prix',
|
||||||
|
meeting_official_name: 'British Grand Prix',
|
||||||
|
location: 'Silverstone',
|
||||||
|
country_name: 'UK',
|
||||||
|
country_code: 'GB',
|
||||||
|
country_flag: '',
|
||||||
|
circuit_short_name: 'Silverstone',
|
||||||
|
date_start: '2026-07-03T09:00:00Z',
|
||||||
|
date_end: '2026-07-05T16:00:00Z',
|
||||||
|
year: 2026,
|
||||||
|
},
|
||||||
|
championship_round: 1,
|
||||||
|
total_championship_rounds: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical contract: archive-only just-finished Race vs older ready Practice. */
|
||||||
|
function archiveOnlySettlingContext(previousAnalysis: string): WeekendContext {
|
||||||
|
return {
|
||||||
|
...baseContext,
|
||||||
|
// Older already-ingested session remains the default analysis target.
|
||||||
|
default_analysis_session: contextSession(10, 'Practice 1', 'complete'),
|
||||||
|
// Just-finished Race is archive-complete but not yet analysis-ready.
|
||||||
|
previous_completed_session: contextSession(99, 'Race', previousAnalysis),
|
||||||
|
next_session: contextSession(12, 'Qualifying', 'not_applicable'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: LiveTimingRow[] = [
|
||||||
|
{ RacingNumber: '1', Position: 1, Driver: { RacingNumber: '1', Position: 1 } as never, Info: { Tla: 'VER' } as never },
|
||||||
|
{ RacingNumber: '4', Position: 2, Driver: { RacingNumber: '4', Position: 2 } as never, Info: { Tla: 'NOR' } as never },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('analysisIsReady', () => {
|
||||||
|
it('is true only when local analysis is complete', () => {
|
||||||
|
expect(analysisIsReady(contextSession(11, 'Race', 'complete'))).toBe(true)
|
||||||
|
expect(analysisIsReady(contextSession(11, 'Race', 'pending'))).toBe(false)
|
||||||
|
expect(analysisIsReady(contextSession(11, 'Race', 'partial'))).toBe(false)
|
||||||
|
expect(analysisIsReady(undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('handoffAnalysisSession', () => {
|
||||||
|
it('settling prefers previous_completed_session over an older default', () => {
|
||||||
|
const ctx = archiveOnlySettlingContext('pending')
|
||||||
|
const analysis = handoffAnalysisSession(ctx, 'settling')
|
||||||
|
expect(analysis?.session.session_key).toBe(99)
|
||||||
|
expect(analysis?.session.session_name).toBe('Race')
|
||||||
|
expect(analysisIsReady(analysis)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inactive prefers default_analysis_session (shared analysisSessionKey)', () => {
|
||||||
|
const ctx = archiveOnlySettlingContext('pending')
|
||||||
|
const analysis = handoffAnalysisSession(ctx, 'inactive')
|
||||||
|
expect(analysis?.session.session_key).toBe(10)
|
||||||
|
expect(analysis?.session.session_name).toBe('Practice 1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('settling falls back to default when previous is absent', () => {
|
||||||
|
const ctx = {
|
||||||
|
...baseContext,
|
||||||
|
default_analysis_session: contextSession(11, 'Race', 'pending'),
|
||||||
|
}
|
||||||
|
expect(handoffAnalysisSession(ctx, 'settling')?.session.session_key).toBe(11)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('shouldPollHandoffAnalysis', () => {
|
||||||
|
it('keeps polling when previous is pending even if default is already complete', () => {
|
||||||
|
expect(shouldPollHandoffAnalysis(archiveOnlySettlingContext('pending'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops polling once the just-finished previous session is ready', () => {
|
||||||
|
expect(shouldPollHandoffAnalysis(archiveOnlySettlingContext('complete'))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('LiveHandoff settling', () => {
|
||||||
|
it('shows SESSION SETTLING with a pending analysis action while ingesting', () => {
|
||||||
|
render(
|
||||||
|
<LiveHandoff
|
||||||
|
phase="settling"
|
||||||
|
transport="connected"
|
||||||
|
context={{
|
||||||
|
...baseContext,
|
||||||
|
previous_completed_session: contextSession(11, 'Race', 'pending'),
|
||||||
|
}}
|
||||||
|
rows={rows}
|
||||||
|
capturedAt="2026-07-05T16:02:00Z"
|
||||||
|
hasArchive
|
||||||
|
onViewArchive={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('live-settling')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('SESSION SETTLING')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('live-handoff-captured')).toHaveTextContent('Final feed snapshot captured')
|
||||||
|
const action = screen.getByTestId('live-handoff-analysis')
|
||||||
|
expect(action).toHaveAttribute('href', '/race-hub?session_key=11')
|
||||||
|
expect(action).toHaveAttribute('data-ready', 'false')
|
||||||
|
expect(action).toHaveTextContent('Open Race analysis')
|
||||||
|
expect(action).toHaveTextContent(/analysis will fill in as data ingests/i)
|
||||||
|
// Provisional final order from the retained snapshot.
|
||||||
|
expect(screen.getByTestId('live-handoff-snapshot')).toHaveTextContent('VER')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('links/labels/readiness follow archive-only previous, not an older ready default', () => {
|
||||||
|
render(
|
||||||
|
<LiveHandoff
|
||||||
|
phase="settling"
|
||||||
|
transport="connected"
|
||||||
|
context={archiveOnlySettlingContext('pending')}
|
||||||
|
rows={rows}
|
||||||
|
capturedAt="2026-07-05T16:02:00Z"
|
||||||
|
hasArchive
|
||||||
|
onViewArchive={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const action = screen.getByTestId('live-handoff-analysis')
|
||||||
|
expect(action).toHaveAttribute('href', '/race-hub?session_key=99')
|
||||||
|
expect(action).toHaveAttribute('data-ready', 'false')
|
||||||
|
expect(action).toHaveTextContent('Open Race analysis')
|
||||||
|
expect(action).toHaveTextContent(/Settling — analysis will fill in as data ingests/i)
|
||||||
|
expect(action).not.toHaveTextContent('Practice 1')
|
||||||
|
expect(action).not.toHaveTextContent(/full timing, strategy & story ready/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flips to analysis-ready once local ingestion completes', () => {
|
||||||
|
render(
|
||||||
|
<LiveHandoff
|
||||||
|
phase="settling"
|
||||||
|
transport="connected"
|
||||||
|
context={{
|
||||||
|
...baseContext,
|
||||||
|
previous_completed_session: contextSession(11, 'Race', 'complete'),
|
||||||
|
}}
|
||||||
|
rows={rows}
|
||||||
|
capturedAt="2026-07-05T16:02:00Z"
|
||||||
|
hasArchive={false}
|
||||||
|
onViewArchive={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const action = screen.getByTestId('live-handoff-analysis')
|
||||||
|
expect(action).toHaveAttribute('data-ready', 'true')
|
||||||
|
expect(action).toHaveTextContent(/full timing, strategy & story ready/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never renders live/connected chrome or a countdown in settling', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<LiveHandoff
|
||||||
|
phase="settling"
|
||||||
|
transport="connected"
|
||||||
|
context={{
|
||||||
|
...baseContext,
|
||||||
|
previous_completed_session: contextSession(11, 'Race', 'complete'),
|
||||||
|
}}
|
||||||
|
rows={rows}
|
||||||
|
capturedAt="2026-07-05T16:02:00Z"
|
||||||
|
hasArchive
|
||||||
|
onViewArchive={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.queryByText('LIVE SESSION')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('live-clock')).not.toBeInTheDocument()
|
||||||
|
expect(container.querySelector('.live-session-flag-live')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses non-settling readiness copy when inactive and analysis is still ingesting', () => {
|
||||||
|
render(
|
||||||
|
<LiveHandoff
|
||||||
|
phase="inactive"
|
||||||
|
transport="connected"
|
||||||
|
context={{ ...baseContext, default_analysis_session: contextSession(11, 'Race', 'pending') }}
|
||||||
|
rows={[]}
|
||||||
|
hasArchive={false}
|
||||||
|
onViewArchive={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const action = screen.getByTestId('live-handoff-analysis')
|
||||||
|
expect(action).toHaveAttribute('data-ready', 'false')
|
||||||
|
expect(action).toHaveTextContent(/analysis will fill in as data ingests/i)
|
||||||
|
expect(action).not.toHaveTextContent(/^Settling/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('LiveHandoff inactive', () => {
|
||||||
|
it('uses shared next + recap context instead of a telemetry-offline dead end', () => {
|
||||||
|
render(
|
||||||
|
<LiveHandoff
|
||||||
|
phase="inactive"
|
||||||
|
transport="connected"
|
||||||
|
context={{
|
||||||
|
...baseContext,
|
||||||
|
temporal_state: 'between_sessions',
|
||||||
|
default_analysis_session: contextSession(11, 'Practice 1', 'complete'),
|
||||||
|
next_session: contextSession(12, 'Qualifying', 'not_applicable'),
|
||||||
|
previous_completed_session: contextSession(10, 'Practice 2', 'complete'),
|
||||||
|
}}
|
||||||
|
rows={[]}
|
||||||
|
hasArchive={false}
|
||||||
|
onViewArchive={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByTestId('live-inactive')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('NO LIVE SESSION')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('live-handoff-next')).toHaveTextContent('Qualifying')
|
||||||
|
expect(screen.getByTestId('live-handoff-recap')).toHaveTextContent('Practice 2')
|
||||||
|
// No provisional-order snapshot when inactive.
|
||||||
|
expect(screen.queryByTestId('live-handoff-snapshot')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not duplicate the recap card when previous equals the analysis target', () => {
|
||||||
|
render(
|
||||||
|
<LiveHandoff
|
||||||
|
phase="inactive"
|
||||||
|
transport="connected"
|
||||||
|
context={{
|
||||||
|
...baseContext,
|
||||||
|
default_analysis_session: contextSession(11, 'Race', 'complete'),
|
||||||
|
previous_completed_session: contextSession(11, 'Race', 'complete'),
|
||||||
|
}}
|
||||||
|
rows={[]}
|
||||||
|
hasArchive={false}
|
||||||
|
onViewArchive={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.queryByTestId('live-handoff-recap')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to Weekend when the context has nothing to offer', () => {
|
||||||
|
render(
|
||||||
|
<LiveHandoff
|
||||||
|
phase="inactive"
|
||||||
|
transport="error"
|
||||||
|
context={{ temporal_state: 'no_season', championship_round: 0, total_championship_rounds: 0 }}
|
||||||
|
rows={[]}
|
||||||
|
hasArchive={false}
|
||||||
|
onViewArchive={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByTestId('live-handoff-fallback')).toHaveTextContent('Weekend')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,32 +1,34 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { LiveTimingPage } from '../pages/LiveTimingPage'
|
import { LiveTimingPage, WEEKEND_CONTEXT_POLL_MS } from '../pages/LiveTimingPage'
|
||||||
import type { LiveStateResponse, LiveStreamData } from '../types'
|
import type { LiveStateResponse, LiveStreamData, WeekendContext } from '../types'
|
||||||
|
|
||||||
vi.mock('../api', () => ({
|
vi.mock('../api', () => ({
|
||||||
fetchLiveState: vi.fn(),
|
fetchLiveState: vi.fn(),
|
||||||
fetchLiveTrackOutline: vi.fn(),
|
fetchLiveTrackOutline: vi.fn(),
|
||||||
|
fetchWeekendContext: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import { fetchLiveState, fetchLiveTrackOutline } from '../api'
|
import { fetchLiveState, fetchLiveTrackOutline, fetchWeekendContext } from '../api'
|
||||||
|
|
||||||
const mockFetchLiveState = vi.mocked(fetchLiveState)
|
const mockFetchLiveState = vi.mocked(fetchLiveState)
|
||||||
const mockFetchLiveTrackOutline = vi.mocked(fetchLiveTrackOutline)
|
const mockFetchLiveTrackOutline = vi.mocked(fetchLiveTrackOutline)
|
||||||
|
const mockFetchWeekendContext = vi.mocked(fetchWeekendContext)
|
||||||
|
|
||||||
|
// A MockEventSource that opens on construct but never emits a snapshot, so the
|
||||||
|
// initial /api/v1/live/state query drives the rendered phase.
|
||||||
class MockEventSource {
|
class MockEventSource {
|
||||||
onopen: (() => void) | null = null
|
onopen: (() => void) | null = null
|
||||||
onerror: (() => void) | null = null
|
onerror: (() => void) | null = null
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
setTimeout(() => this.onopen?.(), 0)
|
setTimeout(() => this.onopen?.(), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
addEventListener() {}
|
addEventListener() {}
|
||||||
close() {}
|
close() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const archivedSnapshot: LiveStreamData = {
|
const raceSnapshot: LiveStreamData = {
|
||||||
Drivers: {
|
Drivers: {
|
||||||
'1': {
|
'1': {
|
||||||
RacingNumber: '1',
|
RacingNumber: '1',
|
||||||
@@ -63,19 +65,10 @@ const archivedSnapshot: LiveStreamData = {
|
|||||||
LastName: 'Verstappen',
|
LastName: 'Verstappen',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Tyres: {
|
Tyres: { '1': { Compound: 'HARD', New: false, Age: 12 } },
|
||||||
'1': { Compound: 'HARD', New: false, Age: 12 },
|
|
||||||
},
|
|
||||||
Telemetry: {},
|
Telemetry: {},
|
||||||
RCMessages: [],
|
RCMessages: [],
|
||||||
Weather: {
|
Weather: { AirTemp: 22, TrackTemp: 41, Humidity: 58, WindSpeed: 3, WindDir: 180, Rainfall: false },
|
||||||
AirTemp: 22,
|
|
||||||
TrackTemp: 41,
|
|
||||||
Humidity: 58,
|
|
||||||
WindSpeed: 3,
|
|
||||||
WindDir: 180,
|
|
||||||
Rainfall: false,
|
|
||||||
},
|
|
||||||
Session: {
|
Session: {
|
||||||
MeetingName: 'Testonia Grand Prix',
|
MeetingName: 'Testonia Grand Prix',
|
||||||
CircuitName: 'Testring',
|
CircuitName: 'Testring',
|
||||||
@@ -84,9 +77,9 @@ const archivedSnapshot: LiveStreamData = {
|
|||||||
Path: '',
|
Path: '',
|
||||||
},
|
},
|
||||||
TeamRadio: [],
|
TeamRadio: [],
|
||||||
SessionStatus: 'Finished',
|
SessionStatus: 'Started',
|
||||||
TrackStatus: '1',
|
TrackStatus: '1',
|
||||||
CurrentLap: 57,
|
CurrentLap: 30,
|
||||||
TotalLaps: 57,
|
TotalLaps: 57,
|
||||||
Clock: '',
|
Clock: '',
|
||||||
ClockRefTime: '',
|
ClockRefTime: '',
|
||||||
@@ -94,17 +87,93 @@ const archivedSnapshot: LiveStreamData = {
|
|||||||
Stints: {},
|
Stints: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPage(response: LiveStateResponse) {
|
function weekendContext(localAnalysis: string): WeekendContext {
|
||||||
const queryClient = new QueryClient({
|
return {
|
||||||
defaultOptions: { queries: { retry: false } },
|
temporal_state: 'session_settling',
|
||||||
})
|
focus_meeting: {
|
||||||
|
meeting_key: 1,
|
||||||
|
meeting_name: 'Testonia Grand Prix',
|
||||||
|
meeting_official_name: 'Testonia Grand Prix',
|
||||||
|
location: 'Testring',
|
||||||
|
country_name: 'Testonia',
|
||||||
|
country_code: 'TS',
|
||||||
|
country_flag: '',
|
||||||
|
circuit_short_name: 'Testring',
|
||||||
|
date_start: '2026-07-03T09:00:00Z',
|
||||||
|
date_end: '2026-07-05T16:00:00Z',
|
||||||
|
year: 2026,
|
||||||
|
},
|
||||||
|
previous_completed_session: {
|
||||||
|
session: {
|
||||||
|
session_key: 99,
|
||||||
|
session_name: 'Race',
|
||||||
|
session_type: 'Race',
|
||||||
|
meeting_key: 1,
|
||||||
|
date_start: '2026-07-05T14:00:00Z',
|
||||||
|
date_end: '2026-07-05T16:00:00Z',
|
||||||
|
gmt_offset: '',
|
||||||
|
},
|
||||||
|
availability: {
|
||||||
|
schedule: 'available',
|
||||||
|
live_transport: 'unknown',
|
||||||
|
live_session: 'inactive',
|
||||||
|
archive: 'available',
|
||||||
|
local_analysis: localAnalysis,
|
||||||
|
freshness: 'fresh',
|
||||||
|
limitations: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
championship_round: 1,
|
||||||
|
total_championship_rounds: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Just-finished archive-only Race + older already-ready Practice default. */
|
||||||
|
function archiveOnlySettlingContext(previousAnalysis: string): WeekendContext {
|
||||||
|
return {
|
||||||
|
...weekendContext(previousAnalysis),
|
||||||
|
default_analysis_session: {
|
||||||
|
session: {
|
||||||
|
session_key: 10,
|
||||||
|
session_name: 'Practice 1',
|
||||||
|
session_type: 'Practice',
|
||||||
|
meeting_key: 1,
|
||||||
|
date_start: '2026-07-04T12:00:00Z',
|
||||||
|
date_end: '2026-07-04T13:00:00Z',
|
||||||
|
gmt_offset: '',
|
||||||
|
},
|
||||||
|
availability: {
|
||||||
|
schedule: 'available',
|
||||||
|
live_transport: 'unknown',
|
||||||
|
live_session: 'inactive',
|
||||||
|
archive: 'available',
|
||||||
|
local_analysis: 'complete',
|
||||||
|
freshness: 'fresh',
|
||||||
|
limitations: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage(
|
||||||
|
response: LiveStateResponse,
|
||||||
|
context?: WeekendContext,
|
||||||
|
options: { setWeekendContext?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
mockFetchLiveState.mockResolvedValue(response)
|
mockFetchLiveState.mockResolvedValue(response)
|
||||||
mockFetchLiveTrackOutline.mockResolvedValue({
|
mockFetchLiveTrackOutline.mockResolvedValue({
|
||||||
circuit_key: 1,
|
circuit_key: 1,
|
||||||
points: [],
|
points: [],
|
||||||
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
|
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
|
||||||
})
|
})
|
||||||
|
// Allow callers to pre-configure the weekend-context mock (e.g. pending→complete
|
||||||
|
// polling) without this helper overwriting the chain.
|
||||||
|
if (options.setWeekendContext !== false) {
|
||||||
|
mockFetchWeekendContext.mockResolvedValue(
|
||||||
|
context ?? { temporal_state: 'no_season', championship_round: 0, total_championship_rounds: 0 },
|
||||||
|
)
|
||||||
|
}
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<LiveTimingPage />
|
<LiveTimingPage />
|
||||||
@@ -112,7 +181,7 @@ function renderPage(response: LiveStateResponse) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('LiveTimingPage archive mode', () => {
|
describe('LiveTimingPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
Object.defineProperty(window, 'EventSource', {
|
Object.defineProperty(window, 'EventSource', {
|
||||||
@@ -122,45 +191,205 @@ describe('LiveTimingPage archive mode', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps archived snapshots behind the View Last Session action', async () => {
|
afterEach(() => {
|
||||||
renderPage({
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the live timing tower for an active session', async () => {
|
||||||
|
renderPage({ is_live: true, data: raceSnapshot })
|
||||||
|
expect(await screen.findByText('Timing Tower')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'live')
|
||||||
|
expect(screen.getByTestId('live-session-flag')).toHaveTextContent('LIVE SESSION')
|
||||||
|
expect(screen.getByTestId('live-feed-health')).toBeInTheDocument()
|
||||||
|
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enters the settling handoff (not archive) when the session ends cleanly', async () => {
|
||||||
|
renderPage(
|
||||||
|
{
|
||||||
is_live: false,
|
is_live: false,
|
||||||
data: null,
|
data: null,
|
||||||
last_snapshot: archivedSnapshot,
|
last_snapshot: { ...raceSnapshot, SessionStatus: 'Finished' },
|
||||||
last_positions: {
|
last_snapshot_at: '2026-07-05T16:02:00Z',
|
||||||
'1': { x: 10, y: 20, z: 0, status: 'OnTrack' },
|
|
||||||
},
|
},
|
||||||
last_snapshot_at: '2026-07-04T14:00:00Z',
|
weekendContext('pending'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const settling = await screen.findByTestId('live-settling')
|
||||||
|
expect(settling).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'settling')
|
||||||
|
// Just-finished previous_completed_session drives the primary action target.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId('live-handoff-analysis')).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/race-hub?session_key=99',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// The timing tower is not shown while settling — it is a handoff surface.
|
||||||
|
expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active')
|
it('settles against archive-only previous, not an older ready default_analysis_session', async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||||
|
mockFetchWeekendContext
|
||||||
|
.mockResolvedValueOnce(archiveOnlySettlingContext('pending'))
|
||||||
|
.mockResolvedValue(archiveOnlySettlingContext('complete'))
|
||||||
|
|
||||||
|
renderPage(
|
||||||
|
{
|
||||||
|
is_live: false,
|
||||||
|
data: null,
|
||||||
|
last_snapshot: { ...raceSnapshot, SessionStatus: 'Finished' },
|
||||||
|
last_snapshot_at: '2026-07-05T16:02:00Z',
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ setWeekendContext: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
const action = await screen.findByTestId('live-handoff-analysis')
|
||||||
|
expect(action).toHaveAttribute('href', '/race-hub?session_key=99')
|
||||||
|
expect(action).toHaveAttribute('data-ready', 'false')
|
||||||
|
expect(action).toHaveTextContent('Open Race analysis')
|
||||||
|
expect(action).not.toHaveTextContent('Practice 1')
|
||||||
|
|
||||||
|
// Older default is already complete — polling must continue for previous.
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(WEEKEND_CONTEXT_POLL_MS + 500)
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId('live-handoff-analysis')).toHaveAttribute('data-ready', 'true'),
|
||||||
|
)
|
||||||
|
expect(screen.getByTestId('live-handoff-analysis')).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/race-hub?session_key=99',
|
||||||
|
)
|
||||||
|
expect(mockFetchWeekendContext.mock.calls.length).toBeGreaterThan(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flips settling → analysis-ready when polling sees ingestion complete', async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||||
|
// First fetch: pending. Subsequent polls: complete.
|
||||||
|
mockFetchWeekendContext
|
||||||
|
.mockResolvedValueOnce(weekendContext('pending'))
|
||||||
|
.mockResolvedValue(weekendContext('complete'))
|
||||||
|
|
||||||
|
renderPage(
|
||||||
|
{
|
||||||
|
is_live: false,
|
||||||
|
data: null,
|
||||||
|
last_snapshot: { ...raceSnapshot, SessionStatus: 'Finished' },
|
||||||
|
last_snapshot_at: '2026-07-05T16:02:00Z',
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ setWeekendContext: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
const action = await screen.findByTestId('live-handoff-analysis')
|
||||||
|
expect(action).toHaveAttribute('data-ready', 'false')
|
||||||
|
expect(action).toHaveTextContent(/analysis will fill in as data ingests/i)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(WEEKEND_CONTEXT_POLL_MS + 500)
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId('live-handoff-analysis')).toHaveAttribute('data-ready', 'true'),
|
||||||
|
)
|
||||||
|
expect(screen.getByTestId('live-handoff-analysis')).toHaveTextContent(
|
||||||
|
/full timing, strategy & story ready/i,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retains the last snapshot with a disconnected warning (not settling) on a feed drop', async () => {
|
||||||
|
// is_live=false but the retained snapshot has a NON-terminal SessionStatus:
|
||||||
|
// the FIA feed dropped while the session was still active.
|
||||||
|
renderPage(
|
||||||
|
{
|
||||||
|
is_live: false,
|
||||||
|
data: null,
|
||||||
|
last_snapshot: { ...raceSnapshot, SessionStatus: 'Started' },
|
||||||
|
last_snapshot_at: '2026-07-05T15:30:00Z',
|
||||||
|
},
|
||||||
|
weekendContext('pending'),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('live-disconnected-strip')).toHaveTextContent(
|
||||||
|
/showing the last live data/i,
|
||||||
|
)
|
||||||
|
expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'disconnected')
|
||||||
|
// The retained live tower stays visible; we never fell into settling/archive.
|
||||||
|
expect(screen.getByText('Timing Tower')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('live-settling')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('live-archive-strip')).not.toBeInTheDocument()
|
||||||
|
// SSE may still be open (MockEventSource opens) — health must not say healthy.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId('live-feed-health')).toHaveTextContent(/reconnecting/i),
|
||||||
|
)
|
||||||
|
expect(screen.getByTestId('live-feed-health')).not.toHaveTextContent(/feed healthy/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the settled snapshot behind an explicit read-only archive action', async () => {
|
||||||
|
renderPage(
|
||||||
|
{
|
||||||
|
is_live: false,
|
||||||
|
data: null,
|
||||||
|
last_snapshot: { ...raceSnapshot, SessionStatus: 'Finished' },
|
||||||
|
last_positions: { '1': { x: 10, y: 20, z: 0, status: 'OnTrack' } },
|
||||||
|
last_snapshot_at: '2026-07-05T16:02:00Z',
|
||||||
|
},
|
||||||
|
weekendContext('complete'),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Settling first — the tower is hidden until the user opens the archive.
|
||||||
|
await screen.findByTestId('live-settling')
|
||||||
expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument()
|
expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument()
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /view last session/i }))
|
fireEvent.click(screen.getByTestId('live-handoff-archive'))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() =>
|
||||||
expect(screen.getByTestId('live-archive-strip')).toHaveTextContent('Archived snapshot')
|
expect(screen.getByTestId('live-archive-strip')).toHaveTextContent(/read-only/i),
|
||||||
})
|
)
|
||||||
expect(screen.getByText('Timing Tower')).toBeInTheDocument()
|
expect(screen.getByText('Timing Tower')).toBeInTheDocument()
|
||||||
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
|
// Archive presents its read-only, timestamped chrome — never LIVE.
|
||||||
expect(screen.getByText('archive')).toBeInTheDocument()
|
expect(screen.getByText('archive')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('LIVE SESSION')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not show the archive action when no snapshot is retained', async () => {
|
it('shows the inactive weekend context when nothing is live or retained', async () => {
|
||||||
renderPage({ is_live: false, data: null })
|
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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active')
|
expect(await screen.findByTestId('live-inactive')).toBeInTheDocument()
|
||||||
expect(screen.queryByRole('button', { name: /view last session/i })).not.toBeInTheDocument()
|
expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'inactive')
|
||||||
})
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId('live-handoff-next')).toHaveTextContent('Practice 1'),
|
||||||
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()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ describe('LocalDataNavigator', () => {
|
|||||||
|
|
||||||
expect(await screen.findByTestId('local-nav')).toBeInTheDocument()
|
expect(await screen.findByTestId('local-nav')).toBeInTheDocument()
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025)
|
expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025, expect.any(AbortSignal))
|
||||||
})
|
})
|
||||||
expect(screen.getByText('Monaco')).toBeInTheDocument()
|
expect(screen.getByText('Monaco')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -384,7 +384,8 @@ describe('RaceHubPage', () => {
|
|||||||
|
|
||||||
renderRaceHub(0)
|
renderRaceHub(0)
|
||||||
|
|
||||||
await waitFor(() => expect(mockFetchRaceHub).toHaveBeenCalledWith(9471))
|
await waitFor(() => expect(mockFetchRaceHub).toHaveBeenCalledWith(9471, expect.any(AbortSignal)))
|
||||||
|
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9600, expect.anything())
|
||||||
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9600)
|
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9600)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -278,6 +278,15 @@ describe('RaceStoryCanvas replay map', () => {
|
|||||||
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
|
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('exposes the final running order outcome region with winner position', () => {
|
||||||
|
renderCanvas()
|
||||||
|
|
||||||
|
const outcome = screen.getByTestId('race-story-outcome')
|
||||||
|
expect(outcome).toHaveAttribute('aria-label', 'Final running order')
|
||||||
|
expect(outcome).toHaveTextContent('VER')
|
||||||
|
expect(outcome.querySelector('.rs-pos-col')).toHaveTextContent('1')
|
||||||
|
})
|
||||||
|
|
||||||
it('renders the empty-state card when positions are unavailable', () => {
|
it('renders the empty-state card when positions are unavailable', () => {
|
||||||
renderCanvas({
|
renderCanvas({
|
||||||
datasets: { positions: { status: 'missing', source: 'local', count: 0 } },
|
datasets: { positions: { status: 'missing', source: 'local', count: 0 } },
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
Outlet,
|
Outlet,
|
||||||
@@ -239,7 +239,39 @@ describe('WeekendPage canonical contract rendering', () => {
|
|||||||
mockContext.mockRejectedValue(new Error('API 500: boom'))
|
mockContext.mockRejectedValue(new Error('API 500: boom'))
|
||||||
renderAt('/')
|
renderAt('/')
|
||||||
await waitFor(() => expect(screen.getByTestId('weekend-error')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByTestId('weekend-error')).toBeInTheDocument())
|
||||||
expect(screen.getByTestId('weekend-error')).toHaveTextContent('boom')
|
// 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]> = [
|
const stateCases: Array<[TemporalState, string]> = [
|
||||||
|
|||||||
291
frontend/src/test/fetch.test.ts
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
apiFetch,
|
||||||
|
clearApiFetchInflight,
|
||||||
|
DEFAULT_FETCH_TIMEOUT_MS,
|
||||||
|
isTimeoutError,
|
||||||
|
userFacingError,
|
||||||
|
} from '../lib/fetch'
|
||||||
|
|
||||||
|
describe('apiFetch', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearApiFetchInflight()
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
clearApiFetchInflight()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves JSON within the timeout boundary', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async () =>
|
||||||
|
new Response(JSON.stringify({ ok: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(apiFetch<{ ok: boolean }>('/api/v1/ping')).resolves.toEqual({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('times out and leaves no hanging promise after the deadline', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((_url: string, init?: RequestInit) => {
|
||||||
|
return new Promise<Response>((_resolve, reject) => {
|
||||||
|
const signal = init?.signal
|
||||||
|
if (!signal) return
|
||||||
|
signal.addEventListener('abort', () => {
|
||||||
|
reject(new DOMException('Aborted', 'AbortError'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const pending = apiFetch('/api/v1/slow', { timeoutMs: 1000 })
|
||||||
|
const assertion = expect(pending).rejects.toSatisfy(
|
||||||
|
(err: unknown) => isTimeoutError(err) && userFacingError(err).includes('too long'),
|
||||||
|
)
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1000)
|
||||||
|
await assertion
|
||||||
|
})
|
||||||
|
|
||||||
|
it('propagates caller abort as an ApiError', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((_url: string, init?: RequestInit) => {
|
||||||
|
return new Promise<Response>((_resolve, reject) => {
|
||||||
|
init?.signal?.addEventListener('abort', () => {
|
||||||
|
reject(new DOMException('Aborted', 'AbortError'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const pending = apiFetch('/api/v1/abort-me', {
|
||||||
|
signal: controller.signal,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
})
|
||||||
|
const assertion = expect(pending).rejects.toMatchObject({ kind: 'abort' })
|
||||||
|
controller.abort()
|
||||||
|
await assertion
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deduplicates concurrent in-flight requests for the same key', async () => {
|
||||||
|
let starts = 0
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async () => {
|
||||||
|
starts += 1
|
||||||
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
return new Response(JSON.stringify({ n: starts }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const a = apiFetch<{ n: number }>('/api/v1/shared', { dedupeKey: 'shared' })
|
||||||
|
const b = apiFetch<{ n: number }>('/api/v1/shared', { dedupeKey: 'shared' })
|
||||||
|
await vi.advanceTimersByTimeAsync(50)
|
||||||
|
const [ra, rb] = await Promise.all([a, b])
|
||||||
|
expect(starts).toBe(1)
|
||||||
|
expect(ra).toEqual(rb)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not poison a second caller when the first aborts the same dedupe key', async () => {
|
||||||
|
let starts = 0
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((_url: string, init?: RequestInit) => {
|
||||||
|
starts += 1
|
||||||
|
return new Promise<Response>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
resolve(
|
||||||
|
new Response(JSON.stringify({ ok: true, starts }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}, 100)
|
||||||
|
init?.signal?.addEventListener(
|
||||||
|
'abort',
|
||||||
|
() => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
reject(new DOMException('Aborted', 'AbortError'))
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const first = new AbortController()
|
||||||
|
const second = new AbortController()
|
||||||
|
const a = apiFetch<{ ok: boolean }>('/api/v1/shared', {
|
||||||
|
dedupeKey: 'shared-abort-safe',
|
||||||
|
signal: first.signal,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
})
|
||||||
|
const b = apiFetch<{ ok: boolean }>('/api/v1/shared', {
|
||||||
|
dedupeKey: 'shared-abort-safe',
|
||||||
|
signal: second.signal,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
first.abort()
|
||||||
|
await expect(a).rejects.toMatchObject({ kind: 'abort' })
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100)
|
||||||
|
await expect(b).resolves.toEqual({ ok: true, starts: 1 })
|
||||||
|
expect(starts).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancels the shared request only after the last subscriber aborts', async () => {
|
||||||
|
let aborted = false
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((_url: string, init?: RequestInit) => {
|
||||||
|
return new Promise<Response>((_resolve, reject) => {
|
||||||
|
init?.signal?.addEventListener(
|
||||||
|
'abort',
|
||||||
|
() => {
|
||||||
|
aborted = true
|
||||||
|
reject(new DOMException('Aborted', 'AbortError'))
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const first = new AbortController()
|
||||||
|
const second = new AbortController()
|
||||||
|
const a = apiFetch('/api/v1/shared', {
|
||||||
|
dedupeKey: 'shared-last-abort',
|
||||||
|
signal: first.signal,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
})
|
||||||
|
const b = apiFetch('/api/v1/shared', {
|
||||||
|
dedupeKey: 'shared-last-abort',
|
||||||
|
signal: second.signal,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
first.abort()
|
||||||
|
await expect(a).rejects.toMatchObject({ kind: 'abort' })
|
||||||
|
expect(aborted).toBe(false)
|
||||||
|
|
||||||
|
second.abort()
|
||||||
|
await expect(b).rejects.toMatchObject({ kind: 'abort' })
|
||||||
|
expect(aborted).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('survives a Strict Mode style remount: aborted first consumer, fresh second consumer', async () => {
|
||||||
|
let starts = 0
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((_url: string, init?: RequestInit) => {
|
||||||
|
starts += 1
|
||||||
|
return new Promise<Response>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
resolve(
|
||||||
|
new Response(JSON.stringify({ starts }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}, 50)
|
||||||
|
init?.signal?.addEventListener(
|
||||||
|
'abort',
|
||||||
|
() => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
reject(new DOMException('Aborted', 'AbortError'))
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mount #1
|
||||||
|
const first = new AbortController()
|
||||||
|
const pendingFirst = apiFetch<{ starts: number }>('/api/v1/seasons', {
|
||||||
|
dedupeKey: 'seasons',
|
||||||
|
signal: first.signal,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Strict Mode unmount cancels the first subscription (sole subscriber → shared abort)
|
||||||
|
first.abort()
|
||||||
|
await expect(pendingFirst).rejects.toMatchObject({ kind: 'abort' })
|
||||||
|
|
||||||
|
// Remount starts a fresh consumer — must not inherit the aborted request
|
||||||
|
const second = new AbortController()
|
||||||
|
const pendingSecond = apiFetch<{ starts: number }>('/api/v1/seasons', {
|
||||||
|
dedupeKey: 'seasons',
|
||||||
|
signal: second.signal,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(50)
|
||||||
|
await expect(pendingSecond).resolves.toEqual({ starts: 2 })
|
||||||
|
expect(starts).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows an explicit retry after the prior request settles', async () => {
|
||||||
|
let starts = 0
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async () => {
|
||||||
|
starts += 1
|
||||||
|
return new Response(JSON.stringify({ starts }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(apiFetch('/api/v1/retry', { dedupeKey: 'retry-once' })).resolves.toEqual({
|
||||||
|
starts: 1,
|
||||||
|
})
|
||||||
|
await expect(apiFetch('/api/v1/retry', { dedupeKey: 'retry-once' })).resolves.toEqual({
|
||||||
|
starts: 2,
|
||||||
|
})
|
||||||
|
expect(starts).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps HTTP errors without raw status jargon in userFacingError', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async () =>
|
||||||
|
new Response(JSON.stringify({ error: 'upstream failed', stale: true }), {
|
||||||
|
status: 502,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiFetch('/api/v1/fail')
|
||||||
|
expect.unreachable('should throw')
|
||||||
|
} catch (err) {
|
||||||
|
expect(err).toBeInstanceOf(ApiError)
|
||||||
|
expect((err as ApiError).stale).toBe(true)
|
||||||
|
expect(userFacingError(err)).not.toMatch(/502|API /)
|
||||||
|
expect(userFacingError(err)).toMatch(/cached|Retry/i)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the default primary-route timeout', () => {
|
||||||
|
expect(DEFAULT_FETCH_TIMEOUT_MS).toBe(15_000)
|
||||||
|
})
|
||||||
|
})
|
||||||
183
frontend/src/test/liveState.test.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
allowsLiveInterpretations,
|
||||||
|
deriveLivePhase,
|
||||||
|
effectiveFeedHealth,
|
||||||
|
isReadOnlyPhase,
|
||||||
|
rendersSnapshot,
|
||||||
|
terminalSessionStatus,
|
||||||
|
type LiveStateInputs,
|
||||||
|
} from '../lib/liveState'
|
||||||
|
|
||||||
|
const base: LiveStateInputs = {
|
||||||
|
transport: 'connecting',
|
||||||
|
isLive: false,
|
||||||
|
hasActiveSnapshot: false,
|
||||||
|
hasArchive: false,
|
||||||
|
archiveMode: false,
|
||||||
|
sessionEndedCleanly: false,
|
||||||
|
wasLive: false,
|
||||||
|
stateLoaded: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('terminalSessionStatus', () => {
|
||||||
|
it('recognizes genuine session-end statuses', () => {
|
||||||
|
for (const s of ['Finished', 'Finalised', 'Finalized', 'ENDED', 'Aborted']) {
|
||||||
|
expect(terminalSessionStatus(s)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats still-running / unknown statuses as non-terminal', () => {
|
||||||
|
for (const s of ['Started', 'Resumed', 'Inactive', '', undefined, null]) {
|
||||||
|
expect(terminalSessionStatus(s)).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deriveLivePhase transitions', () => {
|
||||||
|
it('connecting: cold start with the feed still opening', () => {
|
||||||
|
expect(deriveLivePhase({ ...base, transport: 'connecting' })).toBe('connecting')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('connecting → live once a session snapshot arrives', () => {
|
||||||
|
expect(
|
||||||
|
deriveLivePhase({
|
||||||
|
...base,
|
||||||
|
transport: 'connected',
|
||||||
|
isLive: true,
|
||||||
|
hasActiveSnapshot: true,
|
||||||
|
}),
|
||||||
|
).toBe('live')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('live → disconnected when the SSE transport drops mid-session', () => {
|
||||||
|
expect(
|
||||||
|
deriveLivePhase({
|
||||||
|
...base,
|
||||||
|
transport: 'disconnected',
|
||||||
|
isLive: true,
|
||||||
|
hasActiveSnapshot: true,
|
||||||
|
wasLive: true,
|
||||||
|
}),
|
||||||
|
).toBe('disconnected')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('live → settling when the session ends with a terminal status', () => {
|
||||||
|
// Feed reports is_live=false, transport healthy, retained snapshot carries a
|
||||||
|
// terminal SessionStatus → the session genuinely finished.
|
||||||
|
expect(
|
||||||
|
deriveLivePhase({
|
||||||
|
...base,
|
||||||
|
transport: 'connected',
|
||||||
|
isLive: false,
|
||||||
|
hasArchive: true,
|
||||||
|
sessionEndedCleanly: true,
|
||||||
|
wasLive: true,
|
||||||
|
}),
|
||||||
|
).toBe('settling')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('live → disconnected (NOT settling) when the FIA feed drops but status is non-terminal', () => {
|
||||||
|
// This is the P0 case: server deactivate() moved a still-active snapshot to
|
||||||
|
// last_snapshot with is_live=false but a non-terminal SessionStatus. The
|
||||||
|
// browser transport is healthy. We must warn + retain, never settle/archive.
|
||||||
|
expect(
|
||||||
|
deriveLivePhase({
|
||||||
|
...base,
|
||||||
|
transport: 'connected',
|
||||||
|
isLive: false,
|
||||||
|
hasArchive: true,
|
||||||
|
sessionEndedCleanly: false,
|
||||||
|
wasLive: true,
|
||||||
|
}),
|
||||||
|
).toBe('disconnected')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a retained non-terminal snapshot on cold load is disconnected, not settling', () => {
|
||||||
|
expect(
|
||||||
|
deriveLivePhase({
|
||||||
|
...base,
|
||||||
|
transport: 'connected',
|
||||||
|
isLive: false,
|
||||||
|
hasArchive: true,
|
||||||
|
sessionEndedCleanly: false,
|
||||||
|
wasLive: false,
|
||||||
|
}),
|
||||||
|
).toBe('disconnected')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('settling → archive when the user explicitly opens the read-only snapshot', () => {
|
||||||
|
expect(
|
||||||
|
deriveLivePhase({
|
||||||
|
...base,
|
||||||
|
transport: 'connected',
|
||||||
|
isLive: false,
|
||||||
|
hasArchive: true,
|
||||||
|
sessionEndedCleanly: true,
|
||||||
|
archiveMode: true,
|
||||||
|
}),
|
||||||
|
).toBe('archive')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('archive mode wins over an active session (user-chosen read-only view)', () => {
|
||||||
|
expect(
|
||||||
|
deriveLivePhase({
|
||||||
|
...base,
|
||||||
|
transport: 'connected',
|
||||||
|
isLive: true,
|
||||||
|
hasActiveSnapshot: true,
|
||||||
|
hasArchive: true,
|
||||||
|
archiveMode: true,
|
||||||
|
}),
|
||||||
|
).toBe('archive')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inactive when there is no session and nothing retained', () => {
|
||||||
|
expect(deriveLivePhase({ ...base, transport: 'connected', stateLoaded: true })).toBe('inactive')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves connecting for inactive once the initial state resolves, even mid-handshake', () => {
|
||||||
|
// connecting → inactive: the SSE transport handshake is still pending
|
||||||
|
// (transport === "connecting") but the initial live-state query resolved as
|
||||||
|
// idle, so we must not spin on "connecting…" forever.
|
||||||
|
expect(deriveLivePhase({ ...base, transport: 'connecting', stateLoaded: true })).toBe('inactive')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('phase capability helpers', () => {
|
||||||
|
it('renders the snapshot surface only for live/disconnected/archive', () => {
|
||||||
|
expect(rendersSnapshot('live')).toBe(true)
|
||||||
|
expect(rendersSnapshot('disconnected')).toBe(true)
|
||||||
|
expect(rendersSnapshot('archive')).toBe(true)
|
||||||
|
expect(rendersSnapshot('settling')).toBe(false)
|
||||||
|
expect(rendersSnapshot('inactive')).toBe(false)
|
||||||
|
expect(rendersSnapshot('connecting')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows live-only interpretations only while the session is moving', () => {
|
||||||
|
expect(allowsLiveInterpretations('live')).toBe(true)
|
||||||
|
expect(allowsLiveInterpretations('disconnected')).toBe(true)
|
||||||
|
// A frozen archive frame must never present live-only interpretations.
|
||||||
|
expect(allowsLiveInterpretations('archive')).toBe(false)
|
||||||
|
expect(allowsLiveInterpretations('settling')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks archive as the read-only phase', () => {
|
||||||
|
expect(isReadOnlyPhase('archive')).toBe(true)
|
||||||
|
expect(isReadOnlyPhase('live')).toBe(false)
|
||||||
|
expect(isReadOnlyPhase('disconnected')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('effectiveFeedHealth', () => {
|
||||||
|
it('downgrades a still-open SSE to reconnecting while phase is disconnected', () => {
|
||||||
|
expect(effectiveFeedHealth('connected', 'disconnected')).toBe('disconnected')
|
||||||
|
expect(effectiveFeedHealth('connecting', 'disconnected')).toBe('disconnected')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves transport when the session phase is live or settling', () => {
|
||||||
|
expect(effectiveFeedHealth('connected', 'live')).toBe('connected')
|
||||||
|
expect(effectiveFeedHealth('connected', 'settling')).toBe('connected')
|
||||||
|
expect(effectiveFeedHealth('error', 'disconnected')).toBe('error')
|
||||||
|
})
|
||||||
|
})
|
||||||
102
frontend/src/test/route-resilience.test.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
|
||||||
|
import { DriverProfilePage } from '../pages/DriverProfilePage'
|
||||||
|
import { ApiError } from '../lib/fetch'
|
||||||
|
import type { DriverSummary } from '../types'
|
||||||
|
|
||||||
|
vi.mock('../api', () => ({
|
||||||
|
fetchSeasons: vi.fn(),
|
||||||
|
fetchDriverSummary: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { fetchSeasons, fetchDriverSummary } from '../api'
|
||||||
|
|
||||||
|
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
||||||
|
const mockFetchSummary = vi.mocked(fetchDriverSummary)
|
||||||
|
|
||||||
|
const summary: DriverSummary = {
|
||||||
|
season: 2025,
|
||||||
|
driver_number: 1,
|
||||||
|
name_acronym: 'VER',
|
||||||
|
full_name: 'Max Verstappen',
|
||||||
|
team_name: 'Red Bull',
|
||||||
|
team_colour: '3671c6',
|
||||||
|
headshot_url: '',
|
||||||
|
points: 50,
|
||||||
|
position: 1,
|
||||||
|
wins: 2,
|
||||||
|
podiums: 2,
|
||||||
|
poles: 1,
|
||||||
|
form: [25, 25],
|
||||||
|
cumulative: [25, 50],
|
||||||
|
round_labels: ['R1', 'R2'],
|
||||||
|
rounds: [],
|
||||||
|
source: 'local',
|
||||||
|
enrichment: 'limited',
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage(props: { driverNumber: number; year?: number }) {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
const rootRoute = createRootRoute({
|
||||||
|
component: () => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<DriverProfilePage driverNumber={props.driverNumber} year={props.year} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
const indexRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/',
|
||||||
|
component: () => null,
|
||||||
|
})
|
||||||
|
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
|
||||||
|
return render(<RouterProvider router={router} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DriverProfilePage resilience', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockFetchSeasons.mockResolvedValue([2025])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves loading after a timeout error and offers retry', async () => {
|
||||||
|
mockFetchSummary.mockRejectedValue(
|
||||||
|
new ApiError('timeout', 'Request timed out', {
|
||||||
|
userMessage: 'This request took too long. Check your connection, then retry.',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage({ driverNumber: 1, year: 2025 })
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('driver-profile-error')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.queryByTestId('driver-profile-loading')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('driver-profile-error')).toHaveTextContent(/took too long/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dedupes concurrent retries via a single refetch gate', async () => {
|
||||||
|
let calls = 0
|
||||||
|
mockFetchSummary.mockImplementation(async () => {
|
||||||
|
calls += 1
|
||||||
|
if (calls === 1) {
|
||||||
|
throw new ApiError('http', 'boom', { status: 500 })
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
})
|
||||||
|
|
||||||
|
renderPage({ driverNumber: 1, year: 2025 })
|
||||||
|
await waitFor(() => expect(screen.getByTestId('driver-profile-error')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const retry = screen.getByRole('button', { name: 'Retry' })
|
||||||
|
fireEvent.click(retry)
|
||||||
|
fireEvent.click(retry)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('driver-profile')).toBeInTheDocument())
|
||||||
|
expect(calls).toBe(2)
|
||||||
|
expect(screen.getByTestId('driver-profile-limited')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -446,6 +446,10 @@ export interface DriverSummary {
|
|||||||
cumulative: number[]
|
cumulative: number[]
|
||||||
round_labels: string[]
|
round_labels: string[]
|
||||||
rounds: DriverSummaryRound[]
|
rounds: DriverSummaryRound[]
|
||||||
|
/** Data origin: local domain DB or OpenF1 fallback. */
|
||||||
|
source?: 'local' | 'openf1'
|
||||||
|
/** Optional remote enrichment status — limited means profile still renders. */
|
||||||
|
enrichment?: 'full' | 'limited' | 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Weekend Context ──
|
// ── Weekend Context ──
|
||||||
|
|||||||
@@ -10,9 +10,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// --- /api/v1/driver/summary ---
|
// --- /api/v1/driver/summary ---
|
||||||
// Per-driver season summary: championship standing plus per-round race results,
|
// Per-driver season summary: championship standing plus per-round race results.
|
||||||
// aggregated server-side from the same sources as the championship hub. Caching
|
// Current-season identity/results are local-first from the domain DB. Optional
|
||||||
// relies on the OpenF1 client's HTTP cache TTLs — no extra layer here.
|
// OpenF1 enrichment (headshot / polished identity) is bounded so it cannot hang
|
||||||
|
// the profile when remote data is slow or unavailable.
|
||||||
|
|
||||||
|
// driverEnrichmentTimeout bounds optional remote enrichment so a hung OpenF1
|
||||||
|
// call never blocks a local-first profile response. Overridable in tests.
|
||||||
|
var driverEnrichmentTimeout = 2 * time.Second
|
||||||
|
|
||||||
type driverSummaryRound struct {
|
type driverSummaryRound struct {
|
||||||
MeetingKey int `json:"meeting_key"`
|
MeetingKey int `json:"meeting_key"`
|
||||||
@@ -49,6 +54,11 @@ type driverSummaryResponse struct {
|
|||||||
Cumulative []float64 `json:"cumulative"`
|
Cumulative []float64 `json:"cumulative"`
|
||||||
RoundLabels []string `json:"round_labels"`
|
RoundLabels []string `json:"round_labels"`
|
||||||
Rounds []driverSummaryRound `json:"rounds"`
|
Rounds []driverSummaryRound `json:"rounds"`
|
||||||
|
// Source is "local" when served from the domain DB, else "openf1".
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
// Enrichment is "full" when optional remote identity landed, "limited"
|
||||||
|
// when it timed out/failed, or "none" when no enrichment was attempted.
|
||||||
|
Enrichment string `json:"enrichment,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -61,12 +71,92 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
if year == 0 {
|
if year == 0 {
|
||||||
year = time.Now().Year()
|
year = time.Now().Year()
|
||||||
}
|
}
|
||||||
|
mode := parseSourceMode(r)
|
||||||
|
// Driver summary is local-first for current-season identity/results. When the
|
||||||
|
// caller omits ?source=, prefer auto (local then OpenF1) rather than the
|
||||||
|
// package default of openf1-only.
|
||||||
|
if r.URL.Query().Get("source") == "" {
|
||||||
|
mode = sourceAuto
|
||||||
|
}
|
||||||
|
|
||||||
champ, err := s.client.GetDriverChampionshipForYear(year)
|
if mode == sourceLocal || mode == sourceAuto {
|
||||||
|
resp, sessionKey, ok, lerr := s.localDriverSummary(year, driverNumber)
|
||||||
|
if lerr != nil {
|
||||||
|
writeError(w, lerr, http.StatusInternalServerError, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
if mode != sourceLocal {
|
||||||
|
s.tryEnrichDriverSummary(&resp, sessionKey)
|
||||||
|
}
|
||||||
|
writeJSON(w, resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if mode == sourceLocal {
|
||||||
|
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.openF1DriverSummary(year, driverNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if resp == nil {
|
||||||
|
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) localDriverSummary(year, driverNumber int) (driverSummaryResponse, int, bool, error) {
|
||||||
|
if !s.hasLocalQuery() {
|
||||||
|
return driverSummaryResponse{}, 0, false, nil
|
||||||
|
}
|
||||||
|
inputs, err := s.query.GetChampionshipInputs(year)
|
||||||
|
if err != nil {
|
||||||
|
return driverSummaryResponse{}, 0, false, err
|
||||||
|
}
|
||||||
|
if len(inputs.Champ) == 0 {
|
||||||
|
return driverSummaryResponse{}, 0, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
races := make([]meetingRace, 0, len(inputs.Races))
|
||||||
|
for _, race := range inputs.Races {
|
||||||
|
races = append(races, meetingRace{
|
||||||
|
Meeting: race.Meeting,
|
||||||
|
RaceSessionKey: race.RaceSessionKey,
|
||||||
|
Results: race.Results,
|
||||||
|
Grid: race.Grid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, ok := aggregateDriverSummary(year, driverNumber, races, inputs.Champ, inputs.DriverMap)
|
||||||
|
if !ok {
|
||||||
|
return driverSummaryResponse{}, 0, false, nil
|
||||||
|
}
|
||||||
|
resp.Source = "local"
|
||||||
|
resp.Enrichment = "none"
|
||||||
|
|
||||||
|
sessionKey := 0
|
||||||
|
for _, c := range inputs.Champ {
|
||||||
|
if c.DriverNumber == driverNumber && c.SessionKey > 0 {
|
||||||
|
sessionKey = c.SessionKey
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if sessionKey == 0 && c.SessionKey > 0 {
|
||||||
|
sessionKey = c.SessionKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp, sessionKey, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) openF1DriverSummary(year, driverNumber int) (*driverSummaryResponse, error) {
|
||||||
|
champ, err := s.client.GetDriverChampionshipForYear(year)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
var entry *models.ChampionshipDriver
|
var entry *models.ChampionshipDriver
|
||||||
for i := range champ {
|
for i := range champ {
|
||||||
if champ[i].DriverNumber == driverNumber {
|
if champ[i].DriverNumber == driverNumber {
|
||||||
@@ -75,12 +165,12 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if entry == nil {
|
if entry == nil {
|
||||||
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
|
return nil, nil
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
driverInfo := map[int]models.Driver{}
|
driverInfo := map[int]models.Driver{}
|
||||||
if ds, derr := s.client.GetDriversForSession(champ[0].SessionKey); derr == nil {
|
sessionKey := champ[0].SessionKey
|
||||||
|
if ds, derr := s.client.GetDriversForSession(sessionKey); derr == nil {
|
||||||
driverInfo = buildDriverMapFirst(ds)
|
driverInfo = buildDriverMapFirst(ds)
|
||||||
}
|
}
|
||||||
if d, ok := s.championshipDriverInfo(entry.SessionKey, driverNumber, driverInfo); ok {
|
if d, ok := s.championshipDriverInfo(entry.SessionKey, driverNumber, driverInfo); ok {
|
||||||
@@ -89,16 +179,69 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
races, _, err := s.fetchSeasonRaces(year)
|
races, _, err := s.fetchSeasonRaces(year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
return nil, err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, ok := aggregateDriverSummary(year, driverNumber, races, champ, driverInfo)
|
resp, ok := aggregateDriverSummary(year, driverNumber, races, champ, driverInfo)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
|
return nil, nil
|
||||||
|
}
|
||||||
|
resp.Source = "openf1"
|
||||||
|
resp.Enrichment = "full"
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
if resp != nil && resp.Enrichment == "none" {
|
||||||
|
// No session to enrich from — leave as none (local identity only).
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, resp)
|
|
||||||
|
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 {
|
||||||
|
resp.Enrichment = "limited"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applyDriverEnrichment(resp, result.driver)
|
||||||
|
resp.Enrichment = "full"
|
||||||
|
case <-time.After(driverEnrichmentTimeout):
|
||||||
|
resp.Enrichment = "limited"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDriverEnrichment(resp *driverSummaryResponse, d models.Driver) {
|
||||||
|
if d.HeadshotURL != "" {
|
||||||
|
resp.HeadshotURL = d.HeadshotURL
|
||||||
|
}
|
||||||
|
if d.FullName != "" {
|
||||||
|
resp.FullName = d.FullName
|
||||||
|
}
|
||||||
|
if d.NameAcronym != "" {
|
||||||
|
resp.NameAcronym = d.NameAcronym
|
||||||
|
}
|
||||||
|
if d.TeamName != "" {
|
||||||
|
resp.TeamName = d.TeamName
|
||||||
|
}
|
||||||
|
if d.TeamColour != "" {
|
||||||
|
resp.TeamColour = d.TeamColour
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// aggregateDriverSummary is the pure aggregation core (no network) so it can be
|
// aggregateDriverSummary is the pure aggregation core (no network) so it can be
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/api"
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func driverSummaryFixtures() ([]meetingRace, []models.ChampionshipDriver, map[int]models.Driver) {
|
func driverSummaryFixtures() ([]meetingRace, []models.ChampionshipDriver, map[int]models.Driver) {
|
||||||
@@ -134,3 +138,179 @@ func TestHandleDriverSummaryBadRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func seedDriverSummaryStore(t *testing.T, st *store.Store) {
|
||||||
|
t.Helper()
|
||||||
|
meetingKey := 1201
|
||||||
|
sessionKey := 9901
|
||||||
|
|
||||||
|
if err := st.UpsertMeeting(store.Meeting{
|
||||||
|
MeetingKey: meetingKey,
|
||||||
|
MeetingName: "Bahrain GP",
|
||||||
|
CountryCode: "BHR",
|
||||||
|
CountryName: "Bahrain",
|
||||||
|
Year: 2025,
|
||||||
|
DateStart: "2025-03-02",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpsertMeeting: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertSession(store.Session{
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
MeetingKey: meetingKey,
|
||||||
|
SessionName: "Race",
|
||||||
|
SessionType: "Race",
|
||||||
|
DateStart: "2025-03-02T15:00:00Z",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpsertSession: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertDriver(store.Driver{
|
||||||
|
DriverNumber: 1,
|
||||||
|
FullName: "Max Verstappen",
|
||||||
|
NameAcronym: "VER",
|
||||||
|
TeamName: "Red Bull",
|
||||||
|
TeamColour: "3671c6",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpsertDriver: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertSessionDriver(store.SessionDriver{
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
DriverNumber: 1,
|
||||||
|
MeetingKey: meetingKey,
|
||||||
|
FullName: "Max Verstappen",
|
||||||
|
NameAcronym: "VER",
|
||||||
|
TeamName: "Red Bull",
|
||||||
|
TeamColour: "3671c6",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpsertSessionDriver: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertSessionResult(store.SessionResult{
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
DriverNumber: 1,
|
||||||
|
MeetingKey: meetingKey,
|
||||||
|
Position: 1,
|
||||||
|
Points: 25,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpsertSessionResult: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertStartingGridEntry(store.StartingGridEntry{
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
DriverNumber: 1,
|
||||||
|
MeetingKey: meetingKey,
|
||||||
|
Position: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpsertStartingGridEntry: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleDriverSummaryLocalFirstIgnoresHangingEnrichment(t *testing.T) {
|
||||||
|
prev := driverEnrichmentTimeout
|
||||||
|
driverEnrichmentTimeout = 40 * time.Millisecond
|
||||||
|
t.Cleanup(func() { driverEnrichmentTimeout = prev })
|
||||||
|
|
||||||
|
st := openTestStore(t)
|
||||||
|
seedDriverSummaryStore(t, st)
|
||||||
|
|
||||||
|
// Enrichment seam: OpenF1 hangs until released. Local summary must still return.
|
||||||
|
release := make(chan struct{})
|
||||||
|
hang := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
select {
|
||||||
|
case <-release:
|
||||||
|
case <-r.Context().Done():
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(func() {
|
||||||
|
close(release)
|
||||||
|
hang.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
client := api.NewOpenF1Client(hang.URL, 15*time.Second)
|
||||||
|
t.Cleanup(func() { _ = client.Close() })
|
||||||
|
srv := NewServer(client, 8080, st)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/driver/summary?year=2025&driver_number=1", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.handleDriverSummary(rec, req)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if elapsed > 500*time.Millisecond {
|
||||||
|
t.Fatalf("handler blocked on enrichment for %v", elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp driverSummaryResponse
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Source != "local" {
|
||||||
|
t.Errorf("source = %q, want local", resp.Source)
|
||||||
|
}
|
||||||
|
if resp.Enrichment != "limited" {
|
||||||
|
t.Errorf("enrichment = %q, want limited", resp.Enrichment)
|
||||||
|
}
|
||||||
|
if resp.DriverNumber != 1 || resp.NameAcronym != "VER" || resp.Points != 25 {
|
||||||
|
t.Errorf("local identity/results missing: %+v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleDriverSummaryLocalFirstWithFailingEnrichment(t *testing.T) {
|
||||||
|
prev := driverEnrichmentTimeout
|
||||||
|
driverEnrichmentTimeout = 200 * time.Millisecond
|
||||||
|
t.Cleanup(func() { driverEnrichmentTimeout = prev })
|
||||||
|
|
||||||
|
st := openTestStore(t)
|
||||||
|
seedDriverSummaryStore(t, st)
|
||||||
|
|
||||||
|
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "boom", http.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
t.Cleanup(fail.Close)
|
||||||
|
|
||||||
|
client := api.NewOpenF1Client(fail.URL, 2*time.Second)
|
||||||
|
t.Cleanup(func() { _ = client.Close() })
|
||||||
|
srv := NewServer(client, 8080, st)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/driver/summary?year=2025&driver_number=1&source=auto", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.handleDriverSummary(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp driverSummaryResponse
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Source != "local" {
|
||||||
|
t.Errorf("source = %q, want local", resp.Source)
|
||||||
|
}
|
||||||
|
if resp.Enrichment != "limited" {
|
||||||
|
t.Errorf("enrichment = %q, want limited", resp.Enrichment)
|
||||||
|
}
|
||||||
|
if resp.FullName != "Max Verstappen" {
|
||||||
|
t.Errorf("full_name = %q, want local identity", resp.FullName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleDriverSummarySourceLocalOnly(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
seedDriverSummaryStore(t, st)
|
||||||
|
|
||||||
|
// Even with a broken OpenF1 client, source=local must succeed from the DB.
|
||||||
|
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "nope", http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
t.Cleanup(fail.Close)
|
||||||
|
client := api.NewOpenF1Client(fail.URL, time.Second)
|
||||||
|
t.Cleanup(func() { _ = client.Close() })
|
||||||
|
srv := NewServer(client, 8080, st)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/driver/summary?year=2025&driver_number=1&source=local", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.handleDriverSummary(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ const raceSnapshot = {
|
|||||||
SessionType: 'Race',
|
SessionType: 'Race',
|
||||||
SessionName: 'Race',
|
SessionName: 'Race',
|
||||||
},
|
},
|
||||||
|
SessionStatus: 'Started',
|
||||||
TrackStatus: '2',
|
TrackStatus: '2',
|
||||||
CurrentLap: 30,
|
CurrentLap: 30,
|
||||||
TotalLaps: 57,
|
TotalLaps: 57,
|
||||||
@@ -138,6 +139,7 @@ const sprintQualifyingSnapshot = {
|
|||||||
SessionType: 'Sprint Qualifying',
|
SessionType: 'Sprint Qualifying',
|
||||||
SessionName: 'Sprint Qualifying',
|
SessionName: 'Sprint Qualifying',
|
||||||
},
|
},
|
||||||
|
SessionStatus: 'Started',
|
||||||
TrackStatus: '1',
|
TrackStatus: '1',
|
||||||
CurrentLap: 0,
|
CurrentLap: 0,
|
||||||
TotalLaps: 0,
|
TotalLaps: 0,
|
||||||
@@ -164,6 +166,12 @@ test.describe('Live Timing (mocked snapshot)', () => {
|
|||||||
|
|
||||||
test('renders timing tower with drivers from the snapshot', async ({ page }) => {
|
test('renders timing tower with drivers from the snapshot', async ({ page }) => {
|
||||||
await expect(page.getByTestId('live-page')).toBeVisible()
|
await expect(page.getByTestId('live-page')).toBeVisible()
|
||||||
|
// The hermetic SSE mock ends after a heartbeat, so transport may drop to
|
||||||
|
// disconnected while the session snapshot remains active — both phases keep
|
||||||
|
// the LIVE SESSION chrome and timing tower.
|
||||||
|
await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', /^(live|disconnected)$/)
|
||||||
|
await expect(page.getByTestId('live-session-flag')).toContainText('LIVE SESSION')
|
||||||
|
await expect(page.getByTestId('live-feed-health')).toBeVisible()
|
||||||
const tower = page.locator('.live-tower')
|
const tower = page.locator('.live-tower')
|
||||||
await expect(tower).toBeVisible()
|
await expect(tower).toBeVisible()
|
||||||
await expect(tower).toContainText('VER')
|
await expect(tower).toContainText('VER')
|
||||||
@@ -233,45 +241,215 @@ test.describe('Live Timing (mocked Sprint Qualifying)', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const weekendContext = (localAnalysis: string) => ({
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
// Just-finished session lives in previous_completed_session (canonical contract).
|
||||||
|
previous_completed_session: {
|
||||||
|
session: {
|
||||||
|
session_key: 9472,
|
||||||
|
session_name: 'Race',
|
||||||
|
session_type: 'Race',
|
||||||
|
meeting_key: 1,
|
||||||
|
date_start: '2026-07-05T14:00:00Z',
|
||||||
|
date_end: '2026-07-05T16:00:00Z',
|
||||||
|
gmt_offset: '',
|
||||||
|
},
|
||||||
|
availability: {
|
||||||
|
schedule: 'available',
|
||||||
|
live_transport: 'unknown',
|
||||||
|
live_session: 'inactive',
|
||||||
|
archive: 'available',
|
||||||
|
local_analysis: localAnalysis,
|
||||||
|
freshness: 'fresh',
|
||||||
|
limitations: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
championship_round: 1,
|
||||||
|
total_championship_rounds: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Archive-only just-finished Race + older already-ready Practice default. */
|
||||||
|
const archiveOnlySettlingContext = (previousAnalysis: string) => ({
|
||||||
|
...weekendContext(previousAnalysis),
|
||||||
|
default_analysis_session: {
|
||||||
|
session: {
|
||||||
|
session_key: 9001,
|
||||||
|
session_name: 'Practice 1',
|
||||||
|
session_type: 'Practice',
|
||||||
|
meeting_key: 1,
|
||||||
|
date_start: '2026-07-04T12:00:00Z',
|
||||||
|
date_end: '2026-07-04T13:00:00Z',
|
||||||
|
gmt_offset: '',
|
||||||
|
},
|
||||||
|
availability: {
|
||||||
|
schedule: 'available',
|
||||||
|
live_transport: 'unknown',
|
||||||
|
live_session: 'inactive',
|
||||||
|
archive: 'available',
|
||||||
|
local_analysis: 'complete',
|
||||||
|
freshness: 'fresh',
|
||||||
|
limitations: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const heartbeatStream = (route: import('@playwright/test').Route) =>
|
||||||
|
route.fulfill({ contentType: 'text/event-stream', body: 'event: heartbeat\ndata: {}\n\n' })
|
||||||
|
|
||||||
test.describe('Live Timing (no session)', () => {
|
test.describe('Live Timing (no session)', () => {
|
||||||
test('shows the empty state when the feed has no snapshot', async ({ page }) => {
|
test('shows the inactive weekend-context handoff when the feed has no snapshot', async ({ page }) => {
|
||||||
|
await page.route('**/api/v1/live/stream', heartbeatStream)
|
||||||
await page.goto('/live')
|
await page.goto('/live')
|
||||||
await expect(page.getByTestId('live-empty')).toBeVisible()
|
// The e2e server (BOXBOX_DISABLE_LIVE=1) exposes a real /api/v1/weekend-context.
|
||||||
await expect(page.getByTestId('live-page')).toContainText('No live session active')
|
await expect(page.getByTestId('live-inactive')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', 'inactive')
|
||||||
|
await expect(page.getByText('NO LIVE SESSION')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('renders an archived snapshot only after View Last Session', async ({ page }) => {
|
test('enters settling (not archive) when a finished session is retained', async ({ page }) => {
|
||||||
await page.route('**/api/v1/live/state', (route) =>
|
await page.route('**/api/v1/live/state', (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
is_live: false,
|
is_live: false,
|
||||||
data: null,
|
data: null,
|
||||||
last_snapshot: {
|
last_snapshot: { ...raceSnapshot.data, SessionStatus: 'Finished' },
|
||||||
...raceSnapshot.data,
|
last_positions: { '1': { x: 100, y: -50, z: 2, status: 'OnTrack' } },
|
||||||
SessionStatus: 'Finished',
|
|
||||||
},
|
|
||||||
last_positions: {
|
|
||||||
'1': { x: 100, y: -50, z: 2, status: 'OnTrack' },
|
|
||||||
},
|
|
||||||
last_snapshot_at: '2026-07-04T14:00:00Z',
|
last_snapshot_at: '2026-07-04T14:00:00Z',
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.route('**/api/v1/live/stream', (route) =>
|
await page.route('**/api/v1/weekend-context', (route) =>
|
||||||
route.fulfill({
|
route.fulfill({ contentType: 'application/json', body: JSON.stringify(weekendContext('complete')) }),
|
||||||
contentType: 'text/event-stream',
|
|
||||||
body: 'event: heartbeat\ndata: {}\n\n',
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
await page.route('**/api/v1/live/stream', heartbeatStream)
|
||||||
|
|
||||||
await page.goto('/live')
|
await page.goto('/live')
|
||||||
await expect(page.getByTestId('live-empty')).toContainText('No live session active')
|
await expect(page.getByTestId('live-settling')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', 'settling')
|
||||||
|
await expect(page.getByText('SESSION SETTLING')).toBeVisible()
|
||||||
await expect(page.getByText('Timing Tower')).toHaveCount(0)
|
await expect(page.getByText('Timing Tower')).toHaveCount(0)
|
||||||
|
// Canonical weekend-context drives the analysis target + readiness.
|
||||||
|
const analysis = page.getByTestId('live-handoff-analysis')
|
||||||
|
await expect(analysis).toHaveAttribute('href', '/race-hub?session_key=9472')
|
||||||
|
await expect(analysis).toHaveAttribute('data-ready', 'true')
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'View Last Session' }).click()
|
// Opening the read-only archive reveals the frozen tower, never LIVE chrome.
|
||||||
await expect(page.getByTestId('live-archive-strip')).toContainText('Archived snapshot')
|
await page.getByTestId('live-handoff-archive').click()
|
||||||
|
await expect(page.getByTestId('live-archive-strip')).toContainText('read-only')
|
||||||
await expect(page.getByText('Timing Tower')).toBeVisible()
|
await expect(page.getByText('Timing Tower')).toBeVisible()
|
||||||
await expect(page.locator('.live-state')).toContainText('archive')
|
await expect(page.locator('.live-state')).toContainText('archive')
|
||||||
|
await expect(page.getByText('LIVE SESSION')).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('settling shows pending analysis until weekend-context reports complete', async ({ page }) => {
|
||||||
|
await page.route('**/api/v1/live/state', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
is_live: false,
|
||||||
|
data: null,
|
||||||
|
last_snapshot: { ...raceSnapshot.data, SessionStatus: 'Finished' },
|
||||||
|
last_snapshot_at: '2026-07-04T14:00:00Z',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.route('**/api/v1/weekend-context', (route) =>
|
||||||
|
route.fulfill({ contentType: 'application/json', body: JSON.stringify(weekendContext('pending')) }),
|
||||||
|
)
|
||||||
|
await page.route('**/api/v1/live/stream', heartbeatStream)
|
||||||
|
|
||||||
|
await page.goto('/live')
|
||||||
|
await expect(page.getByTestId('live-settling')).toBeVisible()
|
||||||
|
const analysis = page.getByTestId('live-handoff-analysis')
|
||||||
|
await expect(analysis).toHaveAttribute('data-ready', 'false')
|
||||||
|
await expect(analysis).toContainText(/analysis will fill in as data ingests/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('settling targets archive-only previous_completed_session over older default', async ({ page }) => {
|
||||||
|
await page.route('**/api/v1/live/state', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
is_live: false,
|
||||||
|
data: null,
|
||||||
|
last_snapshot: { ...raceSnapshot.data, SessionStatus: 'Finished' },
|
||||||
|
last_snapshot_at: '2026-07-04T14:00:00Z',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.route('**/api/v1/weekend-context', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(archiveOnlySettlingContext('pending')),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.route('**/api/v1/live/stream', heartbeatStream)
|
||||||
|
|
||||||
|
await page.goto('/live')
|
||||||
|
await expect(page.getByTestId('live-settling')).toBeVisible()
|
||||||
|
const analysis = page.getByTestId('live-handoff-analysis')
|
||||||
|
await expect(analysis).toHaveAttribute('href', '/race-hub?session_key=9472')
|
||||||
|
await expect(analysis).toHaveAttribute('data-ready', 'false')
|
||||||
|
await expect(analysis).toContainText('Open Race analysis')
|
||||||
|
await expect(analysis).not.toContainText('Practice 1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('retains the last live snapshot with a disconnected warning on a feed drop', async ({ page }) => {
|
||||||
|
// is_live=false but SessionStatus is still "Started": the FIA feed dropped
|
||||||
|
// mid-session. The page must warn + retain the live tower, never settle.
|
||||||
|
// Keep the browser SSE open via a long-lived stream so transport stays connected
|
||||||
|
// while phase is disconnected — health must still say Reconnecting.
|
||||||
|
await page.route('**/api/v1/live/state', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
is_live: false,
|
||||||
|
data: null,
|
||||||
|
last_snapshot: { ...raceSnapshot.data, SessionStatus: 'Started' },
|
||||||
|
last_snapshot_at: '2026-07-04T13:30:00Z',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.route('**/api/v1/weekend-context', (route) =>
|
||||||
|
route.fulfill({ contentType: 'application/json', body: JSON.stringify(weekendContext('pending')) }),
|
||||||
|
)
|
||||||
|
await page.route('**/api/v1/live/stream', async (route) => {
|
||||||
|
const body = [
|
||||||
|
'event: heartbeat',
|
||||||
|
'data: {}',
|
||||||
|
'',
|
||||||
|
'event: heartbeat',
|
||||||
|
'data: {}',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
await route.fulfill({
|
||||||
|
contentType: 'text/event-stream',
|
||||||
|
headers: { 'Cache-Control': 'no-cache' },
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto('/live')
|
||||||
|
await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', 'disconnected')
|
||||||
|
await expect(page.getByTestId('live-disconnected-strip')).toContainText('last live data')
|
||||||
|
await expect(page.getByText('Timing Tower')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('live-settling')).toHaveCount(0)
|
||||||
|
await expect(page.getByTestId('live-archive-strip')).toHaveCount(0)
|
||||||
|
await expect(page.getByTestId('live-feed-health')).toContainText(/reconnecting/i)
|
||||||
|
await expect(page.getByTestId('live-feed-health')).not.toContainText(/feed healthy/i)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,8 +18,15 @@ test.describe('Production serving (Go + built React)', () => {
|
|||||||
await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible()
|
await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible()
|
||||||
|
|
||||||
await page.getByRole('tab', { name: 'Race Story' }).click()
|
await page.getByRole('tab', { name: 'Race Story' }).click()
|
||||||
await expect(page.getByText('Final Classification')).toBeVisible()
|
|
||||||
await expect(page.locator('.drv-code', { hasText: 'VER' })).toBeVisible()
|
const outcome = page.getByRole('region', { name: 'Final running order' })
|
||||||
|
await expect(outcome).toBeVisible()
|
||||||
|
await expect(outcome).toHaveAttribute('data-testid', 'race-story-outcome')
|
||||||
|
|
||||||
|
const verRow = page.locator('.rs-driver-row', {
|
||||||
|
has: page.locator('.rs-driver-name', { hasText: 'VER' }),
|
||||||
|
})
|
||||||
|
await expect(verRow.locator('.rs-pos-col')).toHaveText('1')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('serves admin / data health route', async ({ page }) => {
|
test('serves admin / data health route', async ({ page }) => {
|
||||||
@@ -37,11 +44,11 @@ test.describe('Production serving (Go + built React)', () => {
|
|||||||
await expect(page.getByTestId('dl-meeting-1229')).toBeVisible()
|
await expect(page.getByTestId('dl-meeting-1229')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('serves live route with empty state when live is disabled', async ({ page }) => {
|
test('serves live route with the inactive handoff when live is disabled', async ({ page }) => {
|
||||||
await page.goto('/live')
|
await page.goto('/live')
|
||||||
|
|
||||||
await expect(page.getByTestId('live-empty')).toBeVisible()
|
await expect(page.getByTestId('live-inactive')).toBeVisible()
|
||||||
await expect(page.getByText('No live session active')).toBeVisible()
|
await expect(page.getByText('NO LIVE SESSION')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('primary nav links and Admin utility work from built SPA', async ({ page }) => {
|
test('primary nav links and Admin utility work from built SPA', async ({ page }) => {
|
||||||
|
|||||||
42
tests/route-resilience.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Primary-route resilience (forced errors)', () => {
|
||||||
|
test('Weekend leaves loading and shows retry after weekend-context failure', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.route('**/api/v1/weekend-context', async (route) => {
|
||||||
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
await route.fulfill({
|
||||||
|
status: 503,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: 'forced weekend-context failure', stale: false }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.getByTestId('weekend-error')).toBeVisible({ timeout: 15_000 })
|
||||||
|
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible()
|
||||||
|
await expect(page.getByTestId('weekend-error')).not.toHaveText(/API 503|forced weekend-context/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Driver Profile leaves loading and shows retry after summary timeout', async ({ page }) => {
|
||||||
|
await page.route('**/api/v1/driver/summary**', async (route) => {
|
||||||
|
// Hold the connection past the UI wait without fulfilling — the browser
|
||||||
|
// abort/timeout path is covered in unit tests; here we force an HTTP error
|
||||||
|
// so the shared RouteState recovery UI is exercised hermetically.
|
||||||
|
await route.fulfill({
|
||||||
|
status: 504,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: 'forced timeout', stale: false }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await page.route('**/api/v1/seasons', (route) =>
|
||||||
|
route.fulfill({ contentType: 'application/json', body: JSON.stringify([2025]) }),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto('/drivers/1?year=2025')
|
||||||
|
await expect(page.getByTestId('driver-profile-error')).toBeVisible({ timeout: 15_000 })
|
||||||
|
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible()
|
||||||
|
await expect(page.getByTestId('driver-profile-error')).not.toHaveText(/API 504/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
BIN
tests/visual/__snapshots__/desktop/live-active.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 56 KiB |
BIN
tests/visual/__snapshots__/mobile/live-active.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 34 KiB |
BIN
tests/visual/__snapshots__/tablet/live-active.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 45 KiB |
@@ -62,10 +62,162 @@ export async function gotoDataLibraryReady(page: Page): Promise<void> {
|
|||||||
await waitForScreenshotReady(page)
|
await waitForScreenshotReady(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function gotoLiveEmptyReady(page: Page): Promise<void> {
|
export async function gotoLiveInactiveReady(page: Page): Promise<void> {
|
||||||
await page.goto('/live')
|
await page.goto('/live')
|
||||||
await expect(page.locator('.loading-state')).toHaveCount(0)
|
await expect(page.locator('.loading-state')).toHaveCount(0)
|
||||||
await expect(page.getByTestId('live-empty')).toBeVisible()
|
// With BOXBOX_DISABLE_LIVE=1 the feed is silent, so the page settles into the
|
||||||
|
// inactive weekend-context handoff sourced from /api/v1/weekend-context.
|
||||||
|
await expect(page.getByTestId('live-inactive')).toBeVisible()
|
||||||
|
// Integrated #73 shell — stale Command/Live/Race Hub baselines must not pass.
|
||||||
|
await expect(page.getByRole('navigation')).toContainText('Weekend')
|
||||||
|
await expect(page.getByRole('navigation')).not.toContainText('Command')
|
||||||
|
await waitForScreenshotReady(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic active Live hierarchy (mocked snapshot + sticky SSE). */
|
||||||
|
export async function gotoLiveActiveReady(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.localStorage.clear()
|
||||||
|
class StickyEventSource {
|
||||||
|
onopen: ((ev: Event) => void) | null = null
|
||||||
|
onerror: ((ev: Event) => void) | null = null
|
||||||
|
constructor(_url: string | URL) {
|
||||||
|
queueMicrotask(() => this.onopen?.(new Event('open')))
|
||||||
|
}
|
||||||
|
addEventListener(_type: string, _listener: EventListenerOrEventListenerObject) {}
|
||||||
|
close() {}
|
||||||
|
}
|
||||||
|
Object.defineProperty(window, 'EventSource', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: StickyEventSource,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const driver = (
|
||||||
|
num: string,
|
||||||
|
pos: number,
|
||||||
|
interval: string,
|
||||||
|
gap: string,
|
||||||
|
overrides: Record<string, unknown> = {},
|
||||||
|
) => ({
|
||||||
|
RacingNumber: num,
|
||||||
|
Position: pos,
|
||||||
|
PrevPosition: pos,
|
||||||
|
GapToLeader: gap,
|
||||||
|
Interval: interval,
|
||||||
|
LastLapTime: '1:21.345',
|
||||||
|
LastLapPB: false,
|
||||||
|
LastLapOB: false,
|
||||||
|
BestLapTime: '1:20.987',
|
||||||
|
BestLapPB: true,
|
||||||
|
BestLapOB: false,
|
||||||
|
BestLapNum: 22,
|
||||||
|
InPit: false,
|
||||||
|
PitOut: false,
|
||||||
|
Retired: false,
|
||||||
|
KnockedOut: false,
|
||||||
|
Cutoff: false,
|
||||||
|
OnFlyingLap: false,
|
||||||
|
NumberOfLaps: 30,
|
||||||
|
SpeedTrap: '312',
|
||||||
|
Sectors: [],
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
const info = (num: string, tla: string, first: string, last: string, team: string, colour: string) => ({
|
||||||
|
RacingNumber: num,
|
||||||
|
BroadcastName: `${first[0]} ${last.toUpperCase()}`,
|
||||||
|
Tla: tla,
|
||||||
|
TeamName: team,
|
||||||
|
TeamColour: colour,
|
||||||
|
FirstName: first,
|
||||||
|
LastName: last,
|
||||||
|
})
|
||||||
|
|
||||||
|
const liveState = {
|
||||||
|
is_live: true,
|
||||||
|
data: {
|
||||||
|
Drivers: {
|
||||||
|
'1': driver('1', 1, '', ''),
|
||||||
|
'4': driver('4', 2, '+0.523', '+0.523'),
|
||||||
|
'44': driver('44', 3, '+3.214', '+3.737'),
|
||||||
|
'63': driver('63', 4, '+12.001', '+15.738', { InPit: true }),
|
||||||
|
},
|
||||||
|
DriverInfo: {
|
||||||
|
'1': info('1', 'VER', 'Max', 'Verstappen', 'Red Bull Racing', '3671C6'),
|
||||||
|
'4': info('4', 'NOR', 'Lando', 'Norris', 'McLaren', 'FF8000'),
|
||||||
|
'44': info('44', 'HAM', 'Lewis', 'Hamilton', 'Ferrari', 'E80020'),
|
||||||
|
'63': info('63', 'RUS', 'George', 'Russell', 'Mercedes', '27F4D2'),
|
||||||
|
},
|
||||||
|
Tyres: {
|
||||||
|
'1': { Compound: 'HARD', New: false, Age: 12 },
|
||||||
|
'4': { Compound: 'MEDIUM', New: false, Age: 8 },
|
||||||
|
'44': { Compound: 'MEDIUM', New: true, Age: 3 },
|
||||||
|
'63': { Compound: 'HARD', New: true, Age: 0 },
|
||||||
|
},
|
||||||
|
Stints: {
|
||||||
|
'1': [
|
||||||
|
{ Compound: 'MEDIUM', New: true, Laps: 18 },
|
||||||
|
{ Compound: 'HARD', New: false, Laps: 12 },
|
||||||
|
],
|
||||||
|
'4': [
|
||||||
|
{ Compound: 'SOFT', New: true, Laps: 14 },
|
||||||
|
{ Compound: 'MEDIUM', New: true, Laps: 16 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
RCMessages: [
|
||||||
|
{
|
||||||
|
Time: '2026-07-03T14:05:00Z',
|
||||||
|
Category: 'Flag',
|
||||||
|
Flag: 'YELLOW',
|
||||||
|
Message: 'YELLOW IN SECTOR 2',
|
||||||
|
Lap: 29,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Weather: {
|
||||||
|
AirTemp: 22.5,
|
||||||
|
TrackTemp: 41.3,
|
||||||
|
Humidity: 58,
|
||||||
|
WindSpeed: 3.4,
|
||||||
|
WindDir: 180,
|
||||||
|
Rainfall: false,
|
||||||
|
},
|
||||||
|
Session: {
|
||||||
|
MeetingName: 'Testonia Grand Prix',
|
||||||
|
CircuitName: 'Testring',
|
||||||
|
SessionType: 'Race',
|
||||||
|
SessionName: 'Race',
|
||||||
|
Path: '',
|
||||||
|
},
|
||||||
|
TeamRadio: [],
|
||||||
|
SessionStatus: 'Started',
|
||||||
|
TrackStatus: '2',
|
||||||
|
CurrentLap: 30,
|
||||||
|
TotalLaps: 57,
|
||||||
|
// Fixed empty clock → "--:--:--" (no live extrapolation drift).
|
||||||
|
Clock: '',
|
||||||
|
ClockRefTime: '',
|
||||||
|
ClockExtrapolating: false,
|
||||||
|
Telemetry: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.route('**/api/v1/live/state', (route) =>
|
||||||
|
route.fulfill({ contentType: 'application/json', body: JSON.stringify(liveState) }),
|
||||||
|
)
|
||||||
|
await page.route('**/api/v1/live/stream', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'text/event-stream',
|
||||||
|
body: 'event: heartbeat\ndata: {}\n\n',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto('/live')
|
||||||
|
await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', 'live')
|
||||||
|
await expect(page.getByTestId('live-session-flag')).toContainText('LIVE SESSION')
|
||||||
|
await expect(page.getByText('Timing Tower')).toBeVisible()
|
||||||
|
await expect(page.getByRole('navigation')).toContainText('Weekend')
|
||||||
|
await expect(page.getByRole('navigation')).not.toContainText('Command')
|
||||||
await waitForScreenshotReady(page)
|
await waitForScreenshotReady(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
9
tests/visual/live-active.spec.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { test } from '@playwright/test'
|
||||||
|
import { gotoLiveActiveReady, screenshotPage } from './helpers'
|
||||||
|
|
||||||
|
test.describe('Live active visual regression', () => {
|
||||||
|
test('live-active', async ({ page }) => {
|
||||||
|
await gotoLiveActiveReady(page)
|
||||||
|
await screenshotPage(page, 'live-active')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,7 +2,7 @@ import { test } from '@playwright/test'
|
|||||||
import {
|
import {
|
||||||
gotoWeekendReady,
|
gotoWeekendReady,
|
||||||
gotoDataLibraryReady,
|
gotoDataLibraryReady,
|
||||||
gotoLiveEmptyReady,
|
gotoLiveInactiveReady,
|
||||||
gotoRaceHubFutureReady,
|
gotoRaceHubFutureReady,
|
||||||
gotoRaceHubReady,
|
gotoRaceHubReady,
|
||||||
screenshotPage,
|
screenshotPage,
|
||||||
@@ -32,8 +32,8 @@ test.describe('MVP visual regression', () => {
|
|||||||
await screenshotPage(page, 'data-library')
|
await screenshotPage(page, 'data-library')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('live-empty', async ({ page }) => {
|
test('live-inactive', async ({ page }) => {
|
||||||
await gotoLiveEmptyReady(page)
|
await gotoLiveInactiveReady(page)
|
||||||
await screenshotPage(page, 'live')
|
await screenshotPage(page, 'live')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ test.describe('Weekend home (seeded canonical context)', () => {
|
|||||||
await expect(page.getByTestId('data-library')).toBeVisible()
|
await expect(page.getByTestId('data-library')).toBeVisible()
|
||||||
|
|
||||||
await page.goto('/live')
|
await page.goto('/live')
|
||||||
await expect(page.getByTestId('live-empty')).toBeVisible()
|
await expect(page.getByTestId('live-inactive')).toBeVisible()
|
||||||
|
|
||||||
await page.goto('/explore')
|
await page.goto('/explore')
|
||||||
await expect(page.getByTestId('explore-page')).toBeVisible()
|
await expect(page.getByTestId('explore-page')).toBeVisible()
|
||||||
|
|||||||