fix(race-hub): consume canonical weekend context for #75 review

Address PR #82 review blockers: drop the competing /weekend
default_analysis_session resolver, land bare /race-hub via
/api/v1/weekend-context, derive Live/preparing/partial/unavailable from
authoritative context with a moving clock, hide Local Coverage behind
Diagnostics, isolate the future-session fixture from the shared seed,
and strengthen return-to-Weekend context coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-12 18:49:10 -04:00
parent 7c98489b91
commit 5d5987cc7e
26 changed files with 766 additions and 424 deletions

View File

@@ -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) {
</ul>
)}
</section>
<section className="rh-panel ui-card">
<div className="sec-header">
<span className="sec-title">Local Coverage</span>
<span className="sec-meta mono">
{coverage.available}/{coverage.total}
</span>
</div>
<div className="rh-coverage-meter" aria-hidden="true">
<div
className="rh-coverage-fill"
style={{ width: `${(coverage.available / coverage.total) * 100}%` }}
/>
</div>
<div className="rh-empty-line" style={{ marginTop: 'var(--s2)' }}>
{coverage.available === coverage.total
? 'Every Race Hub dataset is local for this session.'
: `${coverage.total - coverage.available} dataset${
coverage.total - coverage.available === 1 ? '' : 's'
} not ingested yet — see Diagnostics.`}
</div>
</section>
</div>
</div>
)

View File

@@ -1,7 +1,10 @@
import { useEffect, useState } from 'react'
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<string, string> = {
results: 'Final results',
@@ -14,9 +17,10 @@ const EXPECTED_LABELS: Record<string, string> = {
weather: 'Track conditions',
}
interface Props {
interface PreSessionProps {
session: Session
sessionName: string
now: Date
}
/**
@@ -25,16 +29,8 @@ interface Props {
* session is upcoming and previews the analysis that will appear once the data
* is ingested.
*/
export function PreSessionView({ session, sessionName }: Props) {
export function PreSessionView({ session, sessionName, now }: PreSessionProps) {
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 (
@@ -70,3 +66,76 @@ export function PreSessionView({ session, sessionName }: Props) {
</div>
)
}
interface PhaseProps {
state: Extract<SessionState, 'preparing' | 'unavailable' | 'cancelled'>
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 (
<div className="rh-presession" data-testid={testId}>
<section className="rh-presession-band">
<span className="rh-presession-eyebrow mono">{sessionStateLabelEyebrow(state)}</span>
<h2 className="rh-presession-title">{title}</h2>
<p className="rh-presession-sub">
{sessionName}: {sessionStateDescription(state)}
</p>
{state === 'preparing' && (
<p className="rh-presession-sub">
Check back shortly, or open Diagnostics if you need raw dataset coverage.
</p>
)}
{onOpenDiagnostics && (state === 'preparing' || state === 'unavailable') && (
<div className="rh-empty-actions" style={{ marginTop: 'var(--s4)' }}>
<button type="button" className="rh-empty-action" onClick={onOpenDiagnostics}>
Open Diagnostics
</button>
</div>
)}
</section>
</div>
)
}
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 (
<div className="rh-partial-banner" data-testid="rh-partial-banner" role="status">
<span>Partial analysis some datasets are still missing.</span>
{onOpenDiagnostics && (
<button type="button" className="rh-partial-banner-link" onClick={onOpenDiagnostics}>
Diagnostics
</button>
)}
</div>
)
}

View File

@@ -3,22 +3,36 @@ import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
import { sessionTypeAbbrev } from '../lib/coverage'
import { sessionState, sessionStateDotClass, sessionStateLabel } from '../lib/sessionState'
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<number | null>(null)
const [openMeetingKey, setOpenMeetingKey] = useState<number | null>(
currentMeetingKey ?? null,
)
const now = nowProp ?? new Date()
const weekendQuery = useQuery({
queryKey: ['weekend', openMeetingKey],
@@ -26,9 +40,6 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
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) return
const currentYear = weekendQuery.data?.meeting?.year
@@ -48,7 +59,6 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
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 } })
@@ -115,11 +125,16 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
{weekendQuery.isLoading && (
<div className="rh-switcher-empty">loading sessions</div>
)}
{weekend && weekend.meeting_key === m.meeting_key &&
{weekend &&
weekend.meeting_key === m.meeting_key &&
weekend.sessions.map((weekendSession) => {
const { session } = weekendSession
const active = session.session_key === currentSessionKey
const state = sessionState(weekendSession, now)
const state = resolveSessionState({
weekendSession,
context,
now,
})
return (
<button
key={session.session_key}