diff --git a/frontend/src/components/OverviewView.tsx b/frontend/src/components/OverviewView.tsx index aa9215c..960a8dc 100644 --- a/frontend/src/components/OverviewView.tsx +++ b/frontend/src/components/OverviewView.tsx @@ -148,7 +148,7 @@ export function OverviewView({ data }: Props) { ? 'Every Race Hub dataset is local for this session.' : `${coverage.total - coverage.available} dataset${ coverage.total - coverage.available === 1 ? '' : 's' - } not ingested yet — see Data Status tab.`} + } not ingested yet — see Diagnostics.`} diff --git a/frontend/src/components/PreSessionView.tsx b/frontend/src/components/PreSessionView.tsx new file mode 100644 index 0000000..7ecd477 --- /dev/null +++ b/frontend/src/components/PreSessionView.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from 'react' +import type { Session } from '../types' +import { RACE_HUB_DATASETS } from '../lib/coverage' +import { formatCountdown, formatSessionScheduleTime, sessionStartTime } from '../lib/schedule' + +const EXPECTED_LABELS: Record = { + results: 'Final results', + starting_grid: 'Starting grid', + stints: 'Tyre strategy', + pit_stops: 'Pit stops', + positions: 'Position changes', + laps: 'Lap times', + race_control: 'Race control', + weather: 'Track conditions', +} + +interface Props { + session: Session + sessionName: string +} + +/** + * Purpose-built view for a session that has not run yet. Instead of rendering + * empty Winner / Podium / Pole / Strategy / Compare cards, it explains that the + * session is upcoming and previews the analysis that will appear once the data + * is ingested. + */ +export function PreSessionView({ session, sessionName }: Props) { + 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 ( +
+
+ Upcoming session +

{sessionName}

+

+ This session hasn’t run yet, so there’s no result to analyse. Winner, + podium, pole, strategy and comparison views will appear here once the + session completes and its data is ingested. +

+
+ {start + ? `Starts ${formatSessionScheduleTime(session.date_start)} · in ${formatCountdown(start, now)}` + : 'Start time to be confirmed.'} +
+
+ +
+
+ Expected once complete +
+
+ {expected.map((key) => ( +
+
+ ))} +
+
+
+ ) +} diff --git a/frontend/src/components/TabBar.tsx b/frontend/src/components/TabBar.tsx index 5d1678c..772f01e 100644 --- a/frontend/src/components/TabBar.tsx +++ b/frontend/src/components/TabBar.tsx @@ -8,15 +8,46 @@ export type Tab = | 'race_control' | 'data_status' -const TABS: { id: Tab; label: string }[] = [ - { id: 'overview', label: 'Overview' }, - { id: 'race_story', label: 'Race Story' }, - { id: 'strategy', label: 'Strategy' }, - { id: 'compare', label: 'Compare' }, - { id: 'lap_data', label: 'Lap Data' }, - { id: 'conditions', label: 'Conditions' }, - { id: 'race_control', label: 'Race Control' }, - { id: 'data_status', label: 'Data Status' }, +interface TabDef { + id: Tab + label: string +} + +interface TabGroup { + id: string + label: string + tabs: TabDef[] +} + +// Fan-facing hierarchy: Story first, then Analysis, then Data/Context. Every +// existing capability is preserved — only the grouping and ordering change. +const TAB_GROUPS: TabGroup[] = [ + { + id: 'story', + label: 'Story', + tabs: [ + { id: 'overview', label: 'Overview' }, + { id: 'race_story', label: 'Race Story' }, + ], + }, + { + id: 'analysis', + label: 'Analysis', + tabs: [ + { id: 'strategy', label: 'Strategy' }, + { id: 'compare', label: 'Compare' }, + { id: 'lap_data', label: 'Lap Data' }, + ], + }, + { + id: 'context', + label: 'Data & Context', + tabs: [ + { id: 'conditions', label: 'Conditions' }, + { id: 'race_control', label: 'Race Control' }, + { id: 'data_status', label: 'Diagnostics' }, + ], + }, ] interface Props { @@ -26,17 +57,26 @@ interface Props { export function TabBar({ active, onChange }: Props) { return ( -
- {TABS.map((t) => ( - +
+ {TAB_GROUPS.map((group) => ( +
+ +
+ {group.tabs.map((t) => ( + + ))} +
+
))}
) diff --git a/frontend/src/components/WeekendSwitcher.tsx b/frontend/src/components/WeekendSwitcher.tsx index edbeb07..da32e9f 100644 --- a/frontend/src/components/WeekendSwitcher.tsx +++ b/frontend/src/components/WeekendSwitcher.tsx @@ -2,7 +2,8 @@ import { useEffect, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { useNavigate } from '@tanstack/react-router' import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api' -import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage' +import { sessionTypeAbbrev } from '../lib/coverage' +import { sessionState, sessionStateDotClass, sessionStateLabel } from '../lib/sessionState' import { countryDecal, formatGpDateRange } from '../lib/gpIdentity' interface Props { @@ -19,11 +20,24 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose currentMeetingKey ?? null, ) + const weekendQuery = useQuery({ + queryKey: ['weekend', openMeetingKey], + queryFn: () => fetchWeekend(openMeetingKey!), + 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 && seasonsQuery.data?.length) { + if (year != null) return + const currentYear = weekendQuery.data?.meeting?.year + if (currentYear) { + setYear(currentYear) + } else if (seasonsQuery.data?.length) { setYear(seasonsQuery.data[0]) } - }, [seasonsQuery.data, year]) + }, [seasonsQuery.data, weekendQuery.data, year]) const meetingsQuery = useQuery({ queryKey: ['meetings', year], @@ -31,15 +45,10 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose enabled: year != null, }) - const weekendQuery = useQuery({ - queryKey: ['weekend', openMeetingKey], - queryFn: () => fetchWeekend(openMeetingKey!), - enabled: openMeetingKey != null, - }) - 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 } }) @@ -107,8 +116,10 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
loading sessions…
)} {weekend && weekend.meeting_key === m.meeting_key && - weekend.sessions.map(({ session, source, datasets }) => { + weekend.sessions.map((weekendSession) => { + const { session } = weekendSession const active = session.session_key === currentSessionKey + const state = sessionState(weekendSession, now) return ( ) diff --git a/frontend/src/lib/schedule.ts b/frontend/src/lib/schedule.ts index 926af49..cb47723 100644 --- a/frontend/src/lib/schedule.ts +++ b/frontend/src/lib/schedule.ts @@ -95,6 +95,22 @@ export function pickFocusMeeting(meetings: Meeting[], now: Date): Meeting | null ) } +/** + * Meeting a fan-facing default landing (bare `/race-hub`) should open. Unlike + * `pickFocusMeeting`, it prefers a completed weekend over an upcoming one so the + * default never lands on a future race with no analysis. Falls back to the next + * upcoming meeting only when nothing has happened yet. + */ +export function pickAnalysisFocusMeeting(meetings: Meeting[], now: Date): Meeting | null { + return ( + currentMeeting(meetings, now) ?? + mostRecentPastMeeting(meetings, now) ?? + nextUpcomingMeeting(meetings, now) ?? + meetings[0] ?? + null + ) +} + export function meetingHasStarted(meeting: Meeting, now: Date): boolean { const start = meetingStartTime(meeting) return start != null && now >= start diff --git a/frontend/src/lib/sessionState.ts b/frontend/src/lib/sessionState.ts new file mode 100644 index 0000000..1c8dba1 --- /dev/null +++ b/frontend/src/lib/sessionState.ts @@ -0,0 +1,100 @@ +import type { WeekendSession } from '../types' +import { isSessionComplete } from './coverage' +import { sessionEndTime, sessionStartTime } from './schedule' + +/** + * User-facing lifecycle state for a weekend session. Combines the schedule + * (has it started / finished) with local dataset coverage so the UI can speak + * in fan language instead of raw `x/11` coverage counts. + * + * - `upcoming` — starts in the future; render a pre-session view. + * - `live` — currently running (started, not yet finished). + * - `preparing` — finished (or unknown timing) but no local analysis yet. + * - `partial` — finished with some, but not all, local datasets. + * - `ready` — finished with full local coverage; analysis is trustworthy. + * - `cancelled` — session was cancelled. + */ +export type SessionState = + | 'upcoming' + | 'live' + | 'preparing' + | 'partial' + | 'ready' + | 'cancelled' + +export function sessionState(session: WeekendSession, now: Date): SessionState { + if (session.source === 'cancelled') return 'cancelled' + + const start = sessionStartTime(session.session) + const end = sessionEndTime(session.session) + + if (start && start > now) return 'upcoming' + if (start && end && now >= start && now < end) return 'live' + + // Session has started/finished (or timing unknown) — describe it by coverage. + if (isSessionComplete(session.datasets)) return 'ready' + if (session.source === 'none') return 'preparing' + return 'partial' +} + +/** Short label suitable for chips and the session switcher. */ +export function sessionStateLabel(state: SessionState): string { + switch (state) { + case 'upcoming': + return 'Upcoming' + case 'live': + return 'Live' + case 'preparing': + return 'Preparing' + case 'partial': + return 'Partial' + case 'ready': + return 'Ready' + case 'cancelled': + return 'Cancelled' + } +} + +/** Longer, sentence-style description for headers and empty states. */ +export function sessionStateDescription(state: SessionState): string { + switch (state) { + case 'upcoming': + return 'Session has not started yet.' + case 'live': + return 'Session is running now.' + case 'preparing': + return 'Analysis is being prepared — no local data ingested yet.' + case 'partial': + return 'Partial analysis available — some datasets are still missing.' + case 'ready': + return 'Full analysis is ready.' + case 'cancelled': + return 'This session was cancelled.' + } +} + +/** + * Class suffix used for the coverage dot, so the rail can colour a session by + * its lifecycle state rather than only by data source. + */ +export function sessionStateDotClass(state: SessionState): string { + switch (state) { + case 'ready': + return 'rh-state-ready' + case 'partial': + return 'rh-state-partial' + case 'live': + return 'rh-state-live' + case 'upcoming': + return 'rh-state-upcoming' + case 'cancelled': + return 'rh-state-cancelled' + default: + return 'rh-state-preparing' + } +} + +/** Whether a session should render the pre-session (expected availability) view. */ +export function isPreSession(state: SessionState): boolean { + return state === 'upcoming' +} diff --git a/frontend/src/pages/RaceHubPage.tsx b/frontend/src/pages/RaceHubPage.tsx index e680e9d..087053a 100644 --- a/frontend/src/pages/RaceHubPage.tsx +++ b/frontend/src/pages/RaceHubPage.tsx @@ -17,37 +17,63 @@ import { CompareView } from '../components/CompareView' import { RaceControlView } from '../components/RaceControlView' import { WeatherView } from '../components/WeatherView' import { OverviewView } from '../components/OverviewView' +import { PreSessionView } from '../components/PreSessionView' import { WeekendSwitcher } from '../components/WeekendSwitcher' import { SourceBadge } from '../components/SourceBadge' import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity' -import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage' +import { sessionTypeAbbrev } from '../lib/coverage' import { formatSessionScheduleTime, - pickFocusMeeting, + pickAnalysisFocusMeeting, + sessionStartTime, sortSessionsByStart, } from '../lib/schedule' +import { + isPreSession, + sessionState, + sessionStateDotClass, + sessionStateLabel, +} from '../lib/sessionState' import type { Weekend, WeekendSession } from '../types' interface Props { sessionKey: number } -function pickAnalysisSession(weekend: Weekend | undefined): WeekendSession | undefined { +/** + * Resolve the session a bare `/race-hub` should open. Prefers the canonical + * Weekend Context `default_analysis_session` (which never points at a future + * session), then any completed session with the richest coverage. Returns + * `undefined` when every session is still upcoming so the caller can fall back + * to the switcher instead of opening empty analysis. + */ +function pickAnalysisSession(weekend: Weekend | undefined, now: Date): number | 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 + if (weekend.default_analysis_session && weekend.default_analysis_session > 0) { + return weekend.default_analysis_session + } + + const started = weekend.sessions.filter((s) => { + const start = sessionStartTime(s.session) + return !start || start <= now + }) + if (started.length === 0) return undefined + + const local = started.filter((s) => s.source === 'local') + const partial = started.filter((s) => s.source === 'partial') + const pool = local.length > 0 ? local : partial.length > 0 ? partial : started const race = pool.find((s) => s.session.session_type?.toLowerCase().includes('race')) - if (race) return race + if (race) return race.session.session_key const qual = pool.find((s) => s.session.session_type?.toLowerCase().includes('qualifying')) - if (qual) return qual - return pool[0] + if (qual) return qual.session.session_key + return pool[pool.length - 1]?.session.session_key } export function RaceHubPage({ sessionKey }: Props) { const navigate = useNavigate() const [activeTab, setActiveTab] = useState('overview') const [switcherOpen, setSwitcherOpen] = useState(false) + const now = useMemo(() => new Date(), []) // ─── Auto-redirect when no session_key is supplied ─── const seasonsQuery = useQuery({ @@ -66,8 +92,8 @@ export function RaceHubPage({ sessionKey }: Props) { const focusMeeting = useMemo(() => { if (sessionKey !== 0 || !meetingsQuery.data) return null - return pickFocusMeeting(meetingsQuery.data, new Date()) - }, [sessionKey, meetingsQuery.data]) + return pickAnalysisFocusMeeting(meetingsQuery.data, now) + }, [sessionKey, meetingsQuery.data, now]) const fallbackWeekendQuery = useQuery({ queryKey: ['weekend', focusMeeting?.meeting_key], @@ -79,13 +105,11 @@ export function RaceHubPage({ sessionKey }: Props) { 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 + const target = pickAnalysisSession(weekend, now) if (target) { navigate({ to: '/race-hub', search: { session_key: target }, replace: true }) } - }, [sessionKey, fallbackWeekendQuery.data, navigate]) + }, [sessionKey, fallbackWeekendQuery.data, navigate, now]) // ─── Active session payload ─── const raceHubQuery = useQuery({ @@ -108,6 +132,8 @@ export function RaceHubPage({ sessionKey }: Props) { const accent = countryAccent(data?.meeting ?? null) const accentStyle = { '--gp-accent': accent } as React.CSSProperties + const [showDiagnostics, setShowDiagnostics] = useState(false) + // ─── No session_key: show resolving state, fall back to switcher if no local data ─── if (sessionKey === 0) { if (seasonsQuery.isLoading || meetingsQuery.isLoading || fallbackWeekendQuery.isLoading) { @@ -136,6 +162,38 @@ export function RaceHubPage({ sessionKey }: Props) {
) } + // Weekend resolved but every session is upcoming — offer the switcher instead + // of silently opening empty analysis. + if (fallbackWeekendQuery.data && !pickAnalysisSession(fallbackWeekendQuery.data, now)) { + 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. +

+
+ + Back to Command Center +
+
+ {switcherOpen && ( + setSwitcherOpen(false)} + /> + )} +
+ ) + } return (
resolving latest local weekend…
@@ -143,7 +201,7 @@ export function RaceHubPage({ sessionKey }: Props) { ) } - // ─── Loading / error for the requested session_key ─── + // ─── Loading / error for the requested session_key (retry + back to weekend) ─── if (raceHubQuery.isLoading) { return (
@@ -153,11 +211,26 @@ export function RaceHubPage({ sessionKey }: Props) { } if (raceHubQuery.isError || !data) { return ( -
-
- {raceHubQuery.error instanceof Error - ? raceHubQuery.error.message - : `Failed to load session ${sessionKey}.`} +
+
+
+ {raceHubQuery.error instanceof Error + ? raceHubQuery.error.message + : `Failed to load session ${sessionKey}.`} +
+
+ + + Back to Weekend + +
) @@ -168,7 +241,9 @@ 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: WeekendSession | undefined = sessionMeta[sessionKey] + const activeState = activeSessionMeta ? sessionState(activeSessionMeta, now) : undefined + const preSession = activeState != null && isPreSession(activeState) return (
@@ -226,6 +301,7 @@ export function RaceHubPage({ sessionKey }: Props) { {sessions.map((session) => { const meta = sessionMeta[session.session_key] const active = session.session_key === sessionKey + const state = meta ? sessionState(meta, now) : undefined return ( @@ -269,101 +345,119 @@ export function RaceHubPage({ sessionKey }: Props) { {formatSessionScheduleTime(data.session.date_start)} - {activeSessionMeta && ( - + {activeState && ( + )} key {sessionKey}
)} - + {preSession && data.session ? ( + + ) : ( + <> + - + {activeTab === 'overview' && } - {activeTab === 'overview' && } + {activeTab === 'race_story' && ( +
+ +
+ )} - {activeTab === 'race_story' && ( -
- -
- )} + {activeTab === 'strategy' && ( +
+
+ Race Strategy +
+ +
+ )} - {activeTab === 'strategy' && ( -
-
- Race Strategy -
- -
- )} + {activeTab === 'compare' && ( +
+
+ Driver Compare +
+ +
+ )} - {activeTab === 'compare' && ( -
-
- Driver Compare -
- -
- )} + {activeTab === 'lap_data' && ( +
+
+ Lap Data + {data.laps.length > 0 && ( + {data.laps.length} samples + )} +
+ +
+ )} - {activeTab === 'lap_data' && ( -
-
- Lap Data - {data.laps.length > 0 && ( - {data.laps.length} samples - )} -
- -
- )} + {activeTab === 'conditions' && ( +
+
+ Conditions + {data.weather.length > 0 && ( + {data.weather.length} samples + )} +
+ +
+ )} - {activeTab === 'conditions' && ( -
-
- Conditions - {data.weather.length > 0 && ( - {data.weather.length} samples - )} -
- -
- )} + {activeTab === 'race_control' && ( +
+
+ Race Control + {data.race_control.length > 0 && ( + {data.race_control.length} messages + )} +
+ +
+ )} - {activeTab === 'race_control' && ( -
-
- Race Control - {data.race_control.length > 0 && ( - {data.race_control.length} messages - )} -
- -
- )} - - {activeTab === 'data_status' && ( -
-
- Data Status -
- -
+ {activeTab === 'data_status' && ( +
+
+ Diagnostics + +
+ + {showDiagnostics && ( +
+ +
+ )} +
+ )} + )}
) diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 3b98635..752e9c6 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -1820,6 +1820,141 @@ a { color: inherit; text-decoration: none; } .tab-btn:hover { color: var(--text-2); } .tab-btn.active { color: var(--text); border-bottom-color: var(--red); } +/* ── Grouped analysis navigation ── */ +.tab-bar-grouped { + gap: var(--s5); + align-items: flex-end; +} +.tab-group { + display: flex; + flex-direction: column; + gap: 2px; + flex-shrink: 0; +} +.tab-group + .tab-group { + border-left: 1px solid var(--border); + padding-left: var(--s5); +} +.tab-group-label { + font-size: 9px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-3); + opacity: 0.7; + padding: 0 14px; +} +.tab-group-btns { + display: flex; +} + +/* ── Session lifecycle state dots ── */ +.rh-state-ready { background: var(--green); } +.rh-state-partial { background: var(--yellow); } +.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-cancelled { background: var(--text-3); opacity: 0.35; } + +/* ── Pre-session (expected availability) view ── */ +.rh-presession { + display: flex; + flex-direction: column; + gap: var(--s5); +} +.rh-presession-band { + padding: var(--s6) var(--s5); + background: var(--surface); + border: 1px solid var(--border); + border-left: 3px solid var(--gp-accent); + border-radius: 4px; + display: flex; + flex-direction: column; + gap: var(--s3); +} +.rh-presession-eyebrow { + font-size: 10px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-3); +} +.rh-presession-title { + font-size: 22px; + font-weight: 700; + margin: 0; +} +.rh-presession-sub { color: var(--text-2); max-width: 60ch; } +.rh-presession-countdown { + font-size: 15px; + color: var(--text); + margin-top: var(--s3); +} +.rh-expected-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: var(--s4); +} +.rh-expected-card { + padding: var(--s4); + border: 1px solid var(--border); + border-radius: 4px; + background: var(--surface); + display: flex; + align-items: center; + gap: var(--s3); + color: var(--text-2); + font-size: 13px; +} +.rh-expected-card .rh-expected-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-3); + opacity: 0.6; +} + +/* ── Loading / error recovery ── */ +.rh-recover { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--s4); + padding: var(--s6) var(--s5); +} +.rh-recover-actions { + display: flex; + gap: var(--s3); + flex-wrap: wrap; +} +.rh-recover-btn { + padding: 8px 14px; + font-size: 12px; + font-weight: 600; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--surface); + color: var(--text); + cursor: pointer; + text-decoration: none; +} +.rh-recover-btn:hover { border-color: var(--gp-accent); } +.rh-recover-btn.primary { border-color: var(--red); color: var(--text); } + +/* ── Diagnostics (secondary) action ── */ +.rh-diagnostics-toggle { + align-self: flex-start; + margin-left: auto; + padding: 6px 12px; + font-size: 11px; + font-weight: 600; + color: var(--text-3); + background: none; + border: 1px solid var(--border); + border-radius: 4px; + cursor: pointer; +} +.rh-diagnostics-toggle:hover { color: var(--text-2); border-color: var(--gp-accent); } +.rh-diagnostics-toggle.active { color: var(--text); } + /* ── Dataset status view ── */ .ds-legend { display: flex; diff --git a/frontend/src/test/RaceHubPage.test.tsx b/frontend/src/test/RaceHubPage.test.tsx index 3e90681..21fa527 100644 --- a/frontend/src/test/RaceHubPage.test.tsx +++ b/frontend/src/test/RaceHubPage.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, waitFor, fireEvent } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { @@ -25,6 +25,9 @@ const mockFetchSeasons = vi.mocked(fetchSeasons) const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings) const mockFetchWeekend = vi.mocked(fetchWeekend) +// Use a fixed clock so upcoming/completed states are deterministic in tests. +const NOW = new Date('2025-06-01T00:00:00Z') + const meeting: Meeting = { meeting_key: 1229, meeting_name: 'Monaco Grand Prix', @@ -59,6 +62,17 @@ 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', + session_type: 'Race', + meeting_key: 1300, + date_start: '2099-05-25T13:00:00+00:00', + date_end: '2099-05-25T15:00:00+00:00', + gmt_offset: '02:00:00', +} + const fullDatasets: Record = { meeting: { status: 'available', source: 'local', count: 1 }, session: { status: 'available', source: 'local', count: 1 }, @@ -163,6 +177,7 @@ 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 }, @@ -206,12 +221,18 @@ function renderRaceHub(sessionKey: number) { describe('RaceHubPage', () => { beforeEach(() => { vi.clearAllMocks() + vi.useFakeTimers({ shouldAdvanceTime: true }) + vi.setSystemTime(NOW) mockFetchSeasons.mockResolvedValue([2025]) mockFetchLocalMeetings.mockResolvedValue([meeting]) mockFetchWeekend.mockResolvedValue(weekend) mockFetchRaceHub.mockResolvedValue(raceHub) }) + afterEach(() => { + vi.useRealTimers() + }) + it('renders the workspace identity band, session rail, and overview for a known session', async () => { renderRaceHub(9472) @@ -239,14 +260,44 @@ describe('RaceHubPage', () => { }) - it('keeps Data Status accessible and free of inline CLI guidance', async () => { + it('keeps Diagnostics accessible behind a secondary action, free of inline CLI guidance', async () => { renderRaceHub(9472) await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument()) - fireEvent.click(screen.getByRole('tab', { name: 'Data Status' })) + fireEvent.click(screen.getByRole('tab', { name: 'Diagnostics' })) 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() + }) + + it('does not render the raw dataset strip before fan-facing content', async () => { + 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() + }) + + it('groups analysis navigation into Story, Analysis, and Data & Context', async () => { + renderRaceHub(9472) + await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument()) + + 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() + expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Diagnostics' })).toBeInTheDocument() }) it('toggles the inline weekend switcher', async () => { @@ -256,4 +307,83 @@ describe('RaceHubPage', () => { fireEvent.click(screen.getByTestId('rh-switch-weekend')) 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: {} }, + ], + }) + + renderRaceHub(0) + + await waitFor(() => expect(mockFetchRaceHub).toHaveBeenCalledWith(9471)) + expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9600) + }) + + it('renders a pre-session view instead of empty analysis for a future session', async () => { + mockFetchRaceHub.mockResolvedValue({ + ...raceHub, + session_key: 9600, + source: 'none', + session: futureSession, + meeting: { ...meeting, meeting_key: 1300 }, + results: [], + starting_grid: [], + datasets: {}, + }) + mockFetchWeekend.mockResolvedValue({ + source: 'none', + meeting_key: 1300, + meeting: { ...meeting, meeting_key: 1300 }, + sessions: [{ session: futureSession, source: 'none', datasets: {} }], + }) + + renderRaceHub(9600) + + 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 () => { + mockFetchWeekend.mockResolvedValue({ + ...weekend, + sessions: [ + { session: qualSession, source: 'local', datasets: fullDatasets }, + { session: raceSession, source: 'partial', datasets: { drivers: fullDatasets.drivers } }, + ], + }) + + renderRaceHub(9472) + + await waitFor(() => expect(screen.getByTestId('rh-active-state')).toBeInTheDocument()) + expect(screen.getByTestId('rh-active-state')).toHaveTextContent('Partial') + }) + + it('offers retry and back-to-Weekend on an error', async () => { + mockFetchRaceHub.mockRejectedValue(new Error('boom')) + + renderRaceHub(9472) + + 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') + + mockFetchRaceHub.mockResolvedValue(raceHub) + fireEvent.click(screen.getByTestId('rh-retry')) + await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument()) + }) }) diff --git a/frontend/src/test/TabBar.test.tsx b/frontend/src/test/TabBar.test.tsx index da6dd41..52fbddd 100644 --- a/frontend/src/test/TabBar.test.tsx +++ b/frontend/src/test/TabBar.test.tsx @@ -3,7 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react' import { TabBar } from '../components/TabBar' describe('TabBar', () => { - it('renders all Race Hub workspace tabs', () => { + it('renders all Race Hub workspace tabs grouped into a hierarchy', () => { render( {}} />) expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Race Story' })).toBeInTheDocument() @@ -12,7 +12,11 @@ describe('TabBar', () => { expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Conditions' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument() - expect(screen.getByRole('tab', { name: 'Data Status' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Diagnostics' })).toBeInTheDocument() + + expect(screen.getByTestId('rh-tabgroup-story')).toBeInTheDocument() + expect(screen.getByTestId('rh-tabgroup-analysis')).toBeInTheDocument() + expect(screen.getByTestId('rh-tabgroup-context')).toBeInTheDocument() }) it('marks the active tab with aria-selected', () => { diff --git a/frontend/src/test/sessionState.test.ts b/frontend/src/test/sessionState.test.ts new file mode 100644 index 0000000..e820184 --- /dev/null +++ b/frontend/src/test/sessionState.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest' +import { sessionState, sessionStateLabel } from '../lib/sessionState' +import type { DatasetInfo, Session, WeekendSession } from '../types' + +const NOW = new Date('2025-06-01T00:00:00Z') + +function mk( + overrides: Partial, + source: WeekendSession['source'], + datasets: Record = {}, +): WeekendSession { + return { + session: { + session_key: 1, + session_name: 'Race', + session_type: 'Race', + meeting_key: 1, + date_start: '2025-05-25T13:00:00+00:00', + date_end: '2025-05-25T15:00:00+00:00', + gmt_offset: '00:00:00', + ...overrides, + }, + source, + datasets, + } +} + +const FULL: Record = Object.fromEntries( + [ + 'meeting', + 'session', + 'drivers', + 'results', + 'starting_grid', + 'stints', + 'pit_stops', + 'positions', + 'race_control', + 'weather', + 'laps', + ].map((k) => [k, { status: 'available', source: 'local', count: 1 }]), +) + +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', () => { + 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') + }) + + it('marks a finished session with full local data as ready', () => { + const s = mk({}, 'local', FULL) + expect(sessionState(s, NOW)).toBe('ready') + }) + + it('marks a finished session with no data as preparing', () => { + const s = mk({}, 'none', {}) + expect(sessionState(s, NOW)).toBe('preparing') + }) + + it('marks a finished session with partial data as partial', () => { + const s = mk({}, 'partial', { drivers: { status: 'available', source: 'local', count: 20 } }) + expect(sessionState(s, NOW)).toBe('partial') + }) + + it('marks a cancelled session as cancelled', () => { + const s = mk({}, 'cancelled') + expect(sessionState(s, NOW)).toBe('cancelled') + }) + + it('uses user language labels rather than coverage counts', () => { + expect(sessionStateLabel('ready')).toBe('Ready') + expect(sessionStateLabel('upcoming')).toBe('Upcoming') + expect(sessionStateLabel('partial')).toBe('Partial') + }) +}) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index efae0bf..b5433c9 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -215,6 +215,12 @@ 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 b2994bf..8139606 100644 --- a/internal/query/navigation.go +++ b/internal/query/navigation.go @@ -3,11 +3,16 @@ 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") @@ -25,6 +30,10 @@ type Weekend struct { 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. @@ -85,6 +94,7 @@ 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 } @@ -141,6 +151,51 @@ 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 b15bf04..41aa834 100644 --- a/internal/query/query_test.go +++ b/internal/query/query_test.go @@ -5,6 +5,7 @@ import ( "errors" "path/filepath" "testing" + "time" "github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/store" @@ -376,6 +377,88 @@ 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 9789a69..0f5afa2 100644 --- a/scripts/seed-e2e-db/main.go +++ b/scripts/seed-e2e-db/main.go @@ -30,6 +30,11 @@ 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) @@ -59,6 +64,10 @@ func main() { fail(err) } + if err := seedFutureSession(st, futureSessionKey, meetingKey); err != nil { + fail(err) + } + fmt.Printf("seeded e2e db at %s\n", *dbPath) } @@ -95,6 +104,20 @@ func seedSession(st *store.Store, sessionKey, meetingKey int, name string) error }) } +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", + }) +} + func seedDrivers(st *store.Store, sessionKey, meetingKey int) error { drivers := []store.Driver{ { diff --git a/tests/race-hub.spec.ts b/tests/race-hub.spec.ts index 66b0b43..270753e 100644 --- a/tests/race-hub.spec.ts +++ b/tests/race-hub.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from '@playwright/test' 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 }) => { @@ -91,20 +92,56 @@ test.describe('Race Hub Weekend Workspace', () => { await expect(page.getByTestId(`rh-switcher-session-${FULL_SESSION}`)).toBeVisible() }) - test('Data Status tab points at admin instead of inline CLI hints', async ({ page }) => { + test('Diagnostics is a secondary action and points at admin, not inline CLI hints', async ({ page }) => { await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`) - await page.getByRole('tab', { name: 'Data Status' }).click() + await page.getByRole('tab', { name: 'Diagnostics' }).click() await expect(page.getByTestId('rh-data-status')).toBeVisible() await expect(page.getByRole('link', { name: /manage ingestion/i })).toHaveAttribute( '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() }) - test('bare /race-hub redirects to the focus session', async ({ page }) => { + test('groups analysis navigation into Story, Analysis, and Data & Context', async ({ page }) => { + await page.goto(`/race-hub?session_key=${FULL_SESSION}`) + await expect(page.getByTestId('rh-tabgroup-story')).toBeVisible() + await expect(page.getByTestId('rh-tabgroup-analysis')).toBeVisible() + await expect(page.getByTestId('rh-tabgroup-context')).toBeVisible() + }) + + test('bare /race-hub resolves to a completed session, never a future one', 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}`)) + }) + + test('explicit completed session deep link stays stable and shows analysis', async ({ page }) => { + await page.goto(`/race-hub?session_key=${FULL_SESSION}`) + await expect(page).toHaveURL(new RegExp(`session_key=${FULL_SESSION}`)) + await expect(page.getByTestId('rh-overview')).toBeVisible() + }) + + test('explicit future session renders the pre-session view, not empty analysis', async ({ 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 }) => { + await page.goto(`/race-hub?session_key=${FULL_SESSION}`) + await page.getByRole('tab', { name: 'Strategy' }).click() + + 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() }) }) diff --git a/tests/visual/__snapshots__/desktop/data-library.png b/tests/visual/__snapshots__/desktop/data-library.png index 07a6e3c..a83a8b7 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-future.png b/tests/visual/__snapshots__/desktop/race-hub-future.png new file mode 100644 index 0000000..cf38847 Binary files /dev/null and b/tests/visual/__snapshots__/desktop/race-hub-future.png differ diff --git a/tests/visual/__snapshots__/desktop/race-hub.png b/tests/visual/__snapshots__/desktop/race-hub.png index 9357944..405a419 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 37f8b19..4ef4541 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 055a052..697d502 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-future.png b/tests/visual/__snapshots__/mobile/race-hub-future.png new file mode 100644 index 0000000..01787cd Binary files /dev/null and b/tests/visual/__snapshots__/mobile/race-hub-future.png differ diff --git a/tests/visual/__snapshots__/mobile/race-hub.png b/tests/visual/__snapshots__/mobile/race-hub.png index a247ea7..8baa8ad 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 551f7a1..6884811 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 3b672b3..9186d4b 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-future.png b/tests/visual/__snapshots__/tablet/race-hub-future.png new file mode 100644 index 0000000..f7a1d07 Binary files /dev/null and b/tests/visual/__snapshots__/tablet/race-hub-future.png differ diff --git a/tests/visual/__snapshots__/tablet/race-hub.png b/tests/visual/__snapshots__/tablet/race-hub.png index 06af5a5..96fdb7b 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 d87b667..b0d646b 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 a621d71..adcb56e 100644 --- a/tests/visual/helpers.ts +++ b/tests/visual/helpers.ts @@ -7,6 +7,7 @@ export const VIEWPORTS = { } as const export const FULL_SESSION = 9472 +export const FUTURE_SESSION = 9600 /** Wait for web fonts and layout to settle before screenshots. */ export async function waitForScreenshotReady(page: Page): Promise { @@ -33,6 +34,16 @@ export async function gotoRaceHubReady(page: Page, sessionKey = FULL_SESSION): P await waitForScreenshotReady(page) } +export async function gotoRaceHubFutureReady( + page: Page, + sessionKey = FUTURE_SESSION, +): Promise { + await page.goto(`/race-hub?session_key=${sessionKey}`) + await expect(page.getByTestId('race-hub')).toBeVisible() + await expect(page.getByTestId('rh-presession')).toBeVisible() + await waitForScreenshotReady(page) +} + export async function gotoRaceStoryReady(page: Page, sessionKey = FULL_SESSION): Promise { await page.goto(`/race-hub?session_key=${sessionKey}`) await expect(page.getByTestId('race-hub')).toBeVisible() diff --git a/tests/visual/mvp-screens.spec.ts b/tests/visual/mvp-screens.spec.ts index 9ff4fe9..e6f063a 100644 --- a/tests/visual/mvp-screens.spec.ts +++ b/tests/visual/mvp-screens.spec.ts @@ -3,6 +3,7 @@ import { gotoWeekendReady, gotoDataLibraryReady, gotoLiveEmptyReady, + gotoRaceHubFutureReady, gotoRaceHubReady, screenshotPage, } from './helpers' @@ -18,6 +19,14 @@ test.describe('MVP visual regression', () => { await screenshotPage(page, 'race-hub') }) + test('race-hub-future', async ({ page }) => { + await gotoRaceHubFutureReady(page) + // The countdown ticks every second — mask it so the snapshot stays stable. + await screenshotPage(page, 'race-hub-future', { + mask: [page.getByTestId('rh-presession-countdown')], + }) + }) + test('data-library', async ({ page }) => { await gotoDataLibraryReady(page) await screenshotPage(page, 'data-library')