feat(race-hub): trustworthy defaults and fan-facing analysis hierarchy (#75)

Make bare /race-hub resolve to a completed session and never open empty
post-session analysis for a future race.

- Backend: add `default_analysis_session` to the Weekend context. It never
  resolves to a future session (picks the richest completed session, ties
  toward the later one; 0 when everything is upcoming). Existing
  `default_session_key` and deep links are unchanged.
- Frontend default resolution prefers the most recently completed weekend
  (`pickAnalysisFocusMeeting`) and consumes `default_analysis_session`, falling
  back to the switcher when only upcoming sessions exist.
- New `sessionState` lib maps timing + coverage to user language
  (upcoming/live/preparing/partial/ready/cancelled); the session rail, active
  sub-bar, and WeekendSwitcher now label states instead of raw x/11 counts.
- Future sessions render a purpose-built PreSessionView (expected availability +
  countdown) instead of empty Winner/Podium/Pole/Strategy/Compare cards.
- Analysis navigation regrouped into Story / Analysis / Data & Context; every
  existing tab is preserved. Diagnostics (renamed from Data Status) is now a
  secondary action and the raw dataset strip is hidden behind an explicit
  toggle, so operational coverage no longer precedes fan content.
- Loading/error states offer Retry and a path back to Weekend.

Tests: Go query tests for future-exclusion; Vitest for default selection,
future pre-session, partial state, error/retry, grouped nav, and sessionState;
hermetic Playwright for bare/completed/future/return-to-Weekend; new
race-hub-future visual snapshots. Seed adds a far-future session inside the
Monaco meeting (kept in-meeting so Command Center focus is unaffected).

Note: `default_analysis_session` is an additive field on the existing
`/weekend` contract (no new endpoint), per the spec's "context contract" scope.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-12 18:29:34 -04:00
parent 84a9a3579f
commit 7c98489b91
30 changed files with 1055 additions and 144 deletions

View File

@@ -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<Tab>('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) {
</div>
)
}
// Weekend resolved but every session is upcoming — offer the switcher instead
// of silently opening empty analysis.
if (fallbackWeekendQuery.data && !pickAnalysisSession(fallbackWeekendQuery.data, now)) {
return (
<div className="rh-page rh-empty" data-testid="race-hub-no-analysis" style={accentStyle}>
<div className="rh-empty-band">
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
<h1 className="rh-empty-title">No completed session to analyse yet</h1>
<p className="rh-empty-sub">
The next weekend hasnt run. Pick a past session to review, or check
back once its complete.
</p>
<div className="rh-empty-actions">
<button
type="button"
className="rh-empty-action"
onClick={() => setSwitcherOpen(true)}
>
Browse Weekends
</button>
<a href="/" className="rh-empty-action">Back to Command Center</a>
</div>
</div>
{switcherOpen && (
<WeekendSwitcher
currentMeetingKey={fallbackWeekendQuery.data.meeting_key}
onClose={() => setSwitcherOpen(false)}
/>
)}
</div>
)
}
return (
<div className="rh-page" style={accentStyle}>
<div className="loading-state">resolving latest local weekend</div>
@@ -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 (
<div className="rh-page" style={accentStyle}>
@@ -153,11 +211,26 @@ export function RaceHubPage({ sessionKey }: Props) {
}
if (raceHubQuery.isError || !data) {
return (
<div className="rh-page" style={accentStyle}>
<div className="error-box">
{raceHubQuery.error instanceof Error
? raceHubQuery.error.message
: `Failed to load session ${sessionKey}.`}
<div className="rh-page" data-testid="race-hub-error" style={accentStyle}>
<div className="rh-recover">
<div className="error-box">
{raceHubQuery.error instanceof Error
? raceHubQuery.error.message
: `Failed to load session ${sessionKey}.`}
</div>
<div className="rh-recover-actions">
<button
type="button"
className="rh-recover-btn primary"
onClick={() => raceHubQuery.refetch()}
data-testid="rh-retry"
>
Retry
</button>
<a href="/race-hub" className="rh-recover-btn" data-testid="rh-back-weekend">
Back to Weekend
</a>
</div>
</div>
</div>
)
@@ -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 (
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
@@ -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 (
<button
key={session.session_key}
@@ -247,13 +323,13 @@ export function RaceHubPage({ sessionKey }: Props) {
<span className="rh-session-time mono">
{formatSessionScheduleTime(session.date_start)}
</span>
{meta && (
{state && (
<span className="rh-session-cov mono">
<span
className={`cc-cov-dot cc-cov-${meta.source}`}
className={`cc-cov-dot ${sessionStateDotClass(state)}`}
aria-hidden="true"
/>
{formatCoverageHint(meta.datasets)}
{sessionStateLabel(state)}
</span>
)}
</button>
@@ -269,101 +345,119 @@ export function RaceHubPage({ sessionKey }: Props) {
<span className="rh-active-meta mono">
{formatSessionScheduleTime(data.session.date_start)}
</span>
{activeSessionMeta && (
<span className="rh-active-cov mono">
{activeState && (
<span className="rh-active-cov mono" data-testid="rh-active-state">
<span
className={`cc-cov-dot cc-cov-${activeSessionMeta.source}`}
className={`cc-cov-dot ${sessionStateDotClass(activeState)}`}
aria-hidden="true"
/>
{formatCoverageHint(activeSessionMeta.datasets)} datasets local
{sessionStateLabel(activeState)}
</span>
)}
<span className="rh-active-key mono">key {sessionKey}</span>
</div>
)}
<DatasetStrip datasets={data.datasets} />
{preSession && data.session ? (
<PreSessionView session={data.session} sessionName={data.session.session_name} />
) : (
<>
<TabBar active={activeTab} onChange={setActiveTab} />
<TabBar active={activeTab} onChange={setActiveTab} />
{activeTab === 'overview' && <OverviewView data={data} />}
{activeTab === 'overview' && <OverviewView data={data} />}
{activeTab === 'race_story' && (
<div className="data-section">
<RaceStoryCanvas data={data} />
</div>
)}
{activeTab === 'race_story' && (
<div className="data-section">
<RaceStoryCanvas data={data} />
</div>
)}
{activeTab === 'strategy' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Race Strategy</span>
</div>
<StrategyView
results={data.results}
stints={data.stints}
pit_stops={data.pit_stops}
hasStints={data.datasets['stints']?.status === 'available'}
/>
</div>
)}
{activeTab === 'strategy' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Race Strategy</span>
</div>
<StrategyView
results={data.results}
stints={data.stints}
pit_stops={data.pit_stops}
hasStints={data.datasets['stints']?.status === 'available'}
/>
</div>
)}
{activeTab === 'compare' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Driver Compare</span>
</div>
<CompareView
sessionKey={sessionKey}
results={data.results}
drivers={data.drivers}
/>
</div>
)}
{activeTab === 'compare' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Driver Compare</span>
</div>
<CompareView
sessionKey={sessionKey}
results={data.results}
drivers={data.drivers}
/>
</div>
)}
{activeTab === 'lap_data' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Lap Data</span>
{data.laps.length > 0 && (
<span className="sec-meta mono">{data.laps.length} samples</span>
)}
</div>
<LapsView laps={data.laps} drivers={data.drivers} />
</div>
)}
{activeTab === 'lap_data' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Lap Data</span>
{data.laps.length > 0 && (
<span className="sec-meta mono">{data.laps.length} samples</span>
)}
</div>
<LapsView laps={data.laps} drivers={data.drivers} />
</div>
)}
{activeTab === 'conditions' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Conditions</span>
{data.weather.length > 0 && (
<span className="sec-meta mono">{data.weather.length} samples</span>
)}
</div>
<WeatherView weather={data.weather} />
</div>
)}
{activeTab === 'conditions' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Conditions</span>
{data.weather.length > 0 && (
<span className="sec-meta mono">{data.weather.length} samples</span>
)}
</div>
<WeatherView weather={data.weather} />
</div>
)}
{activeTab === 'race_control' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Race Control</span>
{data.race_control.length > 0 && (
<span className="sec-meta mono">{data.race_control.length} messages</span>
)}
</div>
<RaceControlView messages={data.race_control} />
</div>
)}
{activeTab === 'race_control' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Race Control</span>
{data.race_control.length > 0 && (
<span className="sec-meta mono">{data.race_control.length} messages</span>
)}
</div>
<RaceControlView messages={data.race_control} />
</div>
)}
{activeTab === 'data_status' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Data Status</span>
</div>
<DatasetStatusView datasets={data.datasets} />
</div>
{activeTab === 'data_status' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Diagnostics</span>
<button
type="button"
className={`rh-diagnostics-toggle${showDiagnostics ? ' active' : ''}`}
onClick={() => setShowDiagnostics((v) => !v)}
aria-expanded={showDiagnostics}
data-testid="rh-diagnostics-toggle"
>
{showDiagnostics ? 'Hide dataset coverage' : 'Show dataset coverage'}
</button>
</div>
<DatasetStatusView datasets={data.datasets} />
{showDiagnostics && (
<div style={{ marginTop: 'var(--s5)' }} data-testid="rh-dataset-strip">
<DatasetStrip datasets={data.datasets} />
</div>
)}
</div>
)}
</>
)}
</div>
)