fix(#98): preserve bare race hub handoff

This commit is contained in:
2026-07-29 03:46:57 -04:00
parent 56ea860101
commit b884ba8885
5 changed files with 192 additions and 37 deletions

View File

@@ -53,14 +53,6 @@ export function RaceHubPage({ sessionKey }: Props) {
enabled: preSession && preSessionMeetingKey != null && preSessionMeetingKey > 0,
})
useEffect(() => {
if (sessionKey !== 0) return
const target = context?.race_hub_default_session?.session.session_key
if (target && !context?.race_hub_pre_session) {
navigate({ to: '/race-hub', search: { session_key: target }, replace: true })
}
}, [sessionKey, context, navigate])
useEffect(() => {
if (sessionKey !== 0) return
const delay = refreshDeadlineDelay(context?.race_hub_refresh_at)
@@ -75,11 +67,15 @@ export function RaceHubPage({ sessionKey }: Props) {
return () => window.clearInterval(timer)
}, [preSession])
// A bare route retains canonical context ownership while rendering its
// completed analysis selection. Explicit URLs remain user-owned.
const selectedSessionKey = sessionKey || context?.race_hub_default_session?.session.session_key || 0
// ─── Active session payload ───
const raceHubQuery = useQuery({
queryKey: ['race-hub', sessionKey],
queryFn: () => fetchRaceHub(sessionKey),
enabled: sessionKey > 0,
queryKey: ['race-hub', selectedSessionKey],
queryFn: () => fetchRaceHub(selectedSessionKey),
enabled: selectedSessionKey > 0 && (sessionKey > 0 || !preSession),
staleTime: 30_000,
})
@@ -108,7 +104,7 @@ export function RaceHubPage({ sessionKey }: Props) {
if (preSession && preSessionRef) {
return <RaceHubPreSession session={preSessionRef} weekend={preSessionWeekendQuery.data} now={now} />
}
if (!context?.race_hub_default_session) {
if (!selectedSessionKey) {
return (
<div className="rh-page rh-empty" data-testid="race-hub-empty" style={accentStyle}>
<div className="rh-empty-band">
@@ -126,18 +122,13 @@ export function RaceHubPage({ sessionKey }: Props) {
</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) {
return (
<div className="rh-page" style={accentStyle}>
<div className="loading-state">loading session {sessionKey}</div>
<div className="loading-state">loading session {selectedSessionKey}</div>
</div>
)
}
@@ -147,7 +138,7 @@ export function RaceHubPage({ sessionKey }: Props) {
<div className="error-box">
{raceHubQuery.error instanceof Error
? raceHubQuery.error.message
: `Failed to load session ${sessionKey}.`}
: `Failed to load session ${selectedSessionKey}.`}
</div>
</div>
)
@@ -158,7 +149,7 @@ export function RaceHubPage({ sessionKey }: Props) {
const sessionMeta = weekend
? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w]))
: {}
const activeSessionMeta = sessionMeta[sessionKey]
const activeSessionMeta = sessionMeta[selectedSessionKey]
return (
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
@@ -184,7 +175,7 @@ export function RaceHubPage({ sessionKey }: Props) {
{switcherOpen && (
<WeekendSwitcher
currentMeetingKey={meetingKey}
currentSessionKey={sessionKey}
currentSessionKey={selectedSessionKey}
onClose={() => setSwitcherOpen(false)}
/>
)}
@@ -215,7 +206,7 @@ export function RaceHubPage({ sessionKey }: Props) {
<nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail">
{sessions.map((session) => {
const meta = sessionMeta[session.session_key]
const active = session.session_key === sessionKey
const active = session.session_key === selectedSessionKey
return (
<button
key={session.session_key}
@@ -268,7 +259,7 @@ export function RaceHubPage({ sessionKey }: Props) {
{formatCoverageHint(activeSessionMeta.datasets)} datasets local
</span>
)}
<span className="rh-active-key mono">key {sessionKey}</span>
<span className="rh-active-key mono">key {selectedSessionKey}</span>
</div>
)}
@@ -304,7 +295,7 @@ export function RaceHubPage({ sessionKey }: Props) {
<span className="sec-title">Driver Compare</span>
</div>
<CompareView
sessionKey={sessionKey}
sessionKey={selectedSessionKey}
results={data.results}
drivers={data.drivers}
/>
@@ -364,6 +355,7 @@ function RaceHubPreSession({ session, weekend, now }: { session: ContextSession;
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}>
@@ -371,7 +363,9 @@ function RaceHubPreSession({ session, weekend, now }: { session: ContextSession;
<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">
{session.session.session_name} begins in <span className="mono">{formatCountdown(target, new Date(now))}</span>
{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">

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { act, render, screen, waitFor, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
Outlet,
@@ -215,18 +215,18 @@ function renderRaceHub(sessionKey: number) {
return <RaceHubPage sessionKey={session_key ?? 0} />
},
})
window.history.pushState({}, '', sessionKey ? `/race-hub?session_key=${sessionKey}` : '/race-hub')
const router = createRouter({
routeTree: rootRoute.addChildren([raceHubRoute]),
history: undefined,
})
// Navigate to the URL before mounting
router.navigate({ to: '/race-hub', search: sessionKey ? { session_key: sessionKey } : {} })
return render(<RouterProvider router={router} />)
return { queryClient, ...render(<RouterProvider router={router} />) }
}
describe('RaceHubPage', () => {
beforeEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
mockFetchSeasons.mockResolvedValue([2025])
mockFetchLocalMeetings.mockResolvedValue([meeting])
@@ -235,6 +235,10 @@ describe('RaceHubPage', () => {
mockFetchWeekendContext.mockResolvedValue(analysisContext)
})
afterEach(() => {
vi.useRealTimers()
})
it('renders the workspace identity band, session rail, and overview for a known session', async () => {
renderRaceHub(9472)
@@ -280,7 +284,7 @@ describe('RaceHubPage', () => {
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
})
it('uses the server-selected completed analysis session for bare Race Hub', async () => {
it('uses the server-selected completed analysis session for bare Race Hub without changing the URL', async () => {
renderRaceHub(0)
await waitFor(() => expect(mockFetchRaceHub).toHaveBeenCalledWith(9472))
@@ -319,4 +323,42 @@ describe('RaceHubPage', () => {
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('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)
})
})