Merge pull request #99 from AmanTahiliani/feat/issue-98-keep-completed-analysis-as-the-race-hub-

Keep completed analysis as the Race Hub default until the next Grand Prix is imminent (#98)
This commit is contained in:
Aman Tahiliani
2026-07-29 03:58:30 -04:00
committed by GitHub
11 changed files with 628 additions and 85 deletions

View File

@@ -15,6 +15,7 @@ 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> {
@@ -114,6 +115,14 @@ export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
return res.json() return res.json()
} }
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

@@ -47,7 +47,7 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
} }
return ( return (
<div className="rh-switcher" data-testid="rh-switcher"> <div id="rh-weekend-switcher" className="rh-switcher" data-testid="rh-switcher">
<div className="rh-switcher-head"> <div className="rh-switcher-head">
<span className="sec-title">Switch Weekend</span> <span className="sec-title">Switch Weekend</span>
<div className="rh-switcher-years"> <div className="rh-switcher-years">

View File

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

View File

@@ -1,12 +1,7 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { import { fetchRaceHub, fetchWeekend, fetchWeekendContext } from '../api'
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'
@@ -22,76 +17,72 @@ import { SourceBadge } from '../components/SourceBadge'
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity' import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage' import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
import { import {
MAX_BROWSER_TIMEOUT,
formatCountdown,
formatSessionScheduleTime, formatSessionScheduleTime,
pickFocusMeeting, refreshDeadlineDelay,
sortSessionsByStart, sortSessionsByStart,
} from '../lib/schedule' } from '../lib/schedule'
import type { Weekend, WeekendSession } from '../types' import type { ContextSession, Weekend } 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 [now, setNow] = useState(() => Date.now())
const [refreshGeneration, setRefreshGeneration] = useState(0)
// ─── Auto-redirect when no session_key is supplied ─── // The server owns bare Race Hub selection so every open tab crosses the
const seasonsQuery = useQuery({ // one-hour handoff at the same instant.
queryKey: ['seasons'], const contextQuery = useQuery({
queryFn: fetchSeasons, queryKey: ['weekend-context'],
queryFn: fetchWeekendContext,
enabled: sessionKey === 0, enabled: sessionKey === 0,
}) })
const { refetch: refetchContext } = contextQuery
const latestSeason = seasonsQuery.data?.[0] ?? null const context = contextQuery.data
const preSession = sessionKey === 0 && context?.race_hub_pre_session === true
const meetingsQuery = useQuery({ const preSessionRef = context?.race_hub_default_session
queryKey: ['meetings', latestSeason], const preSessionMeetingKey = preSessionRef?.meeting?.meeting_key
queryFn: () => fetchLocalMeetings(latestSeason!), const preSessionWeekendQuery = useQuery({
enabled: sessionKey === 0 && latestSeason != null, queryKey: ['weekend', preSessionMeetingKey],
}) queryFn: () => fetchWeekend(preSessionMeetingKey!),
enabled: preSession && preSessionMeetingKey != null && preSessionMeetingKey > 0,
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,
}) })
useEffect(() => { useEffect(() => {
if (sessionKey !== 0) return if (sessionKey !== 0) return
const weekend = fallbackWeekendQuery.data const delay = refreshDeadlineDelay(context?.race_hub_refresh_at)
if (!weekend) return if (delay == null) return
const target = pickAnalysisSession(weekend)?.session.session_key const rearmAfterRefetch = delay === MAX_BROWSER_TIMEOUT
?? weekend.default_session_key const timer = window.setTimeout(() => {
?? weekend.sessions[0]?.session.session_key void refetchContext().finally(() => {
if (target) { if (rearmAfterRefetch) setRefreshGeneration((generation) => generation + 1)
navigate({ to: '/race-hub', search: { session_key: target }, replace: true }) })
} }, delay)
}, [sessionKey, fallbackWeekendQuery.data, navigate]) return () => window.clearTimeout(timer)
}, [sessionKey, context?.race_hub_refresh_at, refetchContext, refreshGeneration])
useEffect(() => {
if (!preSession) return
const timer = window.setInterval(() => setNow(Date.now()), 1_000)
return () => window.clearInterval(timer)
}, [preSession])
// A bare route retains canonical context ownership while rendering its
// completed analysis selection. Explicit URLs remain user-owned.
const selectedSessionKey = sessionKey || context?.race_hub_default_session?.session.session_key || 0
// ─── Active session payload ─── // ─── Active session payload ───
const raceHubQuery = useQuery({ const raceHubQuery = useQuery({
queryKey: ['race-hub', sessionKey], queryKey: ['race-hub', selectedSessionKey],
queryFn: () => fetchRaceHub(sessionKey), queryFn: () => fetchRaceHub(selectedSessionKey),
enabled: sessionKey > 0, enabled: selectedSessionKey > 0 && (sessionKey > 0 || !preSession),
staleTime: 30_000, staleTime: 30_000,
}) })
@@ -108,25 +99,36 @@ export function RaceHubPage({ sessionKey }: Props) {
const accent = countryAccent(data?.meeting ?? null) const accent = countryAccent(data?.meeting ?? null)
const accentStyle = { '--gp-accent': accent } as React.CSSProperties const accentStyle = { '--gp-accent': accent } as React.CSSProperties
// ─── No session_key: show resolving state, fall back to switcher if no local data ─── // ─── No session_key: resolve exclusively through canonical Weekend Context ───
if (sessionKey === 0) { if (sessionKey === 0) {
if (seasonsQuery.isLoading || meetingsQuery.isLoading || fallbackWeekendQuery.isLoading) { if (contextQuery.isLoading || (preSession && preSessionWeekendQuery.isLoading)) {
return ( return (
<div className="rh-page" style={accentStyle}> <div className="rh-page" style={accentStyle}>
<div className="loading-state">resolving latest local weekend</div> <div className="loading-state">resolving latest local weekend</div>
</div> </div>
) )
} }
const seasons = seasonsQuery.data ?? [] if (preSession && preSessionRef) {
if (seasons.length === 0) { return (
<RaceHubPreSession
session={preSessionRef}
weekend={preSessionWeekendQuery.data}
now={now}
switcherOpen={switcherOpen}
onToggleSwitcher={() => setSwitcherOpen((open) => !open)}
onCloseSwitcher={() => setSwitcherOpen(false)}
/>
)
}
if (!selectedSessionKey) {
return ( return (
<div className="rh-page rh-empty" data-testid="race-hub-empty" style={accentStyle}> <div className="rh-page rh-empty" data-testid="race-hub-empty" style={accentStyle}>
<div className="rh-empty-band"> <div className="rh-empty-band">
<span className="rh-empty-eyebrow mono">box-box · race hub</span> <span className="rh-empty-eyebrow mono">box-box · race hub</span>
<h1 className="rh-empty-title">No local sessions yet</h1> <h1 className="rh-empty-title">No completed local analysis yet</h1>
<p className="rh-empty-sub"> <p className="rh-empty-sub">
The Race Hub reads from local ingest only. Once a weekend is ingested Race Hub opens completed local analysis between weekends. Check Data Health
it will open here automatically. to ingest a completed session.
</p> </p>
<div className="rh-empty-actions"> <div className="rh-empty-actions">
<a href="/admin" className="rh-empty-action">Open Admin · Data Health</a> <a href="/admin" className="rh-empty-action">Open Admin · Data Health</a>
@@ -136,18 +138,13 @@ export function RaceHubPage({ sessionKey }: Props) {
</div> </div>
) )
} }
return (
<div className="rh-page" style={accentStyle}>
<div className="loading-state">resolving latest local weekend</div>
</div>
)
} }
// ─── Loading / error for the requested session_key ─── // ─── Loading / error for the selected session ───
if (raceHubQuery.isLoading) { if (raceHubQuery.isLoading) {
return ( return (
<div className="rh-page" style={accentStyle}> <div className="rh-page" style={accentStyle}>
<div className="loading-state">loading session {sessionKey}</div> <div className="loading-state">loading session {selectedSessionKey}</div>
</div> </div>
) )
} }
@@ -157,7 +154,7 @@ export function RaceHubPage({ sessionKey }: Props) {
<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 ${selectedSessionKey}.`}
</div> </div>
</div> </div>
) )
@@ -168,7 +165,7 @@ export function RaceHubPage({ sessionKey }: Props) {
const sessionMeta = weekend const sessionMeta = weekend
? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w])) ? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w]))
: {} : {}
const activeSessionMeta = sessionMeta[sessionKey] const activeSessionMeta = sessionMeta[selectedSessionKey]
return ( return (
<div className="rh-page" data-testid="race-hub" style={accentStyle}> <div className="rh-page" data-testid="race-hub" style={accentStyle}>
@@ -194,7 +191,7 @@ export function RaceHubPage({ sessionKey }: Props) {
{switcherOpen && ( {switcherOpen && (
<WeekendSwitcher <WeekendSwitcher
currentMeetingKey={meetingKey} currentMeetingKey={meetingKey}
currentSessionKey={sessionKey} currentSessionKey={selectedSessionKey}
onClose={() => setSwitcherOpen(false)} onClose={() => setSwitcherOpen(false)}
/> />
)} )}
@@ -225,7 +222,7 @@ export function RaceHubPage({ sessionKey }: Props) {
<nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail"> <nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail">
{sessions.map((session) => { {sessions.map((session) => {
const meta = sessionMeta[session.session_key] const meta = sessionMeta[session.session_key]
const active = session.session_key === sessionKey const active = session.session_key === selectedSessionKey
return ( return (
<button <button
key={session.session_key} key={session.session_key}
@@ -278,7 +275,7 @@ export function RaceHubPage({ sessionKey }: Props) {
{formatCoverageHint(activeSessionMeta.datasets)} datasets local {formatCoverageHint(activeSessionMeta.datasets)} datasets local
</span> </span>
)} )}
<span className="rh-active-key mono">key {sessionKey}</span> <span className="rh-active-key mono">key {selectedSessionKey}</span>
</div> </div>
)} )}
@@ -314,7 +311,7 @@ export function RaceHubPage({ sessionKey }: Props) {
<span className="sec-title">Driver Compare</span> <span className="sec-title">Driver Compare</span>
</div> </div>
<CompareView <CompareView
sessionKey={sessionKey} sessionKey={selectedSessionKey}
results={data.results} results={data.results}
drivers={data.drivers} drivers={data.drivers}
/> />
@@ -368,3 +365,72 @@ export function RaceHubPage({ sessionKey }: Props) {
</div> </div>
) )
} }
function RaceHubPreSession({
session,
weekend,
now,
switcherOpen,
onToggleSwitcher,
onCloseSwitcher,
}: {
session: ContextSession
weekend?: Weekend
now: number
switcherOpen: boolean
onToggleSwitcher: () => void
onCloseSwitcher: () => void
}) {
const meeting = session.meeting
const sessions = sortSessionsByStart((weekend?.sessions ?? []).map((entry) => entry.session))
const target = new Date(session.session.date_start)
const accent = countryAccent(meeting ?? null)
const pendingLiveEvidence = target.getTime() <= now
return (
<div className="rh-page rh-empty" data-testid="race-hub-pre-session" style={{ '--gp-accent': accent } as React.CSSProperties}>
<div className="rh-topbar">
<span className="rh-topbar-label mono">box-box · race hub</span>
<span className="rh-topbar-spacer" />
<button
type="button"
className={`rh-switcher-toggle${switcherOpen ? ' active' : ''}`}
onClick={onToggleSwitcher}
aria-expanded={switcherOpen}
aria-controls="rh-weekend-switcher"
data-testid="rh-switch-weekend"
>
{switcherOpen ? 'Close' : 'Switch Weekend'}
</button>
</div>
{switcherOpen && (
<WeekendSwitcher
currentMeetingKey={meeting?.meeting_key}
currentSessionKey={session.session.session_key}
onClose={onCloseSwitcher}
/>
)}
<section className="rh-empty-band">
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
<h1 className="rh-empty-title">{meeting?.meeting_name ?? 'Next race weekend'}</h1>
<p className="rh-empty-sub">
{pendingLiveEvidence
? `${session.session.session_name} is scheduled; awaiting live timing.`
: <>{session.session.session_name} begins in <span className="mono">{formatCountdown(target, new Date(now))}</span></>}
</p>
{sessions.length > 0 && (
<div className="preview-schedule" data-testid="rh-pre-session-schedule">
{sessions.map((scheduled) => (
<div key={scheduled.session_key} className="preview-schedule-item">
<span className="preview-schedule-name">{scheduled.session_name}</span>
<span className="preview-schedule-time">{formatSessionScheduleTime(scheduled.date_start)}</span>
</div>
))}
</div>
)}
</section>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react' import { act, render, screen, waitFor, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { import {
Outlet, Outlet,
@@ -9,21 +9,24 @@ import {
createRoute, createRoute,
} from '@tanstack/react-router' } from '@tanstack/react-router'
import { RaceHubPage } from '../pages/RaceHubPage' import { RaceHubPage } from '../pages/RaceHubPage'
import type { DatasetInfo, Meeting, RaceHub, Session, Weekend } from '../types' import { MAX_BROWSER_TIMEOUT } from '../lib/schedule'
import type { ContextAvailability, 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 { fetchRaceHub, fetchSeasons, fetchLocalMeetings, fetchWeekend } from '../api' import { fetchLocalMeetings, fetchRaceHub, fetchSeasons, 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 meeting: Meeting = { const meeting: Meeting = {
meeting_key: 1229, meeting_key: 1229,
@@ -169,6 +172,26 @@ const weekend: Weekend = {
], ],
} }
const availability: ContextAvailability = {
source: 'local',
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'complete',
freshness: 'local',
limitations: [],
}
const analysisContext: WeekendContext = {
temporal_state: 'between_weekends',
default_analysis_session: { session: raceSession, meeting, availability },
race_hub_default_session: { session: raceSession, meeting, availability },
race_hub_pre_session: false,
championship_round: 1,
total_championship_rounds: 24,
}
function renderRaceHub(sessionKey: number) { function renderRaceHub(sessionKey: number) {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }, defaultOptions: { queries: { retry: false } },
@@ -193,23 +216,28 @@ function renderRaceHub(sessionKey: number) {
return <RaceHubPage sessionKey={session_key ?? 0} /> return <RaceHubPage sessionKey={session_key ?? 0} />
}, },
}) })
window.history.pushState({}, '', sessionKey ? `/race-hub?session_key=${sessionKey}` : '/race-hub')
const router = createRouter({ const router = createRouter({
routeTree: rootRoute.addChildren([raceHubRoute]), routeTree: rootRoute.addChildren([raceHubRoute]),
history: undefined, history: undefined,
}) })
// Navigate to the URL before mounting return { queryClient, ...render(<RouterProvider router={router} />) }
router.navigate({ to: '/race-hub', search: sessionKey ? { session_key: sessionKey } : {} })
return render(<RouterProvider router={router} />)
} }
describe('RaceHubPage', () => { describe('RaceHubPage', () => {
beforeEach(() => { beforeEach(() => {
vi.useRealTimers()
vi.clearAllMocks() vi.clearAllMocks()
mockFetchSeasons.mockResolvedValue([2025]) mockFetchSeasons.mockResolvedValue([2025])
mockFetchLocalMeetings.mockResolvedValue([meeting]) mockFetchLocalMeetings.mockResolvedValue([meeting])
mockFetchWeekend.mockResolvedValue(weekend) mockFetchWeekend.mockResolvedValue(weekend)
mockFetchRaceHub.mockResolvedValue(raceHub) mockFetchRaceHub.mockResolvedValue(raceHub)
mockFetchWeekendContext.mockResolvedValue(analysisContext)
})
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 () => {
@@ -256,4 +284,122 @@ 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('uses the server-selected completed analysis session for bare Race Hub without changing the URL', async () => {
renderRaceHub(0)
await waitFor(() => expect(mockFetchRaceHub).toHaveBeenCalledWith(9472))
expect(mockFetchWeekendContext).toHaveBeenCalledTimes(1)
})
it('renders the intentional pre-session state without analysis cards', async () => {
mockFetchWeekendContext.mockResolvedValue({
...analysisContext,
race_hub_default_session: {
session: { ...raceSession, session_key: 9473, session_name: 'Practice 1', session_type: 'Practice', date_start: '2099-05-23T13:00:00Z' },
meeting,
availability,
},
race_hub_pre_session: true,
race_hub_refresh_at: '2099-05-23T13:00:00Z',
})
renderRaceHub(0)
expect(await screen.findByTestId('race-hub-pre-session')).toBeInTheDocument()
expect(screen.getByTestId('rh-pre-session-schedule')).toBeInTheDocument()
expect(screen.queryByText('Winner')).not.toBeInTheDocument()
expect(mockFetchRaceHub).not.toHaveBeenCalled()
})
it('opens the weekend switcher from pre-session and navigates to the selected explicit session', async () => {
mockFetchWeekendContext.mockResolvedValue({
...analysisContext,
race_hub_default_session: {
session: { ...raceSession, session_key: 9473, session_name: 'Practice 1', session_type: 'Practice', date_start: '2099-05-23T13:00:00Z' },
meeting,
availability,
},
race_hub_pre_session: true,
race_hub_refresh_at: '2099-05-23T13:00:00Z',
})
renderRaceHub(0)
const switchWeekend = await screen.findByTestId('rh-switch-weekend')
expect(switchWeekend).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(switchWeekend)
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
expect(switchWeekend).toHaveAttribute('aria-expanded', 'true')
fireEvent.click(await screen.findByTestId('rh-switcher-session-9471'))
await waitFor(() => expect(window.location.search).toBe('?session_key=9471'))
})
it('shows recovery instead of selecting an empty future session', async () => {
mockFetchWeekendContext.mockResolvedValue({
...analysisContext,
race_hub_default_session: undefined,
race_hub_pre_session: false,
})
renderRaceHub(0)
expect(await screen.findByTestId('race-hub-empty')).toHaveTextContent('No completed local analysis yet')
expect(mockFetchRaceHub).not.toHaveBeenCalled()
})
it('hands a bare route from completed analysis to pre-session at the supplied refresh boundary', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
const handoff = new Date(Date.now() + 10_000).toISOString()
const pendingContext: WeekendContext = {
...analysisContext,
race_hub_default_session: {
session: { ...raceSession, session_key: 9473, session_name: 'Practice 1', session_type: 'Practice', date_start: handoff },
meeting,
availability,
},
race_hub_pre_session: true,
race_hub_refresh_at: new Date(Date.now() + 16_000).toISOString(),
}
mockFetchWeekendContext
.mockResolvedValueOnce({ ...analysisContext, race_hub_refresh_at: handoff })
.mockResolvedValueOnce(pendingContext)
renderRaceHub(0)
await screen.findByTestId('race-hub')
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
expect(await screen.findByTestId('race-hub-pre-session')).toBeInTheDocument()
expect(mockFetchWeekendContext).toHaveBeenCalledTimes(2)
expect(mockFetchRaceHub).toHaveBeenCalledWith(9472)
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9473)
})
it('re-arms a bare route refresh after a capped browser timer', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
mockFetchWeekendContext.mockResolvedValue({
...analysisContext,
race_hub_refresh_at: new Date(Date.now() + MAX_BROWSER_TIMEOUT + 1_000).toISOString(),
})
renderRaceHub(0)
await screen.findByTestId('race-hub')
await act(async () => { await vi.advanceTimersByTimeAsync(MAX_BROWSER_TIMEOUT) })
await waitFor(() => expect(mockFetchWeekendContext).toHaveBeenCalledTimes(2))
await act(async () => { await vi.advanceTimersByTimeAsync(1_000) })
await waitFor(() => expect(mockFetchWeekendContext).toHaveBeenCalledTimes(3))
})
it('keeps an explicit session URL stable across the canonical refresh boundary', async () => {
vi.useFakeTimers()
renderRaceHub(9472)
await act(async () => { await vi.advanceTimersByTimeAsync(60_000) })
expect(mockFetchWeekendContext).not.toHaveBeenCalled()
expect(mockFetchRaceHub).toHaveBeenCalledWith(9472)
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9473)
})
}) })

View File

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

View File

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

View File

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

View File

@@ -399,3 +399,88 @@ func TestResolveWeekendContextMissingScheduleDoesNotClaimSeasonComplete(t *testi
t.Fatalf("total rounds = %d, want scheduled round retained", got.TotalChampionshipRounds) t.Fatalf("total rounds = %d, want scheduled round retained", got.TotalChampionshipRounds)
} }
} }
func TestResolveWeekendContextRaceHubDefault(t *testing.T) {
seed := func(t *testing.T, svc *Service) {
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
completeContextSession(t, svc, 11, 1)
addContextMeeting(t, svc, 2, "Belgian Grand Prix", "2026-07-17T09:00:00Z", "2026-07-19T16:00:00Z", false)
addContextSession(t, svc, 21, 2, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
}
t.Run("keeps completed analysis before handoff", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-16T07:59:59Z")
svc := contextService(t, now)
seed(t, svc)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 11 || got.RaceHubPreSession {
t.Fatalf("race hub default = %+v, pre-session = %t", got.RaceHubDefaultSession, got.RaceHubPreSession)
}
if got.RaceHubRefreshAt != "2026-07-17T08:00:00Z" {
t.Fatalf("refresh = %q", got.RaceHubRefreshAt)
}
})
t.Run("hands off exactly one hour before first session", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-17T08:00:00Z")
svc := contextService(t, now)
seed(t, svc)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 21 || !got.RaceHubPreSession {
t.Fatalf("race hub handoff = %+v, pre-session = %t", got.RaceHubDefaultSession, got.RaceHubPreSession)
}
if got.RaceHubRefreshAt != "2026-07-17T09:00:00Z" {
t.Fatalf("refresh = %q", got.RaceHubRefreshAt)
}
})
t.Run("keeps the scheduled session pending after its start without live evidence", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-17T09:00:00Z")
svc := contextService(t, now)
seed(t, svc)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 21 || !got.RaceHubPreSession {
t.Fatalf("scheduled race hub default = %+v, pre-session = %t", got.RaceHubDefaultSession, got.RaceHubPreSession)
}
if got.RaceHubRefreshAt != "2026-07-17T09:00:15Z" {
t.Fatalf("refresh = %q", got.RaceHubRefreshAt)
}
})
t.Run("active live session wins", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-17T08:30:00Z")
svc := contextService(t, now)
seed(t, svc)
got, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "Belgian Grand Prix", SessionName: "Practice 1", SessionType: "Practice 1"})
if err != nil {
t.Fatal(err)
}
if got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 21 || got.RaceHubPreSession {
t.Fatalf("live default = %+v, pre-session = %t", got.RaceHubDefaultSession, got.RaceHubPreSession)
}
})
t.Run("does not select an empty future session before handoff", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-16T12:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 2, "Belgian Grand Prix", "2026-07-17T09:00:00Z", "2026-07-19T16:00:00Z", false)
addContextSession(t, svc, 21, 2, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.RaceHubDefaultSession != nil || got.RaceHubPreSession {
t.Fatalf("unexpected empty future default: %+v", got)
}
})
}

View File

@@ -167,3 +167,30 @@ func TestTerminalSessionStatus(t *testing.T) {
} }
} }
} }
func TestWeekendContextHandlerSerializesRaceHubRefreshDeadline(t *testing.T) {
st := openContextStore(t)
seedContextHandler(t, st)
if err := st.UpsertSessionResult(store.SessionResult{SessionKey: 11, MeetingKey: 1, DriverNumber: 1, Position: 1}); err != nil {
t.Fatal(err)
}
if err := st.UpsertMeeting(store.Meeting{MeetingKey: 2, MeetingName: "Belgian Grand Prix", CircuitShortName: "Spa", Year: 2026, DateStart: "2026-07-17T09:00:00Z", DateEnd: "2026-07-19T16:00:00Z"}); err != nil {
t.Fatal(err)
}
if err := st.UpsertSession(store.Session{SessionKey: 21, MeetingKey: 2, SessionName: "Practice 1", SessionType: "Practice", DateStart: "2026-07-17T09:00:00Z", DateEnd: "2026-07-17T10:00:00Z"}); err != nil {
t.Fatal(err)
}
s := NewServer(nil, 0, st)
s.query = query.NewServiceWithClock(st, func() time.Time {
return time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)
})
rr := httptest.NewRecorder()
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
var got query.WeekendContext
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !got.RaceHubPreSession || got.RaceHubRefreshAt != "2026-07-17T09:00:00Z" || got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 21 {
t.Fatalf("race hub context = %+v", got)
}
}

View File

@@ -2,6 +2,42 @@ import { test, expect } from '@playwright/test'
const FULL_SESSION = 9472 const FULL_SESSION = 9472
const CORE_ONLY_SESSION = 9000 const CORE_ONLY_SESSION = 9000
const CONTEXT_MEETING = {
meeting_key: 1229,
meeting_name: 'Monaco',
country_code: 'MON',
}
const CONTEXT_AVAILABILITY = {
source: 'local', schedule: 'available', live_transport: 'unknown', live_session: 'inactive',
archive: 'unavailable', local_analysis: 'complete', freshness: 'local', limitations: [],
}
function completedContext(refreshAt?: string) {
return {
temporal_state: 'between_weekends',
race_hub_default_session: {
session: { session_key: FULL_SESSION }, meeting: CONTEXT_MEETING, availability: CONTEXT_AVAILABILITY,
},
race_hub_pre_session: false,
race_hub_refresh_at: refreshAt,
}
}
function pendingContext(refreshAt: string) {
return {
temporal_state: 'pre_session',
race_hub_default_session: {
session: {
session_key: 9473, meeting_key: 1229, session_name: 'Practice 1', session_type: 'Practice',
date_start: '2030-01-01T00:00:01Z', date_end: '2030-01-01T01:00:01Z', gmt_offset: '00:00:00',
},
meeting: CONTEXT_MEETING,
availability: { ...CONTEXT_AVAILABILITY, local_analysis: 'not_applicable' },
},
race_hub_pre_session: true,
race_hub_refresh_at: refreshAt,
}
}
test.describe('Race Hub Weekend Workspace', () => { test.describe('Race Hub Weekend Workspace', () => {
test('lands on the Overview tab with workspace identity', async ({ page }) => { test('lands on the Overview tab with workspace identity', async ({ page }) => {
@@ -102,9 +138,87 @@ test.describe('Race Hub Weekend Workspace', () => {
) )
}) })
test('bare /race-hub redirects to the focus session', async ({ page }) => { test('bare /race-hub shows server-selected completed analysis without changing the URL', async ({ page }) => {
await page.route('**/api/v1/weekend-context', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(completedContext()) }),
)
await page.goto('/race-hub') await page.goto('/race-hub')
await expect(page).toHaveURL(/session_key=\d+/) await expect(page).toHaveURL(/\/race-hub$/)
await expect(page.getByTestId('race-hub')).toBeVisible() await expect(page.getByTestId('race-hub')).toBeVisible()
}) })
test('bare /race-hub hands off to pending pre-session state at the refresh deadline', async ({ page }) => {
await page.clock.install({ time: new Date('2030-01-01T00:00:00Z') })
let requests = 0
const raceHubRequests: number[] = []
page.on('request', (request) => {
const url = new URL(request.url())
if (url.pathname === '/api/v1/race-hub') {
raceHubRequests.push(Number(url.searchParams.get('session_key')))
}
})
await page.route('**/api/v1/weekend-context', (route) => {
requests += 1
const body = requests === 1
? completedContext('2030-01-01T00:00:01Z')
: pendingContext('2030-01-01T00:00:16Z')
return route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
})
await page.goto('/race-hub')
await expect(page.getByTestId('race-hub')).toBeVisible()
await page.clock.fastForward(1_000)
await expect(page.getByTestId('race-hub-pre-session')).toBeVisible()
await expect(page).toHaveURL(/\/race-hub$/)
expect(raceHubRequests).toContain(FULL_SESSION)
expect(raceHubRequests).not.toContain(9473)
})
test('pre-session state opens the weekend switcher and navigates to an explicit session', async ({ page }) => {
await page.route('**/api/v1/weekend-context', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify(pendingContext('2030-01-01T00:00:16Z')),
}),
)
await page.goto('/race-hub')
await expect(page.getByTestId('race-hub-pre-session')).toBeVisible()
const switchWeekend = page.getByTestId('rh-switch-weekend')
await expect(switchWeekend).toHaveAttribute('aria-expanded', 'false')
await switchWeekend.click()
await expect(page.getByTestId('rh-switcher')).toBeVisible()
await expect(switchWeekend).toHaveAttribute('aria-expanded', 'true')
await page.getByTestId(`rh-switcher-session-${FULL_SESSION}`).click()
await expect(page).toHaveURL(new RegExp(`/race-hub\\?session_key=${FULL_SESSION}`))
await expect(page.getByTestId('race-hub')).toBeVisible()
})
test('bare /race-hub recovers when no completed local analysis exists', async ({ page }) => {
await page.route('**/api/v1/weekend-context', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ temporal_state: 'between_weekends', race_hub_pre_session: false }),
}),
)
await page.goto('/race-hub')
await expect(page.getByTestId('race-hub-empty')).toContainText('No completed local analysis yet')
})
test('an explicit session URL remains stable when canonical context would refresh', async ({ page }) => {
let contextRequested = false
await page.route('**/api/v1/weekend-context', (route) => {
contextRequested = true
return route.fulfill({ contentType: 'application/json', body: JSON.stringify(pendingContext('2030-01-01T00:00:01Z')) })
})
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await expect(page.getByTestId('race-hub')).toBeVisible()
await expect(page).toHaveURL(new RegExp(`session_key=${FULL_SESSION}`))
expect(contextRequested).toBe(false)
})
}) })