From 56ea86010146e003be899a64940d48f0af008c72 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Wed, 29 Jul 2026 03:28:42 -0400 Subject: [PATCH 1/4] feat(#98): Keep completed analysis as the Race Hub default until the next Grand Prix is imminent Implemented by opencode via .agents/dev dispatch. --- frontend/src/api.ts | 9 ++ frontend/src/lib/schedule.ts | 7 ++ frontend/src/pages/RaceHubPage.tsx | 131 ++++++++++++++----------- frontend/src/test/RaceHubPage.test.tsx | 67 ++++++++++++- frontend/src/test/schedule.test.ts | 6 ++ frontend/src/types.ts | 35 +++++++ internal/query/context.go | 28 ++++++ internal/query/context_test.go | 69 +++++++++++++ internal/web/context_test.go | 27 +++++ 9 files changed, 321 insertions(+), 58 deletions(-) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d749b5f..6466dcb 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -15,6 +15,7 @@ import type { Session, TrackOutline, Weekend, + WeekendContext, } from './types' export async function fetchRaceHub(sessionKey: number): Promise { @@ -114,6 +115,14 @@ export async function fetchWeekend(meetingKey: number): Promise { return res.json() } +export async function fetchWeekendContext(): Promise { + 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 { const params = new URLSearchParams({ source: 'auto' }) if (year) params.set('year', year.toString()) diff --git a/frontend/src/lib/schedule.ts b/frontend/src/lib/schedule.ts index 926af49..00cd527 100644 --- a/frontend/src/lib/schedule.ts +++ b/frontend/src/lib/schedule.ts @@ -145,6 +145,13 @@ export function formatSessionScheduleTime(value: string): string { }) } +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), 2_147_483_647) +} + export type FocusMeetingKind = 'current' | 'next' | 'recent' | 'fallback' export function focusMeetingKind(meeting: Meeting, now: Date): FocusMeetingKind { diff --git a/frontend/src/pages/RaceHubPage.tsx b/frontend/src/pages/RaceHubPage.tsx index e680e9d..195a24a 100644 --- a/frontend/src/pages/RaceHubPage.tsx +++ b/frontend/src/pages/RaceHubPage.tsx @@ -1,12 +1,7 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { useNavigate } from '@tanstack/react-router' -import { - fetchLocalMeetings, - fetchRaceHub, - fetchSeasons, - fetchWeekend, -} from '../api' +import { fetchRaceHub, fetchWeekend, fetchWeekendContext } from '../api' import { DatasetStrip } from '../components/DatasetStrip' import { RaceStoryCanvas } from '../components/RaceStoryCanvas' import { TabBar, type Tab } from '../components/TabBar' @@ -22,70 +17,63 @@ import { SourceBadge } from '../components/SourceBadge' import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity' import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage' import { + formatCountdown, formatSessionScheduleTime, - pickFocusMeeting, + refreshDeadlineDelay, sortSessionsByStart, } from '../lib/schedule' -import type { Weekend, WeekendSession } from '../types' +import type { ContextSession, Weekend } from '../types' interface Props { 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) { const navigate = useNavigate() const [activeTab, setActiveTab] = useState('overview') const [switcherOpen, setSwitcherOpen] = useState(false) + const [now, setNow] = useState(() => Date.now()) - // ─── Auto-redirect when no session_key is supplied ─── - const seasonsQuery = useQuery({ - queryKey: ['seasons'], - queryFn: fetchSeasons, + // The server owns bare Race Hub selection so every open tab crosses the + // one-hour handoff at the same instant. + const contextQuery = useQuery({ + queryKey: ['weekend-context'], + queryFn: fetchWeekendContext, enabled: sessionKey === 0, }) + const { refetch: refetchContext } = contextQuery - const latestSeason = seasonsQuery.data?.[0] ?? null - - const meetingsQuery = useQuery({ - 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 + const preSession = sessionKey === 0 && context?.race_hub_pre_session === true + const preSessionRef = context?.race_hub_default_session + const preSessionMeetingKey = preSessionRef?.meeting?.meeting_key + const preSessionWeekendQuery = useQuery({ + queryKey: ['weekend', preSessionMeetingKey], + queryFn: () => fetchWeekend(preSessionMeetingKey!), + enabled: preSession && preSessionMeetingKey != null && preSessionMeetingKey > 0, }) useEffect(() => { if (sessionKey !== 0) return - const weekend = fallbackWeekendQuery.data - if (!weekend) return - const target = pickAnalysisSession(weekend)?.session.session_key - ?? weekend.default_session_key - ?? weekend.sessions[0]?.session.session_key - if (target) { + 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, fallbackWeekendQuery.data, navigate]) + }, [sessionKey, context, navigate]) + + useEffect(() => { + if (sessionKey !== 0) return + const delay = refreshDeadlineDelay(context?.race_hub_refresh_at) + if (delay == null) return + const timer = window.setTimeout(() => { void refetchContext() }, delay) + return () => window.clearTimeout(timer) + }, [sessionKey, context?.race_hub_refresh_at, refetchContext]) + + useEffect(() => { + if (!preSession) return + const timer = window.setInterval(() => setNow(Date.now()), 1_000) + return () => window.clearInterval(timer) + }, [preSession]) // ─── Active session payload ─── const raceHubQuery = useQuery({ @@ -108,25 +96,27 @@ export function RaceHubPage({ sessionKey }: Props) { const accent = countryAccent(data?.meeting ?? null) 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 (seasonsQuery.isLoading || meetingsQuery.isLoading || fallbackWeekendQuery.isLoading) { + if (contextQuery.isLoading || (preSession && preSessionWeekendQuery.isLoading)) { return (
resolving latest local weekend…
) } - const seasons = seasonsQuery.data ?? [] - if (seasons.length === 0) { + if (preSession && preSessionRef) { + return + } + if (!context?.race_hub_default_session) { return (
box-box · race hub -

No local sessions yet

+

No completed local analysis yet

- The Race Hub reads from local ingest only. Once a weekend is ingested - it will open here automatically. + Race Hub opens completed local analysis between weekends. Check Data Health + to ingest a completed session.

Open Admin · Data Health @@ -368,3 +358,32 @@ export function RaceHubPage({ sessionKey }: Props) {
) } + +function RaceHubPreSession({ session, weekend, now }: { session: ContextSession; weekend?: Weekend; now: number }) { + 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) + + return ( +
+
+ box-box · race hub +

{meeting?.meeting_name ?? 'Next race weekend'}

+

+ {session.session.session_name} begins in {formatCountdown(target, new Date(now))} +

+ {sessions.length > 0 && ( +
+ {sessions.map((scheduled) => ( +
+ {scheduled.session_name} + {formatSessionScheduleTime(scheduled.date_start)} +
+ ))} +
+ )} +
+
+ ) +} diff --git a/frontend/src/test/RaceHubPage.test.tsx b/frontend/src/test/RaceHubPage.test.tsx index 3e90681..2a3f7e5 100644 --- a/frontend/src/test/RaceHubPage.test.tsx +++ b/frontend/src/test/RaceHubPage.test.tsx @@ -9,21 +9,23 @@ import { createRoute, } from '@tanstack/react-router' import { RaceHubPage } from '../pages/RaceHubPage' -import type { DatasetInfo, Meeting, RaceHub, Session, Weekend } from '../types' +import type { ContextAvailability, DatasetInfo, Meeting, RaceHub, Session, Weekend, WeekendContext } from '../types' vi.mock('../api', () => ({ fetchRaceHub: vi.fn(), fetchSeasons: vi.fn(), fetchLocalMeetings: 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 mockFetchSeasons = vi.mocked(fetchSeasons) const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings) const mockFetchWeekend = vi.mocked(fetchWeekend) +const mockFetchWeekendContext = vi.mocked(fetchWeekendContext) const meeting: Meeting = { meeting_key: 1229, @@ -169,6 +171,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) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -210,6 +232,7 @@ describe('RaceHubPage', () => { mockFetchLocalMeetings.mockResolvedValue([meeting]) mockFetchWeekend.mockResolvedValue(weekend) mockFetchRaceHub.mockResolvedValue(raceHub) + mockFetchWeekendContext.mockResolvedValue(analysisContext) }) it('renders the workspace identity band, session rail, and overview for a known session', async () => { @@ -256,4 +279,44 @@ describe('RaceHubPage', () => { fireEvent.click(screen.getByTestId('rh-switch-weekend')) expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument() }) + + it('uses the server-selected completed analysis session for bare Race Hub', 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('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() + }) }) diff --git a/frontend/src/test/schedule.test.ts b/frontend/src/test/schedule.test.ts index 1bf05d6..0be065c 100644 --- a/frontend/src/test/schedule.test.ts +++ b/frontend/src/test/schedule.test.ts @@ -7,6 +7,7 @@ import { formatCountdown, nextUpcomingMeeting, pickFocusMeeting, + refreshDeadlineDelay, } from '../lib/schedule' import type { Meeting, Session } from '../types' @@ -78,4 +79,9 @@ describe('schedule helpers', () => { const target = new Date('2025-05-25T13:00:00+00:00') 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() + }) }) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 1ff3410..96189e1 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -217,6 +217,41 @@ export interface Weekend { 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 { is_live: boolean data: LiveStreamData | null diff --git a/internal/query/context.go b/internal/query/context.go index b876f61..31a7f8a 100644 --- a/internal/query/context.go +++ b/internal/query/context.go @@ -69,6 +69,9 @@ type WeekendContext struct { ActiveSession *ContextSession `json:"active_session,omitempty"` NextSession *ContextSession `json:"next_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"` TotalChampionshipRounds int `json:"total_championship_rounds"` } @@ -188,9 +191,34 @@ 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) 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) { + 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 analysis != nil { + out.RaceHubDefaultSession = out.DefaultAnalysisSession + } +} + func currentLocalSeason(years []int, current int) int { for _, year := range years { if year == current { diff --git a/internal/query/context_test.go b/internal/query/context_test.go index 7c4bac7..47b4620 100644 --- a/internal/query/context_test.go +++ b/internal/query/context_test.go @@ -399,3 +399,72 @@ func TestResolveWeekendContextMissingScheduleDoesNotClaimSeasonComplete(t *testi 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("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) + } + }) +} diff --git a/internal/web/context_test.go b/internal/web/context_test.go index 18dcd79..062731d 100644 --- a/internal/web/context_test.go +++ b/internal/web/context_test.go @@ -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) + } +} From b884ba888548def229dd2e6b8562f998045740e8 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Wed, 29 Jul 2026 03:46:57 -0400 Subject: [PATCH 2/4] fix(#98): preserve bare race hub handoff --- frontend/src/pages/RaceHubPage.tsx | 46 ++++++------ frontend/src/test/RaceHubPage.test.tsx | 54 +++++++++++++-- internal/query/context.go | 17 ++++- internal/query/context_test.go | 16 +++++ tests/race-hub.spec.ts | 96 +++++++++++++++++++++++++- 5 files changed, 192 insertions(+), 37 deletions(-) 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) + }) }) From d6d0558c7270ece8f27b62c284f3028034b17e04 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Wed, 29 Jul 2026 03:52:51 -0400 Subject: [PATCH 3/4] fix(#98): expose weekend switcher before sessions --- frontend/src/components/WeekendSwitcher.tsx | 2 +- frontend/src/pages/RaceHubPage.tsx | 50 ++++++++++++++++++++- frontend/src/test/RaceHubPage.test.tsx | 24 ++++++++++ tests/race-hub.spec.ts | 22 +++++++++ 4 files changed, 95 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/WeekendSwitcher.tsx b/frontend/src/components/WeekendSwitcher.tsx index edbeb07..aa024fc 100644 --- a/frontend/src/components/WeekendSwitcher.tsx +++ b/frontend/src/components/WeekendSwitcher.tsx @@ -47,7 +47,7 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose } return ( -
+
Switch Weekend
diff --git a/frontend/src/pages/RaceHubPage.tsx b/frontend/src/pages/RaceHubPage.tsx index 3e7ec26..fb84eeb 100644 --- a/frontend/src/pages/RaceHubPage.tsx +++ b/frontend/src/pages/RaceHubPage.tsx @@ -102,7 +102,16 @@ export function RaceHubPage({ sessionKey }: Props) { ) } if (preSession && preSessionRef) { - return + return ( + setSwitcherOpen((open) => !open)} + onCloseSwitcher={() => setSwitcherOpen(false)} + /> + ) } if (!selectedSessionKey) { return ( @@ -350,7 +359,21 @@ export function RaceHubPage({ sessionKey }: Props) { ) } -function RaceHubPreSession({ session, weekend, now }: { session: ContextSession; weekend?: Weekend; now: number }) { +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) @@ -359,6 +382,29 @@ function RaceHubPreSession({ session, weekend, now }: { session: ContextSession; return (
+
+ box-box · race hub + + +
+ + {switcherOpen && ( + + )} +
box-box · race hub

{meeting?.meeting_name ?? 'Next race weekend'}

diff --git a/frontend/src/test/RaceHubPage.test.tsx b/frontend/src/test/RaceHubPage.test.tsx index 8eeca29..cbadde5 100644 --- a/frontend/src/test/RaceHubPage.test.tsx +++ b/frontend/src/test/RaceHubPage.test.tsx @@ -311,6 +311,30 @@ describe('RaceHubPage', () => { 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, diff --git a/tests/race-hub.spec.ts b/tests/race-hub.spec.ts index c27458e..dbf9fe1 100644 --- a/tests/race-hub.spec.ts +++ b/tests/race-hub.spec.ts @@ -175,6 +175,28 @@ test.describe('Race Hub Weekend Workspace', () => { 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({ From 81deed4c75df915ab2f07953386003325bbbbaa3 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Wed, 29 Jul 2026 03:56:22 -0400 Subject: [PATCH 4/4] fix(#98): rearm capped race hub refresh --- frontend/src/lib/schedule.ts | 4 +++- frontend/src/pages/RaceHubPage.tsx | 11 +++++++++-- frontend/src/test/RaceHubPage.test.tsx | 17 +++++++++++++++++ frontend/src/test/schedule.test.ts | 7 +++++++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/schedule.ts b/frontend/src/lib/schedule.ts index 00cd527..2c29f32 100644 --- a/frontend/src/lib/schedule.ts +++ b/frontend/src/lib/schedule.ts @@ -145,11 +145,13 @@ 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), 2_147_483_647) + return Math.min(Math.max(0, deadline - now), MAX_BROWSER_TIMEOUT) } export type FocusMeetingKind = 'current' | 'next' | 'recent' | 'fallback' diff --git a/frontend/src/pages/RaceHubPage.tsx b/frontend/src/pages/RaceHubPage.tsx index fb84eeb..46cc189 100644 --- a/frontend/src/pages/RaceHubPage.tsx +++ b/frontend/src/pages/RaceHubPage.tsx @@ -17,6 +17,7 @@ import { SourceBadge } from '../components/SourceBadge' import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity' import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage' import { + MAX_BROWSER_TIMEOUT, formatCountdown, formatSessionScheduleTime, refreshDeadlineDelay, @@ -33,6 +34,7 @@ export function RaceHubPage({ sessionKey }: Props) { const [activeTab, setActiveTab] = useState('overview') const [switcherOpen, setSwitcherOpen] = useState(false) const [now, setNow] = useState(() => Date.now()) + const [refreshGeneration, setRefreshGeneration] = useState(0) // The server owns bare Race Hub selection so every open tab crosses the // one-hour handoff at the same instant. @@ -57,9 +59,14 @@ export function RaceHubPage({ sessionKey }: Props) { if (sessionKey !== 0) return const delay = refreshDeadlineDelay(context?.race_hub_refresh_at) if (delay == null) return - const timer = window.setTimeout(() => { void refetchContext() }, delay) + const rearmAfterRefetch = delay === MAX_BROWSER_TIMEOUT + const timer = window.setTimeout(() => { + void refetchContext().finally(() => { + if (rearmAfterRefetch) setRefreshGeneration((generation) => generation + 1) + }) + }, delay) return () => window.clearTimeout(timer) - }, [sessionKey, context?.race_hub_refresh_at, refetchContext]) + }, [sessionKey, context?.race_hub_refresh_at, refetchContext, refreshGeneration]) useEffect(() => { if (!preSession) return diff --git a/frontend/src/test/RaceHubPage.test.tsx b/frontend/src/test/RaceHubPage.test.tsx index cbadde5..e1c8b79 100644 --- a/frontend/src/test/RaceHubPage.test.tsx +++ b/frontend/src/test/RaceHubPage.test.tsx @@ -9,6 +9,7 @@ import { createRoute, } from '@tanstack/react-router' import { RaceHubPage } from '../pages/RaceHubPage' +import { MAX_BROWSER_TIMEOUT } from '../lib/schedule' import type { ContextAvailability, DatasetInfo, Meeting, RaceHub, Session, Weekend, WeekendContext } from '../types' vi.mock('../api', () => ({ @@ -375,6 +376,22 @@ describe('RaceHubPage', () => { 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) diff --git a/frontend/src/test/schedule.test.ts b/frontend/src/test/schedule.test.ts index 0be065c..5d70f6e 100644 --- a/frontend/src/test/schedule.test.ts +++ b/frontend/src/test/schedule.test.ts @@ -5,6 +5,7 @@ import { focusMeetingKind, focusMeetingLabel, formatCountdown, + MAX_BROWSER_TIMEOUT, nextUpcomingMeeting, pickFocusMeeting, refreshDeadlineDelay, @@ -84,4 +85,10 @@ describe('schedule helpers', () => { 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) + }) })