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 (
@@ -126,18 +122,13 @@ export function RaceHubPage({ sessionKey }: Props) {
) } - return ( -
-
resolving latest local weekend…
-
- ) } - // ─── Loading / error for the requested session_key ─── + // ─── Loading / error for the selected session ─── if (raceHubQuery.isLoading) { return (
-
loading session {sessionKey}…
+
loading session {selectedSessionKey}…
) } @@ -147,7 +138,7 @@ export function RaceHubPage({ sessionKey }: Props) {
{raceHubQuery.error instanceof Error ? raceHubQuery.error.message - : `Failed to load session ${sessionKey}.`} + : `Failed to load session ${selectedSessionKey}.`}
) @@ -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 (
@@ -184,7 +175,7 @@ export function RaceHubPage({ sessionKey }: Props) { {switcherOpen && ( setSwitcherOpen(false)} /> )} @@ -215,7 +206,7 @@ export function RaceHubPage({ sessionKey }: Props) {
)} @@ -304,7 +295,7 @@ export function RaceHubPage({ sessionKey }: Props) { Driver Compare @@ -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 (
@@ -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) + }) })