diff --git a/frontend/src/pages/RaceHubPage.tsx b/frontend/src/pages/RaceHubPage.tsx
index 195a24a..3e7ec26 100644
--- a/frontend/src/pages/RaceHubPage.tsx
+++ b/frontend/src/pages/RaceHubPage.tsx
@@ -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
}
- if (!context?.race_hub_default_session) {
+ if (!selectedSessionKey) {
return (
@@ -184,7 +175,7 @@ export function RaceHubPage({ sessionKey }: Props) {
{switcherOpen && (
setSwitcherOpen(false)}
/>
)}
@@ -215,7 +206,7 @@ export function RaceHubPage({ sessionKey }: Props) {
{sessions.map((session) => {
const meta = sessionMeta[session.session_key]
- const active = session.session_key === sessionKey
+ const active = session.session_key === selectedSessionKey
return (
)}
- key {sessionKey}
+ key {selectedSessionKey}
)}
@@ -304,7 +295,7 @@ export function RaceHubPage({ sessionKey }: Props) {
@@ -371,7 +363,9 @@ function RaceHubPreSession({ session, weekend, now }: { session: ContextSession;
box-box · race hub
{meeting?.meeting_name ?? 'Next race weekend'}
- {session.session.session_name} begins in {formatCountdown(target, new Date(now))}
+ {pendingLiveEvidence
+ ? `${session.session.session_name} is scheduled; awaiting live timing.`
+ : <>{session.session.session_name} begins in {formatCountdown(target, new Date(now))} >}
{sessions.length > 0 && (
diff --git a/frontend/src/test/RaceHubPage.test.tsx b/frontend/src/test/RaceHubPage.test.tsx
index 2a3f7e5..8eeca29 100644
--- a/frontend/src/test/RaceHubPage.test.tsx
+++ b/frontend/src/test/RaceHubPage.test.tsx
@@ -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
},
})
+ 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( )
+ return { queryClient, ...render( ) }
}
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)
+ })
})
diff --git a/internal/query/context.go b/internal/query/context.go
index 31a7f8a..63cb30b 100644
--- a/internal/query/context.go
+++ b/internal/query/context.go
@@ -24,6 +24,7 @@ const (
preSessionWindow = 48 * time.Hour
postWeekendWindow = 48 * time.Hour
+ raceHubPendingPollInterval = 15 * time.Second
)
// LiveEvidence is the small, transport-independent subset of FIA state needed
@@ -153,7 +154,7 @@ func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext,
}
}
- var previous, next, defaultAnalysis *contextCandidate
+ var previous, next, defaultAnalysis, pending *contextCandidate
for i := range candidates {
c := &candidates[i]
isActive := active != nil && active.session.SessionKey != 0 && c.session.SessionKey == active.session.SessionKey
@@ -167,6 +168,10 @@ func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext,
if !isActive && !c.start.IsZero() && !c.start.Before(now) && (next == nil || c.start.Before(next.start)) {
next = c
}
+ if !isActive && !c.complete && !c.start.IsZero() && !c.start.After(now) &&
+ (c.end.IsZero() || now.Before(c.end)) && (pending == nil || c.start.After(pending.start)) {
+ pending = c
+ }
}
if previous != nil {
@@ -191,14 +196,14 @@ func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext,
if out.FocusMeeting != nil {
out.ChampionshipRound = championshipRound(champMeetings, int(out.FocusMeeting.MeetingKey))
}
- applyRaceHubDefault(&out, active, defaultAnalysis, next, now)
+ applyRaceHubDefault(&out, active, defaultAnalysis, next, pending, now)
return out, nil
}
// applyRaceHubDefault is deliberately distinct from TemporalPreSession. Other
// weekend surfaces begin preparation 48 hours ahead; Race Hub remains an
// analysis destination until the one-hour handoff before the next session.
-func applyRaceHubDefault(out *WeekendContext, active, analysis, next *contextCandidate, now time.Time) {
+func applyRaceHubDefault(out *WeekendContext, active, analysis, next, pending *contextCandidate, now time.Time) {
if active != nil {
out.RaceHubDefaultSession = out.ActiveSession
return
@@ -214,6 +219,12 @@ func applyRaceHubDefault(out *WeekendContext, active, analysis, next *contextCan
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
}
diff --git a/internal/query/context_test.go b/internal/query/context_test.go
index 47b4620..3143b29 100644
--- a/internal/query/context_test.go
+++ b/internal/query/context_test.go
@@ -441,6 +441,22 @@ func TestResolveWeekendContextRaceHubDefault(t *testing.T) {
}
})
+ 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)
diff --git a/tests/race-hub.spec.ts b/tests/race-hub.spec.ts
index 66b0b43..c27458e 100644
--- a/tests/race-hub.spec.ts
+++ b/tests/race-hub.spec.ts
@@ -2,6 +2,42 @@ import { test, expect } from '@playwright/test'
const FULL_SESSION = 9472
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('lands on the Overview tab with workspace identity', async ({ page }) => {
@@ -102,9 +138,65 @@ 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 expect(page).toHaveURL(/session_key=\d+/)
+ await expect(page).toHaveURL(/\/race-hub$/)
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('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)
+ })
})