Compare commits

..

4 Commits

Author SHA1 Message Date
AmanTahiliani
255b296ecc fix(live): merge stint deltas instead of replacing stint history
The F1 feed sends TimingAppData stints as sparse deltas keyed by stint
index — a mid-stint update is just {"1": {"TotalLaps": 14}}. The parser
replaced the whole stint slice with whatever a delta carried, so pit
history collapsed to a single entry and tyre age was pinned near zero for
the entire race. A partial delta was also dropped outright, because the
parser required a Compound field that mid-stint updates do not send.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 10:16:38 -04:00
Aman Tahiliani
c475011c49 Merge pull request #88 from AmanTahiliani/feat/issue-87-live-correct-fp-q-timing-truth-hierarchy
[Live] Correct FP/Q timing truth, hierarchy, and mobile navigation (#87)
2026-07-17 12:08:45 -04:00
AmanTahiliani
5a6323d3b4 fix(live): keep red-flagged sessions active 2026-07-17 12:04:13 -04:00
AmanTahiliani
ed3b8cf628 feat(#87): [Live] Correct FP/Q timing truth, hierarchy, and mobile navigation
Implemented by claude via .agents/dev dispatch.
2026-07-17 11:53:38 -04:00
98 changed files with 1940 additions and 7691 deletions

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -15,7 +15,6 @@ import type {
Session, Session,
TrackOutline, TrackOutline,
Weekend, Weekend,
WeekendContext,
} from './types' } from './types'
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> { export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
@@ -115,20 +114,6 @@ export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
return res.json() return res.json()
} }
// fetchWeekendContext consumes the canonical /api/v1/weekend-context endpoint
// (backend story #72). The response is the authoritative WeekendContext shape and
// is used verbatim as the Weekend home's source of truth. Any HTTP error throws
// so the hook can surface an explicit error state; there is no client-side
// re-derivation of the contract. Race Hub bare-default landing also reads this
// for `default_analysis_session` (#75).
export async function fetchWeekendContext(): Promise<WeekendContext> {
const res = await fetch('/api/v1/weekend-context')
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}
export async function fetchChampionshipHub(year?: number): Promise<ChampionshipHub> { export async function fetchChampionshipHub(year?: number): 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())

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,7 +52,10 @@ function StintSparkline({ seconds }: { seconds: number[] }) {
} }
export function TyreDegPanel({ rows, sessionType, pinned }: Props) { export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
const [collapsed, setCollapsed] = useState(false) const isRace = isRaceSession(sessionType)
// In practice/qualifying deg trends are secondary — collapse by default so
// the Timing Tower stays above the fold. Races keep it open.
const [collapsed, setCollapsed] = useState(!isRace)
const [stints, setStints] = useState<StintHistoryMap>({}) const [stints, setStints] = useState<StintHistoryMap>({})
// One lap-history update per received snapshot (rows is rebuilt per snapshot). // One lap-history update per received snapshot (rows is rebuilt per snapshot).
@@ -61,8 +64,6 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
setStints((prev) => recordStintSamples(prev, rows.map(stintInputFromRow))) setStints((prev) => recordStintSamples(prev, rows.map(stintInputFromRow)))
}, [rows]) }, [rows])
const isRace = isRaceSession(sessionType)
const visible = useMemo( const visible = useMemo(
() => () =>
rows.filter( rows.filter(

View File

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

View File

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

View File

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

View File

@@ -1,56 +0,0 @@
import { Link } from '@tanstack/react-router'
import { ChevronRight } from 'lucide-react'
import type { WeekendContext } from '../../types'
import { RacePreviewPage } from '../../pages/RacePreviewPage'
import { ChampionshipRoundStrip, CountdownDisplay, Flag, SessionRail, railNodes } from './shared'
import { meetingIdentity } from '../../lib/weekendContext'
/**
* PreSessionView folds the race preview surface into the Weekend home so there is
* no separate Preview destination. It reuses the existing preview page content and
* frames it with the next-session countdown and compact season navigation. It also
* backs the /preview alias, so saved preview links resolve here instead of looping.
*/
export function PreSessionView({ context, now }: { context: WeekendContext; now: Date }) {
const meeting = meetingIdentity(context.next_meeting ?? context.focus_meeting)
const next = context.next_session?.session
const nextStart = next?.date_start ?? meeting?.date_start
const nodes = railNodes(
context.previous_completed_session,
context.active_session,
context.next_session,
)
return (
<div className="wk-pre" data-testid="weekend-pre-session" data-state="pre_session">
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">Pre-session</div>
{meeting && (
<header className="wk-pre-head" data-testid="wk-pre-head">
<div className="wk-event-id">
<Flag code={meeting.country_code} flag={meeting.country_flag} />
<h1 className="wk-sessions-title">{meeting.meeting_name}</h1>
</div>
<div className="wk-pre-countdown">
<span className="wk-next-label mono">{next?.session_name ?? 'Next session'}</span>
<CountdownDisplay target={nextStart} now={now} />
</div>
<Link to="/live" className="wk-cta wk-cta-ghost" data-testid="wk-pre-live">
Live timing <ChevronRight size={14} aria-hidden="true" />
</Link>
</header>
)}
{nodes.length > 0 && <SessionRail nodes={nodes} />}
<div className="wk-pre-preview" data-testid="wk-pre-preview">
<RacePreviewPage />
</div>
<ChampionshipRoundStrip
round={context.championship_round}
total={context.total_championship_rounds}
/>
</div>
)
}

View File

@@ -1,36 +0,0 @@
import { Link } from '@tanstack/react-router'
export function WeekendLoading() {
return (
<div className="wk-status wk-status-loading" data-testid="weekend-loading" aria-busy="true">
<span className="wk-status-eyebrow mono">box-box · weekend</span>
<p className="wk-status-title">Reading the weekend</p>
</div>
)
}
export function WeekendError({ message }: { message?: string }) {
return (
<div className="wk-status wk-status-error" data-testid="weekend-error" role="alert">
<span className="wk-status-eyebrow mono">box-box · weekend</span>
<p className="wk-status-title">Weekend unavailable</p>
<p className="wk-status-sub">{message ?? 'Something went wrong loading the weekend context.'}</p>
</div>
)
}
export function WeekendLimited({ message, season }: { message?: string; season?: number }) {
return (
<div className="wk-status wk-status-limited" data-testid="weekend-limited">
<span className="wk-status-eyebrow mono">box-box · weekend</span>
<p className="wk-status-title">Nothing to show yet</p>
<p className="wk-status-sub">
{message ?? `No weekend data for the ${season || 'current'} season is available right now.`}
</p>
<div className="wk-status-actions">
<Link to="/live" className="wk-cta wk-cta-ghost">Live timing</Link>
<Link to="/explore" className="wk-cta wk-cta-ghost">Explore</Link>
</div>
</div>
)
}

View File

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

View File

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

View File

@@ -1,89 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchChampionshipHub, fetchNews, fetchWeekendContext } from '../api'
import { championshipImpact, briefingItems } from '../lib/weekendContext'
import type {
WeekendChampionshipImpact,
WeekendBriefingItem,
WeekendContext,
} from '../types'
export type WeekendLoadState = 'loading' | 'error' | 'ready'
export interface UseWeekendContextResult {
/** Canonical context, present only when loadState === 'ready'. */
context: WeekendContext | null
loadState: WeekendLoadState
error?: Error
/** Supplementary championship movers (not part of the #72 contract). */
championship?: WeekendChampionshipImpact
/** Supplementary briefing items (not part of the #72 contract). */
briefing: WeekendBriefingItem[]
now: Date
}
/**
* useWeekendContext reads the canonical /api/v1/weekend-context endpoint as the
* single source of truth for the Weekend home. When the canonical read succeeds
* it layers on two pieces of supplementary data that the contract intentionally
* omits — championship movers and the paddock briefing — and nothing else.
*
* It deliberately does NOT fan out to season / meetings / per-weekend / OpenF1
* session / live-state queries: those would defeat the local-first canonical read
* model and can hit the rate-limited OpenF1 REST surface during active sessions.
* Supplementary queries are gated on a successful canonical read so a failing or
* pending endpoint issues no extra requests.
*/
export function useWeekendContext(): UseWeekendContextResult {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [])
const nowDate = useMemo(() => new Date(now), [now])
const contextQuery = useQuery({
queryKey: ['weekend-context'],
queryFn: fetchWeekendContext,
staleTime: 30_000,
})
const canonical = contextQuery.data ?? null
const canonicalReady = canonical != null
const season = canonical?.season
// Supplementary data — only fetched once the canonical context has resolved,
// so a pending/failed canonical read never triggers a request fan-out.
const championshipQuery = useQuery({
queryKey: ['championship-hub', season ?? 'current'],
queryFn: () => fetchChampionshipHub(season),
enabled: canonicalReady,
staleTime: 60_000,
})
const newsQuery = useQuery({
queryKey: ['news', 6],
queryFn: () => fetchNews(6),
enabled: canonicalReady,
staleTime: 60_000,
})
const championship = useMemo(
() => championshipImpact(championshipQuery.data),
[championshipQuery.data],
)
const briefing = useMemo(() => briefingItems(newsQuery.data ?? []), [newsQuery.data])
let loadState: WeekendLoadState = 'loading'
if (contextQuery.isError) loadState = 'error'
else if (canonicalReady) loadState = 'ready'
return {
context: canonical,
loadState,
error: contextQuery.error instanceof Error ? contextQuery.error : undefined,
championship,
briefing,
now: nowDate,
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { fetchLiveState, fetchWeekendContext } from '../api' import { fetchLiveState } from '../api'
import type { LivePosition, LiveStreamData } from '../types' import type { LivePosition, LiveStreamData } from '../types'
import { import {
loadPinnedDrivers, loadPinnedDrivers,
@@ -17,15 +17,6 @@ import { recordGapSamples } from '../lib/gapHistory'
import { battleNumbers, detectBattles } from '../lib/battles' import { battleNumbers, detectBattles } from '../lib/battles'
import type { LiveEvent } from '../lib/events' import type { LiveEvent } from '../lib/events'
import { appendEvents, diffSnapshots, sessionSignature } from '../lib/events' import { appendEvents, diffSnapshots, sessionSignature } from '../lib/events'
import {
allowsLiveInterpretations,
deriveLivePhase,
effectiveFeedHealth,
rendersSnapshot,
terminalSessionStatus,
} from '../lib/liveState'
import type { TransportHealth } from '../lib/liveState'
import { shouldPollHandoffAnalysis } from '../components/live/LiveHandoff'
import { SessionBanner } from '../components/live/SessionBanner' import { SessionBanner } from '../components/live/SessionBanner'
import { TrackStatusBanner } from '../components/live/TrackStatusBanner' import { TrackStatusBanner } from '../components/live/TrackStatusBanner'
import { TimingTower } from '../components/live/TimingTower' import { TimingTower } from '../components/live/TimingTower'
@@ -35,12 +26,9 @@ import { RaceControlFeed } from '../components/live/RaceControlFeed'
import { EventRail } from '../components/live/EventRail' import { EventRail } from '../components/live/EventRail'
import { TeamRadioTicker } from '../components/live/TeamRadioTicker' import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
import { TyreDegPanel } from '../components/live/TyreDegPanel' import { TyreDegPanel } from '../components/live/TyreDegPanel'
import { LiveHandoff } from '../components/live/LiveHandoff' import { Archive, Radio } from 'lucide-react'
import '../styles/live-state.css'
/** How often to re-check weekend-context while analysis is still ingesting. */ type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
export const WEEKEND_CONTEXT_POLL_MS = 15_000
const WEEKEND_CONTEXT_STALE_MS = 15_000
export function LiveTimingPage() { export function LiveTimingPage() {
const [activeSnapshot, setActiveSnapshot] = useState<LiveStreamData | null>(null) const [activeSnapshot, setActiveSnapshot] = useState<LiveStreamData | null>(null)
@@ -49,84 +37,31 @@ export function LiveTimingPage() {
const [archiveSnapshotAt, setArchiveSnapshotAt] = useState<string | null>(null) const [archiveSnapshotAt, setArchiveSnapshotAt] = useState<string | null>(null)
const [archiveMode, setArchiveMode] = useState(false) const [archiveMode, setArchiveMode] = useState(false)
const [isLive, setIsLive] = useState(false) const [isLive, setIsLive] = useState(false)
const [streamStatus, setStreamStatus] = useState<TransportHealth>('connecting') const [streamStatus, setStreamStatus] = useState<StreamStatus>('connecting')
const [now, setNow] = useState(Date.now()) const [now, setNow] = useState(Date.now())
const [gapHistory, setGapHistory] = useState<GapHistoryMap>({}) const [gapHistory, setGapHistory] = useState<GapHistoryMap>({})
const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers()) const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers())
const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({}) const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({})
const [, setPositions] = useState<Record<string, LivePosition>>({}) const [positions, setPositions] = useState<Record<string, LivePosition>>({})
const [events, setEvents] = useState<LiveEvent[]>([]) const [events, setEvents] = useState<LiveEvent[]>([])
const prevSnapshotRef = useRef<LiveStreamData | null>(null) const prevSnapshotRef = useRef<LiveStreamData | null>(null)
const sessionSigRef = useRef('') const sessionSigRef = useRef('')
const isLiveRef = useRef(false) const isLiveRef = useRef(false)
const archiveModeRef = useRef(false) const archiveModeRef = useRef(false)
// Whether we ever observed this session as live — used to reason about a const snapshot = isLive ? activeSnapshot : archiveMode ? archiveSnapshot : null
// dropped feed vs. a session that was never live in this page session.
const wasLiveRef = useRef(false)
const hasArchive = Boolean(archiveSnapshot) const hasArchive = Boolean(archiveSnapshot)
const { const { data, isLoading, isError, error } = useQuery({
data,
isError,
error,
isFetched: liveStateFetched,
} = useQuery({
queryKey: ['live-state'], queryKey: ['live-state'],
queryFn: fetchLiveState, queryFn: fetchLiveState,
staleTime: 5_000, staleTime: 5_000,
}) })
// A retained snapshot is only "settled" (session genuinely finished) if it
// carries a terminal FIA SessionStatus. Otherwise the upstream feed dropped
// while the session was still active and we must stay in `disconnected`.
const sessionEndedCleanly = terminalSessionStatus(archiveSnapshot?.SessionStatus)
// Transport health, active-session state, and archive availability are
// orthogonal inputs; deriveLivePhase collapses them into one UI phase.
const phase = deriveLivePhase({
transport: streamStatus,
isLive,
hasActiveSnapshot: Boolean(activeSnapshot),
hasArchive,
archiveMode,
sessionEndedCleanly,
wasLive: wasLiveRef.current,
stateLoaded: liveStateFetched,
})
const snapshot =
phase === 'archive'
? archiveSnapshot
: isLive
? activeSnapshot
: phase === 'disconnected'
? archiveSnapshot
: null
// Canonical weekend context (issue #72) — the single source of truth for
// previous/next/default-analysis identity and temporal state. Only needed
// when no session is streaming; keep it idle during a live session. Poll
// while a completed session is still ingesting so `settling` can flip to
// analysis-ready without a manual refresh.
const contextQuery = useQuery({
queryKey: ['weekend-context'],
queryFn: fetchWeekendContext,
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)
@@ -176,7 +111,6 @@ export function LiveTimingPage() {
setPositions({}) setPositions({})
} }
isLiveRef.current = true isLiveRef.current = true
wasLiveRef.current = true
setActiveSnapshot(state.data) setActiveSnapshot(state.data)
setArchiveMode(false) setArchiveMode(false)
} else { } else {
@@ -233,8 +167,6 @@ export function LiveTimingPage() {
}, [snapshot]) }, [snapshot])
const rawRows = useMemo(() => sortLiveTimingRows(snapshot), [snapshot]) const rawRows = useMemo(() => sortLiveTimingRows(snapshot), [snapshot])
// Final-snapshot rows for the settling handoff (independent of live rows).
const settlingRows = useMemo(() => sortLiveTimingRows(archiveSnapshot), [archiveSnapshot])
useEffect(() => { useEffect(() => {
if (rawRows.length === 0) { if (rawRows.length === 0) {
@@ -278,75 +210,68 @@ export function LiveTimingPage() {
setPositions(archivePositions) setPositions(archivePositions)
} }
const handleExitArchive = () => {
setArchiveMode(false)
}
const archiveTimestamp = archiveSnapshotAt ? new Date(archiveSnapshotAt) : null const archiveTimestamp = archiveSnapshotAt ? new Date(archiveSnapshotAt) : null
const archiveLabel = const archiveLabel =
archiveTimestamp && !Number.isNaN(archiveTimestamp.getTime()) archiveTimestamp && !Number.isNaN(archiveTimestamp.getTime())
? `Archived snapshot from ${archiveTimestamp.toLocaleString()}` ? `Archived snapshot from ${archiveTimestamp.toLocaleString()}`
: 'Archived live timing snapshot' : 'Archived live timing snapshot'
const showLiveInterpretations = allowsLiveInterpretations(phase)
// Upstream FIA loss can leave the browser SSE open; present one coherent
// feed-health truth rather than "Connection lost" + "Feed healthy".
const feedHealth = effectiveFeedHealth(streamStatus, phase)
return ( return (
<div className="page live-page" data-testid="live-page" data-phase={phase}> <div className="page live-page" data-testid="live-page">
{isError && ( {isError && (
<div className="error-box"> <div className="error-box">
{error instanceof Error ? error.message : 'Failed to load live timing state'} {error instanceof Error ? error.message : 'Failed to load live timing state'}
</div> </div>
)} )}
{phase === 'disconnected' && ( {streamStatus === 'disconnected' && snapshot && !archiveMode && (
<div className="live-status-strip live-status-warn" data-testid="live-disconnected-strip"> <div className="live-status-strip live-status-warn">
Connection lost showing the last live data while we reconnect. This is not an archive. Stream disconnected showing last received snapshot
</div> </div>
)} )}
{phase === 'archive' && ( {archiveMode && snapshot && (
<div className="live-status-strip live-status-archive" data-testid="live-archive-strip"> <div className="live-status-strip live-status-archive" data-testid="live-archive-strip">
<span>{archiveLabel} read-only, live updates are paused</span> {archiveLabel} live updates are paused for this archive view
<button type="button" className="live-archive-exit" onClick={handleExitArchive}>
Exit archive
</button>
</div> </div>
)} )}
{phase === 'connecting' && ( {isLoading && !snapshot && (
<div className="loading-state">connecting to live timing</div> <div className="loading-state">connecting to live timing</div>
)} )}
{(phase === 'settling' || phase === 'inactive') && ( {!isLoading && !snapshot && (
<LiveHandoff <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">
phase={phase} <div className="live-empty-status" style={{ marginBottom: '16px' }}>
transport={feedHealth} <span className={`live-conn live-conn-${streamStatus}`}>{streamStatus}</span>
context={weekendContext} </div>
rows={settlingRows} <Radio size={48} style={{ color: 'var(--text-3)', margin: '0 auto 16px auto', display: 'block' }} />
capturedAt={archiveSnapshotAt} <h2 className="empty-state-title" style={{ fontSize: '20px', marginBottom: '8px' }}>No live session active</h2>
hasArchive={hasArchive} <p className="empty-state-desc" style={{ color: 'var(--text-2)' }}>
onViewArchive={handleViewArchive} The telemetry feed is currently offline. <br /><br /> Check the <a href="/" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Command Center</a> for the weekend schedule or explore historical data in the <a href="/race-hub" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Race Hub</a>.
/> </p>
{hasArchive && (
<button type="button" className="live-archive-btn" onClick={handleViewArchive}>
<Archive size={15} />
View Last Session
</button>
)}
</div>
)} )}
{snapshot && rendersSnapshot(phase) && ( {snapshot && (
<> <>
<SessionBanner <SessionBanner
phase={phase} isLive={isLive}
isArchive={archiveMode}
snapshot={snapshot} snapshot={snapshot}
rows={rows} rows={rows}
transport={feedHealth} connection={streamStatus}
now={now} now={now}
capturedAt={phase === 'archive' ? archiveSnapshotAt : null}
/> />
<TrackStatusBanner status={snapshot.TrackStatus} /> <TrackStatusBanner status={snapshot.TrackStatus} />
<PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} /> <PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} />
{showLiveInterpretations && (
<TyreDegPanel rows={rows} sessionType={snapshot.Session?.SessionType} pinned={pinned} /> <TyreDegPanel rows={rows} sessionType={snapshot.Session?.SessionType} pinned={pinned} />
)}
<div className="live-columns"> <div className="live-columns">
<div className="live-tower-col"> <div className="live-tower-col">
<div className="sec-header"> <div className="sec-header">

View File

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

View File

@@ -1,113 +0,0 @@
import { useWeekendContext } from '../hooks/useWeekendContext'
import { BetweenRacesView } from '../components/weekend/BetweenRacesView'
import { BetweenSessionsView } from '../components/weekend/BetweenSessionsView'
import { LiveHandoffView } from '../components/weekend/LiveHandoffView'
import { PreSessionView } from '../components/weekend/PreSessionView'
import { WeekendError, WeekendLimited, WeekendLoading } from '../components/weekend/StatusViews'
import { WeekendFocusBanner } from '../components/weekend/WeekendFocusBanner'
import { resolveViewState } from '../lib/weekendContext'
import type {
WeekendBriefingItem,
WeekendChampionshipImpact,
WeekendContext,
WeekendViewState,
} from '../types'
import '../styles/weekend.css'
interface RenderArgs {
context: WeekendContext
now: Date
championship?: WeekendChampionshipImpact
briefing: WeekendBriefingItem[]
/** When true (the /preview alias), foreground the preparation surface. */
preview: boolean
}
function renderState(view: WeekendViewState, args: RenderArgs) {
const { context, now, championship, briefing, preview } = args
// The /preview alias always resolves to the preparation surface as long as
// there is a next event to prepare for, regardless of the temporal state. This
// keeps saved /preview links and the "Prepare for …" CTA meaningful instead of
// redirecting straight back to the same between-races screen.
if (preview && context.next_meeting) {
return <PreSessionView context={context} now={now} />
}
switch (view) {
case 'no_season':
return <WeekendLimited season={context.season} />
case 'between_weekends':
case 'post_weekend':
case 'season_complete':
return (
<BetweenRacesView
context={context}
now={now}
view={view}
championship={championship}
briefing={briefing}
/>
)
case 'between_sessions':
case 'session_settling':
return <BetweenSessionsView context={context} now={now} view={view} briefing={briefing} />
case 'session_live':
return <LiveHandoffView context={context} />
case 'pre_session':
return <PreSessionView context={context} now={now} />
default:
return <WeekendLimited season={context.season} />
}
}
export function WeekendPage({
preview = false,
focusMeetingKey,
focusSessionKey,
}: {
preview?: boolean
/** Restored from `/?meeting_key=` when returning from Race Hub analysis. */
focusMeetingKey?: number
/** Restored from `/?session_key=` when returning from Race Hub analysis. */
focusSessionKey?: number
}) {
const { context, loadState, error, championship, briefing, now } = useWeekendContext()
const hasFocus =
(focusMeetingKey != null && focusMeetingKey > 0) ||
(focusSessionKey != null && focusSessionKey > 0)
if (loadState === 'loading') {
return (
<main className="wk-page" data-testid="weekend-page" data-state="loading">
<WeekendLoading />
</main>
)
}
if (loadState === 'error' || context == null) {
return (
<main className="wk-page" data-testid="weekend-page" data-state="error">
<WeekendError message={error?.message} />
</main>
)
}
const view = resolveViewState(context)
return (
<main
className="wk-page"
data-testid="weekend-page"
data-state={view}
data-temporal-state={context.temporal_state}
data-preview={preview ? 'true' : undefined}
data-meeting-key={focusMeetingKey ?? undefined}
data-session-key={focusSessionKey ?? undefined}
>
{hasFocus && (
<WeekendFocusBanner meetingKey={focusMeetingKey} sessionKey={focusSessionKey} />
)}
{renderState(view, { context, now, championship, briefing, preview })}
</main>
)
}

View File

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

View File

@@ -112,6 +112,30 @@ a { color: inherit; text-decoration: none; }
.nav-links a:hover { color: var(--text); background: var(--surface-h); } .nav-links a:hover { color: var(--text); background: var(--surface-h); }
.nav-links a.active { color: var(--text); background: var(--surface-2); } .nav-links a.active { color: var(--text); background: var(--surface-2); }
/* Live nav gets a pulsing marker only while a session is on air. */
.nav-live { display: inline-flex; align-items: center; gap: 6px; }
.nav-links a.nav-live-on { color: var(--text); font-weight: 600; }
.nav-live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--red);
box-shadow: 0 0 0 0 rgba(225, 6, 0, 0.4);
animation: pulse-live 1.6s infinite;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.nav-utility { .nav-utility {
margin-left: auto; margin-left: auto;
display: flex; display: flex;
@@ -142,53 +166,6 @@ a { color: inherit; text-decoration: none; }
background: var(--surface-2); background: var(--surface-2);
} }
/* ── Mobile bottom nav ── */
.app-bottom-nav {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
height: 60px;
padding-bottom: env(safe-area-inset-bottom, 0);
background: rgba(18, 18, 18, 0.92);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.bottom-nav-link {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
font-size: 10px;
font-weight: 600;
color: var(--text-3);
}
.bottom-nav-link.active { color: var(--red); }
.bottom-nav-link:hover { color: var(--text-2); }
@media (max-width: 640px) {
/* On mobile the bottom bar is the single primary navigation system. The top
bar's Primary links are hidden so the two are never both shown; the top bar
keeps only the logo and the Admin operator utility. */
.app-bottom-nav { display: flex; }
.app-nav .nav-links { display: none; }
.app-nav { justify-content: space-between; }
/* Keep page content clear of the fixed bottom bar. */
.wk-page { padding-bottom: calc(60px + env(safe-area-inset-bottom, 0) + var(--s4)); }
}
/* ── Focus visibility (accessibility) ── */
:focus-visible {
outline: 2px solid var(--red);
outline-offset: 2px;
border-radius: 3px;
}
/* ── Page ── */ /* ── Page ── */
.page { .page {
max-width: 1040px; max-width: 1040px;
@@ -1820,163 +1797,6 @@ a { color: inherit; text-decoration: none; }
.tab-btn:hover { color: var(--text-2); } .tab-btn:hover { color: var(--text-2); }
.tab-btn.active { color: var(--text); border-bottom-color: var(--red); } .tab-btn.active { color: var(--text); border-bottom-color: var(--red); }
/* ── Grouped analysis navigation ── */
.tab-bar-grouped {
gap: var(--s5);
align-items: flex-end;
}
.tab-group {
display: flex;
flex-direction: column;
gap: 2px;
flex-shrink: 0;
}
.tab-group + .tab-group {
border-left: 1px solid var(--border);
padding-left: var(--s5);
}
.tab-group-label {
font-size: 9px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-3);
opacity: 0.7;
padding: 0 14px;
}
.tab-group-btns {
display: flex;
}
/* ── Session lifecycle state dots ── */
.rh-state-ready { background: var(--green); }
.rh-state-partial { background: var(--yellow); }
.rh-state-live { background: var(--red); }
.rh-state-upcoming { background: var(--text-3); }
.rh-state-preparing { background: var(--text-3); opacity: 0.5; }
.rh-state-unavailable { background: var(--text-3); opacity: 0.4; }
.rh-state-cancelled { background: var(--text-3); opacity: 0.35; }
/* ── Pre-session (expected availability) view ── */
.rh-presession {
display: flex;
flex-direction: column;
gap: var(--s5);
}
.rh-presession-band {
padding: var(--s6) var(--s5);
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--gp-accent);
border-radius: 4px;
display: flex;
flex-direction: column;
gap: var(--s3);
}
.rh-presession-eyebrow {
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-3);
}
.rh-presession-title {
font-size: 22px;
font-weight: 700;
margin: 0;
}
.rh-presession-sub { color: var(--text-2); max-width: 60ch; }
.rh-presession-countdown {
font-size: 15px;
color: var(--text);
margin-top: var(--s3);
}
.rh-partial-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s3);
margin: 0 var(--s5) var(--s3);
padding: var(--s3) var(--s4);
border: 1px solid color-mix(in srgb, var(--yellow) 45%, var(--border));
background: color-mix(in srgb, var(--yellow) 12%, transparent);
color: var(--text-2);
font-size: 12px;
}
.rh-partial-banner-link {
appearance: none;
background: transparent;
border: 0;
color: var(--text);
text-decoration: underline;
cursor: pointer;
font: inherit;
}
.rh-expected-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--s4);
}
.rh-expected-card {
padding: var(--s4);
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
display: flex;
align-items: center;
gap: var(--s3);
color: var(--text-2);
font-size: 13px;
}
.rh-expected-card .rh-expected-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-3);
opacity: 0.6;
}
/* ── Loading / error recovery ── */
.rh-recover {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--s4);
padding: var(--s6) var(--s5);
}
.rh-recover-actions {
display: flex;
gap: var(--s3);
flex-wrap: wrap;
}
.rh-recover-btn {
padding: 8px 14px;
font-size: 12px;
font-weight: 600;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
color: var(--text);
cursor: pointer;
text-decoration: none;
}
.rh-recover-btn:hover { border-color: var(--gp-accent); }
.rh-recover-btn.primary { border-color: var(--red); color: var(--text); }
/* ── Diagnostics (secondary) action ── */
.rh-diagnostics-toggle {
align-self: flex-start;
margin-left: auto;
padding: 6px 12px;
font-size: 11px;
font-weight: 600;
color: var(--text-3);
background: none;
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
}
.rh-diagnostics-toggle:hover { color: var(--text-2); border-color: var(--gp-accent); }
.rh-diagnostics-toggle.active { color: var(--text); }
/* ── Dataset status view ── */ /* ── Dataset status view ── */
.ds-legend { .ds-legend {
display: flex; display: flex;
@@ -2826,31 +2646,6 @@ a { color: inherit; text-decoration: none; }
.rh-topbar-spacer { flex: 1; } .rh-topbar-spacer { flex: 1; }
.rh-back-weekend {
flex-shrink: 0;
padding: 4px 10px;
font-size: 10px;
font-weight: 700;
font-family: var(--f-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-2);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 2px;
transition: color 0.1s, border-color 0.1s, background 0.1s;
}
.rh-back-weekend:hover,
.rh-back-weekend:focus-visible {
color: var(--text);
border-color: var(--gp-accent);
background: var(--surface-h);
outline: none;
}
.rh-back-weekend:focus-visible {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--gp-accent) 45%, transparent);
}
.rh-switcher-toggle { .rh-switcher-toggle {
padding: 4px 10px; padding: 4px 10px;
font-size: 10px; font-size: 10px;
@@ -3426,10 +3221,7 @@ a { color: inherit; text-decoration: none; }
.rh-overview-row { grid-template-columns: 1fr; } .rh-overview-row { grid-template-columns: 1fr; }
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.rh-page { padding: var(--s4); gap: var(--s4); padding-bottom: 88px; } .rh-page { padding: var(--s4); gap: var(--s4); }
.rh-topbar { flex-wrap: wrap; gap: var(--s3); }
.rh-topbar-spacer { display: none; }
.rh-back-weekend { order: -1; }
.rh-identity-body { padding: var(--s3) var(--s4); gap: var(--s3); } .rh-identity-body { padding: var(--s3) var(--s4); gap: var(--s3); }
.rh-identity-decal { font-size: 28px; } .rh-identity-decal { font-size: 28px; }
.rh-identity-name { font-size: 17px; } .rh-identity-name { font-size: 17px; }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -81,14 +81,20 @@ describe('TyreDegPanel', () => {
expect(panel).toHaveTextContent('→ ~P2') expect(panel).toHaveTextContent('→ ~P2')
}) })
it('hides the rejoin estimate outside race sessions and collapses on toggle', () => { it('collapses by default outside race sessions and hides the rejoin estimate when expanded', () => {
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Qualifying" pinned={[]} />) render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Qualifying" pinned={[]} />)
const panel = screen.getByTestId('tyredeg-panel') const panel = screen.getByTestId('tyredeg-panel')
expect(panel).not.toHaveTextContent('~P')
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2) // Practice/qualifying starts collapsed so the Timing Tower stays above the fold.
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0) expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
expect(panel).not.toHaveTextContent('~P')
})
it('starts expanded during a race', () => {
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
}) })
it('limits rows to the top ten plus pinned drivers', () => { it('limits rows to the top ten plus pinned drivers', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -448,107 +448,6 @@ export interface DriverSummary {
rounds: DriverSummaryRound[] rounds: DriverSummaryRound[]
} }
// ── Weekend Context ──
// Canonical local-first contract for the adaptive Weekend home, served verbatim
// by /api/v1/weekend-context (backend story #72, internal/query/context.go).
// The frontend consumes this shape as the single source of truth; view-model
// derivation lives in lib/weekendContext.ts.
// TemporalState mirrors query.TemporalState exactly (the JSON `temporal_state`).
export type TemporalState =
| 'no_season'
| 'between_weekends'
| 'pre_session'
| 'session_live'
| 'session_settling'
| 'between_sessions'
| 'post_weekend'
| 'season_complete'
// ContextAvailability mirrors query.ContextAvailability. Every field is present
// in a canonical payload except the optional `observed_at`.
export interface ContextAvailability {
schedule: string
live_transport: string
live_session: string
archive: string
local_analysis: string
freshness: string
observed_at?: string
limitations: string[]
}
// ContextSession mirrors query.ContextSession: a session identity coupled with
// its meeting and structured availability contract.
export interface ContextSession {
session: Session
meeting?: Meeting
availability: ContextAvailability
}
// WeekendContext mirrors query.WeekendContext (the canonical endpoint body).
export interface WeekendContext {
season?: number
temporal_state: TemporalState
previous_meeting?: Meeting
focus_meeting?: Meeting
next_meeting?: Meeting
previous_completed_session?: ContextSession
active_session?: ContextSession
next_session?: ContextSession
default_analysis_session?: ContextSession
championship_round: number
total_championship_rounds: number
}
// ── Weekend view model (client-only) ──
// The rendered Weekend home layers a small set of non-canonical UI states
// (loading/error) plus supplementary data (championship movers, briefing) on top
// of the canonical context. None of these are part of the #72 contract.
export type WeekendViewState =
| 'loading'
| 'error'
| 'no_season'
| 'between_weekends'
| 'pre_session'
| 'session_live'
| 'session_settling'
| 'between_sessions'
| 'post_weekend'
| 'season_complete'
export interface WeekendChampionshipMover {
position: number
driver_number: number
name_acronym: string
team_colour: string
points: number
delta?: number
}
export interface WeekendChampionshipImpact {
leaders: WeekendChampionshipMover[]
note?: string
}
export interface WeekendBriefingItem {
category?: string
title: string
url: string
source: string
published_at?: string
image_url?: string
}
export interface WeekendPodiumEntry {
position: number
driver_number: number
name_acronym: string
team_name: string
team_colour: string
gap: string
}
export interface NewsItem { export interface NewsItem {
source: string source: string
title: string title: string

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,52 +0,0 @@
package web
import (
"net/http"
"strings"
"github.com/AmanTahiliani/box-box/internal/live"
"github.com/AmanTahiliani/box-box/internal/query"
)
func (s *Server) handleWeekendContext(w http.ResponseWriter, _ *http.Request) {
if !s.hasLocalQuery() {
writeJSON(w, query.WeekendContext{TemporalState: query.TemporalNoSeason})
return
}
state := s.hub.State()
evidence := query.LiveEvidence{}
if state.IsLive && state.Data != nil {
evidence = liveEvidence(state.Data, true, false)
} else if state.LastSnapshot != nil && terminalSessionStatus(state.LastSnapshot.SessionStatus) {
evidence = liveEvidence(state.LastSnapshot, false, true)
if state.LastSnapshotAt != nil {
evidence.ObservedAt = *state.LastSnapshotAt
}
}
context, err := s.query.ResolveWeekendContext(evidence)
if err != nil {
writeError(w, err, http.StatusInternalServerError, false)
return
}
writeJSON(w, context)
}
func liveEvidence(data *live.LiveStreamData, active, final bool) query.LiveEvidence {
return query.LiveEvidence{Active: active, Final: final, MeetingName: data.Session.MeetingName, CircuitName: data.Session.CircuitName, SessionName: data.Session.SessionName, SessionType: data.Session.SessionType}
}
func terminalSessionStatus(status string) bool {
normalized := strings.ToLower(strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
return r
}
return -1
}, status))
switch normalized {
case "finished", "finalised", "finalized", "ended", "aborted":
return true
default:
return false
}
}

View File

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

View File

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

View File

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

View File

@@ -62,7 +62,6 @@ func (s *Server) routes() (http.Handler, error) {
// REST API — /api/v1/laps/comparison must be registered before /api/v1/laps // REST API — /api/v1/laps/comparison must be registered before /api/v1/laps
// because Go's ServeMux uses longest-prefix matching. // because Go's ServeMux uses longest-prefix matching.
mux.HandleFunc("/api/v1/weekend-context", s.handleWeekendContext)
mux.HandleFunc("/api/v1/race-hub", s.handleRaceHub) mux.HandleFunc("/api/v1/race-hub", s.handleRaceHub)
mux.HandleFunc("/api/v1/seasons", s.handleSeasons) mux.HandleFunc("/api/v1/seasons", s.handleSeasons)
mux.HandleFunc("/api/v1/weekend", s.handleWeekend) mux.HandleFunc("/api/v1/weekend", s.handleWeekend)

View File

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

View File

@@ -0,0 +1,77 @@
import { test, expect } from '@playwright/test'
const FULL_SESSION = 9472
test.describe('Command Center', () => {
test('loads as default route with weekend identity band', async ({ page }) => {
await page.goto('/')
await expect(page.getByTestId('command-center')).toBeVisible()
await expect(page.getByTestId('cc-focus')).toBeVisible()
await expect(page.getByTestId('cc-session-9472')).toBeVisible()
await expect(page.getByTestId('hero-last-race-link')).toBeVisible()
})
test('nav link reaches command center from race hub', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await page.getByRole('link', { name: 'Command' }).click()
await expect(page).toHaveURL('/')
await expect(page.getByTestId('command-center')).toBeVisible()
})
test('open analysis action opens race hub for the focus session', async ({ page }) => {
await page.goto('/')
await expect(page.getByTestId('hero-last-race-link')).toBeVisible()
await page.getByTestId('hero-last-race-link').click()
await expect(page).toHaveURL(new RegExp(`/race-hub\\?session_key=${FULL_SESSION}`))
await expect(page.getByTestId('rh-identity')).toBeVisible()
await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible()
})
test('archived live snapshot does not mark command center live', async ({ page }) => {
await page.route('**/api/v1/live/state', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
is_live: false,
data: null,
last_snapshot: {
Drivers: { '1': { RacingNumber: '1', Position: 1 } },
DriverInfo: { '1': { RacingNumber: '1', Tla: 'VER', TeamColour: '3671C6' } },
Tyres: {},
RCMessages: [],
Weather: {},
Session: { MeetingName: 'Archived GP', SessionName: 'Race', SessionType: 'Race' },
TeamRadio: [],
SessionStatus: 'Finished',
TrackStatus: '1',
CurrentLap: 57,
TotalLaps: 57,
Clock: '',
ClockRefTime: '',
ClockExtrapolating: false,
Stints: {},
},
last_snapshot_at: '2026-07-04T14:00:00Z',
}),
}),
)
await page.goto('/')
await expect(page.getByTestId('command-center')).toBeVisible()
await expect(page.getByTestId('cc-live-status')).toContainText('No live session')
await expect(page.getByTestId('cc-live-status')).not.toContainText('Live session active')
})
test('existing routes continue to work', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await expect(page.getByTestId('race-hub')).toBeVisible()
await expect(page.getByTestId('rh-overview')).toBeVisible()
await page.goto('/admin')
await expect(page.getByTestId('data-library')).toBeVisible()
await page.goto('/live')
await expect(page.getByTestId('live-empty')).toBeVisible()
})
})

View File

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

View File

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

View File

@@ -3,11 +3,11 @@ import { test, expect } from '@playwright/test'
const FULL_SESSION = 9472 const FULL_SESSION = 9472
test.describe('Production serving (Go + built React)', () => { test.describe('Production serving (Go + built React)', () => {
test('serves the Weekend home as default route', async ({ page }) => { test('serves command center as default route', async ({ page }) => {
await page.goto('/') await page.goto('/')
await expect(page.getByTestId('weekend-page')).toBeVisible() await expect(page.getByTestId('command-center')).toBeVisible()
await expect(page.getByTestId('weekend-between-races')).toBeVisible() await expect(page.getByTestId('cc-session-9472')).toBeVisible()
}) })
test('serves race hub workspace from built assets', async ({ page }) => { test('serves race hub workspace from built assets', async ({ page }) => {
@@ -37,28 +37,23 @@ test.describe('Production serving (Go + built React)', () => {
await expect(page.getByTestId('dl-meeting-1229')).toBeVisible() await expect(page.getByTestId('dl-meeting-1229')).toBeVisible()
}) })
test('serves live route with the inactive handoff when live is disabled', async ({ page }) => { test('serves live route with empty state when live is disabled', async ({ page }) => {
await page.goto('/live') await page.goto('/live')
await expect(page.getByTestId('live-inactive')).toBeVisible() await expect(page.getByTestId('live-empty')).toBeVisible()
await expect(page.getByText('NO LIVE SESSION')).toBeVisible() await expect(page.getByText('No live session active')).toBeVisible()
}) })
test('primary nav links and Admin utility work from built SPA', async ({ page }) => { test('nav links work from built SPA', async ({ page }) => {
await page.goto('/') await page.goto('/')
const primary = page.getByRole('navigation', { name: 'Primary' }).first() await page.locator('.app-nav').getByRole('link', { name: 'Race Hub' }).click()
await expect(page).toHaveURL(/\/race-hub/)
await primary.getByRole('link', { name: 'Championship', exact: true }).click() await page.locator('.app-nav').getByRole('link', { name: 'Admin', exact: true }).click()
await expect(page).toHaveURL(/\/championship/)
await primary.getByRole('link', { name: 'Explore', exact: true }).click()
await expect(page).toHaveURL(/\/explore/)
await primary.getByRole('link', { name: 'Weekend', exact: true }).click()
await expect(page).toHaveURL('/')
// Admin is an operator utility outside the Primary landmark.
await page.getByRole('toolbar', { name: 'Operator utilities' }).getByRole('link', { name: 'Admin' }).click()
await expect(page).toHaveURL(/\/admin/) await expect(page).toHaveURL(/\/admin/)
await page.getByRole('link', { name: 'Live', exact: true }).click()
await expect(page).toHaveURL(/\/live/)
await expect(page.getByTestId('live-empty')).toBeVisible()
}) })
}) })

View File

@@ -1,5 +1,4 @@
import { test, expect } from '@playwright/test' import { test, expect } from '@playwright/test'
import { FUTURE_SESSION, mockFutureRaceHubSession } from './fixtures/future-session'
const FULL_SESSION = 9472 const FULL_SESSION = 9472
const CORE_ONLY_SESSION = 9000 const CORE_ONLY_SESSION = 9000
@@ -16,7 +15,6 @@ test.describe('Race Hub Weekend Workspace', () => {
'aria-selected', 'aria-selected',
'true', 'true',
) )
await expect(page.getByText('Local Coverage')).toHaveCount(0)
}) })
test('shows final running order when switching to Race Story', async ({ page }) => { test('shows final running order when switching to Race Story', async ({ page }) => {
@@ -89,110 +87,24 @@ test.describe('Race Hub Weekend Workspace', () => {
await page.getByTestId('rh-switch-weekend').click() await page.getByTestId('rh-switch-weekend').click()
await expect(page.getByTestId('rh-switcher')).toBeVisible() await expect(page.getByTestId('rh-switcher')).toBeVisible()
// Active session is already loaded; just confirm a session button is reachable
await expect(page.getByTestId(`rh-switcher-session-${FULL_SESSION}`)).toBeVisible() await expect(page.getByTestId(`rh-switcher-session-${FULL_SESSION}`)).toBeVisible()
}) })
test('Diagnostics is a secondary action and points at admin, not inline CLI hints', async ({ page }) => { test('Data Status tab points at admin instead of inline CLI hints', async ({ page }) => {
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`) await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
await page.getByRole('tab', { name: 'Diagnostics' }).click() await page.getByRole('tab', { name: 'Data Status' }).click()
await expect(page.getByTestId('rh-data-status')).toBeVisible() await expect(page.getByTestId('rh-data-status')).toBeVisible()
await expect(page.getByRole('link', { name: /manage ingestion/i })).toHaveAttribute( await expect(page.getByRole('link', { name: /manage ingestion/i })).toHaveAttribute(
'href', 'href',
'/admin', '/admin',
) )
await expect(page.getByTestId('rh-dataset-strip')).toHaveCount(0)
await page.getByTestId('rh-diagnostics-toggle').click()
await expect(page.getByTestId('rh-dataset-strip')).toBeVisible()
}) })
test('groups analysis navigation into Story, Analysis, and Data & Context', async ({ page }) => { test('bare /race-hub redirects to the focus session', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await expect(page.getByTestId('rh-tabgroup-story')).toBeVisible()
await expect(page.getByTestId('rh-tabgroup-analysis')).toBeVisible()
await expect(page.getByTestId('rh-tabgroup-context')).toBeVisible()
})
test('bare /race-hub resolves to a completed session via Weekend Context', async ({ page }) => {
await page.goto('/race-hub') await page.goto('/race-hub')
await expect(page).toHaveURL(/session_key=\d+/) await expect(page).toHaveURL(/session_key=\d+/)
await expect(page.getByTestId('race-hub')).toBeVisible() await expect(page.getByTestId('race-hub')).toBeVisible()
await expect(page).toHaveURL(new RegExp(`session_key=${FULL_SESSION}`))
await expect(page.getByTestId('rh-identity')).toContainText('Monaco')
})
test('explicit completed session deep link stays stable and shows analysis', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await expect(page).toHaveURL(new RegExp(`session_key=${FULL_SESSION}`))
await expect(page.getByTestId('rh-overview')).toBeVisible()
})
test('explicit future session renders the pre-session view, not empty analysis', async ({
page,
}) => {
await mockFutureRaceHubSession(page)
await page.goto(`/race-hub?session_key=${FUTURE_SESSION}`)
await expect(page.getByTestId('race-hub')).toBeVisible()
await expect(page.getByTestId('rh-presession')).toBeVisible()
await expect(page.getByTestId('rh-overview')).toHaveCount(0)
})
test('returning to Weekend from analysis preserves meeting and session context', async ({
page,
}) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await expect(page.getByTestId('rh-identity')).toContainText('Monaco')
await page.getByRole('tab', { name: 'Strategy' }).click()
await expect(page.locator('[data-testid="strategy-chart"]')).toBeVisible()
// Navigate to the sibling core-only session within the same weekend.
await page.getByTestId(`rh-session-${CORE_ONLY_SESSION}`).click()
await expect(page).toHaveURL(new RegExp(`session_key=${CORE_ONLY_SESSION}`))
await expect(page.getByTestId('rh-identity')).toContainText('Monaco')
// Explicit Race Hub → Weekend return carries meeting/session in the URL.
const back = page.getByRole('link', { name: 'Back to Weekend' })
await expect(back).toBeVisible()
await expect(back).toHaveAttribute('href', /meeting_key=1229/)
await expect(back).toHaveAttribute('href', /session_key=9000/)
await back.click()
await expect(page).toHaveURL(/\/\?.*meeting_key=1229/)
await expect(page).toHaveURL(/session_key=9000/)
await expect(page.getByTestId('weekend-page')).toBeVisible()
await expect(page.getByTestId('weekend-page')).toHaveAttribute('data-meeting-key', '1229')
await expect(page.getByTestId('weekend-page')).toHaveAttribute('data-session-key', '9000')
await expect(page.getByTestId('wk-focus-context')).toBeVisible()
await expect(page.getByTestId('wk-focus-meeting')).toContainText('Monaco')
await expect(page.getByTestId('wk-focus-session')).toContainText('Core Only')
// Contextual action returns to the exact selected analysis session.
const continueAnalysis = page.getByRole('link', { name: /Continue analysis/i })
await expect(continueAnalysis).toHaveAttribute(
'href',
new RegExp(`session_key=${CORE_ONLY_SESSION}`),
)
await continueAnalysis.click()
await expect(page).toHaveURL(new RegExp(`session_key=${CORE_ONLY_SESSION}`))
await expect(page.getByTestId('race-hub')).toBeVisible()
await expect(page.getByTestId('rh-identity')).toContainText('Monaco')
})
test('Back to Weekend is keyboard-focusable and works at mobile width', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
await expect(page.getByTestId('race-hub')).toBeVisible()
const back = page.getByRole('link', { name: 'Back to Weekend' })
await expect(back).toBeVisible()
await back.focus()
await expect(back).toBeFocused()
await page.keyboard.press('Enter')
await expect(page.getByTestId('weekend-page')).toBeVisible()
await expect(page.getByTestId('weekend-page')).toHaveAttribute('data-meeting-key', '1229')
await expect(page.getByTestId('weekend-page')).toHaveAttribute('data-session-key', '9000')
await expect(page.getByTestId('wk-focus-context')).toBeVisible()
await expect(page.getByRole('link', { name: /Continue analysis/i })).toBeVisible()
}) })
}) })

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -1,5 +1,4 @@
import { expect, type Locator, type Page } from '@playwright/test' import { expect, type Locator, type Page } from '@playwright/test'
import { FUTURE_SESSION, mockFutureRaceHubSession } from '../fixtures/future-session'
export const VIEWPORTS = { export const VIEWPORTS = {
desktop: { width: 1280, height: 800 }, desktop: { width: 1280, height: 800 },
@@ -8,7 +7,6 @@ export const VIEWPORTS = {
} as const } as const
export const FULL_SESSION = 9472 export const FULL_SESSION = 9472
export { FUTURE_SESSION }
/** Wait for web fonts and layout to settle before screenshots. */ /** Wait for web fonts and layout to settle before screenshots. */
export async function waitForScreenshotReady(page: Page): Promise<void> { export async function waitForScreenshotReady(page: Page): Promise<void> {
@@ -16,13 +14,16 @@ export async function waitForScreenshotReady(page: Page): Promise<void> {
await page.waitForTimeout(150) await page.waitForTimeout(150)
} }
export async function gotoWeekendReady(page: Page): Promise<void> { export async function gotoCommandCenterReady(page: Page): Promise<void> {
await page.goto('/') await page.goto('/')
await expect(page.getByTestId('weekend-page')).toBeVisible() await expect(page.getByTestId('command-center')).toBeVisible()
// The seeded hermetic DB (Monaco 2025, completed) resolves to season_complete, await expect(page.getByTestId('cc-focus')).toBeVisible()
// rendered by the between-races surface. await expect(page.getByTestId('cc-session-9472')).toBeVisible()
await expect(page.getByTestId('weekend-between-races')).toBeVisible() // The e2e stack runs with an unreachable OpenF1 base URL, so wait for the
await expect(page.getByTestId('wk-last-event')).toBeVisible() // season-calendar query to settle on its local fallback before screenshotting.
await expect(
page.getByText('Using local meetings because the full calendar could not load.'),
).toBeVisible()
await waitForScreenshotReady(page) await waitForScreenshotReady(page)
} }
@@ -35,17 +36,6 @@ export async function gotoRaceHubReady(page: Page, sessionKey = FULL_SESSION): P
await waitForScreenshotReady(page) await waitForScreenshotReady(page)
} }
export async function gotoRaceHubFutureReady(
page: Page,
sessionKey = FUTURE_SESSION,
): Promise<void> {
await mockFutureRaceHubSession(page)
await page.goto(`/race-hub?session_key=${sessionKey}`)
await expect(page.getByTestId('race-hub')).toBeVisible()
await expect(page.getByTestId('rh-presession')).toBeVisible()
await waitForScreenshotReady(page)
}
export async function gotoRaceStoryReady(page: Page, sessionKey = FULL_SESSION): Promise<void> { export async function gotoRaceStoryReady(page: Page, sessionKey = FULL_SESSION): Promise<void> {
await page.goto(`/race-hub?session_key=${sessionKey}`) await page.goto(`/race-hub?session_key=${sessionKey}`)
await expect(page.getByTestId('race-hub')).toBeVisible() await expect(page.getByTestId('race-hub')).toBeVisible()
@@ -62,162 +52,10 @@ export async function gotoDataLibraryReady(page: Page): Promise<void> {
await waitForScreenshotReady(page) await waitForScreenshotReady(page)
} }
export async function gotoLiveInactiveReady(page: Page): Promise<void> { export async function gotoLiveEmptyReady(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)
// With BOXBOX_DISABLE_LIVE=1 the feed is silent, so the page settles into the await expect(page.getByTestId('live-empty')).toBeVisible()
// 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)
} }

View File

@@ -1,9 +0,0 @@
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')
})
})

View File

@@ -1,17 +1,16 @@
import { test } from '@playwright/test' import { test } from '@playwright/test'
import { import {
gotoWeekendReady, gotoCommandCenterReady,
gotoDataLibraryReady, gotoDataLibraryReady,
gotoLiveInactiveReady, gotoLiveEmptyReady,
gotoRaceHubFutureReady,
gotoRaceHubReady, gotoRaceHubReady,
screenshotPage, screenshotPage,
} from './helpers' } from './helpers'
test.describe('MVP visual regression', () => { test.describe('MVP visual regression', () => {
test('weekend', async ({ page }) => { test('command-center', async ({ page }) => {
await gotoWeekendReady(page) await gotoCommandCenterReady(page)
await screenshotPage(page, 'weekend') await screenshotPage(page, 'command-center')
}) })
test('race-hub', async ({ page }) => { test('race-hub', async ({ page }) => {
@@ -19,21 +18,13 @@ test.describe('MVP visual regression', () => {
await screenshotPage(page, 'race-hub') await screenshotPage(page, 'race-hub')
}) })
test('race-hub-future', async ({ page }) => {
await gotoRaceHubFutureReady(page)
// The countdown ticks every second — mask it so the snapshot stays stable.
await screenshotPage(page, 'race-hub-future', {
mask: [page.getByTestId('rh-presession-countdown')],
})
})
test('data-library', async ({ page }) => { test('data-library', async ({ page }) => {
await gotoDataLibraryReady(page) await gotoDataLibraryReady(page)
await screenshotPage(page, 'data-library') await screenshotPage(page, 'data-library')
}) })
test('live-inactive', async ({ page }) => { test('live-empty', async ({ page }) => {
await gotoLiveInactiveReady(page) await gotoLiveEmptyReady(page)
await screenshotPage(page, 'live') await screenshotPage(page, 'live')
}) })
}) })

View File

@@ -1,156 +0,0 @@
import { test, expect, type Page } from '@playwright/test'
// These journeys inject the canonical /api/v1/weekend-context payload directly so
// each temporal state is exercised deterministically and hermetically, regardless
// of the seeded database clock. The payloads mirror internal/query.WeekendContext.
const availability = {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'complete',
freshness: 'fresh',
limitations: [],
}
function meeting(key: number, name: string, start: string) {
return {
meeting_key: key,
meeting_name: name,
meeting_official_name: name,
location: name,
country_code: 'GBR',
country_name: 'United Kingdom',
country_flag: '',
circuit_key: key,
circuit_short_name: name,
date_start: start,
date_end: start,
year: 2026,
}
}
function ctxSession(key: number, name: string, start: string, meetingKey: number) {
return {
session: {
session_key: key,
session_name: name,
session_type: name,
meeting_key: meetingKey,
date_start: start,
date_end: start,
gmt_offset: '',
},
meeting: meeting(meetingKey, 'British Grand Prix', start),
availability,
}
}
async function stubContext(page: Page, body: Record<string, unknown>): Promise<void> {
await page.route('**/api/v1/weekend-context', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) }),
)
// Keep supplementary reads hermetic and empty.
await page.route('**/api/v1/news**', (route) =>
route.fulfill({ contentType: 'application/json', body: '[]' }),
)
}
test.describe('Weekend canonical temporal states (injected)', () => {
test('between_weekends pairs the last completed event with the next-event countdown and Prepare CTA', async ({ page }) => {
await stubContext(page, {
season: 2026,
temporal_state: 'between_weekends',
previous_meeting: meeting(1, 'British Grand Prix', '2026-07-05T14:00:00Z'),
previous_completed_session: ctxSession(11, 'Race', '2026-07-05T14:00:00Z', 1),
default_analysis_session: ctxSession(11, 'Race', '2026-07-05T14:00:00Z', 1),
next_meeting: meeting(2, 'Hungarian Grand Prix', '2026-07-24T09:00:00Z'),
next_session: ctxSession(21, 'Practice 1', '2026-07-24T09:00:00Z', 2),
focus_meeting: meeting(2, 'Hungarian Grand Prix', '2026-07-24T09:00:00Z'),
championship_round: 12,
total_championship_rounds: 24,
})
await page.goto('/')
await expect(page.getByTestId('weekend-between-races')).toBeVisible()
await expect(page.getByTestId('wk-last-event')).toBeVisible()
await expect(page.getByTestId('wk-next-event')).toBeVisible()
await expect(page.getByTestId('wk-prepare')).toHaveAttribute('href', '/preview')
await expect(page.getByTestId('wk-season-nav')).toContainText('Round 12 of 24')
})
test('between_races Prepare CTA folds into the preparation surface instead of looping', async ({ page }) => {
await stubContext(page, {
season: 2026,
temporal_state: 'between_weekends',
next_meeting: meeting(2, 'Hungarian Grand Prix', '2026-07-24T09:00:00Z'),
next_session: ctxSession(21, 'Practice 1', '2026-07-24T09:00:00Z', 2),
championship_round: 12,
total_championship_rounds: 24,
})
await page.goto('/')
await page.getByTestId('wk-prepare').click()
await expect(page).toHaveURL(/\/preview$/)
await expect(page.getByTestId('weekend-pre-session')).toBeVisible()
await expect(page.getByTestId('weekend-between-races')).toHaveCount(0)
})
test('pre_session surfaces the preview and the next-session countdown', async ({ page }) => {
await stubContext(page, {
season: 2026,
temporal_state: 'pre_session',
next_meeting: meeting(2, 'Hungarian Grand Prix', '2026-07-24T09:00:00Z'),
next_session: ctxSession(21, 'Practice 1', '2026-07-24T09:00:00Z', 2),
focus_meeting: meeting(2, 'Hungarian Grand Prix', '2026-07-24T09:00:00Z'),
championship_round: 12,
total_championship_rounds: 24,
})
await page.goto('/')
await expect(page.getByTestId('weekend-pre-session')).toBeVisible()
await expect(page.getByTestId('wk-pre-head')).toBeVisible()
})
test('between_sessions shows the last result recap and the next-session countdown together', async ({ page }) => {
await stubContext(page, {
season: 2026,
temporal_state: 'between_sessions',
focus_meeting: meeting(1, 'British Grand Prix', '2026-07-04T10:00:00Z'),
previous_completed_session: ctxSession(11, 'Sprint', '2026-07-04T10:00:00Z', 1),
default_analysis_session: ctxSession(11, 'Sprint', '2026-07-04T10:00:00Z', 1),
next_session: ctxSession(12, 'Race', '2026-07-05T14:00:00Z', 1),
championship_round: 12,
total_championship_rounds: 24,
})
await page.goto('/')
await expect(page.getByTestId('weekend-between-sessions')).toBeVisible()
await expect(page.getByTestId('wk-last-session')).toBeVisible()
await expect(page.getByTestId('wk-next-session')).toBeVisible()
})
test('session_live hands off to live timing', async ({ page }) => {
await stubContext(page, {
season: 2026,
temporal_state: 'session_live',
focus_meeting: meeting(1, 'British Grand Prix', '2026-07-05T14:00:00Z'),
active_session: ctxSession(11, 'Race', '2026-07-05T14:00:00Z', 1),
championship_round: 12,
total_championship_rounds: 24,
})
await page.goto('/')
await expect(page.getByTestId('weekend-live')).toBeVisible()
await expect(page.getByTestId('wk-watch-live')).toHaveAttribute('href', '/live')
})
test('an error from the canonical endpoint shows an explicit error surface', async ({ page }) => {
await page.route('**/api/v1/weekend-context', (route) =>
route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"boom"}' }),
)
await page.goto('/')
await expect(page.getByTestId('weekend-error')).toBeVisible()
})
})

View File

@@ -1,118 +0,0 @@
import { test, expect, type Page } from '@playwright/test'
const FULL_SESSION = 9472
async function documentOverflow(page: Page): Promise<number> {
return page.evaluate(() => {
const doc = document.documentElement
return doc.scrollWidth - doc.clientWidth
})
}
test.describe('Weekend home (seeded canonical context)', () => {
test('renders the Weekend home from the canonical endpoint, not the limited fallback', async ({ page }) => {
await page.goto('/')
await expect(page.getByTestId('weekend-page')).toBeVisible()
// The seeded DB (Monaco 2025, completed) resolves to season_complete, driven
// by the canonical /api/v1/weekend-context endpoint.
await expect(page.getByTestId('weekend-page')).toHaveAttribute('data-temporal-state', 'season_complete')
await expect(page.getByTestId('weekend-between-races')).toBeVisible()
await expect(page.getByTestId('weekend-limited')).toHaveCount(0)
// No ingest/dataset terminology on the Weekend surface.
await expect(page.getByText(/ingest/i)).toHaveCount(0)
})
test('the completed-event CTA opens the canonical analysis session in Race Hub', async ({ page }) => {
await page.goto('/')
await expect(page.getByTestId('wk-last-event')).toBeVisible()
await page.getByTestId('wk-explore-race-story').click()
await expect(page).toHaveURL(/\/race-hub\?session_key=\d+/)
await expect(page.getByTestId('race-hub')).toBeVisible()
})
test('/preview alias resolves without looping back to the same screen', async ({ page }) => {
await page.goto('/preview')
// In the seeded season-complete state there is no next event, so /preview
// resolves to the Weekend home rather than a redirect loop.
await expect(page.getByTestId('weekend-page')).toBeVisible()
await expect(page).toHaveURL(/\/preview$/)
})
test('existing deep-link routes remain valid', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await expect(page.getByTestId('race-hub')).toBeVisible()
await expect(page.getByTestId('rh-overview')).toBeVisible()
await page.goto('/admin')
await expect(page.getByTestId('data-library')).toBeVisible()
await page.goto('/live')
await expect(page.getByTestId('live-inactive')).toBeVisible()
await page.goto('/explore')
await expect(page.getByTestId('explore-page')).toBeVisible()
})
})
test.describe('Weekend navigation hierarchy', () => {
test('all four destinations are reachable from primary navigation', async ({ page }) => {
await page.goto('/')
const primary = page.getByRole('navigation', { name: 'Primary' }).first()
for (const [label, url] of [
['Championship', /\/championship/],
['Briefing', /\/briefing/],
['Explore', /\/explore/],
['Weekend', /\/$/],
] as const) {
await primary.getByRole('link', { name: label, exact: true }).click()
await expect(page).toHaveURL(url)
}
})
test('Admin is an operator utility outside the Primary landmark', async ({ page }) => {
await page.goto('/')
const primaries = page.getByRole('navigation', { name: 'Primary' })
await expect(primaries.first()).toBeVisible()
// Admin must not appear inside any Primary landmark.
await expect(primaries.getByRole('link', { name: /Admin/i })).toHaveCount(0)
await expect(
page.getByRole('toolbar', { name: 'Operator utilities' }).getByRole('link', { name: 'Admin' }),
).toBeVisible()
})
})
test.describe('Weekend responsive — no global horizontal overflow', () => {
for (const { name, width, height } of [
{ name: 'mobile 390', width: 390, height: 844 },
{ name: 'tablet 768', width: 768, height: 1024 },
{ name: 'desktop 1440', width: 1440, height: 900 },
]) {
test(`no document overflow at ${name}`, async ({ page }) => {
await page.setViewportSize({ width, height })
await page.goto('/')
await expect(page.getByTestId('weekend-between-races')).toBeVisible()
expect(await documentOverflow(page)).toBeLessThanOrEqual(1)
})
}
test('mobile shows exactly one visible primary navigation (bottom bar)', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/')
await expect(page.getByTestId('weekend-page')).toBeVisible()
// The bottom bar is the single visible primary nav; the top bar's links are
// hidden by CSS at this breakpoint.
const bottom = page.locator('.app-bottom-nav')
await expect(bottom).toBeVisible()
await expect(bottom.getByRole('link')).toHaveCount(4)
const topLinks = page.locator('.app-nav .nav-links')
await expect(topLinks).toBeHidden()
// All four destinations remain reachable via the bottom bar.
for (const label of ['Weekend', 'Championship', 'Briefing', 'Explore']) {
await expect(bottom.getByRole('link', { name: new RegExp(label) })).toBeVisible()
}
})
})