Compare commits
11 Commits
c475011c49
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
469028b07a | ||
|
|
5d5987cc7e | ||
|
|
7c98489b91 | ||
|
|
84a9a3579f | ||
|
|
07e5857760 | ||
|
|
06223d992f | ||
|
|
ba55945b6f | ||
|
|
643af2355a | ||
|
|
45084558cd | ||
|
|
90c24ad6aa | ||
|
|
e028e3e3fe |
@@ -25,7 +25,8 @@ EOF
|
||||
|
||||
harness_claude() { # Claude Code — print mode, auto-accept edits
|
||||
local dir="$1" prompt="$2"
|
||||
( cd "$dir" && claude -p "$(cat "$prompt")" --permission-mode acceptEdits )
|
||||
local model="${BOXBOX_CLAUDE_MODEL:-opus}"
|
||||
( cd "$dir" && claude -p "$(cat "$prompt")" --model "$model" --effort high --permission-mode acceptEdits )
|
||||
}
|
||||
|
||||
harness_codex() { # OpenAI Codex CLI — non-interactive exec, full auto
|
||||
@@ -40,7 +41,8 @@ harness_opencode() { # opencode — non-interactive run
|
||||
|
||||
harness_cursor() { # Cursor CLI agent — composer-2.5, headless full-auto
|
||||
local dir="$1" prompt="$2"
|
||||
( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --model composer-2.5 --force --trust )
|
||||
local model="${BOXBOX_CURSOR_MODEL:-composer-2.5}"
|
||||
( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --model "$model" --force --trust )
|
||||
}
|
||||
|
||||
harness_agy() { # Antigravity CLI — disabled until headless is verified
|
||||
|
||||
32
docs/product/v0.4.0/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# v0.4.0 — Current Weekend & Product Truth
|
||||
|
||||
Product research references for the v0.4.0 feature sprint.
|
||||
|
||||
## Direction
|
||||
|
||||
The sprint replaces route-first navigation with a state-aware Weekend experience:
|
||||
|
||||
- Weekend is the adaptive home for what happened, what is live, and what is next.
|
||||
- Preview content folds into Weekend before a session.
|
||||
- Live remains a stable deep link and becomes Weekend's active-session state.
|
||||
- Race Hub remains explicit completed-session analysis rather than a primary landing destination.
|
||||
- Championship and Briefing remain dedicated destinations.
|
||||
- Explore owns secondary discovery; Admin moves to operator utility.
|
||||
|
||||
## Mockups
|
||||
|
||||
- `mockups/weekend-between-races.png` — desktop between-races/post-weekend state.
|
||||
- `mockups/weekend-live.png` — desktop active-session state; the circuit is static sector context, not live GPS.
|
||||
- `mockups/weekend-between-sessions-mobile.png` — 390×844 between-session state.
|
||||
|
||||
These are directional references, not pixel-perfect specifications. Implementations must preserve the established box-box visual language, accessibility, data constraints, and responsive behavior while satisfying their issue acceptance criteria.
|
||||
|
||||
## Constraints
|
||||
|
||||
- No OpenF1 REST dependency during active sessions.
|
||||
- Public live GPS is not assumed to be available.
|
||||
- Championship round numbers exclude tests and cancelled meetings.
|
||||
- Connection health, live-session state, archive availability, and local-analysis readiness are separate concepts.
|
||||
- Future sessions must not render empty post-session analysis.
|
||||
|
||||
The authoritative product decisions and research packet are recorded in GitHub issue #71 under epic #70.
|
||||
BIN
docs/product/v0.4.0/mockups/weekend-between-races.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/product/v0.4.0/mockups/weekend-between-sessions-mobile.png
Normal file
|
After Width: | Height: | Size: 366 KiB |
BIN
docs/product/v0.4.0/mockups/weekend-live.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
@@ -15,6 +15,7 @@ import type {
|
||||
Session,
|
||||
TrackOutline,
|
||||
Weekend,
|
||||
WeekendContext,
|
||||
} from './types'
|
||||
|
||||
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
|
||||
@@ -114,6 +115,20 @@ export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// fetchWeekendContext consumes the canonical /api/v1/weekend-context endpoint
|
||||
// (backend story #72). The response is the authoritative WeekendContext shape and
|
||||
// is used verbatim as the Weekend home's source of truth. Any HTTP error throws
|
||||
// so the hook can surface an explicit error state; there is no client-side
|
||||
// re-derivation of the contract. Race Hub bare-default landing also reads this
|
||||
// for `default_analysis_session` (#75).
|
||||
export async function fetchWeekendContext(): Promise<WeekendContext> {
|
||||
const res = await fetch('/api/v1/weekend-context')
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchChampionshipHub(year?: number): Promise<ChampionshipHub> {
|
||||
const params = new URLSearchParams({ source: 'auto' })
|
||||
if (year) params.set('year', year.toString())
|
||||
|
||||
@@ -1,36 +1,66 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Compass, FileText, Home, Trophy } from 'lucide-react'
|
||||
|
||||
const PRIMARY = [
|
||||
{ to: '/', label: 'Weekend', icon: Home, exact: true },
|
||||
{ to: '/championship', label: 'Championship', icon: Trophy, exact: false },
|
||||
{ to: '/briefing', label: 'Briefing', icon: FileText, exact: false },
|
||||
{ to: '/explore', label: 'Explore', icon: Compass, exact: false },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Nav renders one primary navigation system per breakpoint:
|
||||
* - Desktop/tablet: the top bar's `aria-label="Primary"` links.
|
||||
* - Mobile (≤640px): the bottom `aria-label="Primary"` bar; the top bar's links
|
||||
* are hidden via CSS so the two are never both active at once.
|
||||
*
|
||||
* Admin is an operator utility, deliberately outside every Primary landmark — it
|
||||
* lives in a plain toolbar slot and never appears in the mobile bottom nav.
|
||||
*/
|
||||
export function Nav() {
|
||||
return (
|
||||
<nav className="app-nav">
|
||||
<Link to="/" className="nav-logo">
|
||||
box<em>-</em>box
|
||||
</Link>
|
||||
<div className="nav-links">
|
||||
<Link to="/" activeProps={{ className: 'active' }} activeOptions={{ exact: true }}>
|
||||
Command
|
||||
<>
|
||||
<header className="app-nav">
|
||||
<Link to="/" className="nav-logo">
|
||||
box<em>-</em>box
|
||||
</Link>
|
||||
<Link to="/live" activeProps={{ className: 'active' }}>
|
||||
Live
|
||||
</Link>
|
||||
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
||||
Race Hub
|
||||
</Link>
|
||||
<Link to="/preview" activeProps={{ className: 'active' }}>
|
||||
Preview
|
||||
</Link>
|
||||
<Link to="/championship" activeProps={{ className: 'active' }}>
|
||||
Championship
|
||||
</Link>
|
||||
<Link to="/briefing" activeProps={{ className: 'active' }}>
|
||||
Briefing
|
||||
</Link>
|
||||
</div>
|
||||
<div className="nav-utility">
|
||||
<Link to="/admin" className="nav-utility-link" activeProps={{ className: 'nav-utility-link active' }}>
|
||||
Admin
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
<nav className="nav-links" aria-label="Primary">
|
||||
{PRIMARY.map(({ to, label, exact }) => (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
activeProps={{ className: 'active' }}
|
||||
activeOptions={exact ? { exact: true } : undefined}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="nav-utility" role="toolbar" aria-label="Operator utilities">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="nav-utility-link"
|
||||
activeProps={{ className: 'nav-utility-link active' }}
|
||||
>
|
||||
Admin
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="app-bottom-nav" aria-label="Primary">
|
||||
{PRIMARY.map(({ to, label, icon: Icon, exact }) => (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
className="bottom-nav-link"
|
||||
activeProps={{ className: 'bottom-nav-link active' }}
|
||||
activeOptions={exact ? { exact: true } : undefined}
|
||||
>
|
||||
<Icon size={18} aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { RaceHub } from '../types'
|
||||
import { compareFinishPosition, formatDuration, formatGap, formatLapTime } from '../utils'
|
||||
import { countRaceHubDatasets } from '../lib/coverage'
|
||||
import { Thermometer, Map, Droplets, Wind, CloudRain } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
@@ -17,7 +16,6 @@ export function OverviewView({ data }: Props) {
|
||||
const fastest = pickFastestLap(data)
|
||||
const latestWeather = data.weather.length > 0 ? data.weather[data.weather.length - 1] : null
|
||||
const rcHighlights = data.race_control.slice(-3).reverse()
|
||||
const coverage = countRaceHubDatasets(data.datasets)
|
||||
|
||||
const sessionType = (data.session?.session_type ?? '').toLowerCase()
|
||||
const isRace = sessionType.includes('race')
|
||||
@@ -129,28 +127,6 @@ export function OverviewView({ data }: Props) {
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rh-panel ui-card">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Local Coverage</span>
|
||||
<span className="sec-meta mono">
|
||||
{coverage.available}/{coverage.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rh-coverage-meter" aria-hidden="true">
|
||||
<div
|
||||
className="rh-coverage-fill"
|
||||
style={{ width: `${(coverage.available / coverage.total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="rh-empty-line" style={{ marginTop: 'var(--s2)' }}>
|
||||
{coverage.available === coverage.total
|
||||
? 'Every Race Hub dataset is local for this session.'
|
||||
: `${coverage.total - coverage.available} dataset${
|
||||
coverage.total - coverage.available === 1 ? '' : 's'
|
||||
} not ingested yet — see Data Status tab.`}
|
||||
</div>
|
||||
</section>
|
||||
</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'
|
||||
| 'data_status'
|
||||
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'race_story', label: 'Race Story' },
|
||||
{ id: 'strategy', label: 'Strategy' },
|
||||
{ id: 'compare', label: 'Compare' },
|
||||
{ id: 'lap_data', label: 'Lap Data' },
|
||||
{ id: 'conditions', label: 'Conditions' },
|
||||
{ id: 'race_control', label: 'Race Control' },
|
||||
{ id: 'data_status', label: 'Data Status' },
|
||||
interface TabDef {
|
||||
id: Tab
|
||||
label: string
|
||||
}
|
||||
|
||||
interface TabGroup {
|
||||
id: string
|
||||
label: string
|
||||
tabs: TabDef[]
|
||||
}
|
||||
|
||||
// Fan-facing hierarchy: Story first, then Analysis, then Data/Context. Every
|
||||
// existing capability is preserved — only the grouping and ordering change.
|
||||
const TAB_GROUPS: TabGroup[] = [
|
||||
{
|
||||
id: 'story',
|
||||
label: 'Story',
|
||||
tabs: [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'race_story', label: 'Race Story' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'analysis',
|
||||
label: 'Analysis',
|
||||
tabs: [
|
||||
{ id: 'strategy', label: 'Strategy' },
|
||||
{ id: 'compare', label: 'Compare' },
|
||||
{ id: 'lap_data', label: 'Lap Data' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'context',
|
||||
label: 'Data & Context',
|
||||
tabs: [
|
||||
{ id: 'conditions', label: 'Conditions' },
|
||||
{ id: 'race_control', label: 'Race Control' },
|
||||
{ id: 'data_status', label: 'Diagnostics' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
interface Props {
|
||||
@@ -26,17 +57,26 @@ interface Props {
|
||||
|
||||
export function TabBar({ active, onChange }: Props) {
|
||||
return (
|
||||
<div className="tab-bar" role="tablist">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
role="tab"
|
||||
aria-selected={active === t.id}
|
||||
className={`tab-btn${active === t.id ? ' active' : ''}`}
|
||||
onClick={() => onChange(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
<div className="tab-bar tab-bar-grouped" role="tablist" data-testid="rh-tabbar">
|
||||
{TAB_GROUPS.map((group) => (
|
||||
<div key={group.id} className="tab-group" data-testid={`rh-tabgroup-${group.id}`}>
|
||||
<span className="tab-group-label mono" aria-hidden="true">
|
||||
{group.label}
|
||||
</span>
|
||||
<div className="tab-group-btns">
|
||||
{group.tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
role="tab"
|
||||
aria-selected={active === t.id}
|
||||
className={`tab-btn${active === t.id ? ' active' : ''}`}
|
||||
onClick={() => onChange(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,34 +2,37 @@ import { useEffect, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
|
||||
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
|
||||
import { sessionTypeAbbrev } from '../lib/coverage'
|
||||
import {
|
||||
resolveSessionState,
|
||||
sessionStateDotClass,
|
||||
sessionStateLabel,
|
||||
} from '../lib/sessionState'
|
||||
import { countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
||||
import type { WeekendContext } from '../types'
|
||||
|
||||
interface Props {
|
||||
currentMeetingKey?: number
|
||||
currentSessionKey?: number
|
||||
context?: WeekendContext | null
|
||||
now?: Date
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose }: Props) {
|
||||
export function WeekendSwitcher({
|
||||
currentMeetingKey,
|
||||
currentSessionKey,
|
||||
context,
|
||||
now: nowProp,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const navigate = useNavigate()
|
||||
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons })
|
||||
const [year, setYear] = useState<number | null>(null)
|
||||
const [openMeetingKey, setOpenMeetingKey] = useState<number | null>(
|
||||
currentMeetingKey ?? null,
|
||||
)
|
||||
|
||||
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 now = nowProp ?? new Date()
|
||||
|
||||
const weekendQuery = useQuery({
|
||||
queryKey: ['weekend', openMeetingKey],
|
||||
@@ -37,6 +40,22 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
|
||||
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 meetings = meetingsQuery.data ?? []
|
||||
const weekend = weekendQuery.data
|
||||
@@ -106,9 +125,16 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
|
||||
{weekendQuery.isLoading && (
|
||||
<div className="rh-switcher-empty">loading sessions…</div>
|
||||
)}
|
||||
{weekend && weekend.meeting_key === m.meeting_key &&
|
||||
weekend.sessions.map(({ session, source, datasets }) => {
|
||||
{weekend &&
|
||||
weekend.meeting_key === m.meeting_key &&
|
||||
weekend.sessions.map((weekendSession) => {
|
||||
const { session } = weekendSession
|
||||
const active = session.session_key === currentSessionKey
|
||||
const state = resolveSessionState({
|
||||
weekendSession,
|
||||
context,
|
||||
now,
|
||||
})
|
||||
return (
|
||||
<button
|
||||
key={session.session_key}
|
||||
@@ -122,8 +148,11 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
|
||||
</span>
|
||||
<span className="rh-switcher-sess-name">{session.session_name}</span>
|
||||
<span className="rh-switcher-sess-cov mono">
|
||||
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
|
||||
{formatCoverageHint(datasets)}
|
||||
<span
|
||||
className={`cc-cov-dot ${sessionStateDotClass(state)}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{sessionStateLabel(state)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
152
frontend/src/components/weekend/BetweenRacesView.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ChevronRight, Flag as FlagIcon } from 'lucide-react'
|
||||
import type {
|
||||
WeekendBriefingItem,
|
||||
WeekendChampionshipImpact,
|
||||
WeekendContext,
|
||||
WeekendViewState,
|
||||
} from '../../types'
|
||||
import {
|
||||
BriefingStrip,
|
||||
ChampionshipImpactCard,
|
||||
ChampionshipRoundStrip,
|
||||
CountdownDisplay,
|
||||
EventPodium,
|
||||
Flag,
|
||||
} from './shared'
|
||||
import { analysisSessionKey, meetingIdentity } from '../../lib/weekendContext'
|
||||
import { parseScheduleTime } from '../../lib/schedule'
|
||||
|
||||
const EYEBROW: Record<string, string> = {
|
||||
between_weekends: 'Between races',
|
||||
post_weekend: 'Post-weekend',
|
||||
season_complete: 'Season complete',
|
||||
}
|
||||
|
||||
function formatSessionLine(name: string | undefined, start: string | undefined): string {
|
||||
if (!name) return 'Schedule to be confirmed'
|
||||
const date = start ? parseScheduleTime(start) : null
|
||||
if (!date) return name
|
||||
const when = date.toLocaleString('en-GB', {
|
||||
weekday: 'long',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
return `${name} · ${when}`
|
||||
}
|
||||
|
||||
export function BetweenRacesView({
|
||||
context,
|
||||
now,
|
||||
view,
|
||||
championship,
|
||||
briefing,
|
||||
}: {
|
||||
context: WeekendContext
|
||||
now: Date
|
||||
view: WeekendViewState
|
||||
championship?: WeekendChampionshipImpact
|
||||
briefing: WeekendBriefingItem[]
|
||||
}) {
|
||||
const eyebrow = EYEBROW[view] ?? 'Between races'
|
||||
|
||||
const previous = context.previous_completed_session
|
||||
const previousMeeting = meetingIdentity(previous?.meeting ?? context.previous_meeting)
|
||||
const nextMeeting = meetingIdentity(context.next_meeting)
|
||||
const nextSession = context.next_session?.session
|
||||
const analysisKey = analysisSessionKey(context)
|
||||
|
||||
return (
|
||||
<div className="wk-between" data-testid="weekend-between-races" data-state={view}>
|
||||
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">{eyebrow}</div>
|
||||
|
||||
<div className="wk-top-grid">
|
||||
{previousMeeting && (
|
||||
<section className="wk-event-card wk-event-last" data-testid="wk-last-event">
|
||||
<header className="wk-event-head">
|
||||
<div className="wk-event-id">
|
||||
<Flag code={previousMeeting.country_code} flag={previousMeeting.country_flag} />
|
||||
<h2 className="wk-event-name">{previousMeeting.meeting_name}</h2>
|
||||
</div>
|
||||
<span className="wk-event-tag wk-tag-done mono">
|
||||
<FlagIcon size={13} aria-hidden="true" /> Completed
|
||||
</span>
|
||||
</header>
|
||||
<div className="wk-event-body">
|
||||
<EventPodium sessionKey={analysisKey} />
|
||||
<div className="wk-story-card">
|
||||
<span className="wk-story-label">What decided it?</span>
|
||||
{analysisKey ? (
|
||||
<Link
|
||||
to="/race-hub"
|
||||
search={{ session_key: analysisKey }}
|
||||
className="wk-cta wk-cta-primary"
|
||||
data-testid="wk-explore-race-story"
|
||||
>
|
||||
Explore Race Story <ChevronRight size={15} aria-hidden="true" />
|
||||
</Link>
|
||||
) : (
|
||||
<p className="wk-story-text" data-testid="wk-no-analysis">
|
||||
Analysis for this session is not available locally yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{nextMeeting ? (
|
||||
<section className="wk-event-card wk-event-next" data-testid="wk-next-event">
|
||||
<header className="wk-event-head">
|
||||
<div className="wk-event-id">
|
||||
<Flag code={nextMeeting.country_code} flag={nextMeeting.country_flag} />
|
||||
<h2 className="wk-event-name">{nextMeeting.meeting_name}</h2>
|
||||
</div>
|
||||
<span className="wk-event-tag wk-tag-next mono">Next event</span>
|
||||
</header>
|
||||
<div className="wk-next-body">
|
||||
<CountdownDisplay target={nextSession?.date_start ?? nextMeeting.date_start} now={now} />
|
||||
<div className="wk-next-session">
|
||||
<span className="wk-next-label mono">Next session</span>
|
||||
<span className="wk-next-value">
|
||||
{formatSessionLine(nextSession?.session_name, nextSession?.date_start)}
|
||||
</span>
|
||||
</div>
|
||||
<Link to="/preview" className="wk-cta wk-cta-primary wk-cta-wide" data-testid="wk-prepare">
|
||||
Prepare for {nextMeeting.short_name}
|
||||
<ChevronRight size={15} aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="wk-event-card wk-event-next" data-testid="wk-season-complete-card">
|
||||
<header className="wk-event-head">
|
||||
<h2 className="wk-event-name">That's a wrap</h2>
|
||||
<span className="wk-event-tag wk-tag-next mono">Off-season</span>
|
||||
</header>
|
||||
<div className="wk-next-body">
|
||||
<p className="wk-season-complete-copy">
|
||||
The {context.season} calendar is complete. Explore the season's races or revisit the championship
|
||||
battle while the next schedule is confirmed.
|
||||
</p>
|
||||
<Link to="/explore" className="wk-cta wk-cta-primary wk-cta-wide" data-testid="wk-season-complete-explore">
|
||||
Explore the season <ChevronRight size={15} aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="wk-mid-grid">
|
||||
{championship && <ChampionshipImpactCard impact={championship} />}
|
||||
<ChampionshipRoundStrip
|
||||
round={context.championship_round}
|
||||
total={context.total_championship_rounds}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{briefing.length > 0 && <BriefingStrip items={briefing} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
83
frontend/src/components/weekend/BetweenSessionsView.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import type { WeekendBriefingItem, WeekendContext, WeekendViewState } from '../../types'
|
||||
import { BriefingStrip, CountdownDisplay, EventPodium, SessionRail, railNodes } from './shared'
|
||||
import { analysisSessionKey, meetingIdentity } from '../../lib/weekendContext'
|
||||
import { parseScheduleTime } from '../../lib/schedule'
|
||||
|
||||
function nextSessionWhen(start: string | undefined): string {
|
||||
const date = start ? parseScheduleTime(start) : null
|
||||
if (!date) return 'Time to be confirmed'
|
||||
return date.toLocaleString('en-GB', {
|
||||
weekday: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function BetweenSessionsView({
|
||||
context,
|
||||
now,
|
||||
view,
|
||||
briefing,
|
||||
}: {
|
||||
context: WeekendContext
|
||||
now: Date
|
||||
view: WeekendViewState
|
||||
briefing: WeekendBriefingItem[]
|
||||
}) {
|
||||
const settling = view === 'session_settling'
|
||||
const focus = meetingIdentity(context.focus_meeting)
|
||||
const previous = context.previous_completed_session
|
||||
const previousName = previous?.session.session_name ?? 'Last session'
|
||||
const next = context.next_session?.session
|
||||
const analysisKey = analysisSessionKey(context)
|
||||
const nodes = railNodes(previous, context.active_session, context.next_session)
|
||||
|
||||
return (
|
||||
<div className="wk-sessions" data-testid="weekend-between-sessions" data-state={view}>
|
||||
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">
|
||||
{settling ? 'Session settling' : 'Between sessions'}
|
||||
</div>
|
||||
|
||||
<header className="wk-sessions-head">
|
||||
<h1 className="wk-sessions-title">{focus?.meeting_name ?? 'Race weekend'}</h1>
|
||||
{focus?.circuit_short_name && <p className="wk-sessions-circuit mono">{focus.circuit_short_name}</p>}
|
||||
</header>
|
||||
|
||||
<SessionRail nodes={nodes} />
|
||||
|
||||
{previous && (
|
||||
<section className="wk-recap-card" data-testid="wk-last-session">
|
||||
<span className="wk-recap-eyebrow mono">
|
||||
{previousName} · {settling ? 'Settling' : 'Complete'}
|
||||
</span>
|
||||
<h2 className="wk-recap-title">What happened in {previousName}</h2>
|
||||
<EventPodium sessionKey={analysisKey} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{next && (
|
||||
<section className="wk-upnext-card" data-testid="wk-next-session">
|
||||
<span className="wk-upnext-eyebrow mono">Up next</span>
|
||||
<h2 className="wk-upnext-title">{next.session_name}</h2>
|
||||
<CountdownDisplay target={next.date_start} now={now} compact />
|
||||
<p className="wk-upnext-when mono">{nextSessionWhen(next.date_start)}</p>
|
||||
{analysisKey && (
|
||||
<Link
|
||||
to="/race-hub"
|
||||
search={{ session_key: analysisKey }}
|
||||
className="wk-cta wk-cta-primary wk-cta-wide"
|
||||
data-testid="wk-view-recap"
|
||||
>
|
||||
View {previousName} recap <ChevronRight size={15} aria-hidden="true" />
|
||||
</Link>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{briefing.length > 0 && <BriefingStrip items={briefing} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
frontend/src/components/weekend/LiveHandoffView.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ChevronRight, Radio } from 'lucide-react'
|
||||
import type { WeekendContext } from '../../types'
|
||||
import { EventPodium, Flag, SessionRail, railNodes } from './shared'
|
||||
import { analysisSessionKey, meetingIdentity } from '../../lib/weekendContext'
|
||||
|
||||
export function LiveHandoffView({ context }: { context: WeekendContext }) {
|
||||
const focus = meetingIdentity(context.focus_meeting ?? context.active_session?.meeting)
|
||||
const active = context.active_session?.session
|
||||
const previous = context.previous_completed_session
|
||||
const previousName = previous?.session.session_name ?? 'Last session'
|
||||
const analysisKey = analysisSessionKey(context)
|
||||
const nodes = railNodes(previous, context.active_session, context.next_session)
|
||||
|
||||
return (
|
||||
<div className="wk-live" data-testid="weekend-live" data-state="session_live">
|
||||
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">Session live</div>
|
||||
|
||||
<section className="wk-live-card" data-testid="wk-live-card">
|
||||
<div className="wk-live-head">
|
||||
<div className="wk-event-id">
|
||||
<Flag code={focus?.country_code} flag={focus?.country_flag} />
|
||||
<h1 className="wk-sessions-title">{focus?.meeting_name ?? 'Live session'}</h1>
|
||||
</div>
|
||||
<span className="wk-live-pulse" aria-hidden="true" />
|
||||
</div>
|
||||
{focus?.circuit_short_name && <p className="wk-sessions-circuit mono">{focus.circuit_short_name}</p>}
|
||||
<p className="wk-live-lead">
|
||||
{active?.session_name ? `${active.session_name} is on track now.` : 'A session is running now.'}
|
||||
</p>
|
||||
<Link to="/live" className="wk-cta wk-cta-primary wk-cta-wide" data-testid="wk-watch-live">
|
||||
<Radio size={15} aria-hidden="true" /> Watch live timing <ChevronRight size={15} aria-hidden="true" />
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
<SessionRail nodes={nodes} />
|
||||
|
||||
{previous && (
|
||||
<section className="wk-recap-card" data-testid="wk-last-session">
|
||||
<span className="wk-recap-eyebrow mono">{previousName} · Complete</span>
|
||||
<h2 className="wk-recap-title">What happened in {previousName}</h2>
|
||||
<EventPodium sessionKey={analysisKey} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
frontend/src/components/weekend/PreSessionView.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import type { WeekendContext } from '../../types'
|
||||
import { RacePreviewPage } from '../../pages/RacePreviewPage'
|
||||
import { ChampionshipRoundStrip, CountdownDisplay, Flag, SessionRail, railNodes } from './shared'
|
||||
import { meetingIdentity } from '../../lib/weekendContext'
|
||||
|
||||
/**
|
||||
* PreSessionView folds the race preview surface into the Weekend home so there is
|
||||
* no separate Preview destination. It reuses the existing preview page content and
|
||||
* frames it with the next-session countdown and compact season navigation. It also
|
||||
* backs the /preview alias, so saved preview links resolve here instead of looping.
|
||||
*/
|
||||
export function PreSessionView({ context, now }: { context: WeekendContext; now: Date }) {
|
||||
const meeting = meetingIdentity(context.next_meeting ?? context.focus_meeting)
|
||||
const next = context.next_session?.session
|
||||
const nextStart = next?.date_start ?? meeting?.date_start
|
||||
const nodes = railNodes(
|
||||
context.previous_completed_session,
|
||||
context.active_session,
|
||||
context.next_session,
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="wk-pre" data-testid="weekend-pre-session" data-state="pre_session">
|
||||
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">Pre-session</div>
|
||||
|
||||
{meeting && (
|
||||
<header className="wk-pre-head" data-testid="wk-pre-head">
|
||||
<div className="wk-event-id">
|
||||
<Flag code={meeting.country_code} flag={meeting.country_flag} />
|
||||
<h1 className="wk-sessions-title">{meeting.meeting_name}</h1>
|
||||
</div>
|
||||
<div className="wk-pre-countdown">
|
||||
<span className="wk-next-label mono">{next?.session_name ?? 'Next session'}</span>
|
||||
<CountdownDisplay target={nextStart} now={now} />
|
||||
</div>
|
||||
<Link to="/live" className="wk-cta wk-cta-ghost" data-testid="wk-pre-live">
|
||||
Live timing <ChevronRight size={14} aria-hidden="true" />
|
||||
</Link>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{nodes.length > 0 && <SessionRail nodes={nodes} />}
|
||||
|
||||
<div className="wk-pre-preview" data-testid="wk-pre-preview">
|
||||
<RacePreviewPage />
|
||||
</div>
|
||||
|
||||
<ChampionshipRoundStrip
|
||||
round={context.championship_round}
|
||||
total={context.total_championship_rounds}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
frontend/src/components/weekend/StatusViews.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
export function WeekendLoading() {
|
||||
return (
|
||||
<div className="wk-status wk-status-loading" data-testid="weekend-loading" aria-busy="true">
|
||||
<span className="wk-status-eyebrow mono">box-box · weekend</span>
|
||||
<p className="wk-status-title">Reading the weekend…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WeekendError({ message }: { message?: string }) {
|
||||
return (
|
||||
<div className="wk-status wk-status-error" data-testid="weekend-error" role="alert">
|
||||
<span className="wk-status-eyebrow mono">box-box · weekend</span>
|
||||
<p className="wk-status-title">Weekend unavailable</p>
|
||||
<p className="wk-status-sub">{message ?? 'Something went wrong loading the weekend context.'}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WeekendLimited({ message, season }: { message?: string; season?: number }) {
|
||||
return (
|
||||
<div className="wk-status wk-status-limited" data-testid="weekend-limited">
|
||||
<span className="wk-status-eyebrow mono">box-box · weekend</span>
|
||||
<p className="wk-status-title">Nothing to show yet</p>
|
||||
<p className="wk-status-sub">
|
||||
{message ?? `No weekend data for the ${season || 'current'} season is available right now.`}
|
||||
</p>
|
||||
<div className="wk-status-actions">
|
||||
<Link to="/live" className="wk-cta wk-cta-ghost">Live timing</Link>
|
||||
<Link to="/explore" className="wk-cta wk-cta-ghost">Explore</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
277
frontend/src/components/weekend/shared.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { fetchRaceHub } from '../../api'
|
||||
import { countryFlag } from '../../lib/gpIdentity'
|
||||
import { parseScheduleTime } from '../../lib/schedule'
|
||||
import { podiumFromResults } from '../../lib/weekendContext'
|
||||
import { teamColor } from '../../utils'
|
||||
import type {
|
||||
ContextSession,
|
||||
WeekendChampionshipImpact,
|
||||
WeekendBriefingItem,
|
||||
WeekendPodiumEntry,
|
||||
} from '../../types'
|
||||
|
||||
export function Flag({ code, flag }: { code?: string; flag?: string }) {
|
||||
const glyph = countryFlag({ country_code: code ?? '', country_flag: flag ?? '' })
|
||||
if (!glyph) return null
|
||||
return <span className="wk-flag" aria-hidden="true">{glyph}</span>
|
||||
}
|
||||
|
||||
interface CountdownParts {
|
||||
days: number
|
||||
hours: number
|
||||
minutes: number
|
||||
seconds: number
|
||||
reached: boolean
|
||||
}
|
||||
|
||||
export function countdownParts(target: string | undefined, now: Date): CountdownParts | null {
|
||||
const date = target ? parseScheduleTime(target) : null
|
||||
if (!date) return null
|
||||
const diff = date.getTime() - now.getTime()
|
||||
const clamped = Math.max(0, diff)
|
||||
const totalSeconds = Math.floor(clamped / 1000)
|
||||
return {
|
||||
days: Math.floor(totalSeconds / 86400),
|
||||
hours: Math.floor((totalSeconds % 86400) / 3600),
|
||||
minutes: Math.floor((totalSeconds % 3600) / 60),
|
||||
seconds: totalSeconds % 60,
|
||||
reached: diff <= 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function CountdownDisplay({
|
||||
target,
|
||||
now,
|
||||
compact,
|
||||
}: {
|
||||
target: string | undefined
|
||||
now: Date
|
||||
compact?: boolean
|
||||
}) {
|
||||
const parts = countdownParts(target, now)
|
||||
if (!parts) {
|
||||
return (
|
||||
<span className="wk-countdown-tbc mono" data-testid="wk-countdown">
|
||||
Schedule TBC
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (parts.reached) {
|
||||
return <span className="wk-countdown-live" data-testid="wk-countdown">Starting now</span>
|
||||
}
|
||||
if (compact || parts.days === 0) {
|
||||
const hh = String(parts.days * 24 + parts.hours).padStart(2, '0')
|
||||
return (
|
||||
<span className="wk-countdown-clock mono" data-testid="wk-countdown">
|
||||
{hh}:{String(parts.minutes).padStart(2, '0')}:{String(parts.seconds).padStart(2, '0')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="wk-countdown" data-testid="wk-countdown">
|
||||
<span className="wk-count-unit"><b>{parts.days}</b>D</span>
|
||||
<span className="wk-count-unit"><b>{String(parts.hours).padStart(2, '0')}</b>H</span>
|
||||
<span className="wk-count-unit"><b>{String(parts.minutes).padStart(2, '0')}</b>M</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* EventPodium fetches and renders the podium for a completed analysis session.
|
||||
* When no analysable session key is available (e.g. an archived result with no
|
||||
* local analysis), it renders an explicit empty state rather than fetching.
|
||||
*/
|
||||
export function EventPodium({ sessionKey }: { sessionKey?: number }) {
|
||||
const enabled = typeof sessionKey === 'number' && sessionKey > 0
|
||||
const query = useQuery({
|
||||
queryKey: ['race-hub', sessionKey, 'podium'],
|
||||
queryFn: () => fetchRaceHub(sessionKey as number),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const podium: WeekendPodiumEntry[] = podiumFromResults(query.data?.results ?? [])
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div className="wk-podium-empty" data-testid="wk-podium-empty">
|
||||
Result not available yet.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (podium.length === 0) {
|
||||
return (
|
||||
<div className="wk-podium-empty" data-testid="wk-podium-empty">
|
||||
{query.isLoading ? 'Loading result…' : 'Result not available yet.'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ol className="wk-podium" data-testid="wk-podium">
|
||||
{podium.map((p) => (
|
||||
<li key={p.driver_number} className="wk-podium-row" style={{ ['--wk-team' as string]: teamColor(p.team_colour) }}>
|
||||
<span className="wk-podium-pos mono">{p.position}</span>
|
||||
<span className="wk-podium-code">{p.name_acronym}</span>
|
||||
<span className="wk-podium-gap mono">{p.gap}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChampionshipImpactCard({ impact }: { impact: WeekendChampionshipImpact }) {
|
||||
const leader = impact.leaders[0]
|
||||
return (
|
||||
<section className="wk-champ" data-testid="wk-champ-impact">
|
||||
<header className="wk-card-head">
|
||||
<span className="wk-card-title">Championship impact</span>
|
||||
<Link to="/championship" className="wk-card-link">Standings →</Link>
|
||||
</header>
|
||||
<ol className="wk-champ-list">
|
||||
{impact.leaders.map((d) => {
|
||||
const gap = leader && d.position !== 1 ? leader.points - d.points : 0
|
||||
return (
|
||||
<li key={d.driver_number} className="wk-champ-row" style={{ ['--wk-team' as string]: teamColor(d.team_colour) }}>
|
||||
<span className="wk-champ-pos mono">{d.position}</span>
|
||||
<span className="wk-champ-code">{d.name_acronym}</span>
|
||||
<span className="wk-champ-pts mono">{d.points}</span>
|
||||
<span className="wk-champ-gap mono">{d.position === 1 ? '—' : `-${gap}`}</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
{impact.note && <p className="wk-champ-note">{impact.note}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function BriefingStrip({ items }: { items: WeekendBriefingItem[] }) {
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<section className="wk-briefing" data-testid="wk-briefing">
|
||||
<header className="wk-card-head">
|
||||
<span className="wk-card-title">Since the chequered flag</span>
|
||||
<Link to="/briefing" className="wk-card-link">All briefing →</Link>
|
||||
</header>
|
||||
<ul className="wk-briefing-list">
|
||||
{items.map((item) => (
|
||||
<li key={item.url} className="wk-briefing-item">
|
||||
<a href={item.url} target="_blank" rel="noreferrer" className="wk-briefing-link">
|
||||
{item.category && <span className="wk-briefing-cat mono">{item.category}</span>}
|
||||
<span className="wk-briefing-title">{item.title}</span>
|
||||
<span className="wk-briefing-source mono">{item.source}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ChampionshipRoundStrip is a compact, progressively-disclosed round indicator.
|
||||
* The canonical contract exposes only the current round and total, so the strip
|
||||
* shows "Round N of M" and links out to Explore for the full calendar rather than
|
||||
* reintroducing the whole calendar on the Weekend home.
|
||||
*/
|
||||
export function ChampionshipRoundStrip({ round, total }: { round: number; total: number }) {
|
||||
if (round <= 0 || total <= 0) return null
|
||||
const pct = Math.min(100, Math.round((round / total) * 100))
|
||||
return (
|
||||
<section className="wk-season-nav" data-testid="wk-season-nav" aria-label="Season progress">
|
||||
<div className="wk-season-progress">
|
||||
<div className="wk-season-progress-head">
|
||||
<span className="wk-card-title">Season progress</span>
|
||||
<Link to="/explore" className="wk-card-link" data-testid="wk-season-explore">
|
||||
Full calendar →
|
||||
</Link>
|
||||
</div>
|
||||
<div
|
||||
className="wk-season-bar"
|
||||
role="progressbar"
|
||||
aria-valuenow={round}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={total}
|
||||
aria-label={`Round ${round} of ${total}`}
|
||||
>
|
||||
<span className="wk-season-bar-fill" style={{ width: `${pct}%` }} aria-hidden="true" />
|
||||
</div>
|
||||
<p className="wk-season-progress-label mono">
|
||||
Round {round} of {total}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
interface TimelineNode {
|
||||
key: string
|
||||
session_name: string
|
||||
status: 'done' | 'live' | 'next'
|
||||
}
|
||||
|
||||
/**
|
||||
* SessionRail renders the previous/active/next sessions the canonical context
|
||||
* exposes as a compact three-node rail. It is intentionally derived only from the
|
||||
* canonical refs — the contract does not enumerate a full weekend session list.
|
||||
*/
|
||||
export function SessionRail({ nodes }: { nodes: TimelineNode[] }) {
|
||||
if (nodes.length === 0) return null
|
||||
return (
|
||||
<ol className="wk-timeline" data-testid="wk-timeline" role="list">
|
||||
{nodes.map((n) => (
|
||||
<li key={n.key} className={`wk-timeline-node wk-timeline-${n.status}`} role="listitem">
|
||||
<span className="wk-timeline-dot" aria-hidden="true" />
|
||||
<span className="wk-timeline-name">{shortSessionName(n.session_name)}</span>
|
||||
<span className="wk-timeline-state mono">{stateLabel(n.status)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
/** Build the compact session rail from the canonical previous/active/next refs. */
|
||||
export function railNodes(
|
||||
previous: ContextSession | undefined,
|
||||
active: ContextSession | undefined,
|
||||
next: ContextSession | undefined,
|
||||
): TimelineNode[] {
|
||||
const nodes: TimelineNode[] = []
|
||||
if (previous?.session.session_name) {
|
||||
nodes.push({ key: `prev-${previous.session.session_key}`, session_name: previous.session.session_name, status: 'done' })
|
||||
}
|
||||
if (active?.session.session_name) {
|
||||
nodes.push({ key: `live-${active.session.session_key}`, session_name: active.session.session_name, status: 'live' })
|
||||
}
|
||||
if (next?.session.session_name) {
|
||||
nodes.push({ key: `next-${next.session.session_key}`, session_name: next.session.session_name, status: 'next' })
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
function shortSessionName(name: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'Practice 1': 'FP1',
|
||||
'Practice 2': 'FP2',
|
||||
'Practice 3': 'FP3',
|
||||
Qualifying: 'Quali',
|
||||
'Sprint Qualifying': 'SQ',
|
||||
'Sprint Shootout': 'SQ',
|
||||
}
|
||||
return map[name] ?? name
|
||||
}
|
||||
|
||||
function stateLabel(status: TimelineNode['status']): string {
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return 'Complete'
|
||||
case 'live':
|
||||
return 'Live'
|
||||
default:
|
||||
return 'Next'
|
||||
}
|
||||
}
|
||||
89
frontend/src/hooks/useWeekendContext.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchChampionshipHub, fetchNews, fetchWeekendContext } from '../api'
|
||||
import { championshipImpact, briefingItems } from '../lib/weekendContext'
|
||||
import type {
|
||||
WeekendChampionshipImpact,
|
||||
WeekendBriefingItem,
|
||||
WeekendContext,
|
||||
} from '../types'
|
||||
|
||||
export type WeekendLoadState = 'loading' | 'error' | 'ready'
|
||||
|
||||
export interface UseWeekendContextResult {
|
||||
/** Canonical context, present only when loadState === 'ready'. */
|
||||
context: WeekendContext | null
|
||||
loadState: WeekendLoadState
|
||||
error?: Error
|
||||
/** Supplementary championship movers (not part of the #72 contract). */
|
||||
championship?: WeekendChampionshipImpact
|
||||
/** Supplementary briefing items (not part of the #72 contract). */
|
||||
briefing: WeekendBriefingItem[]
|
||||
now: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* useWeekendContext reads the canonical /api/v1/weekend-context endpoint as the
|
||||
* single source of truth for the Weekend home. When the canonical read succeeds
|
||||
* it layers on two pieces of supplementary data that the contract intentionally
|
||||
* omits — championship movers and the paddock briefing — and nothing else.
|
||||
*
|
||||
* It deliberately does NOT fan out to season / meetings / per-weekend / OpenF1
|
||||
* session / live-state queries: those would defeat the local-first canonical read
|
||||
* model and can hit the rate-limited OpenF1 REST surface during active sessions.
|
||||
* Supplementary queries are gated on a successful canonical read so a failing or
|
||||
* pending endpoint issues no extra requests.
|
||||
*/
|
||||
export function useWeekendContext(): UseWeekendContextResult {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
const nowDate = useMemo(() => new Date(now), [now])
|
||||
|
||||
const contextQuery = useQuery({
|
||||
queryKey: ['weekend-context'],
|
||||
queryFn: fetchWeekendContext,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const canonical = contextQuery.data ?? null
|
||||
const canonicalReady = canonical != null
|
||||
const season = canonical?.season
|
||||
|
||||
// Supplementary data — only fetched once the canonical context has resolved,
|
||||
// so a pending/failed canonical read never triggers a request fan-out.
|
||||
const championshipQuery = useQuery({
|
||||
queryKey: ['championship-hub', season ?? 'current'],
|
||||
queryFn: () => fetchChampionshipHub(season),
|
||||
enabled: canonicalReady,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const newsQuery = useQuery({
|
||||
queryKey: ['news', 6],
|
||||
queryFn: () => fetchNews(6),
|
||||
enabled: canonicalReady,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const championship = useMemo(
|
||||
() => championshipImpact(championshipQuery.data),
|
||||
[championshipQuery.data],
|
||||
)
|
||||
const briefing = useMemo(() => briefingItems(newsQuery.data ?? []), [newsQuery.data])
|
||||
|
||||
let loadState: WeekendLoadState = 'loading'
|
||||
if (contextQuery.isError) loadState = 'error'
|
||||
else if (canonicalReady) loadState = 'ready'
|
||||
|
||||
return {
|
||||
context: canonical,
|
||||
loadState,
|
||||
error: contextQuery.error instanceof Error ? contextQuery.error : undefined,
|
||||
championship,
|
||||
briefing,
|
||||
now: nowDate,
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
181
frontend/src/lib/weekendContext.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import type {
|
||||
ChampionshipHub,
|
||||
ContextSession,
|
||||
EnrichedResult,
|
||||
Meeting,
|
||||
NewsItem,
|
||||
Session,
|
||||
WeekendBriefingItem,
|
||||
WeekendChampionshipImpact,
|
||||
WeekendChampionshipMover,
|
||||
WeekendContext,
|
||||
WeekendPodiumEntry,
|
||||
WeekendViewState,
|
||||
} from '../types'
|
||||
|
||||
/**
|
||||
* resolveViewState maps the canonical `temporal_state` onto the rendered Weekend
|
||||
* view. It is a total function: every valid canonical `temporal_state` resolves
|
||||
* to a concrete view, so a well-formed payload never falls through to a
|
||||
* limited/empty placeholder. Loading and error are hook-level states that are
|
||||
* not part of the #72 contract.
|
||||
*/
|
||||
export function resolveViewState(context: WeekendContext): WeekendViewState {
|
||||
switch (context.temporal_state) {
|
||||
case 'no_season':
|
||||
return 'no_season'
|
||||
case 'between_weekends':
|
||||
return 'between_weekends'
|
||||
case 'pre_session':
|
||||
return 'pre_session'
|
||||
case 'session_live':
|
||||
return 'session_live'
|
||||
case 'session_settling':
|
||||
return 'session_settling'
|
||||
case 'between_sessions':
|
||||
return 'between_sessions'
|
||||
case 'post_weekend':
|
||||
return 'post_weekend'
|
||||
case 'season_complete':
|
||||
return 'season_complete'
|
||||
default:
|
||||
// Unknown/absent temporal_state is an invalid payload — treat as no_season
|
||||
// rather than inventing a state. The hook still shows an explicit surface.
|
||||
return 'no_season'
|
||||
}
|
||||
}
|
||||
|
||||
export interface MeetingIdentity {
|
||||
meeting_key: number
|
||||
meeting_name: string
|
||||
short_name: string
|
||||
country_code: string
|
||||
country_flag: string
|
||||
circuit_short_name: string
|
||||
circuit_key?: number
|
||||
date_start?: string
|
||||
date_end?: string
|
||||
}
|
||||
|
||||
/** Presentation identity for a meeting, tolerant of partial/absent fields. */
|
||||
export function meetingIdentity(meeting: Meeting | undefined): MeetingIdentity | undefined {
|
||||
if (!meeting) return undefined
|
||||
return {
|
||||
meeting_key: meeting.meeting_key,
|
||||
meeting_name: meeting.meeting_name || meeting.location || 'Grand Prix',
|
||||
short_name: (meeting.meeting_name || meeting.location || 'Grand Prix').replace(/ Grand Prix$/i, ''),
|
||||
country_code: meeting.country_code,
|
||||
country_flag: meeting.country_flag,
|
||||
circuit_short_name: meeting.circuit_short_name || meeting.location || '',
|
||||
circuit_key: meeting.circuit_key,
|
||||
date_start: meeting.date_start || undefined,
|
||||
date_end: meeting.date_end || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** Session identity paired with its resolved meeting for a canonical ContextSession. */
|
||||
export function sessionMeeting(ref: ContextSession | undefined): Meeting | undefined {
|
||||
return ref?.meeting
|
||||
}
|
||||
|
||||
export function sessionOf(ref: ContextSession | undefined): Session | undefined {
|
||||
return ref?.session
|
||||
}
|
||||
|
||||
/**
|
||||
* analysisSessionKey resolves the session a user should open to read analysis for
|
||||
* a completed event. It prefers the canonical default analysis session, falling
|
||||
* back to the previous completed session. Returns undefined when nothing analysable
|
||||
* is available (e.g. an archived result with no local analysis).
|
||||
*/
|
||||
export function analysisSessionKey(context: WeekendContext): number | undefined {
|
||||
const key =
|
||||
context.default_analysis_session?.session.session_key ??
|
||||
context.previous_completed_session?.session.session_key
|
||||
return key && key > 0 ? key : undefined
|
||||
}
|
||||
|
||||
/** True when a ContextSession has local analysis worth linking to. */
|
||||
export function hasLocalAnalysis(ref: ContextSession | undefined): boolean {
|
||||
const status = ref?.availability.local_analysis
|
||||
return status === 'complete' || status === 'partial'
|
||||
}
|
||||
|
||||
/** Countdown target for a session reference — its scheduled start, if known. */
|
||||
export function sessionStart(ref: ContextSession | undefined): string | undefined {
|
||||
const start = ref?.session.date_start
|
||||
return start ? start : undefined
|
||||
}
|
||||
|
||||
export function podiumFromResults(results: EnrichedResult[]): WeekendPodiumEntry[] {
|
||||
return [...results]
|
||||
.filter((r) => r.position >= 1)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.slice(0, 3)
|
||||
.map((r) => ({
|
||||
position: r.position,
|
||||
driver_number: r.driver_number,
|
||||
name_acronym: r.name_acronym,
|
||||
team_name: r.team_name,
|
||||
team_colour: r.team_colour,
|
||||
gap: formatResultGap(r),
|
||||
}))
|
||||
}
|
||||
|
||||
function formatResultGap(r: EnrichedResult): string {
|
||||
if (r.dnf) return 'DNF'
|
||||
if (r.dns) return 'DNS'
|
||||
if (r.dsq) return 'DSQ'
|
||||
if (r.position === 1) {
|
||||
if (typeof r.duration === 'number' && r.duration > 0) return formatDuration(r.duration)
|
||||
return 'Winner'
|
||||
}
|
||||
const gap = Array.isArray(r.gap_to_leader) ? r.gap_to_leader[r.gap_to_leader.length - 1] : r.gap_to_leader
|
||||
if (typeof gap === 'number') return `+${gap.toFixed(3)}`
|
||||
if (typeof gap === 'string' && gap) return gap.startsWith('+') ? gap : `+${gap}`
|
||||
return '—'
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = seconds % 60
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${s.toFixed(3).padStart(6, '0')}`
|
||||
return `${m}:${s.toFixed(3).padStart(6, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* championshipImpact derives the top-of-standings movers from the championship
|
||||
* hub. This is supplementary data layered onto the canonical context, not part of
|
||||
* the #72 contract.
|
||||
*/
|
||||
export function championshipImpact(hub: ChampionshipHub | undefined): WeekendChampionshipImpact | undefined {
|
||||
if (!hub || hub.drivers.length === 0) return undefined
|
||||
const leaders: WeekendChampionshipMover[] = hub.drivers.slice(0, 3).map((d) => {
|
||||
const cumulative = d.cumulative ?? []
|
||||
const delta =
|
||||
cumulative.length >= 2 ? cumulative[cumulative.length - 1] - cumulative[cumulative.length - 2] : undefined
|
||||
return {
|
||||
position: d.position,
|
||||
driver_number: d.driver_number,
|
||||
name_acronym: d.name_acronym,
|
||||
team_colour: d.team_colour,
|
||||
points: d.points,
|
||||
delta,
|
||||
}
|
||||
})
|
||||
const note = hub.last_race ? `Standings after ${hub.last_race}.` : undefined
|
||||
return { leaders, note }
|
||||
}
|
||||
|
||||
/** At most three briefing items, mapped from the news feed. */
|
||||
export function briefingItems(news: NewsItem[]): WeekendBriefingItem[] {
|
||||
return news.slice(0, 3).map((item) => ({
|
||||
category: item.category,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
source: item.source,
|
||||
published_at: item.published_at,
|
||||
image_url: item.og_image_url,
|
||||
}))
|
||||
}
|
||||
@@ -1,451 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
fetchChampionshipHub,
|
||||
fetchLiveState,
|
||||
fetchLocalMeetings,
|
||||
fetchRaceHub,
|
||||
fetchSeasonMeetings,
|
||||
fetchSeasons,
|
||||
fetchSessions,
|
||||
fetchWeekend,
|
||||
} from '../api'
|
||||
import { CommandCenterHero } from '../components/CommandCenterHero'
|
||||
import { PaddockBriefing } from '../components/PaddockBriefing'
|
||||
import { RACE_HUB_DATASETS, countWeekendStats, formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
|
||||
import { countryAccent, countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
|
||||
import { classifySessionStatus, heroState } from '../lib/hero'
|
||||
import {
|
||||
currentAndNextSession,
|
||||
focusMeetingKind,
|
||||
formatSessionScheduleTime,
|
||||
meetingHasStarted,
|
||||
mostRecentPastMeeting,
|
||||
nextUpcomingMeeting,
|
||||
pickFocusMeeting,
|
||||
sortSessionsByStart,
|
||||
} from '../lib/schedule'
|
||||
import type { Meeting, Session, Weekend, WeekendSession } from '../types'
|
||||
import { Trophy } from 'lucide-react'
|
||||
|
||||
const missingDatasets = Object.fromEntries(
|
||||
RACE_HUB_DATASETS.map((dataset) => [dataset, { status: 'missing', source: 'none', count: 0 }]),
|
||||
) as WeekendSession['datasets']
|
||||
|
||||
function meetingStatus(meeting: Meeting, focusKey: number | undefined, now: Date) {
|
||||
if (meeting.meeting_key === focusKey) return 'focus'
|
||||
if (meetingHasStarted(meeting, now)) return 'past'
|
||||
return 'future'
|
||||
}
|
||||
|
||||
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 CommandCenterPage() {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
const seasonsQuery = useQuery({
|
||||
queryKey: ['seasons'],
|
||||
queryFn: fetchSeasons,
|
||||
})
|
||||
|
||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||
|
||||
const meetingsQuery = useQuery({
|
||||
queryKey: ['meetings', latestSeason],
|
||||
queryFn: () => fetchLocalMeetings(latestSeason!),
|
||||
enabled: latestSeason != null,
|
||||
})
|
||||
|
||||
const seasonMeetingsQuery = useQuery({
|
||||
queryKey: ['season-meetings', latestSeason],
|
||||
queryFn: () => fetchSeasonMeetings(latestSeason!),
|
||||
enabled: latestSeason != null,
|
||||
})
|
||||
|
||||
const localMeetings = meetingsQuery.data ?? []
|
||||
const seasonMeetings = seasonMeetingsQuery.data?.length ? seasonMeetingsQuery.data : localMeetings
|
||||
const focusMeetings = seasonMeetings.length > 0 ? seasonMeetings : localMeetings
|
||||
|
||||
const weekendQueries = useQueries({
|
||||
queries: localMeetings.map((meeting) => ({
|
||||
queryKey: ['weekend', meeting.meeting_key],
|
||||
queryFn: () => fetchWeekend(meeting.meeting_key),
|
||||
enabled: localMeetings.length > 0,
|
||||
staleTime: 60_000,
|
||||
})),
|
||||
})
|
||||
|
||||
const liveQuery = useQuery({
|
||||
queryKey: ['live-state'],
|
||||
queryFn: fetchLiveState,
|
||||
staleTime: 5_000,
|
||||
})
|
||||
|
||||
const championshipQuery = useQuery({
|
||||
queryKey: ['championship-hub', latestSeason],
|
||||
queryFn: () => fetchChampionshipHub(latestSeason!),
|
||||
enabled: latestSeason != null,
|
||||
})
|
||||
const champHub = championshipQuery.data
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
const nowDate = useMemo(() => new Date(now), [now])
|
||||
|
||||
const weekendsByKey = useMemo(() => {
|
||||
const map = new Map<number, Weekend>()
|
||||
localMeetings.forEach((meeting, i) => {
|
||||
const data = weekendQueries[i]?.data
|
||||
if (data) map.set(meeting.meeting_key, data)
|
||||
})
|
||||
return map
|
||||
}, [localMeetings, weekendQueries])
|
||||
|
||||
const weekendList = useMemo(() => weekendQueries.map((q) => q.data), [weekendQueries])
|
||||
const meetingStats = countWeekendStats(weekendList)
|
||||
const focusMeeting = pickFocusMeeting(focusMeetings, nowDate)
|
||||
const focusWeekend = focusMeeting ? weekendsByKey.get(focusMeeting.meeting_key) : undefined
|
||||
const openF1SessionsQuery = useQuery({
|
||||
queryKey: ['sessions', focusMeeting?.meeting_key, 'openf1'],
|
||||
queryFn: () => fetchSessions(focusMeeting!.meeting_key, 'openf1'),
|
||||
enabled: focusMeeting != null && focusWeekend == null,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const openF1WeekendSessions: WeekendSession[] = useMemo(
|
||||
() =>
|
||||
(openF1SessionsQuery.data ?? []).map((session) => ({
|
||||
session,
|
||||
source: 'none',
|
||||
datasets: missingDatasets,
|
||||
})),
|
||||
[openF1SessionsQuery.data],
|
||||
)
|
||||
const focusWeekendSessions = focusWeekend?.sessions ?? openF1WeekendSessions
|
||||
const focusKind = focusMeeting ? focusMeetingKind(focusMeeting, nowDate) : null
|
||||
const focusSessions: Session[] = sortSessionsByStart(focusWeekendSessions.map((s) => s.session))
|
||||
const { current: currentSession, next: nextSession } = currentAndNextSession(focusSessions, nowDate)
|
||||
|
||||
const analysisSession = pickAnalysisSession(focusWeekend)
|
||||
const actionSession =
|
||||
analysisSession?.session ??
|
||||
currentSession ??
|
||||
nextSession ??
|
||||
focusWeekendSessions.find((s) => s.session.session_key === focusWeekend?.default_session_key)?.session ??
|
||||
focusWeekendSessions[0]?.session
|
||||
const analysisSessionKey = actionSession?.session_key
|
||||
|
||||
const liveActive = liveQuery.data?.is_live === true
|
||||
const heroStateKind = heroState({
|
||||
now: nowDate,
|
||||
liveActive,
|
||||
currentSession,
|
||||
focusKind,
|
||||
})
|
||||
|
||||
const lastPastMeeting = useMemo(
|
||||
() => mostRecentPastMeeting(focusMeetings, nowDate),
|
||||
[focusMeetings, nowDate],
|
||||
)
|
||||
const lastPastWeekend = lastPastMeeting ? weekendsByKey.get(lastPastMeeting.meeting_key) : undefined
|
||||
const lastRaceAnalysis = pickAnalysisSession(lastPastWeekend)
|
||||
const lastRaceSessionKey = lastRaceAnalysis?.session.session_key
|
||||
|
||||
const lastRaceHubQuery = useQuery({
|
||||
queryKey: ['race-hub', lastRaceSessionKey, 'hero-podium'],
|
||||
queryFn: () => fetchRaceHub(lastRaceSessionKey!),
|
||||
enabled: lastRaceSessionKey != null && heroStateKind === 'between',
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const nextMeetingForHero = useMemo(() => {
|
||||
if (heroStateKind !== 'between') return null
|
||||
return nextUpcomingMeeting(focusMeetings, nowDate)
|
||||
}, [heroStateKind, focusMeetings, nowDate])
|
||||
|
||||
const lastRacePodium = lastRaceHubQuery.data?.results ?? []
|
||||
const lastRaceName = champHub?.last_race ?? lastPastMeeting?.meeting_name ?? ''
|
||||
|
||||
if (seasonsQuery.isLoading) {
|
||||
return <div className="page loading-state">loading command center…</div>
|
||||
}
|
||||
|
||||
if (seasonsQuery.isError) {
|
||||
return (
|
||||
<div className="page error-box">
|
||||
{seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const seasons = seasonsQuery.data ?? []
|
||||
|
||||
if (seasons.length === 0) {
|
||||
return (
|
||||
<div className="cc-page cc-empty" data-testid="command-center-empty">
|
||||
<div className="cc-empty-band">
|
||||
<span className="cc-empty-eyebrow mono">box-box · command center</span>
|
||||
<h1 className="cc-empty-title">No local data yet</h1>
|
||||
<p className="cc-empty-sub">
|
||||
Ingest a race weekend from the CLI to populate this screen with live status, next-session
|
||||
countdowns, and analysis links.
|
||||
</p>
|
||||
</div>
|
||||
<div className="cc-empty-actions">
|
||||
<Link to="/live" className="cc-action cc-action-live">
|
||||
<span className="cc-action-label">Live Timing</span>
|
||||
<span className="cc-action-meta">Standby</span>
|
||||
</Link>
|
||||
<Link to="/admin" className="cc-action">
|
||||
<span className="cc-action-label">Admin · Data Health</span>
|
||||
<span className="cc-action-meta">Ingestion guidance</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const accent = countryAccent(focusMeeting ?? null)
|
||||
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
|
||||
|
||||
return (
|
||||
<div className="cc-page" data-testid="command-center" style={accentStyle}>
|
||||
<div className="cc-topbar">
|
||||
<span className="cc-topbar-label mono">box-box · command center</span>
|
||||
<span className="cc-topbar-meta mono">
|
||||
{latestSeason} season · {meetingStats.full}/{meetingStats.total || 0} weekends full
|
||||
</span>
|
||||
<span className="cc-live-pill" data-testid="cc-live-status">
|
||||
<span className={`cc-live-dot ${liveActive ? 'live' : ''}`} />
|
||||
{liveActive ? 'Live session active' : 'No live session'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!focusMeeting && (
|
||||
<div className="missing-notice">
|
||||
No meetings ingested for {latestSeason}. Run{' '}
|
||||
<code>box-box --ingest-year {latestSeason}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{focusMeeting && focusKind && (
|
||||
<CommandCenterHero
|
||||
state={heroStateKind}
|
||||
now={nowDate}
|
||||
accent={accent}
|
||||
liveActive={liveActive}
|
||||
liveData={liveQuery.data?.data}
|
||||
focusMeeting={focusMeeting}
|
||||
focusKind={focusKind}
|
||||
sessions={focusWeekendSessions}
|
||||
currentSession={currentSession}
|
||||
nextSession={nextSession}
|
||||
analysisSessionKey={analysisSessionKey}
|
||||
analysisSessionName={actionSession?.session_name}
|
||||
lastRaceName={lastRaceName}
|
||||
lastRacePodium={lastRacePodium}
|
||||
lastRaceSessionKey={lastRaceSessionKey}
|
||||
nextMeeting={nextMeetingForHero}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="cc-dashboard-grid">
|
||||
<div className="cc-dashboard-main">
|
||||
<div
|
||||
className="cc-ambient-aura"
|
||||
aria-hidden="true"
|
||||
style={focusMeeting ? { '--aura-accent': countryAccent(focusMeeting) } as React.CSSProperties : undefined}
|
||||
/>
|
||||
<div className="cc-dashboard-panel">
|
||||
{seasonMeetings.length > 0 && (
|
||||
<section className="cc-season-calendar" data-testid="cc-season-calendar">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Season Calendar</span>
|
||||
<span className="sec-meta mono">
|
||||
{seasonMeetings.length} rounds · {meetingStats.full}/{meetingStats.total || 0} local
|
||||
</span>
|
||||
</div>
|
||||
<div className="cc-calendar-grid" role="list">
|
||||
{seasonMeetings.map((meeting, index) => {
|
||||
const weekend = weekendsByKey.get(meeting.meeting_key)
|
||||
const target = pickAnalysisSession(weekend)
|
||||
const targetKey = target?.session.session_key ?? weekend?.default_session_key
|
||||
const status = meetingStatus(meeting, focusMeeting?.meeting_key, nowDate)
|
||||
const cardAccent = countryAccent(meeting)
|
||||
return (
|
||||
<Link
|
||||
key={meeting.meeting_key}
|
||||
to="/race-hub"
|
||||
search={targetKey ? { session_key: targetKey } : {}}
|
||||
className={`cc-calendar-card cc-calendar-${status}`}
|
||||
data-testid={`cc-calendar-${meeting.meeting_key}`}
|
||||
role="listitem"
|
||||
style={{ '--gp-card-accent': cardAccent } as React.CSSProperties}
|
||||
>
|
||||
<div className="cc-calendar-accent" aria-hidden="true" />
|
||||
<div className="cc-calendar-top mono">
|
||||
<span className="cc-calendar-round">R{String(index + 1).padStart(2, '0')}</span>
|
||||
<span className={`cc-cov-dot cc-cov-${weekend?.source ?? 'none'}`} aria-hidden="true" />
|
||||
</div>
|
||||
<div className="cc-calendar-id">
|
||||
{countryFlag(meeting) && <span className="cc-calendar-flag">{countryFlag(meeting)}</span>}
|
||||
</div>
|
||||
<span className="cc-calendar-decal mono">{countryDecal(meeting)}</span>
|
||||
<div className="cc-calendar-copy">
|
||||
<div className="cc-calendar-name">{meeting.meeting_name}</div>
|
||||
<div className="cc-calendar-circuit mono">{meeting.circuit_short_name || meeting.location}</div>
|
||||
<div className="cc-calendar-date mono">{formatGpDateRange(meeting)}</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{seasonMeetingsQuery.isError && (
|
||||
<div className="cc-side-empty">Using local meetings because the full calendar could not load.</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cc-dashboard-sidebar">
|
||||
{champHub && (
|
||||
<section className="cc-champ-snapshot glass-panel" data-testid="cc-champ-snapshot" style={{ padding: 'var(--s5)', borderRadius: '12px' }}>
|
||||
<div className="cc-champ-header">
|
||||
<span>Championship Leaders</span>
|
||||
<Link to="/championship" className="cc-champ-link">Full Standings →</Link>
|
||||
</div>
|
||||
<div className="cc-champ-group-label">Drivers</div>
|
||||
<div className="cc-champ-list">
|
||||
{champHub.drivers.slice(0, 3).map((d) => (
|
||||
<div key={d.driver_number} className="cc-champ-row">
|
||||
<div className="cc-champ-left">
|
||||
<div className="cc-champ-color" style={{ color: `#${d.team_colour}`, background: 'currentColor' }} />
|
||||
<span className="cc-champ-name">{d.name_acronym}</span>
|
||||
</div>
|
||||
<div className="cc-champ-form" title="Last 5 races">
|
||||
<FormSparkline form={d.form} color={`#${d.team_colour}`} />
|
||||
</div>
|
||||
<div className="cc-champ-right">
|
||||
<span className="cc-champ-gap">
|
||||
{d.position === 1 ? '—' : `-${champHub.drivers[0].points - d.points}`}
|
||||
</span>
|
||||
<span className="cc-champ-pts">{d.points}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="cc-champ-group-label">Constructors</div>
|
||||
<div className="cc-champ-list">
|
||||
{champHub.teams.slice(0, 3).map((t, i) => (
|
||||
<div key={t.team_name} className="cc-champ-row">
|
||||
<div className="cc-champ-left">
|
||||
<div className="cc-champ-color" style={{ color: `#${t.team_colour}`, background: 'currentColor' }} />
|
||||
<span className="cc-champ-name">{t.team_name}</span>
|
||||
</div>
|
||||
<div className="cc-champ-form" style={{ opacity: 0.4, fontSize: '11px', display: 'flex', gap: '4px', alignItems: 'center' }} title={`${t.wins} Wins`}>
|
||||
<Trophy size={12} /> {t.wins}
|
||||
</div>
|
||||
<div className="cc-champ-right">
|
||||
<span className="cc-champ-gap">
|
||||
{i === 0 ? '—' : `-${champHub.teams[0].points - t.points}`}
|
||||
</span>
|
||||
<span className="cc-champ-pts">{t.points}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{focusWeekendSessions.length > 0 && (
|
||||
<section className="cc-schedule" data-testid="cc-schedule">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Weekend Schedule</span>
|
||||
<span className="sec-meta mono">{focusWeekendSessions.length} sessions</span>
|
||||
</div>
|
||||
<div className="cc-session-strip" role="list">
|
||||
{focusWeekendSessions.map(({ session, source, datasets }) => {
|
||||
const status = classifySessionStatus(session, nowDate)
|
||||
const isNext = nextSession?.session_key === session.session_key
|
||||
const isCurrent = currentSession?.session_key === session.session_key
|
||||
const isLive = isCurrent && liveActive
|
||||
return (
|
||||
<Link
|
||||
key={session.session_key}
|
||||
to="/race-hub"
|
||||
search={{ session_key: session.session_key }}
|
||||
className={`cc-session-card ui-card interactive cc-status-${status}${isNext ? ' is-next' : ''}${
|
||||
isLive ? ' is-live glass-panel' : ''
|
||||
}`}
|
||||
data-testid={`cc-session-${session.session_key}`}
|
||||
role="listitem"
|
||||
>
|
||||
<div className="cc-session-card-head">
|
||||
<span className="cc-session-abbrev mono">
|
||||
{sessionTypeAbbrev(session.session_type, session.session_name)}
|
||||
</span>
|
||||
<span className={`cc-session-status cc-status-pill-${status}`}>
|
||||
{isCurrent ? 'On track' : status === 'done' ? 'Done' : isNext ? 'Next' : 'Upcoming'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="cc-session-name">{session.session_name}</div>
|
||||
<div className="cc-session-time mono">{formatSessionScheduleTime(session.date_start)}</div>
|
||||
<div className="cc-session-cov mono">
|
||||
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
|
||||
{formatCoverageHint(datasets)}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<PaddockBriefing />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FormSparkline({ form, color }: { form: number[], color: string }) {
|
||||
if (!form || form.length === 0) return null
|
||||
const max = Math.max(...form, 26) // 26 is standard max for a race win
|
||||
const width = 45
|
||||
const height = 14
|
||||
const step = width / Math.max(form.length - 1, 1)
|
||||
|
||||
const points = form.map((val, i) => {
|
||||
const x = i * step
|
||||
const y = height - (val / max) * height
|
||||
return `${x},${y}`
|
||||
}).join(' ')
|
||||
|
||||
return (
|
||||
<svg width={width} height={height} style={{ overflow: 'visible' }}>
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
points={points}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
156
frontend/src/pages/ExplorePage.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
fetchChampionshipHub,
|
||||
fetchLocalMeetings,
|
||||
fetchSeasonMeetings,
|
||||
fetchSeasons,
|
||||
fetchWeekend,
|
||||
} from '../api'
|
||||
import { countryFlag, formatGpDateRange } from '../lib/gpIdentity'
|
||||
import { meetingHasStarted } from '../lib/schedule'
|
||||
import { teamColor } from '../utils'
|
||||
import type { Weekend } from '../types'
|
||||
import '../styles/weekend.css'
|
||||
|
||||
function analysisKey(weekend: Weekend | undefined): number | undefined {
|
||||
if (!weekend) return undefined
|
||||
const race = weekend.sessions.find((s) => s.session.session_type?.toLowerCase().includes('race'))
|
||||
return race?.session.session_key ?? weekend.default_session_key
|
||||
}
|
||||
|
||||
export function ExplorePage() {
|
||||
const now = useMemo(() => new Date(), [])
|
||||
|
||||
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons })
|
||||
const season = seasonsQuery.data?.[0] ?? null
|
||||
|
||||
const localMeetingsQuery = useQuery({
|
||||
queryKey: ['meetings', season, 'local'],
|
||||
queryFn: () => fetchLocalMeetings(season!),
|
||||
enabled: season != null,
|
||||
})
|
||||
const seasonMeetingsQuery = useQuery({
|
||||
queryKey: ['season-meetings', season],
|
||||
queryFn: () => fetchSeasonMeetings(season!),
|
||||
enabled: season != null,
|
||||
})
|
||||
const championshipQuery = useQuery({
|
||||
queryKey: ['championship-hub', season],
|
||||
queryFn: () => fetchChampionshipHub(season!),
|
||||
enabled: season != null,
|
||||
})
|
||||
|
||||
const localMeetings = localMeetingsQuery.data ?? []
|
||||
const meetings = seasonMeetingsQuery.data?.length ? seasonMeetingsQuery.data : localMeetings
|
||||
|
||||
const weekendQueries = useQueries({
|
||||
queries: localMeetings.map((m) => ({
|
||||
queryKey: ['weekend', m.meeting_key],
|
||||
queryFn: () => fetchWeekend(m.meeting_key),
|
||||
enabled: localMeetings.length > 0,
|
||||
staleTime: 60_000,
|
||||
})),
|
||||
})
|
||||
const weekendsByKey = useMemo(() => {
|
||||
const map = new Map<number, Weekend>()
|
||||
localMeetings.forEach((m, i) => {
|
||||
const data = weekendQueries[i]?.data
|
||||
if (data) map.set(m.meeting_key, data)
|
||||
})
|
||||
return map
|
||||
}, [localMeetings, weekendQueries])
|
||||
|
||||
const drivers = championshipQuery.data?.drivers ?? []
|
||||
const activeMeetings = meetings.filter((m) => !m.is_cancelled)
|
||||
|
||||
if (seasonsQuery.isLoading) {
|
||||
return <div className="wk-status" data-testid="explore-loading"><p className="wk-status-title">Loading…</p></div>
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="wk-page wk-explore" data-testid="explore-page">
|
||||
<div className="wk-eyebrow mono">Explore · {season ?? 'season'}</div>
|
||||
|
||||
<section className="wk-explore-section" aria-labelledby="explore-calendar-title">
|
||||
<header className="wk-card-head">
|
||||
<span className="wk-card-title" id="explore-calendar-title">Season races</span>
|
||||
<span className="wk-card-link mono">{activeMeetings.length} rounds</span>
|
||||
</header>
|
||||
{activeMeetings.length === 0 ? (
|
||||
<p className="wk-status-sub" data-testid="explore-calendar-empty">No season calendar available yet.</p>
|
||||
) : (
|
||||
<ol className="wk-explore-grid" role="list">
|
||||
{activeMeetings.map((meeting, index) => {
|
||||
const weekend = weekendsByKey.get(meeting.meeting_key)
|
||||
const target = analysisKey(weekend)
|
||||
const done = meetingHasStarted(meeting, now)
|
||||
const card = (
|
||||
<>
|
||||
<div className="wk-explore-top mono">
|
||||
<span>R{String(index + 1).padStart(2, '0')}</span>
|
||||
<span className="wk-explore-flag">
|
||||
{countryFlag(meeting) || meeting.country_code}
|
||||
</span>
|
||||
</div>
|
||||
<div className="wk-explore-name">{meeting.meeting_name}</div>
|
||||
<div className="wk-explore-meta mono">{meeting.circuit_short_name || meeting.location}</div>
|
||||
<div className="wk-explore-meta mono">{formatGpDateRange(meeting)}</div>
|
||||
<span className={`wk-explore-state ${done ? 'is-done' : 'is-upcoming'}`}>
|
||||
{done ? 'Completed' : 'Upcoming'}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<li key={meeting.meeting_key} className="wk-explore-item" role="listitem">
|
||||
{target ? (
|
||||
<Link
|
||||
to="/race-hub"
|
||||
search={{ session_key: target }}
|
||||
className="wk-explore-card is-link"
|
||||
data-testid={`explore-race-${meeting.meeting_key}`}
|
||||
>
|
||||
{card}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="wk-explore-card" data-testid={`explore-race-${meeting.meeting_key}`}>{card}</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="wk-explore-section" aria-labelledby="explore-drivers-title">
|
||||
<header className="wk-card-head">
|
||||
<span className="wk-card-title" id="explore-drivers-title">Drivers</span>
|
||||
<Link to="/championship" className="wk-card-link">Championship →</Link>
|
||||
</header>
|
||||
{drivers.length === 0 ? (
|
||||
<p className="wk-status-sub" data-testid="explore-drivers-empty">Driver data not available yet.</p>
|
||||
) : (
|
||||
<ol className="wk-explore-drivers" role="list">
|
||||
{drivers.map((d) => (
|
||||
<li key={d.driver_number} role="listitem">
|
||||
<Link
|
||||
to="/drivers/$driverNumber"
|
||||
params={{ driverNumber: String(d.driver_number) }}
|
||||
search={season ? { year: season } : {}}
|
||||
className="wk-driver-chip"
|
||||
style={{ ['--wk-team' as string]: teamColor(d.team_colour) }}
|
||||
data-testid={`explore-driver-${d.driver_number}`}
|
||||
>
|
||||
<span className="wk-driver-pos mono">{d.position}</span>
|
||||
<span className="wk-driver-code">{d.name_acronym}</span>
|
||||
<span className="wk-driver-pts mono">{d.points}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
fetchLocalMeetings,
|
||||
fetchRaceHub,
|
||||
fetchSeasons,
|
||||
fetchWeekend,
|
||||
} from '../api'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { fetchRaceHub, fetchWeekend, fetchWeekendContext } from '../api'
|
||||
import { weekendFocusSearch } from '../lib/routeSearch'
|
||||
import { DatasetStrip } from '../components/DatasetStrip'
|
||||
import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
|
||||
import { TabBar, type Tab } from '../components/TabBar'
|
||||
@@ -17,77 +13,64 @@ import { CompareView } from '../components/CompareView'
|
||||
import { RaceControlView } from '../components/RaceControlView'
|
||||
import { WeatherView } from '../components/WeatherView'
|
||||
import { OverviewView } from '../components/OverviewView'
|
||||
import {
|
||||
PartialAnalysisBanner,
|
||||
PreSessionView,
|
||||
SessionPhaseView,
|
||||
} from '../components/PreSessionView'
|
||||
import { WeekendSwitcher } from '../components/WeekendSwitcher'
|
||||
import { SourceBadge } from '../components/SourceBadge'
|
||||
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
||||
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
|
||||
import { sessionTypeAbbrev } from '../lib/coverage'
|
||||
import { formatSessionScheduleTime, sortSessionsByStart } from '../lib/schedule'
|
||||
import {
|
||||
formatSessionScheduleTime,
|
||||
pickFocusMeeting,
|
||||
sortSessionsByStart,
|
||||
} from '../lib/schedule'
|
||||
import type { Weekend, WeekendSession } from '../types'
|
||||
defaultAnalysisSessionKey,
|
||||
isPartialAnalysis,
|
||||
isPreSession,
|
||||
isPreparing,
|
||||
isUnavailable,
|
||||
resolveSessionState,
|
||||
sessionStateDotClass,
|
||||
sessionStateLabel,
|
||||
} from '../lib/sessionState'
|
||||
import type { WeekendSession } from '../types'
|
||||
|
||||
interface Props {
|
||||
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) {
|
||||
const navigate = useNavigate()
|
||||
const [activeTab, setActiveTab] = useState<Tab>('overview')
|
||||
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 ───
|
||||
const seasonsQuery = useQuery({
|
||||
queryKey: ['seasons'],
|
||||
queryFn: fetchSeasons,
|
||||
enabled: sessionKey === 0,
|
||||
})
|
||||
|
||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||
|
||||
const meetingsQuery = useQuery({
|
||||
queryKey: ['meetings', latestSeason],
|
||||
queryFn: () => fetchLocalMeetings(latestSeason!),
|
||||
enabled: sessionKey === 0 && latestSeason != null,
|
||||
})
|
||||
|
||||
const focusMeeting = useMemo(() => {
|
||||
if (sessionKey !== 0 || !meetingsQuery.data) return null
|
||||
return 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,
|
||||
// Keep schedule-adjacent UI (countdown) moving; Live/completion come from context.
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setNow(new Date()), 1000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const contextQuery = useQuery({
|
||||
queryKey: ['weekend-context'],
|
||||
queryFn: fetchWeekendContext,
|
||||
staleTime: 15_000,
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
const context = contextQuery.data ?? null
|
||||
|
||||
// Bare `/race-hub` resolves only through canonical Weekend Context.
|
||||
useEffect(() => {
|
||||
if (sessionKey !== 0) return
|
||||
const weekend = fallbackWeekendQuery.data
|
||||
if (!weekend) return
|
||||
const target = pickAnalysisSession(weekend)?.session.session_key
|
||||
?? weekend.default_session_key
|
||||
?? weekend.sessions[0]?.session.session_key
|
||||
if (!contextQuery.isSuccess || !context) return
|
||||
const target = defaultAnalysisSessionKey(context)
|
||||
if (target) {
|
||||
navigate({ to: '/race-hub', search: { session_key: target }, replace: true })
|
||||
}
|
||||
}, [sessionKey, fallbackWeekendQuery.data, navigate])
|
||||
}, [sessionKey, contextQuery.isSuccess, context, navigate])
|
||||
|
||||
// ─── Active session payload ───
|
||||
const raceHubQuery = useQuery({
|
||||
queryKey: ['race-hub', sessionKey],
|
||||
queryFn: () => fetchRaceHub(sessionKey),
|
||||
@@ -108,37 +91,86 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
const accent = countryAccent(data?.meeting ?? null)
|
||||
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 (seasonsQuery.isLoading || meetingsQuery.isLoading || fallbackWeekendQuery.isLoading) {
|
||||
if (contextQuery.isLoading) {
|
||||
return (
|
||||
<div className="rh-page" style={accentStyle}>
|
||||
<div className="loading-state">resolving latest local weekend…</div>
|
||||
<div className="loading-state">resolving weekend context…</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const seasons = seasonsQuery.data ?? []
|
||||
if (seasons.length === 0) {
|
||||
if (contextQuery.isError) {
|
||||
return (
|
||||
<div className="rh-page rh-empty" data-testid="race-hub-empty" style={accentStyle}>
|
||||
<div className="rh-empty-band">
|
||||
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
|
||||
<h1 className="rh-empty-title">No local sessions yet</h1>
|
||||
<p className="rh-empty-sub">
|
||||
The Race Hub reads from local ingest only. Once a weekend is ingested
|
||||
it will open here automatically.
|
||||
</p>
|
||||
<div className="rh-empty-actions">
|
||||
<a href="/admin" className="rh-empty-action">Open Admin · Data Health</a>
|
||||
<a href="/" className="rh-empty-action">Back to Command Center</a>
|
||||
<div className="rh-page" data-testid="race-hub-error" style={accentStyle}>
|
||||
<div className="rh-recover">
|
||||
<div className="error-box">
|
||||
{contextQuery.error instanceof Error
|
||||
? contextQuery.error.message
|
||||
: 'Failed to load weekend context.'}
|
||||
</div>
|
||||
<div className="rh-recover-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="rh-recover-btn primary"
|
||||
onClick={() => contextQuery.refetch()}
|
||||
data-testid="rh-retry"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<Link to="/" search={{}} className="rh-recover-btn" data-testid="rh-back-weekend">
|
||||
Back to Weekend
|
||||
</Link>
|
||||
</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 (
|
||||
<div className="rh-page" style={accentStyle}>
|
||||
<div className="loading-state">resolving latest local weekend…</div>
|
||||
<div className="loading-state">resolving weekend context…</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -152,12 +184,39 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
)
|
||||
}
|
||||
if (raceHubQuery.isError || !data) {
|
||||
const backMeeting =
|
||||
context?.focus_meeting?.meeting_key ??
|
||||
context?.previous_completed_session?.meeting?.meeting_key
|
||||
const backSearch = weekendFocusSearch(backMeeting, sessionKey)
|
||||
|
||||
return (
|
||||
<div className="rh-page" style={accentStyle}>
|
||||
<div className="error-box">
|
||||
{raceHubQuery.error instanceof Error
|
||||
? raceHubQuery.error.message
|
||||
: `Failed to load session ${sessionKey}.`}
|
||||
<div className="rh-page" data-testid="race-hub-error" style={accentStyle}>
|
||||
<div className="rh-recover">
|
||||
<div className="error-box">
|
||||
{raceHubQuery.error instanceof Error
|
||||
? raceHubQuery.error.message
|
||||
: `Failed to load session ${sessionKey}.`}
|
||||
</div>
|
||||
<div className="rh-recover-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="rh-recover-btn primary"
|
||||
onClick={() => raceHubQuery.refetch()}
|
||||
data-testid="rh-retry"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<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>
|
||||
)
|
||||
@@ -168,12 +227,30 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
const sessionMeta = weekend
|
||||
? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w]))
|
||||
: {}
|
||||
const activeSessionMeta = sessionMeta[sessionKey]
|
||||
const activeSessionMeta: WeekendSession | undefined = sessionMeta[sessionKey]
|
||||
const activeState = resolveSessionState({
|
||||
weekendSession: activeSessionMeta,
|
||||
context,
|
||||
now,
|
||||
})
|
||||
const preSession = isPreSession(activeState)
|
||||
const preparing = isPreparing(activeState)
|
||||
const unavailable = isUnavailable(activeState)
|
||||
const partial = isPartialAnalysis(activeState)
|
||||
|
||||
return (
|
||||
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
|
||||
{/* Topbar */}
|
||||
<div className="rh-topbar">
|
||||
<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">
|
||||
box-box · race hub
|
||||
{data.meeting?.year ? ` · ${data.meeting.year}` : ''}
|
||||
@@ -195,11 +272,12 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
<WeekendSwitcher
|
||||
currentMeetingKey={meetingKey}
|
||||
currentSessionKey={sessionKey}
|
||||
context={context}
|
||||
now={now}
|
||||
onClose={() => setSwitcherOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* GP Identity band */}
|
||||
{data.meeting && (
|
||||
<section className="rh-identity" data-testid="rh-identity">
|
||||
<div className="rh-identity-accent" aria-hidden="true" />
|
||||
@@ -220,12 +298,16 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Session rail */}
|
||||
{sessions.length > 0 && (
|
||||
<nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail">
|
||||
{sessions.map((session) => {
|
||||
const meta = sessionMeta[session.session_key]
|
||||
const active = session.session_key === sessionKey
|
||||
const state = resolveSessionState({
|
||||
weekendSession: meta,
|
||||
context,
|
||||
now,
|
||||
})
|
||||
return (
|
||||
<button
|
||||
key={session.session_key}
|
||||
@@ -247,123 +329,166 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
<span className="rh-session-time mono">
|
||||
{formatSessionScheduleTime(session.date_start)}
|
||||
</span>
|
||||
{meta && (
|
||||
<span className="rh-session-cov mono">
|
||||
<span
|
||||
className={`cc-cov-dot cc-cov-${meta.source}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{formatCoverageHint(meta.datasets)}
|
||||
</span>
|
||||
)}
|
||||
<span className="rh-session-cov mono">
|
||||
<span
|
||||
className={`cc-cov-dot ${sessionStateDotClass(state)}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{sessionStateLabel(state)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Active session sub-bar */}
|
||||
{data.session && (
|
||||
<div className="rh-active-bar" data-testid="rh-active-bar">
|
||||
<span className="rh-active-name">{data.session.session_name}</span>
|
||||
<span className="rh-active-meta mono">
|
||||
{formatSessionScheduleTime(data.session.date_start)}
|
||||
</span>
|
||||
{activeSessionMeta && (
|
||||
<span className="rh-active-cov mono">
|
||||
<span
|
||||
className={`cc-cov-dot cc-cov-${activeSessionMeta.source}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{formatCoverageHint(activeSessionMeta.datasets)} datasets local
|
||||
</span>
|
||||
)}
|
||||
<span className="rh-active-cov mono" data-testid="rh-active-state">
|
||||
<span
|
||||
className={`cc-cov-dot ${sessionStateDotClass(activeState)}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{sessionStateLabel(activeState)}
|
||||
</span>
|
||||
<span className="rh-active-key mono">key {sessionKey}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DatasetStrip datasets={data.datasets} />
|
||||
|
||||
<TabBar active={activeTab} onChange={setActiveTab} />
|
||||
|
||||
{activeTab === 'overview' && <OverviewView data={data} />}
|
||||
|
||||
{activeTab === 'race_story' && (
|
||||
<div className="data-section">
|
||||
<RaceStoryCanvas data={data} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'strategy' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Race Strategy</span>
|
||||
</div>
|
||||
<StrategyView
|
||||
results={data.results}
|
||||
stints={data.stints}
|
||||
pit_stops={data.pit_stops}
|
||||
hasStints={data.datasets['stints']?.status === 'available'}
|
||||
{preSession && data.session ? (
|
||||
<PreSessionView
|
||||
session={data.session}
|
||||
sessionName={data.session.session_name}
|
||||
now={now}
|
||||
/>
|
||||
) : preparing || unavailable ? (
|
||||
<>
|
||||
<SessionPhaseView
|
||||
state={
|
||||
unavailable
|
||||
? activeState === 'cancelled'
|
||||
? 'cancelled'
|
||||
: 'unavailable'
|
||||
: 'preparing'
|
||||
}
|
||||
sessionName={data.session?.session_name ?? `Session ${sessionKey}`}
|
||||
onOpenDiagnostics={openDiagnostics}
|
||||
/>
|
||||
</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' && (
|
||||
<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 === 'overview' && <OverviewView data={data} />}
|
||||
|
||||
{activeTab === 'lap_data' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Lap Data</span>
|
||||
{data.laps.length > 0 && (
|
||||
<span className="sec-meta mono">{data.laps.length} samples</span>
|
||||
)}
|
||||
</div>
|
||||
<LapsView laps={data.laps} drivers={data.drivers} />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'race_story' && (
|
||||
<div className="data-section">
|
||||
<RaceStoryCanvas data={data} />
|
||||
</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 === 'strategy' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Race Strategy</span>
|
||||
</div>
|
||||
<StrategyView
|
||||
results={data.results}
|
||||
stints={data.stints}
|
||||
pit_stops={data.pit_stops}
|
||||
hasStints={data.datasets['stints']?.status === 'available'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === '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 === 'compare' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Driver Compare</span>
|
||||
</div>
|
||||
<CompareView
|
||||
sessionKey={sessionKey}
|
||||
results={data.results}
|
||||
drivers={data.drivers}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'data_status' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Data Status</span>
|
||||
</div>
|
||||
<DatasetStatusView datasets={data.datasets} />
|
||||
</div>
|
||||
{activeTab === 'lap_data' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Lap Data</span>
|
||||
{data.laps.length > 0 && (
|
||||
<span className="sec-meta mono">{data.laps.length} samples</span>
|
||||
)}
|
||||
</div>
|
||||
<LapsView laps={data.laps} drivers={data.drivers} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'conditions' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Conditions</span>
|
||||
{data.weather.length > 0 && (
|
||||
<span className="sec-meta mono">{data.weather.length} samples</span>
|
||||
)}
|
||||
</div>
|
||||
<WeatherView weather={data.weather} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === '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>
|
||||
)
|
||||
|
||||
113
frontend/src/pages/WeekendPage.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useWeekendContext } from '../hooks/useWeekendContext'
|
||||
import { BetweenRacesView } from '../components/weekend/BetweenRacesView'
|
||||
import { BetweenSessionsView } from '../components/weekend/BetweenSessionsView'
|
||||
import { LiveHandoffView } from '../components/weekend/LiveHandoffView'
|
||||
import { PreSessionView } from '../components/weekend/PreSessionView'
|
||||
import { WeekendError, WeekendLimited, WeekendLoading } from '../components/weekend/StatusViews'
|
||||
import { WeekendFocusBanner } from '../components/weekend/WeekendFocusBanner'
|
||||
import { resolveViewState } from '../lib/weekendContext'
|
||||
import type {
|
||||
WeekendBriefingItem,
|
||||
WeekendChampionshipImpact,
|
||||
WeekendContext,
|
||||
WeekendViewState,
|
||||
} from '../types'
|
||||
import '../styles/weekend.css'
|
||||
|
||||
interface RenderArgs {
|
||||
context: WeekendContext
|
||||
now: Date
|
||||
championship?: WeekendChampionshipImpact
|
||||
briefing: WeekendBriefingItem[]
|
||||
/** When true (the /preview alias), foreground the preparation surface. */
|
||||
preview: boolean
|
||||
}
|
||||
|
||||
function renderState(view: WeekendViewState, args: RenderArgs) {
|
||||
const { context, now, championship, briefing, preview } = args
|
||||
// The /preview alias always resolves to the preparation surface as long as
|
||||
// there is a next event to prepare for, regardless of the temporal state. This
|
||||
// keeps saved /preview links and the "Prepare for …" CTA meaningful instead of
|
||||
// redirecting straight back to the same between-races screen.
|
||||
if (preview && context.next_meeting) {
|
||||
return <PreSessionView context={context} now={now} />
|
||||
}
|
||||
|
||||
switch (view) {
|
||||
case 'no_season':
|
||||
return <WeekendLimited season={context.season} />
|
||||
case 'between_weekends':
|
||||
case 'post_weekend':
|
||||
case 'season_complete':
|
||||
return (
|
||||
<BetweenRacesView
|
||||
context={context}
|
||||
now={now}
|
||||
view={view}
|
||||
championship={championship}
|
||||
briefing={briefing}
|
||||
/>
|
||||
)
|
||||
case 'between_sessions':
|
||||
case 'session_settling':
|
||||
return <BetweenSessionsView context={context} now={now} view={view} briefing={briefing} />
|
||||
case 'session_live':
|
||||
return <LiveHandoffView context={context} />
|
||||
case 'pre_session':
|
||||
return <PreSessionView context={context} now={now} />
|
||||
default:
|
||||
return <WeekendLimited season={context.season} />
|
||||
}
|
||||
}
|
||||
|
||||
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 hasFocus =
|
||||
(focusMeetingKey != null && focusMeetingKey > 0) ||
|
||||
(focusSessionKey != null && focusSessionKey > 0)
|
||||
|
||||
if (loadState === 'loading') {
|
||||
return (
|
||||
<main className="wk-page" data-testid="weekend-page" data-state="loading">
|
||||
<WeekendLoading />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadState === 'error' || context == null) {
|
||||
return (
|
||||
<main className="wk-page" data-testid="weekend-page" data-state="error">
|
||||
<WeekendError message={error?.message} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const view = resolveViewState(context)
|
||||
|
||||
return (
|
||||
<main
|
||||
className="wk-page"
|
||||
data-testid="weekend-page"
|
||||
data-state={view}
|
||||
data-temporal-state={context.temporal_state}
|
||||
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 })}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router'
|
||||
import { Nav } from './components/Nav'
|
||||
import { CommandCenterPage } from './pages/CommandCenterPage'
|
||||
import { WeekendPage } from './pages/WeekendPage'
|
||||
import { ExplorePage } from './pages/ExplorePage'
|
||||
import { RaceHubPage } from './pages/RaceHubPage'
|
||||
import { DataLibraryPage } from './pages/DataLibraryPage'
|
||||
import { LiveTimingPage } from './pages/LiveTimingPage'
|
||||
import { BriefingPage } from './pages/BriefingPage'
|
||||
import { ChampionshipPage } from './pages/ChampionshipPage'
|
||||
import { DriverProfilePage } from './pages/DriverProfilePage'
|
||||
import { RacePreviewPage } from './pages/RacePreviewPage'
|
||||
|
||||
type RaceHubSearch = {
|
||||
session_key?: number
|
||||
}
|
||||
import { parseRaceHubSearch, parseWeekendSearch } from './lib/routeSearch'
|
||||
|
||||
type DriverProfileSearch = {
|
||||
year?: number
|
||||
@@ -26,19 +23,31 @@ const rootRoute = createRootRoute({
|
||||
),
|
||||
})
|
||||
|
||||
export const commandCenterRoute = createRoute({
|
||||
// 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({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: CommandCenterPage,
|
||||
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({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/explore',
|
||||
component: ExplorePage,
|
||||
})
|
||||
|
||||
export const raceHubRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/race-hub',
|
||||
validateSearch: (search: Record<string, unknown>): RaceHubSearch => {
|
||||
const sessionKey = Number(search.session_key)
|
||||
return Number.isFinite(sessionKey) && sessionKey > 0 ? { session_key: sessionKey } : {}
|
||||
},
|
||||
validateSearch: (search: Record<string, unknown>) => parseRaceHubSearch(search),
|
||||
component: function RaceHubRoute() {
|
||||
const { session_key } = raceHubRoute.useSearch()
|
||||
return <RaceHubPage sessionKey={session_key ?? 0} />
|
||||
@@ -90,14 +99,21 @@ export const briefingRoute = createRoute({
|
||||
component: BriefingPage,
|
||||
})
|
||||
|
||||
// Preview folds into the Weekend home. /preview is a stable alias that renders the
|
||||
// Weekend page in its preparation surface (PreSessionView) whenever there is a next
|
||||
// event — so saved preview links and the "Prepare for …" CTA reach real preview
|
||||
// content instead of redirecting back to the same between-races screen.
|
||||
export const previewRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/preview',
|
||||
component: RacePreviewPage,
|
||||
component: function PreviewRoute() {
|
||||
return <WeekendPage preview />
|
||||
},
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
commandCenterRoute,
|
||||
weekendRoute,
|
||||
exploreRoute,
|
||||
raceHubRoute,
|
||||
adminRoute,
|
||||
dataLibraryRoute,
|
||||
|
||||
@@ -142,6 +142,53 @@ a { color: inherit; text-decoration: none; }
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
/* ── Mobile bottom nav ── */
|
||||
.app-bottom-nav {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
height: 60px;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
background: rgba(18, 18, 18, 0.92);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.bottom-nav-link {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.bottom-nav-link.active { color: var(--red); }
|
||||
.bottom-nav-link:hover { color: var(--text-2); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
/* On mobile the bottom bar is the single primary navigation system. The top
|
||||
bar's Primary links are hidden so the two are never both shown; the top bar
|
||||
keeps only the logo and the Admin operator utility. */
|
||||
.app-bottom-nav { display: flex; }
|
||||
.app-nav .nav-links { display: none; }
|
||||
.app-nav { justify-content: space-between; }
|
||||
/* Keep page content clear of the fixed bottom bar. */
|
||||
.wk-page { padding-bottom: calc(60px + env(safe-area-inset-bottom, 0) + var(--s4)); }
|
||||
}
|
||||
|
||||
/* ── Focus visibility (accessibility) ── */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--red);
|
||||
outline-offset: 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ── Page ── */
|
||||
.page {
|
||||
max-width: 1040px;
|
||||
@@ -1773,6 +1820,163 @@ a { color: inherit; text-decoration: none; }
|
||||
.tab-btn:hover { color: var(--text-2); }
|
||||
.tab-btn.active { color: var(--text); border-bottom-color: var(--red); }
|
||||
|
||||
/* ── Grouped analysis navigation ── */
|
||||
.tab-bar-grouped {
|
||||
gap: var(--s5);
|
||||
align-items: flex-end;
|
||||
}
|
||||
.tab-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tab-group + .tab-group {
|
||||
border-left: 1px solid var(--border);
|
||||
padding-left: var(--s5);
|
||||
}
|
||||
.tab-group-label {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
opacity: 0.7;
|
||||
padding: 0 14px;
|
||||
}
|
||||
.tab-group-btns {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ── Session lifecycle state dots ── */
|
||||
.rh-state-ready { background: var(--green); }
|
||||
.rh-state-partial { background: var(--yellow); }
|
||||
.rh-state-live { background: var(--red); }
|
||||
.rh-state-upcoming { background: var(--text-3); }
|
||||
.rh-state-preparing { background: var(--text-3); opacity: 0.5; }
|
||||
.rh-state-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 ── */
|
||||
.ds-legend {
|
||||
display: flex;
|
||||
@@ -2622,6 +2826,31 @@ a { color: inherit; text-decoration: none; }
|
||||
|
||||
.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 {
|
||||
padding: 4px 10px;
|
||||
font-size: 10px;
|
||||
@@ -3197,7 +3426,10 @@ a { color: inherit; text-decoration: none; }
|
||||
.rh-overview-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
@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-decal { font-size: 28px; }
|
||||
.rh-identity-name { font-size: 17px; }
|
||||
|
||||
413
frontend/src/styles/weekend.css
Normal file
@@ -0,0 +1,413 @@
|
||||
/* ── Weekend adaptive home ── */
|
||||
.wk-page {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: var(--s6) var(--s6) 64px;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s4);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-2);
|
||||
margin-bottom: var(--s5);
|
||||
}
|
||||
.wk-eyebrow::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--red), transparent);
|
||||
}
|
||||
|
||||
.wk-card-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--s4);
|
||||
margin-bottom: var(--s4);
|
||||
}
|
||||
.wk-card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.wk-card-link {
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wk-card-link:hover { color: var(--text); }
|
||||
|
||||
.wk-flag { font-size: 22px; line-height: 1; }
|
||||
|
||||
/* ── CTAs ── */
|
||||
.wk-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s3);
|
||||
padding: var(--s4) var(--s5);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
transition: background 0.12s, border-color 0.12s, transform 0.12s;
|
||||
}
|
||||
.wk-cta-primary { background: var(--red); color: #fff; }
|
||||
.wk-cta-primary:hover { background: #ff3b3f; }
|
||||
.wk-cta-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
border: 1px solid var(--border-2);
|
||||
}
|
||||
.wk-cta-ghost:hover { color: var(--text); border-color: var(--text-3); }
|
||||
.wk-cta-wide { width: 100%; }
|
||||
|
||||
/* ── Between-races top grid ── */
|
||||
.wk-top-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--s5);
|
||||
margin-bottom: var(--s5);
|
||||
}
|
||||
.wk-mid-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 340px) minmax(0, 1fr);
|
||||
gap: var(--s5);
|
||||
margin-bottom: var(--s5);
|
||||
}
|
||||
|
||||
.wk-event-card {
|
||||
min-width: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: var(--s5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s5);
|
||||
}
|
||||
.wk-event-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s4);
|
||||
}
|
||||
.wk-event-id { display: flex; align-items: center; gap: var(--s4); min-width: 0; }
|
||||
.wk-event-name {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.wk-event-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wk-tag-done { color: var(--text-2); }
|
||||
.wk-tag-next { color: var(--red); }
|
||||
|
||||
.wk-event-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 200px);
|
||||
gap: var(--s5);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* ── Podium ── */
|
||||
.wk-podium { list-style: none; display: flex; flex-direction: column; gap: var(--s3); }
|
||||
.wk-podium-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--s4);
|
||||
padding: var(--s3) var(--s4);
|
||||
background: var(--surface-h);
|
||||
border-left: 3px solid var(--wk-team, var(--border-2));
|
||||
border-radius: 4px;
|
||||
}
|
||||
.wk-podium-pos { font-weight: 700; color: var(--text-2); }
|
||||
.wk-podium-code { font-weight: 700; color: var(--wk-team, var(--text)); overflow: hidden; text-overflow: ellipsis; }
|
||||
.wk-podium-gap { color: var(--text-2); font-size: 12px; }
|
||||
.wk-podium-empty { color: var(--text-3); font-size: 12px; padding: var(--s4); }
|
||||
|
||||
.wk-story-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s3);
|
||||
padding: var(--s4);
|
||||
background: rgba(230, 36, 41, 0.05);
|
||||
border: 1px solid rgba(230, 36, 41, 0.25);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.wk-story-label { font-size: 13px; font-weight: 700; color: var(--red); }
|
||||
.wk-story-text { font-size: 12px; color: var(--text-2); flex: 1; }
|
||||
|
||||
/* ── Next event ── */
|
||||
.wk-next-body { display: flex; flex-direction: column; gap: var(--s5); }
|
||||
.wk-countdown { display: flex; gap: var(--s5); align-items: baseline; }
|
||||
.wk-count-unit { font-size: 13px; color: var(--text-2); font-family: var(--f-mono); }
|
||||
.wk-count-unit b { font-size: 40px; color: var(--red); font-weight: 700; margin-right: 2px; }
|
||||
.wk-countdown-clock { font-size: 32px; font-weight: 700; color: var(--blue); }
|
||||
.wk-countdown-live { font-size: 20px; font-weight: 700; color: var(--red); }
|
||||
.wk-next-session { display: flex; flex-direction: column; gap: 2px; }
|
||||
.wk-next-label { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-3); }
|
||||
.wk-next-value { font-size: 15px; font-weight: 600; }
|
||||
.wk-season-complete-copy { font-size: 13px; color: var(--text-2); }
|
||||
|
||||
/* ── Championship impact ── */
|
||||
.wk-champ, .wk-briefing, .wk-season-nav {
|
||||
min-width: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: var(--s5);
|
||||
}
|
||||
.wk-champ-list { list-style: none; display: flex; flex-direction: column; gap: var(--s3); }
|
||||
.wk-champ-row {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: var(--s4);
|
||||
padding: var(--s2) var(--s3);
|
||||
border-left: 3px solid var(--wk-team, var(--border-2));
|
||||
}
|
||||
.wk-champ-pos { color: var(--text-3); }
|
||||
.wk-champ-code { font-weight: 700; color: var(--wk-team, var(--text)); }
|
||||
.wk-champ-pts { font-weight: 700; }
|
||||
.wk-champ-gap { color: var(--text-3); font-size: 11px; min-width: 34px; text-align: right; }
|
||||
.wk-champ-note { margin-top: var(--s4); font-size: 11px; color: var(--text-3); }
|
||||
|
||||
/* ── Season progress strip ── */
|
||||
.wk-countdown-tbc { font-size: 14px; color: var(--text-3); }
|
||||
.wk-season-progress { display: flex; flex-direction: column; gap: var(--s3); }
|
||||
.wk-season-progress-head { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); }
|
||||
.wk-season-bar {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-3);
|
||||
overflow: hidden;
|
||||
}
|
||||
.wk-season-bar-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background: var(--red);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.wk-season-progress-label { font-size: 11px; color: var(--text-3); }
|
||||
|
||||
/* ── Briefing ── */
|
||||
.wk-briefing-list { list-style: none; display: flex; flex-direction: column; }
|
||||
.wk-briefing-item { border-top: 1px solid var(--border); }
|
||||
.wk-briefing-item:first-child { border-top: none; }
|
||||
.wk-briefing-link {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, auto) minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--s4);
|
||||
padding: var(--s4) 0;
|
||||
}
|
||||
.wk-briefing-link:hover .wk-briefing-title { color: var(--red); }
|
||||
.wk-briefing-cat { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--red); white-space: nowrap; }
|
||||
.wk-briefing-title { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.wk-briefing-source { font-size: 11px; color: var(--text-3); white-space: nowrap; }
|
||||
|
||||
/* ── Between-sessions / live ── */
|
||||
.wk-sessions, .wk-live, .wk-pre { display: flex; flex-direction: column; gap: var(--s5); }
|
||||
.wk-sessions-head { display: flex; flex-direction: column; gap: 2px; }
|
||||
.wk-sessions-title { font-size: 28px; font-weight: 800; letter-spacing: -0.01em; }
|
||||
.wk-sessions-circuit { font-size: 12px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
|
||||
.wk-timeline {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
gap: var(--s3);
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--s3);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.wk-timeline-node {
|
||||
flex: 1 0 auto;
|
||||
min-width: 72px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
padding: var(--s3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.wk-timeline-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--surface-3); }
|
||||
.wk-timeline-done .wk-timeline-dot { background: var(--green); }
|
||||
.wk-timeline-live .wk-timeline-dot { background: var(--red); box-shadow: 0 0 0 3px rgba(230,36,41,0.25); }
|
||||
.wk-timeline-next { border-color: var(--red); }
|
||||
.wk-timeline-next .wk-timeline-dot { border: 2px solid var(--red); }
|
||||
.wk-timeline-name { font-size: 12px; font-weight: 600; }
|
||||
.wk-timeline-state { font-size: 9px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-3); }
|
||||
.wk-timeline-done .wk-timeline-state { color: var(--green); }
|
||||
.wk-timeline-live .wk-timeline-state, .wk-timeline-next .wk-timeline-state { color: var(--red); }
|
||||
|
||||
.wk-recap-card, .wk-upnext-card, .wk-live-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: var(--s5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s4);
|
||||
}
|
||||
.wk-recap-eyebrow, .wk-upnext-eyebrow { font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--green); }
|
||||
.wk-recap-title, .wk-upnext-title { font-size: 20px; font-weight: 700; }
|
||||
.wk-upnext-when { font-size: 12px; color: var(--text-3); }
|
||||
.wk-upnext-card { border-color: rgba(230,36,41,0.3); }
|
||||
|
||||
.wk-live-card { border-color: rgba(230,36,41,0.35); }
|
||||
.wk-live-head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.wk-live-pulse { width: 12px; height: 12px; border-radius: 50%; background: var(--red); box-shadow: 0 0 0 4px rgba(230,36,41,0.2); }
|
||||
.wk-live-lead { font-size: 14px; color: var(--text-2); }
|
||||
|
||||
/* ── Pre-session ── */
|
||||
.wk-pre-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wk-pre-countdown { display: flex; flex-direction: column; gap: 2px; margin-left: auto; }
|
||||
.wk-pre-preview { min-width: 0; }
|
||||
/* The folded preview page ships its own max-width page; reset it inside Weekend. */
|
||||
.wk-pre-preview .preview-page { max-width: 100%; padding: 0; }
|
||||
|
||||
/* ── Status views ── */
|
||||
.wk-status {
|
||||
max-width: 640px;
|
||||
margin: 64px auto;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s4);
|
||||
align-items: center;
|
||||
}
|
||||
.wk-status-eyebrow { font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--text-3); }
|
||||
.wk-status-title { font-size: 22px; font-weight: 700; }
|
||||
.wk-status-sub { font-size: 13px; color: var(--text-2); }
|
||||
.wk-status-actions { display: flex; gap: var(--s4); margin-top: var(--s4); }
|
||||
.wk-status-error .wk-status-title { color: var(--red); }
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 900px) {
|
||||
.wk-top-grid, .wk-mid-grid { grid-template-columns: 1fr; }
|
||||
.wk-event-body { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.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-sessions-title { font-size: 22px; }
|
||||
.wk-count-unit b { font-size: 32px; }
|
||||
.wk-pre-head { flex-direction: column; align-items: flex-start; }
|
||||
.wk-pre-countdown { margin-left: 0; }
|
||||
}
|
||||
|
||||
/* ── Explore ── */
|
||||
.wk-explore { display: flex; flex-direction: column; gap: var(--s6); }
|
||||
.wk-explore-section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: var(--s5);
|
||||
}
|
||||
.wk-explore-grid {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: var(--s4);
|
||||
}
|
||||
.wk-explore-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s2);
|
||||
padding: var(--s4);
|
||||
min-width: 0;
|
||||
background: var(--surface-h);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
height: 100%;
|
||||
}
|
||||
.wk-explore-card.is-link { transition: border-color 0.12s, background 0.12s; }
|
||||
.wk-explore-card.is-link:hover { border-color: var(--text-3); background: var(--surface-2); }
|
||||
.wk-explore-top { display: flex; justify-content: space-between; align-items: center; font-size: 11px; color: var(--text-3); }
|
||||
.wk-explore-flag { font-size: 18px; }
|
||||
.wk-explore-name { font-size: 14px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; }
|
||||
.wk-explore-meta { font-size: 11px; color: var(--text-3); }
|
||||
.wk-explore-state { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; margin-top: var(--s2); }
|
||||
.wk-explore-state.is-done { color: var(--green); }
|
||||
.wk-explore-state.is-upcoming { color: var(--text-3); }
|
||||
|
||||
.wk-explore-drivers { list-style: none; display: flex; flex-wrap: wrap; gap: var(--s3); }
|
||||
.wk-driver-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
padding: var(--s3) var(--s4);
|
||||
background: var(--surface-h);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--wk-team, var(--border-2));
|
||||
border-radius: 6px;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
.wk-driver-chip:hover { border-color: var(--text-3); }
|
||||
.wk-driver-pos { color: var(--text-3); font-size: 11px; }
|
||||
.wk-driver-code { font-weight: 700; color: var(--wk-team, var(--text)); }
|
||||
.wk-driver-pts { color: var(--text-2); font-size: 12px; }
|
||||
|
||||
/* ── Reduced motion ── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wk-cta, .wk-briefing-title, .wk-live-pulse, .wk-timeline-live .wk-timeline-dot { transition: none; }
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
|
||||
import { CommandCenterPage } from '../pages/CommandCenterPage'
|
||||
import type { DatasetInfo, Meeting, Weekend } from '../types'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
fetchSeasons: vi.fn(),
|
||||
fetchLocalMeetings: vi.fn(),
|
||||
fetchSeasonMeetings: vi.fn(),
|
||||
fetchSessions: vi.fn(),
|
||||
fetchWeekend: vi.fn(),
|
||||
fetchLiveState: vi.fn(),
|
||||
fetchChampionshipHub: vi.fn(),
|
||||
fetchRaceHub: vi.fn(),
|
||||
}))
|
||||
|
||||
import {
|
||||
fetchSeasons,
|
||||
fetchLocalMeetings,
|
||||
fetchSeasonMeetings,
|
||||
fetchSessions,
|
||||
fetchWeekend,
|
||||
fetchLiveState,
|
||||
fetchChampionshipHub,
|
||||
fetchRaceHub,
|
||||
} from '../api'
|
||||
|
||||
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
||||
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
|
||||
const mockFetchSeasonMeetings = vi.mocked(fetchSeasonMeetings)
|
||||
const mockFetchSessions = vi.mocked(fetchSessions)
|
||||
const mockFetchWeekend = vi.mocked(fetchWeekend)
|
||||
const mockFetchLiveState = vi.mocked(fetchLiveState)
|
||||
const mockFetchChampionshipHub = vi.mocked(fetchChampionshipHub)
|
||||
const mockFetchRaceHub = vi.mocked(fetchRaceHub)
|
||||
|
||||
const meeting: Meeting = {
|
||||
meeting_key: 1229,
|
||||
meeting_name: 'Monaco',
|
||||
meeting_official_name: 'FORMULA 1 GRAND PRIX DE MONACO 2025',
|
||||
location: 'Monaco',
|
||||
country_name: 'Monaco',
|
||||
country_code: 'MON',
|
||||
country_flag: '',
|
||||
circuit_short_name: 'Monaco',
|
||||
date_start: '2025-05-23T00:00:00+00:00',
|
||||
date_end: '2025-05-25T00:00:00+00:00',
|
||||
year: 2025,
|
||||
}
|
||||
|
||||
const fullDatasets: Record<string, DatasetInfo> = {
|
||||
meeting: { status: 'available', source: 'local', count: 1 },
|
||||
session: { status: 'available', source: 'local', count: 1 },
|
||||
drivers: { status: 'available', source: 'local', count: 20 },
|
||||
results: { status: 'available', source: 'local', count: 20 },
|
||||
starting_grid: { status: 'available', source: 'local', count: 20 },
|
||||
stints: { status: 'available', source: 'local', count: 2 },
|
||||
pit_stops: { status: 'available', source: 'local', count: 1 },
|
||||
positions: { status: 'available', source: 'local', count: 3 },
|
||||
race_control: { status: 'available', source: 'local', count: 1 },
|
||||
weather: { status: 'available', source: 'local', count: 1 },
|
||||
laps: { status: 'available', source: 'local', count: 1 },
|
||||
}
|
||||
|
||||
const weekend: Weekend = {
|
||||
source: 'local',
|
||||
meeting_key: 1229,
|
||||
meeting,
|
||||
default_session_key: 9472,
|
||||
sessions: [
|
||||
{
|
||||
session: {
|
||||
session_key: 9472,
|
||||
session_name: 'Race',
|
||||
session_type: 'Race',
|
||||
meeting_key: 1229,
|
||||
date_start: '2025-05-25T13:00:00+00:00',
|
||||
date_end: '2025-05-25T15:00:00+00:00',
|
||||
gmt_offset: '02:00:00',
|
||||
},
|
||||
source: 'local',
|
||||
datasets: fullDatasets,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CommandCenterPage />
|
||||
</QueryClientProvider>
|
||||
),
|
||||
})
|
||||
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: CommandCenterPage,
|
||||
})
|
||||
|
||||
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
|
||||
|
||||
return render(<RouterProvider router={router} />)
|
||||
}
|
||||
|
||||
describe('CommandCenterPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchLiveState.mockResolvedValue({ is_live: false, data: null })
|
||||
mockFetchSessions.mockResolvedValue([])
|
||||
mockFetchChampionshipHub.mockResolvedValue({
|
||||
season: 2025,
|
||||
round: 1,
|
||||
total_rounds: 1,
|
||||
rounds_left: 0,
|
||||
last_race: 'Monaco',
|
||||
round_labels: ['R1'],
|
||||
drivers: [],
|
||||
teams: [],
|
||||
})
|
||||
mockFetchRaceHub.mockResolvedValue({
|
||||
source: 'local',
|
||||
session_key: 9472,
|
||||
datasets: fullDatasets,
|
||||
results: [
|
||||
{
|
||||
driver_number: 1,
|
||||
position: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull Racing',
|
||||
team_colour: '3671c6',
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
duration: 7200,
|
||||
gap_to_leader: null,
|
||||
number_of_laps: 78,
|
||||
points: 25,
|
||||
session_key: 9472,
|
||||
meeting_key: 1229,
|
||||
},
|
||||
],
|
||||
starting_grid: [],
|
||||
drivers: [],
|
||||
stints: [],
|
||||
pit_stops: [],
|
||||
positions: [],
|
||||
race_control: [],
|
||||
weather: [],
|
||||
laps: [],
|
||||
chapters: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('shows empty state when no seasons are ingested', async () => {
|
||||
mockFetchSeasons.mockResolvedValue([])
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('command-center-empty')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('No local data yet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows weekend identity band and schedule when data exists', async () => {
|
||||
mockFetchSeasons.mockResolvedValue([2025])
|
||||
mockFetchLocalMeetings.mockResolvedValue([meeting])
|
||||
mockFetchSeasonMeetings.mockResolvedValue([meeting])
|
||||
mockFetchWeekend.mockResolvedValue(weekend)
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('command-center')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cc-session-9472')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
|
||||
expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar')
|
||||
expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01')
|
||||
expect(screen.getByText('No live session')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('hero-last-race-link')).toHaveTextContent('Monaco')
|
||||
})
|
||||
|
||||
it('uses OpenF1 calendar metadata to focus the current weekend when local ingest is behind', async () => {
|
||||
const canada = {
|
||||
...meeting,
|
||||
meeting_key: 1301,
|
||||
meeting_name: 'Canada',
|
||||
country_name: 'Canada',
|
||||
country_code: 'CAN',
|
||||
circuit_short_name: 'Montreal',
|
||||
date_start: '2026-05-22T00:00:00+00:00',
|
||||
date_end: '2026-05-24T23:59:59+00:00',
|
||||
year: 2026,
|
||||
}
|
||||
const monaco = {
|
||||
...meeting,
|
||||
meeting_key: 1302,
|
||||
meeting_name: 'Monaco',
|
||||
date_start: '2026-06-05T00:00:00+00:00',
|
||||
date_end: '2026-06-07T23:59:59+00:00',
|
||||
year: 2026,
|
||||
}
|
||||
|
||||
vi.setSystemTime(new Date('2026-06-06T15:15:00Z'))
|
||||
mockFetchSeasons.mockResolvedValue([2026])
|
||||
mockFetchLocalMeetings.mockResolvedValue([canada])
|
||||
mockFetchSeasonMeetings.mockResolvedValue([canada, monaco])
|
||||
mockFetchWeekend.mockResolvedValue({ ...weekend, meeting: canada, meeting_key: canada.meeting_key })
|
||||
mockFetchSessions.mockResolvedValue([
|
||||
{
|
||||
session_key: 9602,
|
||||
session_name: 'Qualifying',
|
||||
session_type: 'Qualifying',
|
||||
meeting_key: monaco.meeting_key,
|
||||
date_start: '2026-06-06T15:00:00+00:00',
|
||||
date_end: '2026-06-06T16:00:00+00:00',
|
||||
gmt_offset: '02:00:00',
|
||||
},
|
||||
])
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
|
||||
})
|
||||
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Live now')
|
||||
expect(screen.getByTestId('cc-session-9602')).toHaveTextContent('On track')
|
||||
expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live')
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
67
frontend/src/test/Nav.test.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import {
|
||||
Outlet,
|
||||
RouterProvider,
|
||||
createRouter,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createMemoryHistory,
|
||||
} from '@tanstack/react-router'
|
||||
import { Nav } from '../components/Nav'
|
||||
|
||||
function renderNav() {
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<>
|
||||
<Nav />
|
||||
<Outlet />
|
||||
</>
|
||||
),
|
||||
})
|
||||
const stub = (p: string, id: string) =>
|
||||
createRoute({ getParentRoute: () => rootRoute, path: p, component: () => <div data-testid={id} /> })
|
||||
const router = createRouter({
|
||||
routeTree: rootRoute.addChildren([
|
||||
stub('/', 'home'),
|
||||
stub('/championship', 'championship'),
|
||||
stub('/briefing', 'briefing'),
|
||||
stub('/explore', 'explore'),
|
||||
stub('/admin', 'admin'),
|
||||
]),
|
||||
history: createMemoryHistory({ initialEntries: ['/'] }),
|
||||
})
|
||||
return render(<RouterProvider router={router} />)
|
||||
}
|
||||
|
||||
const DESTINATIONS = ['Weekend', 'Championship', 'Briefing', 'Explore']
|
||||
|
||||
describe('Nav — one primary navigation system per breakpoint', () => {
|
||||
it('exposes exactly two Primary landmarks (one per breakpoint), never more', async () => {
|
||||
renderNav()
|
||||
await waitFor(() => expect(screen.getAllByRole('navigation', { name: 'Primary' })).toHaveLength(2))
|
||||
})
|
||||
|
||||
it('every Primary landmark contains all four destinations', async () => {
|
||||
renderNav()
|
||||
await waitFor(() => expect(screen.getAllByRole('navigation', { name: 'Primary' }).length).toBe(2))
|
||||
const primaries = screen.getAllByRole('navigation', { name: 'Primary' })
|
||||
for (const nav of primaries) {
|
||||
for (const label of DESTINATIONS) {
|
||||
expect(within(nav).getByRole('link', { name: new RegExp(`^${label}$`) })).toBeInTheDocument()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps Admin out of every Primary landmark (operator utility only)', async () => {
|
||||
renderNav()
|
||||
await waitFor(() => expect(screen.getAllByRole('navigation', { name: 'Primary' }).length).toBe(2))
|
||||
const primaries = screen.getAllByRole('navigation', { name: 'Primary' })
|
||||
for (const nav of primaries) {
|
||||
expect(within(nav).queryByRole('link', { name: /Admin/i })).not.toBeInTheDocument()
|
||||
}
|
||||
// Admin lives in the operator utilities toolbar.
|
||||
const toolbar = screen.getByRole('toolbar', { name: 'Operator utilities' })
|
||||
expect(within(toolbar).getByRole('link', { name: /Admin/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import {
|
||||
@@ -9,21 +9,38 @@ import {
|
||||
createRoute,
|
||||
} from '@tanstack/react-router'
|
||||
import { RaceHubPage } from '../pages/RaceHubPage'
|
||||
import type { DatasetInfo, Meeting, RaceHub, Session, Weekend } from '../types'
|
||||
import type {
|
||||
DatasetInfo,
|
||||
Meeting,
|
||||
RaceHub,
|
||||
Session,
|
||||
Weekend,
|
||||
WeekendContext,
|
||||
} from '../types'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
fetchRaceHub: vi.fn(),
|
||||
fetchSeasons: vi.fn(),
|
||||
fetchLocalMeetings: vi.fn(),
|
||||
fetchWeekend: vi.fn(),
|
||||
fetchWeekendContext: vi.fn(),
|
||||
}))
|
||||
|
||||
import { fetchRaceHub, fetchSeasons, fetchLocalMeetings, fetchWeekend } from '../api'
|
||||
import {
|
||||
fetchRaceHub,
|
||||
fetchSeasons,
|
||||
fetchLocalMeetings,
|
||||
fetchWeekend,
|
||||
fetchWeekendContext,
|
||||
} from '../api'
|
||||
|
||||
const mockFetchRaceHub = vi.mocked(fetchRaceHub)
|
||||
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
||||
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
|
||||
const mockFetchWeekend = vi.mocked(fetchWeekend)
|
||||
const mockFetchWeekendContext = vi.mocked(fetchWeekendContext)
|
||||
|
||||
const NOW = new Date('2025-06-01T00:00:00Z')
|
||||
|
||||
const meeting: Meeting = {
|
||||
meeting_key: 1229,
|
||||
@@ -59,6 +76,16 @@ const qualSession: Session = {
|
||||
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> = {
|
||||
meeting: { 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) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
@@ -181,6 +243,19 @@ function renderRaceHub(sessionKey: number) {
|
||||
</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({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/race-hub',
|
||||
@@ -194,11 +269,10 @@ function renderRaceHub(sessionKey: number) {
|
||||
},
|
||||
})
|
||||
const router = createRouter({
|
||||
routeTree: rootRoute.addChildren([raceHubRoute]),
|
||||
routeTree: rootRoute.addChildren([weekendRoute, raceHubRoute]),
|
||||
history: undefined,
|
||||
})
|
||||
|
||||
// Navigate to the URL before mounting
|
||||
router.navigate({ to: '/race-hub', search: sessionKey ? { session_key: sessionKey } : {} })
|
||||
return render(<RouterProvider router={router} />)
|
||||
}
|
||||
@@ -206,10 +280,17 @@ function renderRaceHub(sessionKey: number) {
|
||||
describe('RaceHubPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
vi.setSystemTime(NOW)
|
||||
mockFetchSeasons.mockResolvedValue([2025])
|
||||
mockFetchLocalMeetings.mockResolvedValue([meeting])
|
||||
mockFetchWeekend.mockResolvedValue(weekend)
|
||||
mockFetchRaceHub.mockResolvedValue(raceHub)
|
||||
mockFetchWeekendContext.mockResolvedValue(weekendContext)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
// Overview is default
|
||||
expect(screen.getByTestId('rh-overview')).toBeInTheDocument()
|
||||
expect(screen.getByText('Winner')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Local Coverage')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('exposes Race Story sub-controls for classification, grid, and positions', async () => {
|
||||
@@ -236,17 +317,43 @@ describe('RaceHubPage', () => {
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Race Story' }))
|
||||
|
||||
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)
|
||||
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Data Status' }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Diagnostics' }))
|
||||
|
||||
expect(screen.getByTestId('rh-data-status')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument()
|
||||
|
||||
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 () => {
|
||||
@@ -256,4 +363,213 @@ describe('RaceHubPage', () => {
|
||||
fireEvent.click(screen.getByTestId('rh-switch-weekend'))
|
||||
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'
|
||||
|
||||
describe('TabBar', () => {
|
||||
it('renders all Race Hub workspace tabs', () => {
|
||||
it('renders all Race Hub workspace tabs grouped into a hierarchy', () => {
|
||||
render(<TabBar active="overview" onChange={() => {}} />)
|
||||
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Race Story' })).toBeInTheDocument()
|
||||
@@ -12,7 +12,11 @@ describe('TabBar', () => {
|
||||
expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Conditions' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Data Status' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Diagnostics' })).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByTestId('rh-tabgroup-story')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('rh-tabgroup-analysis')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('rh-tabgroup-context')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks the active tab with aria-selected', () => {
|
||||
|
||||
357
frontend/src/test/WeekendPage.test.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import {
|
||||
Outlet,
|
||||
RouterProvider,
|
||||
createRouter,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createMemoryHistory,
|
||||
} from '@tanstack/react-router'
|
||||
import { WeekendPage } from '../pages/WeekendPage'
|
||||
import type {
|
||||
ChampionshipHub,
|
||||
ContextAvailability,
|
||||
ContextSession,
|
||||
Meeting,
|
||||
RaceHub,
|
||||
Session,
|
||||
TemporalState,
|
||||
WeekendContext,
|
||||
} from '../types'
|
||||
import { parseWeekendSearch } from '../lib/routeSearch'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
fetchWeekendContext: vi.fn(),
|
||||
fetchChampionshipHub: vi.fn(),
|
||||
fetchNews: vi.fn(),
|
||||
fetchRaceHub: vi.fn(),
|
||||
fetchWeekend: vi.fn(),
|
||||
// Consumed transitively by RacePreviewPage (folded into PreSessionView):
|
||||
fetchSeasons: vi.fn(),
|
||||
fetchMeetings: vi.fn(),
|
||||
fetchSessions: vi.fn(),
|
||||
fetchResults: vi.fn(),
|
||||
fetchStartingGrid: vi.fn(),
|
||||
fetchTrackOutline: vi.fn(),
|
||||
}))
|
||||
|
||||
import {
|
||||
fetchWeekendContext,
|
||||
fetchChampionshipHub,
|
||||
fetchNews,
|
||||
fetchRaceHub,
|
||||
fetchWeekend,
|
||||
fetchSeasons,
|
||||
fetchMeetings,
|
||||
fetchSessions,
|
||||
fetchResults,
|
||||
fetchStartingGrid,
|
||||
fetchTrackOutline,
|
||||
} from '../api'
|
||||
|
||||
const mockContext = vi.mocked(fetchWeekendContext)
|
||||
const mockHub = vi.mocked(fetchChampionshipHub)
|
||||
const mockNews = vi.mocked(fetchNews)
|
||||
const mockRaceHub = vi.mocked(fetchRaceHub)
|
||||
const mockWeekend = vi.mocked(fetchWeekend)
|
||||
const mockSeasons = vi.mocked(fetchSeasons)
|
||||
const mockMeetings = vi.mocked(fetchMeetings)
|
||||
const mockSessions = vi.mocked(fetchSessions)
|
||||
const mockResults = vi.mocked(fetchResults)
|
||||
const mockGrid = vi.mocked(fetchStartingGrid)
|
||||
const mockTrack = vi.mocked(fetchTrackOutline)
|
||||
|
||||
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 meeting(overrides: Partial<Meeting> = {}): Meeting {
|
||||
return {
|
||||
meeting_key: 1,
|
||||
meeting_name: 'British Grand Prix',
|
||||
meeting_official_name: 'FORMULA 1 BRITISH GRAND PRIX',
|
||||
location: 'Silverstone',
|
||||
country_name: 'United Kingdom',
|
||||
country_code: 'GBR',
|
||||
country_flag: '',
|
||||
circuit_key: 2,
|
||||
circuit_short_name: 'Silverstone',
|
||||
date_start: '2026-07-03T09:00:00Z',
|
||||
date_end: '2026-07-05T16:00:00Z',
|
||||
year: 2026,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function session(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
session_key: 11,
|
||||
session_name: 'Race',
|
||||
session_type: 'Race',
|
||||
meeting_key: 1,
|
||||
date_start: '2026-07-05T14:00:00Z',
|
||||
date_end: '2026-07-05T16:00:00Z',
|
||||
gmt_offset: '',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function ctxSession(overrides: Partial<ContextSession> = {}): ContextSession {
|
||||
return {
|
||||
session: session(),
|
||||
meeting: meeting(),
|
||||
availability: availability(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function context(overrides: Partial<WeekendContext> = {}): WeekendContext {
|
||||
return {
|
||||
season: 2026,
|
||||
temporal_state: 'between_weekends',
|
||||
championship_round: 5,
|
||||
total_championship_rounds: 24,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const hub: ChampionshipHub = {
|
||||
season: 2026,
|
||||
round: 5,
|
||||
total_rounds: 24,
|
||||
rounds_left: 19,
|
||||
last_race: 'British GP',
|
||||
round_labels: [],
|
||||
drivers: [
|
||||
{ driver_number: 1, name_acronym: 'VER', full_name: 'Max', team_name: 'RB', team_colour: '3671c6', points: 120, position: 1, wins: 4, podiums: 5, poles: 3, form: [], cumulative: [95, 120], teammate_wins: 0, teammate_losses: 0, round_positions: [] },
|
||||
],
|
||||
teams: [],
|
||||
}
|
||||
|
||||
const raceHub = {
|
||||
session_key: 11,
|
||||
results: [
|
||||
{ driver_number: 1, position: 1, name_acronym: 'VER', full_name: 'Max', team_name: 'RB', team_colour: '3671c6', dnf: false, dns: false, dsq: false, duration: 5400, gap_to_leader: null },
|
||||
],
|
||||
} as unknown as RaceHub
|
||||
|
||||
function renderAt(path: string) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Outlet />
|
||||
</QueryClientProvider>
|
||||
),
|
||||
})
|
||||
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({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/preview',
|
||||
component: () => <WeekendPage preview />,
|
||||
})
|
||||
const raceHubRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/race-hub',
|
||||
validateSearch: (s: Record<string, unknown>) => {
|
||||
const sk = Number(s.session_key)
|
||||
return Number.isFinite(sk) && sk > 0 ? { session_key: sk } : {}
|
||||
},
|
||||
component: () => <div data-testid="race-hub-stub" />,
|
||||
})
|
||||
const stub = (p: string, id: string) =>
|
||||
createRoute({ getParentRoute: () => rootRoute, path: p, component: () => <div data-testid={id} /> })
|
||||
|
||||
const router = createRouter({
|
||||
routeTree: rootRoute.addChildren([
|
||||
homeRoute,
|
||||
previewRoute,
|
||||
raceHubRoute,
|
||||
stub('/live', 'live-stub'),
|
||||
stub('/explore', 'explore-stub'),
|
||||
stub('/championship', 'championship-stub'),
|
||||
stub('/briefing', 'briefing-stub'),
|
||||
]),
|
||||
history: createMemoryHistory({ initialEntries: [path] }),
|
||||
})
|
||||
return render(<RouterProvider router={router} />)
|
||||
}
|
||||
|
||||
describe('WeekendPage canonical contract rendering', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.setSystemTime(new Date('2026-07-08T12:00:00Z'))
|
||||
mockHub.mockResolvedValue(hub)
|
||||
mockNews.mockResolvedValue([])
|
||||
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])
|
||||
mockMeetings.mockResolvedValue([meeting()])
|
||||
mockSessions.mockResolvedValue([session()])
|
||||
mockResults.mockResolvedValue([])
|
||||
mockGrid.mockResolvedValue([])
|
||||
mockTrack.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows the loading surface before the canonical context resolves', async () => {
|
||||
mockContext.mockReturnValue(new Promise(() => {}))
|
||||
renderAt('/')
|
||||
await waitFor(() => expect(screen.getByTestId('weekend-loading')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('shows an explicit error surface when the canonical endpoint fails', async () => {
|
||||
mockContext.mockRejectedValue(new Error('API 500: boom'))
|
||||
renderAt('/')
|
||||
await waitFor(() => expect(screen.getByTestId('weekend-error')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('weekend-error')).toHaveTextContent('boom')
|
||||
})
|
||||
|
||||
const stateCases: Array<[TemporalState, string]> = [
|
||||
['between_weekends', 'weekend-between-races'],
|
||||
['post_weekend', 'weekend-between-races'],
|
||||
['season_complete', 'weekend-between-races'],
|
||||
['pre_session', 'weekend-pre-session'],
|
||||
['between_sessions', 'weekend-between-sessions'],
|
||||
['session_settling', 'weekend-between-sessions'],
|
||||
['session_live', 'weekend-live'],
|
||||
]
|
||||
|
||||
it.each(stateCases)('renders a designed surface for canonical temporal_state %s', async (temporal, testid) => {
|
||||
mockContext.mockResolvedValue(
|
||||
context({
|
||||
temporal_state: temporal,
|
||||
previous_completed_session: ctxSession({ session: session({ session_key: 11, session_name: 'Race' }) }),
|
||||
default_analysis_session: ctxSession({ session: session({ session_key: 11 }) }),
|
||||
next_meeting: meeting({ meeting_key: 2, meeting_name: 'Hungarian Grand Prix', date_start: '2026-07-24T09:00:00Z' }),
|
||||
next_session: ctxSession({ session: session({ session_key: 21, session_name: 'Practice 1', date_start: '2026-07-24T09:00:00Z' }) }),
|
||||
active_session: temporal === 'session_live'
|
||||
? ctxSession({ session: session({ session_key: 12, session_name: 'Race' }), availability: availability({ live_session: 'active' }) })
|
||||
: undefined,
|
||||
focus_meeting: meeting(),
|
||||
}),
|
||||
)
|
||||
renderAt('/')
|
||||
await waitFor(() => expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-temporal-state', temporal))
|
||||
expect(screen.getByTestId(testid)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the limited surface for no_season and never falls through to it for a valid payload', async () => {
|
||||
mockContext.mockResolvedValue(context({ temporal_state: 'no_season', championship_round: 0, total_championship_rounds: 0 }))
|
||||
renderAt('/')
|
||||
await waitFor(() => expect(screen.getByTestId('weekend-limited')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('between-races pairs the completed analysis CTA with the next-event countdown and championship impact', async () => {
|
||||
mockContext.mockResolvedValue(
|
||||
context({
|
||||
previous_completed_session: ctxSession({ session: session({ session_key: 11, session_name: 'Race' }) }),
|
||||
default_analysis_session: ctxSession({ session: session({ session_key: 11 }) }),
|
||||
next_meeting: meeting({ meeting_key: 2, meeting_name: 'Hungarian Grand Prix', date_start: '2026-07-24T09:00:00Z' }),
|
||||
next_session: ctxSession({ session: session({ session_key: 21, session_name: 'Practice 1', date_start: '2026-07-24T09:00:00Z' }) }),
|
||||
}),
|
||||
)
|
||||
renderAt('/')
|
||||
await waitFor(() => expect(screen.getByTestId('wk-last-event')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('wk-next-event')).toBeInTheDocument()
|
||||
const story = screen.getByTestId('wk-explore-race-story')
|
||||
expect(story).toHaveAttribute('href', expect.stringContaining('session_key=11'))
|
||||
expect(screen.getByTestId('wk-prepare')).toHaveAttribute('href', '/preview')
|
||||
await waitFor(() => expect(screen.getByTestId('wk-champ-impact')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('wk-season-nav')).toHaveTextContent('Round 5 of 24')
|
||||
})
|
||||
|
||||
it('does not fan out to season/meeting/OpenF1/live queries when the canonical context succeeds', async () => {
|
||||
mockContext.mockResolvedValue(context({ temporal_state: 'between_weekends' }))
|
||||
renderAt('/')
|
||||
await waitFor(() => expect(screen.getByTestId('weekend-between-races')).toBeInTheDocument())
|
||||
// Only supplementary championship + news reads are allowed; no season /
|
||||
// meetings / sessions / live fan-out from the Weekend home hook.
|
||||
expect(mockSeasons).not.toHaveBeenCalled()
|
||||
expect(mockSessions).not.toHaveBeenCalled()
|
||||
// fetchMeetings may still be reached only through the folded preview surface,
|
||||
// which is not mounted in the between-races state.
|
||||
expect(mockMeetings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('supplementary reads stay dormant while the canonical context is pending', async () => {
|
||||
mockContext.mockReturnValue(new Promise(() => {}))
|
||||
renderAt('/')
|
||||
await waitFor(() => expect(screen.getByTestId('weekend-loading')).toBeInTheDocument())
|
||||
expect(mockHub).not.toHaveBeenCalled()
|
||||
expect(mockNews).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('the /preview alias renders the preparation surface instead of looping back', async () => {
|
||||
mockContext.mockResolvedValue(
|
||||
context({
|
||||
temporal_state: 'between_weekends',
|
||||
next_meeting: meeting({ meeting_key: 2, meeting_name: 'Hungarian Grand Prix', date_start: '2026-07-24T09:00:00Z' }),
|
||||
next_session: ctxSession({ session: session({ session_key: 21, session_name: 'Practice 1', date_start: '2026-07-24T09:00:00Z' }) }),
|
||||
}),
|
||||
)
|
||||
renderAt('/preview')
|
||||
await waitFor(() => expect(screen.getByTestId('weekend-pre-session')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-preview', 'true')
|
||||
// The between-races surface must NOT be what /preview renders.
|
||||
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')
|
||||
})
|
||||
})
|
||||
223
frontend/src/test/weekendContext.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
analysisSessionKey,
|
||||
briefingItems,
|
||||
championshipImpact,
|
||||
hasLocalAnalysis,
|
||||
meetingIdentity,
|
||||
podiumFromResults,
|
||||
resolveViewState,
|
||||
} from '../lib/weekendContext'
|
||||
import type {
|
||||
ChampionshipHub,
|
||||
ContextAvailability,
|
||||
ContextSession,
|
||||
EnrichedResult,
|
||||
Meeting,
|
||||
NewsItem,
|
||||
Session,
|
||||
TemporalState,
|
||||
WeekendContext,
|
||||
} from '../types'
|
||||
|
||||
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 meeting(overrides: Partial<Meeting> = {}): Meeting {
|
||||
return {
|
||||
meeting_key: 1,
|
||||
meeting_name: 'British Grand Prix',
|
||||
meeting_official_name: 'FORMULA 1 BRITISH GRAND PRIX',
|
||||
location: 'Silverstone',
|
||||
country_name: 'United Kingdom',
|
||||
country_code: 'GBR',
|
||||
country_flag: '',
|
||||
circuit_key: 2,
|
||||
circuit_short_name: 'Silverstone',
|
||||
date_start: '2026-07-03T09:00:00Z',
|
||||
date_end: '2026-07-05T16:00:00Z',
|
||||
year: 2026,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function session(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
session_key: 11,
|
||||
session_name: 'Race',
|
||||
session_type: 'Race',
|
||||
meeting_key: 1,
|
||||
date_start: '2026-07-05T14:00:00Z',
|
||||
date_end: '2026-07-05T16:00:00Z',
|
||||
gmt_offset: '',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function ctxSession(overrides: Partial<ContextSession> = {}): ContextSession {
|
||||
return {
|
||||
session: session(),
|
||||
meeting: meeting(),
|
||||
availability: availability(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function context(overrides: Partial<WeekendContext> = {}): WeekendContext {
|
||||
return {
|
||||
season: 2026,
|
||||
temporal_state: 'between_weekends',
|
||||
championship_round: 1,
|
||||
total_championship_rounds: 24,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveViewState', () => {
|
||||
const cases: Array<[TemporalState, string]> = [
|
||||
['no_season', 'no_season'],
|
||||
['between_weekends', 'between_weekends'],
|
||||
['pre_session', 'pre_session'],
|
||||
['session_live', 'session_live'],
|
||||
['session_settling', 'session_settling'],
|
||||
['between_sessions', 'between_sessions'],
|
||||
['post_weekend', 'post_weekend'],
|
||||
['season_complete', 'season_complete'],
|
||||
]
|
||||
|
||||
it.each(cases)('maps canonical temporal_state %s to view %s', (temporal, expected) => {
|
||||
expect(resolveViewState(context({ temporal_state: temporal }))).toBe(expected)
|
||||
})
|
||||
|
||||
it('treats an unknown/absent temporal_state as no_season rather than inventing a state', () => {
|
||||
expect(resolveViewState(context({ temporal_state: 'garbage' as TemporalState }))).toBe('no_season')
|
||||
})
|
||||
})
|
||||
|
||||
describe('analysisSessionKey', () => {
|
||||
it('prefers the default analysis session', () => {
|
||||
const ctx = context({
|
||||
default_analysis_session: ctxSession({ session: session({ session_key: 32 }) }),
|
||||
previous_completed_session: ctxSession({ session: session({ session_key: 11 }) }),
|
||||
})
|
||||
expect(analysisSessionKey(ctx)).toBe(32)
|
||||
})
|
||||
|
||||
it('falls back to the previous completed session', () => {
|
||||
const ctx = context({
|
||||
previous_completed_session: ctxSession({ session: session({ session_key: 11 }) }),
|
||||
})
|
||||
expect(analysisSessionKey(ctx)).toBe(11)
|
||||
})
|
||||
|
||||
it('returns undefined when there is no analysable session', () => {
|
||||
expect(analysisSessionKey(context())).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for a zero/synthetic session key', () => {
|
||||
const ctx = context({
|
||||
previous_completed_session: ctxSession({ session: session({ session_key: 0 }) }),
|
||||
})
|
||||
expect(analysisSessionKey(ctx)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasLocalAnalysis', () => {
|
||||
it('is true for complete or partial local analysis', () => {
|
||||
expect(hasLocalAnalysis(ctxSession({ availability: availability({ local_analysis: 'complete' }) }))).toBe(true)
|
||||
expect(hasLocalAnalysis(ctxSession({ availability: availability({ local_analysis: 'partial' }) }))).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for pending/not_applicable/absent', () => {
|
||||
expect(hasLocalAnalysis(ctxSession({ availability: availability({ local_analysis: 'pending' }) }))).toBe(false)
|
||||
expect(hasLocalAnalysis(ctxSession({ availability: availability({ local_analysis: 'not_applicable' }) }))).toBe(false)
|
||||
expect(hasLocalAnalysis(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('meetingIdentity', () => {
|
||||
it('derives a short name and tolerates partial fields', () => {
|
||||
const id = meetingIdentity(meeting({ meeting_name: 'Monaco Grand Prix', circuit_short_name: '', location: 'Monaco' }))
|
||||
expect(id?.short_name).toBe('Monaco')
|
||||
expect(id?.circuit_short_name).toBe('Monaco')
|
||||
})
|
||||
|
||||
it('returns undefined for an absent meeting', () => {
|
||||
expect(meetingIdentity(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops empty date strings', () => {
|
||||
const id = meetingIdentity(meeting({ date_start: '', date_end: '' }))
|
||||
expect(id?.date_start).toBeUndefined()
|
||||
expect(id?.date_end).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('championshipImpact', () => {
|
||||
const hub: ChampionshipHub = {
|
||||
season: 2026,
|
||||
round: 2,
|
||||
total_rounds: 24,
|
||||
rounds_left: 22,
|
||||
last_race: 'British GP',
|
||||
round_labels: ['R1', 'R2'],
|
||||
drivers: [
|
||||
{ driver_number: 1, name_acronym: 'VER', full_name: 'Max', team_name: 'RB', team_colour: '3671c6', points: 50, position: 1, wins: 2, podiums: 2, poles: 1, form: [], cumulative: [25, 50], teammate_wins: 0, teammate_losses: 0, round_positions: [] },
|
||||
{ driver_number: 4, name_acronym: 'NOR', full_name: 'Lando', team_name: 'McL', team_colour: 'ff8000', points: 40, position: 2, wins: 1, podiums: 2, poles: 0, form: [], cumulative: [18, 40], teammate_wins: 0, teammate_losses: 0, round_positions: [] },
|
||||
],
|
||||
teams: [],
|
||||
}
|
||||
|
||||
it('maps top-3 movers with deltas and a note', () => {
|
||||
const impact = championshipImpact(hub)
|
||||
expect(impact?.leaders).toHaveLength(2)
|
||||
expect(impact?.leaders[0].delta).toBe(25)
|
||||
expect(impact?.note).toContain('British GP')
|
||||
})
|
||||
|
||||
it('returns undefined for an empty hub', () => {
|
||||
expect(championshipImpact(undefined)).toBeUndefined()
|
||||
expect(championshipImpact({ ...hub, drivers: [] })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('briefingItems', () => {
|
||||
it('caps at three items and maps fields', () => {
|
||||
const news: NewsItem[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
source: 'src',
|
||||
title: `Item ${i}`,
|
||||
url: `https://x/${i}`,
|
||||
fetched_at: '2026-07-06T00:00:00Z',
|
||||
category: 'news',
|
||||
og_image_url: 'img',
|
||||
}))
|
||||
const items = briefingItems(news)
|
||||
expect(items).toHaveLength(3)
|
||||
expect(items[0]).toMatchObject({ title: 'Item 0', image_url: 'img' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('podiumFromResults', () => {
|
||||
it('sorts by position, caps at three, and formats gaps', () => {
|
||||
const results: EnrichedResult[] = [
|
||||
{ driver_number: 44, position: 2, name_acronym: 'HAM', full_name: 'Lewis', team_name: 'Ferrari', team_colour: 'e8002d', dnf: false, dns: false, dsq: false, duration: null, gap_to_leader: 5.123 } as EnrichedResult,
|
||||
{ driver_number: 1, position: 1, name_acronym: 'VER', full_name: 'Max', team_name: 'RB', team_colour: '3671c6', dnf: false, dns: false, dsq: false, duration: 5400, gap_to_leader: null } as EnrichedResult,
|
||||
{ driver_number: 16, position: 3, name_acronym: 'LEC', full_name: 'Charles', team_name: 'Ferrari', team_colour: 'e8002d', dnf: false, dns: false, dsq: false, duration: null, gap_to_leader: '+10.5' } as EnrichedResult,
|
||||
{ driver_number: 55, position: 4, name_acronym: 'SAI', full_name: 'Carlos', team_name: 'W', team_colour: 'fff', dnf: false, dns: false, dsq: false, duration: null, gap_to_leader: 20 } as EnrichedResult,
|
||||
]
|
||||
const podium = podiumFromResults(results)
|
||||
expect(podium.map((p) => p.name_acronym)).toEqual(['VER', 'HAM', 'LEC'])
|
||||
expect(podium[1].gap).toBe('+5.123')
|
||||
expect(podium[2].gap).toBe('+10.5')
|
||||
})
|
||||
})
|
||||
@@ -448,6 +448,107 @@ export interface DriverSummary {
|
||||
rounds: DriverSummaryRound[]
|
||||
}
|
||||
|
||||
// ── Weekend Context ──
|
||||
// Canonical local-first contract for the adaptive Weekend home, served verbatim
|
||||
// by /api/v1/weekend-context (backend story #72, internal/query/context.go).
|
||||
// The frontend consumes this shape as the single source of truth; view-model
|
||||
// derivation lives in lib/weekendContext.ts.
|
||||
|
||||
// TemporalState mirrors query.TemporalState exactly (the JSON `temporal_state`).
|
||||
export type TemporalState =
|
||||
| 'no_season'
|
||||
| 'between_weekends'
|
||||
| 'pre_session'
|
||||
| 'session_live'
|
||||
| 'session_settling'
|
||||
| 'between_sessions'
|
||||
| 'post_weekend'
|
||||
| 'season_complete'
|
||||
|
||||
// ContextAvailability mirrors query.ContextAvailability. Every field is present
|
||||
// in a canonical payload except the optional `observed_at`.
|
||||
export interface ContextAvailability {
|
||||
schedule: string
|
||||
live_transport: string
|
||||
live_session: string
|
||||
archive: string
|
||||
local_analysis: string
|
||||
freshness: string
|
||||
observed_at?: string
|
||||
limitations: string[]
|
||||
}
|
||||
|
||||
// ContextSession mirrors query.ContextSession: a session identity coupled with
|
||||
// its meeting and structured availability contract.
|
||||
export interface ContextSession {
|
||||
session: Session
|
||||
meeting?: Meeting
|
||||
availability: ContextAvailability
|
||||
}
|
||||
|
||||
// WeekendContext mirrors query.WeekendContext (the canonical endpoint body).
|
||||
export interface WeekendContext {
|
||||
season?: number
|
||||
temporal_state: TemporalState
|
||||
previous_meeting?: Meeting
|
||||
focus_meeting?: Meeting
|
||||
next_meeting?: Meeting
|
||||
previous_completed_session?: ContextSession
|
||||
active_session?: ContextSession
|
||||
next_session?: ContextSession
|
||||
default_analysis_session?: ContextSession
|
||||
championship_round: number
|
||||
total_championship_rounds: number
|
||||
}
|
||||
|
||||
// ── Weekend view model (client-only) ──
|
||||
// The rendered Weekend home layers a small set of non-canonical UI states
|
||||
// (loading/error) plus supplementary data (championship movers, briefing) on top
|
||||
// of the canonical context. None of these are part of the #72 contract.
|
||||
export type WeekendViewState =
|
||||
| 'loading'
|
||||
| 'error'
|
||||
| 'no_season'
|
||||
| 'between_weekends'
|
||||
| 'pre_session'
|
||||
| 'session_live'
|
||||
| 'session_settling'
|
||||
| 'between_sessions'
|
||||
| 'post_weekend'
|
||||
| 'season_complete'
|
||||
|
||||
export interface WeekendChampionshipMover {
|
||||
position: number
|
||||
driver_number: number
|
||||
name_acronym: string
|
||||
team_colour: string
|
||||
points: number
|
||||
delta?: number
|
||||
}
|
||||
|
||||
export interface WeekendChampionshipImpact {
|
||||
leaders: WeekendChampionshipMover[]
|
||||
note?: string
|
||||
}
|
||||
|
||||
export interface WeekendBriefingItem {
|
||||
category?: string
|
||||
title: string
|
||||
url: string
|
||||
source: string
|
||||
published_at?: string
|
||||
image_url?: string
|
||||
}
|
||||
|
||||
export interface WeekendPodiumEntry {
|
||||
position: number
|
||||
driver_number: number
|
||||
name_acronym: string
|
||||
team_name: string
|
||||
team_colour: string
|
||||
gap: string
|
||||
}
|
||||
|
||||
export interface NewsItem {
|
||||
source: string
|
||||
title: string
|
||||
|
||||
435
internal/query/context.go
Normal file
@@ -0,0 +1,435 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
"github.com/AmanTahiliani/box-box/internal/store"
|
||||
)
|
||||
|
||||
// TemporalState describes where the fan is in the current season/weekend.
|
||||
type TemporalState string
|
||||
|
||||
const (
|
||||
TemporalNoSeason TemporalState = "no_season"
|
||||
TemporalBetweenWeekends TemporalState = "between_weekends"
|
||||
TemporalPreSession TemporalState = "pre_session"
|
||||
TemporalSessionLive TemporalState = "session_live"
|
||||
TemporalSessionSettling TemporalState = "session_settling"
|
||||
TemporalBetweenSessions TemporalState = "between_sessions"
|
||||
TemporalPostWeekend TemporalState = "post_weekend"
|
||||
TemporalSeasonComplete TemporalState = "season_complete"
|
||||
|
||||
preSessionWindow = 48 * time.Hour
|
||||
postWeekendWindow = 48 * time.Hour
|
||||
)
|
||||
|
||||
// LiveEvidence is the small, transport-independent subset of FIA state needed
|
||||
// by the context resolver. Active identity is authoritative over the schedule.
|
||||
type LiveEvidence struct {
|
||||
Active bool
|
||||
Final bool
|
||||
MeetingName string
|
||||
CircuitName string
|
||||
SessionName string
|
||||
SessionType string
|
||||
ObservedAt time.Time
|
||||
}
|
||||
|
||||
// ContextAvailability is structured source state for a referenced session.
|
||||
type ContextAvailability struct {
|
||||
Schedule string `json:"schedule"`
|
||||
LiveTransport string `json:"live_transport"`
|
||||
LiveSession string `json:"live_session"`
|
||||
Archive string `json:"archive"`
|
||||
LocalAnalysis string `json:"local_analysis"`
|
||||
Freshness string `json:"freshness"`
|
||||
ObservedAt string `json:"observed_at,omitempty"`
|
||||
Limitations []string `json:"limitations"`
|
||||
}
|
||||
|
||||
// ContextSession couples a session identity with its availability contract.
|
||||
type ContextSession struct {
|
||||
Session models.Session `json:"session"`
|
||||
Meeting *models.Meeting `json:"meeting,omitempty"`
|
||||
Availability ContextAvailability `json:"availability"`
|
||||
}
|
||||
|
||||
// WeekendContext is the canonical local-first previous/current/next model.
|
||||
type WeekendContext struct {
|
||||
Season int `json:"season,omitempty"`
|
||||
TemporalState TemporalState `json:"temporal_state"`
|
||||
PreviousMeeting *models.Meeting `json:"previous_meeting,omitempty"`
|
||||
FocusMeeting *models.Meeting `json:"focus_meeting,omitempty"`
|
||||
NextMeeting *models.Meeting `json:"next_meeting,omitempty"`
|
||||
PreviousCompletedSession *ContextSession `json:"previous_completed_session,omitempty"`
|
||||
ActiveSession *ContextSession `json:"active_session,omitempty"`
|
||||
NextSession *ContextSession `json:"next_session,omitempty"`
|
||||
DefaultAnalysisSession *ContextSession `json:"default_analysis_session,omitempty"`
|
||||
ChampionshipRound int `json:"championship_round"`
|
||||
TotalChampionshipRounds int `json:"total_championship_rounds"`
|
||||
}
|
||||
|
||||
type contextCandidate struct {
|
||||
meeting store.Meeting
|
||||
session store.Session
|
||||
start time.Time
|
||||
end time.Time
|
||||
counts store.SessionDatasetCounts
|
||||
complete bool
|
||||
archived bool
|
||||
}
|
||||
|
||||
// ResolveWeekendContext computes the canonical context using only the domain
|
||||
// store, the service clock, and optional in-memory FIA evidence.
|
||||
func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext, error) {
|
||||
now := s.now().UTC()
|
||||
out := WeekendContext{TemporalState: TemporalNoSeason}
|
||||
years, err := s.store.ListYears()
|
||||
if err != nil || len(years) == 0 {
|
||||
return out, err
|
||||
}
|
||||
out.Season = currentLocalSeason(years, now.Year())
|
||||
meetings, err := s.store.ListMeetingsByYear(out.Season)
|
||||
if err != nil {
|
||||
return WeekendContext{}, err
|
||||
}
|
||||
if len(meetings) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
byMeeting := make(map[int][]store.Session, len(meetings))
|
||||
var candidates []contextCandidate
|
||||
for i := range meetings {
|
||||
m := meetings[i]
|
||||
sessions, listErr := s.store.ListSessionsByMeeting(m.MeetingKey)
|
||||
if listErr != nil {
|
||||
return WeekendContext{}, listErr
|
||||
}
|
||||
byMeeting[m.MeetingKey] = sessions
|
||||
applySessionDisplayRange(&meetings[i], sessions)
|
||||
m = meetings[i]
|
||||
for _, sess := range sessions {
|
||||
if m.IsCancelled || sess.IsCancelled {
|
||||
continue
|
||||
}
|
||||
start, _ := parseContextTime(sess.DateStart)
|
||||
end, _ := parseContextTime(sess.DateEnd)
|
||||
if end.IsZero() && !start.IsZero() {
|
||||
end = start.Add(3 * time.Hour)
|
||||
}
|
||||
counts, countErr := s.store.CountSessionDatasets(sess.SessionKey)
|
||||
if countErr != nil {
|
||||
return WeekendContext{}, countErr
|
||||
}
|
||||
archived := evidence.Final && liveMatches(evidence, m, sess)
|
||||
candidates = append(candidates, contextCandidate{
|
||||
meeting: m, session: sess, start: start, end: end, counts: counts,
|
||||
complete: hasMeaningfulAnalysis(counts) || archived, archived: archived,
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].start.Before(candidates[j].start) })
|
||||
|
||||
champMeetings := championshipMeetings(meetings, byMeeting)
|
||||
out.TotalChampionshipRounds = len(champMeetings)
|
||||
|
||||
var active *contextCandidate
|
||||
if evidence.Active {
|
||||
for i := range candidates {
|
||||
if liveMatches(evidence, candidates[i].meeting, candidates[i].session) {
|
||||
active = &candidates[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if active == nil {
|
||||
active = syntheticLiveCandidate(evidence, now)
|
||||
}
|
||||
}
|
||||
|
||||
var previous, next, defaultAnalysis *contextCandidate
|
||||
for i := range candidates {
|
||||
c := &candidates[i]
|
||||
isActive := active != nil && active.session.SessionKey != 0 && c.session.SessionKey == active.session.SessionKey
|
||||
completionEligible := c.start.IsZero() || !c.start.After(now) || c.archived
|
||||
if !isActive && c.complete && completionEligible && (previous == nil || candidateTime(*c).After(candidateTime(*previous))) {
|
||||
previous = c
|
||||
}
|
||||
if !isActive && c.complete && hasMeaningfulAnalysis(c.counts) && (c.start.IsZero() || !c.start.After(now)) && (defaultAnalysis == nil || candidateTime(*c).After(candidateTime(*defaultAnalysis))) {
|
||||
defaultAnalysis = c
|
||||
}
|
||||
if !isActive && !c.start.IsZero() && !c.start.Before(now) && (next == nil || c.start.Before(next.start)) {
|
||||
next = c
|
||||
}
|
||||
}
|
||||
|
||||
if previous != nil {
|
||||
out.PreviousCompletedSession = sessionRef(*previous, evidence, now)
|
||||
out.PreviousMeeting = meetingModelByKey(meetings, previous.meeting.MeetingKey)
|
||||
}
|
||||
if defaultAnalysis != nil {
|
||||
out.DefaultAnalysisSession = sessionRef(*defaultAnalysis, evidence, now)
|
||||
}
|
||||
if next != nil {
|
||||
out.NextSession = sessionRef(*next, evidence, now)
|
||||
out.NextMeeting = meetingModelByKey(meetings, next.meeting.MeetingKey)
|
||||
}
|
||||
if active != nil {
|
||||
out.ActiveSession = sessionRef(*active, evidence, now)
|
||||
out.FocusMeeting = out.ActiveSession.Meeting
|
||||
out.TemporalState = TemporalSessionLive
|
||||
} else {
|
||||
out.FocusMeeting = chooseFocusMeeting(meetings, previous, next)
|
||||
out.TemporalState = classifyTemporalState(now, previous, next, candidates, champMeetings, championshipScheduleUnknown(champMeetings, byMeeting))
|
||||
}
|
||||
if out.FocusMeeting != nil {
|
||||
out.ChampionshipRound = championshipRound(champMeetings, int(out.FocusMeeting.MeetingKey))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func currentLocalSeason(years []int, current int) int {
|
||||
for _, year := range years {
|
||||
if year == current {
|
||||
return year
|
||||
}
|
||||
}
|
||||
for _, year := range years {
|
||||
if year < current {
|
||||
return year
|
||||
}
|
||||
}
|
||||
return years[len(years)-1]
|
||||
}
|
||||
|
||||
func parseContextTime(value string) (time.Time, bool) {
|
||||
if value == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05"} {
|
||||
if parsed, err := time.Parse(layout, value); err == nil {
|
||||
return parsed.UTC(), true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func hasMeaningfulAnalysis(c store.SessionDatasetCounts) bool {
|
||||
return c.Results > 0 || c.Laps > 0 || c.Stints > 0 || c.Positions > 0 || c.RaceControl > 0
|
||||
}
|
||||
|
||||
func candidateTime(c contextCandidate) time.Time {
|
||||
if !c.end.IsZero() {
|
||||
return c.end
|
||||
}
|
||||
return c.start
|
||||
}
|
||||
|
||||
func liveMatches(e LiveEvidence, m store.Meeting, s store.Session) bool {
|
||||
meetingMatch := normalizedContains(e.MeetingName, m.MeetingName) || normalizedContains(e.CircuitName, m.CircuitShortName)
|
||||
sessionMatch := normalizedEqual(e.SessionName, s.SessionName)
|
||||
if normalizeIdentity(e.SessionName) == "" {
|
||||
sessionMatch = normalizedEqual(e.SessionType, s.SessionType)
|
||||
}
|
||||
return meetingMatch && sessionMatch
|
||||
}
|
||||
|
||||
func normalizedEqual(a, b string) bool {
|
||||
return normalizeIdentity(a) != "" && normalizeIdentity(a) == normalizeIdentity(b)
|
||||
}
|
||||
func normalizedContains(a, b string) bool {
|
||||
a, b = normalizeIdentity(a), normalizeIdentity(b)
|
||||
return a != "" && b != "" && (strings.Contains(a, b) || strings.Contains(b, a))
|
||||
}
|
||||
func normalizeIdentity(v string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
return r + ('a' - 'A')
|
||||
}
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, v)
|
||||
}
|
||||
|
||||
func syntheticLiveCandidate(e LiveEvidence, now time.Time) *contextCandidate {
|
||||
return &contextCandidate{meeting: store.Meeting{MeetingName: e.MeetingName, CircuitShortName: e.CircuitName}, session: store.Session{SessionName: e.SessionName, SessionType: e.SessionType, DateStart: now.Format(time.RFC3339)}, start: now}
|
||||
}
|
||||
|
||||
func applySessionDisplayRange(m *store.Meeting, sessions []store.Session) {
|
||||
var first, last time.Time
|
||||
for _, sess := range sessions {
|
||||
if sess.IsCancelled {
|
||||
continue
|
||||
}
|
||||
start, ok := parseContextTime(sess.DateStart)
|
||||
if ok && (first.IsZero() || start.Before(first)) {
|
||||
first = start
|
||||
}
|
||||
end, ok := parseContextTime(sess.DateEnd)
|
||||
if !ok {
|
||||
end = start
|
||||
}
|
||||
if !end.IsZero() && (last.IsZero() || end.After(last)) {
|
||||
last = end
|
||||
}
|
||||
}
|
||||
if !first.IsZero() {
|
||||
m.DateStart = first.Format(time.RFC3339)
|
||||
}
|
||||
if !last.IsZero() {
|
||||
m.DateEnd = last.Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
|
||||
func meetingModelByKey(meetings []store.Meeting, key int) *models.Meeting {
|
||||
for _, meeting := range meetings {
|
||||
if meeting.MeetingKey == key {
|
||||
model := meetingToModel(meeting)
|
||||
return &model
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sessionRef(c contextCandidate, evidence LiveEvidence, now time.Time) *ContextSession {
|
||||
session := sessionToModel(c.session)
|
||||
meeting := meetingToModel(c.meeting)
|
||||
availability := ContextAvailability{Schedule: "available", LiveSession: "inactive", Archive: "unavailable", Freshness: "fresh", Limitations: []string{}}
|
||||
availability.LiveTransport = "unknown"
|
||||
if c.session.SessionKey == 0 {
|
||||
availability.Schedule = "unavailable"
|
||||
availability.Limitations = append(availability.Limitations, "schedule_identity_unmatched")
|
||||
}
|
||||
if evidence.Active && liveMatches(evidence, c.meeting, c.session) {
|
||||
availability.LiveTransport = "connected"
|
||||
availability.LiveSession = "active"
|
||||
if !evidence.ObservedAt.IsZero() {
|
||||
availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
if c.archived {
|
||||
availability.Archive = "available"
|
||||
if !evidence.ObservedAt.IsZero() {
|
||||
availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
if c.counts.Results > 0 && (c.counts.Laps+c.counts.Stints+c.counts.Positions+c.counts.RaceControl > 0) {
|
||||
availability.LocalAnalysis = "complete"
|
||||
} else if hasMeaningfulAnalysis(c.counts) || c.counts.Drivers+c.counts.StartingGrid+c.counts.Weather > 0 {
|
||||
availability.LocalAnalysis = "partial"
|
||||
} else if !c.start.IsZero() && c.start.After(now) {
|
||||
availability.LocalAnalysis = "not_applicable"
|
||||
} else {
|
||||
availability.LocalAnalysis = "pending"
|
||||
}
|
||||
return &ContextSession{Session: session, Meeting: &meeting, Availability: availability}
|
||||
}
|
||||
|
||||
func chooseFocusMeeting(meetings []store.Meeting, previous, next *contextCandidate) *models.Meeting {
|
||||
if next != nil {
|
||||
return meetingModelByKey(meetings, next.meeting.MeetingKey)
|
||||
}
|
||||
if previous != nil {
|
||||
return meetingModelByKey(meetings, previous.meeting.MeetingKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func classifyTemporalState(now time.Time, previous, next *contextCandidate, candidates []contextCandidate, championship []store.Meeting, scheduleUnknown bool) TemporalState {
|
||||
var latestStarted *contextCandidate
|
||||
for i := range candidates {
|
||||
if !candidates[i].start.IsZero() && !candidates[i].start.After(now) && (latestStarted == nil || candidates[i].start.After(latestStarted.start)) {
|
||||
latestStarted = &candidates[i]
|
||||
}
|
||||
}
|
||||
// Once a new meeting enters its preparation window, an ingest gap from an
|
||||
// older meeting must not keep the product stuck in settling.
|
||||
if next != nil && next.start.Sub(now) <= preSessionWindow && (latestStarted == nil || latestStarted.meeting.MeetingKey != next.meeting.MeetingKey) {
|
||||
return TemporalPreSession
|
||||
}
|
||||
if latestStarted != nil && !latestStarted.complete && !latestStarted.end.IsZero() {
|
||||
if now.Before(latestStarted.end) {
|
||||
return TemporalPreSession
|
||||
}
|
||||
return TemporalSessionSettling
|
||||
}
|
||||
if previous != nil && next != nil && previous.meeting.MeetingKey == next.meeting.MeetingKey {
|
||||
return TemporalBetweenSessions
|
||||
}
|
||||
if previous != nil && meetingFinalSession(*previous, candidates) && !candidateTime(*previous).After(now) && now.Sub(candidateTime(*previous)) <= postWeekendWindow {
|
||||
return TemporalPostWeekend
|
||||
}
|
||||
if next != nil && next.start.Sub(now) <= preSessionWindow {
|
||||
return TemporalPreSession
|
||||
}
|
||||
if next == nil && len(championship) > 0 && !scheduleUnknown {
|
||||
return TemporalSeasonComplete
|
||||
}
|
||||
return TemporalBetweenWeekends
|
||||
}
|
||||
|
||||
func championshipScheduleUnknown(meetings []store.Meeting, sessions map[int][]store.Session) bool {
|
||||
for _, meeting := range meetings {
|
||||
for _, session := range sessions[meeting.MeetingKey] {
|
||||
if !session.IsCancelled && isChampionshipRace(session) {
|
||||
if _, ok := parseContextTime(session.DateStart); !ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func meetingFinalSession(previous contextCandidate, candidates []contextCandidate) bool {
|
||||
latest := previous.start
|
||||
for _, c := range candidates {
|
||||
if c.meeting.MeetingKey == previous.meeting.MeetingKey && c.start.After(latest) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func championshipMeetings(meetings []store.Meeting, sessions map[int][]store.Session) []store.Meeting {
|
||||
var out []store.Meeting
|
||||
for _, m := range meetings {
|
||||
if m.IsCancelled || isTestMeeting(m) {
|
||||
continue
|
||||
}
|
||||
for _, sess := range sessions[m.MeetingKey] {
|
||||
if !sess.IsCancelled && isChampionshipRace(sess) {
|
||||
out = append(out, m)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
a, _ := parseContextTime(out[i].DateStart)
|
||||
b, _ := parseContextTime(out[j].DateStart)
|
||||
return a.Before(b)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func isTestMeeting(m store.Meeting) bool {
|
||||
n := strings.ToLower(m.MeetingName + " " + m.MeetingOfficialName)
|
||||
return strings.Contains(n, "test")
|
||||
}
|
||||
func isChampionshipRace(s store.Session) bool {
|
||||
n := strings.ToLower(s.SessionName)
|
||||
t := strings.ToLower(s.SessionType)
|
||||
return (n == "race" || t == "race") && !strings.Contains(n, "sprint") && !strings.Contains(t, "sprint")
|
||||
}
|
||||
func championshipRound(meetings []store.Meeting, key int) int {
|
||||
for i, m := range meetings {
|
||||
if m.MeetingKey == key {
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
321
internal/query/context_test.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/store"
|
||||
)
|
||||
|
||||
func contextService(t *testing.T, now time.Time) *Service {
|
||||
t.Helper()
|
||||
base := openTestService(t)
|
||||
return NewServiceWithClock(base.store, func() time.Time { return now })
|
||||
}
|
||||
|
||||
func addContextMeeting(t *testing.T, svc *Service, key int, name, start, end string, cancelled bool) {
|
||||
t.Helper()
|
||||
if err := svc.store.UpsertMeeting(store.Meeting{MeetingKey: key, MeetingName: name, MeetingOfficialName: name, CircuitShortName: name, Year: 2026, DateStart: start, DateEnd: end, IsCancelled: cancelled}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func addContextSession(t *testing.T, svc *Service, key, meeting int, name, start, end string, cancelled bool) {
|
||||
t.Helper()
|
||||
if err := svc.store.UpsertSession(store.Session{SessionKey: key, MeetingKey: meeting, SessionName: name, SessionType: name, DateStart: start, DateEnd: end, IsCancelled: cancelled}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func completeContextSession(t *testing.T, svc *Service, key, meeting int) {
|
||||
t.Helper()
|
||||
if err := svc.store.UpsertSessionResult(store.SessionResult{SessionKey: key, MeetingKey: meeting, DriverNumber: 1, Position: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWeekendContextTemporalStates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
now string
|
||||
seed func(*testing.T, *Service)
|
||||
evidence LiveEvidence
|
||||
want TemporalState
|
||||
}{
|
||||
{name: "no season", now: "2026-06-01T12:00:00Z", want: TemporalNoSeason},
|
||||
{name: "between weekends", now: "2026-06-10T12:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||
addContextMeeting(t, s, 1, "Monaco Grand Prix", "2026-06-01T09:00:00Z", "2026-06-02T16:00:00Z", false)
|
||||
addContextSession(t, s, 11, 1, "Race", "2026-06-02T14:00:00Z", "2026-06-02T16:00:00Z", false)
|
||||
completeContextSession(t, s, 11, 1)
|
||||
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
|
||||
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||
}, want: TemporalBetweenWeekends},
|
||||
{name: "pre session", now: "2026-06-19T12:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
|
||||
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||
}, want: TemporalPreSession},
|
||||
{name: "session live overrides schedule", now: "2026-06-20T12:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
|
||||
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||
}, evidence: LiveEvidence{Active: true, MeetingName: "Canadian Grand Prix", CircuitName: "Canada Grand Prix", SessionName: "Practice 1", SessionType: "Practice 1"}, want: TemporalSessionLive},
|
||||
{name: "session settling", now: "2026-06-20T11:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
|
||||
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||
}, want: TemporalSessionSettling},
|
||||
{name: "between sessions", now: "2026-06-20T11:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
|
||||
completeContextSession(t, s, 21, 2)
|
||||
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||
}, want: TemporalBetweenSessions},
|
||||
{name: "post weekend", now: "2026-06-22T18:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||
completeContextSession(t, s, 22, 2)
|
||||
}, want: TemporalPostWeekend},
|
||||
{name: "season complete", now: "2026-06-30T12:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||
completeContextSession(t, s, 22, 2)
|
||||
}, want: TemporalSeasonComplete},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
now, _ := time.Parse(time.RFC3339, tt.now)
|
||||
svc := contextService(t, now)
|
||||
if tt.seed != nil {
|
||||
tt.seed(t, svc)
|
||||
}
|
||||
got, err := svc.ResolveWeekendContext(tt.evidence)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.TemporalState != tt.want {
|
||||
t.Fatalf("state = %s, want %s; context=%+v", got.TemporalState, tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWeekendContextTruthRules(t *testing.T) {
|
||||
now, _ := time.Parse(time.RFC3339, "2026-07-10T12:00:00Z")
|
||||
svc := contextService(t, now)
|
||||
addContextMeeting(t, svc, 1, "Pre-Season Testing", "2026-02-01T00:00:00Z", "2026-02-03T00:00:00Z", false)
|
||||
addContextSession(t, svc, 10, 1, "Race", "2026-02-03T10:00:00Z", "2026-02-03T12:00:00Z", false)
|
||||
addContextMeeting(t, svc, 2, "Cancelled Grand Prix", "2026-03-01T00:00:00Z", "2026-03-03T00:00:00Z", true)
|
||||
addContextSession(t, svc, 20, 2, "Race", "2026-03-03T10:00:00Z", "2026-03-03T12:00:00Z", false)
|
||||
addContextMeeting(t, svc, 3, "British Grand Prix", "2026-07-01T00:00:00Z", "2026-07-05T00:00:00Z", false)
|
||||
addContextSession(t, svc, 30, 3, "Practice 1", "2026-07-03T09:00:00Z", "2026-07-03T10:00:00Z", false)
|
||||
addContextSession(t, svc, 31, 3, "Sprint", "2026-07-04T10:00:00Z", "2026-07-04T11:00:00Z", false)
|
||||
addContextSession(t, svc, 32, 3, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||
completeContextSession(t, svc, 32, 3)
|
||||
addContextMeeting(t, svc, 4, "Belgian Grand Prix", "2026-07-16T00:00:00Z", "2026-07-18T00:00:00Z", false)
|
||||
addContextSession(t, svc, 40, 4, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
|
||||
addContextSession(t, svc, 41, 4, "Cancelled Practice", "2026-07-18T09:00:00Z", "2026-07-18T10:00:00Z", true)
|
||||
addContextSession(t, svc, 42, 4, "Race", "2026-07-19T14:00:00Z", "2026-07-19T16:00:00Z", false)
|
||||
|
||||
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.TotalChampionshipRounds != 2 || got.ChampionshipRound != 2 {
|
||||
t.Fatalf("rounds = %d/%d, want 2/2", got.ChampionshipRound, got.TotalChampionshipRounds)
|
||||
}
|
||||
if got.DefaultAnalysisSession == nil || got.DefaultAnalysisSession.Session.SessionKey != 32 {
|
||||
t.Fatalf("default analysis = %+v, want completed race 32", got.DefaultAnalysisSession)
|
||||
}
|
||||
if got.NextSession == nil || got.NextSession.Session.SessionKey != 40 {
|
||||
t.Fatalf("next = %+v, want 40", got.NextSession)
|
||||
}
|
||||
if got.NextMeeting.DateStart != "2026-07-17T09:00:00Z" || got.NextMeeting.DateEnd != "2026-07-19T16:00:00Z" {
|
||||
t.Fatalf("display range = %s..%s", got.NextMeeting.DateStart, got.NextMeeting.DateEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWeekendContextPassedTimeDoesNotCompleteSession(t *testing.T) {
|
||||
now, _ := time.Parse(time.RFC3339, "2026-07-05T18:00:00Z")
|
||||
svc := contextService(t, now)
|
||||
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T00:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.PreviousCompletedSession != nil || got.DefaultAnalysisSession != nil {
|
||||
t.Fatalf("passed schedule was treated complete: %+v", got)
|
||||
}
|
||||
archive, err := svc.ResolveWeekendContext(LiveEvidence{Final: true, MeetingName: "British Grand Prix", CircuitName: "British Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if archive.PreviousCompletedSession == nil || archive.PreviousCompletedSession.Availability.Archive != "available" {
|
||||
t.Fatalf("final archive not used: %+v", archive)
|
||||
}
|
||||
if archive.DefaultAnalysisSession != nil {
|
||||
t.Fatal("archive without local analysis must not become default analysis")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWeekendContextNeverUsesFutureAnalysis(t *testing.T) {
|
||||
now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z")
|
||||
svc := contextService(t, now)
|
||||
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T00:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||
completeContextSession(t, svc, 11, 1) // bad/preloaded data must not make a future session canonical
|
||||
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.PreviousCompletedSession != nil || got.DefaultAnalysisSession != nil {
|
||||
t.Fatalf("future analysis selected: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWeekendContextSprintWeekendHandoff(t *testing.T) {
|
||||
now, _ := time.Parse(time.RFC3339, "2026-07-04T12:00:00Z")
|
||||
svc := contextService(t, now)
|
||||
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||
addContextSession(t, svc, 11, 1, "Sprint", "2026-07-04T10:00:00Z", "2026-07-04T11:00:00Z", false)
|
||||
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||
completeContextSession(t, svc, 11, 1)
|
||||
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.TemporalState != TemporalBetweenSessions || got.PreviousCompletedSession.Session.SessionKey != 11 || got.NextSession.Session.SessionKey != 12 {
|
||||
t.Fatalf("sprint handoff = %+v", got)
|
||||
}
|
||||
if got.TotalChampionshipRounds != 1 {
|
||||
t.Fatalf("sprint created extra championship round: %d", got.TotalChampionshipRounds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWeekendContextBoundaryTimestamps(t *testing.T) {
|
||||
t.Run("pre-session window is inclusive", func(t *testing.T) {
|
||||
now, _ := time.Parse(time.RFC3339, "2026-07-01T09:00:00Z")
|
||||
svc := contextService(t, now)
|
||||
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||
addContextSession(t, svc, 11, 1, "Practice 1", "2026-07-03T09:00:00Z", "2026-07-03T10:00:00Z", false)
|
||||
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.TemporalState != TemporalPreSession {
|
||||
t.Fatalf("state = %s", got.TemporalState)
|
||||
}
|
||||
})
|
||||
t.Run("scheduled end enters settling", func(t *testing.T) {
|
||||
now, _ := time.Parse(time.RFC3339, "2026-07-03T10:00:00Z")
|
||||
svc := contextService(t, now)
|
||||
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||
addContextSession(t, svc, 11, 1, "Practice 1", "2026-07-03T09:00:00Z", "2026-07-03T10:00:00Z", false)
|
||||
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.TemporalState != TemporalSessionSettling {
|
||||
t.Fatalf("state = %s", got.TemporalState)
|
||||
}
|
||||
})
|
||||
t.Run("post-weekend window is inclusive", func(t *testing.T) {
|
||||
now, _ := time.Parse(time.RFC3339, "2026-07-07T16:00:00Z")
|
||||
svc := contextService(t, now)
|
||||
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||
completeContextSession(t, svc, 12, 1)
|
||||
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.TemporalState != TemporalPostWeekend {
|
||||
t.Fatalf("state = %s", got.TemporalState)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveWeekendContextPartialFutureSchedule(t *testing.T) {
|
||||
now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z")
|
||||
svc := contextService(t, now)
|
||||
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "", false)
|
||||
addContextSession(t, svc, 11, 1, "Practice 1", "", "", false)
|
||||
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "", false)
|
||||
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.NextSession == nil || got.NextSession.Session.SessionKey != 12 {
|
||||
t.Fatalf("partial schedule next = %+v", got.NextSession)
|
||||
}
|
||||
if got.FocusMeeting == nil || got.FocusMeeting.DateStart != "2026-07-05T14:00:00Z" || got.FocusMeeting.DateEnd != "2026-07-05T14:00:00Z" {
|
||||
t.Fatalf("partial display range = %+v", got.FocusMeeting)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWeekendContextActiveSessionIsNotCompletedOrDefault(t *testing.T) {
|
||||
now, _ := time.Parse(time.RFC3339, "2026-07-05T15:00:00Z")
|
||||
svc := contextService(t, now)
|
||||
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||
addContextSession(t, svc, 10, 1, "Qualifying", "2026-07-04T14:00:00Z", "2026-07-04T15:00:00Z", false)
|
||||
completeContextSession(t, svc, 10, 1)
|
||||
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||
completeContextSession(t, svc, 11, 1)
|
||||
|
||||
got, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "British Grand Prix", CircuitName: "British Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ActiveSession == nil || got.ActiveSession.Session.SessionKey != 11 {
|
||||
t.Fatalf("active = %+v", got.ActiveSession)
|
||||
}
|
||||
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Session.SessionKey != 10 {
|
||||
t.Fatalf("previous = %+v, want earlier completed session", got.PreviousCompletedSession)
|
||||
}
|
||||
if got.DefaultAnalysisSession == nil || got.DefaultAnalysisSession.Session.SessionKey != 10 {
|
||||
t.Fatalf("default = %+v, want earlier completed session", got.DefaultAnalysisSession)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWeekendContextOldIncompleteSessionDoesNotSuppressNextWeekend(t *testing.T) {
|
||||
now, _ := time.Parse(time.RFC3339, "2026-07-16T12:00:00Z")
|
||||
svc := contextService(t, now)
|
||||
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||
addContextMeeting(t, svc, 2, "Belgian Grand Prix", "2026-07-17T09:00:00Z", "2026-07-19T16:00:00Z", false)
|
||||
addContextSession(t, svc, 21, 2, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
|
||||
addContextSession(t, svc, 22, 2, "Race", "2026-07-19T14:00:00Z", "2026-07-19T16:00:00Z", false)
|
||||
|
||||
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.TemporalState != TemporalPreSession {
|
||||
t.Fatalf("state = %s, want %s; context=%+v", got.TemporalState, TemporalPreSession, got)
|
||||
}
|
||||
if got.FocusMeeting == nil || got.FocusMeeting.MeetingKey != 2 {
|
||||
t.Fatalf("focus = %+v, want Belgian weekend", got.FocusMeeting)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWeekendContextMissingScheduleDoesNotClaimSeasonComplete(t *testing.T) {
|
||||
now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z")
|
||||
svc := contextService(t, now)
|
||||
addContextMeeting(t, svc, 1, "British Grand Prix", "", "", false)
|
||||
addContextSession(t, svc, 11, 1, "Race", "", "", false)
|
||||
|
||||
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.TemporalState != TemporalBetweenWeekends {
|
||||
t.Fatalf("state = %s, want limited %s context", got.TemporalState, TemporalBetweenWeekends)
|
||||
}
|
||||
if got.TotalChampionshipRounds != 1 {
|
||||
t.Fatalf("total rounds = %d, want scheduled round retained", got.TotalChampionshipRounds)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ type WeekendSession struct {
|
||||
}
|
||||
|
||||
// Weekend is the local-first read model for one race weekend.
|
||||
// Fan-facing default analysis resolution lives on /api/v1/weekend-context
|
||||
// (DefaultAnalysisSession); this payload only supplies meeting rail + coverage.
|
||||
type Weekend struct {
|
||||
Source string `json:"source"`
|
||||
MeetingKey int `json:"meeting_key"`
|
||||
|
||||
@@ -3,6 +3,7 @@ package query
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/chapters"
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
@@ -12,11 +13,20 @@ import (
|
||||
// Service assembles store-backed read models.
|
||||
type Service struct {
|
||||
store *store.Store
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService creates a query service over a domain store.
|
||||
func NewService(st *store.Store) *Service {
|
||||
return &Service{store: st}
|
||||
return NewServiceWithClock(st, time.Now)
|
||||
}
|
||||
|
||||
// NewServiceWithClock creates a query service with an injected clock.
|
||||
func NewServiceWithClock(st *store.Store, now func() time.Time) *Service {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Service{store: st, now: now}
|
||||
}
|
||||
|
||||
// EnrichedResult is a session result with driver identity fields.
|
||||
|
||||
52
internal/web/context.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/live"
|
||||
"github.com/AmanTahiliani/box-box/internal/query"
|
||||
)
|
||||
|
||||
func (s *Server) handleWeekendContext(w http.ResponseWriter, _ *http.Request) {
|
||||
if !s.hasLocalQuery() {
|
||||
writeJSON(w, query.WeekendContext{TemporalState: query.TemporalNoSeason})
|
||||
return
|
||||
}
|
||||
|
||||
state := s.hub.State()
|
||||
evidence := query.LiveEvidence{}
|
||||
if state.IsLive && state.Data != nil {
|
||||
evidence = liveEvidence(state.Data, true, false)
|
||||
} else if state.LastSnapshot != nil && terminalSessionStatus(state.LastSnapshot.SessionStatus) {
|
||||
evidence = liveEvidence(state.LastSnapshot, false, true)
|
||||
if state.LastSnapshotAt != nil {
|
||||
evidence.ObservedAt = *state.LastSnapshotAt
|
||||
}
|
||||
}
|
||||
context, err := s.query.ResolveWeekendContext(evidence)
|
||||
if err != nil {
|
||||
writeError(w, err, http.StatusInternalServerError, false)
|
||||
return
|
||||
}
|
||||
writeJSON(w, context)
|
||||
}
|
||||
|
||||
func liveEvidence(data *live.LiveStreamData, active, final bool) query.LiveEvidence {
|
||||
return query.LiveEvidence{Active: active, Final: final, MeetingName: data.Session.MeetingName, CircuitName: data.Session.CircuitName, SessionName: data.Session.SessionName, SessionType: data.Session.SessionType}
|
||||
}
|
||||
|
||||
func terminalSessionStatus(status string) bool {
|
||||
normalized := strings.ToLower(strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, status))
|
||||
switch normalized {
|
||||
case "finished", "finalised", "finalized", "ended", "aborted":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
113
internal/web/context_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/live"
|
||||
"github.com/AmanTahiliani/box-box/internal/query"
|
||||
"github.com/AmanTahiliani/box-box/internal/store"
|
||||
)
|
||||
|
||||
func openContextStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "context.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func seedContextHandler(t *testing.T, st *store.Store) {
|
||||
t.Helper()
|
||||
if err := st.UpsertMeeting(store.Meeting{MeetingKey: 1, MeetingName: "British Grand Prix", CircuitShortName: "Silverstone", Year: 2026, DateStart: "2026-07-03T09:00:00Z", DateEnd: "2026-07-05T16:00:00Z"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.UpsertSession(store.Session{SessionKey: 11, MeetingKey: 1, SessionName: "Race", SessionType: "Race", DateStart: "2026-07-05T14:00:00Z", DateEnd: "2026-07-05T16:00:00Z"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekendContextHandlerWithoutStoreReturnsNoSeason(t *testing.T) {
|
||||
s := NewServer(nil, 0, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rr.Code)
|
||||
}
|
||||
var got query.WeekendContext
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.TemporalState != query.TemporalNoSeason {
|
||||
t.Fatalf("state = %s", got.TemporalState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekendContextHandlerUsesLiveHubIdentityWithoutOpenF1(t *testing.T) {
|
||||
st := openContextStore(t)
|
||||
seedContextHandler(t, st)
|
||||
now, _ := time.Parse(time.RFC3339, "2026-07-05T13:55:00Z")
|
||||
s := NewServer(nil, 0, st) // a nil OpenF1 client makes any REST dependency panic
|
||||
s.query = query.NewServiceWithClock(st, func() time.Time { return now })
|
||||
s.hub.applySnapshot(live.LiveStreamData{SessionStatus: "Started", Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", CircuitName: "Silverstone", SessionName: "Race", SessionType: "Race"}}, now)
|
||||
|
||||
handler, err := s.routes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got query.WeekendContext
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.TemporalState != query.TemporalSessionLive || got.ActiveSession == nil || got.ActiveSession.Session.SessionKey != 11 {
|
||||
t.Fatalf("live context = %+v", got)
|
||||
}
|
||||
if got.ActiveSession.Availability.LiveTransport != "connected" || got.ActiveSession.Availability.LiveSession != "active" {
|
||||
t.Fatalf("availability = %+v", got.ActiveSession.Availability)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekendContextHandlerUsesTerminalArchiveAsCompletionEvidence(t *testing.T) {
|
||||
st := openContextStore(t)
|
||||
seedContextHandler(t, st)
|
||||
now, _ := time.Parse(time.RFC3339, "2026-07-05T16:05:00Z")
|
||||
s := NewServer(nil, 0, st)
|
||||
s.query = query.NewServiceWithClock(st, func() time.Time { return now })
|
||||
s.hub.applySnapshot(live.LiveStreamData{SessionStatus: "Finished", Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", CircuitName: "Silverstone", SessionName: "Race", SessionType: "Race"}}, now)
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
|
||||
var got query.WeekendContext
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Availability.Archive != "available" {
|
||||
t.Fatalf("archive context = %+v", got)
|
||||
}
|
||||
if got.DefaultAnalysisSession != nil {
|
||||
t.Fatal("archive-only session must not become local default analysis")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalSessionStatus(t *testing.T) {
|
||||
for _, status := range []string{"Finished", "Finalised", "ENDED", "Aborted"} {
|
||||
if !terminalSessionStatus(status) {
|
||||
t.Errorf("%q should be terminal", status)
|
||||
}
|
||||
}
|
||||
for _, status := range []string{"Started", "Resumed", "Inactive", ""} {
|
||||
if terminalSessionStatus(status) {
|
||||
t.Errorf("%q should not be terminal", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ func (s *Server) routes() (http.Handler, error) {
|
||||
|
||||
// REST API — /api/v1/laps/comparison must be registered before /api/v1/laps
|
||||
// because Go's ServeMux uses longest-prefix matching.
|
||||
mux.HandleFunc("/api/v1/weekend-context", s.handleWeekendContext)
|
||||
mux.HandleFunc("/api/v1/race-hub", s.handleRaceHub)
|
||||
mux.HandleFunc("/api/v1/seasons", s.handleSeasons)
|
||||
mux.HandleFunc("/api/v1/weekend", s.handleWeekend)
|
||||
|
||||
@@ -84,14 +84,20 @@ func seedMeeting(st *store.Store, meetingKey int) error {
|
||||
}
|
||||
|
||||
func seedSession(st *store.Store, sessionKey, meetingKey int, name string) error {
|
||||
start, end := "2025-05-25T13:00:00+00:00", "2025-05-25T15:00:00+00:00"
|
||||
// Core-only is an earlier weekend session so Weekend Context's
|
||||
// default_analysis_session prefers the later full Race.
|
||||
if name == "Core Only" {
|
||||
start, end = "2025-05-24T13:00:00+00:00", "2025-05-24T15:00:00+00:00"
|
||||
}
|
||||
return st.UpsertSession(store.Session{
|
||||
SessionKey: sessionKey,
|
||||
MeetingKey: meetingKey,
|
||||
SessionName: name,
|
||||
SessionType: "Race",
|
||||
CircuitKey: 10,
|
||||
DateStart: "2025-05-25T13:00:00+00:00",
|
||||
DateEnd: "2025-05-25T15:00:00+00:00",
|
||||
DateStart: start,
|
||||
DateEnd: end,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const FULL_SESSION = 9472
|
||||
|
||||
test.describe('Command Center', () => {
|
||||
test('loads as default route with weekend identity band', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
await expect(page.getByTestId('command-center')).toBeVisible()
|
||||
await expect(page.getByTestId('cc-focus')).toBeVisible()
|
||||
await expect(page.getByTestId('cc-session-9472')).toBeVisible()
|
||||
await expect(page.getByTestId('hero-last-race-link')).toBeVisible()
|
||||
})
|
||||
|
||||
test('nav link reaches command center from race hub', async ({ page }) => {
|
||||
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||
await page.getByRole('link', { name: 'Command' }).click()
|
||||
await expect(page).toHaveURL('/')
|
||||
await expect(page.getByTestId('command-center')).toBeVisible()
|
||||
})
|
||||
|
||||
test('open analysis action opens race hub for the focus session', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page.getByTestId('hero-last-race-link')).toBeVisible()
|
||||
await page.getByTestId('hero-last-race-link').click()
|
||||
await expect(page).toHaveURL(new RegExp(`/race-hub\\?session_key=${FULL_SESSION}`))
|
||||
await expect(page.getByTestId('rh-identity')).toBeVisible()
|
||||
await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible()
|
||||
})
|
||||
|
||||
test('archived live snapshot does not mark command center live', async ({ page }) => {
|
||||
await page.route('**/api/v1/live/state', (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
is_live: false,
|
||||
data: null,
|
||||
last_snapshot: {
|
||||
Drivers: { '1': { RacingNumber: '1', Position: 1 } },
|
||||
DriverInfo: { '1': { RacingNumber: '1', Tla: 'VER', TeamColour: '3671C6' } },
|
||||
Tyres: {},
|
||||
RCMessages: [],
|
||||
Weather: {},
|
||||
Session: { MeetingName: 'Archived GP', SessionName: 'Race', SessionType: 'Race' },
|
||||
TeamRadio: [],
|
||||
SessionStatus: 'Finished',
|
||||
TrackStatus: '1',
|
||||
CurrentLap: 57,
|
||||
TotalLaps: 57,
|
||||
Clock: '',
|
||||
ClockRefTime: '',
|
||||
ClockExtrapolating: false,
|
||||
Stints: {},
|
||||
},
|
||||
last_snapshot_at: '2026-07-04T14:00:00Z',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto('/')
|
||||
await expect(page.getByTestId('command-center')).toBeVisible()
|
||||
await expect(page.getByTestId('cc-live-status')).toContainText('No live session')
|
||||
await expect(page.getByTestId('cc-live-status')).not.toContainText('Live session active')
|
||||
})
|
||||
|
||||
test('existing routes continue to work', async ({ page }) => {
|
||||
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||
await expect(page.getByTestId('rh-overview')).toBeVisible()
|
||||
|
||||
await page.goto('/admin')
|
||||
await expect(page.getByTestId('data-library')).toBeVisible()
|
||||
|
||||
await page.goto('/live')
|
||||
await expect(page.getByTestId('live-empty')).toBeVisible()
|
||||
})
|
||||
})
|
||||
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),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -3,11 +3,11 @@ import { test, expect } from '@playwright/test'
|
||||
const FULL_SESSION = 9472
|
||||
|
||||
test.describe('Production serving (Go + built React)', () => {
|
||||
test('serves command center as default route', async ({ page }) => {
|
||||
test('serves the Weekend home as default route', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
await expect(page.getByTestId('command-center')).toBeVisible()
|
||||
await expect(page.getByTestId('cc-session-9472')).toBeVisible()
|
||||
await expect(page.getByTestId('weekend-page')).toBeVisible()
|
||||
await expect(page.getByTestId('weekend-between-races')).toBeVisible()
|
||||
})
|
||||
|
||||
test('serves race hub workspace from built assets', async ({ page }) => {
|
||||
@@ -44,16 +44,21 @@ test.describe('Production serving (Go + built React)', () => {
|
||||
await expect(page.getByText('No live session active')).toBeVisible()
|
||||
})
|
||||
|
||||
test('nav links work from built SPA', async ({ page }) => {
|
||||
test('primary nav links and Admin utility work from built SPA', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.locator('.app-nav').getByRole('link', { name: 'Race Hub' }).click()
|
||||
await expect(page).toHaveURL(/\/race-hub/)
|
||||
const primary = page.getByRole('navigation', { name: 'Primary' }).first()
|
||||
|
||||
await page.locator('.app-nav').getByRole('link', { name: 'Admin', exact: true }).click()
|
||||
await primary.getByRole('link', { name: 'Championship', exact: true }).click()
|
||||
await expect(page).toHaveURL(/\/championship/)
|
||||
|
||||
await primary.getByRole('link', { name: 'Explore', exact: true }).click()
|
||||
await expect(page).toHaveURL(/\/explore/)
|
||||
|
||||
await primary.getByRole('link', { name: 'Weekend', exact: true }).click()
|
||||
await expect(page).toHaveURL('/')
|
||||
|
||||
// Admin is an operator utility outside the Primary landmark.
|
||||
await page.getByRole('toolbar', { name: 'Operator utilities' }).getByRole('link', { name: 'Admin' }).click()
|
||||
await expect(page).toHaveURL(/\/admin/)
|
||||
|
||||
await page.getByRole('link', { name: 'Live', exact: true }).click()
|
||||
await expect(page).toHaveURL(/\/live/)
|
||||
await expect(page.getByTestId('live-empty')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { FUTURE_SESSION, mockFutureRaceHubSession } from './fixtures/future-session'
|
||||
|
||||
const FULL_SESSION = 9472
|
||||
const CORE_ONLY_SESSION = 9000
|
||||
@@ -15,6 +16,7 @@ test.describe('Race Hub Weekend Workspace', () => {
|
||||
'aria-selected',
|
||||
'true',
|
||||
)
|
||||
await expect(page.getByText('Local Coverage')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('shows final running order when switching to Race Story', async ({ page }) => {
|
||||
@@ -87,24 +89,110 @@ test.describe('Race Hub Weekend Workspace', () => {
|
||||
await page.getByTestId('rh-switch-weekend').click()
|
||||
|
||||
await expect(page.getByTestId('rh-switcher')).toBeVisible()
|
||||
// Active session is already loaded; just confirm a session button is reachable
|
||||
await expect(page.getByTestId(`rh-switcher-session-${FULL_SESSION}`)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Data Status tab points at admin instead of inline CLI hints', async ({ page }) => {
|
||||
test('Diagnostics is a secondary action and points at admin, not inline CLI hints', async ({ page }) => {
|
||||
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
|
||||
await page.getByRole('tab', { name: 'Data Status' }).click()
|
||||
await page.getByRole('tab', { name: 'Diagnostics' }).click()
|
||||
|
||||
await expect(page.getByTestId('rh-data-status')).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: /manage ingestion/i })).toHaveAttribute(
|
||||
'href',
|
||||
'/admin',
|
||||
)
|
||||
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 expect(page).toHaveURL(/session_key=\d+/)
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 148 KiB |
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__/desktop/weekend.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 96 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__/mobile/weekend.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 129 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 |
BIN
tests/visual/__snapshots__/tablet/weekend.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
@@ -1,4 +1,5 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test'
|
||||
import { FUTURE_SESSION, mockFutureRaceHubSession } from '../fixtures/future-session'
|
||||
|
||||
export const VIEWPORTS = {
|
||||
desktop: { width: 1280, height: 800 },
|
||||
@@ -7,6 +8,7 @@ export const VIEWPORTS = {
|
||||
} as const
|
||||
|
||||
export const FULL_SESSION = 9472
|
||||
export { FUTURE_SESSION }
|
||||
|
||||
/** Wait for web fonts and layout to settle before screenshots. */
|
||||
export async function waitForScreenshotReady(page: Page): Promise<void> {
|
||||
@@ -14,16 +16,13 @@ export async function waitForScreenshotReady(page: Page): Promise<void> {
|
||||
await page.waitForTimeout(150)
|
||||
}
|
||||
|
||||
export async function gotoCommandCenterReady(page: Page): Promise<void> {
|
||||
export async function gotoWeekendReady(page: Page): Promise<void> {
|
||||
await page.goto('/')
|
||||
await expect(page.getByTestId('command-center')).toBeVisible()
|
||||
await expect(page.getByTestId('cc-focus')).toBeVisible()
|
||||
await expect(page.getByTestId('cc-session-9472')).toBeVisible()
|
||||
// The e2e stack runs with an unreachable OpenF1 base URL, so wait for the
|
||||
// season-calendar query to settle on its local fallback before screenshotting.
|
||||
await expect(
|
||||
page.getByText('Using local meetings because the full calendar could not load.'),
|
||||
).toBeVisible()
|
||||
await expect(page.getByTestId('weekend-page')).toBeVisible()
|
||||
// The seeded hermetic DB (Monaco 2025, completed) resolves to season_complete,
|
||||
// rendered by the between-races surface.
|
||||
await expect(page.getByTestId('weekend-between-races')).toBeVisible()
|
||||
await expect(page.getByTestId('wk-last-event')).toBeVisible()
|
||||
await waitForScreenshotReady(page)
|
||||
}
|
||||
|
||||
@@ -36,6 +35,17 @@ export async function gotoRaceHubReady(page: Page, sessionKey = FULL_SESSION): P
|
||||
await waitForScreenshotReady(page)
|
||||
}
|
||||
|
||||
export async function gotoRaceHubFutureReady(
|
||||
page: Page,
|
||||
sessionKey = FUTURE_SESSION,
|
||||
): Promise<void> {
|
||||
await 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> {
|
||||
await page.goto(`/race-hub?session_key=${sessionKey}`)
|
||||
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { test } from '@playwright/test'
|
||||
import {
|
||||
gotoCommandCenterReady,
|
||||
gotoWeekendReady,
|
||||
gotoDataLibraryReady,
|
||||
gotoLiveEmptyReady,
|
||||
gotoRaceHubFutureReady,
|
||||
gotoRaceHubReady,
|
||||
screenshotPage,
|
||||
} from './helpers'
|
||||
|
||||
test.describe('MVP visual regression', () => {
|
||||
test('command-center', async ({ page }) => {
|
||||
await gotoCommandCenterReady(page)
|
||||
await screenshotPage(page, 'command-center')
|
||||
test('weekend', async ({ page }) => {
|
||||
await gotoWeekendReady(page)
|
||||
await screenshotPage(page, 'weekend')
|
||||
})
|
||||
|
||||
test('race-hub', async ({ page }) => {
|
||||
@@ -18,6 +19,14 @@ test.describe('MVP visual regression', () => {
|
||||
await screenshotPage(page, 'race-hub')
|
||||
})
|
||||
|
||||
test('race-hub-future', async ({ page }) => {
|
||||
await gotoRaceHubFutureReady(page)
|
||||
// The countdown ticks every second — mask it so the snapshot stays stable.
|
||||
await screenshotPage(page, 'race-hub-future', {
|
||||
mask: [page.getByTestId('rh-presession-countdown')],
|
||||
})
|
||||
})
|
||||
|
||||
test('data-library', async ({ page }) => {
|
||||
await gotoDataLibraryReady(page)
|
||||
await screenshotPage(page, 'data-library')
|
||||
|
||||
156
tests/weekend-states.spec.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
// These journeys inject the canonical /api/v1/weekend-context payload directly so
|
||||
// each temporal state is exercised deterministically and hermetically, regardless
|
||||
// of the seeded database clock. The payloads mirror internal/query.WeekendContext.
|
||||
|
||||
const availability = {
|
||||
schedule: 'available',
|
||||
live_transport: 'unknown',
|
||||
live_session: 'inactive',
|
||||
archive: 'unavailable',
|
||||
local_analysis: 'complete',
|
||||
freshness: 'fresh',
|
||||
limitations: [],
|
||||
}
|
||||
|
||||
function meeting(key: number, name: string, start: string) {
|
||||
return {
|
||||
meeting_key: key,
|
||||
meeting_name: name,
|
||||
meeting_official_name: name,
|
||||
location: name,
|
||||
country_code: 'GBR',
|
||||
country_name: 'United Kingdom',
|
||||
country_flag: '',
|
||||
circuit_key: key,
|
||||
circuit_short_name: name,
|
||||
date_start: start,
|
||||
date_end: start,
|
||||
year: 2026,
|
||||
}
|
||||
}
|
||||
|
||||
function ctxSession(key: number, name: string, start: string, meetingKey: number) {
|
||||
return {
|
||||
session: {
|
||||
session_key: key,
|
||||
session_name: name,
|
||||
session_type: name,
|
||||
meeting_key: meetingKey,
|
||||
date_start: start,
|
||||
date_end: start,
|
||||
gmt_offset: '',
|
||||
},
|
||||
meeting: meeting(meetingKey, 'British Grand Prix', start),
|
||||
availability,
|
||||
}
|
||||
}
|
||||
|
||||
async function stubContext(page: Page, body: Record<string, unknown>): Promise<void> {
|
||||
await page.route('**/api/v1/weekend-context', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) }),
|
||||
)
|
||||
// Keep supplementary reads hermetic and empty.
|
||||
await page.route('**/api/v1/news**', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: '[]' }),
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('Weekend canonical temporal states (injected)', () => {
|
||||
test('between_weekends pairs the last completed event with the next-event countdown and Prepare CTA', async ({ page }) => {
|
||||
await stubContext(page, {
|
||||
season: 2026,
|
||||
temporal_state: 'between_weekends',
|
||||
previous_meeting: meeting(1, 'British Grand Prix', '2026-07-05T14:00:00Z'),
|
||||
previous_completed_session: ctxSession(11, 'Race', '2026-07-05T14:00:00Z', 1),
|
||||
default_analysis_session: ctxSession(11, 'Race', '2026-07-05T14:00:00Z', 1),
|
||||
next_meeting: meeting(2, 'Hungarian Grand Prix', '2026-07-24T09:00:00Z'),
|
||||
next_session: ctxSession(21, 'Practice 1', '2026-07-24T09:00:00Z', 2),
|
||||
focus_meeting: meeting(2, 'Hungarian Grand Prix', '2026-07-24T09:00:00Z'),
|
||||
championship_round: 12,
|
||||
total_championship_rounds: 24,
|
||||
})
|
||||
|
||||
await page.goto('/')
|
||||
await expect(page.getByTestId('weekend-between-races')).toBeVisible()
|
||||
await expect(page.getByTestId('wk-last-event')).toBeVisible()
|
||||
await expect(page.getByTestId('wk-next-event')).toBeVisible()
|
||||
await expect(page.getByTestId('wk-prepare')).toHaveAttribute('href', '/preview')
|
||||
await expect(page.getByTestId('wk-season-nav')).toContainText('Round 12 of 24')
|
||||
})
|
||||
|
||||
test('between_races Prepare CTA folds into the preparation surface instead of looping', async ({ page }) => {
|
||||
await stubContext(page, {
|
||||
season: 2026,
|
||||
temporal_state: 'between_weekends',
|
||||
next_meeting: meeting(2, 'Hungarian Grand Prix', '2026-07-24T09:00:00Z'),
|
||||
next_session: ctxSession(21, 'Practice 1', '2026-07-24T09:00:00Z', 2),
|
||||
championship_round: 12,
|
||||
total_championship_rounds: 24,
|
||||
})
|
||||
|
||||
await page.goto('/')
|
||||
await page.getByTestId('wk-prepare').click()
|
||||
await expect(page).toHaveURL(/\/preview$/)
|
||||
await expect(page.getByTestId('weekend-pre-session')).toBeVisible()
|
||||
await expect(page.getByTestId('weekend-between-races')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('pre_session surfaces the preview and the next-session countdown', async ({ page }) => {
|
||||
await stubContext(page, {
|
||||
season: 2026,
|
||||
temporal_state: 'pre_session',
|
||||
next_meeting: meeting(2, 'Hungarian Grand Prix', '2026-07-24T09:00:00Z'),
|
||||
next_session: ctxSession(21, 'Practice 1', '2026-07-24T09:00:00Z', 2),
|
||||
focus_meeting: meeting(2, 'Hungarian Grand Prix', '2026-07-24T09:00:00Z'),
|
||||
championship_round: 12,
|
||||
total_championship_rounds: 24,
|
||||
})
|
||||
|
||||
await page.goto('/')
|
||||
await expect(page.getByTestId('weekend-pre-session')).toBeVisible()
|
||||
await expect(page.getByTestId('wk-pre-head')).toBeVisible()
|
||||
})
|
||||
|
||||
test('between_sessions shows the last result recap and the next-session countdown together', async ({ page }) => {
|
||||
await stubContext(page, {
|
||||
season: 2026,
|
||||
temporal_state: 'between_sessions',
|
||||
focus_meeting: meeting(1, 'British Grand Prix', '2026-07-04T10:00:00Z'),
|
||||
previous_completed_session: ctxSession(11, 'Sprint', '2026-07-04T10:00:00Z', 1),
|
||||
default_analysis_session: ctxSession(11, 'Sprint', '2026-07-04T10:00:00Z', 1),
|
||||
next_session: ctxSession(12, 'Race', '2026-07-05T14:00:00Z', 1),
|
||||
championship_round: 12,
|
||||
total_championship_rounds: 24,
|
||||
})
|
||||
|
||||
await page.goto('/')
|
||||
await expect(page.getByTestId('weekend-between-sessions')).toBeVisible()
|
||||
await expect(page.getByTestId('wk-last-session')).toBeVisible()
|
||||
await expect(page.getByTestId('wk-next-session')).toBeVisible()
|
||||
})
|
||||
|
||||
test('session_live hands off to live timing', async ({ page }) => {
|
||||
await stubContext(page, {
|
||||
season: 2026,
|
||||
temporal_state: 'session_live',
|
||||
focus_meeting: meeting(1, 'British Grand Prix', '2026-07-05T14:00:00Z'),
|
||||
active_session: ctxSession(11, 'Race', '2026-07-05T14:00:00Z', 1),
|
||||
championship_round: 12,
|
||||
total_championship_rounds: 24,
|
||||
})
|
||||
|
||||
await page.goto('/')
|
||||
await expect(page.getByTestId('weekend-live')).toBeVisible()
|
||||
await expect(page.getByTestId('wk-watch-live')).toHaveAttribute('href', '/live')
|
||||
})
|
||||
|
||||
test('an error from the canonical endpoint shows an explicit error surface', async ({ page }) => {
|
||||
await page.route('**/api/v1/weekend-context', (route) =>
|
||||
route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"boom"}' }),
|
||||
)
|
||||
await page.goto('/')
|
||||
await expect(page.getByTestId('weekend-error')).toBeVisible()
|
||||
})
|
||||
})
|
||||
118
tests/weekend.spec.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const FULL_SESSION = 9472
|
||||
|
||||
async function documentOverflow(page: Page): Promise<number> {
|
||||
return page.evaluate(() => {
|
||||
const doc = document.documentElement
|
||||
return doc.scrollWidth - doc.clientWidth
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Weekend home (seeded canonical context)', () => {
|
||||
test('renders the Weekend home from the canonical endpoint, not the limited fallback', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
await expect(page.getByTestId('weekend-page')).toBeVisible()
|
||||
// The seeded DB (Monaco 2025, completed) resolves to season_complete, driven
|
||||
// by the canonical /api/v1/weekend-context endpoint.
|
||||
await expect(page.getByTestId('weekend-page')).toHaveAttribute('data-temporal-state', 'season_complete')
|
||||
await expect(page.getByTestId('weekend-between-races')).toBeVisible()
|
||||
await expect(page.getByTestId('weekend-limited')).toHaveCount(0)
|
||||
// No ingest/dataset terminology on the Weekend surface.
|
||||
await expect(page.getByText(/ingest/i)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('the completed-event CTA opens the canonical analysis session in Race Hub', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page.getByTestId('wk-last-event')).toBeVisible()
|
||||
await page.getByTestId('wk-explore-race-story').click()
|
||||
await expect(page).toHaveURL(/\/race-hub\?session_key=\d+/)
|
||||
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||
})
|
||||
|
||||
test('/preview alias resolves without looping back to the same screen', async ({ page }) => {
|
||||
await page.goto('/preview')
|
||||
// In the seeded season-complete state there is no next event, so /preview
|
||||
// resolves to the Weekend home rather than a redirect loop.
|
||||
await expect(page.getByTestId('weekend-page')).toBeVisible()
|
||||
await expect(page).toHaveURL(/\/preview$/)
|
||||
})
|
||||
|
||||
test('existing deep-link routes remain valid', async ({ page }) => {
|
||||
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||
await expect(page.getByTestId('rh-overview')).toBeVisible()
|
||||
|
||||
await page.goto('/admin')
|
||||
await expect(page.getByTestId('data-library')).toBeVisible()
|
||||
|
||||
await page.goto('/live')
|
||||
await expect(page.getByTestId('live-empty')).toBeVisible()
|
||||
|
||||
await page.goto('/explore')
|
||||
await expect(page.getByTestId('explore-page')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Weekend navigation hierarchy', () => {
|
||||
test('all four destinations are reachable from primary navigation', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const primary = page.getByRole('navigation', { name: 'Primary' }).first()
|
||||
for (const [label, url] of [
|
||||
['Championship', /\/championship/],
|
||||
['Briefing', /\/briefing/],
|
||||
['Explore', /\/explore/],
|
||||
['Weekend', /\/$/],
|
||||
] as const) {
|
||||
await primary.getByRole('link', { name: label, exact: true }).click()
|
||||
await expect(page).toHaveURL(url)
|
||||
}
|
||||
})
|
||||
|
||||
test('Admin is an operator utility outside the Primary landmark', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const primaries = page.getByRole('navigation', { name: 'Primary' })
|
||||
await expect(primaries.first()).toBeVisible()
|
||||
// Admin must not appear inside any Primary landmark.
|
||||
await expect(primaries.getByRole('link', { name: /Admin/i })).toHaveCount(0)
|
||||
await expect(
|
||||
page.getByRole('toolbar', { name: 'Operator utilities' }).getByRole('link', { name: 'Admin' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Weekend responsive — no global horizontal overflow', () => {
|
||||
for (const { name, width, height } of [
|
||||
{ name: 'mobile 390', width: 390, height: 844 },
|
||||
{ name: 'tablet 768', width: 768, height: 1024 },
|
||||
{ name: 'desktop 1440', width: 1440, height: 900 },
|
||||
]) {
|
||||
test(`no document overflow at ${name}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height })
|
||||
await page.goto('/')
|
||||
await expect(page.getByTestId('weekend-between-races')).toBeVisible()
|
||||
expect(await documentOverflow(page)).toBeLessThanOrEqual(1)
|
||||
})
|
||||
}
|
||||
|
||||
test('mobile shows exactly one visible primary navigation (bottom bar)', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto('/')
|
||||
await expect(page.getByTestId('weekend-page')).toBeVisible()
|
||||
|
||||
// The bottom bar is the single visible primary nav; the top bar's links are
|
||||
// hidden by CSS at this breakpoint.
|
||||
const bottom = page.locator('.app-bottom-nav')
|
||||
await expect(bottom).toBeVisible()
|
||||
await expect(bottom.getByRole('link')).toHaveCount(4)
|
||||
|
||||
const topLinks = page.locator('.app-nav .nav-links')
|
||||
await expect(topLinks).toBeHidden()
|
||||
|
||||
// All four destinations remain reachable via the bottom bar.
|
||||
for (const label of ['Weekend', 'Championship', 'Briefing', 'Explore']) {
|
||||
await expect(bottom.getByRole('link', { name: new RegExp(label) })).toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||