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>
)

View File

@@ -95,6 +95,22 @@ export function pickFocusMeeting(meetings: Meeting[], now: Date): Meeting | null
)
}
/**
* Meeting a fan-facing default landing (bare `/race-hub`) should open. Unlike
* `pickFocusMeeting`, it prefers a completed weekend over an upcoming one so the
* default never lands on a future race with no analysis. Falls back to the next
* upcoming meeting only when nothing has happened yet.
*/
export function pickAnalysisFocusMeeting(meetings: Meeting[], now: Date): Meeting | null {
return (
currentMeeting(meetings, now) ??
mostRecentPastMeeting(meetings, now) ??
nextUpcomingMeeting(meetings, now) ??
meetings[0] ??
null
)
}
export function meetingHasStarted(meeting: Meeting, now: Date): boolean {
const start = meetingStartTime(meeting)
return start != null && now >= start

View File

@@ -0,0 +1,100 @@
import type { WeekendSession } from '../types'
import { isSessionComplete } from './coverage'
import { sessionEndTime, sessionStartTime } from './schedule'
/**
* User-facing lifecycle state for a weekend session. Combines the schedule
* (has it started / finished) with local dataset coverage so the UI can speak
* in fan language instead of raw `x/11` coverage counts.
*
* - `upcoming` — starts in the future; render a pre-session view.
* - `live` — currently running (started, not yet finished).
* - `preparing` — finished (or unknown timing) but no local analysis yet.
* - `partial` — finished with some, but not all, local datasets.
* - `ready` — finished with full local coverage; analysis is trustworthy.
* - `cancelled` — session was cancelled.
*/
export type SessionState =
| 'upcoming'
| 'live'
| 'preparing'
| 'partial'
| 'ready'
| 'cancelled'
export function sessionState(session: WeekendSession, now: Date): SessionState {
if (session.source === 'cancelled') return 'cancelled'
const start = sessionStartTime(session.session)
const end = sessionEndTime(session.session)
if (start && start > now) return 'upcoming'
if (start && end && now >= start && now < end) return 'live'
// Session has started/finished (or timing unknown) — describe it by coverage.
if (isSessionComplete(session.datasets)) return 'ready'
if (session.source === 'none') return 'preparing'
return 'partial'
}
/** Short label suitable for chips and the session switcher. */
export function sessionStateLabel(state: SessionState): string {
switch (state) {
case 'upcoming':
return 'Upcoming'
case 'live':
return 'Live'
case 'preparing':
return 'Preparing'
case 'partial':
return 'Partial'
case 'ready':
return 'Ready'
case 'cancelled':
return 'Cancelled'
}
}
/** Longer, sentence-style description for headers and empty states. */
export function sessionStateDescription(state: SessionState): string {
switch (state) {
case 'upcoming':
return 'Session has not started yet.'
case 'live':
return 'Session is running now.'
case 'preparing':
return 'Analysis is being prepared — no local data ingested yet.'
case 'partial':
return 'Partial analysis available — some datasets are still missing.'
case 'ready':
return 'Full analysis is ready.'
case 'cancelled':
return 'This session was cancelled.'
}
}
/**
* Class suffix used for the coverage dot, so the rail can colour a session by
* its lifecycle state rather than only by data source.
*/
export function sessionStateDotClass(state: SessionState): string {
switch (state) {
case 'ready':
return 'rh-state-ready'
case 'partial':
return 'rh-state-partial'
case 'live':
return 'rh-state-live'
case 'upcoming':
return 'rh-state-upcoming'
case 'cancelled':
return 'rh-state-cancelled'
default:
return 'rh-state-preparing'
}
}
/** Whether a session should render the pre-session (expected availability) view. */
export function isPreSession(state: SessionState): boolean {
return state === 'upcoming'
}

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>
)

View File

@@ -1820,6 +1820,141 @@ a { color: inherit; text-decoration: none; }
.tab-btn:hover { color: var(--text-2); }
.tab-btn.active { color: var(--text); border-bottom-color: var(--red); }
/* ── Grouped analysis navigation ── */
.tab-bar-grouped {
gap: var(--s5);
align-items: flex-end;
}
.tab-group {
display: flex;
flex-direction: column;
gap: 2px;
flex-shrink: 0;
}
.tab-group + .tab-group {
border-left: 1px solid var(--border);
padding-left: var(--s5);
}
.tab-group-label {
font-size: 9px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-3);
opacity: 0.7;
padding: 0 14px;
}
.tab-group-btns {
display: flex;
}
/* ── Session lifecycle state dots ── */
.rh-state-ready { background: var(--green); }
.rh-state-partial { background: var(--yellow); }
.rh-state-live { background: var(--red); }
.rh-state-upcoming { background: var(--text-3); }
.rh-state-preparing { background: var(--text-3); opacity: 0.5; }
.rh-state-cancelled { background: var(--text-3); opacity: 0.35; }
/* ── Pre-session (expected availability) view ── */
.rh-presession {
display: flex;
flex-direction: column;
gap: var(--s5);
}
.rh-presession-band {
padding: var(--s6) var(--s5);
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--gp-accent);
border-radius: 4px;
display: flex;
flex-direction: column;
gap: var(--s3);
}
.rh-presession-eyebrow {
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-3);
}
.rh-presession-title {
font-size: 22px;
font-weight: 700;
margin: 0;
}
.rh-presession-sub { color: var(--text-2); max-width: 60ch; }
.rh-presession-countdown {
font-size: 15px;
color: var(--text);
margin-top: var(--s3);
}
.rh-expected-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--s4);
}
.rh-expected-card {
padding: var(--s4);
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
display: flex;
align-items: center;
gap: var(--s3);
color: var(--text-2);
font-size: 13px;
}
.rh-expected-card .rh-expected-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-3);
opacity: 0.6;
}
/* ── Loading / error recovery ── */
.rh-recover {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--s4);
padding: var(--s6) var(--s5);
}
.rh-recover-actions {
display: flex;
gap: var(--s3);
flex-wrap: wrap;
}
.rh-recover-btn {
padding: 8px 14px;
font-size: 12px;
font-weight: 600;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
color: var(--text);
cursor: pointer;
text-decoration: none;
}
.rh-recover-btn:hover { border-color: var(--gp-accent); }
.rh-recover-btn.primary { border-color: var(--red); color: var(--text); }
/* ── Diagnostics (secondary) action ── */
.rh-diagnostics-toggle {
align-self: flex-start;
margin-left: auto;
padding: 6px 12px;
font-size: 11px;
font-weight: 600;
color: var(--text-3);
background: none;
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
}
.rh-diagnostics-toggle:hover { color: var(--text-2); border-color: var(--gp-accent); }
.rh-diagnostics-toggle.active { color: var(--text); }
/* ── Dataset status view ── */
.ds-legend {
display: flex;

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
@@ -25,6 +25,9 @@ const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
const mockFetchWeekend = vi.mocked(fetchWeekend)
// Use a fixed clock so upcoming/completed states are deterministic in tests.
const NOW = new Date('2025-06-01T00:00:00Z')
const meeting: Meeting = {
meeting_key: 1229,
meeting_name: 'Monaco Grand Prix',
@@ -59,6 +62,17 @@ const qualSession: Session = {
gmt_offset: '02:00:00',
}
// A session scheduled far in the future relative to NOW.
const futureSession: Session = {
session_key: 9600,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1300,
date_start: '2099-05-25T13:00:00+00:00',
date_end: '2099-05-25T15:00:00+00:00',
gmt_offset: '02:00:00',
}
const fullDatasets: Record<string, DatasetInfo> = {
meeting: { status: 'available', source: 'local', count: 1 },
session: { status: 'available', source: 'local', count: 1 },
@@ -163,6 +177,7 @@ const weekend: Weekend = {
meeting_key: 1229,
meeting,
default_session_key: 9472,
default_analysis_session: 9472,
sessions: [
{ session: qualSession, source: 'local', datasets: fullDatasets },
{ session: raceSession, source: 'local', datasets: fullDatasets },
@@ -206,12 +221,18 @@ function renderRaceHub(sessionKey: number) {
describe('RaceHubPage', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers({ shouldAdvanceTime: true })
vi.setSystemTime(NOW)
mockFetchSeasons.mockResolvedValue([2025])
mockFetchLocalMeetings.mockResolvedValue([meeting])
mockFetchWeekend.mockResolvedValue(weekend)
mockFetchRaceHub.mockResolvedValue(raceHub)
})
afterEach(() => {
vi.useRealTimers()
})
it('renders the workspace identity band, session rail, and overview for a known session', async () => {
renderRaceHub(9472)
@@ -239,14 +260,44 @@ describe('RaceHubPage', () => {
})
it('keeps Data Status accessible and free of inline CLI guidance', async () => {
it('keeps Diagnostics accessible behind a secondary action, free of inline CLI guidance', async () => {
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
fireEvent.click(screen.getByRole('tab', { name: 'Data Status' }))
fireEvent.click(screen.getByRole('tab', { name: 'Diagnostics' }))
expect(screen.getByTestId('rh-data-status')).toBeInTheDocument()
expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument()
// Raw dataset coverage strip is hidden until explicitly requested.
expect(screen.queryByTestId('rh-dataset-strip')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('rh-diagnostics-toggle'))
expect(screen.getByTestId('rh-dataset-strip')).toBeInTheDocument()
})
it('does not render the raw dataset strip before fan-facing content', async () => {
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
// Overview (fan content) is present, but the raw diagnostics strip is not.
expect(screen.getByTestId('rh-overview')).toBeInTheDocument()
expect(screen.queryByTestId('rh-dataset-strip')).not.toBeInTheDocument()
})
it('groups analysis navigation into Story, Analysis, and Data & Context', async () => {
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
expect(screen.getByTestId('rh-tabgroup-story')).toBeInTheDocument()
expect(screen.getByTestId('rh-tabgroup-analysis')).toBeInTheDocument()
expect(screen.getByTestId('rh-tabgroup-context')).toBeInTheDocument()
// Every capability preserved
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Strategy' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Compare' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Diagnostics' })).toBeInTheDocument()
})
it('toggles the inline weekend switcher', async () => {
@@ -256,4 +307,83 @@ describe('RaceHubPage', () => {
fireEvent.click(screen.getByTestId('rh-switch-weekend'))
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
})
it('resolves bare /race-hub through the default analysis session (never a future one)', async () => {
const futureMeeting: Meeting = { ...meeting, meeting_key: 1300, meeting_name: 'Future GP' }
mockFetchLocalMeetings.mockResolvedValue([futureMeeting])
mockFetchWeekend.mockResolvedValue({
source: 'partial',
meeting_key: 1300,
meeting: futureMeeting,
// Backend excludes the future session; falls back to the completed quali.
default_session_key: 9600,
default_analysis_session: 9471,
sessions: [
{ session: { ...qualSession, meeting_key: 1300 }, source: 'local', datasets: fullDatasets },
{ session: futureSession, source: 'none', datasets: {} },
],
})
renderRaceHub(0)
await waitFor(() => expect(mockFetchRaceHub).toHaveBeenCalledWith(9471))
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9600)
})
it('renders a pre-session view instead of empty analysis for a future session', async () => {
mockFetchRaceHub.mockResolvedValue({
...raceHub,
session_key: 9600,
source: 'none',
session: futureSession,
meeting: { ...meeting, meeting_key: 1300 },
results: [],
starting_grid: [],
datasets: {},
})
mockFetchWeekend.mockResolvedValue({
source: 'none',
meeting_key: 1300,
meeting: { ...meeting, meeting_key: 1300 },
sessions: [{ session: futureSession, source: 'none', datasets: {} }],
})
renderRaceHub(9600)
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
expect(await screen.findByTestId('rh-presession')).toBeInTheDocument()
// No Winner analysis card for an unrun session.
expect(screen.queryByTestId('rh-overview')).not.toBeInTheDocument()
expect(screen.queryByText('Winner')).not.toBeInTheDocument()
})
it('labels a completed but partial session as Partial in the active state', async () => {
mockFetchWeekend.mockResolvedValue({
...weekend,
sessions: [
{ session: qualSession, source: 'local', datasets: fullDatasets },
{ session: raceSession, source: 'partial', datasets: { drivers: fullDatasets.drivers } },
],
})
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('rh-active-state')).toBeInTheDocument())
expect(screen.getByTestId('rh-active-state')).toHaveTextContent('Partial')
})
it('offers retry and back-to-Weekend on an error', async () => {
mockFetchRaceHub.mockRejectedValue(new Error('boom'))
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('race-hub-error')).toBeInTheDocument())
expect(screen.getByTestId('rh-retry')).toBeInTheDocument()
const back = screen.getByTestId('rh-back-weekend')
expect(back).toHaveAttribute('href', '/race-hub')
mockFetchRaceHub.mockResolvedValue(raceHub)
fireEvent.click(screen.getByTestId('rh-retry'))
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
})
})

View File

@@ -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(<TabBar active="overview" onChange={() => {}} />)
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', () => {

View File

@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest'
import { sessionState, sessionStateLabel } from '../lib/sessionState'
import type { DatasetInfo, Session, WeekendSession } from '../types'
const NOW = new Date('2025-06-01T00:00:00Z')
function mk(
overrides: Partial<Session>,
source: WeekendSession['source'],
datasets: Record<string, DatasetInfo> = {},
): 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<string, DatasetInfo> = Object.fromEntries(
[
'meeting',
'session',
'drivers',
'results',
'starting_grid',
'stints',
'pit_stops',
'positions',
'race_control',
'weather',
'laps',
].map((k) => [k, { status: 'available', source: 'local', count: 1 }]),
)
describe('sessionState', () => {
it('marks a future session as upcoming', () => {
const s = mk({ date_start: '2099-05-25T13:00:00+00:00', date_end: '2099-05-25T15:00:00+00:00' }, 'none')
expect(sessionState(s, NOW)).toBe('upcoming')
})
it('marks a running session as live', () => {
const start = new Date(NOW.getTime() - 60_000).toISOString()
const end = new Date(NOW.getTime() + 60_000).toISOString()
const s = mk({ date_start: start, date_end: end }, 'partial')
expect(sessionState(s, NOW)).toBe('live')
})
it('marks a finished session with full local data as ready', () => {
const s = mk({}, 'local', FULL)
expect(sessionState(s, NOW)).toBe('ready')
})
it('marks a finished session with no data as preparing', () => {
const s = mk({}, 'none', {})
expect(sessionState(s, NOW)).toBe('preparing')
})
it('marks a finished session with partial data as partial', () => {
const s = mk({}, 'partial', { drivers: { status: 'available', source: 'local', count: 20 } })
expect(sessionState(s, NOW)).toBe('partial')
})
it('marks a cancelled session as cancelled', () => {
const s = mk({}, 'cancelled')
expect(sessionState(s, NOW)).toBe('cancelled')
})
it('uses user language labels rather than coverage counts', () => {
expect(sessionStateLabel('ready')).toBe('Ready')
expect(sessionStateLabel('upcoming')).toBe('Upcoming')
expect(sessionStateLabel('partial')).toBe('Partial')
})
})

View File

@@ -215,6 +215,12 @@ export interface Weekend {
meeting: Meeting
sessions: WeekendSession[]
default_session_key?: number
/**
* Fan-facing default landing session. Never resolves to a future session, so
* bare `/race-hub` never opens empty post-session analysis. `0`/undefined
* means every session is still upcoming.
*/
default_analysis_session?: number
}
export interface LiveStateResponse {

View File

@@ -3,11 +3,16 @@ package query
import (
"database/sql"
"errors"
"time"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/store"
)
// weekendNow is the clock used to decide whether a session has started. It is a
// package var so tests can pin it deterministically.
var weekendNow = time.Now
// ErrMeetingNotFound is returned when a meeting is not in the local store.
var ErrMeetingNotFound = errors.New("meeting not found")
@@ -25,6 +30,10 @@ type Weekend struct {
Meeting models.Meeting `json:"meeting"`
Sessions []WeekendSession `json:"sessions"`
DefaultSessionKey int `json:"default_session_key,omitempty"`
// DefaultAnalysisSession is the session a fan-facing default landing should
// open. Unlike DefaultSessionKey it never resolves to a future session, so
// bare /race-hub never renders empty post-session analysis.
DefaultAnalysisSession int `json:"default_analysis_session,omitempty"`
}
// ListSeasons returns years with ingested meetings, newest first.
@@ -85,6 +94,7 @@ func (s *Service) GetWeekend(meetingKey int) (Weekend, error) {
out.Source = weekendSource(out.Sessions)
}
out.DefaultSessionKey = pickDefaultSession(out.Sessions)
out.DefaultAnalysisSession = pickDefaultAnalysisSession(out.Sessions, weekendNow())
return out, nil
}
@@ -141,6 +151,51 @@ func pickDefaultSession(sessions []WeekendSession) int {
return sessions[bestIdx].Session.SessionKey
}
// pickDefaultAnalysisSession chooses the session a fan should land on by default.
// It never returns a future session: among sessions that have already started
// (or whose start time is unknown) it prefers the one with the richest local
// dataset coverage, breaking ties toward the later session. When every session
// is still upcoming it returns 0 so callers render a pre-session view instead of
// empty analysis.
func pickDefaultAnalysisSession(sessions []WeekendSession, now time.Time) int {
bestKey := 0
bestScore := -1
var bestStart time.Time
for _, sess := range sessions {
start, ok := parseSessionStart(sess.Session.DateStart)
// Skip sessions that are clearly in the future; unknown start times are
// treated as eligible so historical data without timestamps still works.
if ok && start.After(now) {
continue
}
score := datasetScore(sess.Datasets)
if score > bestScore || (score == bestScore && ok && start.After(bestStart)) {
bestScore = score
bestKey = sess.Session.SessionKey
if ok {
bestStart = start
}
}
}
return bestKey
}
func parseSessionStart(value string) (time.Time, bool) {
if value == "" {
return time.Time{}, false
}
if t, err := time.Parse(time.RFC3339, value); err == nil {
return t, true
}
if t, err := time.Parse("2006-01-02T15:04:05", value); err == nil {
return t, true
}
if t, err := time.Parse("2006-01-02", value[:min(len(value), 10)]); err == nil {
return t, true
}
return time.Time{}, false
}
func datasetScore(datasets map[string]DatasetInfo) int {
score := 0
for _, info := range datasets {

View File

@@ -5,6 +5,7 @@ import (
"errors"
"path/filepath"
"testing"
"time"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/store"
@@ -376,6 +377,88 @@ func TestGetWeekendWithSessions(t *testing.T) {
}
}
func TestPickDefaultAnalysisSessionSkipsFuture(t *testing.T) {
now := mustTime(t, "2025-05-24T18:00:00Z")
sessions := []WeekendSession{
{ // completed qualifying, partial coverage
Session: models.Session{SessionKey: 100, DateStart: "2025-05-24T14:00:00+00:00"},
Datasets: map[string]DatasetInfo{"results": availableLocal(1)},
},
{ // future race with the richest coverage — must NOT be selected
Session: models.Session{SessionKey: 200, DateStart: "2025-05-25T13:00:00+00:00"},
Datasets: map[string]DatasetInfo{
"results": availableLocal(1),
"laps": availableLocal(1),
"stints": availableLocal(1),
},
},
}
got := pickDefaultAnalysisSession(sessions, now)
if got != 100 {
t.Fatalf("pickDefaultAnalysisSession() = %d, want 100 (never a future session)", got)
}
}
func TestPickDefaultAnalysisSessionAllFuture(t *testing.T) {
now := mustTime(t, "2025-05-20T00:00:00Z")
sessions := []WeekendSession{
{Session: models.Session{SessionKey: 100, DateStart: "2025-05-24T14:00:00+00:00"}},
{Session: models.Session{SessionKey: 200, DateStart: "2025-05-25T13:00:00+00:00"}},
}
if got := pickDefaultAnalysisSession(sessions, now); got != 0 {
t.Fatalf("pickDefaultAnalysisSession() = %d, want 0 (everything upcoming)", got)
}
}
func TestPickDefaultAnalysisSessionPrefersRichestCompleted(t *testing.T) {
now := mustTime(t, "2025-05-26T00:00:00Z")
sessions := []WeekendSession{
{
Session: models.Session{SessionKey: 100, DateStart: "2025-05-24T14:00:00+00:00"},
Datasets: map[string]DatasetInfo{"results": availableLocal(1)},
},
{
Session: models.Session{SessionKey: 200, DateStart: "2025-05-25T13:00:00+00:00"},
Datasets: map[string]DatasetInfo{
"results": availableLocal(1),
"laps": availableLocal(1),
},
},
}
if got := pickDefaultAnalysisSession(sessions, now); got != 200 {
t.Fatalf("pickDefaultAnalysisSession() = %d, want 200 (richest completed)", got)
}
}
func TestGetWeekendSetsDefaultAnalysisSession(t *testing.T) {
prev := weekendNow
weekendNow = func() time.Time { return mustTime(t, "2025-05-26T00:00:00Z") }
t.Cleanup(func() { weekendNow = prev })
svc := openTestService(t)
seedRaceHubData(t, svc.store)
weekend, err := svc.GetWeekend(1229)
if err != nil {
t.Fatalf("GetWeekend() error = %v", err)
}
if weekend.DefaultAnalysisSession != 9472 {
t.Fatalf("DefaultAnalysisSession = %d, want 9472", weekend.DefaultAnalysisSession)
}
}
func mustTime(t *testing.T, value string) time.Time {
t.Helper()
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
t.Fatalf("parse time %q: %v", value, err)
}
return parsed
}
func TestGetChampionshipInputsIncludesSprintPoints(t *testing.T) {
// Regression for #57: Race-only aggregation dropped Sprint points.
// Setup: same meeting 1229 has Race (9472) 25pts + Sprint (9473) 8pts => total 33.

View File

@@ -30,6 +30,11 @@ func main() {
const meetingKey = 1229
const fullSessionKey = 9472
const coreOnlySessionKey = 9000
// A far-future session inside the same Monaco meeting so bare /race-hub never
// lands on it (default_analysis_session picks the completed race) yet an
// explicit deep link renders the dedicated pre-session view. Kept in the same
// meeting so the Command Center's focus selection is unaffected.
const futureSessionKey = 9600
if err := seedMeeting(st, meetingKey); err != nil {
fail(err)
@@ -59,6 +64,10 @@ func main() {
fail(err)
}
if err := seedFutureSession(st, futureSessionKey, meetingKey); err != nil {
fail(err)
}
fmt.Printf("seeded e2e db at %s\n", *dbPath)
}
@@ -95,6 +104,20 @@ func seedSession(st *store.Store, sessionKey, meetingKey int, name string) error
})
}
func seedFutureSession(st *store.Store, sessionKey, meetingKey int) error {
// Far-future date so this session is always "upcoming" relative to the wall
// clock and renders the pre-session view on an explicit deep link.
return st.UpsertSession(store.Session{
SessionKey: sessionKey,
MeetingKey: meetingKey,
SessionName: "Future Sprint",
SessionType: "Race",
CircuitKey: 10,
DateStart: "2099-05-25T13:00:00+00:00",
DateEnd: "2099-05-25T15:00:00+00:00",
})
}
func seedDrivers(st *store.Store, sessionKey, meetingKey int) error {
drivers := []store.Driver{
{

View File

@@ -2,6 +2,7 @@ import { test, expect } from '@playwright/test'
const FULL_SESSION = 9472
const CORE_ONLY_SESSION = 9000
const FUTURE_SESSION = 9600
test.describe('Race Hub Weekend Workspace', () => {
test('lands on the Overview tab with workspace identity', async ({ page }) => {
@@ -91,20 +92,56 @@ test.describe('Race Hub Weekend Workspace', () => {
await expect(page.getByTestId(`rh-switcher-session-${FULL_SESSION}`)).toBeVisible()
})
test('Data Status tab points at admin instead of inline CLI hints', async ({ page }) => {
test('Diagnostics is a secondary action and points at admin, not inline CLI hints', async ({ page }) => {
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
await page.getByRole('tab', { name: 'Data Status' }).click()
await page.getByRole('tab', { name: 'Diagnostics' }).click()
await expect(page.getByTestId('rh-data-status')).toBeVisible()
await expect(page.getByRole('link', { name: /manage ingestion/i })).toHaveAttribute(
'href',
'/admin',
)
// Raw coverage strip stays hidden until explicitly requested.
await expect(page.getByTestId('rh-dataset-strip')).toHaveCount(0)
await page.getByTestId('rh-diagnostics-toggle').click()
await expect(page.getByTestId('rh-dataset-strip')).toBeVisible()
})
test('bare /race-hub redirects to the focus session', async ({ page }) => {
test('groups analysis navigation into Story, Analysis, and Data & Context', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await expect(page.getByTestId('rh-tabgroup-story')).toBeVisible()
await expect(page.getByTestId('rh-tabgroup-analysis')).toBeVisible()
await expect(page.getByTestId('rh-tabgroup-context')).toBeVisible()
})
test('bare /race-hub resolves to a completed session, never a future one', async ({ page }) => {
await page.goto('/race-hub')
await expect(page).toHaveURL(/session_key=\d+/)
await expect(page.getByTestId('race-hub')).toBeVisible()
// It must not land on the future session.
await expect(page).not.toHaveURL(new RegExp(`session_key=${FUTURE_SESSION}`))
})
test('explicit completed session deep link stays stable and shows analysis', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await expect(page).toHaveURL(new RegExp(`session_key=${FULL_SESSION}`))
await expect(page.getByTestId('rh-overview')).toBeVisible()
})
test('explicit future session renders the pre-session view, not empty analysis', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FUTURE_SESSION}`)
await expect(page.getByTestId('race-hub')).toBeVisible()
await expect(page.getByTestId('rh-presession')).toBeVisible()
await expect(page.getByTestId('rh-overview')).toHaveCount(0)
})
test('returning to Weekend from an analysis view preserves the meeting context', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await page.getByRole('tab', { name: 'Strategy' }).click()
await page.getByTestId('rh-switch-weekend').click()
await expect(page.getByTestId('rh-switcher')).toBeVisible()
// The current session remains reachable/selected from the switcher.
await expect(page.getByTestId(`rh-switcher-session-${FULL_SESSION}`)).toBeVisible()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -7,6 +7,7 @@ export const VIEWPORTS = {
} as const
export const FULL_SESSION = 9472
export const FUTURE_SESSION = 9600
/** Wait for web fonts and layout to settle before screenshots. */
export async function waitForScreenshotReady(page: Page): Promise<void> {
@@ -33,6 +34,16 @@ export async function gotoRaceHubReady(page: Page, sessionKey = FULL_SESSION): P
await waitForScreenshotReady(page)
}
export async function gotoRaceHubFutureReady(
page: Page,
sessionKey = FUTURE_SESSION,
): Promise<void> {
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<void> {
await page.goto(`/race-hub?session_key=${sessionKey}`)
await expect(page.getByTestId('race-hub')).toBeVisible()

View File

@@ -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')