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

@@ -148,7 +148,7 @@ export function OverviewView({ data }: Props) {
? 'Every Race Hub dataset is local for this session.'
: `${coverage.total - coverage.available} dataset${
coverage.total - coverage.available === 1 ? '' : 's'
} not ingested yet — see Data Status tab.`}
} not ingested yet — see Diagnostics.`}
</div>
</section>
</div>

View File

@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react'
import type { Session } from '../types'
import { RACE_HUB_DATASETS } from '../lib/coverage'
import { formatCountdown, formatSessionScheduleTime, sessionStartTime } from '../lib/schedule'
const EXPECTED_LABELS: Record<string, string> = {
results: 'Final results',
starting_grid: 'Starting grid',
stints: 'Tyre strategy',
pit_stops: 'Pit stops',
positions: 'Position changes',
laps: 'Lap times',
race_control: 'Race control',
weather: 'Track conditions',
}
interface Props {
session: Session
sessionName: string
}
/**
* Purpose-built view for a session that has not run yet. Instead of rendering
* empty Winner / Podium / Pole / Strategy / Compare cards, it explains that the
* session is upcoming and previews the analysis that will appear once the data
* is ingested.
*/
export function PreSessionView({ session, sessionName }: Props) {
const start = sessionStartTime(session)
const [now, setNow] = useState(() => new Date())
useEffect(() => {
if (!start) return
const id = setInterval(() => setNow(new Date()), 1000)
return () => clearInterval(id)
}, [start])
const expected = RACE_HUB_DATASETS.filter((key) => EXPECTED_LABELS[key])
return (
<div className="rh-presession" data-testid="rh-presession">
<section className="rh-presession-band">
<span className="rh-presession-eyebrow mono">Upcoming session</span>
<h2 className="rh-presession-title">{sessionName}</h2>
<p className="rh-presession-sub">
This session hasnt run yet, so theres no result to analyse. Winner,
podium, pole, strategy and comparison views will appear here once the
session completes and its data is ingested.
</p>
<div className="rh-presession-countdown mono" data-testid="rh-presession-countdown">
{start
? `Starts ${formatSessionScheduleTime(session.date_start)} · in ${formatCountdown(start, now)}`
: 'Start time to be confirmed.'}
</div>
</section>
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Expected once complete</span>
</div>
<div className="rh-expected-grid">
{expected.map((key) => (
<div key={key} className="rh-expected-card">
<span className="rh-expected-dot" aria-hidden="true" />
<span>{EXPECTED_LABELS[key]}</span>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -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 (
<div className="tab-bar" role="tablist">
{TABS.map((t) => (
<button
key={t.id}
role="tab"
aria-selected={active === t.id}
className={`tab-btn${active === t.id ? ' active' : ''}`}
onClick={() => onChange(t.id)}
>
{t.label}
</button>
<div className="tab-bar tab-bar-grouped" role="tablist" data-testid="rh-tabbar">
{TAB_GROUPS.map((group) => (
<div key={group.id} className="tab-group" data-testid={`rh-tabgroup-${group.id}`}>
<span className="tab-group-label mono" aria-hidden="true">
{group.label}
</span>
<div className="tab-group-btns">
{group.tabs.map((t) => (
<button
key={t.id}
role="tab"
aria-selected={active === t.id}
className={`tab-btn${active === t.id ? ' active' : ''}`}
onClick={() => onChange(t.id)}
>
{t.label}
</button>
))}
</div>
</div>
))}
</div>
)

View File

@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
import { sessionTypeAbbrev } from '../lib/coverage'
import { sessionState, sessionStateDotClass, sessionStateLabel } from '../lib/sessionState'
import { countryDecal, formatGpDateRange } from '../lib/gpIdentity'
interface Props {
@@ -19,11 +20,24 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
currentMeetingKey ?? null,
)
const weekendQuery = useQuery({
queryKey: ['weekend', openMeetingKey],
queryFn: () => fetchWeekend(openMeetingKey!),
enabled: openMeetingKey != null,
})
// Default the visible season to the current weekend's year so the current
// meeting card is actually rendered (seasons are newest-first, which can be a
// future season). Fall back to the newest season only when there's no context.
useEffect(() => {
if (year == null && seasonsQuery.data?.length) {
if (year != null) return
const currentYear = weekendQuery.data?.meeting?.year
if (currentYear) {
setYear(currentYear)
} else if (seasonsQuery.data?.length) {
setYear(seasonsQuery.data[0])
}
}, [seasonsQuery.data, year])
}, [seasonsQuery.data, weekendQuery.data, year])
const meetingsQuery = useQuery({
queryKey: ['meetings', year],
@@ -31,15 +45,10 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
enabled: year != null,
})
const weekendQuery = useQuery({
queryKey: ['weekend', openMeetingKey],
queryFn: () => fetchWeekend(openMeetingKey!),
enabled: openMeetingKey != null,
})
const seasons = seasonsQuery.data ?? []
const meetings = meetingsQuery.data ?? []
const weekend = weekendQuery.data
const now = new Date()
function openSession(sessionKey: number) {
navigate({ to: '/race-hub', search: { session_key: sessionKey } })
@@ -107,8 +116,10 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
<div className="rh-switcher-empty">loading sessions</div>
)}
{weekend && weekend.meeting_key === m.meeting_key &&
weekend.sessions.map(({ session, source, datasets }) => {
weekend.sessions.map((weekendSession) => {
const { session } = weekendSession
const active = session.session_key === currentSessionKey
const state = sessionState(weekendSession, now)
return (
<button
key={session.session_key}
@@ -122,8 +133,11 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
</span>
<span className="rh-switcher-sess-name">{session.session_name}</span>
<span className="rh-switcher-sess-cov mono">
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
{formatCoverageHint(datasets)}
<span
className={`cc-cov-dot ${sessionStateDotClass(state)}`}
aria-hidden="true"
/>
{sessionStateLabel(state)}
</span>
</button>
)