Compare commits
4 Commits
feat/issue
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
469028b07a | ||
|
|
5d5987cc7e | ||
|
|
7c98489b91 | ||
|
|
84a9a3579f |
@@ -119,7 +119,8 @@ export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
|
|||||||
// (backend story #72). The response is the authoritative WeekendContext shape and
|
// (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
|
// 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
|
// 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> {
|
export async function fetchWeekendContext(): Promise<WeekendContext> {
|
||||||
const res = await fetch('/api/v1/weekend-context')
|
const res = await fetch('/api/v1/weekend-context')
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { RaceHub } from '../types'
|
import type { RaceHub } from '../types'
|
||||||
import { compareFinishPosition, formatDuration, formatGap, formatLapTime } from '../utils'
|
import { compareFinishPosition, formatDuration, formatGap, formatLapTime } from '../utils'
|
||||||
import { countRaceHubDatasets } from '../lib/coverage'
|
|
||||||
import { Thermometer, Map, Droplets, Wind, CloudRain } from 'lucide-react'
|
import { Thermometer, Map, Droplets, Wind, CloudRain } from 'lucide-react'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -17,7 +16,6 @@ export function OverviewView({ data }: Props) {
|
|||||||
const fastest = pickFastestLap(data)
|
const fastest = pickFastestLap(data)
|
||||||
const latestWeather = data.weather.length > 0 ? data.weather[data.weather.length - 1] : null
|
const latestWeather = data.weather.length > 0 ? data.weather[data.weather.length - 1] : null
|
||||||
const rcHighlights = data.race_control.slice(-3).reverse()
|
const rcHighlights = data.race_control.slice(-3).reverse()
|
||||||
const coverage = countRaceHubDatasets(data.datasets)
|
|
||||||
|
|
||||||
const sessionType = (data.session?.session_type ?? '').toLowerCase()
|
const sessionType = (data.session?.session_type ?? '').toLowerCase()
|
||||||
const isRace = sessionType.includes('race')
|
const isRace = sessionType.includes('race')
|
||||||
@@ -129,28 +127,6 @@ export function OverviewView({ data }: Props) {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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 Data Status tab.`}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
141
frontend/src/components/PreSessionView.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
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',
|
||||||
|
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 PreSessionProps {
|
||||||
|
session: Session
|
||||||
|
sessionName: string
|
||||||
|
now: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, now }: PreSessionProps) {
|
||||||
|
const start = sessionStartTime(session)
|
||||||
|
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 hasn’t run yet, so there’s 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,15 +8,46 @@ export type Tab =
|
|||||||
| 'race_control'
|
| 'race_control'
|
||||||
| 'data_status'
|
| 'data_status'
|
||||||
|
|
||||||
const TABS: { id: Tab; label: string }[] = [
|
interface TabDef {
|
||||||
{ id: 'overview', label: 'Overview' },
|
id: Tab
|
||||||
{ id: 'race_story', label: 'Race Story' },
|
label: string
|
||||||
{ id: 'strategy', label: 'Strategy' },
|
}
|
||||||
{ id: 'compare', label: 'Compare' },
|
|
||||||
{ id: 'lap_data', label: 'Lap Data' },
|
interface TabGroup {
|
||||||
{ id: 'conditions', label: 'Conditions' },
|
id: string
|
||||||
{ id: 'race_control', label: 'Race Control' },
|
label: string
|
||||||
{ id: 'data_status', label: 'Data Status' },
|
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 {
|
interface Props {
|
||||||
@@ -26,17 +57,26 @@ interface Props {
|
|||||||
|
|
||||||
export function TabBar({ active, onChange }: Props) {
|
export function TabBar({ active, onChange }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="tab-bar" role="tablist">
|
<div className="tab-bar tab-bar-grouped" role="tablist" data-testid="rh-tabbar">
|
||||||
{TABS.map((t) => (
|
{TAB_GROUPS.map((group) => (
|
||||||
<button
|
<div key={group.id} className="tab-group" data-testid={`rh-tabgroup-${group.id}`}>
|
||||||
key={t.id}
|
<span className="tab-group-label mono" aria-hidden="true">
|
||||||
role="tab"
|
{group.label}
|
||||||
aria-selected={active === t.id}
|
</span>
|
||||||
className={`tab-btn${active === t.id ? ' active' : ''}`}
|
<div className="tab-group-btns">
|
||||||
onClick={() => onChange(t.id)}
|
{group.tabs.map((t) => (
|
||||||
>
|
<button
|
||||||
{t.label}
|
key={t.id}
|
||||||
</button>
|
role="tab"
|
||||||
|
aria-selected={active === t.id}
|
||||||
|
className={`tab-btn${active === t.id ? ' active' : ''}`}
|
||||||
|
onClick={() => onChange(t.id)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,34 +2,37 @@ import { useEffect, useState } from 'react'
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
|
import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
|
||||||
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
|
import { sessionTypeAbbrev } from '../lib/coverage'
|
||||||
|
import {
|
||||||
|
resolveSessionState,
|
||||||
|
sessionStateDotClass,
|
||||||
|
sessionStateLabel,
|
||||||
|
} from '../lib/sessionState'
|
||||||
import { countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
import { countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
||||||
|
import type { WeekendContext } from '../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
currentMeetingKey?: number
|
currentMeetingKey?: number
|
||||||
currentSessionKey?: number
|
currentSessionKey?: number
|
||||||
|
context?: WeekendContext | null
|
||||||
|
now?: Date
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose }: Props) {
|
export function WeekendSwitcher({
|
||||||
|
currentMeetingKey,
|
||||||
|
currentSessionKey,
|
||||||
|
context,
|
||||||
|
now: nowProp,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons })
|
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons })
|
||||||
const [year, setYear] = useState<number | null>(null)
|
const [year, setYear] = useState<number | null>(null)
|
||||||
const [openMeetingKey, setOpenMeetingKey] = useState<number | null>(
|
const [openMeetingKey, setOpenMeetingKey] = useState<number | null>(
|
||||||
currentMeetingKey ?? null,
|
currentMeetingKey ?? null,
|
||||||
)
|
)
|
||||||
|
const now = nowProp ?? new Date()
|
||||||
useEffect(() => {
|
|
||||||
if (year == null && seasonsQuery.data?.length) {
|
|
||||||
setYear(seasonsQuery.data[0])
|
|
||||||
}
|
|
||||||
}, [seasonsQuery.data, year])
|
|
||||||
|
|
||||||
const meetingsQuery = useQuery({
|
|
||||||
queryKey: ['meetings', year],
|
|
||||||
queryFn: () => fetchLocalMeetings(year!),
|
|
||||||
enabled: year != null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const weekendQuery = useQuery({
|
const weekendQuery = useQuery({
|
||||||
queryKey: ['weekend', openMeetingKey],
|
queryKey: ['weekend', openMeetingKey],
|
||||||
@@ -37,6 +40,22 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
|
|||||||
enabled: openMeetingKey != null,
|
enabled: openMeetingKey != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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, weekendQuery.data, year])
|
||||||
|
|
||||||
|
const meetingsQuery = useQuery({
|
||||||
|
queryKey: ['meetings', year],
|
||||||
|
queryFn: () => fetchLocalMeetings(year!),
|
||||||
|
enabled: year != null,
|
||||||
|
})
|
||||||
|
|
||||||
const seasons = seasonsQuery.data ?? []
|
const seasons = seasonsQuery.data ?? []
|
||||||
const meetings = meetingsQuery.data ?? []
|
const meetings = meetingsQuery.data ?? []
|
||||||
const weekend = weekendQuery.data
|
const weekend = weekendQuery.data
|
||||||
@@ -106,9 +125,16 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
|
|||||||
{weekendQuery.isLoading && (
|
{weekendQuery.isLoading && (
|
||||||
<div className="rh-switcher-empty">loading sessions…</div>
|
<div className="rh-switcher-empty">loading sessions…</div>
|
||||||
)}
|
)}
|
||||||
{weekend && weekend.meeting_key === m.meeting_key &&
|
{weekend &&
|
||||||
weekend.sessions.map(({ session, source, datasets }) => {
|
weekend.meeting_key === m.meeting_key &&
|
||||||
|
weekend.sessions.map((weekendSession) => {
|
||||||
|
const { session } = weekendSession
|
||||||
const active = session.session_key === currentSessionKey
|
const active = session.session_key === currentSessionKey
|
||||||
|
const state = resolveSessionState({
|
||||||
|
weekendSession,
|
||||||
|
context,
|
||||||
|
now,
|
||||||
|
})
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={session.session_key}
|
key={session.session_key}
|
||||||
@@ -122,8 +148,11 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
|
|||||||
</span>
|
</span>
|
||||||
<span className="rh-switcher-sess-name">{session.session_name}</span>
|
<span className="rh-switcher-sess-name">{session.session_name}</span>
|
||||||
<span className="rh-switcher-sess-cov mono">
|
<span className="rh-switcher-sess-cov mono">
|
||||||
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
|
<span
|
||||||
{formatCoverageHint(datasets)}
|
className={`cc-cov-dot ${sessionStateDotClass(state)}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{sessionStateLabel(state)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
65
frontend/src/components/weekend/WeekendFocusBanner.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { ChevronRight } from 'lucide-react'
|
||||||
|
import { fetchWeekend } from '../../api'
|
||||||
|
import { meetingIdentity } from '../../lib/weekendContext'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores Race Hub → Weekend meeting/session context from the URL search
|
||||||
|
* contract. Deep-linkable and reload-safe — no hidden component memory.
|
||||||
|
*/
|
||||||
|
export function WeekendFocusBanner({
|
||||||
|
meetingKey,
|
||||||
|
sessionKey,
|
||||||
|
}: {
|
||||||
|
meetingKey?: number
|
||||||
|
sessionKey?: number
|
||||||
|
}) {
|
||||||
|
const hasFocus = (meetingKey != null && meetingKey > 0) || (sessionKey != null && sessionKey > 0)
|
||||||
|
const weekendQuery = useQuery({
|
||||||
|
queryKey: ['weekend', meetingKey],
|
||||||
|
queryFn: () => fetchWeekend(meetingKey!),
|
||||||
|
enabled: meetingKey != null && meetingKey > 0,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!hasFocus) return null
|
||||||
|
|
||||||
|
const weekend = weekendQuery.data
|
||||||
|
const meeting = meetingIdentity(weekend?.meeting)
|
||||||
|
const session = weekend?.sessions.find((s) => s.session.session_key === sessionKey)?.session
|
||||||
|
const meetingLabel = meeting?.meeting_name ?? (meetingKey ? `Meeting ${meetingKey}` : 'Selected weekend')
|
||||||
|
const sessionLabel = session?.session_name ?? (sessionKey ? `Session ${sessionKey}` : null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="wk-focus"
|
||||||
|
data-testid="wk-focus-context"
|
||||||
|
data-meeting-key={meetingKey ?? undefined}
|
||||||
|
data-session-key={sessionKey ?? undefined}
|
||||||
|
aria-label="Selected weekend context"
|
||||||
|
>
|
||||||
|
<div className="wk-focus-copy">
|
||||||
|
<span className="wk-focus-eyebrow mono">From Race Hub</span>
|
||||||
|
<h2 className="wk-focus-title" data-testid="wk-focus-meeting">
|
||||||
|
{meetingLabel}
|
||||||
|
</h2>
|
||||||
|
{sessionLabel && (
|
||||||
|
<p className="wk-focus-session" data-testid="wk-focus-session">
|
||||||
|
Selected session · {sessionLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{sessionKey != null && sessionKey > 0 && (
|
||||||
|
<Link
|
||||||
|
to="/race-hub"
|
||||||
|
search={{ session_key: sessionKey }}
|
||||||
|
className="wk-cta wk-cta-primary wk-focus-cta"
|
||||||
|
data-testid="wk-return-analysis"
|
||||||
|
>
|
||||||
|
Continue analysis <ChevronRight size={15} aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
frontend/src/lib/routeSearch.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/** Shared URL search contracts for deep-linkable Weekend ↔ Race Hub navigation. */
|
||||||
|
|
||||||
|
export type WeekendSearch = {
|
||||||
|
meeting_key?: number
|
||||||
|
session_key?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RaceHubSearch = {
|
||||||
|
session_key?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveInt(value: unknown): number | undefined {
|
||||||
|
const n = Number(value)
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWeekendSearch(search: Record<string, unknown>): WeekendSearch {
|
||||||
|
const meeting_key = parsePositiveInt(search.meeting_key)
|
||||||
|
const session_key = parsePositiveInt(search.session_key)
|
||||||
|
return {
|
||||||
|
...(meeting_key ? { meeting_key } : {}),
|
||||||
|
...(session_key ? { session_key } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRaceHubSearch(search: Record<string, unknown>): RaceHubSearch {
|
||||||
|
const session_key = parsePositiveInt(search.session_key)
|
||||||
|
return session_key ? { session_key } : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build Weekend `/` search so a Race Hub return is reload-safe and deep-linkable. */
|
||||||
|
export function weekendFocusSearch(
|
||||||
|
meetingKey?: number | null,
|
||||||
|
sessionKey?: number | null,
|
||||||
|
): WeekendSearch {
|
||||||
|
return {
|
||||||
|
...(meetingKey && meetingKey > 0 ? { meeting_key: meetingKey } : {}),
|
||||||
|
...(sessionKey && sessionKey > 0 ? { session_key: sessionKey } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
194
frontend/src/lib/sessionState.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import type { ContextSession, WeekendContext, WeekendSession } from '../types'
|
||||||
|
import { isSessionComplete } from './coverage'
|
||||||
|
import { sessionStartTime } from './schedule'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User-facing lifecycle state for a weekend session.
|
||||||
|
*
|
||||||
|
* 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'
|
||||||
|
| 'live'
|
||||||
|
| 'preparing'
|
||||||
|
| 'partial'
|
||||||
|
| 'ready'
|
||||||
|
| 'unavailable'
|
||||||
|
| 'cancelled'
|
||||||
|
|
||||||
|
export interface SessionStateInput {
|
||||||
|
weekendSession?: WeekendSession
|
||||||
|
context?: WeekendContext | null
|
||||||
|
now: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (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. */
|
||||||
|
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 'unavailable':
|
||||||
|
return 'Unavailable'
|
||||||
|
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 — 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.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 'unavailable':
|
||||||
|
return 'rh-state-unavailable'
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,8 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { Link, useNavigate } from '@tanstack/react-router'
|
||||||
import {
|
import { fetchRaceHub, fetchWeekend, fetchWeekendContext } from '../api'
|
||||||
fetchLocalMeetings,
|
import { weekendFocusSearch } from '../lib/routeSearch'
|
||||||
fetchRaceHub,
|
|
||||||
fetchSeasons,
|
|
||||||
fetchWeekend,
|
|
||||||
} from '../api'
|
|
||||||
import { DatasetStrip } from '../components/DatasetStrip'
|
import { DatasetStrip } from '../components/DatasetStrip'
|
||||||
import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
|
import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
|
||||||
import { TabBar, type Tab } from '../components/TabBar'
|
import { TabBar, type Tab } from '../components/TabBar'
|
||||||
@@ -17,77 +13,64 @@ import { CompareView } from '../components/CompareView'
|
|||||||
import { RaceControlView } from '../components/RaceControlView'
|
import { RaceControlView } from '../components/RaceControlView'
|
||||||
import { WeatherView } from '../components/WeatherView'
|
import { WeatherView } from '../components/WeatherView'
|
||||||
import { OverviewView } from '../components/OverviewView'
|
import { OverviewView } from '../components/OverviewView'
|
||||||
|
import {
|
||||||
|
PartialAnalysisBanner,
|
||||||
|
PreSessionView,
|
||||||
|
SessionPhaseView,
|
||||||
|
} from '../components/PreSessionView'
|
||||||
import { WeekendSwitcher } from '../components/WeekendSwitcher'
|
import { WeekendSwitcher } from '../components/WeekendSwitcher'
|
||||||
import { SourceBadge } from '../components/SourceBadge'
|
import { SourceBadge } from '../components/SourceBadge'
|
||||||
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
||||||
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
|
import { sessionTypeAbbrev } from '../lib/coverage'
|
||||||
|
import { formatSessionScheduleTime, sortSessionsByStart } from '../lib/schedule'
|
||||||
import {
|
import {
|
||||||
formatSessionScheduleTime,
|
defaultAnalysisSessionKey,
|
||||||
pickFocusMeeting,
|
isPartialAnalysis,
|
||||||
sortSessionsByStart,
|
isPreSession,
|
||||||
} from '../lib/schedule'
|
isPreparing,
|
||||||
import type { Weekend, WeekendSession } from '../types'
|
isUnavailable,
|
||||||
|
resolveSessionState,
|
||||||
|
sessionStateDotClass,
|
||||||
|
sessionStateLabel,
|
||||||
|
} from '../lib/sessionState'
|
||||||
|
import type { WeekendSession } from '../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionKey: number
|
sessionKey: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickAnalysisSession(weekend: Weekend | undefined): WeekendSession | 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
|
|
||||||
const race = pool.find((s) => s.session.session_type?.toLowerCase().includes('race'))
|
|
||||||
if (race) return race
|
|
||||||
const qual = pool.find((s) => s.session.session_type?.toLowerCase().includes('qualifying'))
|
|
||||||
if (qual) return qual
|
|
||||||
return pool[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RaceHubPage({ sessionKey }: Props) {
|
export function RaceHubPage({ sessionKey }: Props) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('overview')
|
const [activeTab, setActiveTab] = useState<Tab>('overview')
|
||||||
const [switcherOpen, setSwitcherOpen] = useState(false)
|
const [switcherOpen, setSwitcherOpen] = useState(false)
|
||||||
|
const [showDiagnostics, setShowDiagnostics] = useState(false)
|
||||||
|
const [phaseDiagnostics, setPhaseDiagnostics] = useState(false)
|
||||||
|
const [now, setNow] = useState(() => new Date())
|
||||||
|
|
||||||
// ─── Auto-redirect when no session_key is supplied ───
|
// Keep schedule-adjacent UI (countdown) moving; Live/completion come from context.
|
||||||
const seasonsQuery = useQuery({
|
useEffect(() => {
|
||||||
queryKey: ['seasons'],
|
const id = window.setInterval(() => setNow(new Date()), 1000)
|
||||||
queryFn: fetchSeasons,
|
return () => window.clearInterval(id)
|
||||||
enabled: sessionKey === 0,
|
}, [])
|
||||||
})
|
|
||||||
|
const contextQuery = useQuery({
|
||||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
queryKey: ['weekend-context'],
|
||||||
|
queryFn: fetchWeekendContext,
|
||||||
const meetingsQuery = useQuery({
|
staleTime: 15_000,
|
||||||
queryKey: ['meetings', latestSeason],
|
refetchInterval: 30_000,
|
||||||
queryFn: () => fetchLocalMeetings(latestSeason!),
|
|
||||||
enabled: sessionKey === 0 && latestSeason != null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const focusMeeting = useMemo(() => {
|
|
||||||
if (sessionKey !== 0 || !meetingsQuery.data) return null
|
|
||||||
return pickFocusMeeting(meetingsQuery.data, new Date())
|
|
||||||
}, [sessionKey, meetingsQuery.data])
|
|
||||||
|
|
||||||
const fallbackWeekendQuery = useQuery({
|
|
||||||
queryKey: ['weekend', focusMeeting?.meeting_key],
|
|
||||||
queryFn: () => fetchWeekend(focusMeeting!.meeting_key),
|
|
||||||
enabled: sessionKey === 0 && focusMeeting != null,
|
|
||||||
})
|
})
|
||||||
|
const context = contextQuery.data ?? null
|
||||||
|
|
||||||
|
// Bare `/race-hub` resolves only through canonical Weekend Context.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionKey !== 0) return
|
if (sessionKey !== 0) return
|
||||||
const weekend = fallbackWeekendQuery.data
|
if (!contextQuery.isSuccess || !context) return
|
||||||
if (!weekend) return
|
const target = defaultAnalysisSessionKey(context)
|
||||||
const target = pickAnalysisSession(weekend)?.session.session_key
|
|
||||||
?? weekend.default_session_key
|
|
||||||
?? weekend.sessions[0]?.session.session_key
|
|
||||||
if (target) {
|
if (target) {
|
||||||
navigate({ to: '/race-hub', search: { session_key: target }, replace: true })
|
navigate({ to: '/race-hub', search: { session_key: target }, replace: true })
|
||||||
}
|
}
|
||||||
}, [sessionKey, fallbackWeekendQuery.data, navigate])
|
}, [sessionKey, contextQuery.isSuccess, context, navigate])
|
||||||
|
|
||||||
// ─── Active session payload ───
|
|
||||||
const raceHubQuery = useQuery({
|
const raceHubQuery = useQuery({
|
||||||
queryKey: ['race-hub', sessionKey],
|
queryKey: ['race-hub', sessionKey],
|
||||||
queryFn: () => fetchRaceHub(sessionKey),
|
queryFn: () => fetchRaceHub(sessionKey),
|
||||||
@@ -108,37 +91,86 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
const accent = countryAccent(data?.meeting ?? null)
|
const accent = countryAccent(data?.meeting ?? null)
|
||||||
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
|
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
|
||||||
|
|
||||||
// ─── No session_key: show resolving state, fall back to switcher if no local data ───
|
const openDiagnostics = () => {
|
||||||
|
setActiveTab('data_status')
|
||||||
|
setShowDiagnostics(true)
|
||||||
|
setPhaseDiagnostics(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── No session_key: resolve via Weekend Context ───
|
||||||
if (sessionKey === 0) {
|
if (sessionKey === 0) {
|
||||||
if (seasonsQuery.isLoading || meetingsQuery.isLoading || fallbackWeekendQuery.isLoading) {
|
if (contextQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="rh-page" style={accentStyle}>
|
<div className="rh-page" style={accentStyle}>
|
||||||
<div className="loading-state">resolving latest local weekend…</div>
|
<div className="loading-state">resolving weekend context…</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const seasons = seasonsQuery.data ?? []
|
if (contextQuery.isError) {
|
||||||
if (seasons.length === 0) {
|
|
||||||
return (
|
return (
|
||||||
<div className="rh-page rh-empty" data-testid="race-hub-empty" style={accentStyle}>
|
<div className="rh-page" data-testid="race-hub-error" style={accentStyle}>
|
||||||
<div className="rh-empty-band">
|
<div className="rh-recover">
|
||||||
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
|
<div className="error-box">
|
||||||
<h1 className="rh-empty-title">No local sessions yet</h1>
|
{contextQuery.error instanceof Error
|
||||||
<p className="rh-empty-sub">
|
? contextQuery.error.message
|
||||||
The Race Hub reads from local ingest only. Once a weekend is ingested
|
: 'Failed to load weekend context.'}
|
||||||
it will open here automatically.
|
</div>
|
||||||
</p>
|
<div className="rh-recover-actions">
|
||||||
<div className="rh-empty-actions">
|
<button
|
||||||
<a href="/admin" className="rh-empty-action">Open Admin · Data Health</a>
|
type="button"
|
||||||
<a href="/" className="rh-empty-action">Back to Command Center</a>
|
className="rh-recover-btn primary"
|
||||||
|
onClick={() => contextQuery.refetch()}
|
||||||
|
data-testid="rh-retry"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
<Link to="/" search={{}} className="rh-recover-btn" data-testid="rh-back-weekend">
|
||||||
|
Back to Weekend
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
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
|
||||||
|
type="button"
|
||||||
|
className="rh-empty-action"
|
||||||
|
onClick={() => setSwitcherOpen(true)}
|
||||||
|
>
|
||||||
|
Browse Weekends
|
||||||
|
</button>
|
||||||
|
<Link to="/" search={{}} className="rh-empty-action" data-testid="rh-back-weekend">
|
||||||
|
Back to Weekend
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{switcherOpen && (
|
||||||
|
<WeekendSwitcher
|
||||||
|
currentMeetingKey={context.focus_meeting?.meeting_key}
|
||||||
|
context={context}
|
||||||
|
now={now}
|
||||||
|
onClose={() => setSwitcherOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rh-page" style={accentStyle}>
|
<div className="rh-page" style={accentStyle}>
|
||||||
<div className="loading-state">resolving latest local weekend…</div>
|
<div className="loading-state">resolving weekend context…</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -152,12 +184,39 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (raceHubQuery.isError || !data) {
|
if (raceHubQuery.isError || !data) {
|
||||||
|
const backMeeting =
|
||||||
|
context?.focus_meeting?.meeting_key ??
|
||||||
|
context?.previous_completed_session?.meeting?.meeting_key
|
||||||
|
const backSearch = weekendFocusSearch(backMeeting, sessionKey)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rh-page" style={accentStyle}>
|
<div className="rh-page" data-testid="race-hub-error" style={accentStyle}>
|
||||||
<div className="error-box">
|
<div className="rh-recover">
|
||||||
{raceHubQuery.error instanceof Error
|
<div className="error-box">
|
||||||
? raceHubQuery.error.message
|
{raceHubQuery.error instanceof Error
|
||||||
: `Failed to load session ${sessionKey}.`}
|
? 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>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
search={backSearch}
|
||||||
|
className="rh-recover-btn"
|
||||||
|
data-testid="rh-back-weekend"
|
||||||
|
data-meeting-key={backMeeting ?? undefined}
|
||||||
|
data-session-key={sessionKey}
|
||||||
|
>
|
||||||
|
Back to Weekend
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -168,12 +227,30 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
const sessionMeta = weekend
|
const sessionMeta = weekend
|
||||||
? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w]))
|
? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w]))
|
||||||
: {}
|
: {}
|
||||||
const activeSessionMeta = sessionMeta[sessionKey]
|
const activeSessionMeta: WeekendSession | undefined = sessionMeta[sessionKey]
|
||||||
|
const activeState = resolveSessionState({
|
||||||
|
weekendSession: activeSessionMeta,
|
||||||
|
context,
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
const preSession = isPreSession(activeState)
|
||||||
|
const preparing = isPreparing(activeState)
|
||||||
|
const unavailable = isUnavailable(activeState)
|
||||||
|
const partial = isPartialAnalysis(activeState)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
|
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
|
||||||
{/* Topbar */}
|
|
||||||
<div className="rh-topbar">
|
<div className="rh-topbar">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
search={weekendFocusSearch(meetingKey, sessionKey)}
|
||||||
|
className="rh-back-weekend"
|
||||||
|
data-testid="rh-back-weekend"
|
||||||
|
data-meeting-key={meetingKey ?? undefined}
|
||||||
|
data-session-key={sessionKey}
|
||||||
|
>
|
||||||
|
Back to Weekend
|
||||||
|
</Link>
|
||||||
<span className="rh-topbar-label mono">
|
<span className="rh-topbar-label mono">
|
||||||
box-box · race hub
|
box-box · race hub
|
||||||
{data.meeting?.year ? ` · ${data.meeting.year}` : ''}
|
{data.meeting?.year ? ` · ${data.meeting.year}` : ''}
|
||||||
@@ -195,11 +272,12 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
<WeekendSwitcher
|
<WeekendSwitcher
|
||||||
currentMeetingKey={meetingKey}
|
currentMeetingKey={meetingKey}
|
||||||
currentSessionKey={sessionKey}
|
currentSessionKey={sessionKey}
|
||||||
|
context={context}
|
||||||
|
now={now}
|
||||||
onClose={() => setSwitcherOpen(false)}
|
onClose={() => setSwitcherOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* GP Identity band */}
|
|
||||||
{data.meeting && (
|
{data.meeting && (
|
||||||
<section className="rh-identity" data-testid="rh-identity">
|
<section className="rh-identity" data-testid="rh-identity">
|
||||||
<div className="rh-identity-accent" aria-hidden="true" />
|
<div className="rh-identity-accent" aria-hidden="true" />
|
||||||
@@ -220,12 +298,16 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Session rail */}
|
|
||||||
{sessions.length > 0 && (
|
{sessions.length > 0 && (
|
||||||
<nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail">
|
<nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail">
|
||||||
{sessions.map((session) => {
|
{sessions.map((session) => {
|
||||||
const meta = sessionMeta[session.session_key]
|
const meta = sessionMeta[session.session_key]
|
||||||
const active = session.session_key === sessionKey
|
const active = session.session_key === sessionKey
|
||||||
|
const state = resolveSessionState({
|
||||||
|
weekendSession: meta,
|
||||||
|
context,
|
||||||
|
now,
|
||||||
|
})
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={session.session_key}
|
key={session.session_key}
|
||||||
@@ -247,123 +329,166 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
<span className="rh-session-time mono">
|
<span className="rh-session-time mono">
|
||||||
{formatSessionScheduleTime(session.date_start)}
|
{formatSessionScheduleTime(session.date_start)}
|
||||||
</span>
|
</span>
|
||||||
{meta && (
|
<span className="rh-session-cov mono">
|
||||||
<span className="rh-session-cov mono">
|
<span
|
||||||
<span
|
className={`cc-cov-dot ${sessionStateDotClass(state)}`}
|
||||||
className={`cc-cov-dot cc-cov-${meta.source}`}
|
aria-hidden="true"
|
||||||
aria-hidden="true"
|
/>
|
||||||
/>
|
{sessionStateLabel(state)}
|
||||||
{formatCoverageHint(meta.datasets)}
|
</span>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Active session sub-bar */}
|
|
||||||
{data.session && (
|
{data.session && (
|
||||||
<div className="rh-active-bar" data-testid="rh-active-bar">
|
<div className="rh-active-bar" data-testid="rh-active-bar">
|
||||||
<span className="rh-active-name">{data.session.session_name}</span>
|
<span className="rh-active-name">{data.session.session_name}</span>
|
||||||
<span className="rh-active-meta mono">
|
<span className="rh-active-meta mono">
|
||||||
{formatSessionScheduleTime(data.session.date_start)}
|
{formatSessionScheduleTime(data.session.date_start)}
|
||||||
</span>
|
</span>
|
||||||
{activeSessionMeta && (
|
<span className="rh-active-cov mono" data-testid="rh-active-state">
|
||||||
<span className="rh-active-cov mono">
|
<span
|
||||||
<span
|
className={`cc-cov-dot ${sessionStateDotClass(activeState)}`}
|
||||||
className={`cc-cov-dot cc-cov-${activeSessionMeta.source}`}
|
aria-hidden="true"
|
||||||
aria-hidden="true"
|
/>
|
||||||
/>
|
{sessionStateLabel(activeState)}
|
||||||
{formatCoverageHint(activeSessionMeta.datasets)} datasets local
|
</span>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="rh-active-key mono">key {sessionKey}</span>
|
<span className="rh-active-key mono">key {sessionKey}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DatasetStrip datasets={data.datasets} />
|
{preSession && data.session ? (
|
||||||
|
<PreSessionView
|
||||||
<TabBar active={activeTab} onChange={setActiveTab} />
|
session={data.session}
|
||||||
|
sessionName={data.session.session_name}
|
||||||
{activeTab === 'overview' && <OverviewView data={data} />}
|
now={now}
|
||||||
|
/>
|
||||||
{activeTab === 'race_story' && (
|
) : preparing || unavailable ? (
|
||||||
<div className="data-section">
|
<>
|
||||||
<RaceStoryCanvas data={data} />
|
<SessionPhaseView
|
||||||
</div>
|
state={
|
||||||
)}
|
unavailable
|
||||||
|
? activeState === 'cancelled'
|
||||||
{activeTab === 'strategy' && (
|
? 'cancelled'
|
||||||
<div className="data-section">
|
: 'unavailable'
|
||||||
<div className="sec-header">
|
: 'preparing'
|
||||||
<span className="sec-title">Race Strategy</span>
|
}
|
||||||
</div>
|
sessionName={data.session?.session_name ?? `Session ${sessionKey}`}
|
||||||
<StrategyView
|
onOpenDiagnostics={openDiagnostics}
|
||||||
results={data.results}
|
|
||||||
stints={data.stints}
|
|
||||||
pit_stops={data.pit_stops}
|
|
||||||
hasStints={data.datasets['stints']?.status === 'available'}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
{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 === 'compare' && (
|
{activeTab === 'overview' && <OverviewView data={data} />}
|
||||||
<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' && (
|
{activeTab === 'race_story' && (
|
||||||
<div className="data-section">
|
<div className="data-section">
|
||||||
<div className="sec-header">
|
<RaceStoryCanvas data={data} />
|
||||||
<span className="sec-title">Lap Data</span>
|
</div>
|
||||||
{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' && (
|
{activeTab === 'strategy' && (
|
||||||
<div className="data-section">
|
<div className="data-section">
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
<span className="sec-title">Conditions</span>
|
<span className="sec-title">Race Strategy</span>
|
||||||
{data.weather.length > 0 && (
|
</div>
|
||||||
<span className="sec-meta mono">{data.weather.length} samples</span>
|
<StrategyView
|
||||||
)}
|
results={data.results}
|
||||||
</div>
|
stints={data.stints}
|
||||||
<WeatherView weather={data.weather} />
|
pit_stops={data.pit_stops}
|
||||||
</div>
|
hasStints={data.datasets['stints']?.status === 'available'}
|
||||||
)}
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'race_control' && (
|
{activeTab === 'compare' && (
|
||||||
<div className="data-section">
|
<div className="data-section">
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
<span className="sec-title">Race Control</span>
|
<span className="sec-title">Driver Compare</span>
|
||||||
{data.race_control.length > 0 && (
|
</div>
|
||||||
<span className="sec-meta mono">{data.race_control.length} messages</span>
|
<CompareView
|
||||||
)}
|
sessionKey={sessionKey}
|
||||||
</div>
|
results={data.results}
|
||||||
<RaceControlView messages={data.race_control} />
|
drivers={data.drivers}
|
||||||
</div>
|
/>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'data_status' && (
|
{activeTab === 'lap_data' && (
|
||||||
<div className="data-section">
|
<div className="data-section">
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
<span className="sec-title">Data Status</span>
|
<span className="sec-title">Lap Data</span>
|
||||||
</div>
|
{data.laps.length > 0 && (
|
||||||
<DatasetStatusView datasets={data.datasets} />
|
<span className="sec-meta mono">{data.laps.length} samples</span>
|
||||||
</div>
|
)}
|
||||||
|
</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 === '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">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>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { BetweenSessionsView } from '../components/weekend/BetweenSessionsView'
|
|||||||
import { LiveHandoffView } from '../components/weekend/LiveHandoffView'
|
import { LiveHandoffView } from '../components/weekend/LiveHandoffView'
|
||||||
import { PreSessionView } from '../components/weekend/PreSessionView'
|
import { PreSessionView } from '../components/weekend/PreSessionView'
|
||||||
import { WeekendError, WeekendLimited, WeekendLoading } from '../components/weekend/StatusViews'
|
import { WeekendError, WeekendLimited, WeekendLoading } from '../components/weekend/StatusViews'
|
||||||
|
import { WeekendFocusBanner } from '../components/weekend/WeekendFocusBanner'
|
||||||
import { resolveViewState } from '../lib/weekendContext'
|
import { resolveViewState } from '../lib/weekendContext'
|
||||||
import type {
|
import type {
|
||||||
WeekendBriefingItem,
|
WeekendBriefingItem,
|
||||||
@@ -59,8 +60,21 @@ function renderState(view: WeekendViewState, args: RenderArgs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WeekendPage({ preview = false }: { preview?: boolean }) {
|
export function WeekendPage({
|
||||||
|
preview = false,
|
||||||
|
focusMeetingKey,
|
||||||
|
focusSessionKey,
|
||||||
|
}: {
|
||||||
|
preview?: boolean
|
||||||
|
/** Restored from `/?meeting_key=` when returning from Race Hub analysis. */
|
||||||
|
focusMeetingKey?: number
|
||||||
|
/** Restored from `/?session_key=` when returning from Race Hub analysis. */
|
||||||
|
focusSessionKey?: number
|
||||||
|
}) {
|
||||||
const { context, loadState, error, championship, briefing, now } = useWeekendContext()
|
const { context, loadState, error, championship, briefing, now } = useWeekendContext()
|
||||||
|
const hasFocus =
|
||||||
|
(focusMeetingKey != null && focusMeetingKey > 0) ||
|
||||||
|
(focusSessionKey != null && focusSessionKey > 0)
|
||||||
|
|
||||||
if (loadState === 'loading') {
|
if (loadState === 'loading') {
|
||||||
return (
|
return (
|
||||||
@@ -87,7 +101,12 @@ export function WeekendPage({ preview = false }: { preview?: boolean }) {
|
|||||||
data-state={view}
|
data-state={view}
|
||||||
data-temporal-state={context.temporal_state}
|
data-temporal-state={context.temporal_state}
|
||||||
data-preview={preview ? 'true' : undefined}
|
data-preview={preview ? 'true' : undefined}
|
||||||
|
data-meeting-key={focusMeetingKey ?? undefined}
|
||||||
|
data-session-key={focusSessionKey ?? undefined}
|
||||||
>
|
>
|
||||||
|
{hasFocus && (
|
||||||
|
<WeekendFocusBanner meetingKey={focusMeetingKey} sessionKey={focusSessionKey} />
|
||||||
|
)}
|
||||||
{renderState(view, { context, now, championship, briefing, preview })}
|
{renderState(view, { context, now, championship, briefing, preview })}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ import { LiveTimingPage } from './pages/LiveTimingPage'
|
|||||||
import { BriefingPage } from './pages/BriefingPage'
|
import { BriefingPage } from './pages/BriefingPage'
|
||||||
import { ChampionshipPage } from './pages/ChampionshipPage'
|
import { ChampionshipPage } from './pages/ChampionshipPage'
|
||||||
import { DriverProfilePage } from './pages/DriverProfilePage'
|
import { DriverProfilePage } from './pages/DriverProfilePage'
|
||||||
|
import { parseRaceHubSearch, parseWeekendSearch } from './lib/routeSearch'
|
||||||
type RaceHubSearch = {
|
|
||||||
session_key?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type DriverProfileSearch = {
|
type DriverProfileSearch = {
|
||||||
year?: number
|
year?: number
|
||||||
@@ -27,10 +24,18 @@ const rootRoute = createRootRoute({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Weekend is the adaptive home. `/` renders the current temporal state.
|
// Weekend is the adaptive home. `/` renders the current temporal state.
|
||||||
|
// Optional meeting_key/session_key search restores Race Hub return context
|
||||||
|
// (deep-linkable, reload-safe — no hidden component memory).
|
||||||
export const weekendRoute = createRoute({
|
export const weekendRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/',
|
path: '/',
|
||||||
component: WeekendPage,
|
validateSearch: (search: Record<string, unknown>) => parseWeekendSearch(search),
|
||||||
|
component: function WeekendRoute() {
|
||||||
|
const { meeting_key, session_key } = weekendRoute.useSearch()
|
||||||
|
return (
|
||||||
|
<WeekendPage focusMeetingKey={meeting_key} focusSessionKey={session_key} />
|
||||||
|
)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const exploreRoute = createRoute({
|
export const exploreRoute = createRoute({
|
||||||
@@ -42,10 +47,7 @@ export const exploreRoute = createRoute({
|
|||||||
export const raceHubRoute = createRoute({
|
export const raceHubRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/race-hub',
|
path: '/race-hub',
|
||||||
validateSearch: (search: Record<string, unknown>): RaceHubSearch => {
|
validateSearch: (search: Record<string, unknown>) => parseRaceHubSearch(search),
|
||||||
const sessionKey = Number(search.session_key)
|
|
||||||
return Number.isFinite(sessionKey) && sessionKey > 0 ? { session_key: sessionKey } : {}
|
|
||||||
},
|
|
||||||
component: function RaceHubRoute() {
|
component: function RaceHubRoute() {
|
||||||
const { session_key } = raceHubRoute.useSearch()
|
const { session_key } = raceHubRoute.useSearch()
|
||||||
return <RaceHubPage sessionKey={session_key ?? 0} />
|
return <RaceHubPage sessionKey={session_key ?? 0} />
|
||||||
|
|||||||
@@ -1820,6 +1820,163 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.tab-btn:hover { color: var(--text-2); }
|
.tab-btn:hover { color: var(--text-2); }
|
||||||
.tab-btn.active { color: var(--text); border-bottom-color: var(--red); }
|
.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-unavailable { background: var(--text-3); opacity: 0.4; }
|
||||||
|
.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-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));
|
||||||
|
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 ── */
|
/* ── Dataset status view ── */
|
||||||
.ds-legend {
|
.ds-legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2669,6 +2826,31 @@ a { color: inherit; text-decoration: none; }
|
|||||||
|
|
||||||
.rh-topbar-spacer { flex: 1; }
|
.rh-topbar-spacer { flex: 1; }
|
||||||
|
|
||||||
|
.rh-back-weekend {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-2);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: color 0.1s, border-color 0.1s, background 0.1s;
|
||||||
|
}
|
||||||
|
.rh-back-weekend:hover,
|
||||||
|
.rh-back-weekend:focus-visible {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--gp-accent);
|
||||||
|
background: var(--surface-h);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.rh-back-weekend:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--gp-accent) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.rh-switcher-toggle {
|
.rh-switcher-toggle {
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@@ -3244,7 +3426,10 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.rh-overview-row { grid-template-columns: 1fr; }
|
.rh-overview-row { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.rh-page { padding: var(--s4); gap: var(--s4); }
|
.rh-page { padding: var(--s4); gap: var(--s4); padding-bottom: 88px; }
|
||||||
|
.rh-topbar { flex-wrap: wrap; gap: var(--s3); }
|
||||||
|
.rh-topbar-spacer { display: none; }
|
||||||
|
.rh-back-weekend { order: -1; }
|
||||||
.rh-identity-body { padding: var(--s3) var(--s4); gap: var(--s3); }
|
.rh-identity-body { padding: var(--s3) var(--s4); gap: var(--s3); }
|
||||||
.rh-identity-decal { font-size: 28px; }
|
.rh-identity-decal { font-size: 28px; }
|
||||||
.rh-identity-name { font-size: 17px; }
|
.rh-identity-name { font-size: 17px; }
|
||||||
|
|||||||
@@ -6,6 +6,36 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wk-focus {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s5);
|
||||||
|
margin-bottom: var(--s5);
|
||||||
|
padding: var(--s5);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--red);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.wk-focus-copy { display: flex; flex-direction: column; gap: var(--s2); min-width: 0; }
|
||||||
|
.wk-focus-eyebrow {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
.wk-focus-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.wk-focus-session {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.wk-focus-cta { flex-shrink: 0; }
|
||||||
|
|
||||||
.wk-eyebrow {
|
.wk-eyebrow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -313,6 +343,11 @@
|
|||||||
}
|
}
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.wk-page { padding: var(--s5) var(--s5) 88px; }
|
.wk-page { padding: var(--s5) var(--s5) 88px; }
|
||||||
|
.wk-focus {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.wk-focus-cta { width: 100%; justify-content: center; }
|
||||||
.wk-event-name { font-size: 20px; }
|
.wk-event-name { font-size: 20px; }
|
||||||
.wk-sessions-title { font-size: 22px; }
|
.wk-sessions-title { font-size: 22px; }
|
||||||
.wk-count-unit b { font-size: 32px; }
|
.wk-count-unit b { font-size: 32px; }
|
||||||
|
|||||||
@@ -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 { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
@@ -9,21 +9,38 @@ import {
|
|||||||
createRoute,
|
createRoute,
|
||||||
} from '@tanstack/react-router'
|
} from '@tanstack/react-router'
|
||||||
import { RaceHubPage } from '../pages/RaceHubPage'
|
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', () => ({
|
vi.mock('../api', () => ({
|
||||||
fetchRaceHub: vi.fn(),
|
fetchRaceHub: vi.fn(),
|
||||||
fetchSeasons: vi.fn(),
|
fetchSeasons: vi.fn(),
|
||||||
fetchLocalMeetings: vi.fn(),
|
fetchLocalMeetings: vi.fn(),
|
||||||
fetchWeekend: 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 mockFetchRaceHub = vi.mocked(fetchRaceHub)
|
||||||
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
||||||
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
|
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
|
||||||
const mockFetchWeekend = vi.mocked(fetchWeekend)
|
const mockFetchWeekend = vi.mocked(fetchWeekend)
|
||||||
|
const mockFetchWeekendContext = vi.mocked(fetchWeekendContext)
|
||||||
|
|
||||||
|
const NOW = new Date('2025-06-01T00:00:00Z')
|
||||||
|
|
||||||
const meeting: Meeting = {
|
const meeting: Meeting = {
|
||||||
meeting_key: 1229,
|
meeting_key: 1229,
|
||||||
@@ -59,6 +76,16 @@ const qualSession: Session = {
|
|||||||
gmt_offset: '02:00:00',
|
gmt_offset: '02:00:00',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> = {
|
const fullDatasets: Record<string, DatasetInfo> = {
|
||||||
meeting: { status: 'available', source: 'local', count: 1 },
|
meeting: { status: 'available', source: 'local', count: 1 },
|
||||||
session: { status: 'available', source: 'local', count: 1 },
|
session: { status: 'available', source: 'local', count: 1 },
|
||||||
@@ -169,6 +196,41 @@ const weekend: Weekend = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function renderRaceHub(sessionKey: number) {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: false } },
|
defaultOptions: { queries: { retry: false } },
|
||||||
@@ -181,6 +243,19 @@ function renderRaceHub(sessionKey: number) {
|
|||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
const weekendRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/',
|
||||||
|
validateSearch: (search: Record<string, unknown>) => {
|
||||||
|
const meetingKey = Number(search.meeting_key)
|
||||||
|
const sk = Number(search.session_key)
|
||||||
|
return {
|
||||||
|
...(Number.isFinite(meetingKey) && meetingKey > 0 ? { meeting_key: meetingKey } : {}),
|
||||||
|
...(Number.isFinite(sk) && sk > 0 ? { session_key: sk } : {}),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: () => <div data-testid="weekend-stub" />,
|
||||||
|
})
|
||||||
const raceHubRoute = createRoute({
|
const raceHubRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/race-hub',
|
path: '/race-hub',
|
||||||
@@ -194,11 +269,10 @@ function renderRaceHub(sessionKey: number) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
routeTree: rootRoute.addChildren([raceHubRoute]),
|
routeTree: rootRoute.addChildren([weekendRoute, raceHubRoute]),
|
||||||
history: undefined,
|
history: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Navigate to the URL before mounting
|
|
||||||
router.navigate({ to: '/race-hub', search: sessionKey ? { session_key: sessionKey } : {} })
|
router.navigate({ to: '/race-hub', search: sessionKey ? { session_key: sessionKey } : {} })
|
||||||
return render(<RouterProvider router={router} />)
|
return render(<RouterProvider router={router} />)
|
||||||
}
|
}
|
||||||
@@ -206,10 +280,17 @@ function renderRaceHub(sessionKey: number) {
|
|||||||
describe('RaceHubPage', () => {
|
describe('RaceHubPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||||
|
vi.setSystemTime(NOW)
|
||||||
mockFetchSeasons.mockResolvedValue([2025])
|
mockFetchSeasons.mockResolvedValue([2025])
|
||||||
mockFetchLocalMeetings.mockResolvedValue([meeting])
|
mockFetchLocalMeetings.mockResolvedValue([meeting])
|
||||||
mockFetchWeekend.mockResolvedValue(weekend)
|
mockFetchWeekend.mockResolvedValue(weekend)
|
||||||
mockFetchRaceHub.mockResolvedValue(raceHub)
|
mockFetchRaceHub.mockResolvedValue(raceHub)
|
||||||
|
mockFetchWeekendContext.mockResolvedValue(weekendContext)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the workspace identity band, session rail, and overview for a known session', async () => {
|
it('renders the workspace identity band, session rail, and overview for a known session', async () => {
|
||||||
@@ -224,9 +305,9 @@ describe('RaceHubPage', () => {
|
|||||||
)
|
)
|
||||||
expect(screen.getByTestId('rh-session-9471')).toBeInTheDocument()
|
expect(screen.getByTestId('rh-session-9471')).toBeInTheDocument()
|
||||||
|
|
||||||
// Overview is default
|
|
||||||
expect(screen.getByTestId('rh-overview')).toBeInTheDocument()
|
expect(screen.getByTestId('rh-overview')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Winner')).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 () => {
|
it('exposes Race Story sub-controls for classification, grid, and positions', async () => {
|
||||||
@@ -236,17 +317,43 @@ describe('RaceHubPage', () => {
|
|||||||
fireEvent.click(screen.getByRole('tab', { name: 'Race Story' }))
|
fireEvent.click(screen.getByRole('tab', { name: 'Race Story' }))
|
||||||
|
|
||||||
expect(screen.getByText('VER')).toBeInTheDocument()
|
expect(screen.getByText('VER')).toBeInTheDocument()
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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)
|
renderRaceHub(9472)
|
||||||
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
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.getByTestId('rh-data-status')).toBeInTheDocument()
|
||||||
expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument()
|
expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
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())
|
||||||
|
|
||||||
|
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()
|
||||||
|
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 () => {
|
it('toggles the inline weekend switcher', async () => {
|
||||||
@@ -256,4 +363,213 @@ describe('RaceHubPage', () => {
|
|||||||
fireEvent.click(screen.getByTestId('rh-switch-weekend'))
|
fireEvent.click(screen.getByTestId('rh-switch-weekend'))
|
||||||
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
|
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockFetchRaceHub).toHaveBeenCalledWith(9471))
|
||||||
|
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,
|
||||||
|
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()
|
||||||
|
expect(screen.queryByTestId('rh-overview')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Winner')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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: [
|
||||||
|
{ session: qualSession, source: 'local', datasets: fullDatasets },
|
||||||
|
{ 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 () => {
|
||||||
|
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', expect.stringMatching(/meeting_key=1229/))
|
||||||
|
expect(back).toHaveAttribute('href', expect.stringMatching(/session_key=9472/))
|
||||||
|
expect(back.getAttribute('href')).toMatch(/^\//)
|
||||||
|
|
||||||
|
mockFetchRaceHub.mockResolvedValue(raceHub)
|
||||||
|
fireEvent.click(screen.getByTestId('rh-retry'))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exposes a Back to Weekend link that carries meeting and session context', async () => {
|
||||||
|
renderRaceHub(9472)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
||||||
|
const back = screen.getByRole('link', { name: 'Back to Weekend' })
|
||||||
|
expect(back).toHaveAttribute('data-testid', 'rh-back-weekend')
|
||||||
|
expect(back).toHaveAttribute('href', expect.stringMatching(/meeting_key=1229/))
|
||||||
|
expect(back).toHaveAttribute('href', expect.stringMatching(/session_key=9472/))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react'
|
|||||||
import { TabBar } from '../components/TabBar'
|
import { TabBar } from '../components/TabBar'
|
||||||
|
|
||||||
describe('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={() => {}} />)
|
render(<TabBar active="overview" onChange={() => {}} />)
|
||||||
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Race Story' })).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: 'Lap Data' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Conditions' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Conditions' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Race Control' })).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', () => {
|
it('marks the active tab with aria-selected', () => {
|
||||||
|
|||||||
@@ -20,12 +20,14 @@ import type {
|
|||||||
TemporalState,
|
TemporalState,
|
||||||
WeekendContext,
|
WeekendContext,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
import { parseWeekendSearch } from '../lib/routeSearch'
|
||||||
|
|
||||||
vi.mock('../api', () => ({
|
vi.mock('../api', () => ({
|
||||||
fetchWeekendContext: vi.fn(),
|
fetchWeekendContext: vi.fn(),
|
||||||
fetchChampionshipHub: vi.fn(),
|
fetchChampionshipHub: vi.fn(),
|
||||||
fetchNews: vi.fn(),
|
fetchNews: vi.fn(),
|
||||||
fetchRaceHub: vi.fn(),
|
fetchRaceHub: vi.fn(),
|
||||||
|
fetchWeekend: vi.fn(),
|
||||||
// Consumed transitively by RacePreviewPage (folded into PreSessionView):
|
// Consumed transitively by RacePreviewPage (folded into PreSessionView):
|
||||||
fetchSeasons: vi.fn(),
|
fetchSeasons: vi.fn(),
|
||||||
fetchMeetings: vi.fn(),
|
fetchMeetings: vi.fn(),
|
||||||
@@ -40,6 +42,7 @@ import {
|
|||||||
fetchChampionshipHub,
|
fetchChampionshipHub,
|
||||||
fetchNews,
|
fetchNews,
|
||||||
fetchRaceHub,
|
fetchRaceHub,
|
||||||
|
fetchWeekend,
|
||||||
fetchSeasons,
|
fetchSeasons,
|
||||||
fetchMeetings,
|
fetchMeetings,
|
||||||
fetchSessions,
|
fetchSessions,
|
||||||
@@ -52,6 +55,7 @@ const mockContext = vi.mocked(fetchWeekendContext)
|
|||||||
const mockHub = vi.mocked(fetchChampionshipHub)
|
const mockHub = vi.mocked(fetchChampionshipHub)
|
||||||
const mockNews = vi.mocked(fetchNews)
|
const mockNews = vi.mocked(fetchNews)
|
||||||
const mockRaceHub = vi.mocked(fetchRaceHub)
|
const mockRaceHub = vi.mocked(fetchRaceHub)
|
||||||
|
const mockWeekend = vi.mocked(fetchWeekend)
|
||||||
const mockSeasons = vi.mocked(fetchSeasons)
|
const mockSeasons = vi.mocked(fetchSeasons)
|
||||||
const mockMeetings = vi.mocked(fetchMeetings)
|
const mockMeetings = vi.mocked(fetchMeetings)
|
||||||
const mockSessions = vi.mocked(fetchSessions)
|
const mockSessions = vi.mocked(fetchSessions)
|
||||||
@@ -152,7 +156,15 @@ function renderAt(path: string) {
|
|||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
const homeRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: () => <WeekendPage /> })
|
const homeRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/',
|
||||||
|
validateSearch: (s: Record<string, unknown>) => parseWeekendSearch(s),
|
||||||
|
component: function HomeRoute() {
|
||||||
|
const { meeting_key, session_key } = homeRoute.useSearch()
|
||||||
|
return <WeekendPage focusMeetingKey={meeting_key} focusSessionKey={session_key} />
|
||||||
|
},
|
||||||
|
})
|
||||||
const previewRoute = createRoute({
|
const previewRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/preview',
|
path: '/preview',
|
||||||
@@ -192,6 +204,19 @@ describe('WeekendPage canonical contract rendering', () => {
|
|||||||
mockHub.mockResolvedValue(hub)
|
mockHub.mockResolvedValue(hub)
|
||||||
mockNews.mockResolvedValue([])
|
mockNews.mockResolvedValue([])
|
||||||
mockRaceHub.mockResolvedValue(raceHub)
|
mockRaceHub.mockResolvedValue(raceHub)
|
||||||
|
mockWeekend.mockResolvedValue({
|
||||||
|
source: 'local',
|
||||||
|
meeting_key: 1,
|
||||||
|
meeting: meeting(),
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
session: session({ session_key: 11, session_name: 'Race' }),
|
||||||
|
source: 'local',
|
||||||
|
datasets: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
default_session_key: 11,
|
||||||
|
})
|
||||||
mockSeasons.mockResolvedValue([2026])
|
mockSeasons.mockResolvedValue([2026])
|
||||||
mockMeetings.mockResolvedValue([meeting()])
|
mockMeetings.mockResolvedValue([meeting()])
|
||||||
mockSessions.mockResolvedValue([session()])
|
mockSessions.mockResolvedValue([session()])
|
||||||
@@ -306,4 +331,27 @@ describe('WeekendPage canonical contract rendering', () => {
|
|||||||
// The between-races surface must NOT be what /preview renders.
|
// The between-races surface must NOT be what /preview renders.
|
||||||
expect(screen.queryByTestId('weekend-between-races')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('weekend-between-races')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('restores Race Hub meeting/session focus from the Weekend URL search contract', async () => {
|
||||||
|
mockContext.mockResolvedValue(
|
||||||
|
context({
|
||||||
|
temporal_state: 'season_complete',
|
||||||
|
previous_completed_session: ctxSession({ session: session({ session_key: 11, session_name: 'Race' }) }),
|
||||||
|
default_analysis_session: ctxSession({ session: session({ session_key: 11 }) }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
renderAt('/?meeting_key=1&session_key=11')
|
||||||
|
await waitFor(() => expect(screen.getByTestId('wk-focus-context')).toBeInTheDocument())
|
||||||
|
expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-meeting-key', '1')
|
||||||
|
expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-session-key', '11')
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId('wk-focus-meeting')).toHaveTextContent('British Grand Prix'),
|
||||||
|
)
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId('wk-focus-session')).toHaveTextContent('Race'),
|
||||||
|
)
|
||||||
|
const continueAnalysis = screen.getByRole('link', { name: /Continue analysis/i })
|
||||||
|
expect(continueAnalysis).toHaveAttribute('href', expect.stringContaining('session_key=11'))
|
||||||
|
expect(mockWeekend).toHaveBeenCalledWith(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
24
frontend/src/test/routeSearch.test.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { parseRaceHubSearch, parseWeekendSearch, weekendFocusSearch } from '../lib/routeSearch'
|
||||||
|
|
||||||
|
describe('routeSearch', () => {
|
||||||
|
it('parses positive weekend meeting/session keys and drops invalid values', () => {
|
||||||
|
expect(parseWeekendSearch({ meeting_key: '1229', session_key: '9000' })).toEqual({
|
||||||
|
meeting_key: 1229,
|
||||||
|
session_key: 9000,
|
||||||
|
})
|
||||||
|
expect(parseWeekendSearch({ meeting_key: '0', session_key: '-1', foo: 'bar' })).toEqual({})
|
||||||
|
expect(parseWeekendSearch({})).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses race-hub session keys', () => {
|
||||||
|
expect(parseRaceHubSearch({ session_key: '9472' })).toEqual({ session_key: 9472 })
|
||||||
|
expect(parseRaceHubSearch({ session_key: 'nope' })).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds Weekend focus search for Race Hub returns', () => {
|
||||||
|
expect(weekendFocusSearch(1229, 9000)).toEqual({ meeting_key: 1229, session_key: 9000 })
|
||||||
|
expect(weekendFocusSearch(null, 9000)).toEqual({ session_key: 9000 })
|
||||||
|
expect(weekendFocusSearch(1229, undefined)).toEqual({ meeting_key: 1229 })
|
||||||
|
})
|
||||||
|
})
|
||||||
146
frontend/src/test/sessionState.test.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
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')
|
||||||
|
|
||||||
|
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 }]),
|
||||||
|
)
|
||||||
|
|
||||||
|
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('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 }, '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', () => {
|
||||||
|
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 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')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses user language labels rather than coverage counts', () => {
|
||||||
|
expect(sessionStateLabel('ready')).toBe('Ready')
|
||||||
|
expect(sessionStateLabel('upcoming')).toBe('Upcoming')
|
||||||
|
expect(sessionStateLabel('partial')).toBe('Partial')
|
||||||
|
expect(sessionStateLabel('unavailable')).toBe('Unavailable')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -19,6 +19,8 @@ type WeekendSession struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Weekend is the local-first read model for one race weekend.
|
// 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 {
|
type Weekend struct {
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
MeetingKey int `json:"meeting_key"`
|
MeetingKey int `json:"meeting_key"`
|
||||||
|
|||||||
@@ -84,14 +84,20 @@ func seedMeeting(st *store.Store, meetingKey int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func seedSession(st *store.Store, sessionKey, meetingKey int, name string) 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{
|
return st.UpsertSession(store.Session{
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
MeetingKey: meetingKey,
|
MeetingKey: meetingKey,
|
||||||
SessionName: name,
|
SessionName: name,
|
||||||
SessionType: "Race",
|
SessionType: "Race",
|
||||||
CircuitKey: 10,
|
CircuitKey: 10,
|
||||||
DateStart: "2025-05-25T13:00:00+00:00",
|
DateStart: start,
|
||||||
DateEnd: "2025-05-25T15:00:00+00:00",
|
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,4 +1,5 @@
|
|||||||
import { test, expect } from '@playwright/test'
|
import { test, expect } from '@playwright/test'
|
||||||
|
import { FUTURE_SESSION, mockFutureRaceHubSession } from './fixtures/future-session'
|
||||||
|
|
||||||
const FULL_SESSION = 9472
|
const FULL_SESSION = 9472
|
||||||
const CORE_ONLY_SESSION = 9000
|
const CORE_ONLY_SESSION = 9000
|
||||||
@@ -15,6 +16,7 @@ test.describe('Race Hub Weekend Workspace', () => {
|
|||||||
'aria-selected',
|
'aria-selected',
|
||||||
'true',
|
'true',
|
||||||
)
|
)
|
||||||
|
await expect(page.getByText('Local Coverage')).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('shows final running order when switching to Race Story', async ({ page }) => {
|
test('shows final running order when switching to Race Story', async ({ page }) => {
|
||||||
@@ -87,24 +89,110 @@ test.describe('Race Hub Weekend Workspace', () => {
|
|||||||
await page.getByTestId('rh-switch-weekend').click()
|
await page.getByTestId('rh-switch-weekend').click()
|
||||||
|
|
||||||
await expect(page.getByTestId('rh-switcher')).toBeVisible()
|
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()
|
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.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.getByTestId('rh-data-status')).toBeVisible()
|
||||||
await expect(page.getByRole('link', { name: /manage ingestion/i })).toHaveAttribute(
|
await expect(page.getByRole('link', { name: /manage ingestion/i })).toHaveAttribute(
|
||||||
'href',
|
'href',
|
||||||
'/admin',
|
'/admin',
|
||||||
)
|
)
|
||||||
|
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 via Weekend Context', async ({ page }) => {
|
||||||
await page.goto('/race-hub')
|
await page.goto('/race-hub')
|
||||||
await expect(page).toHaveURL(/session_key=\d+/)
|
await expect(page).toHaveURL(/session_key=\d+/)
|
||||||
await expect(page.getByTestId('race-hub')).toBeVisible()
|
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||||
|
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 }) => {
|
||||||
|
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 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 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()
|
||||||
|
|
||||||
|
// 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')
|
||||||
|
|
||||||
|
// Explicit Race Hub → Weekend return carries meeting/session in the URL.
|
||||||
|
const back = page.getByRole('link', { name: 'Back to Weekend' })
|
||||||
|
await expect(back).toBeVisible()
|
||||||
|
await expect(back).toHaveAttribute('href', /meeting_key=1229/)
|
||||||
|
await expect(back).toHaveAttribute('href', /session_key=9000/)
|
||||||
|
await back.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/\?.*meeting_key=1229/)
|
||||||
|
await expect(page).toHaveURL(/session_key=9000/)
|
||||||
|
await expect(page.getByTestId('weekend-page')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('weekend-page')).toHaveAttribute('data-meeting-key', '1229')
|
||||||
|
await expect(page.getByTestId('weekend-page')).toHaveAttribute('data-session-key', '9000')
|
||||||
|
await expect(page.getByTestId('wk-focus-context')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('wk-focus-meeting')).toContainText('Monaco')
|
||||||
|
await expect(page.getByTestId('wk-focus-session')).toContainText('Core Only')
|
||||||
|
|
||||||
|
// Contextual action returns to the exact selected analysis session.
|
||||||
|
const continueAnalysis = page.getByRole('link', { name: /Continue analysis/i })
|
||||||
|
await expect(continueAnalysis).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
new RegExp(`session_key=${CORE_ONLY_SESSION}`),
|
||||||
|
)
|
||||||
|
await continueAnalysis.click()
|
||||||
|
await expect(page).toHaveURL(new RegExp(`session_key=${CORE_ONLY_SESSION}`))
|
||||||
|
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('rh-identity')).toContainText('Monaco')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Back to Weekend is keyboard-focusable and works at mobile width', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 })
|
||||||
|
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
|
||||||
|
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||||
|
|
||||||
|
const back = page.getByRole('link', { name: 'Back to Weekend' })
|
||||||
|
await expect(back).toBeVisible()
|
||||||
|
await back.focus()
|
||||||
|
await expect(back).toBeFocused()
|
||||||
|
await page.keyboard.press('Enter')
|
||||||
|
|
||||||
|
await expect(page.getByTestId('weekend-page')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('weekend-page')).toHaveAttribute('data-meeting-key', '1229')
|
||||||
|
await expect(page.getByTestId('weekend-page')).toHaveAttribute('data-session-key', '9000')
|
||||||
|
await expect(page.getByTestId('wk-focus-context')).toBeVisible()
|
||||||
|
await expect(page.getByRole('link', { name: /Continue analysis/i })).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
BIN
tests/visual/__snapshots__/desktop/race-hub-future.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
BIN
tests/visual/__snapshots__/mobile/race-hub-future.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 44 KiB |
BIN
tests/visual/__snapshots__/tablet/race-hub-future.png
Normal file
|
After Width: | Height: | Size: 49 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 { expect, type Locator, type Page } from '@playwright/test'
|
||||||
|
import { FUTURE_SESSION, mockFutureRaceHubSession } from '../fixtures/future-session'
|
||||||
|
|
||||||
export const VIEWPORTS = {
|
export const VIEWPORTS = {
|
||||||
desktop: { width: 1280, height: 800 },
|
desktop: { width: 1280, height: 800 },
|
||||||
@@ -7,6 +8,7 @@ export const VIEWPORTS = {
|
|||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const FULL_SESSION = 9472
|
export const FULL_SESSION = 9472
|
||||||
|
export { FUTURE_SESSION }
|
||||||
|
|
||||||
/** Wait for web fonts and layout to settle before screenshots. */
|
/** Wait for web fonts and layout to settle before screenshots. */
|
||||||
export async function waitForScreenshotReady(page: Page): Promise<void> {
|
export async function waitForScreenshotReady(page: Page): Promise<void> {
|
||||||
@@ -33,6 +35,17 @@ export async function gotoRaceHubReady(page: Page, sessionKey = FULL_SESSION): P
|
|||||||
await waitForScreenshotReady(page)
|
await waitForScreenshotReady(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
await waitForScreenshotReady(page)
|
||||||
|
}
|
||||||
|
|
||||||
export async function gotoRaceStoryReady(page: Page, sessionKey = FULL_SESSION): Promise<void> {
|
export async function gotoRaceStoryReady(page: Page, sessionKey = FULL_SESSION): Promise<void> {
|
||||||
await page.goto(`/race-hub?session_key=${sessionKey}`)
|
await page.goto(`/race-hub?session_key=${sessionKey}`)
|
||||||
await expect(page.getByTestId('race-hub')).toBeVisible()
|
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
gotoWeekendReady,
|
gotoWeekendReady,
|
||||||
gotoDataLibraryReady,
|
gotoDataLibraryReady,
|
||||||
gotoLiveEmptyReady,
|
gotoLiveEmptyReady,
|
||||||
|
gotoRaceHubFutureReady,
|
||||||
gotoRaceHubReady,
|
gotoRaceHubReady,
|
||||||
screenshotPage,
|
screenshotPage,
|
||||||
} from './helpers'
|
} from './helpers'
|
||||||
@@ -18,6 +19,14 @@ test.describe('MVP visual regression', () => {
|
|||||||
await screenshotPage(page, 'race-hub')
|
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 }) => {
|
test('data-library', async ({ page }) => {
|
||||||
await gotoDataLibraryReady(page)
|
await gotoDataLibraryReady(page)
|
||||||
await screenshotPage(page, 'data-library')
|
await screenshotPage(page, 'data-library')
|
||||||
|
|||||||