diff --git a/frontend/src/api.ts b/frontend/src/api.ts index de201cd..467f16e 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -119,7 +119,8 @@ export async function fetchWeekend(meetingKey: number): Promise { // (backend story #72). The response is the authoritative WeekendContext shape and // is used verbatim as the Weekend home's source of truth. Any HTTP error throws // so the hook can surface an explicit error state; there is no client-side -// re-derivation of the contract. +// re-derivation of the contract. Race Hub bare-default landing also reads this +// for `default_analysis_session` (#75). export async function fetchWeekendContext(): Promise { const res = await fetch('/api/v1/weekend-context') if (!res.ok) { diff --git a/frontend/src/components/OverviewView.tsx b/frontend/src/components/OverviewView.tsx index 960a8dc..e285747 100644 --- a/frontend/src/components/OverviewView.tsx +++ b/frontend/src/components/OverviewView.tsx @@ -1,6 +1,5 @@ import type { RaceHub } from '../types' import { compareFinishPosition, formatDuration, formatGap, formatLapTime } from '../utils' -import { countRaceHubDatasets } from '../lib/coverage' import { Thermometer, Map, Droplets, Wind, CloudRain } from 'lucide-react' interface Props { @@ -17,7 +16,6 @@ export function OverviewView({ data }: Props) { const fastest = pickFastestLap(data) const latestWeather = data.weather.length > 0 ? data.weather[data.weather.length - 1] : null const rcHighlights = data.race_control.slice(-3).reverse() - const coverage = countRaceHubDatasets(data.datasets) const sessionType = (data.session?.session_type ?? '').toLowerCase() const isRace = sessionType.includes('race') @@ -129,28 +127,6 @@ export function OverviewView({ data }: Props) { )} - -
-
- Local Coverage - - {coverage.available}/{coverage.total} - -
-
) diff --git a/frontend/src/components/PreSessionView.tsx b/frontend/src/components/PreSessionView.tsx index 7ecd477..e443937 100644 --- a/frontend/src/components/PreSessionView.tsx +++ b/frontend/src/components/PreSessionView.tsx @@ -1,7 +1,10 @@ -import { useEffect, useState } from 'react' import type { Session } from '../types' import { RACE_HUB_DATASETS } from '../lib/coverage' import { formatCountdown, formatSessionScheduleTime, sessionStartTime } from '../lib/schedule' +import { + sessionStateDescription, + type SessionState, +} from '../lib/sessionState' const EXPECTED_LABELS: Record = { results: 'Final results', @@ -14,9 +17,10 @@ const EXPECTED_LABELS: Record = { weather: 'Track conditions', } -interface Props { +interface PreSessionProps { session: Session sessionName: string + now: Date } /** @@ -25,16 +29,8 @@ interface Props { * session is upcoming and previews the analysis that will appear once the data * is ingested. */ -export function PreSessionView({ session, sessionName }: Props) { +export function PreSessionView({ session, sessionName, now }: PreSessionProps) { const start = sessionStartTime(session) - const [now, setNow] = useState(() => new Date()) - - useEffect(() => { - if (!start) return - const id = setInterval(() => setNow(new Date()), 1000) - return () => clearInterval(id) - }, [start]) - const expected = RACE_HUB_DATASETS.filter((key) => EXPECTED_LABELS[key]) return ( @@ -70,3 +66,76 @@ export function PreSessionView({ session, sessionName }: Props) { ) } + +interface PhaseProps { + state: Extract + sessionName: string + onOpenDiagnostics?: () => void +} + +/** + * Distinct fan-facing surfaces for settling/preparing and unavailable sessions. + * Genuine request failures stay on the page-level error recovery path. + */ +export function SessionPhaseView({ state, sessionName, onOpenDiagnostics }: PhaseProps) { + const title = + state === 'preparing' + ? 'Analysis preparing' + : state === 'cancelled' + ? 'Session cancelled' + : 'Analysis unavailable' + + const testId = + state === 'preparing' + ? 'rh-preparing' + : state === 'cancelled' + ? 'rh-cancelled' + : 'rh-unavailable' + + return ( +
+
+ {sessionStateLabelEyebrow(state)} +

{title}

+

+ {sessionName}: {sessionStateDescription(state)} +

+ {state === 'preparing' && ( +

+ Check back shortly, or open Diagnostics if you need raw dataset coverage. +

+ )} + {onOpenDiagnostics && (state === 'preparing' || state === 'unavailable') && ( +
+ +
+ )} +
+
+ ) +} + +function sessionStateLabelEyebrow(state: PhaseProps['state']): string { + if (state === 'preparing') return 'Settling' + if (state === 'cancelled') return 'Cancelled' + return 'Unavailable' +} + +interface PartialBannerProps { + onOpenDiagnostics?: () => void +} + +export function PartialAnalysisBanner({ onOpenDiagnostics }: PartialBannerProps) { + return ( +
+ Partial analysis — some datasets are still missing. + {onOpenDiagnostics && ( + + )} +
+ ) +} diff --git a/frontend/src/components/WeekendSwitcher.tsx b/frontend/src/components/WeekendSwitcher.tsx index da32e9f..ff69aa7 100644 --- a/frontend/src/components/WeekendSwitcher.tsx +++ b/frontend/src/components/WeekendSwitcher.tsx @@ -3,22 +3,36 @@ import { useQuery } from '@tanstack/react-query' import { useNavigate } from '@tanstack/react-router' import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api' import { sessionTypeAbbrev } from '../lib/coverage' -import { sessionState, sessionStateDotClass, sessionStateLabel } from '../lib/sessionState' +import { + resolveSessionState, + sessionStateDotClass, + sessionStateLabel, +} from '../lib/sessionState' import { countryDecal, formatGpDateRange } from '../lib/gpIdentity' +import type { WeekendContext } from '../types' interface Props { currentMeetingKey?: number currentSessionKey?: number + context?: WeekendContext | null + now?: Date onClose: () => void } -export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose }: Props) { +export function WeekendSwitcher({ + currentMeetingKey, + currentSessionKey, + context, + now: nowProp, + onClose, +}: Props) { const navigate = useNavigate() const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons }) const [year, setYear] = useState(null) const [openMeetingKey, setOpenMeetingKey] = useState( currentMeetingKey ?? null, ) + const now = nowProp ?? new Date() const weekendQuery = useQuery({ queryKey: ['weekend', openMeetingKey], @@ -26,9 +40,6 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose enabled: openMeetingKey != null, }) - // Default the visible season to the current weekend's year so the current - // meeting card is actually rendered (seasons are newest-first, which can be a - // future season). Fall back to the newest season only when there's no context. useEffect(() => { if (year != null) return const currentYear = weekendQuery.data?.meeting?.year @@ -48,7 +59,6 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose const seasons = seasonsQuery.data ?? [] const meetings = meetingsQuery.data ?? [] const weekend = weekendQuery.data - const now = new Date() function openSession(sessionKey: number) { navigate({ to: '/race-hub', search: { session_key: sessionKey } }) @@ -115,11 +125,16 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose {weekendQuery.isLoading && (
loading sessions…
)} - {weekend && weekend.meeting_key === m.meeting_key && + {weekend && + weekend.meeting_key === m.meeting_key && weekend.sessions.map((weekendSession) => { const { session } = weekendSession const active = session.session_key === currentSessionKey - const state = sessionState(weekendSession, now) + const state = resolveSessionState({ + weekendSession, + context, + now, + }) return ( + + Back to Command Center + ) } - // Weekend resolved but every session is upcoming — offer the switcher instead - // of silently opening empty analysis. - if (fallbackWeekendQuery.data && !pickAnalysisSession(fallbackWeekendQuery.data, now)) { + + if (context && !defaultAnalysisSessionKey(context)) { return (
box-box · race hub

No completed session to analyse yet

- The next weekend hasn’t run. Pick a past session to review, or check - back once it’s complete. + Weekend Context has no default analysis session. Pick a past session + to review, or check back once a session completes with local analysis.

{switcherOpen && ( setSwitcherOpen(false)} /> )}
) } + return (
-
resolving latest local weekend…
+
resolving weekend context…
) } - // ─── Loading / error for the requested session_key (retry + back to weekend) ─── + // ─── Loading / error for the requested session_key ─── if (raceHubQuery.isLoading) { return (
@@ -210,6 +183,15 @@ export function RaceHubPage({ sessionKey }: Props) { ) } if (raceHubQuery.isError || !data) { + const backMeeting = context?.focus_meeting?.meeting_key + const backSession = + defaultAnalysisSessionKey(context) ?? + context?.previous_completed_session?.session.session_key + const backHref = + backSession && backSession > 0 + ? `/race-hub?session_key=${backSession}` + : '/race-hub' + return (
@@ -227,7 +209,12 @@ export function RaceHubPage({ sessionKey }: Props) { > Retry - + Back to Weekend
@@ -242,12 +229,18 @@ export function RaceHubPage({ sessionKey }: Props) { ? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w])) : {} const activeSessionMeta: WeekendSession | undefined = sessionMeta[sessionKey] - const activeState = activeSessionMeta ? sessionState(activeSessionMeta, now) : undefined - const preSession = activeState != null && isPreSession(activeState) + const activeState = resolveSessionState({ + weekendSession: activeSessionMeta, + context, + now, + }) + const preSession = isPreSession(activeState) + const preparing = isPreparing(activeState) + const unavailable = isUnavailable(activeState) + const partial = isPartialAnalysis(activeState) return (
- {/* Topbar */}
box-box · race hub @@ -270,11 +263,12 @@ export function RaceHubPage({ sessionKey }: Props) { setSwitcherOpen(false)} /> )} - {/* GP Identity band */} {data.meeting && (
)} - {/* Session rail */} {sessions.length > 0 && ( )} - {/* Active session sub-bar */} {data.session && (
{data.session.session_name} {formatSessionScheduleTime(data.session.date_start)} - {activeState && ( - - - )} + + key {sessionKey}
)} {preSession && data.session ? ( - + + ) : preparing || unavailable ? ( + <> + + {phaseDiagnostics && ( +
+
+ Diagnostics +
+ +
+ +
+
+ )} + ) : ( <> + {partial && } {activeTab === 'overview' && } diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 752e9c6..d8a90a0 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -1853,6 +1853,7 @@ a { color: inherit; text-decoration: none; } .rh-state-live { background: var(--red); } .rh-state-upcoming { background: var(--text-3); } .rh-state-preparing { background: var(--text-3); opacity: 0.5; } +.rh-state-unavailable { background: var(--text-3); opacity: 0.4; } .rh-state-cancelled { background: var(--text-3); opacity: 0.35; } /* ── Pre-session (expected availability) view ── */ @@ -1888,6 +1889,27 @@ a { color: inherit; text-decoration: none; } color: var(--text); margin-top: var(--s3); } +.rh-partial-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s3); + margin: 0 var(--s5) var(--s3); + padding: var(--s3) var(--s4); + border: 1px solid color-mix(in srgb, var(--yellow) 45%, var(--border)); + background: color-mix(in srgb, var(--yellow) 12%, transparent); + color: var(--text-2); + font-size: 12px; +} +.rh-partial-banner-link { + appearance: none; + background: transparent; + border: 0; + color: var(--text); + text-decoration: underline; + cursor: pointer; + font: inherit; +} .rh-expected-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); diff --git a/frontend/src/test/RaceHubPage.test.tsx b/frontend/src/test/RaceHubPage.test.tsx index 21fa527..60dbe5b 100644 --- a/frontend/src/test/RaceHubPage.test.tsx +++ b/frontend/src/test/RaceHubPage.test.tsx @@ -9,23 +9,37 @@ import { createRoute, } from '@tanstack/react-router' import { RaceHubPage } from '../pages/RaceHubPage' -import type { DatasetInfo, Meeting, RaceHub, Session, Weekend } from '../types' +import type { + 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 { + fetchRaceHub, + fetchSeasons, + fetchLocalMeetings, + 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) -// Use a fixed clock so upcoming/completed states are deterministic in tests. const NOW = new Date('2025-06-01T00:00:00Z') const meeting: Meeting = { @@ -62,7 +76,6 @@ const qualSession: Session = { gmt_offset: '02:00:00', } -// A session scheduled far in the future relative to NOW. const futureSession: Session = { session_key: 9600, session_name: 'Race', @@ -177,13 +190,47 @@ const weekend: Weekend = { meeting_key: 1229, meeting, default_session_key: 9472, - default_analysis_session: 9472, sessions: [ { session: qualSession, source: 'local', datasets: fullDatasets }, { session: raceSession, source: 'local', datasets: fullDatasets }, ], } +const weekendContext: WeekendContext = { + season: 2025, + temporal_state: 'post_weekend', + focus_meeting: meeting, + previous_meeting: meeting, + default_analysis_session: { + session: raceSession, + meeting, + availability: { + schedule: 'available', + live_transport: 'unknown', + live_session: 'inactive', + archive: 'unavailable', + local_analysis: 'complete', + freshness: 'fresh', + limitations: [], + }, + }, + previous_completed_session: { + session: raceSession, + meeting, + availability: { + schedule: 'available', + live_transport: 'unknown', + live_session: 'inactive', + archive: 'unavailable', + local_analysis: 'complete', + freshness: 'fresh', + limitations: [], + }, + }, + championship_round: 8, + total_championship_rounds: 24, +} + function renderRaceHub(sessionKey: number) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -213,7 +260,6 @@ function renderRaceHub(sessionKey: number) { history: undefined, }) - // Navigate to the URL before mounting router.navigate({ to: '/race-hub', search: sessionKey ? { session_key: sessionKey } : {} }) return render() } @@ -227,6 +273,7 @@ describe('RaceHubPage', () => { mockFetchLocalMeetings.mockResolvedValue([meeting]) mockFetchWeekend.mockResolvedValue(weekend) mockFetchRaceHub.mockResolvedValue(raceHub) + mockFetchWeekendContext.mockResolvedValue(weekendContext) }) afterEach(() => { @@ -245,9 +292,9 @@ describe('RaceHubPage', () => { ) expect(screen.getByTestId('rh-session-9471')).toBeInTheDocument() - // Overview is default expect(screen.getByTestId('rh-overview')).toBeInTheDocument() expect(screen.getByText('Winner')).toBeInTheDocument() + expect(screen.queryByText('Local Coverage')).not.toBeInTheDocument() }) it('exposes Race Story sub-controls for classification, grid, and positions', async () => { @@ -257,7 +304,6 @@ describe('RaceHubPage', () => { fireEvent.click(screen.getByRole('tab', { name: 'Race Story' })) expect(screen.getByText('VER')).toBeInTheDocument() - }) it('keeps Diagnostics accessible behind a secondary action, free of inline CLI guidance', async () => { @@ -269,7 +315,6 @@ describe('RaceHubPage', () => { expect(screen.getByTestId('rh-data-status')).toBeInTheDocument() expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument() - // Raw dataset coverage strip is hidden until explicitly requested. expect(screen.queryByTestId('rh-dataset-strip')).not.toBeInTheDocument() fireEvent.click(screen.getByTestId('rh-diagnostics-toggle')) expect(screen.getByTestId('rh-dataset-strip')).toBeInTheDocument() @@ -279,7 +324,6 @@ describe('RaceHubPage', () => { renderRaceHub(9472) await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument()) - // Overview (fan content) is present, but the raw diagnostics strip is not. expect(screen.getByTestId('rh-overview')).toBeInTheDocument() expect(screen.queryByTestId('rh-dataset-strip')).not.toBeInTheDocument() }) @@ -291,7 +335,6 @@ describe('RaceHubPage', () => { expect(screen.getByTestId('rh-tabgroup-story')).toBeInTheDocument() expect(screen.getByTestId('rh-tabgroup-analysis')).toBeInTheDocument() expect(screen.getByTestId('rh-tabgroup-context')).toBeInTheDocument() - // Every capability preserved expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Strategy' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Compare' })).toBeInTheDocument() @@ -308,20 +351,22 @@ describe('RaceHubPage', () => { expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument() }) - it('resolves bare /race-hub through the default analysis session (never a future one)', async () => { - const futureMeeting: Meeting = { ...meeting, meeting_key: 1300, meeting_name: 'Future GP' } - mockFetchLocalMeetings.mockResolvedValue([futureMeeting]) - mockFetchWeekend.mockResolvedValue({ - source: 'partial', - meeting_key: 1300, - meeting: futureMeeting, - // Backend excludes the future session; falls back to the completed quali. - default_session_key: 9600, - default_analysis_session: 9471, - sessions: [ - { session: { ...qualSession, meeting_key: 1300 }, source: 'local', datasets: fullDatasets }, - { session: futureSession, source: 'none', datasets: {} }, - ], + it('resolves bare /race-hub through canonical Weekend Context default analysis', async () => { + mockFetchWeekendContext.mockResolvedValue({ + ...weekendContext, + default_analysis_session: { + session: qualSession, + meeting, + availability: { + schedule: 'available', + live_transport: 'unknown', + live_session: 'inactive', + archive: 'unavailable', + local_analysis: 'complete', + freshness: 'fresh', + limitations: [], + }, + }, }) renderRaceHub(0) @@ -330,6 +375,19 @@ describe('RaceHubPage', () => { expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9600) }) + it('shows no-analysis fallback when Weekend Context has no default analysis', async () => { + mockFetchWeekendContext.mockResolvedValue({ + ...weekendContext, + default_analysis_session: undefined, + previous_completed_session: undefined, + }) + + renderRaceHub(0) + + await waitFor(() => expect(screen.getByTestId('race-hub-no-analysis')).toBeInTheDocument()) + expect(mockFetchRaceHub).not.toHaveBeenCalled() + }) + it('renders a pre-session view instead of empty analysis for a future session', async () => { mockFetchRaceHub.mockResolvedValue({ ...raceHub, @@ -352,12 +410,97 @@ describe('RaceHubPage', () => { await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument()) expect(await screen.findByTestId('rh-presession')).toBeInTheDocument() - // No Winner analysis card for an unrun session. expect(screen.queryByTestId('rh-overview')).not.toBeInTheDocument() expect(screen.queryByText('Winner')).not.toBeInTheDocument() }) - it('labels a completed but partial session as Partial in the active state', async () => { + it('renders a preparing view for a settling session with no local analysis', async () => { + mockFetchWeekend.mockResolvedValue({ + ...weekend, + sessions: [ + { session: raceSession, source: 'none', datasets: {} }, + ], + }) + mockFetchWeekendContext.mockResolvedValue({ + ...weekendContext, + temporal_state: 'session_settling', + default_analysis_session: undefined, + previous_completed_session: { + session: raceSession, + meeting, + availability: { + schedule: 'available', + live_transport: 'unknown', + live_session: 'inactive', + archive: 'unavailable', + local_analysis: 'pending', + freshness: 'fresh', + limitations: [], + }, + }, + }) + mockFetchRaceHub.mockResolvedValue({ + ...raceHub, + source: 'none', + results: [], + starting_grid: [], + datasets: {}, + }) + + renderRaceHub(9472) + + await waitFor(() => expect(screen.getByTestId('rh-preparing')).toBeInTheDocument()) + expect(screen.queryByTestId('rh-overview')).not.toBeInTheDocument() + }) + + it('renders unavailable distinctly from request errors', async () => { + mockFetchWeekend.mockResolvedValue({ + ...weekend, + sessions: [{ session: raceSession, source: 'none', datasets: {} }], + }) + mockFetchWeekendContext.mockResolvedValue({ + ...weekendContext, + previous_completed_session: { + session: raceSession, + meeting, + availability: { + schedule: 'available', + live_transport: 'unknown', + live_session: 'inactive', + archive: 'unavailable', + local_analysis: 'unavailable', + freshness: 'stale', + limitations: ['analysis_blocked'], + }, + }, + default_analysis_session: { + session: raceSession, + meeting, + availability: { + schedule: 'available', + live_transport: 'unknown', + live_session: 'inactive', + archive: 'unavailable', + local_analysis: 'unavailable', + freshness: 'stale', + limitations: ['analysis_blocked'], + }, + }, + }) + mockFetchRaceHub.mockResolvedValue({ + ...raceHub, + source: 'none', + results: [], + datasets: {}, + }) + + renderRaceHub(9472) + + await waitFor(() => expect(screen.getByTestId('rh-unavailable')).toBeInTheDocument()) + expect(screen.queryByTestId('race-hub-error')).not.toBeInTheDocument() + }) + + it('labels a completed but partial session as Partial and keeps analysis', async () => { mockFetchWeekend.mockResolvedValue({ ...weekend, sessions: [ @@ -365,11 +508,29 @@ describe('RaceHubPage', () => { { session: raceSession, source: 'partial', datasets: { drivers: fullDatasets.drivers } }, ], }) + mockFetchWeekendContext.mockResolvedValue({ + ...weekendContext, + default_analysis_session: { + session: raceSession, + meeting, + availability: { + schedule: 'available', + live_transport: 'unknown', + live_session: 'inactive', + archive: 'unavailable', + local_analysis: 'partial', + freshness: 'fresh', + limitations: [], + }, + }, + }) renderRaceHub(9472) await waitFor(() => expect(screen.getByTestId('rh-active-state')).toBeInTheDocument()) expect(screen.getByTestId('rh-active-state')).toHaveTextContent('Partial') + expect(screen.getByTestId('rh-partial-banner')).toBeInTheDocument() + expect(screen.getByTestId('rh-overview')).toBeInTheDocument() }) it('offers retry and back-to-Weekend on an error', async () => { @@ -380,7 +541,7 @@ describe('RaceHubPage', () => { await waitFor(() => expect(screen.getByTestId('race-hub-error')).toBeInTheDocument()) expect(screen.getByTestId('rh-retry')).toBeInTheDocument() const back = screen.getByTestId('rh-back-weekend') - expect(back).toHaveAttribute('href', '/race-hub') + expect(back).toHaveAttribute('href', '/race-hub?session_key=9472') mockFetchRaceHub.mockResolvedValue(raceHub) fireEvent.click(screen.getByTestId('rh-retry')) diff --git a/frontend/src/test/sessionState.test.ts b/frontend/src/test/sessionState.test.ts index e820184..14b21d5 100644 --- a/frontend/src/test/sessionState.test.ts +++ b/frontend/src/test/sessionState.test.ts @@ -1,6 +1,17 @@ import { describe, it, expect } from 'vitest' -import { sessionState, sessionStateLabel } from '../lib/sessionState' -import type { DatasetInfo, Session, WeekendSession } from '../types' +import { + resolveSessionState, + sessionState, + sessionStateLabel, +} from '../lib/sessionState' +import type { + ContextAvailability, + ContextSession, + DatasetInfo, + Session, + WeekendContext, + WeekendSession, +} from '../types' const NOW = new Date('2025-06-01T00:00:00Z') @@ -41,17 +52,57 @@ const FULL: Record = Object.fromEntries( ].map((k) => [k, { status: 'available', source: 'local', count: 1 }]), ) +function availability(overrides: Partial = {}): ContextAvailability { + return { + schedule: 'available', + live_transport: 'unknown', + live_session: 'inactive', + archive: 'unavailable', + local_analysis: 'complete', + freshness: 'fresh', + limitations: [], + ...overrides, + } +} + +function contextSession( + session: Session, + avail: Partial = {}, +): ContextSession { + return { session, availability: availability(avail) } +} + +function context(overrides: Partial = {}): WeekendContext { + return { + temporal_state: 'post_weekend', + championship_round: 1, + total_championship_rounds: 1, + ...overrides, + } +} + describe('sessionState', () => { it('marks a future session as upcoming', () => { const s = mk({ date_start: '2099-05-25T13:00:00+00:00', date_end: '2099-05-25T15:00:00+00:00' }, 'none') expect(sessionState(s, NOW)).toBe('upcoming') }) - it('marks a running session as live', () => { + it('does not mark Live from schedule alone', () => { const start = new Date(NOW.getTime() - 60_000).toISOString() const end = new Date(NOW.getTime() + 60_000).toISOString() - const s = mk({ date_start: start, date_end: end }, 'partial') - expect(sessionState(s, NOW)).toBe('live') + const s = mk({ date_start: start, date_end: end }, 'none') + expect(sessionState(s, NOW)).toBe('preparing') + }) + + it('marks Live only when Weekend Context active identity matches', () => { + const start = new Date(NOW.getTime() - 60_000).toISOString() + const end = new Date(NOW.getTime() + 60_000).toISOString() + const s = mk({ session_key: 42, date_start: start, date_end: end }, 'none') + const ctx = context({ + temporal_state: 'session_live', + active_session: contextSession(s.session, { live_session: 'active' }), + }) + expect(resolveSessionState({ weekendSession: s, context: ctx, now: NOW })).toBe('live') }) it('marks a finished session with full local data as ready', () => { @@ -69,6 +120,18 @@ describe('sessionState', () => { expect(sessionState(s, NOW)).toBe('partial') }) + it('marks unavailable from context availability', () => { + const s = mk({ session_key: 7 }, 'none') + const ctx = context({ + previous_completed_session: contextSession(s.session, { + local_analysis: 'unavailable', + }), + }) + expect(resolveSessionState({ weekendSession: s, context: ctx, now: NOW })).toBe( + 'unavailable', + ) + }) + it('marks a cancelled session as cancelled', () => { const s = mk({}, 'cancelled') expect(sessionState(s, NOW)).toBe('cancelled') @@ -78,5 +141,6 @@ describe('sessionState', () => { expect(sessionStateLabel('ready')).toBe('Ready') expect(sessionStateLabel('upcoming')).toBe('Upcoming') expect(sessionStateLabel('partial')).toBe('Partial') + expect(sessionStateLabel('unavailable')).toBe('Unavailable') }) }) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b5433c9..efae0bf 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -215,12 +215,6 @@ export interface Weekend { meeting: Meeting sessions: WeekendSession[] default_session_key?: number - /** - * Fan-facing default landing session. Never resolves to a future session, so - * bare `/race-hub` never opens empty post-session analysis. `0`/undefined - * means every session is still upcoming. - */ - default_analysis_session?: number } export interface LiveStateResponse { diff --git a/internal/query/navigation.go b/internal/query/navigation.go index 8139606..f6bf068 100644 --- a/internal/query/navigation.go +++ b/internal/query/navigation.go @@ -3,16 +3,11 @@ package query import ( "database/sql" "errors" - "time" "github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/store" ) -// weekendNow is the clock used to decide whether a session has started. It is a -// package var so tests can pin it deterministically. -var weekendNow = time.Now - // ErrMeetingNotFound is returned when a meeting is not in the local store. var ErrMeetingNotFound = errors.New("meeting not found") @@ -24,16 +19,14 @@ type WeekendSession struct { } // Weekend is the local-first read model for one race weekend. +// Fan-facing default analysis resolution lives on /api/v1/weekend-context +// (DefaultAnalysisSession); this payload only supplies meeting rail + coverage. type Weekend struct { Source string `json:"source"` MeetingKey int `json:"meeting_key"` Meeting models.Meeting `json:"meeting"` Sessions []WeekendSession `json:"sessions"` DefaultSessionKey int `json:"default_session_key,omitempty"` - // DefaultAnalysisSession is the session a fan-facing default landing should - // open. Unlike DefaultSessionKey it never resolves to a future session, so - // bare /race-hub never renders empty post-session analysis. - DefaultAnalysisSession int `json:"default_analysis_session,omitempty"` } // ListSeasons returns years with ingested meetings, newest first. @@ -94,7 +87,6 @@ func (s *Service) GetWeekend(meetingKey int) (Weekend, error) { out.Source = weekendSource(out.Sessions) } out.DefaultSessionKey = pickDefaultSession(out.Sessions) - out.DefaultAnalysisSession = pickDefaultAnalysisSession(out.Sessions, weekendNow()) return out, nil } @@ -151,51 +143,6 @@ func pickDefaultSession(sessions []WeekendSession) int { return sessions[bestIdx].Session.SessionKey } -// pickDefaultAnalysisSession chooses the session a fan should land on by default. -// It never returns a future session: among sessions that have already started -// (or whose start time is unknown) it prefers the one with the richest local -// dataset coverage, breaking ties toward the later session. When every session -// is still upcoming it returns 0 so callers render a pre-session view instead of -// empty analysis. -func pickDefaultAnalysisSession(sessions []WeekendSession, now time.Time) int { - bestKey := 0 - bestScore := -1 - var bestStart time.Time - for _, sess := range sessions { - start, ok := parseSessionStart(sess.Session.DateStart) - // Skip sessions that are clearly in the future; unknown start times are - // treated as eligible so historical data without timestamps still works. - if ok && start.After(now) { - continue - } - score := datasetScore(sess.Datasets) - if score > bestScore || (score == bestScore && ok && start.After(bestStart)) { - bestScore = score - bestKey = sess.Session.SessionKey - if ok { - bestStart = start - } - } - } - return bestKey -} - -func parseSessionStart(value string) (time.Time, bool) { - if value == "" { - return time.Time{}, false - } - if t, err := time.Parse(time.RFC3339, value); err == nil { - return t, true - } - if t, err := time.Parse("2006-01-02T15:04:05", value); err == nil { - return t, true - } - if t, err := time.Parse("2006-01-02", value[:min(len(value), 10)]); err == nil { - return t, true - } - return time.Time{}, false -} - func datasetScore(datasets map[string]DatasetInfo) int { score := 0 for _, info := range datasets { diff --git a/internal/query/query_test.go b/internal/query/query_test.go index 41aa834..b15bf04 100644 --- a/internal/query/query_test.go +++ b/internal/query/query_test.go @@ -5,7 +5,6 @@ import ( "errors" "path/filepath" "testing" - "time" "github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/store" @@ -377,88 +376,6 @@ func TestGetWeekendWithSessions(t *testing.T) { } } -func TestPickDefaultAnalysisSessionSkipsFuture(t *testing.T) { - now := mustTime(t, "2025-05-24T18:00:00Z") - sessions := []WeekendSession{ - { // completed qualifying, partial coverage - Session: models.Session{SessionKey: 100, DateStart: "2025-05-24T14:00:00+00:00"}, - Datasets: map[string]DatasetInfo{"results": availableLocal(1)}, - }, - { // future race with the richest coverage — must NOT be selected - Session: models.Session{SessionKey: 200, DateStart: "2025-05-25T13:00:00+00:00"}, - Datasets: map[string]DatasetInfo{ - "results": availableLocal(1), - "laps": availableLocal(1), - "stints": availableLocal(1), - }, - }, - } - - got := pickDefaultAnalysisSession(sessions, now) - if got != 100 { - t.Fatalf("pickDefaultAnalysisSession() = %d, want 100 (never a future session)", got) - } -} - -func TestPickDefaultAnalysisSessionAllFuture(t *testing.T) { - now := mustTime(t, "2025-05-20T00:00:00Z") - sessions := []WeekendSession{ - {Session: models.Session{SessionKey: 100, DateStart: "2025-05-24T14:00:00+00:00"}}, - {Session: models.Session{SessionKey: 200, DateStart: "2025-05-25T13:00:00+00:00"}}, - } - - if got := pickDefaultAnalysisSession(sessions, now); got != 0 { - t.Fatalf("pickDefaultAnalysisSession() = %d, want 0 (everything upcoming)", got) - } -} - -func TestPickDefaultAnalysisSessionPrefersRichestCompleted(t *testing.T) { - now := mustTime(t, "2025-05-26T00:00:00Z") - sessions := []WeekendSession{ - { - Session: models.Session{SessionKey: 100, DateStart: "2025-05-24T14:00:00+00:00"}, - Datasets: map[string]DatasetInfo{"results": availableLocal(1)}, - }, - { - Session: models.Session{SessionKey: 200, DateStart: "2025-05-25T13:00:00+00:00"}, - Datasets: map[string]DatasetInfo{ - "results": availableLocal(1), - "laps": availableLocal(1), - }, - }, - } - - if got := pickDefaultAnalysisSession(sessions, now); got != 200 { - t.Fatalf("pickDefaultAnalysisSession() = %d, want 200 (richest completed)", got) - } -} - -func TestGetWeekendSetsDefaultAnalysisSession(t *testing.T) { - prev := weekendNow - weekendNow = func() time.Time { return mustTime(t, "2025-05-26T00:00:00Z") } - t.Cleanup(func() { weekendNow = prev }) - - svc := openTestService(t) - seedRaceHubData(t, svc.store) - - weekend, err := svc.GetWeekend(1229) - if err != nil { - t.Fatalf("GetWeekend() error = %v", err) - } - if weekend.DefaultAnalysisSession != 9472 { - t.Fatalf("DefaultAnalysisSession = %d, want 9472", weekend.DefaultAnalysisSession) - } -} - -func mustTime(t *testing.T, value string) time.Time { - t.Helper() - parsed, err := time.Parse(time.RFC3339, value) - if err != nil { - t.Fatalf("parse time %q: %v", value, err) - } - return parsed -} - func TestGetChampionshipInputsIncludesSprintPoints(t *testing.T) { // Regression for #57: Race-only aggregation dropped Sprint points. // Setup: same meeting 1229 has Race (9472) 25pts + Sprint (9473) 8pts => total 33. diff --git a/scripts/seed-e2e-db/main.go b/scripts/seed-e2e-db/main.go index 0f5afa2..10b6bb7 100644 --- a/scripts/seed-e2e-db/main.go +++ b/scripts/seed-e2e-db/main.go @@ -30,11 +30,6 @@ func main() { const meetingKey = 1229 const fullSessionKey = 9472 const coreOnlySessionKey = 9000 - // A far-future session inside the same Monaco meeting so bare /race-hub never - // lands on it (default_analysis_session picks the completed race) yet an - // explicit deep link renders the dedicated pre-session view. Kept in the same - // meeting so the Command Center's focus selection is unaffected. - const futureSessionKey = 9600 if err := seedMeeting(st, meetingKey); err != nil { fail(err) @@ -64,10 +59,6 @@ func main() { fail(err) } - if err := seedFutureSession(st, futureSessionKey, meetingKey); err != nil { - fail(err) - } - fmt.Printf("seeded e2e db at %s\n", *dbPath) } @@ -93,28 +84,20 @@ func seedMeeting(st *store.Store, meetingKey int) error { } func seedSession(st *store.Store, sessionKey, meetingKey int, name string) error { + start, end := "2025-05-25T13:00:00+00:00", "2025-05-25T15:00:00+00:00" + // Core-only is an earlier weekend session so Weekend Context's + // default_analysis_session prefers the later full Race. + if name == "Core Only" { + start, end = "2025-05-24T13:00:00+00:00", "2025-05-24T15:00:00+00:00" + } return st.UpsertSession(store.Session{ SessionKey: sessionKey, MeetingKey: meetingKey, SessionName: name, SessionType: "Race", CircuitKey: 10, - DateStart: "2025-05-25T13:00:00+00:00", - DateEnd: "2025-05-25T15:00:00+00:00", - }) -} - -func seedFutureSession(st *store.Store, sessionKey, meetingKey int) error { - // Far-future date so this session is always "upcoming" relative to the wall - // clock and renders the pre-session view on an explicit deep link. - return st.UpsertSession(store.Session{ - SessionKey: sessionKey, - MeetingKey: meetingKey, - SessionName: "Future Sprint", - SessionType: "Race", - CircuitKey: 10, - DateStart: "2099-05-25T13:00:00+00:00", - DateEnd: "2099-05-25T15:00:00+00:00", + DateStart: start, + DateEnd: end, }) } diff --git a/tests/fixtures/future-session.ts b/tests/fixtures/future-session.ts new file mode 100644 index 0000000..dde1bd3 --- /dev/null +++ b/tests/fixtures/future-session.ts @@ -0,0 +1,78 @@ +import type { Page } from '@playwright/test' + +/** Isolated future-session key — not present in the shared e2e seed DB. */ +export const FUTURE_SESSION = 9600 +export const FUTURE_MEETING = 1300 + +const emptyDatasets = {} + +const futureMeeting = { + meeting_key: FUTURE_MEETING, + meeting_name: 'Future Grand Prix', + meeting_official_name: 'FORMULA 1 FUTURE GRAND PRIX 2099', + location: 'Futureville', + country_name: 'Testland', + country_code: 'TST', + country_flag: '', + circuit_short_name: 'Future', + date_start: '2099-05-23T00:00:00+00:00', + date_end: '2099-05-25T00:00:00+00:00', + year: 2099, +} + +const futureSession = { + session_key: FUTURE_SESSION, + session_name: 'Race', + session_type: 'Race', + meeting_key: FUTURE_MEETING, + date_start: '2099-05-25T13:00:00+00:00', + date_end: '2099-05-25T15:00:00+00:00', + gmt_offset: '00:00:00', +} + +const futureRaceHub = { + source: 'none', + session_key: FUTURE_SESSION, + datasets: emptyDatasets, + meeting: futureMeeting, + session: futureSession, + drivers: [], + results: [], + starting_grid: [], + stints: [], + pit_stops: [], + positions: [], + race_control: [], + weather: [], + laps: [], + chapters: [], +} + +const futureWeekend = { + source: 'none', + meeting_key: FUTURE_MEETING, + meeting: futureMeeting, + default_session_key: FUTURE_SESSION, + sessions: [{ session: futureSession, source: 'none', datasets: emptyDatasets }], +} + +/** + * Route-mock a far-future session without contaminating the shared Monaco seed + * (which would rewrite Command Center / Data Library baselines). + */ +export async function mockFutureRaceHubSession(page: Page): Promise { + await page.route(`**/api/v1/race-hub?session_key=${FUTURE_SESSION}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(futureRaceHub), + }) + }) + await page.route(`**/api/v1/weekend?meeting_key=${FUTURE_MEETING}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(futureWeekend), + }) + }) +} diff --git a/tests/race-hub.spec.ts b/tests/race-hub.spec.ts index 270753e..62052c5 100644 --- a/tests/race-hub.spec.ts +++ b/tests/race-hub.spec.ts @@ -1,8 +1,8 @@ import { test, expect } from '@playwright/test' +import { FUTURE_SESSION, mockFutureRaceHubSession } from './fixtures/future-session' const FULL_SESSION = 9472 const CORE_ONLY_SESSION = 9000 -const FUTURE_SESSION = 9600 test.describe('Race Hub Weekend Workspace', () => { test('lands on the Overview tab with workspace identity', async ({ page }) => { @@ -16,6 +16,7 @@ test.describe('Race Hub Weekend Workspace', () => { 'aria-selected', 'true', ) + await expect(page.getByText('Local Coverage')).toHaveCount(0) }) test('shows final running order when switching to Race Story', async ({ page }) => { @@ -88,7 +89,6 @@ test.describe('Race Hub Weekend Workspace', () => { await page.getByTestId('rh-switch-weekend').click() await expect(page.getByTestId('rh-switcher')).toBeVisible() - // Active session is already loaded; just confirm a session button is reachable await expect(page.getByTestId(`rh-switcher-session-${FULL_SESSION}`)).toBeVisible() }) @@ -101,7 +101,6 @@ test.describe('Race Hub Weekend Workspace', () => { 'href', '/admin', ) - // Raw coverage strip stays hidden until explicitly requested. await expect(page.getByTestId('rh-dataset-strip')).toHaveCount(0) await page.getByTestId('rh-diagnostics-toggle').click() await expect(page.getByTestId('rh-dataset-strip')).toBeVisible() @@ -114,12 +113,12 @@ test.describe('Race Hub Weekend Workspace', () => { await expect(page.getByTestId('rh-tabgroup-context')).toBeVisible() }) - test('bare /race-hub resolves to a completed session, never a future one', async ({ page }) => { + test('bare /race-hub resolves to a completed session via Weekend Context', async ({ page }) => { await page.goto('/race-hub') await expect(page).toHaveURL(/session_key=\d+/) await expect(page.getByTestId('race-hub')).toBeVisible() - // It must not land on the future session. - await expect(page).not.toHaveURL(new RegExp(`session_key=${FUTURE_SESSION}`)) + await expect(page).toHaveURL(new RegExp(`session_key=${FULL_SESSION}`)) + await expect(page.getByTestId('rh-identity')).toContainText('Monaco') }) test('explicit completed session deep link stays stable and shows analysis', async ({ page }) => { @@ -128,20 +127,34 @@ test.describe('Race Hub Weekend Workspace', () => { await expect(page.getByTestId('rh-overview')).toBeVisible() }) - test('explicit future session renders the pre-session view, not empty analysis', async ({ page }) => { + test('explicit future session renders the pre-session view, not empty analysis', async ({ + page, + }) => { + await mockFutureRaceHubSession(page) await page.goto(`/race-hub?session_key=${FUTURE_SESSION}`) await expect(page.getByTestId('race-hub')).toBeVisible() await expect(page.getByTestId('rh-presession')).toBeVisible() await expect(page.getByTestId('rh-overview')).toHaveCount(0) }) - test('returning to Weekend from an analysis view preserves the meeting context', async ({ page }) => { + test('returning to Weekend from analysis preserves meeting and session context', async ({ + page, + }) => { await page.goto(`/race-hub?session_key=${FULL_SESSION}`) + await expect(page.getByTestId('rh-identity')).toContainText('Monaco') await page.getByRole('tab', { name: 'Strategy' }).click() + await expect(page.locator('[data-testid="strategy-chart"]')).toBeVisible() - await page.getByTestId('rh-switch-weekend').click() - await expect(page.getByTestId('rh-switcher')).toBeVisible() - // The current session remains reachable/selected from the switcher. - await expect(page.getByTestId(`rh-switcher-session-${FULL_SESSION}`)).toBeVisible() + // Navigate to the sibling core-only session within the same weekend. + await page.getByTestId(`rh-session-${CORE_ONLY_SESSION}`).click() + await expect(page).toHaveURL(new RegExp(`session_key=${CORE_ONLY_SESSION}`)) + await expect(page.getByTestId('rh-identity')).toContainText('Monaco') + + // Back to Weekend via bare /race-hub — Weekend Context should restore the + // same meeting's default analysis session. + await page.goto('/race-hub') + await expect(page).toHaveURL(new RegExp(`session_key=${FULL_SESSION}`)) + await expect(page.getByTestId('rh-identity')).toContainText('Monaco') + await expect(page.getByTestId('rh-overview')).toBeVisible() }) }) diff --git a/tests/visual/__snapshots__/desktop/data-library.png b/tests/visual/__snapshots__/desktop/data-library.png index a83a8b7..07a6e3c 100644 Binary files a/tests/visual/__snapshots__/desktop/data-library.png and b/tests/visual/__snapshots__/desktop/data-library.png differ diff --git a/tests/visual/__snapshots__/desktop/race-hub.png b/tests/visual/__snapshots__/desktop/race-hub.png index 405a419..6345515 100644 Binary files a/tests/visual/__snapshots__/desktop/race-hub.png and b/tests/visual/__snapshots__/desktop/race-hub.png differ diff --git a/tests/visual/__snapshots__/desktop/race-story.png b/tests/visual/__snapshots__/desktop/race-story.png index 4ef4541..09c0582 100644 Binary files a/tests/visual/__snapshots__/desktop/race-story.png and b/tests/visual/__snapshots__/desktop/race-story.png differ diff --git a/tests/visual/__snapshots__/mobile/data-library.png b/tests/visual/__snapshots__/mobile/data-library.png index 697d502..055a052 100644 Binary files a/tests/visual/__snapshots__/mobile/data-library.png and b/tests/visual/__snapshots__/mobile/data-library.png differ diff --git a/tests/visual/__snapshots__/mobile/race-hub.png b/tests/visual/__snapshots__/mobile/race-hub.png index 8baa8ad..c3ff86a 100644 Binary files a/tests/visual/__snapshots__/mobile/race-hub.png and b/tests/visual/__snapshots__/mobile/race-hub.png differ diff --git a/tests/visual/__snapshots__/mobile/race-story.png b/tests/visual/__snapshots__/mobile/race-story.png index 6884811..339bf9a 100644 Binary files a/tests/visual/__snapshots__/mobile/race-story.png and b/tests/visual/__snapshots__/mobile/race-story.png differ diff --git a/tests/visual/__snapshots__/tablet/data-library.png b/tests/visual/__snapshots__/tablet/data-library.png index 9186d4b..3b672b3 100644 Binary files a/tests/visual/__snapshots__/tablet/data-library.png and b/tests/visual/__snapshots__/tablet/data-library.png differ diff --git a/tests/visual/__snapshots__/tablet/race-hub.png b/tests/visual/__snapshots__/tablet/race-hub.png index 96fdb7b..ede04d0 100644 Binary files a/tests/visual/__snapshots__/tablet/race-hub.png and b/tests/visual/__snapshots__/tablet/race-hub.png differ diff --git a/tests/visual/__snapshots__/tablet/race-story.png b/tests/visual/__snapshots__/tablet/race-story.png index b0d646b..548a7ed 100644 Binary files a/tests/visual/__snapshots__/tablet/race-story.png and b/tests/visual/__snapshots__/tablet/race-story.png differ diff --git a/tests/visual/helpers.ts b/tests/visual/helpers.ts index adcb56e..f01d5ea 100644 --- a/tests/visual/helpers.ts +++ b/tests/visual/helpers.ts @@ -1,4 +1,5 @@ import { expect, type Locator, type Page } from '@playwright/test' +import { FUTURE_SESSION, mockFutureRaceHubSession } from '../fixtures/future-session' export const VIEWPORTS = { desktop: { width: 1280, height: 800 }, @@ -7,7 +8,7 @@ export const VIEWPORTS = { } as const export const FULL_SESSION = 9472 -export const FUTURE_SESSION = 9600 +export { FUTURE_SESSION } /** Wait for web fonts and layout to settle before screenshots. */ export async function waitForScreenshotReady(page: Page): Promise { @@ -38,6 +39,7 @@ export async function gotoRaceHubFutureReady( page: Page, sessionKey = FUTURE_SESSION, ): Promise { + await mockFutureRaceHubSession(page) await page.goto(`/race-hub?session_key=${sessionKey}`) await expect(page.getByTestId('race-hub')).toBeVisible() await expect(page.getByTestId('rh-presession')).toBeVisible()