Compare commits

...

18 Commits

Author SHA1 Message Date
AmanTahiliani
07e5857760 fix(#73): consume canonical Weekend Context contract and repair navigation
Address the independent review blockers on PR #80 after rebasing onto the
authoritative #72 Weekend Context API.

- Replace the invented frontend WeekendContext with the exact backend contract
  (temporal_state, previous/focus/next meetings, previous_completed/active/next/
  default_analysis sessions with availability). Every valid canonical payload now
  maps to a designed state via a total resolveViewState; a well-formed response
  can never fall through to the limited-data placeholder.
- Make the canonical read the single source of truth: useWeekendContext no longer
  fans out to season/meetings/per-weekend/OpenF1/live queries. Only supplementary
  championship + news reads run, and only once the canonical context resolves.
- Fix the Prepare/analysis flow: /preview is a stable alias that renders the
  preparation surface (PreSessionView) instead of redirecting back to the same
  between-races screen.
- One primary navigation system per breakpoint: the mobile top-bar links are
  hidden so the bottom bar is the sole primary nav, and Admin is moved out of
  every Primary landmark into an operator-utilities toolbar.
- Add Vitest coverage for the contract mapping, every temporal state, loading/
  error/limited surfaces, the no-fanout guarantee, the /preview CTA, and the nav
  hierarchy; add hermetic Playwright journeys (seeded + injected canonical
  payloads), 390/768/1440 overflow checks, and Weekend visual snapshots. Retire
  the stale Command Center specs/snapshots.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 18:38:08 -04:00
AmanTahiliani
06223d992f feat(#73): Adaptive Weekend shell and responsive navigation
Implemented by claude via .agents/dev dispatch.
2026-07-12 18:22:04 -04:00
Aman Tahiliani
ba55945b6f Merge pull request #79 from AmanTahiliani/feat/issue-72-canonical-weekend-context-api-and-sessio
Canonical Weekend Context API and session truth (#72)
2026-07-12 18:19:46 -04:00
AmanTahiliani
643af2355a fix: make weekend context transitions truthful 2026-07-12 18:15:14 -04:00
AmanTahiliani
45084558cd feat: add canonical weekend context API 2026-07-12 18:05:13 -04:00
AmanTahiliani
90c24ad6aa chore: make sprint harness models explicit 2026-07-12 17:25:19 -04:00
AmanTahiliani
e028e3e3fe docs: add v0.4.0 product direction and mockups 2026-07-12 17:24:42 -04:00
AmanTahiliani
0a42c05487 fix: align race story cursor to chart 2026-07-12 16:11:26 -04:00
AmanTahiliani
08ff75b469 fix: align race story chart to lap timing 2026-07-12 16:05:04 -04:00
Aman Tahiliani
16c035b037 Merge pull request #69 from AmanTahiliani/feat/issue-68-polish-the-race-story-section-chapter-st
Polish the Race Story section (chapter strip, playback controls, map empty-state, graph) (#68)
2026-07-12 13:44:00 -04:00
AmanTahiliani
1dac73cdc5 fix(race-story): sync chapter highlight and empty-state E2E (#68)
Clamp active-chapter detection to the same scrub bounds as chapter clicks,
keep an explicit selection on click for out-of-window timestamps, point E2E
at the empty-state card, and move Race Story empty-state styles out of app.css.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 13:40:21 -04:00
AmanTahiliani
02a84f67b4 feat(race-story): polish chapter strip, controls, graph, and map UX (#68)
Consolidate the Race Story section into a cohesive timeline surface: custom
chapter-strip scrolling with active-card sync, unified segmented playback
controls, chapter bands on the position graph with decimated axes and
de-collided labels, map toggle gated by an on-mount replay/outline probe,
and shared empty-state cards. Adds vitest coverage and visual snapshots.

Note: replay/outline queries now probe on mount (not only when Map opens) so
the toggle can be hidden before users hit a dead panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 13:23:07 -04:00
Aman Tahiliani
f0a8e4e631 Merge pull request #67 from AmanTahiliani/phase-2
Phase 2 delivery — Replay & Storytelling, Driver & Rivalry Hub, Between-Races
2026-07-12 13:05:53 -04:00
AmanTahiliani
519053d340 docs(phase-2): delivery screenshots for the phase-2 PR
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:51:11 -04:00
AmanTahiliani
e0d7b9981e fix(test): add required round_positions to hub driver fixtures from pre-#24 branches
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:39:45 -04:00
Aman Tahiliani
79acf0579a Merge pull request #66 from AmanTahiliani/feat/issue-24-rivalry-compare-view
Rivalry compare view (#24)
2026-07-12 12:38:00 -04:00
AmanTahiliani
82abbd6b35 feat(#24): Rivalry compare view
Implemented by claude via .agents/dev dispatch.
2026-07-11 18:29:52 -04:00
Aman Tahiliani
faa173578c Merge pull request #65 from AmanTahiliani/feat/issue-23-teammate-h2h-surfacing
Teammate H2H surfacing (#23)
2026-07-11 18:25:51 -04:00
85 changed files with 5402 additions and 1098 deletions

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 683 KiB

View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -15,6 +15,7 @@ import type {
Session,
TrackOutline,
Weekend,
WeekendContext,
} from './types'
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
@@ -114,6 +115,19 @@ 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.
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())

View File

@@ -1,3 +1,5 @@
import { useEffect, useRef } from 'react'
import { BookOpen } from 'lucide-react'
import type { Chapter } from '../types'
import {
activeChapterIndex,
@@ -5,6 +7,7 @@ import {
chapterLapRange,
chapterStartScrub,
} from '../lib/chapters'
import { EmptyStateCard } from './EmptyStateCard'
import '../styles/chapters.css'
interface Props {
@@ -14,6 +17,8 @@ interface Props {
tRange: number
tourActive: boolean
tourChapterIndex: number | null
/** Explicit selection from a chapter click; wins over scrub-derived active. */
selectedChapterIndex?: number | null
onChapterClick: (index: number, scrub: number) => void
onTourToggle: () => void
}
@@ -25,19 +30,38 @@ export function ChapterStrip({
tRange,
tourActive,
tourChapterIndex,
selectedChapterIndex = null,
onChapterClick,
onTourToggle,
}: Props) {
const scrollRef = useRef<HTMLDivElement>(null)
const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange)
const highlightedIndex = tourActive
? tourChapterIndex
: (selectedChapterIndex ?? activeIndex)
useEffect(() => {
if (highlightedIndex === null || !scrollRef.current) return
const card = scrollRef.current.querySelector<HTMLElement>(
`[data-testid="chapter-card-${highlightedIndex}"]`,
)
card?.scrollIntoView?.({ behavior: 'smooth', inline: 'center', block: 'nearest' })
}, [highlightedIndex])
if (chapters.length === 0) {
return (
<div className="chapter-strip" data-testid="chapter-strip">
<p className="chapter-strip-empty">No story chapters for this session.</p>
<EmptyStateCard
icon={BookOpen}
title="No story chapters"
hint="This session does not have enough race-control or position data to build narrative chapters."
testId="chapter-strip-empty"
className="chapter-strip-empty-card"
/>
</div>
)
}
const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange)
return (
<div className="chapter-strip" data-testid="chapter-strip">
<div className="chapter-strip-header">
@@ -53,34 +77,39 @@ export function ChapterStrip({
</button>
</div>
</div>
<div className="chapter-strip-scroll" role="list" aria-label="Race story chapters">
{chapters.map((chapter, index) => {
const scrub = chapterStartScrub(chapter, tMin, tRange) ?? index / Math.max(chapters.length - 1, 1)
const isActive = tourActive
? tourChapterIndex === index
: activeIndex === index
const headline = chapter.headline || chapter.title
<div className="chapter-strip-scroll-wrap">
<div
ref={scrollRef}
className="chapter-strip-scroll"
role="list"
aria-label="Race story chapters"
>
{chapters.map((chapter, index) => {
const scrub = chapterStartScrub(chapter, tMin, tRange) ?? index / Math.max(chapters.length - 1, 1)
const isActive = highlightedIndex === index
const headline = chapter.headline || chapter.title
return (
<button
key={`${chapter.kind}-${chapter.start_lap}-${index}`}
type="button"
role="listitem"
className={`chapter-card ${isActive ? (tourActive ? 'tour-active' : 'active') : ''}`}
onClick={() => onChapterClick(index, scrub)}
aria-current={isActive ? 'true' : undefined}
data-testid={`chapter-card-${index}`}
>
<div className="chapter-card-top">
<span className={`chapter-kind chapter-kind--${chapter.kind}`}>
{chapterKindLabel(chapter.kind)}
</span>
<span className="chapter-lap-range">{chapterLapRange(chapter)}</span>
</div>
<span className="chapter-headline">{headline}</span>
</button>
)
})}
return (
<button
key={`${chapter.kind}-${chapter.start_lap}-${index}`}
type="button"
role="listitem"
className={`chapter-card ${isActive ? (tourActive ? 'tour-active' : 'active') : ''}`}
onClick={() => onChapterClick(index, scrub)}
aria-current={isActive ? 'true' : undefined}
data-testid={`chapter-card-${index}`}
>
<div className="chapter-card-top">
<span className={`chapter-kind chapter-kind--${chapter.kind}`}>
{chapterKindLabel(chapter.kind)}
</span>
<span className="chapter-lap-range">{chapterLapRange(chapter)}</span>
</div>
<span className="chapter-headline">{headline}</span>
</button>
)
})}
</div>
</div>
</div>
)

View File

@@ -0,0 +1,20 @@
import type { LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react'
interface Props {
icon: LucideIcon
title: string
hint: ReactNode
testId?: string
className?: string
}
export function EmptyStateCard({ icon: Icon, title, hint, testId, className = '' }: Props) {
return (
<div className={`empty-state ui-card ${className}`.trim()} data-testid={testId}>
<Icon size={32} className="empty-state-icon" aria-hidden />
<div className="empty-state-title">{title}</div>
<div className="empty-state-desc">{hint}</div>
</div>
)
}

View File

@@ -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>
</>
)
}

View File

@@ -1,13 +1,36 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { LineChart } from 'lucide-react'
import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session, Chapter } from '../types'
import { fetchReplayFrames, fetchTrackOutline } from '../api'
import { ReplayTrackMap } from './ReplayTrackMap'
import { ChapterStrip } from './ChapterStrip'
import { EmptyStateCard } from './EmptyStateCard'
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
import { chapterEndScrub, chapterStartScrub, chapterTourDurations } from '../lib/chapters'
import {
chapterBandFill,
chapterEndScrub,
chapterStartScrub,
chapterTourDurations,
deCollideYPositions,
decimatedPositionLabels,
} from '../lib/chapters'
import { isReplayMapAvailable } from '../lib/replayMap'
import '../styles/race-story.css'
const CHAPTER_TOUR_MS = 90_000
const CHART_W = 640
const CHART_H = 180
const CHART_PL = 40
const CHART_PR = 48
const CHART_PT = 8
const CHART_PB = 20
interface ChartTiming {
tMin: number
tMax: number
tRange: number
}
interface Props {
data: {
@@ -25,6 +48,48 @@ interface Props {
}
}
/**
* Position samples are recorded for the entire session, including the pre-race
* grid period. Use the winning driver's laps to define the race window so
* that the x-axis starts at lights-out rather than at the earliest sample.
*/
function raceChartTiming(
positions: PositionSample[],
laps: Lap[],
results: EnrichedResult[],
): ChartTiming | null {
const positionTimes = positions
.map((position) => new Date(position.date).getTime())
.filter(Number.isFinite)
if (positionTimes.length === 0) return null
const fallbackMin = Math.min(...positionTimes)
const fallbackMax = Math.max(...positionTimes)
const fallback = {
tMin: fallbackMin,
tMax: fallbackMax,
tRange: Math.max(fallbackMax - fallbackMin, 1),
}
const winner = results.find((result) => result.position === 1)
if (!winner) return fallback
const winnerLaps = laps
.filter((lap) => lap.driver_number === winner.driver_number && lap.lap_number > 0)
.map((lap) => ({ ...lap, start: new Date(lap.date_start).getTime() }))
.filter((lap) => Number.isFinite(lap.start))
.sort((a, b) => a.lap_number - b.lap_number)
if (winnerLaps.length === 0) return fallback
const firstLap = winnerLaps[0]
const lastLap = winnerLaps[winnerLaps.length - 1]
const finalLapDuration = lastLap.lap_duration ?? 0
const tMax = lastLap.start + (finalLapDuration > 0 ? finalLapDuration * 1000 : 0)
if (tMax <= firstLap.start) return fallback
return { tMin: firstLap.start, tMax, tRange: tMax - firstLap.start }
}
export function RaceStoryCanvas({ data }: Props) {
const {
results,
@@ -48,32 +113,46 @@ export function RaceStoryCanvas({ data }: Props) {
const [playbackSpeed, setPlaybackSpeed] = useState(10)
const [chapterTourActive, setChapterTourActive] = useState(false)
const [tourChapterIndex, setTourChapterIndex] = useState<number | null>(null)
const [selectedChapterIndex, setSelectedChapterIndex] = useState<number | null>(null)
const svgRef = useRef<SVGSVGElement>(null)
const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 })
// Position Evolution Chart Logic
const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions])
const hasChartData = hasPositions && allTimes.length > 0
const chartTiming = useMemo(() => {
if (!hasChartData) return null
const tMin = new Date(allTimes[0]).getTime()
const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
return { tMin, tMax, tRange: Math.max(tMax - tMin, 1) }
}, [allTimes, hasChartData])
const chartTiming = useMemo(() => raceChartTiming(positions, laps, results), [laps, positions, results])
const hasChartData = hasPositions && chartTiming !== null
const circuitKey = session?.circuit_key ?? meeting?.circuit_key ?? 0
const outlineYear = meeting?.year ?? (session?.date_start ? new Date(session.date_start).getFullYear() : 0)
const canProbeMap = Boolean(session?.session_key) && circuitKey > 0 && outlineYear > 0
const replayQuery = useQuery({
queryKey: ['replay-frames', session?.session_key, 5000],
queryFn: () => fetchReplayFrames(session!.session_key, 5000),
enabled: mapOpen && Boolean(session?.session_key),
enabled: canProbeMap,
})
const outlineQuery = useQuery({
queryKey: ['track-outline', circuitKey, outlineYear],
queryFn: () => fetchTrackOutline(circuitKey, outlineYear),
enabled: mapOpen && circuitKey > 0 && outlineYear > 0,
enabled: canProbeMap,
})
const mapProbeSettled = !canProbeMap || (!replayQuery.isLoading && !outlineQuery.isLoading)
const mapAvailable = useMemo(
() =>
canProbeMap &&
isReplayMapAvailable(
replayQuery.data,
outlineQuery.data,
replayQuery.isError || outlineQuery.isError,
),
[canProbeMap, outlineQuery.data, outlineQuery.isError, replayQuery.data, replayQuery.isError],
)
const showMapPanel = mapOpen && mapAvailable
useEffect(() => {
if (!mapAvailable && mapOpen) {
setMapOpen(false)
}
}, [mapAvailable, mapOpen])
useEffect(() => {
if (!isPlaying || !chartTiming) return
@@ -104,9 +183,14 @@ export function RaceStoryCanvas({ data }: Props) {
const jumpToChapter = (index: number, scrub: number) => {
setIsPlaying(false)
stopChapterTour()
setSelectedChapterIndex(index)
setScrubTime(scrub)
}
const clearChapterSelection = () => {
setSelectedChapterIndex(null)
}
const toggleChapterTour = () => {
if (chapterTourActive) {
stopChapterTour()
@@ -114,6 +198,7 @@ export function RaceStoryCanvas({ data }: Props) {
}
if (!chartTiming || chapters.length === 0) return
setIsPlaying(false)
setSelectedChapterIndex(null)
setChapterTourActive(true)
setTourChapterIndex(0)
const startScrub = chapterStartScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? 0
@@ -194,16 +279,19 @@ export function RaceStoryCanvas({ data }: Props) {
if (hasChartData && chartTiming) {
const { tMin, tRange } = chartTiming
const normaliseTime = (time: number) => Math.max(0, Math.min(1, (time - tMin) / tRange))
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
for (const p of positions) {
const time = new Date(p.date).getTime()
if (!Number.isFinite(time)) continue
if (!byDriver.has(p.driver_number)) byDriver.set(p.driver_number, [])
byDriver.get(p.driver_number)!.push({
t: (new Date(p.date).getTime() - tMin) / tRange,
t: normaliseTime(time),
pos: p.position,
})
}
const dnfSet = new Set(results.filter(r => r.dnf || r.dns || r.dsq).map(r => r.driver_number))
const dnfSet = new Set(results.filter((r) => r.dnf || r.dns || r.dsq).map((r) => r.driver_number))
for (const [dNum, samples] of byDriver.entries()) {
samples.sort((a, b) => a.t - b.t)
if (samples.length > 0 && !dnfSet.has(dNum)) {
@@ -211,16 +299,16 @@ export function RaceStoryCanvas({ data }: Props) {
}
}
const getInterpPos = (samples: {t: number, pos: number}[], t: number) => {
const getInterpPos = (samples: { t: number; pos: number }[], t: number) => {
if (!samples || samples.length === 0) return null
if (t <= samples[0].t) return samples[0].pos
if (t >= samples[samples.length - 1].t) return samples[samples.length - 1].pos
for (let i = 0; i < samples.length - 1; i++) {
if (samples[i].t <= t && samples[i+1].t >= t) {
const dt = samples[i+1].t - samples[i].t
if (samples[i].t <= t && samples[i + 1].t >= t) {
const dt = samples[i + 1].t - samples[i].t
if (dt === 0) return samples[i].pos
const frac = (t - samples[i].t) / dt
return samples[i].pos + (samples[i+1].pos - samples[i].pos) * frac
return samples[i].pos + (samples[i + 1].pos - samples[i].pos) * frac
}
}
return null
@@ -244,34 +332,37 @@ export function RaceStoryCanvas({ data }: Props) {
const maxPos = Math.max(...positions.map((p) => p.position), results.length, 2)
const colorByDriver = new Map(results.map((r) => [r.driver_number, r.team_colour]))
const acronymByDriver = new Map(results.map((r) => [r.driver_number, r.name_acronym]))
const positionLabels = decimatedPositionLabels(maxPos)
const W = 640
const H = 180
const PL = 40
const PR = 48
const PT = 8
const PB = 20
const W = CHART_W
const H = CHART_H
const PL = CHART_PL
const PR = CHART_PR
const PT = CHART_PT
const PB = CHART_PB
const plotW = W - PL - PR
const plotH = H - PT - PB
const toX = (t: number) => PL + t * plotW
const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH
const winner = results.find(r => r.position === 1)
const winnerLaps = winner ? laps.filter(l => l.driver_number === winner.driver_number) : []
const lapTicks: { lap: number, t: number }[] = []
const winner = results.find((r) => r.position === 1)
const winnerLaps = winner ? laps.filter((l) => l.driver_number === winner.driver_number) : []
const lapTicks: { lap: number; t: number }[] = []
const lapInterval = winnerLaps.length < 30 ? 5 : 10
for (const lap of winnerLaps) {
for (let i = 0; i < winnerLaps.length; i++) {
const lap = winnerLaps[i]
if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) {
const t = (new Date(lap.date_start).getTime() - tMin) / tRange
if (t >= 0 && t <= 1) {
lapTicks.push({ lap: lap.lap_number, t })
}
const lapStart = new Date(lap.date_start).getTime()
const nextLapStart = winnerLaps[i + 1] ? new Date(winnerLaps[i + 1].date_start).getTime() : NaN
const lapEnd = lap.lap_duration && lap.lap_duration > 0
? lapStart + lap.lap_duration * 1000
: nextLapStart
if (Number.isFinite(lapEnd)) lapTicks.push({ lap: lap.lap_number, t: normaliseTime(lapEnd) })
}
}
// Safety Car / VSC periods
const scPeriods: { start: number; end: number | null; type: 'SC' | 'VSC' }[] = []
let activeSC: { start: number; type: 'SC' | 'VSC' } | null = null
const rc = [...race_control].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
@@ -296,25 +387,73 @@ export function RaceStoryCanvas({ data }: Props) {
scPeriods.push({ start: activeSC.start, end: null, type: activeSC.type })
}
const chapterBands = chapters
.map((chapter, index) => {
const startT = chapterStartScrub(chapter, tMin, tRange)
const endT = chapterEndScrub(chapter, tMin, tRange)
if (startT === null) return null
const end = endT ?? startT
return {
key: `${chapter.kind}-${index}`,
start: Math.min(startT, end),
end: Math.max(startT, end),
fill: chapterBandFill(chapter.kind),
}
})
.filter((band): band is NonNullable<typeof band> => band !== null)
const labelCandidates = Array.from(byDriver.entries())
.map(([dNum, samples]) => {
const last = samples[samples.length - 1]
if (!last) return null
return { key: dNum, y: toY(last.pos) }
})
.filter((item): item is { key: number; y: number } => item !== null)
const labelYByDriver = deCollideYPositions(labelCandidates, 12)
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
setIsPlaying(false)
stopChapterTour()
clearChapterSelection()
if (!svgRef.current) return
const rect = svgRef.current.getBoundingClientRect()
const x = e.clientX - rect.left
if (rect.width <= 0) return
// Pointer coordinates are CSS pixels; convert them to the SVG viewBox
// before comparing with the fixed chart margins and plot width.
const x = ((e.clientX - rect.left) / rect.width) * W
const t = Math.max(0, Math.min(1, (x - PL) / plotW))
setScrubTime(t)
}
chartContent = (
<div className="rs-chart-container scroll-x" data-testid="position-chart">
<div
className={`rs-chart-container scroll-x${showMapPanel ? '' : ' rs-chart-container--full'}`}
data-testid="position-chart"
>
<svg
ref={svgRef}
className="rs-position-chart-svg"
viewBox={`0 0 ${W} ${H}`}
style={{ width: '100%', minWidth: 280, maxWidth: W, display: 'block' }}
role="img"
aria-label="Position evolution chart"
>
{chapterBands.map((band) => {
const x1 = toX(Math.max(0, band.start))
const x2 = toX(Math.min(1, band.end))
if (x2 <= PL || x1 >= W - PR) return null
return (
<rect
key={band.key}
x={x1}
y={PT}
width={Math.max(0, x2 - x1)}
height={plotH}
fill={band.fill}
data-testid="chapter-band"
/>
)
})}
{scPeriods.map((sc, i) => {
const startT = (sc.start - tMin) / tRange
const endT = sc.end ? (sc.end - tMin) / tRange : 1
@@ -333,33 +472,41 @@ export function RaceStoryCanvas({ data }: Props) {
)
})}
{Array.from({ length: maxPos }, (_, i) => i + 1).map((pos) => (
{positionLabels.map((pos) => (
<g key={pos}>
<line
x1={PL}
x2={W - PR}
y1={toY(pos)}
y2={toY(pos)}
stroke="var(--border)"
strokeWidth={0.5}
className="rs-chart-grid-line"
/>
<text
x={PL - 4}
y={toY(pos) + 4}
textAnchor="end"
fill="var(--text-3)"
fontSize={8}
fontFamily="var(--f-mono)"
className="rs-chart-axis-label"
>
P{pos}
</text>
</g>
))}
{lapTicks.map(tick => (
{lapTicks.map((tick) => (
<g key={`lap-${tick.lap}`}>
<line x1={toX(tick.t)} x2={toX(tick.t)} y1={H - PB} y2={H - PB + 4} stroke="var(--border)" strokeWidth={1} />
<text x={toX(tick.t)} y={H - PB + 14} textAnchor="middle" fill="var(--text-3)" fontSize={9} fontFamily="var(--f-mono)">
<line
x1={toX(tick.t)}
x2={toX(tick.t)}
y1={H - PB}
y2={H - PB + 4}
className="rs-chart-lap-tick"
/>
<text
x={toX(tick.t)}
y={H - PB + 14}
textAnchor="middle"
className="rs-chart-axis-label"
>
L{tick.lap}
</text>
</g>
@@ -372,8 +519,9 @@ export function RaceStoryCanvas({ data }: Props) {
const last = samples[samples.length - 1]
const isHovered = hoverDriver === dNum
const isFaded = hoverDriver !== null && !isHovered
const labelY = last ? (labelYByDriver.get(dNum) ?? toY(last.pos)) : 0
const driverPits = pit_stops.filter(p => p.driver_number === dNum)
const driverPits = pit_stops.filter((p) => p.driver_number === dNum)
return (
<g
@@ -394,8 +542,9 @@ export function RaceStoryCanvas({ data }: Props) {
/>
{driverPits.map((p, i) => {
const t = (new Date(p.date).getTime() - tMin) / tRange
if (t < 0 || t > 1) return null
const time = new Date(p.date).getTime()
if (!Number.isFinite(time) || time < tMin || time > tMin + tRange) return null
const t = normaliseTime(time)
const pos = getInterpPos(samples, t)
if (pos === null) return null
return (
@@ -415,7 +564,7 @@ export function RaceStoryCanvas({ data }: Props) {
{last && (
<text
x={toX(last.t) + 6}
y={toY(last.pos) + 4}
y={labelY + 4}
fill={color}
fontSize={isHovered ? 11 : 9}
fontFamily="var(--f-mono)"
@@ -449,44 +598,57 @@ export function RaceStoryCanvas({ data }: Props) {
width={plotW}
height={plotH}
fill="transparent"
data-testid="position-chart-interaction"
onPointerMove={handlePointerMove}
onPointerLeave={() => {
if (!isPlaying) setScrubTime(null)
if (!isPlaying) {
clearChapterSelection()
setScrubTime(null)
}
}}
style={{ cursor: 'crosshair', touchAction: 'none' }}
/>
</svg>
<div className="rs-replay-tools" aria-label="Race replay controls">
<div className="rs-segmented-control" aria-label="Race replay controls" data-testid="replay-controls">
<button
type="button"
className={`rs-tool-btn ${isPlaying ? 'active' : ''}`}
className={`rs-segment ${isPlaying ? 'active' : ''}`}
onClick={() => {
setScrubTime((current) => current ?? 0)
stopChapterTour()
clearChapterSelection()
setIsPlaying((current) => !current)
}}
aria-pressed={isPlaying}
>
{isPlaying ? 'Pause' : 'Play'}
</button>
<div className="rs-speed-group" aria-label="Playback speed">
{[1, 10, 30].map((speed) => (
<span className="rs-segment-divider" aria-hidden />
{[1, 10, 30].map((speed) => (
<button
key={speed}
type="button"
className={`rs-segment ${playbackSpeed === speed ? 'active' : ''}`}
onClick={() => setPlaybackSpeed(speed)}
aria-pressed={playbackSpeed === speed}
>
{speed}x
</button>
))}
{mapProbeSettled && mapAvailable && (
<>
<span className="rs-segment-divider" aria-hidden />
<button
key={speed}
type="button"
className={`rs-speed-btn ${playbackSpeed === speed ? 'active' : ''}`}
onClick={() => setPlaybackSpeed(speed)}
className={`rs-segment ${mapOpen ? 'active' : ''}`}
onClick={() => setMapOpen((current) => !current)}
aria-pressed={mapOpen}
data-testid="replay-map-toggle"
>
{speed}x
Map
</button>
))}
</div>
<button
type="button"
className={`rs-tool-btn ${mapOpen ? 'active' : ''}`}
onClick={() => setMapOpen((current) => !current)}
>
Map
</button>
</>
)}
</div>
</div>
)
@@ -502,36 +664,41 @@ export function RaceStoryCanvas({ data }: Props) {
tRange={chartTiming.tRange}
tourActive={chapterTourActive}
tourChapterIndex={tourChapterIndex}
selectedChapterIndex={selectedChapterIndex}
onChapterClick={jumpToChapter}
onTourToggle={toggleChapterTour}
/>
)}
<div className="rs-replay-shell">
<div className={`rs-replay-shell${showMapPanel ? ' rs-replay-shell--split' : ''}`}>
<div className="rs-replay-main">
{hasChartData ? (
chartContent
) : (
<div className="analysis-notice">
<strong>Lap-by-lap positions not available.</strong> This session does not
have ingested position samples in <code>/api/v1/race-hub</code>.
</div>
<EmptyStateCard
icon={LineChart}
title="Lap-by-lap positions not available"
hint={
<>
This session does not have ingested position samples in{' '}
<code>/api/v1/race-hub</code>.
</>
}
testId="race-story-no-positions"
className="race-story-empty-card"
/>
)}
</div>
{mapOpen && (
<div className="rs-replay-map-slot">
{circuitKey > 0 && outlineYear > 0 ? (
<ReplayTrackMap
outline={outlineQuery.data}
replay={replayQuery.data}
tMs={replayTMs}
drivers={drivers}
results={results}
loading={outlineQuery.isLoading || replayQuery.isLoading}
error={outlineQuery.isError || replayQuery.isError}
/>
) : (
<div className="rs-replay-map-placeholder">track identity unavailable for replay map</div>
)}
{showMapPanel && (
<div className="rs-replay-map-slot" data-testid="replay-map-slot">
<ReplayTrackMap
outline={outlineQuery.data}
replay={replayQuery.data}
tMs={replayTMs}
drivers={drivers}
results={results}
loading={outlineQuery.isLoading || replayQuery.isLoading}
error={outlineQuery.isError || replayQuery.isError}
/>
</div>
)}
</div>
@@ -542,7 +709,8 @@ export function RaceStoryCanvas({ data }: Props) {
const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
const currentPos = scrubTime !== null ? i + 1 : r.position
const isWinner = i === 0 && r.position === 1
const pClass = currentPos === 1 ? 'rs-pos-p1' : currentPos === 2 ? 'rs-pos-p2' : currentPos === 3 ? 'rs-pos-p3' : ''
const pClass =
currentPos === 1 ? 'rs-pos-p1' : currentPos === 2 ? 'rs-pos-p2' : currentPos === 3 ? 'rs-pos-p3' : ''
let currentPoints: number | string = r.points
if (scrubTime !== null) {
@@ -559,9 +727,7 @@ export function RaceStoryCanvas({ data }: Props) {
onMouseLeave={() => setHoverDriver(null)}
>
<div className="rs-driver-left">
<div className={`rs-pos-col ${pClass}`}>
{currentPos}
</div>
<div className={`rs-pos-col ${pClass}`}>{currentPos}</div>
<div className="rs-driver-cell">
<div
className="rs-driver-color"

View File

@@ -1,7 +1,9 @@
import { useMemo, useState } from 'react'
import { Loader2, MapPin, Satellite } from 'lucide-react'
import type { Driver, EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types'
import { buildOutlinePath } from '../lib/trackmap'
import { interpolateReplayCars, replayCarToSvg } from '../lib/replay'
import { EmptyStateCard } from './EmptyStateCard'
import '../styles/replay-map.css'
interface Props {
@@ -60,7 +62,13 @@ export function ReplayTrackMap({
if (loading) {
return (
<section className="replay-map-panel" data-testid="replay-track-map">
<div className="replay-map-empty">loading replay GPS...</div>
<EmptyStateCard
icon={Loader2}
title="Loading replay map"
hint="Fetching track outline and historical GPS frames."
testId="replay-map-loading"
className="replay-map-empty-card"
/>
</section>
)
}
@@ -68,7 +76,13 @@ export function ReplayTrackMap({
if (error) {
return (
<section className="replay-map-panel" data-testid="replay-track-map">
<div className="replay-map-empty">replay GPS unavailable for this session</div>
<EmptyStateCard
icon={Satellite}
title="Replay GPS unavailable"
hint="This session does not have ingested location samples for the replay map."
testId="replay-map-error"
className="replay-map-empty-card"
/>
</section>
)
}
@@ -76,7 +90,13 @@ export function ReplayTrackMap({
if (!outline || !outlinePath) {
return (
<section className="replay-map-panel" data-testid="replay-track-map">
<div className="replay-map-empty">track outline unavailable for this session</div>
<EmptyStateCard
icon={MapPin}
title="Track outline unavailable"
hint="Circuit GPS outline data is missing for this session."
testId="replay-map-no-outline"
className="replay-map-empty-card"
/>
</section>
)
}
@@ -84,7 +104,13 @@ export function ReplayTrackMap({
if (!replay?.frames?.length || cars.length === 0) {
return (
<section className="replay-map-panel" data-testid="replay-track-map">
<div className="replay-map-empty">historical GPS unavailable for this session</div>
<EmptyStateCard
icon={Satellite}
title="Historical GPS unavailable"
hint="Fewer than two replay frames were returned for this session."
testId="replay-map-no-frames"
className="replay-map-empty-card"
/>
</section>
)
}

View File

@@ -0,0 +1,271 @@
import { useMemo, useState } from 'react'
import type { ChampHubDriver, ChampionshipHub } from '../types'
import { teamColor } from '../utils'
import { gapSeries, h2hTally, lastRounds } from '../lib/rivalry'
import '../styles/rivalry.css'
const PAD_L = 48
const PAD_T = 16
const PLOT_W = 882
const PLOT_H = 316
const GAP_PLOT_H = 200
function fmtPts(n: number): string {
return Number.isInteger(n) ? String(n) : n.toFixed(1)
}
/** Up to ~7 evenly spaced round labels, always including the last round. */
function xTicks(n: number, labels: string[], x: (i: number) => number): { x: number; label: string }[] {
const step = Math.max(1, Math.ceil(n / 7))
const ticks: { x: number; label: string }[] = []
for (let i = 0; i < n; i += step) {
ticks.push({ x: x(i), label: labels[i] ?? `R${i + 1}` })
}
const last = labels[n - 1] ?? `R${n}`
if (ticks[ticks.length - 1]?.label !== last) {
ticks.push({ x: x(n - 1), label: last })
}
return ticks
}
function polyline(values: number[], x: (i: number) => number, y: (v: number) => number): string {
return values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ')
}
interface PickerProps {
id: 'a' | 'b'
label: string
drivers: ChampHubDriver[]
value: number
color: string
onChange: (driverNumber: number) => void
}
function DriverPicker({ id, label, drivers, value, color, onChange }: PickerProps) {
return (
<label className="rivalry-picker">
<span className="rivalry-picker-label">{label}</span>
<select
className="rivalry-picker-select"
style={{ borderLeft: `3px solid ${color}` }}
value={value}
data-testid={`rivalry-pick-${id}`}
onChange={(e) => onChange(Number(e.target.value))}
>
{drivers.map((d) => (
<option key={d.driver_number} value={d.driver_number}>
{d.name_acronym} · {d.full_name}
</option>
))}
</select>
</label>
)
}
export function RivalryCompare({ hub }: { hub: ChampionshipHub }) {
const { drivers } = hub
const [pickA, setPickA] = useState<number | null>(null)
const [pickB, setPickB] = useState<number | null>(null)
// Default to the top two in the standings; fall back there if a picked
// driver disappears (e.g. season switch re-fetches the hub).
const a = drivers.find((d) => d.driver_number === pickA) ?? drivers[0]
const b = drivers.find((d) => d.driver_number === pickB) ?? drivers[1]
const tally = useMemo(
() => (a && b ? h2hTally(a.round_positions ?? [], b.round_positions ?? [], hub.round_labels) : null),
[a, b, hub.round_labels],
)
if (drivers.length < 2) {
return (
<div className="champ-chart-empty" data-testid="champ-view-rivalry">
Need at least two drivers in the standings to compare a rivalry.
</div>
)
}
const colorA = teamColor(a.team_colour)
const colorB = teamColor(b.team_colour)
const sameTeam = a.team_name === b.team_name
const cumA = a.cumulative ?? []
const cumB = b.cumulative ?? []
const rounds = Math.min(cumA.length, cumB.length)
const gaps = gapSeries(cumA, cumB)
const lastGap = gaps[gaps.length - 1] ?? 0
const strip = tally ? lastRounds(tally, 5) : []
const pickers = (
<div className="rivalry-pickers">
<DriverPicker id="a" label="Driver A" drivers={drivers} value={a.driver_number} color={colorA} onChange={setPickA} />
<span className="rivalry-vs mono">vs</span>
<DriverPicker id="b" label="Driver B" drivers={drivers} value={b.driver_number} color={colorB} onChange={setPickB} />
</div>
)
if (rounds === 0) {
return (
<div className="rivalry" data-testid="champ-view-rivalry">
{pickers}
<div className="champ-chart-empty">
No completed rounds yet the rivalry will appear after the first race.
</div>
</div>
)
}
// Points race scales.
const peak = Math.max(...cumA.slice(0, rounds), ...cumB.slice(0, rounds), 1)
const maxY = Math.max(50, Math.ceil(peak / 50) * 50)
const px = (i: number) => (rounds <= 1 ? PAD_L : PAD_L + (i * PLOT_W) / (rounds - 1))
const py = (v: number) => PAD_T + PLOT_H - (v / maxY) * PLOT_H
const pyGrid = [0, 0.25, 0.5, 0.75, 1].map((f) => ({ y: py(maxY * f), label: Math.round(maxY * f) }))
const pxGrid = xTicks(rounds, hub.round_labels, px)
// Gap scales: symmetric around zero.
const maxAbs = Math.max(10, Math.ceil(Math.max(...gaps.map(Math.abs), 1) / 10) * 10)
const gy = (v: number) => PAD_T + GAP_PLOT_H / 2 - (v / maxAbs) * (GAP_PLOT_H / 2)
const gyGrid = [maxAbs, 0, -maxAbs].map((v) => ({ y: gy(v), label: v > 0 ? `+${v}` : String(v) }))
const gxGrid = xTicks(rounds, hub.round_labels, px)
const gapLeader = lastGap === 0 ? null : lastGap > 0 ? a : b
const gapCaption = gapLeader
? `${gapLeader.name_acronym} leads by ${fmtPts(Math.abs(lastGap))} pts after ${hub.round_labels[rounds - 1] ?? `R${rounds}`}.`
: 'Dead level on points.'
return (
<div className="rivalry" data-testid="champ-view-rivalry">
{pickers}
<div className="rivalry-h2h" data-testid="rivalry-h2h">
<div className="rivalry-h2h-score">
<span className="rivalry-h2h-code mono" style={{ color: colorA }}>
{a.name_acronym}
</span>
<span className="rivalry-h2h-num mono" data-testid="rivalry-h2h-num">
{tally ? `${tally.a}${tally.b}` : '—'}
</span>
<span className="rivalry-h2h-code mono" style={{ color: colorB }}>
{b.name_acronym}
</span>
</div>
<span className="rivalry-h2h-meta">
Race head-to-head · {tally?.rounds.length ?? 0} round{(tally?.rounds.length ?? 0) === 1 ? '' : 's'} counted
{tally && tally.skipped > 0 ? ` · ${tally.skipped} skipped` : ''}
</span>
{strip.length > 0 && (
<div className="rivalry-strip" data-testid="rivalry-strip">
<span className="rivalry-strip-label">Last {strip.length}</span>
{strip.map((r) => (
<span className="rivalry-chip" key={r.round} title={`${r.label}: ${a.name_acronym} P${r.posA} · ${b.name_acronym} P${r.posB}`}>
<span className="rivalry-chip-round mono">{r.label}</span>
<span className="rivalry-chip-winner mono" style={{ color: r.winner === 'a' ? colorA : colorB }}>
{r.winner === 'a' ? a.name_acronym : b.name_acronym}
</span>
</span>
))}
</div>
)}
</div>
<section className="rivalry-section" data-testid="rivalry-points-race">
<div className="champ-chart-head">
<span className="champ-chart-title mono">
Points race {a.name_acronym} vs {b.name_acronym}
</span>
<span className="champ-chart-meta">
Rounds 1{rounds} · {hub.season}
</span>
</div>
<div className="champ-chart">
<svg viewBox="0 0 1000 380" className="champ-chart-svg" preserveAspectRatio="none">
{pyGrid.map((g) => (
<g key={g.label}>
<line x1={PAD_L} y1={g.y} x2={930} y2={g.y} stroke="var(--border)" strokeWidth="1" />
<text x={40} y={g.y + 4} textAnchor="end" className="champ-chart-axis">
{g.label}
</text>
</g>
))}
{pxGrid.map((g, i) => (
<text key={i} x={g.x} y={372} textAnchor="middle" className="champ-chart-axis">
{g.label}
</text>
))}
{[
{ slot: 'a', d: a, color: colorA, dash: '0', values: cumA.slice(0, rounds) },
{ slot: 'b', d: b, color: colorB, dash: sameTeam ? '5 4' : '0', values: cumB.slice(0, rounds) },
].map((s) => (
<g key={s.slot}>
<polyline
points={polyline(s.values, px, py)}
fill="none"
stroke={s.color}
strokeWidth="2.4"
strokeDasharray={s.dash}
strokeLinejoin="round"
strokeLinecap="round"
/>
<circle cx={px(s.values.length - 1)} cy={py(s.values[s.values.length - 1])} r="3" fill={s.color} />
<text
x={px(s.values.length - 1) + 8}
y={py(s.values[s.values.length - 1]) + 4}
fill={s.color}
className="champ-chart-label"
>
{s.d.name_acronym}
</text>
</g>
))}
</svg>
</div>
</section>
<section className="rivalry-section" data-testid="rivalry-gap">
<div className="champ-chart-head">
<span className="champ-chart-title mono">
Gap over season {a.name_acronym} {b.name_acronym}
</span>
<span className="champ-chart-meta">{gapCaption}</span>
</div>
<div className="champ-chart">
<svg viewBox="0 0 1000 250" className="champ-chart-svg" preserveAspectRatio="none">
{gyGrid.map((g) => (
<g key={g.label}>
<line
x1={PAD_L}
y1={g.y}
x2={930}
y2={g.y}
stroke={g.label === '0' ? 'var(--border-2)' : 'var(--border)'}
strokeWidth={g.label === '0' ? 1.5 : 1}
/>
<text x={40} y={g.y + 4} textAnchor="end" className="champ-chart-axis">
{g.label}
</text>
</g>
))}
{gxGrid.map((g, i) => (
<text key={i} x={g.x} y={242} textAnchor="middle" className="champ-chart-axis">
{g.label}
</text>
))}
<polyline
points={polyline(gaps, px, gy)}
fill="none"
stroke={colorA}
strokeWidth="2.4"
strokeLinejoin="round"
strokeLinecap="round"
/>
<circle cx={px(gaps.length - 1)} cy={gy(lastGap)} r="3" fill={colorA} />
</svg>
</div>
<p className="rivalry-caption">
Above the zero line: {a.name_acronym} ahead. Below: {b.name_acronym} ahead.
</p>
</section>
</div>
)
}

View 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&apos;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&apos;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>
)
}

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

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

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

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

View 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'
}
}

View 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,
}
}

View File

@@ -53,7 +53,10 @@ export function chapterEndScrub(
return Math.max(0, Math.min(1, (ms - tMin) / tRange))
}
/** Index of the chapter containing the current scrub position, if any. */
/** Index of the chapter containing the current scrub position, if any.
* Uses the same 01 clamped bounds as chapterStartScrub/chapterEndScrub so
* chapters whose timestamps fall outside the position-sample window still
* activate when the scrubber is parked at the clamped edge. */
export function activeChapterIndex(
chapters: Chapter[],
scrubTime: number | null,
@@ -61,15 +64,13 @@ export function activeChapterIndex(
tRange: number,
): number | null {
if (scrubTime === null || chapters.length === 0 || tRange <= 0) return null
const chartMs = tMin + scrubTime * tRange
for (let i = 0; i < chapters.length; i++) {
const ch = chapters[i]
const startMs = ch.start_time ? new Date(ch.start_time).getTime() : NaN
const endRaw = ch.end_time ?? ch.start_time
const endMs = endRaw ? new Date(endRaw).getTime() : NaN
if (!Number.isNaN(startMs) && !Number.isNaN(endMs) && chartMs >= startMs && chartMs <= endMs) {
return i
}
const start = chapterStartScrub(chapters[i], tMin, tRange)
const end = chapterEndScrub(chapters[i], tMin, tRange)
if (start === null || end === null) continue
const lo = Math.min(start, end)
const hi = Math.max(start, end)
if (scrubTime >= lo && scrubTime <= hi) return i
}
return null
}
@@ -80,3 +81,52 @@ export function chapterTourDurations(chapters: Chapter[], totalMs = 90_000): num
const perChapter = totalMs / chapters.length
return chapters.map(() => perChapter)
}
/** Decimated position axis labels, e.g. P1, P5, P10, P15, P20. */
export function decimatedPositionLabels(maxPos: number): number[] {
if (maxPos <= 1) return [1]
const labels = new Set<number>([1])
for (let pos = 5; pos < maxPos; pos += 5) {
labels.add(pos)
}
labels.add(maxPos)
return [...labels].sort((a, b) => a - b)
}
/** Subtle background fill for chapter time-bands on the position graph. */
export function chapterBandFill(kind: string): string {
switch (kind) {
case 'safety_car':
return 'rgba(255, 153, 0, 0.14)'
case 'virtual_safety_car':
return 'rgba(255, 204, 0, 0.12)'
case 'red_flag':
return 'rgba(230, 36, 41, 0.12)'
case 'pit_phase':
return 'rgba(96, 165, 250, 0.08)'
case 'decisive_swing':
return 'rgba(34, 197, 94, 0.08)'
case 'finish':
return 'rgba(255, 204, 0, 0.06)'
default:
return 'rgba(255, 255, 255, 0.03)'
}
}
/** Minimum vertical spacing between colliding labels (SVG units). */
export function deCollideYPositions(
items: ReadonlyArray<{ key: string | number; y: number }>,
minGap = 12,
): Map<string | number, number> {
if (items.length === 0) return new Map()
const sorted = [...items].sort((a, b) => a.y - b.y)
const adjusted = sorted.map((item) => ({ ...item }))
for (let i = 1; i < adjusted.length; i++) {
const prev = adjusted[i - 1]
const curr = adjusted[i]
if (curr.y - prev.y < minGap) {
curr.y = prev.y + minGap
}
}
return new Map(adjusted.map((item) => [item.key, item.y]))
}

View File

@@ -0,0 +1,14 @@
import type { ReplayFramesResponse, TrackOutline } from '../types'
/** Whether replay map can render (probe uses existing frames + outline queries). */
export function isReplayMapAvailable(
replay: ReplayFramesResponse | null | undefined,
outline: TrackOutline | null | undefined,
hasError: boolean,
): boolean {
if (hasError) return false
const frames = replay?.frames ?? []
if (frames.length < 2) return false
if (!outline?.points?.length) return false
return true
}

View File

@@ -0,0 +1,61 @@
// Pure helpers for the championship rivalry compare view.
/** One round that counted toward the head-to-head tally. */
export interface H2HRound {
round: number // 1-based season round
label: string
posA: number
posB: number
winner: 'a' | 'b'
}
export interface H2HTally {
a: number
b: number
/** Rounds counted in the tally, in season order. */
rounds: H2HRound[]
/** Rounds skipped because either driver had no finishing position. */
skipped: number
}
/**
* Per-round points gap (a b) across the rounds both cumulative series
* cover. Positive values mean driver A is ahead.
*/
export function gapSeries(a: number[], b: number[]): number[] {
const n = Math.min(a.length, b.length)
const out: number[] = []
for (let i = 0; i < n; i++) out.push(a[i] - b[i])
return out
}
/**
* Race head-to-head: per round, the lower finishing position wins. Rounds
* where either driver has no position (0 / missing) are skipped from the
* tally, as are equal positions (defensive — races can't tie).
*/
export function h2hTally(posA: number[], posB: number[], labels: string[]): H2HTally {
const n = Math.max(posA.length, posB.length)
const rounds: H2HRound[] = []
let a = 0
let b = 0
let skipped = 0
for (let i = 0; i < n; i++) {
const pa = posA[i] ?? 0
const pb = posB[i] ?? 0
if (pa <= 0 || pb <= 0 || pa === pb) {
skipped++
continue
}
const winner = pa < pb ? 'a' : 'b'
if (winner === 'a') a++
else b++
rounds.push({ round: i + 1, label: labels[i] ?? `R${i + 1}`, posA: pa, posB: pb, winner })
}
return { a, b, rounds, skipped }
}
/** Last n counted rounds of a tally, for the mini strip. */
export function lastRounds(tally: H2HTally, n: number): H2HRound[] {
return tally.rounds.slice(-n)
}

View 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,
}))
}

View File

@@ -5,12 +5,13 @@ import { fetchChampionshipHub, fetchSeasons } from '../api'
import { teamColor } from '../utils'
import type { ChampHubDriver, ChampionshipHub } from '../types'
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
import { RivalryCompare } from '../components/RivalryCompare'
import { Meaning } from '../components/Meaning'
import { TeammateH2H } from '../components/TeammateH2H'
import { teammatePairs } from '../lib/h2h'
import { pointsGapMeaning } from '../lib/meaning'
type View = 'drivers' | 'constructors' | 'progression' | 'simulator'
type View = 'drivers' | 'constructors' | 'progression' | 'rivalry' | 'simulator'
const GOLD = '#ffd700'
const SILVER = '#c0c0c0'
@@ -216,6 +217,14 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
>
Progression
</button>
<button
type="button"
className={`champ-tab${view === 'rivalry' ? ' is-active' : ''}`}
onClick={() => setView('rivalry')}
data-testid="champ-tab-rivalry"
>
Rivalry
</button>
<button
type="button"
className={`champ-tab${view === 'simulator' ? ' is-active' : ''}`}
@@ -251,6 +260,7 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
)}
{view === 'constructors' && <ConstructorsView hub={hub} />}
{view === 'progression' && <ProgressionView hub={hub} />}
{view === 'rivalry' && <RivalryCompare hub={hub} />}
{view === 'simulator' && <ChampionshipSimulator hub={hub} />}
</div>
)

View File

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

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

View File

@@ -0,0 +1,94 @@
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 { 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 }: { preview?: boolean }) {
const { context, loadState, error, championship, briefing, now } = useWeekendContext()
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}
>
{renderState(view, { context, now, championship, briefing, preview })}
</main>
)
}

View File

@@ -1,13 +1,13 @@
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
@@ -26,10 +26,17 @@ const rootRoute = createRootRoute({
),
})
export const commandCenterRoute = createRoute({
// Weekend is the adaptive home. `/` renders the current temporal state.
export const weekendRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: CommandCenterPage,
component: WeekendPage,
})
export const exploreRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/explore',
component: ExplorePage,
})
export const raceHubRoute = createRoute({
@@ -90,14 +97,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,

View File

@@ -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;

View File

@@ -9,6 +9,7 @@
align-items: center;
justify-content: space-between;
gap: var(--s4);
flex-wrap: wrap;
}
.chapter-strip-title {
@@ -44,6 +45,31 @@
color: var(--text);
}
.chapter-strip-scroll-wrap {
position: relative;
}
.chapter-strip-scroll-wrap::before,
.chapter-strip-scroll-wrap::after {
content: '';
position: absolute;
top: 0;
bottom: var(--s2);
width: 28px;
pointer-events: none;
z-index: 1;
}
.chapter-strip-scroll-wrap::before {
left: 0;
background: linear-gradient(to right, var(--bg), transparent);
}
.chapter-strip-scroll-wrap::after {
right: 0;
background: linear-gradient(to left, var(--bg), transparent);
}
.chapter-strip-scroll {
display: flex;
gap: var(--s3);
@@ -51,10 +77,26 @@
padding-bottom: var(--s2);
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: var(--border-2) transparent;
}
.chapter-strip-scroll::-webkit-scrollbar {
height: 4px;
}
.chapter-strip-scroll::-webkit-scrollbar-track {
background: transparent;
}
.chapter-strip-scroll::-webkit-scrollbar-thumb {
background: var(--border-2);
border-radius: 999px;
}
.chapter-card {
flex: 0 0 min(240px, 72vw);
min-height: 88px;
display: flex;
flex-direction: column;
gap: var(--s2);
@@ -64,7 +106,7 @@
background: var(--surface);
text-align: left;
cursor: pointer;
scroll-snap-align: start;
scroll-snap-align: center;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
}
@@ -145,10 +187,22 @@
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
flex: 1;
}
.chapter-strip-empty {
font-size: 12px;
color: var(--text-3);
padding: var(--s3) 0;
.chapter-strip-empty-card {
padding: var(--s6) var(--s5);
margin: 0;
}
.chapter-strip-empty-card .empty-state-icon {
display: block;
margin: 0 auto var(--s3);
color: var(--text-3);
}
@media (max-width: 900px) {
.chapter-strip-header {
align-items: flex-start;
}
}

View File

@@ -0,0 +1,157 @@
.race-story-empty-card {
padding: var(--s6) var(--s5);
margin: 0;
}
.race-story-empty-card .empty-state-icon {
display: block;
margin: 0 auto var(--s3);
color: var(--text-3);
}
.rs-replay-shell {
display: grid;
grid-template-columns: 1fr;
gap: var(--s5);
align-items: stretch;
}
.rs-replay-shell--split {
grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.8fr);
}
.rs-replay-main {
min-width: 0;
}
.rs-chart-container {
--rs-chart-max-width: 100%;
}
.rs-chart-container--full {
--rs-chart-max-width: 100%;
}
.rs-segmented-control {
display: inline-flex;
align-items: stretch;
flex-wrap: wrap;
gap: 0;
margin-top: var(--s3);
border: 1px solid var(--border-2);
border-radius: var(--s1);
background: var(--surface);
overflow: hidden;
}
.rs-segment {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 30px;
padding: 0 var(--s4);
font-family: var(--f-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text-3);
background: transparent;
border: none;
border-right: 1px solid var(--border);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.rs-segment:last-child {
border-right: none;
}
.rs-segment:hover:not(:disabled) {
color: var(--text);
background: var(--surface-h);
}
.rs-segment:focus-visible {
outline: 2px solid var(--red);
outline-offset: -2px;
z-index: 1;
}
.rs-segment.active {
color: var(--text);
background: rgba(230, 36, 41, 0.12);
box-shadow: inset 0 -2px 0 var(--red);
}
.rs-segment:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.rs-segment-divider {
width: 1px;
align-self: stretch;
background: var(--border);
flex-shrink: 0;
}
.rs-replay-map-slot {
min-width: 0;
}
.rs-position-chart-svg {
width: 100%;
min-width: 280px;
max-width: var(--rs-chart-max-width, 640px);
display: block;
}
.rs-chart-axis-label {
fill: var(--text-3);
font-family: var(--f-mono);
font-size: 9px;
}
.rs-chart-grid-line {
stroke: var(--border);
stroke-width: 0.5;
}
.rs-chart-lap-tick {
stroke: var(--border);
stroke-width: 1;
}
@media (max-width: 900px) {
.rs-replay-shell--split {
grid-template-columns: 1fr;
}
.rs-segmented-control {
width: 100%;
}
.rs-segment {
flex: 1 1 auto;
}
}
@media (max-width: 560px) {
.rs-segmented-control {
flex-direction: column;
align-items: stretch;
}
.rs-segment {
border-right: none;
border-bottom: 1px solid var(--border);
}
.rs-segment:last-child {
border-bottom: none;
}
.rs-segment-divider {
display: none;
}
}

View File

@@ -1,76 +1,9 @@
.rs-replay-shell {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.8fr);
gap: var(--s5);
align-items: stretch;
}
.rs-replay-main {
min-width: 0;
}
.rs-replay-tools {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--s3);
margin-top: var(--s3);
}
.rs-tool-btn,
.rs-speed-btn {
height: 28px;
padding: 0 var(--s4);
font-family: var(--f-mono);
font-size: 10px;
font-weight: 700;
color: var(--text-2);
background: var(--surface);
border: 1px solid var(--border-2);
border-radius: 2px;
cursor: pointer;
}
.rs-tool-btn:hover,
.rs-speed-btn:hover {
color: var(--text);
border-color: var(--text-3);
}
.rs-tool-btn.active,
.rs-speed-btn.active {
color: var(--text);
border-color: var(--red);
background: rgba(230, 36, 41, 0.12);
}
.rs-speed-group {
display: inline-flex;
gap: 2px;
}
.rs-replay-map-slot {
min-width: 0;
}
.rs-replay-map-placeholder {
min-height: 244px;
display: grid;
place-items: center;
padding: var(--s5);
color: var(--text-3);
font-family: var(--f-mono);
font-size: 11px;
text-align: center;
border: 1px dashed var(--border-2);
background: var(--surface);
}
.replay-map-panel {
min-height: 244px;
height: 100%;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--s2);
}
.replay-map-stage {
@@ -143,19 +76,16 @@
stroke-width: 1.2;
}
.replay-map-empty {
.replay-map-empty-card {
min-height: 244px;
display: grid;
place-items: center;
padding: var(--s5);
color: var(--text-3);
font-family: var(--f-mono);
font-size: 11px;
text-align: center;
place-content: center;
padding: var(--s6);
margin: 0;
}
@media (max-width: 860px) {
.rs-replay-shell {
grid-template-columns: 1fr;
}
.replay-map-empty-card .empty-state-icon {
display: block;
margin: 0 auto var(--s3);
color: var(--text-3);
}

View File

@@ -0,0 +1,134 @@
.rivalry {
display: flex;
flex-direction: column;
gap: var(--s5);
}
.rivalry-pickers {
display: flex;
flex-wrap: wrap;
gap: var(--s4);
align-items: flex-end;
}
.rivalry-vs {
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
padding-bottom: var(--s3);
}
.rivalry-picker {
display: flex;
flex-direction: column;
gap: var(--s2);
min-width: 200px;
}
.rivalry-picker-label {
font-family: var(--f-mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
}
.rivalry-picker-select {
font-family: var(--f-mono);
font-size: 13px;
padding: var(--s2) var(--s3);
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface-2);
color: var(--text);
}
.rivalry-picker-select:focus {
outline: 2px solid var(--red);
outline-offset: 1px;
}
.rivalry-h2h {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s4) var(--s5);
padding: var(--s4) var(--s5);
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface-2);
}
.rivalry-h2h-score {
display: flex;
align-items: baseline;
gap: var(--s3);
}
.rivalry-h2h-code {
font-size: 14px;
font-weight: 700;
}
.rivalry-h2h-num {
font-size: 24px;
font-weight: 700;
color: var(--text);
}
.rivalry-h2h-meta {
font-family: var(--f-mono);
font-size: 11px;
color: var(--text-3);
}
.rivalry-strip {
display: flex;
align-items: center;
gap: var(--s2);
margin-left: auto;
}
.rivalry-strip-label {
font-family: var(--f-mono);
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
margin-right: var(--s2);
}
.rivalry-chip {
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
padding: var(--s1) var(--s3);
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
}
.rivalry-chip-round {
font-size: 9px;
color: var(--text-3);
}
.rivalry-chip-winner {
font-size: 11px;
font-weight: 700;
}
.rivalry-section {
display: flex;
flex-direction: column;
gap: var(--s3);
}
.rivalry-caption {
font-family: var(--f-mono);
font-size: 11px;
color: var(--text-3);
margin: 0;
}

View File

@@ -0,0 +1,378 @@
/* ── Weekend adaptive home ── */
.wk-page {
max-width: 1280px;
margin: 0 auto;
padding: var(--s6) var(--s6) 64px;
width: 100%;
}
.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-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; }
}

View File

@@ -73,6 +73,7 @@ const drivers: ChampHubDriver[] = [
cumulative: [25, 43, 68, 100],
teammate_wins: 4,
teammate_losses: 1,
round_positions: [],
},
{
driver_number: 4,
@@ -89,6 +90,7 @@ const drivers: ChampHubDriver[] = [
cumulative: [18, 36, 54, 80],
teammate_wins: 3,
teammate_losses: 2,
round_positions: [],
},
]

View File

@@ -29,6 +29,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
poles: 4,
form: [25, 18, 25, 15, 25],
cumulative: [25, 43, 68, 83, 108, 200],
round_positions: [1, 2, 1, 3, 1, 1],
teammate_wins: 9,
teammate_losses: 1,
...over,
@@ -47,6 +48,7 @@ const drivers: ChampHubDriver[] = [
position: 2,
wins: 3,
cumulative: [18, 36, 54, 80, 120, 160],
round_positions: [2, 1, 2, 2, 2, 2],
}),
driver({
driver_number: 16,
@@ -58,6 +60,7 @@ const drivers: ChampHubDriver[] = [
position: 3,
wins: 1,
cumulative: [15, 28, 40, 60, 90, 120],
round_positions: [3, 3, 3, 0, 3, 3],
}),
]
@@ -129,6 +132,22 @@ describe('ChampionshipPage', () => {
expect(screen.getByText('Cumulative points', { exact: false })).toBeInTheDocument()
})
it('switches to the rivalry view with the top two drivers preselected', async () => {
renderPage()
await waitFor(() => expect(screen.getByTestId('championship')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('champ-tab-rivalry'))
expect(screen.getByTestId('champ-view-rivalry')).toBeInTheDocument()
// Default pair is the top two in the standings: VER vs NOR.
expect(screen.getByTestId('rivalry-pick-a')).toHaveValue('1')
expect(screen.getByTestId('rivalry-pick-b')).toHaveValue('4')
// VER beats NOR in rounds 1, 3, 5, 6 → 42.
expect(screen.getByTestId('rivalry-h2h-num')).toHaveTextContent('42')
expect(screen.getByTestId('rivalry-points-race')).toBeInTheDocument()
expect(screen.getByTestId('rivalry-gap')).toBeInTheDocument()
})
it('switches to the simulator view and projects standings', async () => {
renderPage()

View File

@@ -17,6 +17,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
poles: 0,
form: [],
cumulative: [],
round_positions: [],
teammate_wins: 0,
teammate_losses: 0,
...over,

View File

@@ -68,6 +68,25 @@ describe('ChapterStrip', () => {
expect(screen.getByText('L12L15')).toBeInTheDocument()
})
it('renders an empty-state card when there are no chapters', () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={queryClient}>
<ChapterStrip
chapters={[]}
scrubTime={null}
tMin={tMin}
tRange={tRange}
tourActive={false}
tourChapterIndex={null}
onChapterClick={vi.fn()}
onTourToggle={vi.fn()}
/>
</QueryClientProvider>,
)
expect(screen.getByTestId('chapter-strip-empty')).toBeInTheDocument()
})
it('highlights the active chapter from scrub time', () => {
const scrub = (new Date('2025-05-25T13:13:00Z').getTime() - tMin) / tRange
renderStrip({ scrubTime: scrub })
@@ -84,4 +103,26 @@ describe('ChapterStrip', () => {
expect(index).toBe(1)
expect(scrub).toBeCloseTo(0.6, 2)
})
it('highlights an explicitly selected chapter even when scrub is outside its raw window', () => {
// Scrub parked at chart start; chapter 1's raw times are mid-race, but selection wins.
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={queryClient}>
<ChapterStrip
chapters={chapters}
scrubTime={0}
tMin={tMin}
tRange={tRange}
tourActive={false}
tourChapterIndex={null}
selectedChapterIndex={1}
onChapterClick={vi.fn()}
onTourToggle={vi.fn()}
/>
</QueryClientProvider>,
)
expect(screen.getByTestId('chapter-card-1')).toHaveClass('active')
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
})
})

View File

@@ -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()
})
})

View 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()
})
})

View File

@@ -109,6 +109,7 @@ const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
poles: 4,
form: [25],
cumulative: [200],
round_positions: [1],
teammate_wins: 9,
teammate_losses: 1,
...over,

View File

@@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
import type { RaceHub, ReplayFramesResponse, TrackOutline } from '../types'
import type { Chapter, RaceHub, ReplayFramesResponse, TrackOutline } from '../types'
vi.mock('../api', () => ({
fetchReplayFrames: vi.fn(),
@@ -28,9 +28,35 @@ const replay: ReplayFramesResponse = {
session_key: 99,
interval_ms: 5000,
start_time: '2025-05-25T13:00:00Z',
frames: [{ t: 0, cars: { '1': { x: 10, y: 20 } } }],
frames: [
{ t: 0, cars: { '1': { x: 10, y: 20 } } },
{ t: 5000, cars: { '1': { x: 50, y: 50 } } },
],
}
const chapters: Chapter[] = [
{
kind: 'start',
title: 'Start',
headline: 'Lights out',
start_lap: 1,
end_lap: 1,
start_time: '2025-05-25T13:00:00Z',
end_time: '2025-05-25T13:01:00Z',
driver_numbers: [],
},
{
kind: 'safety_car',
title: 'Safety Car',
headline: 'Incident brings out the Safety Car',
start_lap: 2,
end_lap: 2,
start_time: '2025-05-25T13:05:00Z',
end_time: '2025-05-25T13:05:00Z',
driver_numbers: [],
},
]
const raceHub: RaceHub = {
source: 'local',
session_key: 99,
@@ -103,14 +129,14 @@ const raceHub: RaceHub = {
race_control: [],
weather: [],
laps: [],
chapters: [],
chapters,
}
function renderCanvas() {
function renderCanvas(overrides: Partial<RaceHub> = {}) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={queryClient}>
<RaceStoryCanvas data={raceHub} />
<RaceStoryCanvas data={{ ...raceHub, ...overrides }} />
</QueryClientProvider>,
)
}
@@ -122,14 +148,144 @@ describe('RaceStoryCanvas replay map', () => {
mockFetchTrackOutline.mockResolvedValue(outline)
})
it('fetches replay frames lazily when the map panel opens', async () => {
it('probes replay frames on mount and opens the map when data is available', async () => {
renderCanvas()
expect(mockFetchReplayFrames).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Map' }))
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalledWith(99, 5000))
expect(mockFetchTrackOutline).toHaveBeenCalledWith(1, 2025)
const mapToggle = await screen.findByTestId('replay-map-toggle')
fireEvent.click(mapToggle)
expect(await screen.findByTestId('replay-track-map')).toBeInTheDocument()
expect(screen.getByTestId('replay-map-slot')).toBeInTheDocument()
})
it('hides the map toggle when replay frames are unavailable', async () => {
mockFetchReplayFrames.mockResolvedValue({ ...replay, frames: [] })
renderCanvas()
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalled())
await waitFor(() => expect(screen.queryByTestId('replay-map-toggle')).not.toBeInTheDocument())
})
it('uses full-width chart layout when the map is closed', async () => {
renderCanvas()
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalled())
const chart = screen.getByTestId('position-chart')
expect(chart).toHaveClass('rs-chart-container--full')
expect(screen.queryByTestId('replay-map-slot')).not.toBeInTheDocument()
expect(document.querySelector('.rs-replay-shell--split')).not.toBeInTheDocument()
})
it('uses lap timing rather than pre-race position samples for the chart scale', () => {
const laps = Array.from({ length: 20 }, (_, index) => ({
session_key: 99,
driver_number: 1,
meeting_key: 1,
lap_number: index + 1,
date_start: `2025-05-25T13:${String(index).padStart(2, '0')}:00Z`,
lap_duration: 60,
is_pit_out_lap: false,
}))
renderCanvas({
results: [{ ...raceHub.results[0], number_of_laps: 20 }],
laps,
positions: [
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T12:00:00Z', position: 1 },
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:20:00Z', position: 1 },
],
})
// L10 is the end of the tenth 60s lap, exactly halfway through the race.
const lapTenLabel = screen.getByText('L10')
expect(lapTenLabel.previousElementSibling).toHaveAttribute('x1', '316')
// The pre-race position is retained as the starting grid at the left edge.
expect(document.querySelector('.rs-driver-line')).toHaveAttribute('points', '40,8 592,8 592,8')
})
it('maps cursor positions from rendered pixels to the SVG viewBox', () => {
renderCanvas()
const svg = document.querySelector('.rs-position-chart-svg')!
vi.spyOn(svg, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
width: 1280,
height: 360,
top: 0,
right: 1280,
bottom: 360,
left: 0,
toJSON: () => ({}),
})
const pointerMove = new Event('pointermove', { bubbles: true })
Object.defineProperty(pointerMove, 'clientX', { value: 640 })
fireEvent(screen.getByTestId('position-chart-interaction'), pointerMove)
// 640px is the middle of a 1280px rendered chart, which is x=320 in its 640-unit viewBox.
expect(document.querySelector('.rs-playhead')).toHaveAttribute('x1', '320')
})
it('syncs active chapter highlight when a chapter card is clicked', async () => {
renderCanvas()
fireEvent.click(screen.getByTestId('chapter-card-1'))
await waitFor(() => expect(screen.getByTestId('chapter-card-1')).toHaveClass('active'))
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
})
it('highlights out-of-window chapters after click (clamped scrub + selection)', async () => {
// Position samples start at 13:05; start chapter ends at 13:01 — outside the window.
renderCanvas({
positions: [
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:05:00Z', position: 1 },
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:10:00Z', position: 1 },
],
chapters: [
{
kind: 'start',
title: 'Start',
headline: 'Lights out before samples',
start_lap: 1,
end_lap: 1,
start_time: '2025-05-25T13:00:00Z',
end_time: '2025-05-25T13:01:00Z',
driver_numbers: [],
},
{
kind: 'finish',
title: 'Finish',
headline: 'Flag after samples',
start_lap: 78,
end_lap: 78,
start_time: '2025-05-25T13:20:00Z',
end_time: '2025-05-25T13:21:00Z',
driver_numbers: [],
},
],
})
fireEvent.click(screen.getByTestId('chapter-card-0'))
await waitFor(() => expect(screen.getByTestId('chapter-card-0')).toHaveClass('active'))
expect(screen.getByTestId('chapter-card-1')).not.toHaveClass('active')
fireEvent.click(screen.getByTestId('chapter-card-1'))
await waitFor(() => expect(screen.getByTestId('chapter-card-1')).toHaveClass('active'))
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
})
it('renders the empty-state card when positions are unavailable', () => {
renderCanvas({
datasets: { positions: { status: 'missing', source: 'local', count: 0 } },
positions: [],
chapters: [],
})
expect(screen.getByTestId('race-story-no-positions')).toBeInTheDocument()
expect(screen.getByText('Lap-by-lap positions not available')).toBeInTheDocument()
})
})

View File

@@ -44,9 +44,10 @@ const results: EnrichedResult[] = [
]
describe('ReplayTrackMap', () => {
it('renders an empty state when replay frames are missing', () => {
it('renders an empty-state card when replay frames are missing', () => {
render(<ReplayTrackMap outline={outline} replay={{ ...replay, frames: [] }} tMs={0} drivers={[]} results={results} />)
expect(screen.getByTestId('replay-track-map')).toHaveTextContent(/historical GPS unavailable/i)
expect(screen.getByTestId('replay-map-no-frames')).toBeInTheDocument()
expect(screen.getByText('Historical GPS unavailable')).toBeInTheDocument()
})
it('renders car labels from result metadata', () => {

View File

@@ -0,0 +1,114 @@
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { RivalryCompare } from '../components/RivalryCompare'
import type { ChampHubDriver, ChampionshipHub } from '../types'
function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
return {
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
points: 100,
position: 1,
wins: 2,
podiums: 3,
poles: 1,
form: [25, 18, 25],
cumulative: [25, 43, 68],
round_positions: [1, 2, 1],
teammate_wins: 3,
teammate_losses: 0,
...over,
}
}
function makeHub(drivers: ChampHubDriver[]): ChampionshipHub {
return {
season: 2025,
round: 3,
total_rounds: 10,
rounds_left: 7,
last_race: 'Japan GP',
round_labels: ['R1', 'R2', 'R3'],
drivers,
teams: [],
}
}
const hub = makeHub([
driver({}),
driver({
driver_number: 4,
name_acronym: 'NOR',
full_name: 'Lando Norris',
team_name: 'McLaren',
team_colour: 'ff8000',
points: 90,
position: 2,
cumulative: [18, 43, 61],
round_positions: [2, 1, 2],
}),
driver({
driver_number: 16,
name_acronym: 'LEC',
full_name: 'Charles Leclerc',
team_name: 'Ferrari',
team_colour: 'e8002d',
points: 50,
position: 3,
cumulative: [15, 28, 40],
round_positions: [3, 0, 3],
}),
])
describe('RivalryCompare', () => {
it('defaults to the top two drivers and shows the H2H score', () => {
render(<RivalryCompare hub={hub} />)
expect(screen.getByTestId('champ-view-rivalry')).toBeInTheDocument()
expect(screen.getByTestId('rivalry-pick-a')).toHaveValue('1')
expect(screen.getByTestId('rivalry-pick-b')).toHaveValue('4')
// VER wins R1 and R3, NOR wins R2.
expect(screen.getByTestId('rivalry-h2h-num')).toHaveTextContent('21')
expect(screen.getByText('3 rounds counted', { exact: false })).toBeInTheDocument()
expect(screen.getByTestId('rivalry-points-race')).toBeInTheDocument()
expect(screen.getByTestId('rivalry-gap')).toBeInTheDocument()
// Last gap: 68 61 = +7 → VER ahead.
expect(screen.getByText('VER leads by 7 pts', { exact: false })).toBeInTheDocument()
})
it('recomputes when a different driver is picked and skips missing rounds', () => {
render(<RivalryCompare hub={hub} />)
fireEvent.change(screen.getByTestId('rivalry-pick-b'), { target: { value: '16' } })
// VER vs LEC: R2 skipped (LEC has no position), VER wins R1 and R3.
expect(screen.getByTestId('rivalry-h2h-num')).toHaveTextContent('20')
expect(screen.getByText('2 rounds counted · 1 skipped', { exact: false })).toBeInTheDocument()
})
it('shows an empty state with fewer than two drivers', () => {
render(<RivalryCompare hub={makeHub([driver({})])} />)
expect(screen.getByTestId('champ-view-rivalry')).toHaveTextContent('at least two drivers')
})
it('shows an empty message when no rounds are completed', () => {
const empty = makeHub([
driver({ cumulative: [], round_positions: [], form: [] }),
driver({
driver_number: 4,
name_acronym: 'NOR',
cumulative: [],
round_positions: [],
form: [],
}),
])
empty.round = 0
empty.round_labels = []
render(<RivalryCompare hub={empty} />)
expect(screen.getByTestId('champ-view-rivalry')).toHaveTextContent('No completed rounds yet')
})
})

View File

@@ -0,0 +1,309 @@
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'
vi.mock('../api', () => ({
fetchWeekendContext: vi.fn(),
fetchChampionshipHub: vi.fn(),
fetchNews: vi.fn(),
fetchRaceHub: 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,
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 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: '/', component: () => <WeekendPage /> })
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)
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()
})
})

View File

@@ -1,10 +1,13 @@
import { describe, expect, it } from 'vitest'
import {
activeChapterIndex,
chapterBandFill,
chapterKindLabel,
chapterLapRange,
chapterStartScrub,
chapterTourDurations,
deCollideYPositions,
decimatedPositionLabels,
} from '../lib/chapters'
import type { Chapter } from '../types'
@@ -57,7 +60,65 @@ describe('chapters lib', () => {
expect(activeChapterIndex(sampleChapters, scrub, tMin, tRange)).toBe(1)
})
it('activates chapters whose timestamps clamp outside the position window', () => {
// Position samples only cover 13:0513:10; chapters sit before/after that window.
const tMin = new Date('2025-05-25T13:05:00Z').getTime()
const tMax = new Date('2025-05-25T13:10:00Z').getTime()
const tRange = tMax - tMin
const outOfWindow: Chapter[] = [
{
kind: 'start',
title: 'Start',
headline: 'Lights out',
start_lap: 1,
end_lap: 1,
start_time: '2025-05-25T13:00:00Z',
end_time: '2025-05-25T13:01:00Z',
driver_numbers: [],
},
{
kind: 'finish',
title: 'Finish',
headline: 'Chequered flag',
start_lap: 78,
end_lap: 78,
start_time: '2025-05-25T13:20:00Z',
end_time: '2025-05-25T13:21:00Z',
driver_numbers: [],
},
]
expect(chapterStartScrub(outOfWindow[0], tMin, tRange)).toBe(0)
expect(chapterStartScrub(outOfWindow[1], tMin, tRange)).toBe(1)
expect(activeChapterIndex(outOfWindow, 0, tMin, tRange)).toBe(0)
expect(activeChapterIndex(outOfWindow, 1, tMin, tRange)).toBe(1)
expect(activeChapterIndex(outOfWindow, 0.5, tMin, tRange)).toBeNull()
})
it('splits 90s evenly across chapters', () => {
expect(chapterTourDurations(sampleChapters)).toEqual([45_000, 45_000])
})
it('decimates position axis labels', () => {
expect(decimatedPositionLabels(22)).toEqual([1, 5, 10, 15, 20, 22])
})
it('returns chapter band fills by kind', () => {
expect(chapterBandFill('safety_car')).toContain('rgba')
expect(chapterBandFill('virtual_safety_car')).toContain('rgba')
})
it('de-collides overlapping label y positions', () => {
const adjusted = deCollideYPositions(
[
{ key: 'a', y: 10 },
{ key: 'b', y: 12 },
{ key: 'c', y: 30 },
],
12,
)
expect(adjusted.get('a')).toBe(10)
expect(adjusted.get('b')).toBe(22)
expect(adjusted.get('c')).toBe(34)
})
})

View File

@@ -51,6 +51,7 @@ const driver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
cumulative: [25, 43, 68, 100],
teammate_wins: 4,
teammate_losses: 1,
round_positions: [],
...over,
})

View File

@@ -18,6 +18,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
cumulative: [],
teammate_wins: 9,
teammate_losses: 1,
round_positions: [],
...over,
}
}

View File

@@ -54,6 +54,7 @@ const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
poles: 4,
form: [25],
cumulative: [200],
round_positions: [1],
teammate_wins: 9,
teammate_losses: 1,
...over,

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { isReplayMapAvailable } from '../lib/replayMap'
import type { ReplayFramesResponse, TrackOutline } from '../types'
const outline: TrackOutline = {
circuit_key: 1,
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
points: [{ x: 0, y: 0 }],
}
const replay: ReplayFramesResponse = {
session_key: 1,
interval_ms: 5000,
start_time: '2025-05-25T13:00:00Z',
frames: [
{ t: 0, cars: {} },
{ t: 5000, cars: {} },
],
}
describe('isReplayMapAvailable', () => {
it('returns true when frames and outline are present', () => {
expect(isReplayMapAvailable(replay, outline, false)).toBe(true)
})
it('returns false when frames are sparse', () => {
expect(isReplayMapAvailable({ ...replay, frames: [{ t: 0, cars: {} }] }, outline, false)).toBe(false)
})
it('returns false on query error', () => {
expect(isReplayMapAvailable(replay, outline, true)).toBe(false)
})
})

View File

@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest'
import { gapSeries, h2hTally, lastRounds } from '../lib/rivalry'
describe('gapSeries', () => {
it('computes per-round a b', () => {
expect(gapSeries([25, 43, 68], [18, 36, 54])).toEqual([7, 7, 14])
})
it('handles negative gaps (b ahead)', () => {
expect(gapSeries([10, 20], [18, 36])).toEqual([-8, -16])
})
it('truncates to the shorter series', () => {
expect(gapSeries([25, 43, 68], [18])).toEqual([7])
expect(gapSeries([], [18, 36])).toEqual([])
})
})
describe('h2hTally', () => {
it('tallies lower-position wins per round', () => {
const t = h2hTally([1, 2, 1], [2, 1, 3], ['R1', 'R2', 'R3'])
expect(t.a).toBe(2)
expect(t.b).toBe(1)
expect(t.skipped).toBe(0)
expect(t.rounds.map((r) => r.winner)).toEqual(['a', 'b', 'a'])
expect(t.rounds[0]).toEqual({ round: 1, label: 'R1', posA: 1, posB: 2, winner: 'a' })
})
it('skips rounds where either driver has no position', () => {
// R2: a missing (0). R3: b missing (0). Only R1 and R4 count.
const t = h2hTally([1, 0, 5, 3], [4, 2, 0, 1], ['R1', 'R2', 'R3', 'R4'])
expect(t.a).toBe(1)
expect(t.b).toBe(1)
expect(t.skipped).toBe(2)
expect(t.rounds.map((r) => r.round)).toEqual([1, 4])
})
it('handles arrays of different lengths (missing tail = skipped)', () => {
const t = h2hTally([1, 2, 3], [2], ['R1', 'R2', 'R3'])
expect(t.a).toBe(1)
expect(t.b).toBe(0)
expect(t.skipped).toBe(2)
})
it('skips equal positions defensively and falls back on labels', () => {
const t = h2hTally([2, 1], [2, 3], ['R1'])
expect(t.a).toBe(1)
expect(t.b).toBe(0)
expect(t.skipped).toBe(1)
expect(t.rounds[0].label).toBe('R2')
})
it('returns an empty tally for empty inputs', () => {
const t = h2hTally([], [], [])
expect(t).toEqual({ a: 0, b: 0, rounds: [], skipped: 0 })
})
})
describe('lastRounds', () => {
it('returns the last n counted rounds in order', () => {
const t = h2hTally([1, 1, 1, 2, 1, 1, 2], [2, 2, 2, 1, 2, 2, 1], [])
const last = lastRounds(t, 5)
expect(last).toHaveLength(5)
expect(last.map((r) => r.round)).toEqual([3, 4, 5, 6, 7])
})
it('returns fewer when the tally has fewer counted rounds', () => {
const t = h2hTally([1, 0], [2, 0], [])
expect(lastRounds(t, 5)).toHaveLength(1)
})
})

View File

@@ -29,6 +29,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
poles: 0,
form: [],
cumulative: [],
round_positions: [],
teammate_wins: 0,
teammate_losses: 0,
...over,

View 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')
})
})

View File

@@ -391,6 +391,7 @@ export interface ChampHubDriver {
poles: number
form: number[]
cumulative: number[]
round_positions: number[]
teammate_wins: number
teammate_losses: number
}
@@ -447,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
View 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
}

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

View File

@@ -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.

View File

@@ -614,8 +614,9 @@ type champHubDriver struct {
Wins int `json:"wins"`
Podiums int `json:"podiums"`
Poles int `json:"poles"`
Form []float64 `json:"form"` // last 5 races' points
Cumulative []float64 `json:"cumulative"` // running total per completed round
Form []float64 `json:"form"` // last 5 races' points
Cumulative []float64 `json:"cumulative"` // running total per completed round
RoundPositions []int `json:"round_positions"` // finishing position per completed round (0 = no result)
TeammateWins int `json:"teammate_wins"`
TeammateLosses int `json:"teammate_losses"`
}
@@ -995,6 +996,10 @@ func aggregateChampionshipHub(
if len(form) > 5 {
form = form[len(form)-5:]
}
roundPositions := make([]int, completed)
for round := 1; round <= completed; round++ {
roundPositions[round-1] = a.finishByRound[round]
}
info := driverInfo[c.DriverNumber]
drivers = append(drivers, champHubDriver{
DriverNumber: c.DriverNumber,
@@ -1009,6 +1014,7 @@ func aggregateChampionshipHub(
Poles: a.poles,
Form: form,
Cumulative: cumulative[c.DriverNumber],
RoundPositions: roundPositions,
TeammateWins: twins[c.DriverNumber],
TeammateLosses: tloss[c.DriverNumber],
})

View File

@@ -17,20 +17,22 @@ func TestAggregateChampionshipHub(t *testing.T) {
1: {DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull", TeamColour: "3671c6"},
2: {DriverNumber: 2, NameAcronym: "PER", FullName: "Sergio Perez", TeamName: "Red Bull", TeamColour: "3671c6"},
3: {DriverNumber: 3, NameAcronym: "HAM", FullName: "Lewis Hamilton", TeamName: "Mercedes", TeamColour: "27f4d2"},
4: {DriverNumber: 4, NameAcronym: "NOR", FullName: "Lando Norris", TeamName: "McLaren", TeamColour: "ff8000"},
}
champ := []models.ChampionshipDriver{
{DriverNumber: 1, PointsCurrent: 50, PositionCurrent: 1, SessionKey: 99},
{DriverNumber: 3, PointsCurrent: 33, PositionCurrent: 2, SessionKey: 99},
{DriverNumber: 2, PointsCurrent: 30, PositionCurrent: 3, SessionKey: 99},
{DriverNumber: 4, PointsCurrent: 12, PositionCurrent: 4, SessionKey: 99},
}
teams := []models.ChampionshipTeam{
{TeamName: "Red Bull", PointsCurrent: 80, PositionCurrent: 1},
{TeamName: "Mercedes", PointsCurrent: 33, PositionCurrent: 2},
}
// Round 1: VER P1(25), HAM P2(18), PER P3(15). Pole: VER.
// Round 2: VER P1(25), PER P2(18), HAM P3(15). Pole: HAM.
// Round 1: VER P1(25), HAM P2(18), PER P3(15). Pole: VER. NOR absent.
// Round 2: VER P1(25), PER P2(18), HAM P3(15), NOR P4(12). Pole: HAM.
races := []meetingRace{
{
Meeting: models.Meeting{MeetingName: "Bahrain GP"},
@@ -39,7 +41,7 @@ func TestAggregateChampionshipHub(t *testing.T) {
},
{
Meeting: models.Meeting{MeetingName: "Saudi GP"},
Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(2, 2, 18), raceResult(3, 3, 15)},
Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(2, 2, 18), raceResult(3, 3, 15), raceResult(4, 4, 12)},
Grid: []models.StartingGrid{{DriverNumber: 3, Position: 1}},
},
// Round 3: not yet run (no results) — should not count as completed.
@@ -67,9 +69,9 @@ func TestAggregateChampionshipHub(t *testing.T) {
t.Errorf("round labels = %v, want [R1 R2]", resp.RoundLabels)
}
// Drivers are sorted by official position: VER, HAM, PER.
if len(resp.Drivers) != 3 {
t.Fatalf("drivers = %d, want 3", len(resp.Drivers))
// Drivers are sorted by official position: VER, HAM, PER, NOR.
if len(resp.Drivers) != 4 {
t.Fatalf("drivers = %d, want 4", len(resp.Drivers))
}
ver := resp.Drivers[0]
if ver.NameAcronym != "VER" || ver.Position != 1 {
@@ -91,6 +93,9 @@ func TestAggregateChampionshipHub(t *testing.T) {
if len(ver.Cumulative) != 2 || ver.Cumulative[0] != 25 || ver.Cumulative[1] != 50 {
t.Errorf("VER cumulative = %v, want [25 50]", ver.Cumulative)
}
if len(ver.RoundPositions) != 2 || ver.RoundPositions[0] != 1 || ver.RoundPositions[1] != 1 {
t.Errorf("VER round positions = %v, want [1 1]", ver.RoundPositions)
}
// VER beat teammate PER in both rounds.
if ver.TeammateWins != 2 || ver.TeammateLosses != 0 {
t.Errorf("VER h2h = %d-%d, want 2-0", ver.TeammateWins, ver.TeammateLosses)
@@ -109,6 +114,9 @@ func TestAggregateChampionshipHub(t *testing.T) {
if per.Poles != 0 {
t.Errorf("PER poles = %d, want 0", per.Poles)
}
if len(per.RoundPositions) != 2 || per.RoundPositions[0] != 3 || per.RoundPositions[1] != 2 {
t.Errorf("PER round positions = %v, want [3 2]", per.RoundPositions)
}
// HAM has no teammate in the data — no h2h recorded.
var ham champHubDriver
@@ -124,6 +132,17 @@ func TestAggregateChampionshipHub(t *testing.T) {
t.Errorf("HAM poles = %d, want 1", ham.Poles)
}
// NOR missed round 1 — position 0 marks the absent round.
var nor champHubDriver
for _, d := range resp.Drivers {
if d.NameAcronym == "NOR" {
nor = d
}
}
if len(nor.RoundPositions) != 2 || nor.RoundPositions[0] != 0 || nor.RoundPositions[1] != 4 {
t.Errorf("NOR round positions = %v, want [0 4]", nor.RoundPositions)
}
// Teams sorted by position; Red Bull wins = VER(2) + PER(0) = 2.
if len(resp.Teams) != 2 {
t.Fatalf("teams = %d, want 2", len(resp.Teams))

52
internal/web/context.go Normal file
View 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
}
}

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

View File

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

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -39,7 +39,17 @@ test.describe('Race Hub Weekend Workspace', () => {
await expect(
page.getByRole('img', { name: 'Position evolution chart' }),
).toBeVisible()
await expect(page.getByText('Lap-by-lap positions not available.')).not.toBeVisible()
await expect(page.getByTestId('race-story-no-positions')).not.toBeVisible()
})
test('Race Story highlights a chapter card when clicked', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await page.getByRole('tab', { name: 'Race Story' }).click()
const firstCard = page.getByTestId('chapter-card-0')
await expect(firstCard).toBeVisible()
await firstCard.click()
await expect(firstCard).toHaveClass(/active/)
})
test('strategy tab renders stint chart when stints are available', async ({ page }) => {
@@ -58,13 +68,15 @@ test.describe('Race Hub Weekend Workspace', () => {
await expect(page.locator('[data-testid="strategy-chart"]')).not.toBeVisible()
})
test('Race Story shows missing notice when positions are unavailable', async ({
test('Race Story shows empty-state card when positions are unavailable', async ({
page,
}) => {
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
await page.getByRole('tab', { name: 'Race Story' }).click()
await expect(page.getByText('Lap-by-lap positions not available.')).toBeVisible()
const empty = page.getByTestId('race-story-no-positions')
await expect(empty).toBeVisible()
await expect(empty.getByText('Lap-by-lap positions not available')).toBeVisible()
await expect(page.locator('[data-testid="position-chart"]')).not.toBeVisible()
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -14,16 +14,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 +33,14 @@ export async function gotoRaceHubReady(page: Page, sessionKey = FULL_SESSION): P
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()
await page.getByRole('tab', { name: 'Race Story' }).click()
await expect(page.getByTestId('position-chart')).toBeVisible()
await waitForScreenshotReady(page)
}
export async function gotoDataLibraryReady(page: Page): Promise<void> {
await page.goto('/admin')
await expect(page.getByTestId('data-library')).toBeVisible()

View File

@@ -1,6 +1,6 @@
import { test } from '@playwright/test'
import {
gotoCommandCenterReady,
gotoWeekendReady,
gotoDataLibraryReady,
gotoLiveEmptyReady,
gotoRaceHubReady,
@@ -8,9 +8,9 @@ import {
} 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 }) => {

View File

@@ -0,0 +1,9 @@
import { test } from '@playwright/test'
import { gotoRaceStoryReady, screenshotPage } from './helpers'
test.describe('Race Story visual regression', () => {
test('race-story', async ({ page }) => {
await gotoRaceStoryReady(page)
await screenshotPage(page, 'race-story')
})
})

View 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
View 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()
}
})
})