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) + } +}