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 aa9215c..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 new file mode 100644 index 0000000..e443937 --- /dev/null +++ b/frontend/src/components/PreSessionView.tsx @@ -0,0 +1,141 @@ +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', + 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 PreSessionProps { + session: Session + sessionName: string + now: Date +} + +/** + * 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, now }: PreSessionProps) { + const start = sessionStartTime(session) + 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) => ( +
+
+ ))} +
+
+
+ ) +} + +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/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..ff69aa7 100644 --- a/frontend/src/components/WeekendSwitcher.tsx +++ b/frontend/src/components/WeekendSwitcher.tsx @@ -2,34 +2,37 @@ 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 { + 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, ) - - useEffect(() => { - if (year == null && seasonsQuery.data?.length) { - setYear(seasonsQuery.data[0]) - } - }, [seasonsQuery.data, year]) - - const meetingsQuery = useQuery({ - queryKey: ['meetings', year], - queryFn: () => fetchLocalMeetings(year!), - enabled: year != null, - }) + const now = nowProp ?? new Date() const weekendQuery = useQuery({ queryKey: ['weekend', openMeetingKey], @@ -37,6 +40,22 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose enabled: openMeetingKey != null, }) + useEffect(() => { + 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, weekendQuery.data, year]) + + const meetingsQuery = useQuery({ + queryKey: ['meetings', year], + queryFn: () => fetchLocalMeetings(year!), + enabled: year != null, + }) + const seasons = seasonsQuery.data ?? [] const meetings = meetingsQuery.data ?? [] const weekend = weekendQuery.data @@ -106,9 +125,16 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose {weekendQuery.isLoading && (
loading sessions…
)} - {weekend && weekend.meeting_key === m.meeting_key && - weekend.sessions.map(({ session, source, datasets }) => { + {weekend && + weekend.meeting_key === m.meeting_key && + weekend.sessions.map((weekendSession) => { + const { session } = weekendSession const active = session.session_key === currentSessionKey + const state = resolveSessionState({ + weekendSession, + context, + now, + }) return ( ) diff --git a/frontend/src/components/weekend/WeekendFocusBanner.tsx b/frontend/src/components/weekend/WeekendFocusBanner.tsx new file mode 100644 index 0000000..e0212f7 --- /dev/null +++ b/frontend/src/components/weekend/WeekendFocusBanner.tsx @@ -0,0 +1,65 @@ +import { Link } from '@tanstack/react-router' +import { useQuery } from '@tanstack/react-query' +import { ChevronRight } from 'lucide-react' +import { fetchWeekend } from '../../api' +import { meetingIdentity } from '../../lib/weekendContext' + +/** + * Restores Race Hub → Weekend meeting/session context from the URL search + * contract. Deep-linkable and reload-safe — no hidden component memory. + */ +export function WeekendFocusBanner({ + meetingKey, + sessionKey, +}: { + meetingKey?: number + sessionKey?: number +}) { + const hasFocus = (meetingKey != null && meetingKey > 0) || (sessionKey != null && sessionKey > 0) + const weekendQuery = useQuery({ + queryKey: ['weekend', meetingKey], + queryFn: () => fetchWeekend(meetingKey!), + enabled: meetingKey != null && meetingKey > 0, + staleTime: 60_000, + }) + + if (!hasFocus) return null + + const weekend = weekendQuery.data + const meeting = meetingIdentity(weekend?.meeting) + const session = weekend?.sessions.find((s) => s.session.session_key === sessionKey)?.session + const meetingLabel = meeting?.meeting_name ?? (meetingKey ? `Meeting ${meetingKey}` : 'Selected weekend') + const sessionLabel = session?.session_name ?? (sessionKey ? `Session ${sessionKey}` : null) + + return ( +
+
+ From Race Hub +

+ {meetingLabel} +

+ {sessionLabel && ( +

+ Selected session · {sessionLabel} +

+ )} +
+ {sessionKey != null && sessionKey > 0 && ( + + Continue analysis
+ ) +} diff --git a/frontend/src/lib/routeSearch.ts b/frontend/src/lib/routeSearch.ts new file mode 100644 index 0000000..5e514ed --- /dev/null +++ b/frontend/src/lib/routeSearch.ts @@ -0,0 +1,40 @@ +/** Shared URL search contracts for deep-linkable Weekend ↔ Race Hub navigation. */ + +export type WeekendSearch = { + meeting_key?: number + session_key?: number +} + +export type RaceHubSearch = { + session_key?: number +} + +function parsePositiveInt(value: unknown): number | undefined { + const n = Number(value) + return Number.isFinite(n) && n > 0 ? n : undefined +} + +export function parseWeekendSearch(search: Record): WeekendSearch { + const meeting_key = parsePositiveInt(search.meeting_key) + const session_key = parsePositiveInt(search.session_key) + return { + ...(meeting_key ? { meeting_key } : {}), + ...(session_key ? { session_key } : {}), + } +} + +export function parseRaceHubSearch(search: Record): RaceHubSearch { + const session_key = parsePositiveInt(search.session_key) + return session_key ? { session_key } : {} +} + +/** Build Weekend `/` search so a Race Hub return is reload-safe and deep-linkable. */ +export function weekendFocusSearch( + meetingKey?: number | null, + sessionKey?: number | null, +): WeekendSearch { + return { + ...(meetingKey && meetingKey > 0 ? { meeting_key: meetingKey } : {}), + ...(sessionKey && sessionKey > 0 ? { session_key: sessionKey } : {}), + } +} diff --git a/frontend/src/lib/sessionState.ts b/frontend/src/lib/sessionState.ts new file mode 100644 index 0000000..e8de384 --- /dev/null +++ b/frontend/src/lib/sessionState.ts @@ -0,0 +1,194 @@ +import type { ContextSession, WeekendContext, WeekendSession } from '../types' +import { isSessionComplete } from './coverage' +import { sessionStartTime } from './schedule' + +/** + * User-facing lifecycle state for a weekend session. + * + * Live comes only from Weekend Context's FIA-backed active identity — never from + * the scheduled start/end window alone. Preparing / partial / ready / unavailable + * come from structured `availability.local_analysis` when a context ref exists, + * otherwise from local dataset coverage after the scheduled start. + */ +export type SessionState = + | 'upcoming' + | 'live' + | 'preparing' + | 'partial' + | 'ready' + | 'unavailable' + | 'cancelled' + +export interface SessionStateInput { + weekendSession?: WeekendSession + context?: WeekendContext | null + now: Date +} + +function contextRefFor( + context: WeekendContext | null | undefined, + sessionKey: number | undefined, +): ContextSession | undefined { + if (!context || !sessionKey) return undefined + const refs = [ + context.active_session, + context.default_analysis_session, + context.previous_completed_session, + context.next_session, + ] + return refs.find((ref) => ref?.session.session_key === sessionKey) +} + +function fromAvailability(ref: ContextSession): SessionState | undefined { + const { schedule, live_session, local_analysis } = ref.availability + if (live_session === 'active') return 'live' + if (schedule === 'unavailable' || local_analysis === 'unavailable') return 'unavailable' + if (local_analysis === 'not_applicable') return 'upcoming' + if (local_analysis === 'pending') return 'preparing' + if (local_analysis === 'partial') return 'partial' + if (local_analysis === 'complete') return 'ready' + return undefined +} + +/** + * Resolve fan-facing session state. Prefer Weekend Context availability; never + * assert Live from wall-clock schedule alone. + */ +export function resolveSessionState({ + weekendSession, + context, + now, +}: SessionStateInput): SessionState { + if (weekendSession?.source === 'cancelled') return 'cancelled' + + const sessionKey = weekendSession?.session.session_key + const active = context?.active_session + if ( + active && + sessionKey && + active.session.session_key === sessionKey && + active.availability.live_session === 'active' + ) { + return 'live' + } + + const ref = contextRefFor(context, sessionKey) + if (ref) { + const fromCtx = fromAvailability(ref) + if (fromCtx) return fromCtx + } + + // Settling temporal state with no analysis yet — even without a matching ref. + if ( + context?.temporal_state === 'session_settling' && + weekendSession && + weekendSession.source === 'none' + ) { + return 'preparing' + } + + if (!weekendSession) return 'unavailable' + + const start = sessionStartTime(weekendSession.session) + if (start && start > now) return 'upcoming' + + if (isSessionComplete(weekendSession.datasets)) return 'ready' + if (weekendSession.source === 'none') return 'preparing' + if (weekendSession.source === 'partial') return 'partial' + return 'ready' +} + +/** @deprecated Prefer resolveSessionState with Weekend Context. */ +export function sessionState(session: WeekendSession, now: Date): SessionState { + return resolveSessionState({ weekendSession: session, now }) +} + +/** 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 'unavailable': + return 'Unavailable' + 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 — local data is still settling.' + case 'partial': + return 'Partial analysis available — some datasets are still missing.' + case 'ready': + return 'Full analysis is ready.' + case 'unavailable': + return 'Analysis is not available for this session.' + 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 'unavailable': + return 'rh-state-unavailable' + 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' +} + +/** Settling / empty post-session — not yet analysable. */ +export function isPreparing(state: SessionState): boolean { + return state === 'preparing' +} + +export function isUnavailable(state: SessionState): boolean { + return state === 'unavailable' || state === 'cancelled' +} + +/** Show fan analysis with a partial banner; keep available capabilities. */ +export function isPartialAnalysis(state: SessionState): boolean { + return state === 'partial' +} + +/** Canonical default analysis session key from Weekend Context, if any. */ +export function defaultAnalysisSessionKey( + context: WeekendContext | null | undefined, +): number | undefined { + const key = context?.default_analysis_session?.session.session_key + return key && key > 0 ? key : undefined +} diff --git a/frontend/src/pages/RaceHubPage.tsx b/frontend/src/pages/RaceHubPage.tsx index e680e9d..c50d2ff 100644 --- a/frontend/src/pages/RaceHubPage.tsx +++ b/frontend/src/pages/RaceHubPage.tsx @@ -1,12 +1,8 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useState } from 'react' import { useQuery } from '@tanstack/react-query' -import { useNavigate } from '@tanstack/react-router' -import { - fetchLocalMeetings, - fetchRaceHub, - fetchSeasons, - fetchWeekend, -} from '../api' +import { Link, useNavigate } from '@tanstack/react-router' +import { fetchRaceHub, fetchWeekend, fetchWeekendContext } from '../api' +import { weekendFocusSearch } from '../lib/routeSearch' import { DatasetStrip } from '../components/DatasetStrip' import { RaceStoryCanvas } from '../components/RaceStoryCanvas' import { TabBar, type Tab } from '../components/TabBar' @@ -17,77 +13,64 @@ import { CompareView } from '../components/CompareView' import { RaceControlView } from '../components/RaceControlView' import { WeatherView } from '../components/WeatherView' import { OverviewView } from '../components/OverviewView' +import { + PartialAnalysisBanner, + PreSessionView, + SessionPhaseView, +} 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, sortSessionsByStart } from '../lib/schedule' import { - formatSessionScheduleTime, - pickFocusMeeting, - sortSessionsByStart, -} from '../lib/schedule' -import type { Weekend, WeekendSession } from '../types' + defaultAnalysisSessionKey, + isPartialAnalysis, + isPreSession, + isPreparing, + isUnavailable, + resolveSessionState, + sessionStateDotClass, + sessionStateLabel, +} from '../lib/sessionState' +import type { WeekendSession } from '../types' interface Props { sessionKey: number } -function pickAnalysisSession(weekend: Weekend | undefined): WeekendSession | undefined { - if (!weekend) return undefined - const local = weekend.sessions.filter((s) => s.source === 'local') - const partial = weekend.sessions.filter((s) => s.source === 'partial') - const pool = local.length > 0 ? local : partial.length > 0 ? partial : weekend.sessions - const race = pool.find((s) => s.session.session_type?.toLowerCase().includes('race')) - if (race) return race - const qual = pool.find((s) => s.session.session_type?.toLowerCase().includes('qualifying')) - if (qual) return qual - return pool[0] -} - export function RaceHubPage({ sessionKey }: Props) { const navigate = useNavigate() const [activeTab, setActiveTab] = useState('overview') const [switcherOpen, setSwitcherOpen] = useState(false) + const [showDiagnostics, setShowDiagnostics] = useState(false) + const [phaseDiagnostics, setPhaseDiagnostics] = useState(false) + const [now, setNow] = useState(() => new Date()) - // ─── Auto-redirect when no session_key is supplied ─── - const seasonsQuery = useQuery({ - queryKey: ['seasons'], - queryFn: fetchSeasons, - enabled: sessionKey === 0, - }) - - const latestSeason = seasonsQuery.data?.[0] ?? null - - const meetingsQuery = useQuery({ - queryKey: ['meetings', latestSeason], - queryFn: () => fetchLocalMeetings(latestSeason!), - enabled: sessionKey === 0 && latestSeason != null, - }) - - const focusMeeting = useMemo(() => { - if (sessionKey !== 0 || !meetingsQuery.data) return null - return pickFocusMeeting(meetingsQuery.data, new Date()) - }, [sessionKey, meetingsQuery.data]) - - const fallbackWeekendQuery = useQuery({ - queryKey: ['weekend', focusMeeting?.meeting_key], - queryFn: () => fetchWeekend(focusMeeting!.meeting_key), - enabled: sessionKey === 0 && focusMeeting != null, + // Keep schedule-adjacent UI (countdown) moving; Live/completion come from context. + useEffect(() => { + const id = window.setInterval(() => setNow(new Date()), 1000) + return () => window.clearInterval(id) + }, []) + + const contextQuery = useQuery({ + queryKey: ['weekend-context'], + queryFn: fetchWeekendContext, + staleTime: 15_000, + refetchInterval: 30_000, }) + const context = contextQuery.data ?? null + // Bare `/race-hub` resolves only through canonical Weekend Context. useEffect(() => { if (sessionKey !== 0) return - const weekend = fallbackWeekendQuery.data - if (!weekend) return - const target = pickAnalysisSession(weekend)?.session.session_key - ?? weekend.default_session_key - ?? weekend.sessions[0]?.session.session_key + if (!contextQuery.isSuccess || !context) return + const target = defaultAnalysisSessionKey(context) if (target) { navigate({ to: '/race-hub', search: { session_key: target }, replace: true }) } - }, [sessionKey, fallbackWeekendQuery.data, navigate]) + }, [sessionKey, contextQuery.isSuccess, context, navigate]) - // ─── Active session payload ─── const raceHubQuery = useQuery({ queryKey: ['race-hub', sessionKey], queryFn: () => fetchRaceHub(sessionKey), @@ -108,37 +91,86 @@ export function RaceHubPage({ sessionKey }: Props) { const accent = countryAccent(data?.meeting ?? null) const accentStyle = { '--gp-accent': accent } as React.CSSProperties - // ─── No session_key: show resolving state, fall back to switcher if no local data ─── + const openDiagnostics = () => { + setActiveTab('data_status') + setShowDiagnostics(true) + setPhaseDiagnostics(true) + } + + // ─── No session_key: resolve via Weekend Context ─── if (sessionKey === 0) { - if (seasonsQuery.isLoading || meetingsQuery.isLoading || fallbackWeekendQuery.isLoading) { + if (contextQuery.isLoading) { return (
-
resolving latest local weekend…
+
resolving weekend context…
) } - const seasons = seasonsQuery.data ?? [] - if (seasons.length === 0) { + if (contextQuery.isError) { return ( -
-
- box-box · race hub -

No local sessions yet

-

- The Race Hub reads from local ingest only. Once a weekend is ingested - it will open here automatically. -

-
- Open Admin · Data Health - Back to Command Center +
+
+
+ {contextQuery.error instanceof Error + ? contextQuery.error.message + : 'Failed to load weekend context.'} +
+
+ + + Back to Weekend +
) } + + if (context && !defaultAnalysisSessionKey(context)) { + return ( +
+
+ box-box · race hub +

No completed session to analyse yet

+

+ Weekend Context has no default analysis session. Pick a past session + to review, or check back once a session completes with local analysis. +

+
+ + + Back to Weekend + +
+
+ {switcherOpen && ( + setSwitcherOpen(false)} + /> + )} +
+ ) + } + return (
-
resolving latest local weekend…
+
resolving weekend context…
) } @@ -152,12 +184,39 @@ export function RaceHubPage({ sessionKey }: Props) { ) } if (raceHubQuery.isError || !data) { + const backMeeting = + context?.focus_meeting?.meeting_key ?? + context?.previous_completed_session?.meeting?.meeting_key + const backSearch = weekendFocusSearch(backMeeting, sessionKey) + 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,12 +227,30 @@ 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 = resolveSessionState({ + weekendSession: activeSessionMeta, + context, + now, + }) + const preSession = isPreSession(activeState) + const preparing = isPreparing(activeState) + const unavailable = isUnavailable(activeState) + const partial = isPartialAnalysis(activeState) return (
- {/* Topbar */}
+ + Back to Weekend + box-box · race hub {data.meeting?.year ? ` · ${data.meeting.year}` : ''} @@ -195,11 +272,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)} - {activeSessionMeta && ( - - - )} + + key {sessionKey}
)} - - - - - {activeTab === 'overview' && } - - {activeTab === 'race_story' && ( -
- -
- )} - - {activeTab === 'strategy' && ( -
-
- Race Strategy -
- + ) : preparing || unavailable ? ( + <> + -
- )} + {phaseDiagnostics && ( +
+
+ Diagnostics +
+ +
+ +
+
+ )} + + ) : ( + <> + {partial && } + - {activeTab === 'compare' && ( -
-
- Driver Compare -
- -
- )} + {activeTab === 'overview' && } - {activeTab === 'lap_data' && ( -
-
- Lap Data - {data.laps.length > 0 && ( - {data.laps.length} samples - )} -
- -
- )} + {activeTab === 'race_story' && ( +
+ +
+ )} - {activeTab === 'conditions' && ( -
-
- Conditions - {data.weather.length > 0 && ( - {data.weather.length} samples - )} -
- -
- )} + {activeTab === 'strategy' && ( +
+
+ Race Strategy +
+ +
+ )} - {activeTab === 'race_control' && ( -
-
- Race Control - {data.race_control.length > 0 && ( - {data.race_control.length} messages - )} -
- -
- )} + {activeTab === 'compare' && ( +
+
+ Driver Compare +
+ +
+ )} - {activeTab === 'data_status' && ( -
-
- Data Status -
- -
+ {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 === 'race_control' && ( +
+
+ Race Control + {data.race_control.length > 0 && ( + {data.race_control.length} messages + )} +
+ +
+ )} + + {activeTab === 'data_status' && ( +
+
+ Diagnostics + +
+ + {showDiagnostics && ( +
+ +
+ )} +
+ )} + )}
) diff --git a/frontend/src/pages/WeekendPage.tsx b/frontend/src/pages/WeekendPage.tsx index 1ae64a2..57c99fc 100644 --- a/frontend/src/pages/WeekendPage.tsx +++ b/frontend/src/pages/WeekendPage.tsx @@ -4,6 +4,7 @@ import { BetweenSessionsView } from '../components/weekend/BetweenSessionsView' import { LiveHandoffView } from '../components/weekend/LiveHandoffView' import { PreSessionView } from '../components/weekend/PreSessionView' import { WeekendError, WeekendLimited, WeekendLoading } from '../components/weekend/StatusViews' +import { WeekendFocusBanner } from '../components/weekend/WeekendFocusBanner' import { resolveViewState } from '../lib/weekendContext' import type { WeekendBriefingItem, @@ -59,8 +60,21 @@ function renderState(view: WeekendViewState, args: RenderArgs) { } } -export function WeekendPage({ preview = false }: { preview?: boolean }) { +export function WeekendPage({ + preview = false, + focusMeetingKey, + focusSessionKey, +}: { + preview?: boolean + /** Restored from `/?meeting_key=` when returning from Race Hub analysis. */ + focusMeetingKey?: number + /** Restored from `/?session_key=` when returning from Race Hub analysis. */ + focusSessionKey?: number +}) { const { context, loadState, error, championship, briefing, now } = useWeekendContext() + const hasFocus = + (focusMeetingKey != null && focusMeetingKey > 0) || + (focusSessionKey != null && focusSessionKey > 0) if (loadState === 'loading') { return ( @@ -87,7 +101,12 @@ export function WeekendPage({ preview = false }: { preview?: boolean }) { data-state={view} data-temporal-state={context.temporal_state} data-preview={preview ? 'true' : undefined} + data-meeting-key={focusMeetingKey ?? undefined} + data-session-key={focusSessionKey ?? undefined} > + {hasFocus && ( + + )} {renderState(view, { context, now, championship, briefing, preview })} ) diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index d38430e..fd8bac5 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -8,10 +8,7 @@ import { LiveTimingPage } from './pages/LiveTimingPage' import { BriefingPage } from './pages/BriefingPage' import { ChampionshipPage } from './pages/ChampionshipPage' import { DriverProfilePage } from './pages/DriverProfilePage' - -type RaceHubSearch = { - session_key?: number -} +import { parseRaceHubSearch, parseWeekendSearch } from './lib/routeSearch' type DriverProfileSearch = { year?: number @@ -27,10 +24,18 @@ const rootRoute = createRootRoute({ }) // Weekend is the adaptive home. `/` renders the current temporal state. +// Optional meeting_key/session_key search restores Race Hub return context +// (deep-linkable, reload-safe — no hidden component memory). export const weekendRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', - component: WeekendPage, + validateSearch: (search: Record) => parseWeekendSearch(search), + component: function WeekendRoute() { + const { meeting_key, session_key } = weekendRoute.useSearch() + return ( + + ) + }, }) export const exploreRoute = createRoute({ @@ -42,10 +47,7 @@ export const exploreRoute = createRoute({ export const raceHubRoute = createRoute({ getParentRoute: () => rootRoute, path: '/race-hub', - validateSearch: (search: Record): RaceHubSearch => { - const sessionKey = Number(search.session_key) - return Number.isFinite(sessionKey) && sessionKey > 0 ? { session_key: sessionKey } : {} - }, + validateSearch: (search: Record) => parseRaceHubSearch(search), component: function RaceHubRoute() { const { session_key } = raceHubRoute.useSearch() return diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 3b98635..4d41ee6 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -1820,6 +1820,163 @@ 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-unavailable { background: var(--text-3); opacity: 0.4; } +.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-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)); + 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; @@ -2669,6 +2826,31 @@ a { color: inherit; text-decoration: none; } .rh-topbar-spacer { flex: 1; } +.rh-back-weekend { + flex-shrink: 0; + padding: 4px 10px; + font-size: 10px; + font-weight: 700; + font-family: var(--f-mono); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-2); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 2px; + transition: color 0.1s, border-color 0.1s, background 0.1s; +} +.rh-back-weekend:hover, +.rh-back-weekend:focus-visible { + color: var(--text); + border-color: var(--gp-accent); + background: var(--surface-h); + outline: none; +} +.rh-back-weekend:focus-visible { + box-shadow: 0 0 0 2px color-mix(in srgb, var(--gp-accent) 45%, transparent); +} + .rh-switcher-toggle { padding: 4px 10px; font-size: 10px; @@ -3244,7 +3426,10 @@ a { color: inherit; text-decoration: none; } .rh-overview-row { grid-template-columns: 1fr; } } @media (max-width: 640px) { - .rh-page { padding: var(--s4); gap: var(--s4); } + .rh-page { padding: var(--s4); gap: var(--s4); padding-bottom: 88px; } + .rh-topbar { flex-wrap: wrap; gap: var(--s3); } + .rh-topbar-spacer { display: none; } + .rh-back-weekend { order: -1; } .rh-identity-body { padding: var(--s3) var(--s4); gap: var(--s3); } .rh-identity-decal { font-size: 28px; } .rh-identity-name { font-size: 17px; } diff --git a/frontend/src/styles/weekend.css b/frontend/src/styles/weekend.css index 2f03a9b..9414d29 100644 --- a/frontend/src/styles/weekend.css +++ b/frontend/src/styles/weekend.css @@ -6,6 +6,36 @@ width: 100%; } +.wk-focus { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s5); + margin-bottom: var(--s5); + padding: var(--s5); + background: var(--surface); + border: 1px solid var(--border); + border-left: 3px solid var(--red); + border-radius: 8px; +} +.wk-focus-copy { display: flex; flex-direction: column; gap: var(--s2); min-width: 0; } +.wk-focus-eyebrow { + font-size: 10px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-3); +} +.wk-focus-title { + font-size: 18px; + font-weight: 700; + line-height: 1.2; +} +.wk-focus-session { + font-size: 13px; + color: var(--text-2); +} +.wk-focus-cta { flex-shrink: 0; } + .wk-eyebrow { display: flex; align-items: center; @@ -313,6 +343,11 @@ } @media (max-width: 640px) { .wk-page { padding: var(--s5) var(--s5) 88px; } + .wk-focus { + flex-direction: column; + align-items: stretch; + } + .wk-focus-cta { width: 100%; justify-content: center; } .wk-event-name { font-size: 20px; } .wk-sessions-title { font-size: 22px; } .wk-count-unit b { font-size: 32px; } diff --git a/frontend/src/test/RaceHubPage.test.tsx b/frontend/src/test/RaceHubPage.test.tsx index 3e90681..4bd998b 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 { @@ -9,21 +9,38 @@ 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) + +const NOW = new Date('2025-06-01T00:00:00Z') const meeting: Meeting = { meeting_key: 1229, @@ -59,6 +76,16 @@ const qualSession: Session = { gmt_offset: '02:00:00', } +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 }, @@ -169,6 +196,41 @@ const weekend: Weekend = { ], } +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 } }, @@ -181,6 +243,19 @@ function renderRaceHub(sessionKey: number) { ), }) + const weekendRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: (search: Record) => { + const meetingKey = Number(search.meeting_key) + const sk = Number(search.session_key) + return { + ...(Number.isFinite(meetingKey) && meetingKey > 0 ? { meeting_key: meetingKey } : {}), + ...(Number.isFinite(sk) && sk > 0 ? { session_key: sk } : {}), + } + }, + component: () =>
, + }) const raceHubRoute = createRoute({ getParentRoute: () => rootRoute, path: '/race-hub', @@ -194,11 +269,10 @@ function renderRaceHub(sessionKey: number) { }, }) const router = createRouter({ - routeTree: rootRoute.addChildren([raceHubRoute]), + routeTree: rootRoute.addChildren([weekendRoute, raceHubRoute]), history: undefined, }) - // Navigate to the URL before mounting router.navigate({ to: '/race-hub', search: sessionKey ? { session_key: sessionKey } : {} }) return render() } @@ -206,10 +280,17 @@ 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) + mockFetchWeekendContext.mockResolvedValue(weekendContext) + }) + + afterEach(() => { + vi.useRealTimers() }) it('renders the workspace identity band, session rail, and overview for a known session', async () => { @@ -224,9 +305,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 () => { @@ -236,17 +317,43 @@ describe('RaceHubPage', () => { fireEvent.click(screen.getByRole('tab', { name: 'Race Story' })) expect(screen.getByText('VER')).toBeInTheDocument() - }) - 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() + + 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()) + + 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() + 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 +363,213 @@ describe('RaceHubPage', () => { fireEvent.click(screen.getByTestId('rh-switch-weekend')) expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument() }) + + 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) + + await waitFor(() => expect(mockFetchRaceHub).toHaveBeenCalledWith(9471)) + 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, + 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() + expect(screen.queryByTestId('rh-overview')).not.toBeInTheDocument() + expect(screen.queryByText('Winner')).not.toBeInTheDocument() + }) + + 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: [ + { session: qualSession, source: 'local', datasets: fullDatasets }, + { 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 () => { + 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', expect.stringMatching(/meeting_key=1229/)) + expect(back).toHaveAttribute('href', expect.stringMatching(/session_key=9472/)) + expect(back.getAttribute('href')).toMatch(/^\//) + + mockFetchRaceHub.mockResolvedValue(raceHub) + fireEvent.click(screen.getByTestId('rh-retry')) + await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument()) + }) + + it('exposes a Back to Weekend link that carries meeting and session context', async () => { + renderRaceHub(9472) + + await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument()) + const back = screen.getByRole('link', { name: 'Back to Weekend' }) + expect(back).toHaveAttribute('data-testid', 'rh-back-weekend') + expect(back).toHaveAttribute('href', expect.stringMatching(/meeting_key=1229/)) + expect(back).toHaveAttribute('href', expect.stringMatching(/session_key=9472/)) + }) }) 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/WeekendPage.test.tsx b/frontend/src/test/WeekendPage.test.tsx index f0e0515..3a3f229 100644 --- a/frontend/src/test/WeekendPage.test.tsx +++ b/frontend/src/test/WeekendPage.test.tsx @@ -20,12 +20,14 @@ import type { TemporalState, WeekendContext, } from '../types' +import { parseWeekendSearch } from '../lib/routeSearch' vi.mock('../api', () => ({ fetchWeekendContext: vi.fn(), fetchChampionshipHub: vi.fn(), fetchNews: vi.fn(), fetchRaceHub: vi.fn(), + fetchWeekend: vi.fn(), // Consumed transitively by RacePreviewPage (folded into PreSessionView): fetchSeasons: vi.fn(), fetchMeetings: vi.fn(), @@ -40,6 +42,7 @@ import { fetchChampionshipHub, fetchNews, fetchRaceHub, + fetchWeekend, fetchSeasons, fetchMeetings, fetchSessions, @@ -52,6 +55,7 @@ const mockContext = vi.mocked(fetchWeekendContext) const mockHub = vi.mocked(fetchChampionshipHub) const mockNews = vi.mocked(fetchNews) const mockRaceHub = vi.mocked(fetchRaceHub) +const mockWeekend = vi.mocked(fetchWeekend) const mockSeasons = vi.mocked(fetchSeasons) const mockMeetings = vi.mocked(fetchMeetings) const mockSessions = vi.mocked(fetchSessions) @@ -152,7 +156,15 @@ function renderAt(path: string) { ), }) - const homeRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: () => }) + const homeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: (s: Record) => parseWeekendSearch(s), + component: function HomeRoute() { + const { meeting_key, session_key } = homeRoute.useSearch() + return + }, + }) const previewRoute = createRoute({ getParentRoute: () => rootRoute, path: '/preview', @@ -192,6 +204,19 @@ describe('WeekendPage canonical contract rendering', () => { mockHub.mockResolvedValue(hub) mockNews.mockResolvedValue([]) mockRaceHub.mockResolvedValue(raceHub) + mockWeekend.mockResolvedValue({ + source: 'local', + meeting_key: 1, + meeting: meeting(), + sessions: [ + { + session: session({ session_key: 11, session_name: 'Race' }), + source: 'local', + datasets: {}, + }, + ], + default_session_key: 11, + }) mockSeasons.mockResolvedValue([2026]) mockMeetings.mockResolvedValue([meeting()]) mockSessions.mockResolvedValue([session()]) @@ -306,4 +331,27 @@ describe('WeekendPage canonical contract rendering', () => { // The between-races surface must NOT be what /preview renders. expect(screen.queryByTestId('weekend-between-races')).not.toBeInTheDocument() }) + + it('restores Race Hub meeting/session focus from the Weekend URL search contract', async () => { + mockContext.mockResolvedValue( + context({ + temporal_state: 'season_complete', + previous_completed_session: ctxSession({ session: session({ session_key: 11, session_name: 'Race' }) }), + default_analysis_session: ctxSession({ session: session({ session_key: 11 }) }), + }), + ) + renderAt('/?meeting_key=1&session_key=11') + await waitFor(() => expect(screen.getByTestId('wk-focus-context')).toBeInTheDocument()) + expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-meeting-key', '1') + expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-session-key', '11') + await waitFor(() => + expect(screen.getByTestId('wk-focus-meeting')).toHaveTextContent('British Grand Prix'), + ) + await waitFor(() => + expect(screen.getByTestId('wk-focus-session')).toHaveTextContent('Race'), + ) + const continueAnalysis = screen.getByRole('link', { name: /Continue analysis/i }) + expect(continueAnalysis).toHaveAttribute('href', expect.stringContaining('session_key=11')) + expect(mockWeekend).toHaveBeenCalledWith(1) + }) }) diff --git a/frontend/src/test/routeSearch.test.ts b/frontend/src/test/routeSearch.test.ts new file mode 100644 index 0000000..0bae53c --- /dev/null +++ b/frontend/src/test/routeSearch.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { parseRaceHubSearch, parseWeekendSearch, weekendFocusSearch } from '../lib/routeSearch' + +describe('routeSearch', () => { + it('parses positive weekend meeting/session keys and drops invalid values', () => { + expect(parseWeekendSearch({ meeting_key: '1229', session_key: '9000' })).toEqual({ + meeting_key: 1229, + session_key: 9000, + }) + expect(parseWeekendSearch({ meeting_key: '0', session_key: '-1', foo: 'bar' })).toEqual({}) + expect(parseWeekendSearch({})).toEqual({}) + }) + + it('parses race-hub session keys', () => { + expect(parseRaceHubSearch({ session_key: '9472' })).toEqual({ session_key: 9472 }) + expect(parseRaceHubSearch({ session_key: 'nope' })).toEqual({}) + }) + + it('builds Weekend focus search for Race Hub returns', () => { + expect(weekendFocusSearch(1229, 9000)).toEqual({ meeting_key: 1229, session_key: 9000 }) + expect(weekendFocusSearch(null, 9000)).toEqual({ session_key: 9000 }) + expect(weekendFocusSearch(1229, undefined)).toEqual({ meeting_key: 1229 }) + }) +}) diff --git a/frontend/src/test/sessionState.test.ts b/frontend/src/test/sessionState.test.ts new file mode 100644 index 0000000..14b21d5 --- /dev/null +++ b/frontend/src/test/sessionState.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect } from 'vitest' +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') + +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 }]), +) + +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('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 }, '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', () => { + 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 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') + }) + + it('uses user language labels rather than coverage counts', () => { + expect(sessionStateLabel('ready')).toBe('Ready') + expect(sessionStateLabel('upcoming')).toBe('Upcoming') + expect(sessionStateLabel('partial')).toBe('Partial') + expect(sessionStateLabel('unavailable')).toBe('Unavailable') + }) +}) diff --git a/internal/query/navigation.go b/internal/query/navigation.go index b2994bf..f6bf068 100644 --- a/internal/query/navigation.go +++ b/internal/query/navigation.go @@ -19,6 +19,8 @@ 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"` diff --git a/scripts/seed-e2e-db/main.go b/scripts/seed-e2e-db/main.go index 9789a69..10b6bb7 100644 --- a/scripts/seed-e2e-db/main.go +++ b/scripts/seed-e2e-db/main.go @@ -84,14 +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", + 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 66b0b43..9c915c0 100644 --- a/tests/race-hub.spec.ts +++ b/tests/race-hub.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' +import { FUTURE_SESSION, mockFutureRaceHubSession } from './fixtures/future-session' const FULL_SESSION = 9472 const CORE_ONLY_SESSION = 9000 @@ -15,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 }) => { @@ -87,24 +89,110 @@ 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() }) - 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', ) + 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 via Weekend Context', async ({ page }) => { await page.goto('/race-hub') await expect(page).toHaveURL(/session_key=\d+/) await expect(page.getByTestId('race-hub')).toBeVisible() + 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 }) => { + 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 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 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() + + // 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') + + // Explicit Race Hub → Weekend return carries meeting/session in the URL. + const back = page.getByRole('link', { name: 'Back to Weekend' }) + await expect(back).toBeVisible() + await expect(back).toHaveAttribute('href', /meeting_key=1229/) + await expect(back).toHaveAttribute('href', /session_key=9000/) + await back.click() + + await expect(page).toHaveURL(/\/\?.*meeting_key=1229/) + await expect(page).toHaveURL(/session_key=9000/) + await expect(page.getByTestId('weekend-page')).toBeVisible() + await expect(page.getByTestId('weekend-page')).toHaveAttribute('data-meeting-key', '1229') + await expect(page.getByTestId('weekend-page')).toHaveAttribute('data-session-key', '9000') + await expect(page.getByTestId('wk-focus-context')).toBeVisible() + await expect(page.getByTestId('wk-focus-meeting')).toContainText('Monaco') + await expect(page.getByTestId('wk-focus-session')).toContainText('Core Only') + + // Contextual action returns to the exact selected analysis session. + const continueAnalysis = page.getByRole('link', { name: /Continue analysis/i }) + await expect(continueAnalysis).toHaveAttribute( + 'href', + new RegExp(`session_key=${CORE_ONLY_SESSION}`), + ) + await continueAnalysis.click() + await expect(page).toHaveURL(new RegExp(`session_key=${CORE_ONLY_SESSION}`)) + await expect(page.getByTestId('race-hub')).toBeVisible() + await expect(page.getByTestId('rh-identity')).toContainText('Monaco') + }) + + test('Back to Weekend is keyboard-focusable and works at mobile width', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`) + await expect(page.getByTestId('race-hub')).toBeVisible() + + const back = page.getByRole('link', { name: 'Back to Weekend' }) + await expect(back).toBeVisible() + await back.focus() + await expect(back).toBeFocused() + await page.keyboard.press('Enter') + + await expect(page.getByTestId('weekend-page')).toBeVisible() + await expect(page.getByTestId('weekend-page')).toHaveAttribute('data-meeting-key', '1229') + await expect(page.getByTestId('weekend-page')).toHaveAttribute('data-session-key', '9000') + await expect(page.getByTestId('wk-focus-context')).toBeVisible() + await expect(page.getByRole('link', { name: /Continue analysis/i })).toBeVisible() }) }) 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..ad4afce 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..b098846 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..ae2993f 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/race-hub-future.png b/tests/visual/__snapshots__/mobile/race-hub-future.png new file mode 100644 index 0000000..20ef9e2 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..6f55abc 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..0b5c0c1 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/race-hub-future.png b/tests/visual/__snapshots__/tablet/race-hub-future.png new file mode 100644 index 0000000..f6e1e86 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..e03ede7 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..dccccb1 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..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,6 +8,7 @@ export const VIEWPORTS = { } as const export const FULL_SESSION = 9472 +export { FUTURE_SESSION } /** Wait for web fonts and layout to settle before screenshots. */ export async function waitForScreenshotReady(page: Page): Promise { @@ -33,6 +35,17 @@ export async function gotoRaceHubReady(page: Page, sessionKey = FULL_SESSION): P await waitForScreenshotReady(page) } +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() + 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')