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>
@@ -119,7 +119,8 @@ export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
|
||||
// (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<WeekendContext> {
|
||||
const res = await fetch('/api/v1/weekend-context')
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -95,22 +95,6 @@ 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
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import type { WeekendSession } from '../types'
|
||||
import type { ContextSession, WeekendContext, WeekendSession } from '../types'
|
||||
import { isSessionComplete } from './coverage'
|
||||
import { sessionEndTime, sessionStartTime } from './schedule'
|
||||
import { 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.
|
||||
* User-facing lifecycle state for a weekend session.
|
||||
*
|
||||
* - `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.
|
||||
* 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'
|
||||
@@ -20,21 +16,91 @@ export type SessionState =
|
||||
| 'preparing'
|
||||
| 'partial'
|
||||
| 'ready'
|
||||
| 'unavailable'
|
||||
| 'cancelled'
|
||||
|
||||
export function sessionState(session: WeekendSession, now: Date): SessionState {
|
||||
if (session.source === 'cancelled') return 'cancelled'
|
||||
export interface SessionStateInput {
|
||||
weekendSession?: WeekendSession
|
||||
context?: WeekendContext | null
|
||||
now: Date
|
||||
}
|
||||
|
||||
const start = sessionStartTime(session.session)
|
||||
const end = sessionEndTime(session.session)
|
||||
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 (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'
|
||||
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. */
|
||||
@@ -50,6 +116,8 @@ export function sessionStateLabel(state: SessionState): string {
|
||||
return 'Partial'
|
||||
case 'ready':
|
||||
return 'Ready'
|
||||
case 'unavailable':
|
||||
return 'Unavailable'
|
||||
case 'cancelled':
|
||||
return 'Cancelled'
|
||||
}
|
||||
@@ -63,11 +131,13 @@ export function sessionStateDescription(state: SessionState): string {
|
||||
case 'live':
|
||||
return 'Session is running now.'
|
||||
case 'preparing':
|
||||
return 'Analysis is being prepared — no local data ingested yet.'
|
||||
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.'
|
||||
}
|
||||
@@ -87,6 +157,8 @@ export function sessionStateDotClass(state: SessionState): string {
|
||||
return 'rh-state-live'
|
||||
case 'upcoming':
|
||||
return 'rh-state-upcoming'
|
||||
case 'unavailable':
|
||||
return 'rh-state-unavailable'
|
||||
case 'cancelled':
|
||||
return 'rh-state-cancelled'
|
||||
default:
|
||||
@@ -98,3 +170,25 @@ export function sessionStateDotClass(state: SessionState): string {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
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 { fetchRaceHub, fetchWeekend, fetchWeekendContext } from '../api'
|
||||
import { DatasetStrip } from '../components/DatasetStrip'
|
||||
import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
|
||||
import { TabBar, type Tab } from '../components/TabBar'
|
||||
@@ -17,101 +12,64 @@ 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 {
|
||||
PartialAnalysisBanner,
|
||||
PreSessionView,
|
||||
SessionPhaseView,
|
||||
} from '../components/PreSessionView'
|
||||
import { WeekendSwitcher } from '../components/WeekendSwitcher'
|
||||
import { SourceBadge } from '../components/SourceBadge'
|
||||
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
||||
import { sessionTypeAbbrev } from '../lib/coverage'
|
||||
import { formatSessionScheduleTime, sortSessionsByStart } from '../lib/schedule'
|
||||
import {
|
||||
formatSessionScheduleTime,
|
||||
pickAnalysisFocusMeeting,
|
||||
sessionStartTime,
|
||||
sortSessionsByStart,
|
||||
} from '../lib/schedule'
|
||||
import {
|
||||
defaultAnalysisSessionKey,
|
||||
isPartialAnalysis,
|
||||
isPreSession,
|
||||
sessionState,
|
||||
isPreparing,
|
||||
isUnavailable,
|
||||
resolveSessionState,
|
||||
sessionStateDotClass,
|
||||
sessionStateLabel,
|
||||
} from '../lib/sessionState'
|
||||
import type { Weekend, WeekendSession } from '../types'
|
||||
import type { WeekendSession } from '../types'
|
||||
|
||||
interface Props {
|
||||
sessionKey: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
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.session.session_key
|
||||
const qual = pool.find((s) => s.session.session_type?.toLowerCase().includes('qualifying'))
|
||||
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(), [])
|
||||
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 pickAnalysisFocusMeeting(meetingsQuery.data, now)
|
||||
}, [sessionKey, meetingsQuery.data, now])
|
||||
|
||||
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, now)
|
||||
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, now])
|
||||
}, [sessionKey, contextQuery.isSuccess, context, navigate])
|
||||
|
||||
// ─── Active session payload ───
|
||||
const raceHubQuery = useQuery({
|
||||
queryKey: ['race-hub', sessionKey],
|
||||
queryFn: () => fetchRaceHub(sessionKey),
|
||||
@@ -132,47 +90,57 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
const accent = countryAccent(data?.meeting ?? null)
|
||||
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
|
||||
|
||||
const [showDiagnostics, setShowDiagnostics] = useState(false)
|
||||
const openDiagnostics = () => {
|
||||
setActiveTab('data_status')
|
||||
setShowDiagnostics(true)
|
||||
setPhaseDiagnostics(true)
|
||||
}
|
||||
|
||||
// ─── No session_key: show resolving state, fall back to switcher if no local data ───
|
||||
// ─── No session_key: resolve via Weekend Context ───
|
||||
if (sessionKey === 0) {
|
||||
if (seasonsQuery.isLoading || meetingsQuery.isLoading || fallbackWeekendQuery.isLoading) {
|
||||
if (contextQuery.isLoading) {
|
||||
return (
|
||||
<div className="rh-page" style={accentStyle}>
|
||||
<div className="loading-state">resolving latest local weekend…</div>
|
||||
<div className="loading-state">resolving weekend context…</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const seasons = seasonsQuery.data ?? []
|
||||
if (seasons.length === 0) {
|
||||
if (contextQuery.isError) {
|
||||
return (
|
||||
<div className="rh-page rh-empty" data-testid="race-hub-empty" style={accentStyle}>
|
||||
<div className="rh-empty-band">
|
||||
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
|
||||
<h1 className="rh-empty-title">No local sessions yet</h1>
|
||||
<p className="rh-empty-sub">
|
||||
The Race Hub reads from local ingest only. Once a weekend is ingested
|
||||
it will open here automatically.
|
||||
</p>
|
||||
<div className="rh-empty-actions">
|
||||
<a href="/admin" className="rh-empty-action">Open Admin · Data Health</a>
|
||||
<a href="/" className="rh-empty-action">Back to Command Center</a>
|
||||
<div className="rh-page" data-testid="race-hub-error" style={accentStyle}>
|
||||
<div className="rh-recover">
|
||||
<div className="error-box">
|
||||
{contextQuery.error instanceof Error
|
||||
? contextQuery.error.message
|
||||
: 'Failed to load weekend context.'}
|
||||
</div>
|
||||
<div className="rh-recover-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="rh-recover-btn primary"
|
||||
onClick={() => contextQuery.refetch()}
|
||||
data-testid="rh-retry"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<a href="/" className="rh-recover-btn">
|
||||
Back to Command Center
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Weekend resolved but every session is upcoming — offer the switcher instead
|
||||
// of silently opening empty analysis.
|
||||
if (fallbackWeekendQuery.data && !pickAnalysisSession(fallbackWeekendQuery.data, now)) {
|
||||
|
||||
if (context && !defaultAnalysisSessionKey(context)) {
|
||||
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 hasn’t run. Pick a past session to review, or check
|
||||
back once it’s complete.
|
||||
Weekend Context has no default analysis session. Pick a past session
|
||||
to review, or check back once a session completes with local analysis.
|
||||
</p>
|
||||
<div className="rh-empty-actions">
|
||||
<button
|
||||
@@ -182,26 +150,31 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
>
|
||||
Browse Weekends
|
||||
</button>
|
||||
<a href="/" className="rh-empty-action">Back to Command Center</a>
|
||||
<a href="/" className="rh-empty-action">
|
||||
Back to Command Center
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{switcherOpen && (
|
||||
<WeekendSwitcher
|
||||
currentMeetingKey={fallbackWeekendQuery.data.meeting_key}
|
||||
currentMeetingKey={context.focus_meeting?.meeting_key}
|
||||
context={context}
|
||||
now={now}
|
||||
onClose={() => setSwitcherOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rh-page" style={accentStyle}>
|
||||
<div className="loading-state">resolving latest local weekend…</div>
|
||||
<div className="loading-state">resolving weekend context…</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Loading / error for the requested session_key (retry + back to weekend) ───
|
||||
// ─── Loading / error for the requested session_key ───
|
||||
if (raceHubQuery.isLoading) {
|
||||
return (
|
||||
<div className="rh-page" style={accentStyle}>
|
||||
@@ -210,6 +183,15 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
)
|
||||
}
|
||||
if (raceHubQuery.isError || !data) {
|
||||
const backMeeting = context?.focus_meeting?.meeting_key
|
||||
const backSession =
|
||||
defaultAnalysisSessionKey(context) ??
|
||||
context?.previous_completed_session?.session.session_key
|
||||
const backHref =
|
||||
backSession && backSession > 0
|
||||
? `/race-hub?session_key=${backSession}`
|
||||
: '/race-hub'
|
||||
|
||||
return (
|
||||
<div className="rh-page" data-testid="race-hub-error" style={accentStyle}>
|
||||
<div className="rh-recover">
|
||||
@@ -227,7 +209,12 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<a href="/race-hub" className="rh-recover-btn" data-testid="rh-back-weekend">
|
||||
<a
|
||||
href={backHref}
|
||||
className="rh-recover-btn"
|
||||
data-testid="rh-back-weekend"
|
||||
data-meeting-key={backMeeting ?? undefined}
|
||||
>
|
||||
Back to Weekend
|
||||
</a>
|
||||
</div>
|
||||
@@ -242,12 +229,18 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w]))
|
||||
: {}
|
||||
const activeSessionMeta: WeekendSession | undefined = sessionMeta[sessionKey]
|
||||
const activeState = activeSessionMeta ? sessionState(activeSessionMeta, now) : undefined
|
||||
const preSession = activeState != null && isPreSession(activeState)
|
||||
const activeState = resolveSessionState({
|
||||
weekendSession: activeSessionMeta,
|
||||
context,
|
||||
now,
|
||||
})
|
||||
const preSession = isPreSession(activeState)
|
||||
const preparing = isPreparing(activeState)
|
||||
const unavailable = isUnavailable(activeState)
|
||||
const partial = isPartialAnalysis(activeState)
|
||||
|
||||
return (
|
||||
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
|
||||
{/* Topbar */}
|
||||
<div className="rh-topbar">
|
||||
<span className="rh-topbar-label mono">
|
||||
box-box · race hub
|
||||
@@ -270,11 +263,12 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
<WeekendSwitcher
|
||||
currentMeetingKey={meetingKey}
|
||||
currentSessionKey={sessionKey}
|
||||
context={context}
|
||||
now={now}
|
||||
onClose={() => setSwitcherOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* GP Identity band */}
|
||||
{data.meeting && (
|
||||
<section className="rh-identity" data-testid="rh-identity">
|
||||
<div className="rh-identity-accent" aria-hidden="true" />
|
||||
@@ -295,13 +289,16 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Session rail */}
|
||||
{sessions.length > 0 && (
|
||||
<nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail">
|
||||
{sessions.map((session) => {
|
||||
const meta = sessionMeta[session.session_key]
|
||||
const active = session.session_key === sessionKey
|
||||
const state = meta ? sessionState(meta, now) : undefined
|
||||
const state = resolveSessionState({
|
||||
weekendSession: meta,
|
||||
context,
|
||||
now,
|
||||
})
|
||||
return (
|
||||
<button
|
||||
key={session.session_key}
|
||||
@@ -323,45 +320,70 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
<span className="rh-session-time mono">
|
||||
{formatSessionScheduleTime(session.date_start)}
|
||||
</span>
|
||||
{state && (
|
||||
<span className="rh-session-cov mono">
|
||||
<span
|
||||
className={`cc-cov-dot ${sessionStateDotClass(state)}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{sessionStateLabel(state)}
|
||||
</span>
|
||||
)}
|
||||
<span className="rh-session-cov mono">
|
||||
<span
|
||||
className={`cc-cov-dot ${sessionStateDotClass(state)}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{sessionStateLabel(state)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Active session sub-bar */}
|
||||
{data.session && (
|
||||
<div className="rh-active-bar" data-testid="rh-active-bar">
|
||||
<span className="rh-active-name">{data.session.session_name}</span>
|
||||
<span className="rh-active-meta mono">
|
||||
{formatSessionScheduleTime(data.session.date_start)}
|
||||
</span>
|
||||
{activeState && (
|
||||
<span className="rh-active-cov mono" data-testid="rh-active-state">
|
||||
<span
|
||||
className={`cc-cov-dot ${sessionStateDotClass(activeState)}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{sessionStateLabel(activeState)}
|
||||
</span>
|
||||
)}
|
||||
<span className="rh-active-cov mono" data-testid="rh-active-state">
|
||||
<span
|
||||
className={`cc-cov-dot ${sessionStateDotClass(activeState)}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{sessionStateLabel(activeState)}
|
||||
</span>
|
||||
<span className="rh-active-key mono">key {sessionKey}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preSession && data.session ? (
|
||||
<PreSessionView session={data.session} sessionName={data.session.session_name} />
|
||||
<PreSessionView
|
||||
session={data.session}
|
||||
sessionName={data.session.session_name}
|
||||
now={now}
|
||||
/>
|
||||
) : preparing || unavailable ? (
|
||||
<>
|
||||
<SessionPhaseView
|
||||
state={
|
||||
unavailable
|
||||
? activeState === 'cancelled'
|
||||
? 'cancelled'
|
||||
: 'unavailable'
|
||||
: 'preparing'
|
||||
}
|
||||
sessionName={data.session?.session_name ?? `Session ${sessionKey}`}
|
||||
onOpenDiagnostics={openDiagnostics}
|
||||
/>
|
||||
{phaseDiagnostics && (
|
||||
<div className="data-section" data-testid="rh-phase-diagnostics">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Diagnostics</span>
|
||||
</div>
|
||||
<DatasetStatusView datasets={data.datasets} />
|
||||
<div style={{ marginTop: 'var(--s5)' }} data-testid="rh-dataset-strip">
|
||||
<DatasetStrip datasets={data.datasets} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{partial && <PartialAnalysisBanner onOpenDiagnostics={openDiagnostics} />}
|
||||
<TabBar active={activeTab} onChange={setActiveTab} />
|
||||
|
||||
{activeTab === 'overview' && <OverviewView data={data} />}
|
||||
|
||||
@@ -1853,6 +1853,7 @@ a { color: inherit; text-decoration: none; }
|
||||
.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 ── */
|
||||
@@ -1888,6 +1889,27 @@ a { color: inherit; text-decoration: none; }
|
||||
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));
|
||||
|
||||
@@ -9,23 +9,37 @@ 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)
|
||||
|
||||
// Use a fixed clock so upcoming/completed states are deterministic in tests.
|
||||
const NOW = new Date('2025-06-01T00:00:00Z')
|
||||
|
||||
const meeting: Meeting = {
|
||||
@@ -62,7 +76,6 @@ 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',
|
||||
@@ -177,13 +190,47 @@ 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 },
|
||||
],
|
||||
}
|
||||
|
||||
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 } },
|
||||
@@ -213,7 +260,6 @@ function renderRaceHub(sessionKey: number) {
|
||||
history: undefined,
|
||||
})
|
||||
|
||||
// Navigate to the URL before mounting
|
||||
router.navigate({ to: '/race-hub', search: sessionKey ? { session_key: sessionKey } : {} })
|
||||
return render(<RouterProvider router={router} />)
|
||||
}
|
||||
@@ -227,6 +273,7 @@ describe('RaceHubPage', () => {
|
||||
mockFetchLocalMeetings.mockResolvedValue([meeting])
|
||||
mockFetchWeekend.mockResolvedValue(weekend)
|
||||
mockFetchRaceHub.mockResolvedValue(raceHub)
|
||||
mockFetchWeekendContext.mockResolvedValue(weekendContext)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -245,9 +292,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 () => {
|
||||
@@ -257,7 +304,6 @@ describe('RaceHubPage', () => {
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Race Story' }))
|
||||
|
||||
expect(screen.getByText('VER')).toBeInTheDocument()
|
||||
|
||||
})
|
||||
|
||||
it('keeps Diagnostics accessible behind a secondary action, free of inline CLI guidance', async () => {
|
||||
@@ -269,7 +315,6 @@ describe('RaceHubPage', () => {
|
||||
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()
|
||||
@@ -279,7 +324,6 @@ describe('RaceHubPage', () => {
|
||||
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()
|
||||
})
|
||||
@@ -291,7 +335,6 @@ describe('RaceHubPage', () => {
|
||||
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()
|
||||
@@ -308,20 +351,22 @@ describe('RaceHubPage', () => {
|
||||
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: {} },
|
||||
],
|
||||
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)
|
||||
@@ -330,6 +375,19 @@ describe('RaceHubPage', () => {
|
||||
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,
|
||||
@@ -352,12 +410,97 @@ describe('RaceHubPage', () => {
|
||||
|
||||
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 () => {
|
||||
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: [
|
||||
@@ -365,11 +508,29 @@ describe('RaceHubPage', () => {
|
||||
{ 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 () => {
|
||||
@@ -380,7 +541,7 @@ describe('RaceHubPage', () => {
|
||||
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')
|
||||
expect(back).toHaveAttribute('href', '/race-hub?session_key=9472')
|
||||
|
||||
mockFetchRaceHub.mockResolvedValue(raceHub)
|
||||
fireEvent.click(screen.getByTestId('rh-retry'))
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { sessionState, sessionStateLabel } from '../lib/sessionState'
|
||||
import type { DatasetInfo, Session, WeekendSession } from '../types'
|
||||
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')
|
||||
|
||||
@@ -41,17 +52,57 @@ const FULL: Record<string, DatasetInfo> = Object.fromEntries(
|
||||
].map((k) => [k, { status: 'available', source: 'local', count: 1 }]),
|
||||
)
|
||||
|
||||
function availability(overrides: Partial<ContextAvailability> = {}): 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<ContextAvailability> = {},
|
||||
): ContextSession {
|
||||
return { session, availability: availability(avail) }
|
||||
}
|
||||
|
||||
function context(overrides: Partial<WeekendContext> = {}): 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('marks a running session as live', () => {
|
||||
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 }, 'partial')
|
||||
expect(sessionState(s, NOW)).toBe('live')
|
||||
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', () => {
|
||||
@@ -69,6 +120,18 @@ describe('sessionState', () => {
|
||||
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')
|
||||
@@ -78,5 +141,6 @@ describe('sessionState', () => {
|
||||
expect(sessionStateLabel('ready')).toBe('Ready')
|
||||
expect(sessionStateLabel('upcoming')).toBe('Upcoming')
|
||||
expect(sessionStateLabel('partial')).toBe('Partial')
|
||||
expect(sessionStateLabel('unavailable')).toBe('Unavailable')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -215,12 +215,6 @@ 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 {
|
||||
|
||||
@@ -3,16 +3,11 @@ 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")
|
||||
|
||||
@@ -24,16 +19,14 @@ 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"`
|
||||
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.
|
||||
@@ -94,7 +87,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -151,51 +143,6 @@ 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 {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
"github.com/AmanTahiliani/box-box/internal/store"
|
||||
@@ -377,88 +376,6 @@ 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.
|
||||
|
||||
@@ -30,11 +30,6 @@ 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)
|
||||
@@ -64,10 +59,6 @@ func main() {
|
||||
fail(err)
|
||||
}
|
||||
|
||||
if err := seedFutureSession(st, futureSessionKey, meetingKey); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
|
||||
fmt.Printf("seeded e2e db at %s\n", *dbPath)
|
||||
}
|
||||
|
||||
@@ -93,28 +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",
|
||||
})
|
||||
}
|
||||
|
||||
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",
|
||||
DateStart: start,
|
||||
DateEnd: end,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
78
tests/fixtures/future-session.ts
vendored
Normal file
@@ -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<void> {
|
||||
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),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { FUTURE_SESSION, mockFutureRaceHubSession } from './fixtures/future-session'
|
||||
|
||||
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 }) => {
|
||||
@@ -16,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 }) => {
|
||||
@@ -88,7 +89,6 @@ 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()
|
||||
})
|
||||
|
||||
@@ -101,7 +101,6 @@ test.describe('Race Hub Weekend Workspace', () => {
|
||||
'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()
|
||||
@@ -114,12 +113,12 @@ test.describe('Race Hub Weekend Workspace', () => {
|
||||
await expect(page.getByTestId('rh-tabgroup-context')).toBeVisible()
|
||||
})
|
||||
|
||||
test('bare /race-hub resolves to a completed session, never a future one', async ({ page }) => {
|
||||
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()
|
||||
// It must not land on the future session.
|
||||
await expect(page).not.toHaveURL(new RegExp(`session_key=${FUTURE_SESSION}`))
|
||||
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 }) => {
|
||||
@@ -128,20 +127,34 @@ test.describe('Race Hub Weekend Workspace', () => {
|
||||
await expect(page.getByTestId('rh-overview')).toBeVisible()
|
||||
})
|
||||
|
||||
test('explicit future session renders the pre-session view, not empty analysis', async ({ page }) => {
|
||||
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 an analysis view preserves the meeting context', async ({ page }) => {
|
||||
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()
|
||||
|
||||
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()
|
||||
// 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')
|
||||
|
||||
// Back to Weekend via bare /race-hub — Weekend Context should restore the
|
||||
// same meeting's default analysis session.
|
||||
await page.goto('/race-hub')
|
||||
await expect(page).toHaveURL(new RegExp(`session_key=${FULL_SESSION}`))
|
||||
await expect(page.getByTestId('rh-identity')).toContainText('Monaco')
|
||||
await expect(page.getByTestId('rh-overview')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
@@ -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,7 +8,7 @@ export const VIEWPORTS = {
|
||||
} as const
|
||||
|
||||
export const FULL_SESSION = 9472
|
||||
export const FUTURE_SESSION = 9600
|
||||
export { FUTURE_SESSION }
|
||||
|
||||
/** Wait for web fonts and layout to settle before screenshots. */
|
||||
export async function waitForScreenshotReady(page: Page): Promise<void> {
|
||||
@@ -38,6 +39,7 @@ export async function gotoRaceHubFutureReady(
|
||||
page: Page,
|
||||
sessionKey = FUTURE_SESSION,
|
||||
): Promise<void> {
|
||||
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()
|
||||
|
||||