Compare commits

...

41 Commits

Author SHA1 Message Date
Aman Tahiliani
3a377d0bc4 Merge pull request #101 from AmanTahiliani/release/v0.4.0-current-weekend
release: v0.4.0 - Weekend fidelity recovery
2026-07-29 23:41:54 -04:00
AmanTahiliani
3228e4adbd fix(#91): read committed fidelity sign-off 2026-07-29 23:25:36 -04:00
AmanTahiliani
44a32bfd73 test(#89): cover season calendar fallback 2026-07-29 23:22:06 -04:00
AmanTahiliani
deda3bc4ad fix(#91): reject stale fidelity approvals 2026-07-29 23:22:06 -04:00
AmanTahiliani
48030a6d7c test(#89): stabilize command center visual fixtures 2026-07-29 23:18:06 -04:00
AmanTahiliani
74b33d1853 test(#89): seed visual command center calendar 2026-07-29 23:17:10 -04:00
AmanTahiliani
cefdac7006 fix(#91): make fidelity evidence states reproducible 2026-07-29 23:12:03 -04:00
AmanTahiliani
7434621024 fix(#89): archive completed focused meetings 2026-07-29 23:12:03 -04:00
AmanTahiliani
7be0b808fe test(#89): update command center visual baselines 2026-07-29 23:03:05 -04:00
AmanTahiliani
26cd1857f6 fix(#91): serialize shared visual test harness 2026-07-29 23:02:00 -04:00
AmanTahiliani
8e0d0253c2 test(#89): allow seeded calendar readiness 2026-07-29 22:58:36 -04:00
AmanTahiliani
de614e6339 fix(#91): wait for seeded calendar in fidelity capture 2026-07-29 22:55:26 -04:00
AmanTahiliani
9ef8c7e9a3 test(#89): stabilize command center visual readiness 2026-07-29 22:55:26 -04:00
AmanTahiliani
dff187da92 fix(#91): stabilize fidelity command center capture 2026-07-29 22:50:44 -04:00
AmanTahiliani
9c0d37904c fix(#89): retain archived session rail 2026-07-29 22:46:55 -04:00
AmanTahiliani
54ddc13f57 feat(#91): add release fidelity evidence gate 2026-07-29 22:41:37 -04:00
AmanTahiliani
a1d8f85fd3 feat(#89): recut weekend command center 2026-07-29 22:41:37 -04:00
AmanTahiliani
3b9b58ff7b docs(#100): preserve v0.4 mockups 2026-07-29 22:41:37 -04:00
Aman Tahiliani
480e6ca860 Merge pull request #99 from AmanTahiliani/feat/issue-98-keep-completed-analysis-as-the-race-hub-
Keep completed analysis as the Race Hub default until the next Grand Prix is imminent (#98)
2026-07-29 03:58:30 -04:00
AmanTahiliani
81deed4c75 fix(#98): rearm capped race hub refresh 2026-07-29 03:56:22 -04:00
AmanTahiliani
d6d0558c72 fix(#98): expose weekend switcher before sessions 2026-07-29 03:52:51 -04:00
AmanTahiliani
b884ba8885 fix(#98): preserve bare race hub handoff 2026-07-29 03:46:57 -04:00
AmanTahiliani
56ea860101 feat(#98): Keep completed analysis as the Race Hub default until the next Grand Prix is imminent
Implemented by opencode via .agents/dev dispatch.
2026-07-29 03:28:42 -04:00
Aman Tahiliani
7c7886e6c6 Merge pull request #95 from AmanTahiliani/fix/live-stint-history-merge
fix(live): merge stint deltas instead of replacing stint history
2026-07-29 01:25:12 -04:00
Aman Tahiliani
71f81924ee Merge pull request #94 from AmanTahiliani/feat/frontend-design-uxpass
Live page design pass: give the timing tower the fold back
2026-07-29 01:25:09 -04:00
Aman Tahiliani
7b86698b22 Merge pull request #93 from AmanTahiliani/feat/issue-76-availability-truth-backend
Harvest availability and freshness truth, backend only (re-cut of #76)
2026-07-29 01:25:06 -04:00
Aman Tahiliani
e88e0885ec Merge pull request #92 from AmanTahiliani/feat/issue-72-canonical-weekend-context
Harvest canonical Weekend Context API onto main (re-cut of #72)
2026-07-29 01:24:54 -04:00
AmanTahiliani
255b296ecc fix(live): merge stint deltas instead of replacing stint history
The F1 feed sends TimingAppData stints as sparse deltas keyed by stint
index — a mid-stint update is just {"1": {"TotalLaps": 14}}. The parser
replaced the whole stint slice with whatever a delta carried, so pit
history collapsed to a single entry and tyre age was pinned near zero for
the entire race. A partial delta was also dropped outright, because the
parser required a Compound field that mid-stint updates do not send.

Observed live at lap 49 of the 70-lap 2026 Hungarian GP: all 22 drivers
reported exactly one stint with age 0 or 3, after most had pitted twice.
With the fix, the same feed at lap 51 yields 3 stints for 15 drivers and
2 for 6, ages spread 1-30 — e.g. car 1 as MEDIUM 17 / HARD 22 / HARD 11.

Stints now merge by index, and Compound, New and TotalLaps each apply only
when the delta actually carries them.

Also stop folding non-numeric keys into index 0 in indexedRawValues. The
feed's "_kf" key-frame marker parsed as 0 and overwrote the first entry;
only the CurrentTyres path guarded against it, leaving the other five
callers exposed.

This feeds the tyre column, the deg model, stint history and the pit
window, all of which were reading near-zero tyre age all race.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 10:16:38 -04:00
AmanTahiliani
888378e210 fix(live): give the timing tower the fold back
The tyre deg panel expanded by default during races and rendered a
placeholder row per driver until enough clean laps existed to fit a
degradation model. For the first third of a race that was ~525px of
'warming up' rows above the Timing Tower, pushing the tower — the thing
the page exists for — off the fold entirely.

The panel now opens when it has something to say rather than because the
session is a race, and says 'collecting clean laps' while it waits. A
reader who toggles it keeps their choice.

Also:
- Hoist degradationModel into a memo shared by the readiness check and the
  rows. It is O(laps) per driver and previously re-ran on every render at
  feed rate.
- Order the side rail most-synthesized to most-raw, so a reader arriving
  mid-session gets 'what just happened' before the regulatory log.
- Fade the trailing edge of the nav below 560px. It scrolls with a hidden
  scrollbar, so the cut-off item read as a clipping bug rather than as
  scrollable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 09:23:17 -04:00
AmanTahiliani
77a6b0f2dd feat(#76): harvest request-scoped availability and freshness truth
Backend-only re-cut of the #76 availability work onto main, stacked on the
canonical Weekend Context API. Adds request-scoped freshness reporting so
aggregate responses cannot report fresh when a component is stale, plus
local-first driver summary resolution and cache/pacing truth.

The frontend half of #76 is deliberately excluded: it is built on the
Weekend shell that failed owner review, including the full-width Partial
banner treatment. Availability presentation is re-cut with the shell in #89.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 09:14:37 -04:00
AmanTahiliani
d03e480e03 test(#90): regress analysis selection against later unrun race
Reproduces the production shape from the v0.4.0 release blocker: a run
British race and an unrun Belgian race two weekends apart, both carrying
result rows. Analysis must resolve to the race that actually happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 09:11:03 -04:00
AmanTahiliani
0b9c3fff0a fix: make weekend context transitions truthful 2026-07-26 09:08:38 -04:00
AmanTahiliani
adfa934f9d feat: add canonical weekend context API 2026-07-26 09:08:38 -04:00
Aman Tahiliani
c475011c49 Merge pull request #88 from AmanTahiliani/feat/issue-87-live-correct-fp-q-timing-truth-hierarchy
[Live] Correct FP/Q timing truth, hierarchy, and mobile navigation (#87)
2026-07-17 12:08:45 -04:00
AmanTahiliani
5a6323d3b4 fix(live): keep red-flagged sessions active 2026-07-17 12:04:13 -04:00
AmanTahiliani
ed3b8cf628 feat(#87): [Live] Correct FP/Q timing truth, hierarchy, and mobile navigation
Implemented by claude via .agents/dev dispatch.
2026-07-17 11:53:38 -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
89 changed files with 5712 additions and 536 deletions

1
.gitignore vendored
View File

@@ -30,6 +30,7 @@ node_modules/
/test-results/ /test-results/
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/release-fidelity/
/playwright/.cache/ /playwright/.cache/
/playwright/.auth/ /playwright/.auth/

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

@@ -57,3 +57,58 @@ npm run test:visual:prod:update
``` ```
Snapshots live under `tests/visual/__snapshots__/`. Snapshots live under `tests/visual/__snapshots__/`.
## Release Fidelity and Owner Review
Visual regression and mockup fidelity are deliberately separate gates. `npm run test:visual:prod` verifies the candidate against committed regression snapshots; it does not establish that the current UI matches the approved product design.
After the approved references are available at `docs/product/<version>/mockups/`, make an offline review packet. For v0.4.0 the packet pairs `weekend-between-races` and `weekend-live` at the 1280×800 desktop reference viewport, plus `weekend-between-sessions-mobile` at the 390×844 mobile reference viewport. The capture configuration uses the same seeded SQLite data and unreachable OpenF1 endpoint as the visual suite.
```bash
export RELEASE_FIDELITY_VERSION=v0.4.0
npm run release:fidelity:capture
npm run release:fidelity:packet
```
Open `release-fidelity/v0.4.0/index.html` and review every approved-mockup/candidate pair. The owner, not an automated tool, records a decision in the committed file `docs/release/owner-reviews/v0.4.0.md`:
```md
# Owner Fidelity Sign-off: v0.4.0
- Version: v0.4.0
- Candidate commit: <full commit SHA>
- Reviewed by: <owner name>
- Reviewed on: YYYY-MM-DD
- Decision: approved
```
Do not create the file or use `approved` until the owner has reviewed the packet. Once it is committed, the release gate can verify the evidence and decision:
```bash
npm run release:fidelity:verify
```
The verifier requires the sign-off commit to be `HEAD` and to change only `docs/release/owner-reviews/<version>.md`. Its full candidate SHA must equal `HEAD^`; any code change after approval requires a new owner sign-off. It also requires the owner, date, and approved decision fields. It intentionally cannot assess visual fidelity or create approval.
For a release candidate, `npm run release:fidelity:gate` runs production visual regression first, then capture, packet generation, and owner-evidence verification in that order. It will remain red until the owner has committed the sign-off.
## Deployment and Rollback
Build and preserve a SHA-256 record with the deployable binary. Verify the staged binary before replacing the running one:
```bash
mkdir -p dist
go build -trimpath -o dist/box-box ./cmd/main.go
sha256sum dist/box-box | tee dist/box-box.sha256
sha256sum -c dist/box-box.sha256
```
Record the current production binary and its SHA before deployment. If owner review rejects the release or deployment fails, restore that saved binary, then verify the restored SHA is byte-identical to the pre-deployment record:
```bash
sha256sum /srv/box-box/box-box
install -m 0755 /srv/box-box/backups/box-box.previous /srv/box-box/box-box
sha256sum -c /srv/box-box/backups/box-box.previous.sha256
```
Restart and health-check the service using the deployment environment's normal procedure. Keep the candidate SHA, prior SHA, fidelity packet path, and owner sign-off path with the release record.

View File

@@ -15,6 +15,7 @@ import type {
Session, Session,
TrackOutline, TrackOutline,
Weekend, Weekend,
WeekendContext,
} from './types' } from './types'
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> { export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
@@ -114,6 +115,14 @@ export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
return res.json() return res.json()
} }
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> { export async function fetchChampionshipHub(year?: number): Promise<ChampionshipHub> {
const params = new URLSearchParams({ source: 'auto' }) const params = new URLSearchParams({ source: 'auto' })
if (year) params.set('year', year.toString()) 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 type { Chapter } from '../types'
import { import {
activeChapterIndex, activeChapterIndex,
@@ -5,6 +7,7 @@ import {
chapterLapRange, chapterLapRange,
chapterStartScrub, chapterStartScrub,
} from '../lib/chapters' } from '../lib/chapters'
import { EmptyStateCard } from './EmptyStateCard'
import '../styles/chapters.css' import '../styles/chapters.css'
interface Props { interface Props {
@@ -14,6 +17,8 @@ interface Props {
tRange: number tRange: number
tourActive: boolean tourActive: boolean
tourChapterIndex: number | null tourChapterIndex: number | null
/** Explicit selection from a chapter click; wins over scrub-derived active. */
selectedChapterIndex?: number | null
onChapterClick: (index: number, scrub: number) => void onChapterClick: (index: number, scrub: number) => void
onTourToggle: () => void onTourToggle: () => void
} }
@@ -25,19 +30,38 @@ export function ChapterStrip({
tRange, tRange,
tourActive, tourActive,
tourChapterIndex, tourChapterIndex,
selectedChapterIndex = null,
onChapterClick, onChapterClick,
onTourToggle, onTourToggle,
}: Props) { }: 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) { if (chapters.length === 0) {
return ( return (
<div className="chapter-strip" data-testid="chapter-strip"> <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> </div>
) )
} }
const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange)
return ( return (
<div className="chapter-strip" data-testid="chapter-strip"> <div className="chapter-strip" data-testid="chapter-strip">
<div className="chapter-strip-header"> <div className="chapter-strip-header">
@@ -53,34 +77,39 @@ export function ChapterStrip({
</button> </button>
</div> </div>
</div> </div>
<div className="chapter-strip-scroll" role="list" aria-label="Race story chapters"> <div className="chapter-strip-scroll-wrap">
{chapters.map((chapter, index) => { <div
const scrub = chapterStartScrub(chapter, tMin, tRange) ?? index / Math.max(chapters.length - 1, 1) ref={scrollRef}
const isActive = tourActive className="chapter-strip-scroll"
? tourChapterIndex === index role="list"
: activeIndex === index aria-label="Race story chapters"
const headline = chapter.headline || chapter.title >
{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 ( return (
<button <button
key={`${chapter.kind}-${chapter.start_lap}-${index}`} key={`${chapter.kind}-${chapter.start_lap}-${index}`}
type="button" type="button"
role="listitem" role="listitem"
className={`chapter-card ${isActive ? (tourActive ? 'tour-active' : 'active') : ''}`} className={`chapter-card ${isActive ? (tourActive ? 'tour-active' : 'active') : ''}`}
onClick={() => onChapterClick(index, scrub)} onClick={() => onChapterClick(index, scrub)}
aria-current={isActive ? 'true' : undefined} aria-current={isActive ? 'true' : undefined}
data-testid={`chapter-card-${index}`} data-testid={`chapter-card-${index}`}
> >
<div className="chapter-card-top"> <div className="chapter-card-top">
<span className={`chapter-kind chapter-kind--${chapter.kind}`}> <span className={`chapter-kind chapter-kind--${chapter.kind}`}>
{chapterKindLabel(chapter.kind)} {chapterKindLabel(chapter.kind)}
</span> </span>
<span className="chapter-lap-range">{chapterLapRange(chapter)}</span> <span className="chapter-lap-range">{chapterLapRange(chapter)}</span>
</div> </div>
<span className="chapter-headline">{headline}</span> <span className="chapter-headline">{headline}</span>
</button> </button>
) )
})} })}
</div>
</div> </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,6 +1,17 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { fetchLiveState } from '../api'
import { isLiveSessionActive } from '../lib/live'
export function Nav() { export function Nav() {
const { data: liveState } = useQuery({
queryKey: ['live-state'],
queryFn: fetchLiveState,
staleTime: 5_000,
refetchInterval: 30_000,
})
const liveActive = isLiveSessionActive(liveState)
return ( return (
<nav className="app-nav"> <nav className="app-nav">
<Link to="/" className="nav-logo"> <Link to="/" className="nav-logo">
@@ -10,8 +21,16 @@ export function Nav() {
<Link to="/" activeProps={{ className: 'active' }} activeOptions={{ exact: true }}> <Link to="/" activeProps={{ className: 'active' }} activeOptions={{ exact: true }}>
Command Command
</Link> </Link>
<Link to="/live" activeProps={{ className: 'active' }}> <Link
to="/live"
className={liveActive ? 'nav-live nav-live-on' : 'nav-live'}
activeProps={{ className: 'active' }}
data-testid="nav-live"
data-live-active={liveActive ? 'true' : 'false'}
>
{liveActive && <span className="nav-live-dot" aria-hidden="true" />}
Live Live
{liveActive && <span className="sr-only"> session active</span>}
</Link> </Link>
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}> <Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
Race Hub Race Hub

View File

@@ -1,13 +1,36 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { LineChart } from 'lucide-react'
import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session, Chapter } from '../types' import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session, Chapter } from '../types'
import { fetchReplayFrames, fetchTrackOutline } from '../api' import { fetchReplayFrames, fetchTrackOutline } from '../api'
import { ReplayTrackMap } from './ReplayTrackMap' import { ReplayTrackMap } from './ReplayTrackMap'
import { ChapterStrip } from './ChapterStrip' import { ChapterStrip } from './ChapterStrip'
import { EmptyStateCard } from './EmptyStateCard'
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils' 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 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 { interface Props {
data: { 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) { export function RaceStoryCanvas({ data }: Props) {
const { const {
results, results,
@@ -48,32 +113,46 @@ export function RaceStoryCanvas({ data }: Props) {
const [playbackSpeed, setPlaybackSpeed] = useState(10) const [playbackSpeed, setPlaybackSpeed] = useState(10)
const [chapterTourActive, setChapterTourActive] = useState(false) const [chapterTourActive, setChapterTourActive] = useState(false)
const [tourChapterIndex, setTourChapterIndex] = useState<number | null>(null) const [tourChapterIndex, setTourChapterIndex] = useState<number | null>(null)
const [selectedChapterIndex, setSelectedChapterIndex] = useState<number | null>(null)
const svgRef = useRef<SVGSVGElement>(null) const svgRef = useRef<SVGSVGElement>(null)
const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 }) const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 })
// Position Evolution Chart Logic const chartTiming = useMemo(() => raceChartTiming(positions, laps, results), [laps, positions, results])
const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions]) const hasChartData = hasPositions && chartTiming !== null
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 circuitKey = session?.circuit_key ?? meeting?.circuit_key ?? 0 const circuitKey = session?.circuit_key ?? meeting?.circuit_key ?? 0
const outlineYear = meeting?.year ?? (session?.date_start ? new Date(session.date_start).getFullYear() : 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({ const replayQuery = useQuery({
queryKey: ['replay-frames', session?.session_key, 5000], queryKey: ['replay-frames', session?.session_key, 5000],
queryFn: () => fetchReplayFrames(session!.session_key, 5000), queryFn: () => fetchReplayFrames(session!.session_key, 5000),
enabled: mapOpen && Boolean(session?.session_key), enabled: canProbeMap,
}) })
const outlineQuery = useQuery({ const outlineQuery = useQuery({
queryKey: ['track-outline', circuitKey, outlineYear], queryKey: ['track-outline', circuitKey, outlineYear],
queryFn: () => fetchTrackOutline(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(() => { useEffect(() => {
if (!isPlaying || !chartTiming) return if (!isPlaying || !chartTiming) return
@@ -104,9 +183,14 @@ export function RaceStoryCanvas({ data }: Props) {
const jumpToChapter = (index: number, scrub: number) => { const jumpToChapter = (index: number, scrub: number) => {
setIsPlaying(false) setIsPlaying(false)
stopChapterTour() stopChapterTour()
setSelectedChapterIndex(index)
setScrubTime(scrub) setScrubTime(scrub)
} }
const clearChapterSelection = () => {
setSelectedChapterIndex(null)
}
const toggleChapterTour = () => { const toggleChapterTour = () => {
if (chapterTourActive) { if (chapterTourActive) {
stopChapterTour() stopChapterTour()
@@ -114,6 +198,7 @@ export function RaceStoryCanvas({ data }: Props) {
} }
if (!chartTiming || chapters.length === 0) return if (!chartTiming || chapters.length === 0) return
setIsPlaying(false) setIsPlaying(false)
setSelectedChapterIndex(null)
setChapterTourActive(true) setChapterTourActive(true)
setTourChapterIndex(0) setTourChapterIndex(0)
const startScrub = chapterStartScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? 0 const startScrub = chapterStartScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? 0
@@ -194,16 +279,19 @@ export function RaceStoryCanvas({ data }: Props) {
if (hasChartData && chartTiming) { if (hasChartData && chartTiming) {
const { tMin, tRange } = 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 }>>() const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
for (const p of positions) { 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, []) if (!byDriver.has(p.driver_number)) byDriver.set(p.driver_number, [])
byDriver.get(p.driver_number)!.push({ byDriver.get(p.driver_number)!.push({
t: (new Date(p.date).getTime() - tMin) / tRange, t: normaliseTime(time),
pos: p.position, 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()) { for (const [dNum, samples] of byDriver.entries()) {
samples.sort((a, b) => a.t - b.t) samples.sort((a, b) => a.t - b.t)
if (samples.length > 0 && !dnfSet.has(dNum)) { 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 (!samples || samples.length === 0) return null
if (t <= samples[0].t) return samples[0].pos if (t <= samples[0].t) return samples[0].pos
if (t >= samples[samples.length - 1].t) return samples[samples.length - 1].pos if (t >= samples[samples.length - 1].t) return samples[samples.length - 1].pos
for (let i = 0; i < samples.length - 1; i++) { for (let i = 0; i < samples.length - 1; i++) {
if (samples[i].t <= t && samples[i+1].t >= t) { if (samples[i].t <= t && samples[i + 1].t >= t) {
const dt = samples[i+1].t - samples[i].t const dt = samples[i + 1].t - samples[i].t
if (dt === 0) return samples[i].pos if (dt === 0) return samples[i].pos
const frac = (t - samples[i].t) / dt 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 return null
@@ -244,34 +332,37 @@ export function RaceStoryCanvas({ data }: Props) {
const maxPos = Math.max(...positions.map((p) => p.position), results.length, 2) 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 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 acronymByDriver = new Map(results.map((r) => [r.driver_number, r.name_acronym]))
const positionLabels = decimatedPositionLabels(maxPos)
const W = 640 const W = CHART_W
const H = 180 const H = CHART_H
const PL = 40 const PL = CHART_PL
const PR = 48 const PR = CHART_PR
const PT = 8 const PT = CHART_PT
const PB = 20 const PB = CHART_PB
const plotW = W - PL - PR const plotW = W - PL - PR
const plotH = H - PT - PB const plotH = H - PT - PB
const toX = (t: number) => PL + t * plotW const toX = (t: number) => PL + t * plotW
const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH
const winner = results.find(r => r.position === 1) const winner = results.find((r) => r.position === 1)
const winnerLaps = winner ? laps.filter(l => l.driver_number === winner.driver_number) : [] const winnerLaps = winner ? laps.filter((l) => l.driver_number === winner.driver_number) : []
const lapTicks: { lap: number, t: number }[] = [] const lapTicks: { lap: number; t: number }[] = []
const lapInterval = winnerLaps.length < 30 ? 5 : 10 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) { if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) {
const t = (new Date(lap.date_start).getTime() - tMin) / tRange const lapStart = new Date(lap.date_start).getTime()
if (t >= 0 && t <= 1) { const nextLapStart = winnerLaps[i + 1] ? new Date(winnerLaps[i + 1].date_start).getTime() : NaN
lapTicks.push({ lap: lap.lap_number, t }) 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' }[] = [] const scPeriods: { start: number; end: number | null; type: 'SC' | 'VSC' }[] = []
let activeSC: { start: number; type: 'SC' | 'VSC' } | null = null 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()) 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 }) 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>) => { const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
setIsPlaying(false) setIsPlaying(false)
stopChapterTour() stopChapterTour()
clearChapterSelection()
if (!svgRef.current) return if (!svgRef.current) return
const rect = svgRef.current.getBoundingClientRect() 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)) const t = Math.max(0, Math.min(1, (x - PL) / plotW))
setScrubTime(t) setScrubTime(t)
} }
chartContent = ( 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 <svg
ref={svgRef} ref={svgRef}
className="rs-position-chart-svg"
viewBox={`0 0 ${W} ${H}`} viewBox={`0 0 ${W} ${H}`}
style={{ width: '100%', minWidth: 280, maxWidth: W, display: 'block' }}
role="img" role="img"
aria-label="Position evolution chart" 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) => { {scPeriods.map((sc, i) => {
const startT = (sc.start - tMin) / tRange const startT = (sc.start - tMin) / tRange
const endT = sc.end ? (sc.end - tMin) / tRange : 1 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}> <g key={pos}>
<line <line
x1={PL} x1={PL}
x2={W - PR} x2={W - PR}
y1={toY(pos)} y1={toY(pos)}
y2={toY(pos)} y2={toY(pos)}
stroke="var(--border)" className="rs-chart-grid-line"
strokeWidth={0.5}
/> />
<text <text
x={PL - 4} x={PL - 4}
y={toY(pos) + 4} y={toY(pos) + 4}
textAnchor="end" textAnchor="end"
fill="var(--text-3)" className="rs-chart-axis-label"
fontSize={8}
fontFamily="var(--f-mono)"
> >
P{pos} P{pos}
</text> </text>
</g> </g>
))} ))}
{lapTicks.map(tick => ( {lapTicks.map((tick) => (
<g key={`lap-${tick.lap}`}> <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} /> <line
<text x={toX(tick.t)} y={H - PB + 14} textAnchor="middle" fill="var(--text-3)" fontSize={9} fontFamily="var(--f-mono)"> 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} L{tick.lap}
</text> </text>
</g> </g>
@@ -372,8 +519,9 @@ export function RaceStoryCanvas({ data }: Props) {
const last = samples[samples.length - 1] const last = samples[samples.length - 1]
const isHovered = hoverDriver === dNum const isHovered = hoverDriver === dNum
const isFaded = hoverDriver !== null && !isHovered 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 ( return (
<g <g
@@ -394,8 +542,9 @@ export function RaceStoryCanvas({ data }: Props) {
/> />
{driverPits.map((p, i) => { {driverPits.map((p, i) => {
const t = (new Date(p.date).getTime() - tMin) / tRange const time = new Date(p.date).getTime()
if (t < 0 || t > 1) return null if (!Number.isFinite(time) || time < tMin || time > tMin + tRange) return null
const t = normaliseTime(time)
const pos = getInterpPos(samples, t) const pos = getInterpPos(samples, t)
if (pos === null) return null if (pos === null) return null
return ( return (
@@ -415,7 +564,7 @@ export function RaceStoryCanvas({ data }: Props) {
{last && ( {last && (
<text <text
x={toX(last.t) + 6} x={toX(last.t) + 6}
y={toY(last.pos) + 4} y={labelY + 4}
fill={color} fill={color}
fontSize={isHovered ? 11 : 9} fontSize={isHovered ? 11 : 9}
fontFamily="var(--f-mono)" fontFamily="var(--f-mono)"
@@ -449,44 +598,57 @@ export function RaceStoryCanvas({ data }: Props) {
width={plotW} width={plotW}
height={plotH} height={plotH}
fill="transparent" fill="transparent"
data-testid="position-chart-interaction"
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerLeave={() => { onPointerLeave={() => {
if (!isPlaying) setScrubTime(null) if (!isPlaying) {
clearChapterSelection()
setScrubTime(null)
}
}} }}
style={{ cursor: 'crosshair', touchAction: 'none' }} style={{ cursor: 'crosshair', touchAction: 'none' }}
/> />
</svg> </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 <button
type="button" type="button"
className={`rs-tool-btn ${isPlaying ? 'active' : ''}`} className={`rs-segment ${isPlaying ? 'active' : ''}`}
onClick={() => { onClick={() => {
setScrubTime((current) => current ?? 0) setScrubTime((current) => current ?? 0)
stopChapterTour() stopChapterTour()
clearChapterSelection()
setIsPlaying((current) => !current) setIsPlaying((current) => !current)
}} }}
aria-pressed={isPlaying}
> >
{isPlaying ? 'Pause' : 'Play'} {isPlaying ? 'Pause' : 'Play'}
</button> </button>
<div className="rs-speed-group" aria-label="Playback speed"> <span className="rs-segment-divider" aria-hidden />
{[1, 10, 30].map((speed) => ( {[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 <button
key={speed}
type="button" type="button"
className={`rs-speed-btn ${playbackSpeed === speed ? 'active' : ''}`} className={`rs-segment ${mapOpen ? 'active' : ''}`}
onClick={() => setPlaybackSpeed(speed)} onClick={() => setMapOpen((current) => !current)}
aria-pressed={mapOpen}
data-testid="replay-map-toggle"
> >
{speed}x Map
</button> </button>
))} </>
</div> )}
<button
type="button"
className={`rs-tool-btn ${mapOpen ? 'active' : ''}`}
onClick={() => setMapOpen((current) => !current)}
>
Map
</button>
</div> </div>
</div> </div>
) )
@@ -502,36 +664,41 @@ export function RaceStoryCanvas({ data }: Props) {
tRange={chartTiming.tRange} tRange={chartTiming.tRange}
tourActive={chapterTourActive} tourActive={chapterTourActive}
tourChapterIndex={tourChapterIndex} tourChapterIndex={tourChapterIndex}
selectedChapterIndex={selectedChapterIndex}
onChapterClick={jumpToChapter} onChapterClick={jumpToChapter}
onTourToggle={toggleChapterTour} onTourToggle={toggleChapterTour}
/> />
)} )}
<div className="rs-replay-shell"> <div className={`rs-replay-shell${showMapPanel ? ' rs-replay-shell--split' : ''}`}>
<div className="rs-replay-main"> <div className="rs-replay-main">
{hasChartData ? ( {hasChartData ? (
chartContent chartContent
) : ( ) : (
<div className="analysis-notice"> <EmptyStateCard
<strong>Lap-by-lap positions not available.</strong> This session does not icon={LineChart}
have ingested position samples in <code>/api/v1/race-hub</code>. title="Lap-by-lap positions not available"
</div> 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> </div>
{mapOpen && ( {showMapPanel && (
<div className="rs-replay-map-slot"> <div className="rs-replay-map-slot" data-testid="replay-map-slot">
{circuitKey > 0 && outlineYear > 0 ? ( <ReplayTrackMap
<ReplayTrackMap outline={outlineQuery.data}
outline={outlineQuery.data} replay={replayQuery.data}
replay={replayQuery.data} tMs={replayTMs}
tMs={replayTMs} drivers={drivers}
drivers={drivers} results={results}
results={results} loading={outlineQuery.isLoading || replayQuery.isLoading}
loading={outlineQuery.isLoading || replayQuery.isLoading} error={outlineQuery.isError || replayQuery.isError}
error={outlineQuery.isError || replayQuery.isError} />
/>
) : (
<div className="rs-replay-map-placeholder">track identity unavailable for replay map</div>
)}
</div> </div>
)} )}
</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 gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
const currentPos = scrubTime !== null ? i + 1 : r.position const currentPos = scrubTime !== null ? i + 1 : r.position
const isWinner = i === 0 && r.position === 1 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 let currentPoints: number | string = r.points
if (scrubTime !== null) { if (scrubTime !== null) {
@@ -559,9 +727,7 @@ export function RaceStoryCanvas({ data }: Props) {
onMouseLeave={() => setHoverDriver(null)} onMouseLeave={() => setHoverDriver(null)}
> >
<div className="rs-driver-left"> <div className="rs-driver-left">
<div className={`rs-pos-col ${pClass}`}> <div className={`rs-pos-col ${pClass}`}>{currentPos}</div>
{currentPos}
</div>
<div className="rs-driver-cell"> <div className="rs-driver-cell">
<div <div
className="rs-driver-color" className="rs-driver-color"

View File

@@ -1,7 +1,9 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Loader2, MapPin, Satellite } from 'lucide-react'
import type { Driver, EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types' import type { Driver, EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types'
import { buildOutlinePath } from '../lib/trackmap' import { buildOutlinePath } from '../lib/trackmap'
import { interpolateReplayCars, replayCarToSvg } from '../lib/replay' import { interpolateReplayCars, replayCarToSvg } from '../lib/replay'
import { EmptyStateCard } from './EmptyStateCard'
import '../styles/replay-map.css' import '../styles/replay-map.css'
interface Props { interface Props {
@@ -60,7 +62,13 @@ export function ReplayTrackMap({
if (loading) { if (loading) {
return ( return (
<section className="replay-map-panel" data-testid="replay-track-map"> <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> </section>
) )
} }
@@ -68,7 +76,13 @@ export function ReplayTrackMap({
if (error) { if (error) {
return ( return (
<section className="replay-map-panel" data-testid="replay-track-map"> <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> </section>
) )
} }
@@ -76,7 +90,13 @@ export function ReplayTrackMap({
if (!outline || !outlinePath) { if (!outline || !outlinePath) {
return ( return (
<section className="replay-map-panel" data-testid="replay-track-map"> <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> </section>
) )
} }
@@ -84,7 +104,13 @@ export function ReplayTrackMap({
if (!replay?.frames?.length || cars.length === 0) { if (!replay?.frames?.length || cars.length === 0) {
return ( return (
<section className="replay-map-panel" data-testid="replay-track-map"> <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> </section>
) )
} }

View File

@@ -47,7 +47,7 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
} }
return ( return (
<div className="rh-switcher" data-testid="rh-switcher"> <div id="rh-weekend-switcher" className="rh-switcher" data-testid="rh-switcher">
<div className="rh-switcher-head"> <div className="rh-switcher-head">
<span className="sec-title">Switch Weekend</span> <span className="sec-title">Switch Weekend</span>
<div className="rh-switcher-years"> <div className="rh-switcher-years">

View File

@@ -80,7 +80,9 @@ export function RaceControlFeed({ messages, driverInfo }: Props) {
<section className="live-rc panel-glass"> <section className="live-rc panel-glass">
<div className="sec-header sticky-header"> <div className="sec-header sticky-header">
<span className="sec-title">Race Control</span> <span className="sec-title">Race Control</span>
{messages.length > 0 && <span className="sec-meta">{messages.length} messages</span>} <span className="sec-meta" data-testid="rc-timezone">
{messages.length > 0 ? `${messages.length} messages · ` : ''}times UTC
</span>
</div> </div>
{latest.length === 0 ? ( {latest.length === 0 ? (
<div className="missing-notice">No race control messages in the current live snapshot.</div> <div className="missing-notice">No race control messages in the current live snapshot.</div>
@@ -102,7 +104,7 @@ export function RaceControlFeed({ messages, driverInfo }: Props) {
return ( return (
<div className={`live-rc-row${flashClass}`} key={key}> <div className={`live-rc-row${flashClass}`} key={key}>
<span className="rc-time">{message.Time || '--:--'}</span> <span className="rc-time" title="UTC">{message.Time || '--:--'}</span>
{message.Lap > 0 && <span className="rc-lap">L{message.Lap}</span>} {message.Lap > 0 && <span className="rc-lap">L{message.Lap}</span>}
{message.Flag {message.Flag
? <span className={`rc-flag ${rcFlagClass(message.Flag)}`}>{message.Flag}</span> ? <span className={`rc-flag ${rcFlagClass(message.Flag)}`}>{message.Flag}</span>

View File

@@ -38,9 +38,11 @@ export function SessionBanner({ isLive, isArchive = false, snapshot, rows, conne
<div className="live-banner-meta"> <div className="live-banner-meta">
{display.advanceCount && <span>{display.advanceCount} advance</span>} {display.advanceCount && <span>{display.advanceCount} advance</span>}
{atRiskLabel && <span>{atRiskLabel}</span>} {atRiskLabel && <span>{atRiskLabel}</span>}
<span> {display.isRace && (
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong> <span data-testid="live-lap-counter">
</span> L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
</span>
)}
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{stateLabel}</span> <span className={isLive ? 'live-state live-state-on' : 'live-state'}>{stateLabel}</span>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,10 @@
import { useState, Fragment } from 'react' import { useState, useMemo, Fragment } from 'react'
import { teamColor } from '../../utils' import { teamColor } from '../../utils'
import { useAutoAnimate } from '@formkit/auto-animate/react' import { useAutoAnimate } from '@formkit/auto-animate/react'
import type { LiveSessionMeta, LiveStintData } from '../../types' import type { LiveSessionMeta, LiveStintData } from '../../types'
import type { LiveTimingRow } from '../../lib/live' import type { LiveTimingRow } from '../../lib/live'
import { import {
bestLapGaps,
driverCode, driverCode,
liveSessionDisplay, liveSessionDisplay,
positionDelta, positionDelta,
@@ -62,6 +63,10 @@ export function TimingTower({
const isQuali = sessionDisplay.isQualifying || !isRace const isQuali = sessionDisplay.isQualifying || !isRace
const columnCount = 7 + (isRace ? 3 : 0) + (isQuali ? 3 : 0) const columnCount = 7 + (isRace ? 3 : 0) + (isQuali ? 3 : 0)
// Practice/qualifying: derive a display-only gap to P1 from valid best laps
// when the upstream feed omits GapToLeader. Empty for races (feed is truth).
const practiceGaps = useMemo(() => bestLapGaps(isRace ? [] : rows), [isRace, rows])
return ( return (
<div className="scroll-x"> <div className="scroll-x">
<table className="data-table live-tower" style={{ minWidth: 760 }}> <table className="data-table live-tower" style={{ minWidth: 760 }}>
@@ -71,7 +76,7 @@ export function TimingTower({
{isRace && <th>Δ</th>} {isRace && <th>Δ</th>}
<th>Driver</th> <th>Driver</th>
<th>Tyre</th> <th>Tyre</th>
<th>Last Lap</th> <th className={isRace ? undefined : 'hide-mobile'}>Last Lap</th>
<th <th
className="interactive" className="interactive"
onClick={() => setGapMode(g => g === 'interval' ? 'leader' : 'interval')} onClick={() => setGapMode(g => g === 'interval' ? 'leader' : 'interval')}
@@ -81,10 +86,10 @@ export function TimingTower({
{gapMode === 'interval' && isRace ? 'Interval' : 'Gap to P1'} {gapMode === 'interval' && isRace ? 'Interval' : 'Gap to P1'}
</th> </th>
{isRace && <th>Trend</th>} {isRace && <th>Trend</th>}
{isQuali && <th>S1</th>} {isQuali && <th className="hide-mobile">S1</th>}
{isQuali && <th>S2</th>} {isQuali && <th className="hide-mobile">S2</th>}
{isQuali && <th>S3</th>} {isQuali && <th className="hide-mobile">S3</th>}
<th className="hide-mobile">Best</th> <th className={isRace ? 'hide-mobile' : undefined}>Best</th>
{isRace && <th className="hide-mobile r">Laps</th>} {isRace && <th className="hide-mobile r">Laps</th>}
<th className="r"></th> <th className="r"></th>
</tr> </tr>
@@ -102,7 +107,15 @@ export function TimingTower({
driver.Cutoff driver.Cutoff
const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition
const gapText = gapMode === 'interval' && isRace ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader let gapText: string
if (isRace) {
gapText = gapMode === 'interval' ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader
} else if (driver.GapToLeader) {
gapText = driver.GapToLeader
} else {
const computed = practiceGaps[row.RacingNumber]
gapText = computed ? (computed.isLeader ? '—' : computed.gap) : ''
}
const intervalAnnotation = const intervalAnnotation =
gapMode === 'interval' && isRace && row.Position > 1 gapMode === 'interval' && isRace && row.Position > 1
? intervalMeaning(parseIntervalSeconds(gapText)) ? intervalMeaning(parseIntervalSeconds(gapText))
@@ -153,7 +166,10 @@ export function TimingTower({
<td> <td>
<span className={`tyre-badge ${tyreClass(row.Tyre)}`}>{tyreLabel(row.Tyre)}</span> <span className={`tyre-badge ${tyreClass(row.Tyre)}`}>{tyreLabel(row.Tyre)}</span>
</td> </td>
<td className={driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono'}> <td className={[
isRace ? '' : 'hide-mobile',
driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono',
].filter(Boolean).join(' ')}>
{driver.LastLapTime || '-'} {driver.LastLapTime || '-'}
</td> </td>
<td className="mono"> <td className="mono">
@@ -171,11 +187,14 @@ export function TimingTower({
</td> </td>
)} )}
{isQuali && <td>{renderSector(0)}</td>} {isQuali && <td className="hide-mobile">{renderSector(0)}</td>}
{isQuali && <td>{renderSector(1)}</td>} {isQuali && <td className="hide-mobile">{renderSector(1)}</td>}
{isQuali && <td>{renderSector(2)}</td>} {isQuali && <td className="hide-mobile">{renderSector(2)}</td>}
<td className={`hide-mobile ${driver.BestLapOB ? 'mono lap-ob' : 'mono'}`}> <td className={[
isRace ? 'hide-mobile' : '',
driver.BestLapOB ? 'mono lap-ob' : 'mono',
].filter(Boolean).join(' ')}>
{driver.BestLapTime || '-'} {driver.BestLapTime || '-'}
</td> </td>
@@ -208,6 +227,16 @@ export function TimingTower({
<div className="mono">{driver.NumberOfLaps || 0}</div> <div className="mono">{driver.NumberOfLaps || 0}</div>
</div> </div>
)} )}
{!isRace && driver.Sectors?.some((sec) => sec?.Value) && (
<div data-testid="expanded-sectors">
<div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>SECTORS</div>
<div className="mono" style={{ display: 'flex', gap: '10px' }}>
{renderSector(0)}
{renderSector(1)}
{renderSector(2)}
</div>
</div>
)}
{driver.SpeedTrap && ( {driver.SpeedTrap && (
<div> <div>
<div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>SPEED TRAP</div> <div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>SPEED TRAP</div>

View File

@@ -52,7 +52,9 @@ function StintSparkline({ seconds }: { seconds: number[] }) {
} }
export function TyreDegPanel({ rows, sessionType, pinned }: Props) { export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
const [collapsed, setCollapsed] = useState(false) const isRace = isRaceSession(sessionType)
const [collapsed, setCollapsed] = useState(true)
const [readerChose, setReaderChose] = useState(false)
const [stints, setStints] = useState<StintHistoryMap>({}) const [stints, setStints] = useState<StintHistoryMap>({})
// One lap-history update per received snapshot (rows is rebuilt per snapshot). // One lap-history update per received snapshot (rows is rebuilt per snapshot).
@@ -61,8 +63,6 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
setStints((prev) => recordStintSamples(prev, rows.map(stintInputFromRow))) setStints((prev) => recordStintSamples(prev, rows.map(stintInputFromRow)))
}, [rows]) }, [rows])
const isRace = isRaceSession(sessionType)
const visible = useMemo( const visible = useMemo(
() => () =>
rows.filter( rows.filter(
@@ -74,6 +74,30 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
[rows, pinned], [rows, pinned],
) )
// One linear fit per driver per snapshot, shared by the readiness check and
// the rows below. degradationModel is O(laps) and this runs at feed rate.
const models = useMemo(() => {
const out: Record<string, ReturnType<typeof degradationModel>> = {}
for (const row of visible) {
out[row.RacingNumber] = degradationModel(stints[row.RacingNumber]?.samples ?? [])
}
return out
}, [visible, stints])
// Before any stint has enough clean laps to fit, every row reads "warming
// up" — a full-height panel of placeholders that pushed the Timing Tower off
// the fold for the first third of a race. Stay collapsed until there is
// something to say, then open. A reader who has toggled it keeps their choice.
const hasSignal = useMemo(
() => visible.some((row) => models[row.RacingNumber] != null),
[visible, models],
)
useEffect(() => {
if (readerChose) return
setCollapsed(!(isRace && hasSignal))
}, [isRace, hasSignal, readerChose])
if (visible.length === 0) return null if (visible.length === 0) return null
return ( return (
@@ -81,18 +105,25 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
<button <button
type="button" type="button"
className="sec-header tyredeg-toggle" className="sec-header tyredeg-toggle"
onClick={() => setCollapsed((prev) => !prev)} onClick={() => {
setReaderChose(true)
setCollapsed((prev) => !prev)
}}
aria-expanded={!collapsed} aria-expanded={!collapsed}
> >
<span className="sec-title">Tyre Deg &amp; Pit Window</span> <span className="sec-title">Tyre Deg &amp; Pit Window</span>
{isRace && <span className="sec-meta">rejoin assumes ~{PIT_LOSS_SECONDS}s pit loss</span>} {!hasSignal ? (
<span className="sec-meta">collecting clean laps</span>
) : (
isRace && <span className="sec-meta">rejoin assumes ~{PIT_LOSS_SECONDS}s pit loss</span>
)}
<span className="tyredeg-chevron" aria-hidden="true">{collapsed ? '▸' : '▾'}</span> <span className="tyredeg-chevron" aria-hidden="true">{collapsed ? '▸' : '▾'}</span>
</button> </button>
{!collapsed && ( {!collapsed && (
<div className="tyredeg-rows"> <div className="tyredeg-rows">
{visible.map((row) => { {visible.map((row) => {
const model = degradationModel(stints[row.RacingNumber]?.samples ?? []) const model = models[row.RacingNumber]
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age) const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age)
return ( return (

View File

@@ -53,7 +53,10 @@ export function chapterEndScrub(
return Math.max(0, Math.min(1, (ms - tMin) / tRange)) 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( export function activeChapterIndex(
chapters: Chapter[], chapters: Chapter[],
scrubTime: number | null, scrubTime: number | null,
@@ -61,15 +64,13 @@ export function activeChapterIndex(
tRange: number, tRange: number,
): number | null { ): number | null {
if (scrubTime === null || chapters.length === 0 || tRange <= 0) return null if (scrubTime === null || chapters.length === 0 || tRange <= 0) return null
const chartMs = tMin + scrubTime * tRange
for (let i = 0; i < chapters.length; i++) { for (let i = 0; i < chapters.length; i++) {
const ch = chapters[i] const start = chapterStartScrub(chapters[i], tMin, tRange)
const startMs = ch.start_time ? new Date(ch.start_time).getTime() : NaN const end = chapterEndScrub(chapters[i], tMin, tRange)
const endRaw = ch.end_time ?? ch.start_time if (start === null || end === null) continue
const endMs = endRaw ? new Date(endRaw).getTime() : NaN const lo = Math.min(start, end)
if (!Number.isNaN(startMs) && !Number.isNaN(endMs) && chartMs >= startMs && chartMs <= endMs) { const hi = Math.max(start, end)
return i if (scrubTime >= lo && scrubTime <= hi) return i
}
} }
return null return null
} }
@@ -80,3 +81,52 @@ export function chapterTourDurations(chapters: Chapter[], totalMs = 90_000): num
const perChapter = totalMs / chapters.length const perChapter = totalMs / chapters.length
return chapters.map(() => perChapter) 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

@@ -268,6 +268,63 @@ export function driverCode(row: LiveTimingRow): string {
return row.Info?.Tla || row.RacingNumber return row.Info?.Tla || row.RacingNumber
} }
/**
* Parse an F1 lap-time string ("1:45.944", "45.944") into total seconds.
* Returns null for empty/invalid values so callers never invent a gap.
*/
export function parseLapTimeSeconds(value: string | null | undefined): number | null {
if (!value) return null
const match = value.trim().match(/^(?:(\d+):)?([0-5]?\d(?:\.\d+)?)$/)
if (!match) return null
const minutes = match[1] ? Number(match[1]) : 0
const seconds = Number(match[2])
if (!Number.isFinite(minutes) || !Number.isFinite(seconds)) return null
return minutes * 60 + seconds
}
export interface BestLapGap {
isLeader: boolean
gap: string
}
/**
* Display-only gap-to-P1 for practice/qualifying, derived from each driver's
* valid best lap. Only drivers with a parseable best lap get an entry, and the
* fastest is flagged as the leader. Never fabricates a gap from a missing or
* invalid lap — the upstream interval remains the source of truth for races.
*/
export function bestLapGaps(rows: ReadonlyArray<LiveTimingRow>): Record<string, BestLapGap> {
let leaderNumber = ''
let best = Infinity
for (const row of rows) {
const seconds = parseLapTimeSeconds(row.Driver.BestLapTime)
if (seconds === null) continue
if (seconds < best) {
best = seconds
leaderNumber = row.RacingNumber
}
}
const out: Record<string, BestLapGap> = {}
if (!Number.isFinite(best)) return out
for (const row of rows) {
const seconds = parseLapTimeSeconds(row.Driver.BestLapTime)
if (seconds === null) continue
out[row.RacingNumber] =
row.RacingNumber === leaderNumber
? { isLeader: true, gap: '' }
: { isLeader: false, gap: `+${(seconds - best).toFixed(3)}` }
}
return out
}
/** True when the live feed reports an in-progress session with timing data. */
export function isLiveSessionActive(
state: { is_live?: boolean; data?: unknown } | null | undefined,
): boolean {
return Boolean(state?.is_live && state.data)
}
export function trackStatusLabel(status: string): string { export function trackStatusLabel(status: string): string {
return TRACK_STATUS_LABELS[status] || status || 'UNKNOWN' return TRACK_STATUS_LABELS[status] || status || 'UNKNOWN'
} }

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

@@ -145,6 +145,15 @@ export function formatSessionScheduleTime(value: string): string {
}) })
} }
export const MAX_BROWSER_TIMEOUT = 2_147_483_647
export function refreshDeadlineDelay(refreshAt: string | undefined, now = Date.now()): number | null {
if (!refreshAt) return null
const deadline = Date.parse(refreshAt)
if (Number.isNaN(deadline)) return null
return Math.min(Math.max(0, deadline - now), MAX_BROWSER_TIMEOUT)
}
export type FocusMeetingKind = 'current' | 'next' | 'recent' | 'fallback' export type FocusMeetingKind = 'current' | 'next' | 'recent' | 'fallback'
export function focusMeetingKind(meeting: Meeting, now: Date): FocusMeetingKind { export function focusMeetingKind(meeting: Meeting, now: Date): FocusMeetingKind {

View File

@@ -20,6 +20,7 @@ import {
currentAndNextSession, currentAndNextSession,
focusMeetingKind, focusMeetingKind,
formatSessionScheduleTime, formatSessionScheduleTime,
meetingEndTime,
meetingHasStarted, meetingHasStarted,
mostRecentPastMeeting, mostRecentPastMeeting,
nextUpcomingMeeting, nextUpcomingMeeting,
@@ -34,6 +35,8 @@ const missingDatasets = Object.fromEntries(
) as WeekendSession['datasets'] ) as WeekendSession['datasets']
function meetingStatus(meeting: Meeting, focusKey: number | undefined, now: Date) { function meetingStatus(meeting: Meeting, focusKey: number | undefined, now: Date) {
const end = meetingEndTime(meeting)
if (end && now >= end) return 'past'
if (meeting.meeting_key === focusKey) return 'focus' if (meeting.meeting_key === focusKey) return 'focus'
if (meetingHasStarted(meeting, now)) return 'past' if (meetingHasStarted(meeting, now)) return 'past'
return 'future' return 'future'
@@ -163,6 +166,12 @@ export function CommandCenterPage() {
const lastPastWeekend = lastPastMeeting ? weekendsByKey.get(lastPastMeeting.meeting_key) : undefined const lastPastWeekend = lastPastMeeting ? weekendsByKey.get(lastPastMeeting.meeting_key) : undefined
const lastRaceAnalysis = pickAnalysisSession(lastPastWeekend) const lastRaceAnalysis = pickAnalysisSession(lastPastWeekend)
const lastRaceSessionKey = lastRaceAnalysis?.session.session_key const lastRaceSessionKey = lastRaceAnalysis?.session.session_key
const railSessions =
focusWeekendSessions.length > 0
? focusWeekendSessions
: heroStateKind === 'between'
? lastPastWeekend?.sessions ?? []
: []
const lastRaceHubQuery = useQuery({ const lastRaceHubQuery = useQuery({
queryKey: ['race-hub', lastRaceSessionKey, 'hero-podium'], queryKey: ['race-hub', lastRaceSessionKey, 'hero-podium'],
@@ -178,6 +187,16 @@ export function CommandCenterPage() {
const lastRacePodium = lastRaceHubQuery.data?.results ?? [] const lastRacePodium = lastRaceHubQuery.data?.results ?? []
const lastRaceName = champHub?.last_race ?? lastPastMeeting?.meeting_name ?? '' const lastRaceName = champHub?.last_race ?? lastPastMeeting?.meeting_name ?? ''
const focusNarrative =
heroStateKind === 'between'
? lastRaceName
? `${lastRaceName} is in the archive. The next chapter begins at ${focusMeeting?.circuit_short_name || focusMeeting?.meeting_name || 'the next round'}.`
: 'The season is between race weekends. Use the timeline to revisit a completed round or look ahead.'
: currentSession
? `${currentSession.session_name} is the active chapter of this weekend. Session detail remains available as it lands locally.`
: nextSession
? `${nextSession.session_name} is next on the timetable. The weekend rail keeps every session and its local analysis in reach.`
: 'The weekend timetable is ready to explore.'
if (seasonsQuery.isLoading) { if (seasonsQuery.isLoading) {
return <div className="page loading-state">loading command center</div> return <div className="page loading-state">loading command center</div>
@@ -226,7 +245,7 @@ export function CommandCenterPage() {
<div className="cc-topbar"> <div className="cc-topbar">
<span className="cc-topbar-label mono">box-box · command center</span> <span className="cc-topbar-label mono">box-box · command center</span>
<span className="cc-topbar-meta mono"> <span className="cc-topbar-meta mono">
{latestSeason} season · {meetingStats.full}/{meetingStats.total || 0} weekends full {latestSeason} season · weekend desk
</span> </span>
<span className="cc-live-pill" data-testid="cc-live-status"> <span className="cc-live-pill" data-testid="cc-live-status">
<span className={`cc-live-dot ${liveActive ? 'live' : ''}`} /> <span className={`cc-live-dot ${liveActive ? 'live' : ''}`} />
@@ -262,6 +281,26 @@ export function CommandCenterPage() {
/> />
)} )}
{focusMeeting && (
<section className="cc-context" data-testid="cc-circuit-context">
<div className="cc-context-decal mono" aria-hidden="true">
{countryDecal(focusMeeting)}
</div>
<div className="cc-context-copy">
<span className="cc-context-kicker mono">Circuit context</span>
<h2>{focusMeeting.circuit_short_name || focusMeeting.meeting_name}</h2>
<p>{focusNarrative}</p>
</div>
<div className="cc-context-meta mono">
<span>{focusMeeting.location || focusMeeting.country_name}</span>
<span>{formatGpDateRange(focusMeeting)}</span>
<Link to="/preview" className="cc-context-preview">
Weekend preview
</Link>
</div>
</section>
)}
<div className="cc-dashboard-grid"> <div className="cc-dashboard-grid">
<div className="cc-dashboard-main"> <div className="cc-dashboard-main">
<div <div
@@ -298,7 +337,9 @@ export function CommandCenterPage() {
<div className="cc-calendar-accent" aria-hidden="true" /> <div className="cc-calendar-accent" aria-hidden="true" />
<div className="cc-calendar-top mono"> <div className="cc-calendar-top mono">
<span className="cc-calendar-round">R{String(index + 1).padStart(2, '0')}</span> <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" /> <span className={`cc-calendar-status cc-calendar-status-${status}`}>
{status === 'past' ? 'Archive' : status === 'focus' ? 'Now' : 'Ahead'}
</span>
</div> </div>
<div className="cc-calendar-id"> <div className="cc-calendar-id">
{countryFlag(meeting) && <span className="cc-calendar-flag">{countryFlag(meeting)}</span>} {countryFlag(meeting) && <span className="cc-calendar-flag">{countryFlag(meeting)}</span>}
@@ -371,14 +412,14 @@ export function CommandCenterPage() {
</section> </section>
)} )}
{focusWeekendSessions.length > 0 && ( {railSessions.length > 0 && (
<section className="cc-schedule" data-testid="cc-schedule"> <section className="cc-schedule" data-testid="cc-schedule">
<div className="sec-header"> <div className="sec-header">
<span className="sec-title">Weekend Schedule</span> <span className="sec-title">Weekend Schedule</span>
<span className="sec-meta mono">{focusWeekendSessions.length} sessions</span> <span className="sec-meta mono">{railSessions.length} sessions</span>
</div> </div>
<div className="cc-session-strip" role="list"> <div className="cc-session-strip" role="list">
{focusWeekendSessions.map(({ session, source, datasets }) => { {railSessions.map(({ session, source, datasets }) => {
const status = classifySessionStatus(session, nowDate) const status = classifySessionStatus(session, nowDate)
const isNext = nextSession?.session_key === session.session_key const isNext = nextSession?.session_key === session.session_key
const isCurrent = currentSession?.session_key === session.session_key const isCurrent = currentSession?.session_key === session.session_key
@@ -404,9 +445,9 @@ export function CommandCenterPage() {
</div> </div>
<div className="cc-session-name">{session.session_name}</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-time mono">{formatSessionScheduleTime(session.date_start)}</div>
<div className="cc-session-cov mono"> <div className="cc-session-cov mono" title={formatCoverageHint(datasets)}>
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" /> <span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
{formatCoverageHint(datasets)} {source === 'local' ? 'Analysis ready' : formatCoverageHint(datasets)}
</div> </div>
</Link> </Link>
) )
@@ -416,6 +457,10 @@ export function CommandCenterPage() {
)} )}
<PaddockBriefing /> <PaddockBriefing />
<div className="cc-data-note mono" data-testid="cc-data-note">
<span className="cc-cov-dot cc-cov-local" aria-hidden="true" />
{meetingStats.full}/{meetingStats.total || 0} locally complete · availability is shown per session
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -448,4 +493,3 @@ function FormSparkline({ form, color }: { form: number[], color: string }) {
</svg> </svg>
) )
} }

View File

@@ -289,14 +289,16 @@ export function LiveTimingPage() {
session={snapshot.Session} session={snapshot.Session}
/> />
</div> </div>
{/* Rail runs most-synthesized to most-raw: a reader arriving
mid-session wants "what did I miss" before the regulatory log. */}
<div className="live-rc-col"> <div className="live-rc-col">
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
<TeamRadioTicker <TeamRadioTicker
captures={snapshot.TeamRadio ?? []} captures={snapshot.TeamRadio ?? []}
driverInfo={snapshot.DriverInfo} driverInfo={snapshot.DriverInfo}
session={snapshot.Session} session={snapshot.Session}
/> />
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} /> <RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
</div> </div>
</div> </div>
</> </>

View File

@@ -1,12 +1,7 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { import { fetchRaceHub, fetchWeekend, fetchWeekendContext } from '../api'
fetchLocalMeetings,
fetchRaceHub,
fetchSeasons,
fetchWeekend,
} from '../api'
import { DatasetStrip } from '../components/DatasetStrip' import { DatasetStrip } from '../components/DatasetStrip'
import { RaceStoryCanvas } from '../components/RaceStoryCanvas' import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
import { TabBar, type Tab } from '../components/TabBar' import { TabBar, type Tab } from '../components/TabBar'
@@ -22,76 +17,72 @@ import { SourceBadge } from '../components/SourceBadge'
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity' import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage' import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
import { import {
MAX_BROWSER_TIMEOUT,
formatCountdown,
formatSessionScheduleTime, formatSessionScheduleTime,
pickFocusMeeting, refreshDeadlineDelay,
sortSessionsByStart, sortSessionsByStart,
} from '../lib/schedule' } from '../lib/schedule'
import type { Weekend, WeekendSession } from '../types' import type { ContextSession, Weekend } from '../types'
interface Props { interface Props {
sessionKey: number sessionKey: number
} }
function pickAnalysisSession(weekend: Weekend | undefined): WeekendSession | undefined {
if (!weekend) return undefined
const local = weekend.sessions.filter((s) => s.source === 'local')
const partial = weekend.sessions.filter((s) => s.source === 'partial')
const pool = local.length > 0 ? local : partial.length > 0 ? partial : weekend.sessions
const race = pool.find((s) => s.session.session_type?.toLowerCase().includes('race'))
if (race) return race
const qual = pool.find((s) => s.session.session_type?.toLowerCase().includes('qualifying'))
if (qual) return qual
return pool[0]
}
export function RaceHubPage({ sessionKey }: Props) { export function RaceHubPage({ sessionKey }: Props) {
const navigate = useNavigate() const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<Tab>('overview') const [activeTab, setActiveTab] = useState<Tab>('overview')
const [switcherOpen, setSwitcherOpen] = useState(false) const [switcherOpen, setSwitcherOpen] = useState(false)
const [now, setNow] = useState(() => Date.now())
const [refreshGeneration, setRefreshGeneration] = useState(0)
// ─── Auto-redirect when no session_key is supplied ─── // The server owns bare Race Hub selection so every open tab crosses the
const seasonsQuery = useQuery({ // one-hour handoff at the same instant.
queryKey: ['seasons'], const contextQuery = useQuery({
queryFn: fetchSeasons, queryKey: ['weekend-context'],
queryFn: fetchWeekendContext,
enabled: sessionKey === 0, enabled: sessionKey === 0,
}) })
const { refetch: refetchContext } = contextQuery
const latestSeason = seasonsQuery.data?.[0] ?? null const context = contextQuery.data
const preSession = sessionKey === 0 && context?.race_hub_pre_session === true
const meetingsQuery = useQuery({ const preSessionRef = context?.race_hub_default_session
queryKey: ['meetings', latestSeason], const preSessionMeetingKey = preSessionRef?.meeting?.meeting_key
queryFn: () => fetchLocalMeetings(latestSeason!), const preSessionWeekendQuery = useQuery({
enabled: sessionKey === 0 && latestSeason != null, queryKey: ['weekend', preSessionMeetingKey],
}) queryFn: () => fetchWeekend(preSessionMeetingKey!),
enabled: preSession && preSessionMeetingKey != null && preSessionMeetingKey > 0,
const focusMeeting = useMemo(() => {
if (sessionKey !== 0 || !meetingsQuery.data) return null
return pickFocusMeeting(meetingsQuery.data, new Date())
}, [sessionKey, meetingsQuery.data])
const fallbackWeekendQuery = useQuery({
queryKey: ['weekend', focusMeeting?.meeting_key],
queryFn: () => fetchWeekend(focusMeeting!.meeting_key),
enabled: sessionKey === 0 && focusMeeting != null,
}) })
useEffect(() => { useEffect(() => {
if (sessionKey !== 0) return if (sessionKey !== 0) return
const weekend = fallbackWeekendQuery.data const delay = refreshDeadlineDelay(context?.race_hub_refresh_at)
if (!weekend) return if (delay == null) return
const target = pickAnalysisSession(weekend)?.session.session_key const rearmAfterRefetch = delay === MAX_BROWSER_TIMEOUT
?? weekend.default_session_key const timer = window.setTimeout(() => {
?? weekend.sessions[0]?.session.session_key void refetchContext().finally(() => {
if (target) { if (rearmAfterRefetch) setRefreshGeneration((generation) => generation + 1)
navigate({ to: '/race-hub', search: { session_key: target }, replace: true }) })
} }, delay)
}, [sessionKey, fallbackWeekendQuery.data, navigate]) return () => window.clearTimeout(timer)
}, [sessionKey, context?.race_hub_refresh_at, refetchContext, refreshGeneration])
useEffect(() => {
if (!preSession) return
const timer = window.setInterval(() => setNow(Date.now()), 1_000)
return () => window.clearInterval(timer)
}, [preSession])
// A bare route retains canonical context ownership while rendering its
// completed analysis selection. Explicit URLs remain user-owned.
const selectedSessionKey = sessionKey || context?.race_hub_default_session?.session.session_key || 0
// ─── Active session payload ─── // ─── Active session payload ───
const raceHubQuery = useQuery({ const raceHubQuery = useQuery({
queryKey: ['race-hub', sessionKey], queryKey: ['race-hub', selectedSessionKey],
queryFn: () => fetchRaceHub(sessionKey), queryFn: () => fetchRaceHub(selectedSessionKey),
enabled: sessionKey > 0, enabled: selectedSessionKey > 0 && (sessionKey > 0 || !preSession),
staleTime: 30_000, staleTime: 30_000,
}) })
@@ -108,25 +99,36 @@ export function RaceHubPage({ sessionKey }: Props) {
const accent = countryAccent(data?.meeting ?? null) const accent = countryAccent(data?.meeting ?? null)
const accentStyle = { '--gp-accent': accent } as React.CSSProperties const accentStyle = { '--gp-accent': accent } as React.CSSProperties
// ─── No session_key: show resolving state, fall back to switcher if no local data ─── // ─── No session_key: resolve exclusively through canonical Weekend Context ───
if (sessionKey === 0) { if (sessionKey === 0) {
if (seasonsQuery.isLoading || meetingsQuery.isLoading || fallbackWeekendQuery.isLoading) { if (contextQuery.isLoading || (preSession && preSessionWeekendQuery.isLoading)) {
return ( return (
<div className="rh-page" style={accentStyle}> <div className="rh-page" style={accentStyle}>
<div className="loading-state">resolving latest local weekend</div> <div className="loading-state">resolving latest local weekend</div>
</div> </div>
) )
} }
const seasons = seasonsQuery.data ?? [] if (preSession && preSessionRef) {
if (seasons.length === 0) { return (
<RaceHubPreSession
session={preSessionRef}
weekend={preSessionWeekendQuery.data}
now={now}
switcherOpen={switcherOpen}
onToggleSwitcher={() => setSwitcherOpen((open) => !open)}
onCloseSwitcher={() => setSwitcherOpen(false)}
/>
)
}
if (!selectedSessionKey) {
return ( return (
<div className="rh-page rh-empty" data-testid="race-hub-empty" style={accentStyle}> <div className="rh-page rh-empty" data-testid="race-hub-empty" style={accentStyle}>
<div className="rh-empty-band"> <div className="rh-empty-band">
<span className="rh-empty-eyebrow mono">box-box · race hub</span> <span className="rh-empty-eyebrow mono">box-box · race hub</span>
<h1 className="rh-empty-title">No local sessions yet</h1> <h1 className="rh-empty-title">No completed local analysis yet</h1>
<p className="rh-empty-sub"> <p className="rh-empty-sub">
The Race Hub reads from local ingest only. Once a weekend is ingested Race Hub opens completed local analysis between weekends. Check Data Health
it will open here automatically. to ingest a completed session.
</p> </p>
<div className="rh-empty-actions"> <div className="rh-empty-actions">
<a href="/admin" className="rh-empty-action">Open Admin · Data Health</a> <a href="/admin" className="rh-empty-action">Open Admin · Data Health</a>
@@ -136,18 +138,13 @@ export function RaceHubPage({ sessionKey }: Props) {
</div> </div>
) )
} }
return (
<div className="rh-page" style={accentStyle}>
<div className="loading-state">resolving latest local weekend</div>
</div>
)
} }
// ─── Loading / error for the requested session_key ─── // ─── Loading / error for the selected session ───
if (raceHubQuery.isLoading) { if (raceHubQuery.isLoading) {
return ( return (
<div className="rh-page" style={accentStyle}> <div className="rh-page" style={accentStyle}>
<div className="loading-state">loading session {sessionKey}</div> <div className="loading-state">loading session {selectedSessionKey}</div>
</div> </div>
) )
} }
@@ -157,7 +154,7 @@ export function RaceHubPage({ sessionKey }: Props) {
<div className="error-box"> <div className="error-box">
{raceHubQuery.error instanceof Error {raceHubQuery.error instanceof Error
? raceHubQuery.error.message ? raceHubQuery.error.message
: `Failed to load session ${sessionKey}.`} : `Failed to load session ${selectedSessionKey}.`}
</div> </div>
</div> </div>
) )
@@ -168,7 +165,7 @@ export function RaceHubPage({ sessionKey }: Props) {
const sessionMeta = weekend const sessionMeta = weekend
? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w])) ? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w]))
: {} : {}
const activeSessionMeta = sessionMeta[sessionKey] const activeSessionMeta = sessionMeta[selectedSessionKey]
return ( return (
<div className="rh-page" data-testid="race-hub" style={accentStyle}> <div className="rh-page" data-testid="race-hub" style={accentStyle}>
@@ -194,7 +191,7 @@ export function RaceHubPage({ sessionKey }: Props) {
{switcherOpen && ( {switcherOpen && (
<WeekendSwitcher <WeekendSwitcher
currentMeetingKey={meetingKey} currentMeetingKey={meetingKey}
currentSessionKey={sessionKey} currentSessionKey={selectedSessionKey}
onClose={() => setSwitcherOpen(false)} onClose={() => setSwitcherOpen(false)}
/> />
)} )}
@@ -225,7 +222,7 @@ export function RaceHubPage({ sessionKey }: Props) {
<nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail"> <nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail">
{sessions.map((session) => { {sessions.map((session) => {
const meta = sessionMeta[session.session_key] const meta = sessionMeta[session.session_key]
const active = session.session_key === sessionKey const active = session.session_key === selectedSessionKey
return ( return (
<button <button
key={session.session_key} key={session.session_key}
@@ -278,7 +275,7 @@ export function RaceHubPage({ sessionKey }: Props) {
{formatCoverageHint(activeSessionMeta.datasets)} datasets local {formatCoverageHint(activeSessionMeta.datasets)} datasets local
</span> </span>
)} )}
<span className="rh-active-key mono">key {sessionKey}</span> <span className="rh-active-key mono">key {selectedSessionKey}</span>
</div> </div>
)} )}
@@ -314,7 +311,7 @@ export function RaceHubPage({ sessionKey }: Props) {
<span className="sec-title">Driver Compare</span> <span className="sec-title">Driver Compare</span>
</div> </div>
<CompareView <CompareView
sessionKey={sessionKey} sessionKey={selectedSessionKey}
results={data.results} results={data.results}
drivers={data.drivers} drivers={data.drivers}
/> />
@@ -368,3 +365,72 @@ export function RaceHubPage({ sessionKey }: Props) {
</div> </div>
) )
} }
function RaceHubPreSession({
session,
weekend,
now,
switcherOpen,
onToggleSwitcher,
onCloseSwitcher,
}: {
session: ContextSession
weekend?: Weekend
now: number
switcherOpen: boolean
onToggleSwitcher: () => void
onCloseSwitcher: () => void
}) {
const meeting = session.meeting
const sessions = sortSessionsByStart((weekend?.sessions ?? []).map((entry) => entry.session))
const target = new Date(session.session.date_start)
const accent = countryAccent(meeting ?? null)
const pendingLiveEvidence = target.getTime() <= now
return (
<div className="rh-page rh-empty" data-testid="race-hub-pre-session" style={{ '--gp-accent': accent } as React.CSSProperties}>
<div className="rh-topbar">
<span className="rh-topbar-label mono">box-box · race hub</span>
<span className="rh-topbar-spacer" />
<button
type="button"
className={`rh-switcher-toggle${switcherOpen ? ' active' : ''}`}
onClick={onToggleSwitcher}
aria-expanded={switcherOpen}
aria-controls="rh-weekend-switcher"
data-testid="rh-switch-weekend"
>
{switcherOpen ? 'Close' : 'Switch Weekend'}
</button>
</div>
{switcherOpen && (
<WeekendSwitcher
currentMeetingKey={meeting?.meeting_key}
currentSessionKey={session.session.session_key}
onClose={onCloseSwitcher}
/>
)}
<section className="rh-empty-band">
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
<h1 className="rh-empty-title">{meeting?.meeting_name ?? 'Next race weekend'}</h1>
<p className="rh-empty-sub">
{pendingLiveEvidence
? `${session.session.session_name} is scheduled; awaiting live timing.`
: <>{session.session.session_name} begins in <span className="mono">{formatCountdown(target, new Date(now))}</span></>}
</p>
{sessions.length > 0 && (
<div className="preview-schedule" data-testid="rh-pre-session-schedule">
{sessions.map((scheduled) => (
<div key={scheduled.session_key} className="preview-schedule-item">
<span className="preview-schedule-name">{scheduled.session_name}</span>
<span className="preview-schedule-time">{formatSessionScheduleTime(scheduled.date_start)}</span>
</div>
))}
</div>
)}
</section>
</div>
)
}

View File

@@ -89,6 +89,16 @@ a { color: inherit; text-decoration: none; }
} }
.app-nav::-webkit-scrollbar { display: none; } .app-nav::-webkit-scrollbar { display: none; }
/* The nav scrolls horizontally with its scrollbar hidden. Below the width
where the links stop fitting, fade the trailing edge so the cut-off item
reads as "scroll for more" instead of as a clipping bug. */
@media (max-width: 560px) {
.app-nav {
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent 100%);
mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent 100%);
}
}
.nav-logo { .nav-logo {
font-family: var(--f-mono); font-family: var(--f-mono);
font-size: 15px; font-size: 15px;
@@ -112,6 +122,30 @@ a { color: inherit; text-decoration: none; }
.nav-links a:hover { color: var(--text); background: var(--surface-h); } .nav-links a:hover { color: var(--text); background: var(--surface-h); }
.nav-links a.active { color: var(--text); background: var(--surface-2); } .nav-links a.active { color: var(--text); background: var(--surface-2); }
/* Live nav gets a pulsing marker only while a session is on air. */
.nav-live { display: inline-flex; align-items: center; gap: 6px; }
.nav-links a.nav-live-on { color: var(--text); font-weight: 600; }
.nav-live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--red);
box-shadow: 0 0 0 0 rgba(225, 6, 0, 0.4);
animation: pulse-live 1.6s infinite;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.nav-utility { .nav-utility {
margin-left: auto; margin-left: auto;
display: flex; display: flex;
@@ -4730,3 +4764,137 @@ a { color: inherit; text-decoration: none; }
background: #f82f34; background: #f82f34;
transform: translateY(-1px); transform: translateY(-1px);
} }
/* Weekend recut: put the active circuit and season journey ahead of data plumbing. */
.cc-context {
position: relative;
display: grid;
grid-template-columns: minmax(84px, 0.3fr) minmax(0, 1fr) auto;
gap: var(--s5);
align-items: center;
min-height: 112px;
padding: var(--s5) var(--s6);
overflow: hidden;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
background:
linear-gradient(90deg, color-mix(in srgb, var(--gp-accent) 9%, transparent), transparent 46%),
var(--surface);
}
.cc-context-decal {
color: color-mix(in srgb, var(--gp-accent) 58%, var(--text));
font-size: clamp(38px, 6vw, 72px);
font-weight: 800;
letter-spacing: -0.08em;
line-height: 0.75;
opacity: 0.72;
}
.cc-context-copy h2 {
font-size: 18px;
line-height: 1.15;
}
.cc-context-kicker {
display: block;
margin-bottom: var(--s2);
color: var(--text-3);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.cc-context-copy p {
max-width: 64ch;
margin-top: var(--s2);
color: var(--text-2);
font-size: 12px;
}
.cc-context-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--s2);
color: var(--text-3);
font-size: 10px;
text-align: right;
}
.cc-context-preview {
margin-top: var(--s2);
color: var(--text-2);
font-weight: 700;
}
.cc-context-preview:hover { color: var(--text); }
.cc-calendar-grid {
display: flex;
gap: 0;
padding: var(--s3) 0 var(--s4);
overflow-x: auto;
scrollbar-width: thin;
}
.cc-calendar-card {
min-width: 112px;
min-height: 138px;
flex: 1 0 112px;
padding: var(--s3) var(--s4);
border-radius: 0;
border-width: 1px 1px 1px 0;
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.cc-calendar-card:first-child { border-left-width: 1px; }
.cc-calendar-card:hover { transform: none; box-shadow: inset 0 -2px 0 var(--gp-card-accent); }
.cc-calendar-card::before { display: none; }
.cc-calendar-accent { top: auto; bottom: 0; height: 2px; opacity: 0.65; }
.cc-calendar-past { filter: none; opacity: 0.62; }
.cc-calendar-past:hover { filter: none; opacity: 1; }
.cc-calendar-focus { background: color-mix(in srgb, var(--gp-card-accent) 10%, transparent); }
.cc-calendar-decal { font-size: 60px; bottom: -5px; }
.cc-calendar-status {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-3);
}
.cc-calendar-status-focus { color: var(--gp-card-accent); }
.cc-calendar-status-future { color: var(--text-2); }
.cc-data-note {
display: flex;
align-items: center;
gap: var(--s2);
padding-top: var(--s4);
border-top: 1px solid var(--border);
color: var(--text-3);
font-size: 10px;
}
@media (max-width: 899px) {
.cc-dashboard-grid { gap: var(--s5); }
.cc-dashboard-sidebar { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s5); }
.cc-briefing, .cc-data-note { grid-column: 1 / -1; }
}
@media (max-width: 640px) {
.cc-context { grid-template-columns: 1fr auto; gap: var(--s4); padding: var(--s4); }
.cc-context-decal { display: none; }
.cc-context-meta { align-items: flex-start; grid-column: 1 / -1; text-align: left; }
.cc-context-copy h2 { font-size: 17px; }
.cc-context-copy p { font-size: 11px; }
.cc-dashboard-sidebar { display: flex; }
.cc-calendar-grid { margin: 0 calc(var(--s4) * -1); padding-left: var(--s4); padding-right: var(--s4); }
.cc-calendar-card { min-width: 104px; flex-basis: 104px; }
.cc-calendar-circuit { display: none; }
.cc-champ-snapshot { order: 2; }
.cc-schedule { order: 1; }
.cc-briefing { order: 3; margin-top: var(--s4); }
.cc-data-note { order: 4; }
}

View File

@@ -9,6 +9,7 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--s4); gap: var(--s4);
flex-wrap: wrap;
} }
.chapter-strip-title { .chapter-strip-title {
@@ -44,6 +45,31 @@
color: var(--text); 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 { .chapter-strip-scroll {
display: flex; display: flex;
gap: var(--s3); gap: var(--s3);
@@ -51,10 +77,26 @@
padding-bottom: var(--s2); padding-bottom: var(--s2);
scroll-snap-type: x mandatory; scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch; -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 { .chapter-card {
flex: 0 0 min(240px, 72vw); flex: 0 0 min(240px, 72vw);
min-height: 88px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--s2); gap: var(--s2);
@@ -64,7 +106,7 @@
background: var(--surface); background: var(--surface);
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
scroll-snap-align: start; scroll-snap-align: center;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
} }
@@ -145,10 +187,22 @@
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
flex: 1;
} }
.chapter-strip-empty { .chapter-strip-empty-card {
font-size: 12px; padding: var(--s6) var(--s5);
color: var(--text-3); margin: 0;
padding: var(--s3) 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 { .replay-map-panel {
min-height: 244px; min-height: 244px;
height: 100%; height: 100%;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--surface); background: var(--surface);
border-radius: var(--s2);
} }
.replay-map-stage { .replay-map-stage {
@@ -143,19 +76,16 @@
stroke-width: 1.2; stroke-width: 1.2;
} }
.replay-map-empty { .replay-map-empty-card {
min-height: 244px; min-height: 244px;
display: grid; display: grid;
place-items: center; place-content: center;
padding: var(--s5); padding: var(--s6);
color: var(--text-3); margin: 0;
font-family: var(--f-mono);
font-size: 11px;
text-align: center;
} }
@media (max-width: 860px) { .replay-map-empty-card .empty-state-icon {
.rs-replay-shell { display: block;
grid-template-columns: 1fr; margin: 0 auto var(--s3);
} color: var(--text-3);
} }

View File

@@ -68,6 +68,25 @@ describe('ChapterStrip', () => {
expect(screen.getByText('L12L15')).toBeInTheDocument() 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', () => { it('highlights the active chapter from scrub time', () => {
const scrub = (new Date('2025-05-25T13:13:00Z').getTime() - tMin) / tRange const scrub = (new Date('2025-05-25T13:13:00Z').getTime() - tMin) / tRange
renderStrip({ scrubTime: scrub }) renderStrip({ scrubTime: scrub })
@@ -84,4 +103,26 @@ describe('ChapterStrip', () => {
expect(index).toBe(1) expect(index).toBe(1)
expect(scrub).toBeCloseTo(0.6, 2) 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

@@ -189,6 +189,9 @@ describe('CommandCenterPage', () => {
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco') expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar') expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar')
expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01') expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01')
expect(screen.getByTestId('cc-circuit-context')).toHaveTextContent('Circuit context')
expect(screen.getByTestId('cc-circuit-context')).toHaveTextContent('Weekend preview')
expect(screen.getByTestId('cc-data-note')).toHaveTextContent('availability is shown per session')
expect(screen.getByText('No live session')).toBeInTheDocument() expect(screen.getByText('No live session')).toBeInTheDocument()
expect(screen.getByTestId('hero-last-race-link')).toHaveTextContent('Monaco') expect(screen.getByTestId('hero-last-race-link')).toHaveTextContent('Monaco')
}) })
@@ -238,8 +241,40 @@ describe('CommandCenterPage', () => {
}) })
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Live now') expect(screen.getByTestId('cc-focus')).toHaveTextContent('Live now')
expect(screen.getByTestId('cc-session-9602')).toHaveTextContent('On track') expect(screen.getByTestId('cc-session-9602')).toHaveTextContent('On track')
expect(screen.getByTestId('cc-calendar-1301')).toHaveTextContent('Archive')
expect(screen.getByTestId('cc-calendar-1302')).toHaveTextContent('Now')
expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live') expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live')
vi.useRealTimers() vi.useRealTimers()
}) })
it('keeps the completed local session rail available when the next weekend has no sessions', async () => {
const nextMeeting = {
...meeting,
meeting_key: 1303,
meeting_name: 'Canada',
country_name: 'Canada',
country_code: 'CAN',
circuit_short_name: 'Montreal',
date_start: '2026-06-12T00:00:00+00:00',
date_end: '2026-06-14T23:59:59+00:00',
year: 2026,
}
vi.setSystemTime(new Date('2026-06-06T15:15:00Z'))
mockFetchSeasons.mockResolvedValue([2026])
mockFetchLocalMeetings.mockResolvedValue([meeting])
mockFetchSeasonMeetings.mockResolvedValue([meeting, nextMeeting])
mockFetchWeekend.mockResolvedValue(weekend)
mockFetchSessions.mockResolvedValue([])
renderPage()
await waitFor(() => {
expect(screen.getByTestId('cc-session-9472')).toBeInTheDocument()
})
expect(screen.getByTestId('cc-schedule')).toHaveTextContent('1 sessions')
vi.useRealTimers()
})
}) })

View File

@@ -209,6 +209,32 @@ describe('TimingTower', () => {
expect(screen.getByText('DRS range')).toBeInTheDocument() expect(screen.getByText('DRS range')).toBeInTheDocument()
}) })
it('derives a gap to P1 from best laps when the practice feed omits GapToLeader', () => {
const practiceRows = [
makeRow('1', 1, 'VER', { BestLapTime: '1:45.944', GapToLeader: '' }),
makeRow('4', 2, 'NOR', { BestLapTime: '1:46.134', GapToLeader: '' }),
makeRow('16', 3, 'LEC', { BestLapTime: '', GapToLeader: '' }),
]
render(
<TimingTower
rows={practiceRows}
session={{
MeetingName: 'Belgian Grand Prix',
CircuitName: 'Spa',
SessionType: 'Practice',
SessionName: 'Practice 2',
Path: '',
}}
/>,
)
// Leader shows a clear leader marker, not a fabricated gap.
expect(screen.getByText('VER').closest('tr')).toHaveTextContent('—')
expect(screen.getByText('+0.190')).toBeInTheDocument()
// A driver without a valid best lap gets no invented gap.
const lecRow = screen.getByText('LEC').closest('tr')!
expect(lecRow).not.toHaveTextContent('+')
})
it('renders the SQ1 cutoff after P17 and marks rows below as at risk', () => { it('renders the SQ1 cutoff after P17 and marks rows below as at risk', () => {
const sprintRows = Array.from({ length: 22 }, (_, index) => const sprintRows = Array.from({ length: 22 }, (_, index) =>
makeRow(String(index + 1), index + 1, `D${index + 1}`), makeRow(String(index + 1), index + 1, `D${index + 1}`),

View File

@@ -153,6 +153,29 @@ describe('LiveTimingPage archive mode', () => {
expect(screen.queryByRole('button', { name: /view last session/i })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: /view last session/i })).not.toBeInTheDocument()
}) })
it('renders the live session for a red-flag paused snapshot instead of the inactive empty state', async () => {
renderPage({
is_live: true,
data: {
...archivedSnapshot,
SessionStatus: 'Inactive',
TrackStatus: '2',
Clock: '00:03:27',
Session: {
MeetingName: 'Belgian Grand Prix',
CircuitName: 'Spa-Francorchamps',
SessionType: 'Practice',
SessionName: 'Practice 2',
Path: '',
},
},
})
expect(await screen.findByText('Timing Tower')).toBeInTheDocument()
expect(screen.queryByTestId('live-empty')).not.toBeInTheDocument()
expect(screen.queryByTestId('live-archive-strip')).not.toBeInTheDocument()
})
it('temporarily omits the track map while live GPS is unavailable', async () => { it('temporarily omits the track map while live GPS is unavailable', async () => {
renderPage({ renderPage({
is_live: true, is_live: true,

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react' import { act, render, screen, waitFor, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { import {
Outlet, Outlet,
@@ -9,21 +9,24 @@ import {
createRoute, createRoute,
} from '@tanstack/react-router' } from '@tanstack/react-router'
import { RaceHubPage } from '../pages/RaceHubPage' import { RaceHubPage } from '../pages/RaceHubPage'
import type { DatasetInfo, Meeting, RaceHub, Session, Weekend } from '../types' import { MAX_BROWSER_TIMEOUT } from '../lib/schedule'
import type { ContextAvailability, DatasetInfo, Meeting, RaceHub, Session, Weekend, WeekendContext } from '../types'
vi.mock('../api', () => ({ vi.mock('../api', () => ({
fetchRaceHub: vi.fn(), fetchRaceHub: vi.fn(),
fetchSeasons: vi.fn(), fetchSeasons: vi.fn(),
fetchLocalMeetings: vi.fn(), fetchLocalMeetings: vi.fn(),
fetchWeekend: vi.fn(), fetchWeekend: vi.fn(),
fetchWeekendContext: vi.fn(),
})) }))
import { fetchRaceHub, fetchSeasons, fetchLocalMeetings, fetchWeekend } from '../api' import { fetchLocalMeetings, fetchRaceHub, fetchSeasons, fetchWeekend, fetchWeekendContext } from '../api'
const mockFetchRaceHub = vi.mocked(fetchRaceHub) const mockFetchRaceHub = vi.mocked(fetchRaceHub)
const mockFetchSeasons = vi.mocked(fetchSeasons) const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings) const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
const mockFetchWeekend = vi.mocked(fetchWeekend) const mockFetchWeekend = vi.mocked(fetchWeekend)
const mockFetchWeekendContext = vi.mocked(fetchWeekendContext)
const meeting: Meeting = { const meeting: Meeting = {
meeting_key: 1229, meeting_key: 1229,
@@ -169,6 +172,26 @@ const weekend: Weekend = {
], ],
} }
const availability: ContextAvailability = {
source: 'local',
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'complete',
freshness: 'local',
limitations: [],
}
const analysisContext: WeekendContext = {
temporal_state: 'between_weekends',
default_analysis_session: { session: raceSession, meeting, availability },
race_hub_default_session: { session: raceSession, meeting, availability },
race_hub_pre_session: false,
championship_round: 1,
total_championship_rounds: 24,
}
function renderRaceHub(sessionKey: number) { function renderRaceHub(sessionKey: number) {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }, defaultOptions: { queries: { retry: false } },
@@ -193,23 +216,28 @@ function renderRaceHub(sessionKey: number) {
return <RaceHubPage sessionKey={session_key ?? 0} /> return <RaceHubPage sessionKey={session_key ?? 0} />
}, },
}) })
window.history.pushState({}, '', sessionKey ? `/race-hub?session_key=${sessionKey}` : '/race-hub')
const router = createRouter({ const router = createRouter({
routeTree: rootRoute.addChildren([raceHubRoute]), routeTree: rootRoute.addChildren([raceHubRoute]),
history: undefined, history: undefined,
}) })
// Navigate to the URL before mounting return { queryClient, ...render(<RouterProvider router={router} />) }
router.navigate({ to: '/race-hub', search: sessionKey ? { session_key: sessionKey } : {} })
return render(<RouterProvider router={router} />)
} }
describe('RaceHubPage', () => { describe('RaceHubPage', () => {
beforeEach(() => { beforeEach(() => {
vi.useRealTimers()
vi.clearAllMocks() vi.clearAllMocks()
mockFetchSeasons.mockResolvedValue([2025]) mockFetchSeasons.mockResolvedValue([2025])
mockFetchLocalMeetings.mockResolvedValue([meeting]) mockFetchLocalMeetings.mockResolvedValue([meeting])
mockFetchWeekend.mockResolvedValue(weekend) mockFetchWeekend.mockResolvedValue(weekend)
mockFetchRaceHub.mockResolvedValue(raceHub) mockFetchRaceHub.mockResolvedValue(raceHub)
mockFetchWeekendContext.mockResolvedValue(analysisContext)
})
afterEach(() => {
vi.useRealTimers()
}) })
it('renders the workspace identity band, session rail, and overview for a known session', async () => { it('renders the workspace identity band, session rail, and overview for a known session', async () => {
@@ -256,4 +284,122 @@ describe('RaceHubPage', () => {
fireEvent.click(screen.getByTestId('rh-switch-weekend')) fireEvent.click(screen.getByTestId('rh-switch-weekend'))
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument() expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
}) })
it('uses the server-selected completed analysis session for bare Race Hub without changing the URL', async () => {
renderRaceHub(0)
await waitFor(() => expect(mockFetchRaceHub).toHaveBeenCalledWith(9472))
expect(mockFetchWeekendContext).toHaveBeenCalledTimes(1)
})
it('renders the intentional pre-session state without analysis cards', async () => {
mockFetchWeekendContext.mockResolvedValue({
...analysisContext,
race_hub_default_session: {
session: { ...raceSession, session_key: 9473, session_name: 'Practice 1', session_type: 'Practice', date_start: '2099-05-23T13:00:00Z' },
meeting,
availability,
},
race_hub_pre_session: true,
race_hub_refresh_at: '2099-05-23T13:00:00Z',
})
renderRaceHub(0)
expect(await screen.findByTestId('race-hub-pre-session')).toBeInTheDocument()
expect(screen.getByTestId('rh-pre-session-schedule')).toBeInTheDocument()
expect(screen.queryByText('Winner')).not.toBeInTheDocument()
expect(mockFetchRaceHub).not.toHaveBeenCalled()
})
it('opens the weekend switcher from pre-session and navigates to the selected explicit session', async () => {
mockFetchWeekendContext.mockResolvedValue({
...analysisContext,
race_hub_default_session: {
session: { ...raceSession, session_key: 9473, session_name: 'Practice 1', session_type: 'Practice', date_start: '2099-05-23T13:00:00Z' },
meeting,
availability,
},
race_hub_pre_session: true,
race_hub_refresh_at: '2099-05-23T13:00:00Z',
})
renderRaceHub(0)
const switchWeekend = await screen.findByTestId('rh-switch-weekend')
expect(switchWeekend).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(switchWeekend)
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
expect(switchWeekend).toHaveAttribute('aria-expanded', 'true')
fireEvent.click(await screen.findByTestId('rh-switcher-session-9471'))
await waitFor(() => expect(window.location.search).toBe('?session_key=9471'))
})
it('shows recovery instead of selecting an empty future session', async () => {
mockFetchWeekendContext.mockResolvedValue({
...analysisContext,
race_hub_default_session: undefined,
race_hub_pre_session: false,
})
renderRaceHub(0)
expect(await screen.findByTestId('race-hub-empty')).toHaveTextContent('No completed local analysis yet')
expect(mockFetchRaceHub).not.toHaveBeenCalled()
})
it('hands a bare route from completed analysis to pre-session at the supplied refresh boundary', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
const handoff = new Date(Date.now() + 10_000).toISOString()
const pendingContext: WeekendContext = {
...analysisContext,
race_hub_default_session: {
session: { ...raceSession, session_key: 9473, session_name: 'Practice 1', session_type: 'Practice', date_start: handoff },
meeting,
availability,
},
race_hub_pre_session: true,
race_hub_refresh_at: new Date(Date.now() + 16_000).toISOString(),
}
mockFetchWeekendContext
.mockResolvedValueOnce({ ...analysisContext, race_hub_refresh_at: handoff })
.mockResolvedValueOnce(pendingContext)
renderRaceHub(0)
await screen.findByTestId('race-hub')
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
expect(await screen.findByTestId('race-hub-pre-session')).toBeInTheDocument()
expect(mockFetchWeekendContext).toHaveBeenCalledTimes(2)
expect(mockFetchRaceHub).toHaveBeenCalledWith(9472)
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9473)
})
it('re-arms a bare route refresh after a capped browser timer', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
mockFetchWeekendContext.mockResolvedValue({
...analysisContext,
race_hub_refresh_at: new Date(Date.now() + MAX_BROWSER_TIMEOUT + 1_000).toISOString(),
})
renderRaceHub(0)
await screen.findByTestId('race-hub')
await act(async () => { await vi.advanceTimersByTimeAsync(MAX_BROWSER_TIMEOUT) })
await waitFor(() => expect(mockFetchWeekendContext).toHaveBeenCalledTimes(2))
await act(async () => { await vi.advanceTimersByTimeAsync(1_000) })
await waitFor(() => expect(mockFetchWeekendContext).toHaveBeenCalledTimes(3))
})
it('keeps an explicit session URL stable across the canonical refresh boundary', async () => {
vi.useFakeTimers()
renderRaceHub(9472)
await act(async () => { await vi.advanceTimersByTimeAsync(60_000) })
expect(mockFetchWeekendContext).not.toHaveBeenCalled()
expect(mockFetchRaceHub).toHaveBeenCalledWith(9472)
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9473)
})
}) })

View File

@@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { RaceStoryCanvas } from '../components/RaceStoryCanvas' import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
import type { RaceHub, ReplayFramesResponse, TrackOutline } from '../types' import type { Chapter, RaceHub, ReplayFramesResponse, TrackOutline } from '../types'
vi.mock('../api', () => ({ vi.mock('../api', () => ({
fetchReplayFrames: vi.fn(), fetchReplayFrames: vi.fn(),
@@ -28,9 +28,35 @@ const replay: ReplayFramesResponse = {
session_key: 99, session_key: 99,
interval_ms: 5000, interval_ms: 5000,
start_time: '2025-05-25T13:00:00Z', 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 = { const raceHub: RaceHub = {
source: 'local', source: 'local',
session_key: 99, session_key: 99,
@@ -103,14 +129,14 @@ const raceHub: RaceHub = {
race_control: [], race_control: [],
weather: [], weather: [],
laps: [], laps: [],
chapters: [], chapters,
} }
function renderCanvas() { function renderCanvas(overrides: Partial<RaceHub> = {}) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render( return render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<RaceStoryCanvas data={raceHub} /> <RaceStoryCanvas data={{ ...raceHub, ...overrides }} />
</QueryClientProvider>, </QueryClientProvider>,
) )
} }
@@ -122,14 +148,144 @@ describe('RaceStoryCanvas replay map', () => {
mockFetchTrackOutline.mockResolvedValue(outline) 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() renderCanvas()
expect(mockFetchReplayFrames).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Map' }))
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalledWith(99, 5000)) await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalledWith(99, 5000))
expect(mockFetchTrackOutline).toHaveBeenCalledWith(1, 2025) 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(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', () => { 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} />) 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', () => { it('renders car labels from result metadata', () => {

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import { SessionBanner } from '../components/live/SessionBanner'
import type { LiveStreamData } from '../types'
function makeSnapshot(sessionType: string, sessionName: string): LiveStreamData {
return {
Drivers: {},
DriverInfo: {},
Tyres: {},
RCMessages: [],
Weather: { AirTemp: 0, TrackTemp: 0, Humidity: 0, WindSpeed: 0, WindDir: 0, Rainfall: false },
Session: {
MeetingName: 'Belgian Grand Prix',
CircuitName: 'Spa-Francorchamps',
SessionType: sessionType,
SessionName: sessionName,
Path: '',
},
TeamRadio: [],
TrackStatus: '1',
CurrentLap: 0,
TotalLaps: 0,
Clock: '00:45:00',
ClockRefTime: '',
ClockExtrapolating: false,
Stints: {},
}
}
describe('SessionBanner', () => {
it('never renders a race lap counter for a practice session', () => {
render(
<SessionBanner
isLive
snapshot={makeSnapshot('Practice', 'Practice 2')}
rows={[]}
connection="connected"
now={0}
/>,
)
expect(screen.queryByTestId('live-lap-counter')).not.toBeInTheDocument()
// Session identity and clock stay intact.
expect(screen.getByText('Belgian Grand Prix')).toBeInTheDocument()
expect(screen.getByTestId('live-clock')).toHaveTextContent('00:45:00')
})
it('shows the lap counter for a race session', () => {
const snapshot = { ...makeSnapshot('Race', 'Race'), CurrentLap: 12, TotalLaps: 44 }
render(
<SessionBanner
isLive
snapshot={snapshot}
rows={[]}
connection="connected"
now={0}
/>,
)
expect(screen.getByTestId('live-lap-counter')).toHaveTextContent('L12/44')
})
})

View File

@@ -47,9 +47,20 @@ function snapshotRows(lap: number, lastLapTime: string): LiveTimingRow[] {
} }
describe('TyreDegPanel', () => { describe('TyreDegPanel', () => {
it('shows a warming-up placeholder until enough clean laps accumulate', () => { it('stays collapsed while every stint is still warming up', () => {
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />) render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
const panel = screen.getByTestId('tyredeg-panel') const panel = screen.getByTestId('tyredeg-panel')
// A panel of "warming up" placeholders carries no information and used to
// push the Timing Tower off the fold for the first third of a race.
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
expect(panel).toHaveTextContent('collecting clean laps')
})
it('shows a warming-up placeholder on each row once expanded', () => {
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
const panel = screen.getByTestId('tyredeg-panel')
expect(panel).toHaveTextContent('VER') expect(panel).toHaveTextContent('VER')
expect(panel).toHaveTextContent('M +5') expect(panel).toHaveTextContent('M +5')
expect(panel).toHaveTextContent('fresh') expect(panel).toHaveTextContent('fresh')
@@ -61,6 +72,7 @@ describe('TyreDegPanel', () => {
makeRow('1', 1, 'VER', { NumberOfLaps: 10, LastLapTime: '1:30.000' }, { Compound: 'MEDIUM', Age: 12 }), makeRow('1', 1, 'VER', { NumberOfLaps: 10, LastLapTime: '1:30.000' }, { Compound: 'MEDIUM', Age: 12 }),
] ]
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={[]} />) render(<TyreDegPanel rows={rows} sessionType="Race" pinned={[]} />)
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
expect(screen.getByText('mid-life')).toBeInTheDocument() expect(screen.getByText('mid-life')).toBeInTheDocument()
}) })
@@ -81,13 +93,46 @@ describe('TyreDegPanel', () => {
expect(panel).toHaveTextContent('→ ~P2') expect(panel).toHaveTextContent('→ ~P2')
}) })
it('hides the rejoin estimate outside race sessions and collapses on toggle', () => { it('collapses by default outside race sessions and hides the rejoin estimate when expanded', () => {
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Qualifying" pinned={[]} />) render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Qualifying" pinned={[]} />)
const panel = screen.getByTestId('tyredeg-panel') const panel = screen.getByTestId('tyredeg-panel')
// Practice/qualifying starts collapsed so the Timing Tower stays above the fold.
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
expect(panel).not.toHaveTextContent('~P') expect(panel).not.toHaveTextContent('~P')
})
it('opens itself during a race as soon as a stint has signal', () => {
const { rerender } = render(
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
)
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
for (let lap = 2; lap <= 6; lap++) {
const time = `1:30.${String((lap - 1) * 100).padStart(3, '0')}`
rerender(<TyreDegPanel rows={snapshotRows(lap, time)} sessionType="Race" pinned={[]} />)
}
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2) expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i })) })
it('keeps the reader\'s own collapse choice when signal arrives', () => {
const { rerender } = render(
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
)
// Reader opens it early, then closes it again — that decision must stick
// even once the panel would otherwise auto-open.
const toggle = screen.getByRole('button', { name: /tyre deg/i })
fireEvent.click(toggle)
fireEvent.click(toggle)
for (let lap = 2; lap <= 6; lap++) {
const time = `1:30.${String((lap - 1) * 100).padStart(3, '0')}`
rerender(<TyreDegPanel rows={snapshotRows(lap, time)} sessionType="Race" pinned={[]} />)
}
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0) expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
}) })
@@ -96,6 +141,7 @@ describe('TyreDegPanel', () => {
makeRow(String(index + 1), index + 1, `D${index + 1}`), makeRow(String(index + 1), index + 1, `D${index + 1}`),
) )
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={['14']} />) render(<TyreDegPanel rows={rows} sessionType="Race" pinned={['14']} />)
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(11) expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(11)
expect(screen.getByText('D14')).toBeInTheDocument() expect(screen.getByText('D14')).toBeInTheDocument()
expect(screen.queryByText('D12')).not.toBeInTheDocument() expect(screen.queryByText('D12')).not.toBeInTheDocument()

View File

@@ -1,10 +1,13 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
activeChapterIndex, activeChapterIndex,
chapterBandFill,
chapterKindLabel, chapterKindLabel,
chapterLapRange, chapterLapRange,
chapterStartScrub, chapterStartScrub,
chapterTourDurations, chapterTourDurations,
deCollideYPositions,
decimatedPositionLabels,
} from '../lib/chapters' } from '../lib/chapters'
import type { Chapter } from '../types' import type { Chapter } from '../types'
@@ -57,7 +60,65 @@ describe('chapters lib', () => {
expect(activeChapterIndex(sampleChapters, scrub, tMin, tRange)).toBe(1) 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', () => { it('splits 90s evenly across chapters', () => {
expect(chapterTourDurations(sampleChapters)).toEqual([45_000, 45_000]) 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

@@ -1,12 +1,15 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
bestLapGaps,
compoundClass, compoundClass,
compoundLetter, compoundLetter,
extrapolateClock, extrapolateClock,
isLiveSessionActive,
latestRaceControl, latestRaceControl,
liveSessionDisplay, liveSessionDisplay,
loadPinnedDrivers, loadPinnedDrivers,
mergeVisibleSectors, mergeVisibleSectors,
parseLapTimeSeconds,
positionDeltaClass, positionDeltaClass,
parseLiveStateEvent, parseLiveStateEvent,
rcFlagClass, rcFlagClass,
@@ -279,6 +282,43 @@ describe('live qualifying display', () => {
}) })
}) })
describe('practice/qualifying computed gaps', () => {
it('parses lap-time strings into seconds and rejects invalid input', () => {
expect(parseLapTimeSeconds('1:45.944')).toBeCloseTo(105.944, 3)
expect(parseLapTimeSeconds('45.944')).toBeCloseTo(45.944, 3)
expect(parseLapTimeSeconds('')).toBeNull()
expect(parseLapTimeSeconds(undefined)).toBeNull()
expect(parseLapTimeSeconds('-')).toBeNull()
expect(parseLapTimeSeconds('nope')).toBeNull()
})
it('derives a gap to P1 from valid best laps only, flagging the leader', () => {
const gaps = bestLapGaps([
timingRow('1', 1, { BestLapTime: '1:45.944' }),
timingRow('4', 2, { BestLapTime: '1:46.134' }),
timingRow('16', 3, { BestLapTime: '' }),
])
expect(gaps['1']).toEqual({ isLeader: true, gap: '' })
expect(gaps['4']).toEqual({ isLeader: false, gap: '+0.190' })
// No valid best lap → no fabricated gap.
expect(gaps['16']).toBeUndefined()
})
it('returns no gaps when nobody has set a lap', () => {
expect(bestLapGaps([timingRow('1', 1, { BestLapTime: '' })])).toEqual({})
})
})
describe('live session activity', () => {
it('is active only when the feed reports a live session with data', () => {
expect(isLiveSessionActive({ is_live: true, data: snapshot })).toBe(true)
expect(isLiveSessionActive({ is_live: true, data: null })).toBe(false)
expect(isLiveSessionActive({ is_live: false, data: snapshot })).toBe(false)
expect(isLiveSessionActive(null)).toBe(false)
expect(isLiveSessionActive(undefined)).toBe(false)
})
})
describe('visible sector display', () => { describe('visible sector display', () => {
it('holds S1 and S2 through temporary blanks while a flying lap is active', () => { it('holds S1 and S2 through temporary blanks while a flying lap is active', () => {
const first = [timingRow('4', 1, { const first = [timingRow('4', 1, {

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

@@ -5,8 +5,10 @@ import {
focusMeetingKind, focusMeetingKind,
focusMeetingLabel, focusMeetingLabel,
formatCountdown, formatCountdown,
MAX_BROWSER_TIMEOUT,
nextUpcomingMeeting, nextUpcomingMeeting,
pickFocusMeeting, pickFocusMeeting,
refreshDeadlineDelay,
} from '../lib/schedule' } from '../lib/schedule'
import type { Meeting, Session } from '../types' import type { Meeting, Session } from '../types'
@@ -78,4 +80,15 @@ describe('schedule helpers', () => {
const target = new Date('2025-05-25T13:00:00+00:00') const target = new Date('2025-05-25T13:00:00+00:00')
expect(formatCountdown(target, now)).toBe('0d 01h 00m 00s') expect(formatCountdown(target, now)).toBe('0d 01h 00m 00s')
}) })
it('uses the server refresh deadline without local timezone conversion', () => {
expect(refreshDeadlineDelay('2025-05-25T13:00:00Z', Date.parse('2025-05-25T12:59:30Z'))).toBe(30_000)
expect(refreshDeadlineDelay(undefined)).toBeNull()
})
it('caps a refresh deadline beyond the browser timer maximum', () => {
const now = Date.parse('2025-05-25T12:00:00Z')
const deadline = new Date(now + MAX_BROWSER_TIMEOUT + 1_000).toISOString()
expect(refreshDeadlineDelay(deadline, now)).toBe(MAX_BROWSER_TIMEOUT)
})
}) })

View File

@@ -217,6 +217,41 @@ export interface Weekend {
default_session_key?: number default_session_key?: number
} }
export interface ContextAvailability {
source: string
schedule: string
live_transport: string
live_session: string
archive: string
local_analysis: string
freshness: string
observed_at?: string
limitations: string[]
}
export interface ContextSession {
session: Session
meeting?: Meeting
availability: ContextAvailability
}
export interface WeekendContext {
season?: number
temporal_state: string
previous_meeting?: Meeting
focus_meeting?: Meeting
next_meeting?: Meeting
previous_completed_session?: ContextSession
active_session?: ContextSession
next_session?: ContextSession
default_analysis_session?: ContextSession
race_hub_default_session?: ContextSession
race_hub_pre_session: boolean
race_hub_refresh_at?: string
championship_round: number
total_championship_rounds: number
}
export interface LiveStateResponse { export interface LiveStateResponse {
is_live: boolean is_live: boolean
data: LiveStreamData | null data: LiveStreamData | null

View File

@@ -164,8 +164,8 @@ func (c *Cache) Get(key string) ([]byte, bool) {
if ttl > 0 { if ttl > 0 {
age := time.Since(time.Unix(createdAt, 0)) age := time.Since(time.Unix(createdAt, 0))
if age > ttl { if age > ttl {
// Expired — delete and return miss. // Expired entries remain stored so get() can use them as a stale
_, _ = c.db.Exec(`DELETE FROM cache WHERE key = ?`, key) // fallback if the live request fails. Prune() owns physical cleanup.
atomic.AddInt64(&c.stats.Misses, 1) atomic.AddInt64(&c.stats.Misses, 1)
return nil, false return nil, false
} }

View File

@@ -1,6 +1,7 @@
package api package api
import ( import (
"context"
"net/http" "net/http"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -26,8 +27,12 @@ type requestPacer struct {
// wait blocks until this caller's reserved slot arrives. // wait blocks until this caller's reserved slot arrives.
func (p *requestPacer) wait() { func (p *requestPacer) wait() {
_ = p.waitContext(context.Background())
}
func (p *requestPacer) waitContext(ctx context.Context) error {
if p == nil || p.interval <= 0 { if p == nil || p.interval <= 0 {
return return nil
} }
p.mu.Lock() p.mu.Lock()
now := time.Now() now := time.Now()
@@ -38,8 +43,18 @@ func (p *requestPacer) wait() {
p.next = p.next.Add(p.interval) p.next = p.next.Add(p.interval)
p.mu.Unlock() p.mu.Unlock()
if sleep > 0 { if sleep > 0 {
time.Sleep(sleep) timer := time.NewTimer(sleep)
defer timer.Stop()
select {
case <-timer.C:
case <-ctx.Done():
// Keep the unused reservation in the schedule. Blindly reclaiming an
// interval can collide with later callers that already reserved their
// wake times, releasing two requests simultaneously.
return ctx.Err()
}
} }
return nil
} }
type OpenF1Client struct { type OpenF1Client struct {
@@ -56,6 +71,26 @@ type OpenF1Client struct {
staleFlag int32 staleFlag int32
} }
// Scoped returns a lightweight request-scoped view of the client. Network,
// pacing and cache resources are shared, while the stale fallback indicator is
// deliberately not shared. Web handlers use this view so a stale fallback in
// one concurrent HTTP request can never mark an unrelated response as stale.
//
// The legacy client-wide stale flag remains available for the TUI, whose loads
// are intentionally aggregated into one navigation-level notice.
func (c *OpenF1Client) Scoped() *OpenF1Client {
if c == nil {
return nil
}
return &OpenF1Client{
url: c.url,
apiKey: c.apiKey,
httpClient: c.httpClient,
cache: c.cache,
pacer: c.pacer,
}
}
func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client { func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client {
return &OpenF1Client{ return &OpenF1Client{
url: url, url: url,

View File

@@ -0,0 +1,123 @@
package api
import (
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
func expireCacheEntry(t *testing.T, c *OpenF1Client, key string) {
t.Helper()
if _, err := c.cache.db.Exec(`UPDATE cache SET created_at = ? WHERE key = ?`, time.Now().Add(-48*time.Hour).Unix(), key); err != nil {
t.Fatal(err)
}
}
func TestScopedClientReportsStaleFallbackWithoutMutatingParent(t *testing.T) {
year := time.Now().Year()
var fail atomic.Bool
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if fail.Load() {
http.Error(w, "unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `[{
"meeting_key": 1,
"meeting_name": "British Grand Prix",
"year": %d
}]`, year)
}))
defer upstream.Close()
client := NewOpenF1Client(upstream.URL, time.Second)
defer client.Close()
client.pacer.interval = 0
key := fmt.Sprintf("%s/v1/meetings?year=%d", upstream.URL, year)
_, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key = ?`, key)
defer func() { _, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key = ?`, key) }()
if _, err := client.GetMeetingsForYear(year); err != nil {
t.Fatalf("prime cache: %v", err)
}
expireCacheEntry(t, client, key)
fail.Store(true)
scoped := client.Scoped()
meetings, err := scoped.GetMeetingsForYear(year)
if err != nil || len(meetings) != 1 {
t.Fatalf("stale fallback = (%+v, %v)", meetings, err)
}
if !scoped.LastResponseWasStale() {
t.Fatal("scoped request did not report its stale fallback")
}
if client.LastResponseWasStale() {
t.Fatal("request-scoped fallback leaked into the parent client")
}
}
func TestScopedClientsDoNotLeakFreshnessAcrossConcurrentRequests(t *testing.T) {
year := time.Now().Year()
staleStarted := make(chan struct{})
releaseStale := make(chan struct{})
var failMeetings atomic.Bool
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/meetings":
if failMeetings.Load() {
close(staleStarted)
<-releaseStale
http.Error(w, "unavailable", http.StatusServiceUnavailable)
return
}
_, _ = fmt.Fprintf(w, `[{"meeting_key":1,"meeting_name":"British Grand Prix","year":%d}]`, year)
case "/v1/sessions":
_, _ = w.Write([]byte(`[{"session_key":11,"meeting_key":2,"session_name":"Race"}]`))
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
client := NewOpenF1Client(upstream.URL, 2*time.Second)
defer client.Close()
client.pacer.interval = 0
meetingKey := fmt.Sprintf("%s/v1/meetings?year=%d", upstream.URL, year)
sessionKey := upstream.URL + "/v1/sessions?meeting_key=2"
_, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key IN (?, ?)`, meetingKey, sessionKey)
defer func() { _, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key IN (?, ?)`, meetingKey, sessionKey) }()
if _, err := client.GetMeetingsForYear(year); err != nil {
t.Fatal(err)
}
expireCacheEntry(t, client, meetingKey)
failMeetings.Store(true)
staleClient := client.Scoped()
staleDone := make(chan error, 1)
go func() {
_, err := staleClient.GetMeetingsForYear(year)
staleDone <- err
}()
<-staleStarted
freshClient := client.Scoped()
if _, err := freshClient.GetSessionsForMeeting(2); err != nil {
t.Fatalf("fresh concurrent request: %v", err)
}
if freshClient.LastResponseWasStale() {
t.Fatal("fresh request inherited concurrent request's stale state")
}
close(releaseStale)
if err := <-staleDone; err != nil {
t.Fatalf("stale request: %v", err)
}
if !staleClient.LastResponseWasStale() {
t.Fatal("stale request lost its own freshness state")
}
if freshClient.LastResponseWasStale() || client.LastResponseWasStale() {
t.Fatal("stale state leaked after concurrent requests completed")
}
}

View File

@@ -2,6 +2,7 @@ package api
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -59,8 +60,14 @@ func retryAfter429(resp *http.Response) time.Duration {
// Without this, concurrent fan-outs (championship hub, track prefetch) burst // Without this, concurrent fan-outs (championship hub, track prefetch) burst
// past the free-tier limit and callers silently treat 429s as missing data. // past the free-tier limit and callers silently treat 429s as missing data.
func (c *OpenF1Client) doPaced(req *http.Request) (*http.Response, error) { func (c *OpenF1Client) doPaced(req *http.Request) (*http.Response, error) {
return c.doPacedContext(req.Context(), req)
}
func (c *OpenF1Client) doPacedContext(ctx context.Context, req *http.Request) (*http.Response, error) {
for attempt := 0; ; attempt++ { for attempt := 0; ; attempt++ {
c.pacer.wait() if err := c.pacer.waitContext(ctx); err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -70,7 +77,14 @@ func (c *OpenF1Client) doPaced(req *http.Request) (*http.Response, error) {
} }
delay := retryAfter429(resp) delay := retryAfter429(resp)
resp.Body.Close() resp.Body.Close()
time.Sleep(delay) timer := time.NewTimer(delay)
select {
case <-timer.C:
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
}
timer.Stop()
} }
} }
@@ -83,13 +97,23 @@ func (c *OpenF1Client) doPaced(req *http.Request) (*http.Response, error) {
// entry for this URL, that stale entry is returned instead of propagating the // entry for this URL, that stale entry is returned instead of propagating the
// error. The client's staleFlag is set so the UI can show a disclaimer. // error. The client's staleFlag is set so the UI can show a disclaimer.
func (c *OpenF1Client) get(url string) (io.ReadCloser, error) { func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
return c.getContext(context.Background(), url)
}
// getContext is the cancellable form used by bounded optional web enrichment.
// A caller cancellation never falls back to stale data: the work is no longer
// relevant to that response and must stop instead of continuing in background.
func (c *OpenF1Client) getContext(ctx context.Context, url string) (io.ReadCloser, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
// 1. Check the cache for a fresh (non-expired) entry. // 1. Check the cache for a fresh (non-expired) entry.
if cachedData, ok := c.cache.Get(url); ok { if cachedData, ok := c.cache.Get(url); ok {
return io.NopCloser(bytes.NewReader(cachedData)), nil return io.NopCloser(bytes.NewReader(cachedData)), nil
} }
// 2. Attempt a live network request. // 2. Attempt a live network request.
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { if err != nil {
// Even a request-construction failure warrants a stale fallback. // Even a request-construction failure warrants a stale fallback.
return c.tryStale(url, err) return c.tryStale(url, err)
@@ -98,8 +122,11 @@ func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("Authorization", "Bearer "+c.apiKey)
} }
resp, err := c.doPaced(req) resp, err := c.doPacedContext(ctx, req)
if err != nil { if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
return c.tryStale(url, err) return c.tryStale(url, err)
} }
defer resp.Body.Close() defer resp.Body.Close()
@@ -128,6 +155,9 @@ func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
// 3. Success — read the body, store in cache, return. // 3. Success — read the body, store in cache, return.
data, err := io.ReadAll(resp.Body) data, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
return c.tryStale(url, err) return c.tryStale(url, err)
} }
@@ -245,7 +275,13 @@ func (c *OpenF1Client) GetDriversForSession(sessionKey int) ([]models.Driver, er
} }
func (c *OpenF1Client) GetDriver(sessionKey, driverNumber int) (*models.Driver, error) { func (c *OpenF1Client) GetDriver(sessionKey, driverNumber int) (*models.Driver, error) {
body, err := c.get(fmt.Sprintf("%s/v1/drivers?session_key=%d&driver_number=%d", c.url, sessionKey, driverNumber)) return c.GetDriverContext(context.Background(), sessionKey, driverNumber)
}
// GetDriverContext is a cancellable single-driver lookup for optional bounded
// enrichment. Other public methods retain their existing background semantics.
func (c *OpenF1Client) GetDriverContext(ctx context.Context, sessionKey, driverNumber int) (*models.Driver, error) {
body, err := c.getContext(ctx, fmt.Sprintf("%s/v1/drivers?session_key=%d&driver_number=%d", c.url, sessionKey, driverNumber))
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -1,7 +1,9 @@
package api package api
import ( import (
"context"
"encoding/json" "encoding/json"
"errors"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -49,6 +51,70 @@ func TestRequestPacerNilSafe(t *testing.T) {
p.wait() // must not panic p.wait() // must not panic
} }
func TestRequestPacerCancellationDoesNotCollideReservedWaiters(t *testing.T) {
const interval = 80 * time.Millisecond
p := &requestPacer{interval: interval}
if err := p.waitContext(context.Background()); err != nil {
t.Fatal(err)
}
p.mu.Lock()
initialNext := p.next
p.mu.Unlock()
waitForReservation := func(want time.Time) {
t.Helper()
deadline := time.Now().Add(250 * time.Millisecond)
for time.Now().Before(deadline) {
p.mu.Lock()
got := p.next
p.mu.Unlock()
if got.Equal(want) {
return
}
time.Sleep(time.Millisecond)
}
t.Fatalf("reservation did not reach %v", want)
}
ctxB, cancelB := context.WithCancel(context.Background())
bDone := make(chan error, 1)
go func() { bDone <- p.waitContext(ctxB) }()
waitForReservation(initialNext.Add(interval))
cDone := make(chan time.Time, 1)
go func() {
_ = p.waitContext(context.Background())
cDone <- time.Now()
}()
waitForReservation(initialNext.Add(2 * interval))
cancelStarted := time.Now()
cancelB()
select {
case err := <-bDone:
if !errors.Is(err, context.Canceled) {
t.Fatalf("B error = %v, want context.Canceled", err)
}
if elapsed := time.Since(cancelStarted); elapsed > 30*time.Millisecond {
t.Fatalf("B cancellation took %v", elapsed)
}
case <-time.After(50 * time.Millisecond):
t.Fatal("B did not return promptly after cancellation")
}
dDone := make(chan time.Time, 1)
go func() {
_ = p.waitContext(context.Background())
dDone <- time.Now()
}()
waitForReservation(initialNext.Add(3 * interval))
cAt, dAt := <-cDone, <-dDone
if separation := dAt.Sub(cAt); separation < interval/2 {
t.Fatalf("C and D collided: wake separation %v, want at least %v", separation, interval/2)
}
}
func TestGetRetriesOn429(t *testing.T) { func TestGetRetriesOn429(t *testing.T) {
var calls atomic.Int32 var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@@ -307,6 +307,28 @@ func TestSessionStatusIsActive(t *testing.T) {
} }
} }
func TestSessionStatusIsTerminal(t *testing.T) {
tests := []struct {
status string
want bool
}{
{"Finished", true},
{"Finalised", true},
{"Finalized", true},
{"Ends", true},
{"Aborted", true},
{"Started", false},
{"Resumed", false},
{"Inactive", false},
{"", false},
}
for _, tt := range tests {
if got := live.SessionStatusIsTerminal(tt.status); got != tt.want {
t.Errorf("SessionStatusIsTerminal(%q) = %v, want %v", tt.status, got, tt.want)
}
}
}
func TestProcessTopicRaceControlMessages(t *testing.T) { func TestProcessTopicRaceControlMessages(t *testing.T) {
state := live.NewState() state := live.NewState()
data := json.RawMessage(`{ data := json.RawMessage(`{
@@ -450,6 +472,62 @@ func TestProcessTopicTimingAppData(t *testing.T) {
} }
} }
// The feed sends stints as sparse deltas keyed by stint index. Replacing the
// slice on each delta collapsed pit history to one entry and pinned tyre age
// near zero — observed live at lap 49 of a 70-lap race, where every driver
// reported a single stint of age 0 despite having pitted.
func TestProcessTopicTimingAppDataMergesSparseStintDeltas(t *testing.T) {
state := live.NewState()
state.ProcessTopic("TimingAppData", json.RawMessage(`{
"Lines": {"4": {"Stints": {"0": {"Compound": "MEDIUM", "New": "true", "TotalLaps": 0}}}}
}`))
// Stint 0 runs to 18 laps, then the driver pits onto a new hard.
state.ProcessTopic("TimingAppData", json.RawMessage(`{
"Lines": {"4": {"Stints": {"0": {"TotalLaps": 18}}}}
}`))
state.ProcessTopic("TimingAppData", json.RawMessage(`{
"Lines": {"4": {"Stints": {"1": {"Compound": "HARD", "New": "true", "TotalLaps": 0}}}}
}`))
state.ProcessTopic("TimingAppData", json.RawMessage(`{
"Lines": {"4": {"Stints": {"1": {"TotalLaps": 12}}}}
}`))
snap := state.Snapshot()
stints := snap.Stints["4"]
if len(stints) != 2 {
t.Fatalf("expected 2 stints after a pit stop, got %d: %+v", len(stints), stints)
}
if stints[0].Compound != "MEDIUM" || stints[0].Laps != 18 {
t.Errorf("first stint lost across deltas: %+v", stints[0])
}
if stints[1].Compound != "HARD" || stints[1].Laps != 12 {
t.Errorf("second stint = %+v", stints[1])
}
if tyre := snap.Tyres["4"]; tyre.Compound != "HARD" || tyre.Age != 12 {
t.Errorf("current tyre should track the latest stint, got %+v", tyre)
}
}
func TestProcessTopicTimingAppDataIgnoresNonNumericStintKeys(t *testing.T) {
state := live.NewState()
state.ProcessTopic("TimingAppData", json.RawMessage(`{
"Lines": {"4": {"Stints": {"0": {"Compound": "SOFT", "New": "true", "TotalLaps": 9}}}}
}`))
// "_kf" is a feed key-frame marker, not a stint index. Parsing it as 0
// would overwrite the real first stint.
state.ProcessTopic("TimingAppData", json.RawMessage(`{
"Lines": {"4": {"Stints": {"_kf": {"Compound": "HARD", "TotalLaps": 99}}}}
}`))
stints := state.Snapshot().Stints["4"]
if len(stints) != 1 {
t.Fatalf("expected 1 stint, got %d: %+v", len(stints), stints)
}
if stints[0].Compound != "SOFT" || stints[0].Laps != 9 {
t.Errorf("key-frame marker corrupted stint 0: %+v", stints[0])
}
}
func TestProcessTopicTimingStats(t *testing.T) { func TestProcessTopicTimingStats(t *testing.T) {
state := live.NewState() state := live.NewState()
state.Drivers["55"] = live.LiveDriverData{RacingNumber: "55"} state.Drivers["55"] = live.LiveDriverData{RacingNumber: "55"}

View File

@@ -10,6 +10,7 @@ import (
"io" "io"
"log" "log"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
) )
@@ -404,22 +405,42 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
Stints json.RawMessage `json:"Stints"` Stints json.RawMessage `json:"Stints"`
} }
if json.Unmarshal(lineRaw, &line) == nil && line.Stints != nil { if json.Unmarshal(lineRaw, &line) == nil && line.Stints != nil {
var driverStints []LiveStintData // The feed sends stints as sparse deltas keyed by stint index:
// a mid-stint update is just {"1": {"TotalLaps": 14}}. Merge
// each entry into the stint it addresses. Replacing the slice
// wholesale discarded every earlier stint, so pit history
// collapsed to one entry and tyre age stuck near zero for the
// whole race.
driverStints := append([]LiveStintData(nil), s.Stints[num]...)
changed := false
for _, sRaw := range indexedRawValues(line.Stints) { for _, sRaw := range indexedRawValues(line.Stints) {
var st struct { var st struct {
Compound string `json:"Compound"` Compound *string `json:"Compound"`
New string `json:"New"` New *string `json:"New"`
TotalLaps int `json:"TotalLaps"` TotalLaps *int `json:"TotalLaps"`
} }
if json.Unmarshal(sRaw.Raw, &st) == nil && st.Compound != "" { if json.Unmarshal(sRaw.Raw, &st) != nil {
driverStints = append(driverStints, LiveStintData{ continue
Compound: st.Compound,
New: st.New == "true" || st.New == "True",
Laps: st.TotalLaps,
})
} }
if st.Compound == nil && st.New == nil && st.TotalLaps == nil {
continue
}
for len(driverStints) <= sRaw.Index {
driverStints = append(driverStints, LiveStintData{})
}
entry := &driverStints[sRaw.Index]
if st.Compound != nil && *st.Compound != "" {
entry.Compound = *st.Compound
}
if st.New != nil {
entry.New = *st.New == "true" || *st.New == "True"
}
if st.TotalLaps != nil {
entry.Laps = *st.TotalLaps
}
changed = true
} }
if len(driverStints) > 0 { if changed {
s.Stints[num] = driverStints s.Stints[num] = driverStints
lastStint := driverStints[len(driverStints)-1] lastStint := driverStints[len(driverStints)-1]
t := s.Tyres[num] t := s.Tyres[num]
@@ -874,8 +895,13 @@ func indexedRawValues(raw json.RawMessage) []indexedRaw {
if err := json.Unmarshal(raw, &obj); err == nil { if err := json.Unmarshal(raw, &obj); err == nil {
values := make([]indexedRaw, 0, len(obj)) values := make([]indexedRaw, 0, len(obj))
for k, v := range obj { for k, v := range obj {
i := 0 // Keys are array indices in the feed's delta form. Non-numeric keys
fmt.Sscanf(k, "%d", &i) // are feed metadata — "_kf" (key frame) is the common one — and must
// not be folded in as index 0, which would clobber the first entry.
i, err := strconv.Atoi(k)
if err != nil || i < 0 {
continue
}
values = append(values, indexedRaw{Index: i, Raw: v}) values = append(values, indexedRaw{Index: i, Raw: v})
} }
sort.Slice(values, func(i, j int) bool { sort.Slice(values, func(i, j int) bool {

View File

@@ -186,6 +186,19 @@ func SessionStatusIsActive(status string) bool {
} }
} }
// SessionStatusIsTerminal reports whether a raw F1 live timing SessionStatus
// value represents a session that has ended and will not resume. A temporarily
// inactive session (e.g. a red-flag pause reported as "Inactive") is neither
// active nor terminal.
func SessionStatusIsTerminal(status string) bool {
switch normalizeSessionStatus(status) {
case "finished", "finalised", "finalized", "ends", "aborted":
return true
default:
return false
}
}
func normalizeSessionStatus(status string) string { func normalizeSessionStatus(status string) string {
out := make([]rune, 0, len(status)) out := make([]rune, 0, len(status))
for _, r := range status { for _, r := range status {

488
internal/query/context.go Normal file
View File

@@ -0,0 +1,488 @@
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
raceHubPendingPollInterval = 15 * time.Second
)
// 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 {
Source string `json:"source"`
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"`
RaceHubDefaultSession *ContextSession `json:"race_hub_default_session,omitempty"`
RaceHubPreSession bool `json:"race_hub_pre_session"`
RaceHubRefreshAt string `json:"race_hub_refresh_at,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, pending *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 !isActive && !c.complete && !c.start.IsZero() && !c.start.After(now) &&
(c.end.IsZero() || now.Before(c.end)) && (pending == nil || c.start.After(pending.start)) {
pending = 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))
}
applyRaceHubDefault(&out, active, defaultAnalysis, next, pending, now)
return out, nil
}
// applyRaceHubDefault is deliberately distinct from TemporalPreSession. Other
// weekend surfaces begin preparation 48 hours ahead; Race Hub remains an
// analysis destination until the one-hour handoff before the next session.
func applyRaceHubDefault(out *WeekendContext, active, analysis, next, pending *contextCandidate, now time.Time) {
if active != nil {
out.RaceHubDefaultSession = out.ActiveSession
return
}
if next != nil {
handoff := next.start.Add(-time.Hour)
if now.Before(handoff) {
out.RaceHubRefreshAt = handoff.Format(time.RFC3339)
} else if now.Before(next.start) {
out.RaceHubDefaultSession = out.NextSession
out.RaceHubPreSession = true
out.RaceHubRefreshAt = next.start.Format(time.RFC3339)
return
}
}
if pending != nil {
out.RaceHubDefaultSession = sessionRef(*pending, LiveEvidence{}, now)
out.RaceHubPreSession = true
out.RaceHubRefreshAt = now.Add(raceHubPendingPollInterval).Format(time.RFC3339)
return
}
if analysis != nil {
out.RaceHubDefaultSession = out.DefaultAnalysisSession
}
}
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)
// Schedule and analysis are domain-store facts. Without an ingestion
// timestamp the resolver cannot honestly call them network-fresh, so local
// is the baseline freshness vocabulary exposed to clients.
availability := ContextAvailability{Source: "local", Schedule: "available", LiveSession: "inactive", Archive: "unavailable", Freshness: "local", 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.Source = "mixed"
availability.LiveTransport = "connected"
availability.LiveSession = "active"
availability.Freshness = "live"
if !evidence.ObservedAt.IsZero() {
availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339)
}
}
if c.archived {
availability.Source = "mixed"
availability.Archive = "available"
availability.Freshness = "archive"
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"
}
if availability.LocalAnalysis == "partial" && availability.Freshness == "local" {
availability.Freshness = "partial"
}
if c.session.SessionKey == 0 && availability.LiveSession == "active" {
availability.Source = "fia"
}
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,486 @@
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 TestResolveWeekendContextAvailabilityUsesTruthfulSources(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)
// Results without laps/stints/positions are meaningful but incomplete local
// analysis, so they must not be labelled universally fresh.
completeContextSession(t, svc, 11, 1)
addContextMeeting(t, svc, 2, "Belgian Grand Prix", "2026-07-17T00: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)
local, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got := local.PreviousCompletedSession.Availability; got.Source != "local" || got.Freshness != "partial" || got.LocalAnalysis != "partial" {
t.Fatalf("partial local availability = %+v", got)
}
if got := local.NextSession.Availability; got.Source != "local" || got.Freshness != "local" {
t.Fatalf("future local availability = %+v", got)
}
liveContext, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "Belgian Grand Prix", CircuitName: "Belgian Grand Prix", SessionName: "Practice 1", SessionType: "Practice 1", ObservedAt: now})
if err != nil {
t.Fatal(err)
}
if got := liveContext.ActiveSession.Availability; got.Source != "mixed" || got.Freshness != "live" || got.LiveSession != "active" {
t.Fatalf("FIA + local availability = %+v", got)
}
archiveContext, 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 got := archiveContext.PreviousCompletedSession.Availability; got.Source != "mixed" || got.Freshness != "archive" || got.Archive != "available" {
t.Fatalf("FIA archive + local availability = %+v", got)
}
synthetic, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "Unscheduled Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
if err != nil {
t.Fatal(err)
}
if got := synthetic.ActiveSession.Availability; got.Source != "fia" || got.Freshness != "live" || got.Schedule != "unavailable" {
t.Fatalf("synthetic FIA availability = %+v", got)
}
}
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)
}
}
// Regression for the v0.4.0 release blocker (#90). Production held a run
// British race (11326) and an unrun Belgian race (11334) two weekends apart.
// Analysis must resolve to the race that actually happened, even when the
// later meeting carries preloaded rows.
func TestResolveWeekendContextPrefersRunRaceOverLaterUnrunRace(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-13T12:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1289, "British Grand Prix", "2026-07-03T11:30:00Z", "2026-07-05T16:00:00Z", false)
addContextSession(t, svc, 11326, 1289, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
completeContextSession(t, svc, 11326, 1289)
addContextMeeting(t, svc, 1290, "Belgian Grand Prix", "2026-07-17T11:30:00Z", "2026-07-19T15:00:00Z", false)
addContextSession(t, svc, 11334, 1290, "Race", "2026-07-19T13:00:00Z", "2026-07-19T15:00:00Z", false)
completeContextSession(t, svc, 11334, 1290)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.DefaultAnalysisSession == nil {
t.Fatal("no analysis session resolved")
}
if key := got.DefaultAnalysisSession.Session.SessionKey; key != 11326 {
t.Fatalf("analysis session = %d, want 11326 (British); 11334 is the unrun Belgian race", key)
}
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Session.SessionKey != 11326 {
t.Fatalf("previous completed = %+v, want 11326", got.PreviousCompletedSession)
}
if got.NextSession == nil || got.NextSession.Session.SessionKey != 11334 {
t.Fatalf("next session = %+v, want 11334", got.NextSession)
}
}
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)
}
}
func TestResolveWeekendContextRaceHubDefault(t *testing.T) {
seed := func(t *testing.T, svc *Service) {
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)
completeContextSession(t, svc, 11, 1)
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)
}
t.Run("keeps completed analysis before handoff", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-16T07:59:59Z")
svc := contextService(t, now)
seed(t, svc)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 11 || got.RaceHubPreSession {
t.Fatalf("race hub default = %+v, pre-session = %t", got.RaceHubDefaultSession, got.RaceHubPreSession)
}
if got.RaceHubRefreshAt != "2026-07-17T08:00:00Z" {
t.Fatalf("refresh = %q", got.RaceHubRefreshAt)
}
})
t.Run("hands off exactly one hour before first session", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-17T08:00:00Z")
svc := contextService(t, now)
seed(t, svc)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 21 || !got.RaceHubPreSession {
t.Fatalf("race hub handoff = %+v, pre-session = %t", got.RaceHubDefaultSession, got.RaceHubPreSession)
}
if got.RaceHubRefreshAt != "2026-07-17T09:00:00Z" {
t.Fatalf("refresh = %q", got.RaceHubRefreshAt)
}
})
t.Run("keeps the scheduled session pending after its start without live evidence", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-17T09:00:00Z")
svc := contextService(t, now)
seed(t, svc)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 21 || !got.RaceHubPreSession {
t.Fatalf("scheduled race hub default = %+v, pre-session = %t", got.RaceHubDefaultSession, got.RaceHubPreSession)
}
if got.RaceHubRefreshAt != "2026-07-17T09:00:15Z" {
t.Fatalf("refresh = %q", got.RaceHubRefreshAt)
}
})
t.Run("active live session wins", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-17T08:30:00Z")
svc := contextService(t, now)
seed(t, svc)
got, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "Belgian Grand Prix", SessionName: "Practice 1", SessionType: "Practice 1"})
if err != nil {
t.Fatal(err)
}
if got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 21 || got.RaceHubPreSession {
t.Fatalf("live default = %+v, pre-session = %t", got.RaceHubDefaultSession, got.RaceHubPreSession)
}
})
t.Run("does not select an empty future session before handoff", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-16T12:00:00Z")
svc := contextService(t, now)
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)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.RaceHubDefaultSession != nil || got.RaceHubPreSession {
t.Fatalf("unexpected empty future default: %+v", got)
}
})
}

View File

@@ -3,6 +3,7 @@ package query
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"time"
"github.com/AmanTahiliani/box-box/internal/chapters" "github.com/AmanTahiliani/box-box/internal/chapters"
"github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/models"
@@ -12,11 +13,20 @@ import (
// Service assembles store-backed read models. // Service assembles store-backed read models.
type Service struct { type Service struct {
store *store.Store store *store.Store
now func() time.Time
} }
// NewService creates a query service over a domain store. // NewService creates a query service over a domain store.
func NewService(st *store.Store) *Service { 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. // EnrichedResult is a session result with driver identity fields.

View File

@@ -16,6 +16,7 @@ import (
readability "codeberg.org/readeck/go-readability/v2" readability "codeberg.org/readeck/go-readability/v2"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/query" "github.com/AmanTahiliani/box-box/internal/query"
) )
@@ -43,6 +44,7 @@ func (s *Server) handleMeetings(w http.ResponseWriter, r *http.Request) {
switch parseSourceMode(r) { switch parseSourceMode(r) {
case sourceLocal: case sourceLocal:
markLocalResponse(w, false)
if !s.hasLocalQuery() { if !s.hasLocalQuery() {
writeJSON(w, []models.Meeting{}) writeJSON(w, []models.Meeting{})
return return
@@ -62,17 +64,20 @@ func (s *Server) handleMeetings(w http.ResponseWriter, r *http.Request) {
return return
} }
if len(meetings) > 0 { if len(meetings) > 0 {
markLocalResponse(w, false)
writeJSON(w, meetings) writeJSON(w, meetings)
return return
} }
} }
} }
meetings, err := s.client.GetMeetingsForYear(year) client := s.client.Scoped()
meetings, err := client.GetMeetingsForYear(year)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
markOpenF1Response(w, client)
writeJSON(w, meetings) writeJSON(w, meetings)
} }
@@ -87,6 +92,7 @@ func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
switch parseSourceMode(r) { switch parseSourceMode(r) {
case sourceLocal: case sourceLocal:
markLocalResponse(w, false)
if !s.hasLocalQuery() { if !s.hasLocalQuery() {
writeJSON(w, []models.Session{}) writeJSON(w, []models.Session{})
return return
@@ -106,23 +112,27 @@ func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
return return
} }
if len(sessions) > 0 { if len(sessions) > 0 {
markLocalResponse(w, false)
writeJSON(w, sessions) writeJSON(w, sessions)
return return
} }
} }
} }
sessions, err := s.client.GetSessionsForMeeting(meetingKey) client := s.client.Scoped()
sessions, err := client.GetSessionsForMeeting(meetingKey)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
markOpenF1Response(w, client)
writeJSON(w, sessions) writeJSON(w, sessions)
} }
// --- /api/v1/news --- // --- /api/v1/news ---
func (s *Server) handleNews(w http.ResponseWriter, r *http.Request) { func (s *Server) handleNews(w http.ResponseWriter, r *http.Request) {
markLocalResponse(w, false)
if !s.hasLocalQuery() { if !s.hasLocalQuery() {
writeJSON(w, []query.NewsItem{}) writeJSON(w, []query.NewsItem{})
return return
@@ -223,6 +233,7 @@ func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) {
switch parseSourceMode(r) { switch parseSourceMode(r) {
case sourceLocal: case sourceLocal:
markLocalResponse(w, false)
if !s.hasLocalQuery() { if !s.hasLocalQuery() {
writeJSON(w, []models.Driver{}) writeJSON(w, []models.Driver{})
return return
@@ -242,6 +253,7 @@ func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) {
if s.hasLocalQuery() { if s.hasLocalQuery() {
drivers, err := s.query.ListDrivers(sessionKey) drivers, err := s.query.ListDrivers(sessionKey)
if err == nil && len(drivers) > 0 { if err == nil && len(drivers) > 0 {
markLocalResponse(w, false)
writeJSON(w, drivers) writeJSON(w, drivers)
return return
} }
@@ -252,11 +264,13 @@ func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) {
} }
} }
drivers, err := s.client.GetDriversForSession(sessionKey) client := s.client.Scoped()
drivers, err := client.GetDriversForSession(sessionKey)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
markOpenF1Response(w, client)
writeJSON(w, drivers) writeJSON(w, drivers)
} }
@@ -279,6 +293,7 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
switch parseSourceMode(r) { switch parseSourceMode(r) {
case sourceLocal: case sourceLocal:
markLocalResponse(w, false)
if !s.hasLocalQuery() { if !s.hasLocalQuery() {
writeJSON(w, []resultWithDriver{}) writeJSON(w, []resultWithDriver{})
return return
@@ -294,6 +309,7 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
if s.hasLocalQuery() { if s.hasLocalQuery() {
results, err := s.query.ListResults(sessionKey) results, err := s.query.ListResults(sessionKey)
if err == nil && len(results) > 0 { if err == nil && len(results) > 0 {
markLocalResponse(w, false)
writeJSON(w, enrichedResultsToAPI(results)) writeJSON(w, enrichedResultsToAPI(results))
return return
} }
@@ -308,30 +324,42 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
results []models.SessionResult results []models.SessionResult
drivers []models.Driver drivers []models.Driver
resultsErr error resultsErr error
driversErr error
wg sync.WaitGroup wg sync.WaitGroup
) )
client := s.client.Scoped()
wg.Add(2) wg.Add(2)
go func() { defer wg.Done(); results, resultsErr = s.client.GetSessionResult(sessionKey) }() go func() { defer wg.Done(); results, resultsErr = client.GetSessionResult(sessionKey) }()
go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }() go func() { defer wg.Done(); drivers, driversErr = client.GetDriversForSession(sessionKey) }()
wg.Wait() wg.Wait()
if resultsErr != nil { if resultsErr != nil {
writeError(w, resultsErr, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, resultsErr, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
driverMap := buildDriverMap(drivers) driverMap := buildDriverMap(drivers)
incomplete := driversErr != nil
enriched := make([]resultWithDriver, 0, len(results)) enriched := make([]resultWithDriver, 0, len(results))
for _, res := range results { for _, res := range results {
e := resultWithDriver{SessionResult: res} e := resultWithDriver{SessionResult: res}
if d, ok := driverMap[res.DriverNumber]; ok { if d, ok := driverMap[res.DriverNumber]; ok && hasDriverPresentation(d) {
e.NameAcronym = d.NameAcronym e.NameAcronym = d.NameAcronym
e.FullName = d.FullName e.FullName = d.FullName
e.TeamName = d.TeamName e.TeamName = d.TeamName
e.TeamColour = d.TeamColour e.TeamColour = d.TeamColour
} else {
incomplete = true
} }
enriched = append(enriched, e) enriched = append(enriched, e)
} }
resultsFreshness := "fresh"
if len(results) == 0 {
resultsFreshness = "limited"
} else if incomplete {
resultsFreshness = "partial"
}
markOpenF1Availability(w, client, resultsFreshness)
writeJSON(w, enriched) writeJSON(w, enriched)
} }
@@ -354,6 +382,7 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
switch parseSourceMode(r) { switch parseSourceMode(r) {
case sourceLocal: case sourceLocal:
markLocalResponse(w, false)
if !s.hasLocalQuery() { if !s.hasLocalQuery() {
writeJSON(w, []gridWithDriver{}) writeJSON(w, []gridWithDriver{})
return return
@@ -369,6 +398,7 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
if s.hasLocalQuery() { if s.hasLocalQuery() {
grid, err := s.query.ListStartingGrid(sessionKey) grid, err := s.query.ListStartingGrid(sessionKey)
if err == nil && len(grid) > 0 { if err == nil && len(grid) > 0 {
markLocalResponse(w, false)
writeJSON(w, enrichedGridToAPI(grid)) writeJSON(w, enrichedGridToAPI(grid))
return return
} }
@@ -380,33 +410,45 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
} }
var ( var (
grid []models.StartingGrid grid []models.StartingGrid
drivers []models.Driver drivers []models.Driver
gridErr error gridErr error
wg sync.WaitGroup driversErr error
wg sync.WaitGroup
) )
client := s.client.Scoped()
wg.Add(2) wg.Add(2)
go func() { defer wg.Done(); grid, gridErr = s.client.GetStartingGrid(sessionKey) }() go func() { defer wg.Done(); grid, gridErr = client.GetStartingGrid(sessionKey) }()
go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }() go func() { defer wg.Done(); drivers, driversErr = client.GetDriversForSession(sessionKey) }()
wg.Wait() wg.Wait()
if gridErr != nil { if gridErr != nil {
writeError(w, gridErr, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, gridErr, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
driverMap := buildDriverMap(drivers) driverMap := buildDriverMap(drivers)
incomplete := driversErr != nil
enriched := make([]gridWithDriver, 0, len(grid)) enriched := make([]gridWithDriver, 0, len(grid))
for _, g := range grid { for _, g := range grid {
e := gridWithDriver{StartingGrid: g} e := gridWithDriver{StartingGrid: g}
if d, ok := driverMap[g.DriverNumber]; ok { if d, ok := driverMap[g.DriverNumber]; ok && hasDriverPresentation(d) {
e.NameAcronym = d.NameAcronym e.NameAcronym = d.NameAcronym
e.FullName = d.FullName e.FullName = d.FullName
e.TeamName = d.TeamName e.TeamName = d.TeamName
e.TeamColour = d.TeamColour e.TeamColour = d.TeamColour
} else {
incomplete = true
} }
enriched = append(enriched, e) enriched = append(enriched, e)
} }
gridFreshness := "fresh"
if len(grid) == 0 {
gridFreshness = "limited"
} else if incomplete {
gridFreshness = "partial"
}
markOpenF1Availability(w, client, gridFreshness)
writeJSON(w, enriched) writeJSON(w, enriched)
} }
@@ -419,26 +461,29 @@ func (s *Server) handleLaps(w http.ResponseWriter, r *http.Request) {
return return
} }
client := s.client.Scoped()
if dnStr := r.URL.Query().Get("driver_number"); dnStr != "" { if dnStr := r.URL.Query().Get("driver_number"); dnStr != "" {
driverNumber, err := strconv.Atoi(dnStr) driverNumber, err := strconv.Atoi(dnStr)
if err != nil || driverNumber == 0 { if err != nil || driverNumber == 0 {
http.Error(w, "invalid driver_number", http.StatusBadRequest) http.Error(w, "invalid driver_number", http.StatusBadRequest)
return return
} }
laps, err := s.client.GetLapsForDriver(sessionKey, driverNumber) laps, err := client.GetLapsForDriver(sessionKey, driverNumber)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
markOpenF1Response(w, client)
writeJSON(w, laps) writeJSON(w, laps)
return return
} }
laps, err := s.client.GetLapsForSession(sessionKey) laps, err := client.GetLapsForSession(sessionKey)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
markOpenF1Response(w, client)
writeJSON(w, laps) writeJSON(w, laps)
} }
@@ -450,11 +495,13 @@ func (s *Server) handleWeather(w http.ResponseWriter, r *http.Request) {
http.Error(w, "session_key required", http.StatusBadRequest) http.Error(w, "session_key required", http.StatusBadRequest)
return return
} }
weather, err := s.client.GetWeather(sessionKey) client := s.client.Scoped()
weather, err := client.GetWeather(sessionKey)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
markOpenF1Response(w, client)
writeJSON(w, weather) writeJSON(w, weather)
} }
@@ -466,11 +513,13 @@ func (s *Server) handleRaceControl(w http.ResponseWriter, r *http.Request) {
http.Error(w, "session_key required", http.StatusBadRequest) http.Error(w, "session_key required", http.StatusBadRequest)
return return
} }
rc, err := s.client.GetRaceControl(sessionKey) client := s.client.Scoped()
rc, err := client.GetRaceControl(sessionKey)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
markOpenF1Response(w, client)
writeJSON(w, rc) writeJSON(w, rc)
} }
@@ -487,11 +536,13 @@ func (s *Server) handleTelemetry(w http.ResponseWriter, r *http.Request) {
http.Error(w, "driver_number required", http.StatusBadRequest) http.Error(w, "driver_number required", http.StatusBadRequest)
return return
} }
carData, err := s.client.GetCarData(sessionKey, driverNumber) client := s.client.Scoped()
carData, err := client.GetCarData(sessionKey, driverNumber)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
markOpenF1Response(w, client)
writeJSON(w, carData) writeJSON(w, carData)
} }
@@ -503,11 +554,13 @@ func (s *Server) handleOvertakes(w http.ResponseWriter, r *http.Request) {
http.Error(w, "session_key required", http.StatusBadRequest) http.Error(w, "session_key required", http.StatusBadRequest)
return return
} }
overtakes, err := s.client.GetOvertakesForSession(sessionKey) client := s.client.Scoped()
overtakes, err := client.GetOvertakesForSession(sessionKey)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
markOpenF1Response(w, client)
writeJSON(w, overtakes) writeJSON(w, overtakes)
} }
@@ -524,11 +577,13 @@ func (s *Server) handleTeamRadio(w http.ResponseWriter, r *http.Request) {
http.Error(w, "driver_number required", http.StatusBadRequest) http.Error(w, "driver_number required", http.StatusBadRequest)
return return
} }
radios, err := s.client.GetTeamRadio(sessionKey, driverNumber) client := s.client.Scoped()
radios, err := client.GetTeamRadio(sessionKey, driverNumber)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
markOpenF1Response(w, client)
writeJSON(w, radios) writeJSON(w, radios)
} }
@@ -547,36 +602,42 @@ func (s *Server) handleChampionshipDrivers(w http.ResponseWriter, r *http.Reques
if year == 0 { if year == 0 {
year = time.Now().Year() year = time.Now().Year()
} }
champ, err := s.client.GetDriverChampionshipForYear(year) client := s.client.Scoped()
champ, err := client.GetDriverChampionshipForYear(year)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
if len(champ) == 0 { if len(champ) == 0 {
markOpenF1Availability(w, client, "limited")
writeJSON(w, []any{}) writeJSON(w, []any{})
return return
} }
drivers, _ := s.client.GetDriversForSession(champ[0].SessionKey) drivers, driversErr := client.GetDriversForSession(champ[0].SessionKey)
driverMap := buildDriverMapFirst(drivers) driverMap := buildDriverMapFirst(drivers)
incomplete := driversErr != nil
enriched := make([]champDriverWithInfo, 0, len(champ)) enriched := make([]champDriverWithInfo, 0, len(champ))
for _, c := range champ { for _, c := range champ {
e := champDriverWithInfo{ChampionshipDriver: c} e := champDriverWithInfo{ChampionshipDriver: c}
d, ok := s.championshipDriverInfo(c.SessionKey, c.DriverNumber, driverMap) d, ok := championshipDriverInfo(client, c.SessionKey, c.DriverNumber, driverMap)
if ok { if ok && hasDriverPresentation(d) {
e.NameAcronym = d.NameAcronym e.NameAcronym = d.NameAcronym
e.FullName = d.FullName e.FullName = d.FullName
e.TeamName = d.TeamName e.TeamName = d.TeamName
e.TeamColour = d.TeamColour e.TeamColour = d.TeamColour
} else {
incomplete = true
} }
enriched = append(enriched, e) enriched = append(enriched, e)
} }
markOpenF1AggregateResponse(w, client, incomplete)
writeJSON(w, enriched) writeJSON(w, enriched)
} }
func (s *Server) championshipDriverInfo(sessionKey, driverNumber int, fallback map[int]models.Driver) (models.Driver, bool) { func championshipDriverInfo(client *api.OpenF1Client, sessionKey, driverNumber int, fallback map[int]models.Driver) (models.Driver, bool) {
if d, err := s.client.GetDriver(sessionKey, driverNumber); err == nil && d != nil { if d, err := client.GetDriver(sessionKey, driverNumber); err == nil && d != nil {
return *d, true return *d, true
} }
d, ok := fallback[driverNumber] d, ok := fallback[driverNumber]
@@ -590,11 +651,17 @@ func (s *Server) handleChampionshipTeams(w http.ResponseWriter, r *http.Request)
if year == 0 { if year == 0 {
year = time.Now().Year() year = time.Now().Year()
} }
teams, err := s.client.GetTeamChampionshipForYear(year) client := s.client.Scoped()
teams, err := client.GetTeamChampionshipForYear(year)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
if len(teams) == 0 {
markOpenF1Availability(w, client, "limited")
} else {
markOpenF1Response(w, client)
}
writeJSON(w, teams) writeJSON(w, teams)
} }
@@ -671,8 +738,10 @@ func champHubTTL(year int, now time.Time) time.Duration {
} }
type champHubEntry struct { type champHubEntry struct {
resp champHubResponse resp champHubResponse
expires time.Time source string
freshness string
expires time.Time
} }
// champHubCache is an in-memory cache of aggregated hub responses keyed by // champHubCache is an in-memory cache of aggregated hub responses keyed by
@@ -693,12 +762,26 @@ func (c *champHubCache) get(year int, now time.Time) (champHubResponse, bool) {
} }
func (c *champHubCache) put(year int, resp champHubResponse, now time.Time, ttl time.Duration) { func (c *champHubCache) put(year int, resp champHubResponse, now time.Time, ttl time.Duration) {
c.putWithMetadata(year, resp, "local", "local", now, ttl)
}
func (c *champHubCache) getWithMetadata(year int, now time.Time) (champHubResponse, string, string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries[year]
if !ok || now.After(e.expires) {
return champHubResponse{}, "", "", false
}
return e.resp, e.source, e.freshness, true
}
func (c *champHubCache) putWithMetadata(year int, resp champHubResponse, source, freshness string, now time.Time, ttl time.Duration) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if c.entries == nil { if c.entries == nil {
c.entries = map[int]champHubEntry{} c.entries = map[int]champHubEntry{}
} }
c.entries[year] = champHubEntry{resp: resp, expires: now.Add(ttl)} c.entries[year] = champHubEntry{resp: resp, source: source, freshness: freshness, expires: now.Add(ttl)}
} }
// fetchMeetingRaces fans fetch out across meetings with bounded concurrency. // fetchMeetingRaces fans fetch out across meetings with bounded concurrency.
@@ -740,13 +823,6 @@ func (s *Server) handleChampionshipHub(w http.ResponseWriter, r *http.Request) {
} }
mode := parseSourceMode(r) mode := parseSourceMode(r)
if mode != sourceLocal {
if resp, ok := s.hubCache.get(year, time.Now()); ok {
writeJSON(w, resp)
return
}
}
if mode == sourceLocal || mode == sourceAuto { if mode == sourceLocal || mode == sourceAuto {
resp, ok, err := s.localChampionshipHub(year) resp, ok, err := s.localChampionshipHub(year)
if err != nil { if err != nil {
@@ -754,21 +830,33 @@ func (s *Server) handleChampionshipHub(w http.ResponseWriter, r *http.Request) {
return return
} }
if ok { if ok {
s.hubCache.put(year, resp, time.Now(), champHubTTL(year, time.Now())) s.hubCache.putWithMetadata(year, resp, "local", "local", time.Now(), champHubTTL(year, time.Now()))
markLocalResponse(w, false)
writeJSON(w, resp) writeJSON(w, resp)
return return
} }
if mode == sourceLocal { if mode == sourceLocal {
markDataResponse(w, "none", "limited")
writeJSON(w, resp) writeJSON(w, resp)
return return
} }
} }
resp, err := s.openF1ChampionshipHub(year) // At this point an auto request has no usable domain aggregate and an
if err != nil { // explicit OpenF1 request must not be satisfied by a local cache entry.
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) if resp, source, freshness, ok := s.hubCache.getWithMetadata(year, time.Now()); ok && source == "openf1" {
markDataResponse(w, source, freshness)
writeJSON(w, resp)
return return
} }
client := s.client.Scoped()
resp, incomplete, err := s.openF1ChampionshipHub(client, year)
if err != nil {
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
markOpenF1AggregateResponse(w, client, incomplete)
writeJSON(w, resp) writeJSON(w, resp)
} }
@@ -797,25 +885,35 @@ func (s *Server) localChampionshipHub(year int) (champHubResponse, bool, error)
return aggregateChampionshipHub(year, races, inputs.Champ, inputs.Teams, inputs.DriverMap), true, nil return aggregateChampionshipHub(year, races, inputs.Champ, inputs.Teams, inputs.DriverMap), true, nil
} }
func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) { func (s *Server) openF1ChampionshipHub(client *api.OpenF1Client, year int) (champHubResponse, bool, error) {
champ, err := s.client.GetDriverChampionshipForYear(year) champ, err := client.GetDriverChampionshipForYear(year)
if err != nil { if err != nil {
return champHubResponse{}, err return champHubResponse{}, false, err
} }
if len(champ) == 0 { if len(champ) == 0 {
return champHubResponse{Season: year, RoundLabels: []string{}, Drivers: []champHubDriver{}, Teams: []champHubTeam{}}, nil return champHubResponse{Season: year, RoundLabels: []string{}, Drivers: []champHubDriver{}, Teams: []champHubTeam{}}, true, nil
} }
teams, _ := s.client.GetTeamChampionshipForYear(year) teams, teamsErr := client.GetTeamChampionshipForYear(year)
driverInfo := map[int]models.Driver{} driverInfo := map[int]models.Driver{}
if ds, derr := s.client.GetDriversForSession(champ[0].SessionKey); derr == nil { driversIncomplete := false
if ds, derr := client.GetDriversForSession(champ[0].SessionKey); derr == nil {
driverInfo = buildDriverMapFirst(ds) driverInfo = buildDriverMapFirst(ds)
} else {
driversIncomplete = true
}
for _, standing := range champ {
if !hasDriverPresentation(driverInfo[standing.DriverNumber]) {
driversIncomplete = true
break
}
} }
races, incomplete, err := s.fetchSeasonRaces(year) races, incomplete, err := fetchSeasonRaces(client, year)
if err != nil { if err != nil {
return champHubResponse{}, err return champHubResponse{}, false, err
} }
incomplete = incomplete || teamsErr != nil || driversIncomplete
resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo) resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo)
ttl := champHubTTL(year, time.Now()) ttl := champHubTTL(year, time.Now())
@@ -825,16 +923,22 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
// so a partial view of the season doesn't stick around for the full TTL. // so a partial view of the season doesn't stick around for the full TTL.
ttl = champHubIncompleteTTL ttl = champHubIncompleteTTL
} }
s.hubCache.put(year, resp, time.Now(), ttl) freshness := "fresh"
return resp, nil if client.LastResponseWasStale() {
freshness = "stale"
} else if incomplete {
freshness = "partial"
}
s.hubCache.putWithMetadata(year, resp, "openf1", freshness, time.Now(), ttl)
return resp, incomplete, nil
} }
// fetchSeasonRaces returns a season's GP meetings in date order, each bundled // fetchSeasonRaces returns a season's GP meetings in date order, each bundled
// with its race results and starting grid fetched from OpenF1. incomplete // with its race results and starting grid fetched from OpenF1. incomplete
// reports whether any per-meeting fetch failed, so callers can avoid caching a // reports whether any per-meeting fetch failed, so callers can avoid caching a
// partial view of the season for long. // partial view of the season for long.
func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete bool, err error) { func fetchSeasonRaces(client *api.OpenF1Client, year int) (races []meetingRace, incomplete bool, err error) {
meetings, err := s.client.GetMeetingsForYear(year) meetings, err := client.GetMeetingsForYear(year)
if err != nil { if err != nil {
return nil, false, err return nil, false, err
} }
@@ -842,7 +946,7 @@ func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete boo
var failed atomic.Bool var failed atomic.Bool
races = fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) { races = fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) {
sessions, serr := s.client.GetSessionsForMeeting(int(m.MeetingKey)) sessions, serr := client.GetSessionsForMeeting(int(m.MeetingKey))
if serr != nil { if serr != nil {
failed.Store(true) failed.Store(true)
return meetingRace{}, false return meetingRace{}, false
@@ -855,10 +959,17 @@ func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete boo
} }
} }
if raceKey == 0 { if raceKey == 0 {
return meetingRace{}, false // not a GP meeting (e.g. pre-season testing) if isKnownNonChampionshipMeeting(m, sessions) {
return meetingRace{}, false
}
failed.Store(true)
// The meeting list does not identify non-championship events. Skipping
// a meeting without a Race may be expected (testing), but the aggregate
// is not proven complete and must be labelled partial.
return meetingRace{}, false
} }
results, rerr := s.client.GetSessionResult(raceKey) results, rerr := client.GetSessionResult(raceKey)
grid, gerr := s.client.GetStartingGrid(raceKey) grid, gerr := client.GetStartingGrid(raceKey)
if rerr != nil || gerr != nil { if rerr != nil || gerr != nil {
failed.Store(true) failed.Store(true)
} }
@@ -867,6 +978,29 @@ func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete boo
return races, failed.Load(), nil return races, failed.Load(), nil
} }
func isKnownNonChampionshipMeeting(meeting models.Meeting, sessions []models.Session) bool {
if hasTestingToken(meeting.MeetingName + " " + meeting.MeetingOfficialName) {
return true
}
for _, session := range sessions {
if hasTestingToken(session.SessionName + " " + session.SessionType) {
return true
}
}
return false
}
func hasTestingToken(value string) bool {
for _, token := range strings.FieldsFunc(strings.ToLower(value), func(r rune) bool {
return (r < 'a' || r > 'z') && (r < '0' || r > '9')
}) {
if token == "test" || token == "tests" || token == "testing" {
return true
}
}
return false
}
// aggregateChampionshipHub is the pure aggregation core (no network) so it can be // aggregateChampionshipHub is the pure aggregation core (no network) so it can be
// unit-tested with synthetic data. races must be ordered ascending by date and // unit-tested with synthetic data. races must be ordered ascending by date and
// contain only GP meetings (those with a Race session). // contain only GP meetings (those with a Race session).
@@ -1077,6 +1211,7 @@ type trackOutlineResponse struct {
} }
func (s *Server) handleTrackOutline(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTrackOutline(w http.ResponseWriter, r *http.Request) {
markLocalResponse(w, false)
year, _ := strconv.Atoi(r.URL.Query().Get("year")) year, _ := strconv.Atoi(r.URL.Query().Get("year"))
if year == 0 { if year == 0 {
year = time.Now().Year() year = time.Now().Year()
@@ -1273,22 +1408,25 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
} }
var ( var (
stints []models.Stint stints []models.Stint
pits []models.Pit pits []models.Pit
results []models.SessionResult results []models.SessionResult
drivers []models.Driver drivers []models.Driver
rc []models.RaceControl rc []models.RaceControl
stintsErr error stintsErr error
pitsErr error pitsErr error
resErr error resErr error
wg sync.WaitGroup driversErr error
rcErr error
wg sync.WaitGroup
) )
client := s.client.Scoped()
wg.Add(5) wg.Add(5)
go func() { defer wg.Done(); stints, stintsErr = s.client.GetStintsForSession(sessionKey) }() go func() { defer wg.Done(); stints, stintsErr = client.GetStintsForSession(sessionKey) }()
go func() { defer wg.Done(); pits, pitsErr = s.client.GetPitStopsForSession(sessionKey) }() go func() { defer wg.Done(); pits, pitsErr = client.GetPitStopsForSession(sessionKey) }()
go func() { defer wg.Done(); results, resErr = s.client.GetSessionResult(sessionKey) }() go func() { defer wg.Done(); results, resErr = client.GetSessionResult(sessionKey) }()
go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }() go func() { defer wg.Done(); drivers, driversErr = client.GetDriversForSession(sessionKey) }()
go func() { defer wg.Done(); rc, _ = s.client.GetRaceControl(sessionKey) }() go func() { defer wg.Done(); rc, rcErr = client.GetRaceControl(sessionKey) }()
wg.Wait() wg.Wait()
if stintsErr != nil || pitsErr != nil || resErr != nil { if stintsErr != nil || pitsErr != nil || resErr != nil {
@@ -1299,17 +1437,21 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
if e == nil { if e == nil {
e = resErr e = resErr
} }
writeError(w, e, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, e, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
// Non-race sessions have no stints. // Empty strategy data may mean a non-race session or a race still settling.
if len(stints) == 0 { if len(stints) == 0 {
// Without session-type evidence, an empty primary strategy dataset is
// not enough to prove "not applicable" (it may still be settling).
markOpenF1Availability(w, client, "limited")
writeJSON(w, map[string]any{"note": "Not applicable", "drivers": []any{}}) writeJSON(w, map[string]any{"note": "Not applicable", "drivers": []any{}})
return return
} }
driverMap := buildDriverMap(drivers) driverMap := buildDriverMap(drivers)
incomplete := driversErr != nil || rcErr != nil
resultMap := make(map[int]models.SessionResult, len(results)) resultMap := make(map[int]models.SessionResult, len(results))
totalLaps := 0 totalLaps := 0
@@ -1342,6 +1484,9 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
stratDrivers := make([]strategyDriver, 0, len(seenDrivers)) stratDrivers := make([]strategyDriver, 0, len(seenDrivers))
for dn := range seenDrivers { for dn := range seenDrivers {
d := driverMap[dn] d := driverMap[dn]
if !hasDriverPresentation(d) {
incomplete = true
}
res := resultMap[dn] res := resultMap[dn]
sd := strategyDriver{ sd := strategyDriver{
@@ -1396,6 +1541,7 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
return pi < pj return pi < pj
}) })
markOpenF1AggregateResponse(w, client, incomplete)
writeJSON(w, strategyResponse{ writeJSON(w, strategyResponse{
SessionKey: sessionKey, SessionKey: sessionKey,
TotalLaps: totalLaps, TotalLaps: totalLaps,
@@ -1491,21 +1637,31 @@ func (s *Server) handleLapsComparison(w http.ResponseWriter, r *http.Request) {
} }
var ( var (
allLaps []models.Lap allLaps []models.Lap
stints []models.Stint stints []models.Stint
pits []models.Pit pits []models.Pit
rc []models.RaceControl rc []models.RaceControl
wg sync.WaitGroup lapsErr error
stintsErr error
pitsErr error
rcErr error
wg sync.WaitGroup
) )
client := s.client.Scoped()
wg.Add(4) wg.Add(4)
go func() { defer wg.Done(); allLaps, _ = s.client.GetLapsForSession(sessionKey) }() go func() { defer wg.Done(); allLaps, lapsErr = client.GetLapsForSession(sessionKey) }()
go func() { defer wg.Done(); stints, _ = s.client.GetStintsForSession(sessionKey) }() go func() { defer wg.Done(); stints, stintsErr = client.GetStintsForSession(sessionKey) }()
go func() { defer wg.Done(); pits, _ = s.client.GetPitStopsForSession(sessionKey) }() go func() { defer wg.Done(); pits, pitsErr = client.GetPitStopsForSession(sessionKey) }()
go func() { defer wg.Done(); rc, _ = s.client.GetRaceControl(sessionKey) }() go func() { defer wg.Done(); rc, rcErr = client.GetRaceControl(sessionKey) }()
wg.Wait() wg.Wait()
if lapsErr != nil {
writeError(w, lapsErr, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
allDrivers, _ := s.client.GetDriversForSession(sessionKey) allDrivers, driversErr := client.GetDriversForSession(sessionKey)
driverMap := buildDriverMap(allDrivers) driverMap := buildDriverMap(allDrivers)
incomplete := stintsErr != nil || pitsErr != nil || rcErr != nil || driversErr != nil
// If no filter, default to first 3 unique driver numbers from lap data. // If no filter, default to first 3 unique driver numbers from lap data.
if len(requestedDrivers) == 0 { if len(requestedDrivers) == 0 {
@@ -1543,6 +1699,9 @@ func (s *Server) handleLapsComparison(w http.ResponseWriter, r *http.Request) {
compDrivers := make([]comparisonDriver, 0, len(requestedDrivers)) compDrivers := make([]comparisonDriver, 0, len(requestedDrivers))
for _, dn := range requestedDrivers { for _, dn := range requestedDrivers {
d := driverMap[dn] d := driverMap[dn]
if !hasDriverPresentation(d) {
incomplete = true
}
cd := comparisonDriver{ cd := comparisonDriver{
DriverNumber: dn, DriverNumber: dn,
NameAcronym: d.NameAcronym, NameAcronym: d.NameAcronym,
@@ -1558,6 +1717,13 @@ func (s *Server) handleLapsComparison(w http.ResponseWriter, r *http.Request) {
compDrivers = append(compDrivers, cd) compDrivers = append(compDrivers, cd)
} }
freshness := "fresh"
if len(allLaps) == 0 {
freshness = "limited"
} else if incomplete {
freshness = "partial"
}
markOpenF1Availability(w, client, freshness)
writeJSON(w, lapsComparisonResponse{ writeJSON(w, lapsComparisonResponse{
SessionKey: sessionKey, SessionKey: sessionKey,
SCPeriods: extractSCPeriods(rc), SCPeriods: extractSCPeriods(rc),

View File

@@ -1,13 +1,136 @@
package web package web
import ( import (
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/models"
) )
func championshipTestUpstream(t *testing.T, driversOK, meetingHasRace bool) *httptest.Server {
t.Helper()
completed := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/v1/sessions":
if r.URL.Query().Get("session_name") == "Race" {
_, _ = fmt.Fprintf(w, `[{"session_key":99,"session_name":"Race","date_end":%q}]`, completed)
return
}
if meetingHasRace {
_, _ = w.Write([]byte(`[{"session_key":101,"meeting_key":1,"session_name":"Race"}]`))
} else {
_, _ = w.Write([]byte(`[{"session_key":100,"meeting_key":1,"session_name":"Practice 1"}]`))
}
case "/v1/championship_drivers":
_, _ = w.Write([]byte(`[{"driver_number":1,"session_key":99,"position_current":1,"points_current":25}]`))
case "/v1/championship_teams":
_, _ = w.Write([]byte(`[{"team_name":"Red Bull","position_current":1,"points_current":25}]`))
case "/v1/drivers":
if !driversOK {
http.Error(w, "identity unavailable", http.StatusBadGateway)
return
}
_, _ = w.Write([]byte(`[{"driver_number":1,"name_acronym":"VER","full_name":"Max Verstappen","team_name":"Red Bull","team_colour":"3671c6"}]`))
case "/v1/meetings":
_, _ = w.Write([]byte(`[{"meeting_key":1,"meeting_name":"Mystery Grand Prix"}]`))
case "/v1/session_result":
_, _ = w.Write([]byte(`[{"driver_number":1,"position":1,"points":25}]`))
case "/v1/starting_grid":
_, _ = w.Write([]byte(`[{"driver_number":1,"position":1}]`))
default:
http.NotFound(w, r)
}
}))
}
func TestOpenF1ChampionshipHubIdentityFailureIsPartialAndCached(t *testing.T) {
upstream := championshipTestUpstream(t, false, true)
defer upstream.Close()
client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
defer client.Close()
server := NewServer(client, 0, nil)
year := time.Now().Year()
_, incomplete, err := server.openF1ChampionshipHub(client.Scoped(), year)
if err != nil {
t.Fatal(err)
}
if !incomplete {
t.Fatal("missing championship driver identity was labelled complete")
}
_, source, freshness, ok := server.hubCache.getWithMetadata(year, time.Now())
if !ok || source != "openf1" || freshness != "partial" {
t.Fatalf("cached metadata = hit %v, %q/%q", ok, source, freshness)
}
}
func TestFetchSeasonRacesMeetingWithoutRaceIsIncomplete(t *testing.T) {
upstream := championshipTestUpstream(t, true, false)
defer upstream.Close()
client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
defer client.Close()
races, incomplete, err := fetchSeasonRaces(client.Scoped(), time.Now().Year())
if err != nil {
t.Fatal(err)
}
if !incomplete || len(races) != 0 {
t.Fatalf("no-Race meeting = races %d, incomplete %v", len(races), incomplete)
}
}
func TestFetchSeasonRacesRecognizedTestingMeetingIsNotIncomplete(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/meetings":
_, _ = w.Write([]byte(`[{"meeting_key":1253,"meeting_name":"Pre-Season Testing"}]`))
case "/v1/sessions":
_, _ = w.Write([]byte(`[{"session_key":1,"meeting_key":1253,"session_name":"Day 1","session_type":"Testing"}]`))
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
defer client.Close()
races, incomplete, err := fetchSeasonRaces(client.Scoped(), time.Now().Year())
if err != nil {
t.Fatal(err)
}
if incomplete || len(races) != 0 {
t.Fatalf("recognized testing meeting = races %d, incomplete %v", len(races), incomplete)
}
}
func TestKnownNonChampionshipMeetingRequiresTestingToken(t *testing.T) {
if !isKnownNonChampionshipMeeting(models.Meeting{MeetingName: "Pre-Season Testing"}, nil) {
t.Fatal("pre-season testing was not recognized")
}
if isKnownNonChampionshipMeeting(models.Meeting{MeetingName: "Fastest Grand Prix"}, nil) {
t.Fatal("substring inside a normal word was treated as testing")
}
if !isKnownNonChampionshipMeeting(models.Meeting{MeetingName: "Winter Event"}, []models.Session{{SessionType: "Test"}}) {
t.Fatal("explicit Test session was not recognized")
}
}
func TestHandleChampionshipHubSourceLocalWithoutAggregateIsLimited(t *testing.T) {
server := NewServer(nil, 0, nil)
recorder := httptest.NewRecorder()
server.handleChampionshipHub(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/championship/hub?year=2026&source=local", nil))
if recorder.Code != http.StatusOK || recorder.Header().Get(dataSourceHeader) != "none" || recorder.Header().Get(dataFreshnessHeader) != "limited" {
t.Fatalf("empty local championship = %d %q/%q body=%s", recorder.Code, recorder.Header().Get(dataSourceHeader), recorder.Header().Get(dataFreshnessHeader), recorder.Body.String())
}
}
func raceResult(num, pos int, pts float64) models.SessionResult { func raceResult(num, pos int, pts float64) models.SessionResult {
return models.SessionResult{DriverNumber: num, Position: pos, Points: pts} return models.SessionResult{DriverNumber: num, Position: pos, Points: pts}
} }

View File

@@ -0,0 +1,120 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/AmanTahiliani/box-box/internal/api"
)
func componentTestServer(t *testing.T, responses map[string]string, failures map[string]bool) *Server {
t.Helper()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if failures[r.URL.Path] {
http.Error(w, "component unavailable", http.StatusBadGateway)
return
}
body, ok := responses[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
}))
t.Cleanup(upstream.Close)
client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
t.Cleanup(func() { _ = client.Close() })
return NewServer(client, 0, nil)
}
func assertAvailabilityHeaders(t *testing.T, recorder *httptest.ResponseRecorder, source, freshness string) {
t.Helper()
if recorder.Code != http.StatusOK || recorder.Header().Get(dataSourceHeader) != source || recorder.Header().Get(dataFreshnessHeader) != freshness {
t.Fatalf("response = status %d, metadata %q/%q, body=%s", recorder.Code, recorder.Header().Get(dataSourceHeader), recorder.Header().Get(dataFreshnessHeader), recorder.Body.String())
}
}
func TestResultsAndGridIdentityFailuresReportPartial(t *testing.T) {
tests := []struct {
name string
path string
body string
run func(*Server, http.ResponseWriter, *http.Request)
}{
{name: "results", path: "/v1/session_result", body: `[{"driver_number":1,"position":1}]`, run: func(s *Server, w http.ResponseWriter, r *http.Request) { s.handleResults(w, r) }},
{name: "grid", path: "/v1/starting_grid", body: `[{"driver_number":1,"position":1}]`, run: func(s *Server, w http.ResponseWriter, r *http.Request) { s.handleGrid(w, r) }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := componentTestServer(t, map[string]string{tt.path: tt.body}, map[string]bool{"/v1/drivers": true})
recorder := httptest.NewRecorder()
tt.run(server, recorder, httptest.NewRequest(http.MethodGet, "/api/v1/"+tt.name+"?session_key=99&source=openf1", nil))
assertAvailabilityHeaders(t, recorder, "openf1", "partial")
})
}
}
func TestStrategyOptionalComponentFailureReportsPartial(t *testing.T) {
server := componentTestServer(t, map[string]string{
"/v1/stints": `[{"driver_number":1,"stint_number":1,"lap_start":1,"lap_end":10,"compound":"MEDIUM"}]`,
"/v1/pit": `[]`,
"/v1/session_result": `[{"driver_number":1,"position":1,"number_of_laps":10}]`,
"/v1/race_control": `[]`,
}, map[string]bool{"/v1/drivers": true})
recorder := httptest.NewRecorder()
server.handleStrategy(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/strategy?session_key=99", nil))
assertAvailabilityHeaders(t, recorder, "openf1", "partial")
}
func TestStrategyEmptyPrimaryDataReportsLimited(t *testing.T) {
server := componentTestServer(t, map[string]string{
"/v1/stints": `[]`,
"/v1/pit": `[]`,
"/v1/session_result": `[{"driver_number":1,"position":1,"number_of_laps":10}]`,
"/v1/drivers": `[{"driver_number":1,"full_name":"Max Verstappen","team_name":"Red Bull","team_colour":"3671c6"}]`,
"/v1/race_control": `[]`,
}, nil)
recorder := httptest.NewRecorder()
server.handleStrategy(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/strategy?session_key=99", nil))
assertAvailabilityHeaders(t, recorder, "openf1", "limited")
}
func TestLapsComparisonDoesNotLabelMissingComponentsFresh(t *testing.T) {
tests := []struct {
name string
laps string
freshness string
}{
{name: "empty primary data", laps: `[]`, freshness: "limited"},
{name: "optional components failed", laps: `[{"driver_number":1,"lap_number":1}]`, freshness: "partial"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := componentTestServer(t, map[string]string{"/v1/laps": tt.laps}, map[string]bool{
"/v1/stints": true,
"/v1/pit": true,
"/v1/race_control": true,
"/v1/drivers": true,
})
recorder := httptest.NewRecorder()
server.handleLapsComparison(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/laps/comparison?session_key=99", nil))
assertAvailabilityHeaders(t, recorder, "openf1", tt.freshness)
})
}
t.Run("primary laps failure is an error", func(t *testing.T) {
server := componentTestServer(t, nil, map[string]bool{
"/v1/laps": true,
"/v1/stints": true,
"/v1/pit": true,
"/v1/race_control": true,
})
recorder := httptest.NewRecorder()
server.handleLapsComparison(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/laps/comparison?session_key=99", nil))
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String())
}
})
}

77
internal/web/context.go Normal file
View File

@@ -0,0 +1,77 @@
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() {
markDataResponse(w, "none", "limited")
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
}
if focus := focusedContextSession(context); focus != nil {
markDataResponse(w, focus.Availability.Source, focus.Availability.Freshness)
} else {
markDataResponse(w, "none", "limited")
}
writeJSON(w, context)
}
// focusedContextSession selects the session whose state the Weekend shell is
// presenting. An older terminal/default session must never override an
// upcoming focus session's metadata.
func focusedContextSession(context query.WeekendContext) *query.ContextSession {
if context.ActiveSession != nil {
return context.ActiveSession
}
if context.FocusMeeting == nil {
return nil
}
focusKey := context.FocusMeeting.MeetingKey
for _, ref := range []*query.ContextSession{context.NextSession, context.PreviousCompletedSession, context.DefaultAnalysisSession} {
if ref != nil && ref.Meeting != nil && ref.Meeting.MeetingKey == focusKey {
return ref
}
}
return nil
}
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,196 @@
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)
}
if rr.Header().Get(dataSourceHeader) != "none" || rr.Header().Get(dataFreshnessHeader) != "limited" {
t.Fatalf("missing context metadata = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
}
}
func TestWeekendContextHandlerEmptyStoreReportsLimited(t *testing.T) {
st := openContextStore(t)
s := NewServer(nil, 0, st)
rr := httptest.NewRecorder()
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
if rr.Code != http.StatusOK || rr.Header().Get(dataSourceHeader) != "none" || rr.Header().Get(dataFreshnessHeader) != "limited" {
t.Fatalf("empty context = %d %q/%q body=%s", rr.Code, rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader), rr.Body.String())
}
}
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)
}
if got.ActiveSession.Availability.Source != "mixed" || got.ActiveSession.Availability.Freshness != "live" {
t.Fatalf("live source/freshness = %+v", got.ActiveSession.Availability)
}
if rr.Header().Get(dataSourceHeader) != "mixed" || rr.Header().Get(dataFreshnessHeader) != "live" {
t.Fatalf("response source/freshness = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
}
}
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.PreviousCompletedSession.Availability.Source != "mixed" || got.PreviousCompletedSession.Availability.Freshness != "archive" {
t.Fatalf("archive source/freshness = %+v", got.PreviousCompletedSession.Availability)
}
if rr.Header().Get(dataSourceHeader) != "mixed" || rr.Header().Get(dataFreshnessHeader) != "archive" {
t.Fatalf("archive response source/freshness = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
}
if got.DefaultAnalysisSession != nil {
t.Fatal("archive-only session must not become local default analysis")
}
}
func TestWeekendContextMetadataFollowsUpcomingFocusNotTerminalPrevious(t *testing.T) {
st := openContextStore(t)
seedContextHandler(t, st)
if err := st.UpsertMeeting(store.Meeting{MeetingKey: 2, MeetingName: "Belgian Grand Prix", CircuitShortName: "Spa", Year: 2026, DateStart: "2026-07-10T09:00:00Z", DateEnd: "2026-07-12T16:00:00Z"}); err != nil {
t.Fatal(err)
}
if err := st.UpsertSession(store.Session{SessionKey: 21, MeetingKey: 2, SessionName: "Practice 1", SessionType: "Practice", DateStart: "2026-07-10T09:00:00Z", DateEnd: "2026-07-10T10:00:00Z"}); err != nil {
t.Fatal(err)
}
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.FocusMeeting == nil || got.FocusMeeting.MeetingKey != 2 || got.NextSession == nil {
t.Fatalf("focus context = %+v", got)
}
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Availability.Freshness != "archive" {
t.Fatalf("terminal previous missing = %+v", got.PreviousCompletedSession)
}
if rr.Header().Get(dataSourceHeader) != "local" || rr.Header().Get(dataFreshnessHeader) != "local" {
t.Fatalf("focus metadata was overridden by archive = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
}
}
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)
}
}
}
func TestWeekendContextHandlerSerializesRaceHubRefreshDeadline(t *testing.T) {
st := openContextStore(t)
seedContextHandler(t, st)
if err := st.UpsertSessionResult(store.SessionResult{SessionKey: 11, MeetingKey: 1, DriverNumber: 1, Position: 1}); err != nil {
t.Fatal(err)
}
if err := st.UpsertMeeting(store.Meeting{MeetingKey: 2, MeetingName: "Belgian Grand Prix", CircuitShortName: "Spa", Year: 2026, DateStart: "2026-07-17T09:00:00Z", DateEnd: "2026-07-19T16:00:00Z"}); err != nil {
t.Fatal(err)
}
if err := st.UpsertSession(store.Session{SessionKey: 21, MeetingKey: 2, SessionName: "Practice 1", SessionType: "Practice", DateStart: "2026-07-17T09:00:00Z", DateEnd: "2026-07-17T10:00:00Z"}); err != nil {
t.Fatal(err)
}
s := NewServer(nil, 0, st)
s.query = query.NewServiceWithClock(st, func() time.Time {
return time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)
})
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.RaceHubPreSession || got.RaceHubRefreshAt != "2026-07-17T09:00:00Z" || got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 21 {
t.Fatalf("race hub context = %+v", got)
}
}

View File

@@ -1,18 +1,25 @@
package web package web
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/models"
) )
// --- /api/v1/driver/summary --- // --- /api/v1/driver/summary ---
// Per-driver season summary: championship standing plus per-round race results, // Per-driver season summary: championship standing plus per-round race results.
// aggregated server-side from the same sources as the championship hub. Caching // Current-season identity/results are local-first from the domain DB. Optional
// relies on the OpenF1 client's HTTP cache TTLs — no extra layer here. // OpenF1 enrichment (headshot / polished identity) is bounded so it cannot hang
// the profile when remote data is slow or unavailable.
// driverEnrichmentTimeout bounds optional remote enrichment so a hung OpenF1
// call never blocks a local-first profile response. Overridable in tests.
var driverEnrichmentTimeout = 2 * time.Second
type driverSummaryRound struct { type driverSummaryRound struct {
MeetingKey int `json:"meeting_key"` MeetingKey int `json:"meeting_key"`
@@ -49,6 +56,11 @@ type driverSummaryResponse struct {
Cumulative []float64 `json:"cumulative"` Cumulative []float64 `json:"cumulative"`
RoundLabels []string `json:"round_labels"` RoundLabels []string `json:"round_labels"`
Rounds []driverSummaryRound `json:"rounds"` Rounds []driverSummaryRound `json:"rounds"`
// Source is "local" when served from the domain DB, else "openf1".
Source string `json:"source,omitempty"`
// Enrichment is "full" when optional remote identity landed, "limited"
// when it timed out/failed, or "none" when no enrichment was attempted.
Enrichment string `json:"enrichment,omitempty"`
} }
func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
@@ -61,12 +73,102 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
if year == 0 { if year == 0 {
year = time.Now().Year() year = time.Now().Year()
} }
mode := parseSourceMode(r)
// Driver summary is local-first for current-season identity/results. When the
// caller omits ?source=, prefer auto (local then OpenF1) rather than the
// package default of openf1-only.
if r.URL.Query().Get("source") == "" {
mode = sourceAuto
}
client := s.client.Scoped()
champ, err := s.client.GetDriverChampionshipForYear(year) if mode == sourceLocal || mode == sourceAuto {
resp, sessionKey, ok, lerr := s.localDriverSummary(year, driverNumber)
if lerr != nil {
writeError(w, lerr, http.StatusInternalServerError, false)
return
}
if ok {
if mode != sourceLocal {
tryEnrichDriverSummary(r.Context(), client, &resp, sessionKey)
}
switch resp.Enrichment {
case "full":
markMixedResponse(w, client, false)
case "limited":
markDataResponse(w, "local", "limited")
default:
markLocalResponse(w, false)
}
writeJSON(w, resp)
return
}
if mode == sourceLocal {
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
return
}
}
resp, incomplete, err := openF1DriverSummary(client, year, driverNumber)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
if resp == nil {
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
return
}
markOpenF1AggregateResponse(w, client, incomplete)
writeJSON(w, resp)
}
func (s *Server) localDriverSummary(year, driverNumber int) (driverSummaryResponse, int, bool, error) {
if !s.hasLocalQuery() {
return driverSummaryResponse{}, 0, false, nil
}
inputs, err := s.query.GetChampionshipInputs(year)
if err != nil {
return driverSummaryResponse{}, 0, false, err
}
if len(inputs.Champ) == 0 {
return driverSummaryResponse{}, 0, false, nil
}
races := make([]meetingRace, 0, len(inputs.Races))
for _, race := range inputs.Races {
races = append(races, meetingRace{
Meeting: race.Meeting,
RaceSessionKey: race.RaceSessionKey,
Results: race.Results,
Grid: race.Grid,
})
}
resp, ok := aggregateDriverSummary(year, driverNumber, races, inputs.Champ, inputs.DriverMap)
if !ok {
return driverSummaryResponse{}, 0, false, nil
}
resp.Source = "local"
resp.Enrichment = "none"
sessionKey := 0
for _, c := range inputs.Champ {
if c.DriverNumber == driverNumber && c.SessionKey > 0 {
sessionKey = c.SessionKey
break
}
if sessionKey == 0 && c.SessionKey > 0 {
sessionKey = c.SessionKey
}
}
return resp, sessionKey, true, nil
}
func openF1DriverSummary(client *api.OpenF1Client, year, driverNumber int) (*driverSummaryResponse, bool, error) {
champ, err := client.GetDriverChampionshipForYear(year)
if err != nil {
return nil, false, err
}
var entry *models.ChampionshipDriver var entry *models.ChampionshipDriver
for i := range champ { for i := range champ {
if champ[i].DriverNumber == driverNumber { if champ[i].DriverNumber == driverNumber {
@@ -75,30 +177,83 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
} }
} }
if entry == nil { if entry == nil {
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound) return nil, false, nil
return
} }
driverInfo := map[int]models.Driver{} driverInfo := map[int]models.Driver{}
if ds, derr := s.client.GetDriversForSession(champ[0].SessionKey); derr == nil { sessionKey := champ[0].SessionKey
if ds, derr := client.GetDriversForSession(sessionKey); derr == nil {
driverInfo = buildDriverMapFirst(ds) driverInfo = buildDriverMapFirst(ds)
} }
if d, ok := s.championshipDriverInfo(entry.SessionKey, driverNumber, driverInfo); ok { d, directErr := client.GetDriver(entry.SessionKey, driverNumber)
driverInfo[driverNumber] = d if directErr == nil && d != nil {
driverInfo[driverNumber] = *d
} else if fallback, ok := driverInfo[driverNumber]; ok {
driverInfo[driverNumber] = fallback
} }
identityIncomplete := !hasDriverPresentation(driverInfo[driverNumber])
races, _, err := s.fetchSeasonRaces(year) races, racesIncomplete, err := fetchSeasonRaces(client, year)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) return nil, false, err
return
} }
resp, ok := aggregateDriverSummary(year, driverNumber, races, champ, driverInfo) resp, ok := aggregateDriverSummary(year, driverNumber, races, champ, driverInfo)
if !ok { if !ok {
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound) return nil, false, nil
}
incomplete := identityIncomplete || racesIncomplete
resp.Source = "openf1"
if identityIncomplete {
resp.Enrichment = "limited"
} else {
resp.Enrichment = "full"
}
return &resp, incomplete, nil
}
func hasDriverPresentation(driver models.Driver) bool {
hasName := driver.FullName != "" || driver.NameAcronym != "" || driver.BroadcastName != ""
return hasName && driver.TeamName != "" && driver.TeamColour != ""
}
// tryEnrichDriverSummary optionally fills headshot / polished identity from
// OpenF1. It never blocks longer than driverEnrichmentTimeout — on timeout or
// failure the local profile remains intact with enrichment=limited.
func tryEnrichDriverSummary(parent context.Context, client *api.OpenF1Client, resp *driverSummaryResponse, sessionKey int) {
if resp == nil || client == nil || sessionKey <= 0 {
if resp != nil && resp.Enrichment == "none" {
// No session to enrich from — leave as none (local identity only).
}
return return
} }
writeJSON(w, resp)
ctx, cancel := context.WithTimeout(parent, driverEnrichmentTimeout)
defer cancel()
driver, err := client.GetDriverContext(ctx, sessionKey, resp.DriverNumber)
if err != nil || driver == nil {
resp.Enrichment = "limited"
return
}
applyDriverEnrichment(resp, *driver)
resp.Enrichment = "full"
}
func applyDriverEnrichment(resp *driverSummaryResponse, d models.Driver) {
if d.HeadshotURL != "" {
resp.HeadshotURL = d.HeadshotURL
}
if d.FullName != "" {
resp.FullName = d.FullName
}
if d.NameAcronym != "" {
resp.NameAcronym = d.NameAcronym
}
if d.TeamName != "" {
resp.TeamName = d.TeamName
}
if d.TeamColour != "" {
resp.TeamColour = d.TeamColour
}
} }
// aggregateDriverSummary is the pure aggregation core (no network) so it can be // aggregateDriverSummary is the pure aggregation core (no network) so it can be

View File

@@ -1,11 +1,16 @@
package web package web
import ( import (
"encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/store"
) )
func driverSummaryFixtures() ([]meetingRace, []models.ChampionshipDriver, map[int]models.Driver) { func driverSummaryFixtures() ([]meetingRace, []models.ChampionshipDriver, map[int]models.Driver) {
@@ -134,3 +139,234 @@ func TestHandleDriverSummaryBadRequest(t *testing.T) {
} }
} }
} }
func seedDriverSummaryStore(t *testing.T, st *store.Store) {
t.Helper()
meetingKey := 1201
sessionKey := 9901
if err := st.UpsertMeeting(store.Meeting{
MeetingKey: meetingKey,
MeetingName: "Bahrain GP",
CountryCode: "BHR",
CountryName: "Bahrain",
Year: 2025,
DateStart: "2025-03-02",
}); err != nil {
t.Fatalf("UpsertMeeting: %v", err)
}
if err := st.UpsertSession(store.Session{
SessionKey: sessionKey,
MeetingKey: meetingKey,
SessionName: "Race",
SessionType: "Race",
DateStart: "2025-03-02T15:00:00Z",
}); err != nil {
t.Fatalf("UpsertSession: %v", err)
}
if err := st.UpsertDriver(store.Driver{
DriverNumber: 1,
FullName: "Max Verstappen",
NameAcronym: "VER",
TeamName: "Red Bull",
TeamColour: "3671c6",
}); err != nil {
t.Fatalf("UpsertDriver: %v", err)
}
if err := st.UpsertSessionDriver(store.SessionDriver{
SessionKey: sessionKey,
DriverNumber: 1,
MeetingKey: meetingKey,
FullName: "Max Verstappen",
NameAcronym: "VER",
TeamName: "Red Bull",
TeamColour: "3671c6",
}); err != nil {
t.Fatalf("UpsertSessionDriver: %v", err)
}
if err := st.UpsertSessionResult(store.SessionResult{
SessionKey: sessionKey,
DriverNumber: 1,
MeetingKey: meetingKey,
Position: 1,
Points: 25,
}); err != nil {
t.Fatalf("UpsertSessionResult: %v", err)
}
if err := st.UpsertStartingGridEntry(store.StartingGridEntry{
SessionKey: sessionKey,
DriverNumber: 1,
MeetingKey: meetingKey,
Position: 1,
}); err != nil {
t.Fatalf("UpsertStartingGridEntry: %v", err)
}
}
func TestHandleDriverSummaryLocalFirstIgnoresHangingEnrichment(t *testing.T) {
prev := driverEnrichmentTimeout
driverEnrichmentTimeout = 40 * time.Millisecond
t.Cleanup(func() { driverEnrichmentTimeout = prev })
st := openTestStore(t)
seedDriverSummaryStore(t, st)
// Enrichment seam: OpenF1 hangs until released. Local summary must still return.
release := make(chan struct{})
cancelObserved := make(chan struct{})
hang := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-release:
case <-r.Context().Done():
close(cancelObserved)
}
}))
t.Cleanup(func() {
close(release)
hang.Close()
})
client := api.NewOpenF1Client(hang.URL, 15*time.Second)
t.Cleanup(func() { _ = client.Close() })
srv := NewServer(client, 8080, st)
start := time.Now()
req := httptest.NewRequest(http.MethodGet, "/api/v1/driver/summary?year=2025&driver_number=1", nil)
rec := httptest.NewRecorder()
srv.handleDriverSummary(rec, req)
elapsed := time.Since(start)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
if elapsed > 500*time.Millisecond {
t.Fatalf("handler blocked on enrichment for %v", elapsed)
}
select {
case <-cancelObserved:
case <-time.After(250 * time.Millisecond):
t.Fatal("timed-out enrichment did not cancel its upstream request")
}
var resp driverSummaryResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Source != "local" {
t.Errorf("source = %q, want local", resp.Source)
}
if resp.Enrichment != "limited" {
t.Errorf("enrichment = %q, want limited", resp.Enrichment)
}
if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "limited" {
t.Errorf("limited metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
}
if resp.DriverNumber != 1 || resp.NameAcronym != "VER" || resp.Points != 25 {
t.Errorf("local identity/results missing: %+v", resp)
}
}
func TestHandleDriverSummaryLocalFirstWithFailingEnrichment(t *testing.T) {
prev := driverEnrichmentTimeout
driverEnrichmentTimeout = 200 * time.Millisecond
t.Cleanup(func() { driverEnrichmentTimeout = prev })
st := openTestStore(t)
seedDriverSummaryStore(t, st)
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusBadGateway)
}))
t.Cleanup(fail.Close)
client := api.NewOpenF1Client(fail.URL, 2*time.Second)
t.Cleanup(func() { _ = client.Close() })
srv := NewServer(client, 8080, st)
req := httptest.NewRequest(http.MethodGet, "/api/v1/driver/summary?year=2025&driver_number=1&source=auto", nil)
rec := httptest.NewRecorder()
srv.handleDriverSummary(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
var resp driverSummaryResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Source != "local" {
t.Errorf("source = %q, want local", resp.Source)
}
if resp.Enrichment != "limited" {
t.Errorf("enrichment = %q, want limited", resp.Enrichment)
}
if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "limited" {
t.Errorf("limited metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
}
if resp.FullName != "Max Verstappen" {
t.Errorf("full_name = %q, want local identity", resp.FullName)
}
}
func TestHandleDriverSummarySourceLocalOnly(t *testing.T) {
st := openTestStore(t)
seedDriverSummaryStore(t, st)
// Even with a broken OpenF1 client, source=local must succeed from the DB.
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "nope", http.StatusInternalServerError)
}))
t.Cleanup(fail.Close)
client := api.NewOpenF1Client(fail.URL, time.Second)
t.Cleanup(func() { _ = client.Close() })
srv := NewServer(client, 8080, st)
req := httptest.NewRequest(http.MethodGet, "/api/v1/driver/summary?year=2025&driver_number=1&source=local", nil)
rec := httptest.NewRecorder()
srv.handleDriverSummary(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "local" {
t.Fatalf("local metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
}
}
func TestHandleRemoteDriverSummaryReportsIdentityAndRoundLimitations(t *testing.T) {
tests := []struct {
name string
driversOK bool
meetingHasRace bool
wantEnrichment string
}{
{name: "missing identity", driversOK: false, meetingHasRace: true, wantEnrichment: "limited"},
{name: "missing race round", driversOK: true, meetingHasRace: false, wantEnrichment: "full"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
upstream := championshipTestUpstream(t, tt.driversOK, tt.meetingHasRace)
defer upstream.Close()
client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
defer client.Close()
server := NewServer(client, 0, nil)
year := time.Now().Year()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/driver/summary?year=%d&driver_number=1&source=openf1", year), nil)
server.handleDriverSummary(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String())
}
var response driverSummaryResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Enrichment != tt.wantEnrichment {
t.Fatalf("enrichment = %q, want %q", response.Enrichment, tt.wantEnrichment)
}
if recorder.Header().Get(dataSourceHeader) != "openf1" || recorder.Header().Get(dataFreshnessHeader) != "partial" {
t.Fatalf("remote limitation metadata = %q/%q", recorder.Header().Get(dataSourceHeader), recorder.Header().Get(dataFreshnessHeader))
}
})
}
}

65
internal/web/freshness.go Normal file
View File

@@ -0,0 +1,65 @@
package web
import (
"net/http"
)
const (
dataSourceHeader = "X-BoxBox-Data-Source"
dataFreshnessHeader = "X-BoxBox-Data-Freshness"
)
type staleResponseReporter interface {
LastResponseWasStale() bool
}
// markOpenF1Response publishes request-scoped success provenance. Callers must
// pass the scoped client used for this response, never Server.client.
func markOpenF1Response(w http.ResponseWriter, client staleResponseReporter) {
markOpenF1AggregateResponse(w, client, false)
}
func markOpenF1AggregateResponse(w http.ResponseWriter, client staleResponseReporter, partial bool) {
freshness := "fresh"
if partial {
freshness = "partial"
}
markOpenF1Availability(w, client, freshness)
}
func markOpenF1Availability(w http.ResponseWriter, client staleResponseReporter, freshness string) {
w.Header().Set(dataSourceHeader, "openf1")
if client != nil && client.LastResponseWasStale() {
w.Header().Set(dataFreshnessHeader, "stale")
return
}
if freshness == "" {
freshness = "fresh"
}
w.Header().Set(dataFreshnessHeader, freshness)
}
func markDataResponse(w http.ResponseWriter, source, freshness string) {
w.Header().Set(dataSourceHeader, source)
w.Header().Set(dataFreshnessHeader, freshness)
}
func markLocalResponse(w http.ResponseWriter, partial bool) {
w.Header().Set(dataSourceHeader, "local")
if partial {
w.Header().Set(dataFreshnessHeader, "partial")
return
}
w.Header().Set(dataFreshnessHeader, "local")
}
func markMixedResponse(w http.ResponseWriter, client staleResponseReporter, partial bool) {
w.Header().Set(dataSourceHeader, "mixed")
if client != nil && client.LastResponseWasStale() {
w.Header().Set(dataFreshnessHeader, "stale")
} else if partial {
w.Header().Set(dataFreshnessHeader, "partial")
} else {
w.Header().Set(dataFreshnessHeader, "local")
}
}

View File

@@ -0,0 +1,86 @@
package web
import (
"database/sql"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/AmanTahiliani/box-box/internal/api"
_ "modernc.org/sqlite"
)
type fakeStaleReporter bool
func (f fakeStaleReporter) LastResponseWasStale() bool { return bool(f) }
func TestStaleFreshnessTakesPrecedenceOverPartialAndLimited(t *testing.T) {
for _, fallback := range []string{"partial", "limited"} {
recorder := httptest.NewRecorder()
markOpenF1Availability(recorder, fakeStaleReporter(true), fallback)
if recorder.Header().Get(dataFreshnessHeader) != "stale" {
t.Fatalf("fallback %q overrode stale: %q", fallback, recorder.Header().Get(dataFreshnessHeader))
}
}
}
func TestOpenF1HandlerReportsFreshThenStaleSuccess(t *testing.T) {
year := time.Now().Year()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, `[{"meeting_key":1,"meeting_name":"British Grand Prix","year":%d}]`, year)
}))
client := api.NewOpenF1Client(upstream.URL, time.Second)
t.Cleanup(func() { _ = client.Close() })
server := NewServer(client, 0, nil)
cacheKey := fmt.Sprintf("%s/v1/meetings?year=%d", upstream.URL, year)
requestURL := fmt.Sprintf("/api/v1/meetings?year=%d&source=openf1", year)
defer func() {
// Keep the shared application cache clean even if this test fails.
db, err := sql.Open("sqlite", api.DefaultCacheDBPath()+"?_busy_timeout=5000")
if err == nil {
_, _ = db.Exec(`DELETE FROM cache WHERE key = ?`, cacheKey)
_ = db.Close()
}
}()
fresh := httptest.NewRecorder()
server.handleMeetings(fresh, httptest.NewRequest(http.MethodGet, requestURL, nil))
if fresh.Code != http.StatusOK || fresh.Header().Get(dataSourceHeader) != "openf1" || fresh.Header().Get(dataFreshnessHeader) != "fresh" {
t.Fatalf("fresh response status/metadata = %d %q/%q body=%s", fresh.Code, fresh.Header().Get(dataSourceHeader), fresh.Header().Get(dataFreshnessHeader), fresh.Body.String())
}
db, err := sql.Open("sqlite", api.DefaultCacheDBPath()+"?_busy_timeout=5000")
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`UPDATE cache SET created_at = ? WHERE key = ?`, time.Now().Add(-48*time.Hour).Unix(), cacheKey); err != nil {
_ = db.Close()
t.Fatal(err)
}
_ = db.Close()
upstream.Close()
stale := httptest.NewRecorder()
server.handleMeetings(stale, httptest.NewRequest(http.MethodGet, requestURL, nil))
if stale.Code != http.StatusOK || stale.Header().Get(dataSourceHeader) != "openf1" || stale.Header().Get(dataFreshnessHeader) != "stale" {
t.Fatalf("stale response status/metadata = %d %q/%q body=%s", stale.Code, stale.Header().Get(dataSourceHeader), stale.Header().Get(dataFreshnessHeader), stale.Body.String())
}
}
func TestFreshnessHeadersAreExposedToBrowserClients(t *testing.T) {
handler := withCORS(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
markDataResponse(w, "local", "partial")
writeJSON(w, map[string]bool{"ok": true})
}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/test", nil))
if got := recorder.Header().Get("Access-Control-Expose-Headers"); got != dataSourceHeader+", "+dataFreshnessHeader {
t.Fatalf("exposed headers = %q", got)
}
if recorder.Header().Get(dataSourceHeader) != "local" || recorder.Header().Get(dataFreshnessHeader) != "partial" {
t.Fatalf("data metadata = %q/%q", recorder.Header().Get(dataSourceHeader), recorder.Header().Get(dataFreshnessHeader))
}
}

View File

@@ -134,7 +134,7 @@ func (h *SSEHub) applySnapshot(data live.LiveStreamData, now time.Time) liveStat
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
if live.SessionStatusIsActive(data.SessionStatus) { if snapshotIsLive(data) {
h.isLive = true h.isLive = true
h.activeSnapshot = &data h.activeSnapshot = &data
if data.PositionUpdated && len(data.Positions) > 0 { if data.PositionUpdated && len(data.Positions) > 0 {
@@ -208,6 +208,40 @@ func (h *SSEHub) stateLocked() liveStatePayload {
return payload return payload
} }
// snapshotIsLive reports whether the current live-timing snapshot represents an
// ongoing session that should be surfaced as live.
//
// An actively running session (Started/Resumed) is always live. A non-terminal
// but temporarily inactive session — e.g. a red-flag pause where SessionStatus
// drops to "Inactive" while the session is still in progress — is also live when
// the snapshot itself carries live evidence: a session clock with time remaining
// plus session metadata. This keeps the paused track state visible instead of
// collapsing to the inactive empty state.
//
// Terminal sessions (Finished/Finalised/Ends/Aborted) are never live. A generic
// inactive/no-session stale snapshot is not live either: the predicate keys off
// the current snapshot's clock and session, so old metadata alone is not enough.
func snapshotIsLive(data live.LiveStreamData) bool {
if live.SessionStatusIsActive(data.SessionStatus) {
return true
}
if live.SessionStatusIsTerminal(data.SessionStatus) {
return false
}
return clockHasTimeRemaining(data.Clock) && data.Session.SessionName != ""
}
// clockHasTimeRemaining reports whether an "HH:MM:SS" session clock has any time
// left. Empty or all-zero clocks (a spent or absent session) return false.
func clockHasTimeRemaining(clock string) bool {
for _, r := range clock {
if r >= '1' && r <= '9' {
return true
}
}
return false
}
func hasLiveSnapshotData(data live.LiveStreamData) bool { func hasLiveSnapshotData(data live.LiveStreamData) bool {
return len(data.Drivers) > 0 || return len(data.Drivers) > 0 ||
len(data.DriverInfo) > 0 || len(data.DriverInfo) > 0 ||
@@ -330,7 +364,15 @@ func cloneLivePositions(in map[string]live.LivePositionData) map[string]live.Liv
// handleLiveState returns the current live data snapshot as JSON. // handleLiveState returns the current live data snapshot as JSON.
func (s *Server) handleLiveState(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLiveState(w http.ResponseWriter, r *http.Request) {
writeJSON(w, s.hub.State()) state := s.hub.State()
freshness := "limited"
if state.IsLive {
freshness = "live"
} else if state.LastSnapshot != nil {
freshness = "archive"
}
markDataResponse(w, "fia", freshness)
writeJSON(w, state)
} }
// handleSSEStream is the persistent SSE endpoint for live data. // handleSSEStream is the persistent SSE endpoint for live data.

View File

@@ -51,6 +51,101 @@ func TestSSEHubArchivesTerminalSessionSnapshot(t *testing.T) {
} }
} }
func TestSnapshotIsLive(t *testing.T) {
redFlag := live.LiveStreamData{
SessionStatus: "Inactive",
TrackStatus: "2",
Clock: "00:03:27",
Session: live.LiveSessionMeta{MeetingName: "Belgian Grand Prix", SessionName: "Practice 2", SessionType: "Practice"},
}
tests := []struct {
name string
data live.LiveStreamData
want bool
}{
{"started", live.LiveStreamData{SessionStatus: "Started"}, true},
{"resumed", live.LiveStreamData{SessionStatus: "Resumed"}, true},
{"red flag inactive with clock and session", redFlag, true},
{"terminal finished with clock", func() live.LiveStreamData {
d := redFlag
d.SessionStatus = "Finished"
return d
}(), false},
{"inactive spent clock", func() live.LiveStreamData {
d := redFlag
d.Clock = "00:00:00"
return d
}(), false},
{"inactive no clock", func() live.LiveStreamData {
d := redFlag
d.Clock = ""
return d
}(), false},
{"inactive no session metadata", func() live.LiveStreamData {
d := redFlag
d.Session = live.LiveSessionMeta{}
return d
}(), false},
}
for _, tt := range tests {
if got := snapshotIsLive(tt.data); got != tt.want {
t.Errorf("%s: snapshotIsLive = %v, want %v", tt.name, got, tt.want)
}
}
}
func TestSSEHubKeepsRedFlagSessionLive(t *testing.T) {
hub := newSSEHub()
now := time.Date(2026, 7, 26, 14, 0, 0, 0, time.UTC)
redFlag := live.LiveStreamData{
SessionStatus: "Inactive",
TrackStatus: "2",
Clock: "00:03:27",
Session: live.LiveSessionMeta{MeetingName: "Belgian Grand Prix", SessionName: "Practice 2", SessionType: "Practice"},
SnapshotUpdated: true,
}
state := hub.applySnapshot(redFlag, now)
if !state.IsLive {
t.Fatal("red-flag paused session should report is_live=true")
}
if state.Data == nil {
t.Fatal("red-flag paused session must still send the active snapshot")
}
if state.Data.TrackStatus != "2" {
t.Fatalf("track status = %q, want the paused state \"2\"", state.Data.TrackStatus)
}
if state.LastSnapshot != nil {
t.Fatalf("red-flag session should not be archived: %+v", state.LastSnapshot)
}
}
func TestSSEHubStaleInactiveSnapshotNotLive(t *testing.T) {
hub := newSSEHub()
now := time.Date(2026, 7, 26, 14, 0, 0, 0, time.UTC)
// Inactive snapshot with session metadata but no remaining clock: this is a
// stale/no-live-evidence snapshot and must not be surfaced as live.
stale := live.LiveStreamData{
SessionStatus: "Inactive",
Clock: "00:00:00",
Session: live.LiveSessionMeta{MeetingName: "Belgian Grand Prix", SessionName: "Practice 2"},
SnapshotUpdated: true,
}
state := hub.applySnapshot(stale, now)
if state.IsLive {
t.Fatal("stale inactive snapshot should report is_live=false")
}
if state.Data != nil {
t.Fatalf("stale inactive snapshot leaked into active data: %+v", state.Data)
}
if state.LastSnapshot == nil {
t.Fatal("stale inactive snapshot with metadata should be archived")
}
}
func TestHandleLiveStateKeepsArchiveOutOfActiveData(t *testing.T) { func TestHandleLiveStateKeepsArchiveOutOfActiveData(t *testing.T) {
hub := newSSEHub() hub := newSSEHub()
now := time.Date(2026, 7, 4, 14, 0, 0, 0, time.UTC) now := time.Date(2026, 7, 4, 14, 0, 0, 0, time.UTC)

View File

@@ -9,6 +9,7 @@ import (
) )
func (s *Server) handleSeasons(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSeasons(w http.ResponseWriter, r *http.Request) {
markLocalResponse(w, false)
if !s.hasLocalQuery() { if !s.hasLocalQuery() {
writeJSON(w, []int{}) writeJSON(w, []int{})
return return
@@ -26,6 +27,7 @@ func (s *Server) handleSeasons(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) handleWeekend(w http.ResponseWriter, r *http.Request) { func (s *Server) handleWeekend(w http.ResponseWriter, r *http.Request) {
markLocalResponse(w, false)
meetingKey, err := strconv.Atoi(r.URL.Query().Get("meeting_key")) meetingKey, err := strconv.Atoi(r.URL.Query().Get("meeting_key"))
if err != nil || meetingKey == 0 { if err != nil || meetingKey == 0 {
http.Error(w, "meeting_key required", http.StatusBadRequest) http.Error(w, "meeting_key required", http.StatusBadRequest)
@@ -46,5 +48,6 @@ func (s *Server) handleWeekend(w http.ResponseWriter, r *http.Request) {
writeError(w, err, http.StatusInternalServerError, false) writeError(w, err, http.StatusInternalServerError, false)
return return
} }
markLocalResponse(w, weekend.Source == query.ResponseSourcePartial)
writeJSON(w, weekend) writeJSON(w, weekend)
} }

View File

@@ -17,6 +17,7 @@ func (s *Server) handleRaceHub(w http.ResponseWriter, r *http.Request) {
} }
if !s.hasLocalQuery() { if !s.hasLocalQuery() {
markDataResponse(w, "none", "limited")
writeJSON(w, emptyRaceHub(sessionKey)) writeJSON(w, emptyRaceHub(sessionKey))
return return
} }
@@ -26,6 +27,14 @@ func (s *Server) handleRaceHub(w http.ResponseWriter, r *http.Request) {
writeError(w, err, http.StatusInternalServerError, false) writeError(w, err, http.StatusInternalServerError, false)
return return
} }
switch hub.Source {
case query.ResponseSourceNone:
markDataResponse(w, "none", "limited")
case query.ResponseSourcePartial:
markLocalResponse(w, true)
default:
markLocalResponse(w, false)
}
writeJSON(w, hub) writeJSON(w, hub)
} }

View File

@@ -86,11 +86,29 @@ func TestHandleRaceHubWithoutStore(t *testing.T) {
if hub.Source != query.ResponseSourceNone { if hub.Source != query.ResponseSourceNone {
t.Fatalf("source = %q, want %q", hub.Source, query.ResponseSourceNone) t.Fatalf("source = %q, want %q", hub.Source, query.ResponseSourceNone)
} }
if rec.Header().Get(dataSourceHeader) != "none" || rec.Header().Get(dataFreshnessHeader) != "limited" {
t.Fatalf("missing hub metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
}
if hub.Datasets["session"].Status != query.DatasetStatusMissing { if hub.Datasets["session"].Status != query.DatasetStatusMissing {
t.Fatalf("session dataset = %+v, want missing", hub.Datasets["session"]) t.Fatalf("session dataset = %+v, want missing", hub.Datasets["session"])
} }
} }
func TestHandleRaceHubUnknownSessionWithStoreReportsLimited(t *testing.T) {
st := openTestStore(t)
srv := testServer(t, st)
rec := httptest.NewRecorder()
srv.handleRaceHub(rec, httptest.NewRequest(http.MethodGet, "/api/v1/race-hub?session_key=999999", nil))
var hub query.RaceHub
if err := json.Unmarshal(rec.Body.Bytes(), &hub); err != nil {
t.Fatal(err)
}
if hub.Source != query.ResponseSourceNone || rec.Header().Get(dataSourceHeader) != "none" || rec.Header().Get(dataFreshnessHeader) != "limited" {
t.Fatalf("unknown session = source %q, metadata %q/%q", hub.Source, rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
}
}
func TestHandleRaceHubWithLocalData(t *testing.T) { func TestHandleRaceHubWithLocalData(t *testing.T) {
st := openTestStore(t) st := openTestStore(t)
seedRaceHubStore(t, st) seedRaceHubStore(t, st)
@@ -117,6 +135,12 @@ func TestHandleRaceHubWithLocalData(t *testing.T) {
if hub.Datasets["results"].Status != query.DatasetStatusMissing { if hub.Datasets["results"].Status != query.DatasetStatusMissing {
t.Fatalf("results dataset = %+v, want missing", hub.Datasets["results"]) t.Fatalf("results dataset = %+v, want missing", hub.Datasets["results"])
} }
if hub.Source != query.ResponseSourcePartial {
t.Fatalf("source = %q, want partial", hub.Source)
}
if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "partial" {
t.Fatalf("partial hub metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
}
} }
func TestHandleRaceHubIncludesChapters(t *testing.T) { func TestHandleRaceHubIncludesChapters(t *testing.T) {

View File

@@ -61,15 +61,27 @@ func (s *Server) handleReplayFrames(w http.ResponseWriter, r *http.Request) {
} }
} }
resp, err := assembleReplayFrames(r.Context(), s.client, sessionKey, intervalMS) client := s.client.Scoped()
resp, incomplete, err := assembleReplayFrames(r.Context(), client, sessionKey, intervalMS)
if err != nil { if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return return
} }
markOpenF1Availability(w, client, replayResponseFreshness(resp, incomplete))
writeJSON(w, resp) writeJSON(w, resp)
} }
func assembleReplayFrames(ctx context.Context, client replayDataClient, sessionKey, intervalMS int) (replayFramesResponse, error) { func replayResponseFreshness(resp replayFramesResponse, incomplete bool) string {
if !incomplete {
return "fresh"
}
if len(resp.Frames) == 0 {
return "limited"
}
return "partial"
}
func assembleReplayFrames(ctx context.Context, client replayDataClient, sessionKey, intervalMS int) (replayFramesResponse, bool, error) {
if intervalMS < defaultReplayIntervalMS { if intervalMS < defaultReplayIntervalMS {
intervalMS = defaultReplayIntervalMS intervalMS = defaultReplayIntervalMS
} }
@@ -82,26 +94,26 @@ func assembleReplayFrames(ctx context.Context, client replayDataClient, sessionK
drivers, err := client.GetDriversForSession(sessionKey) drivers, err := client.GetDriversForSession(sessionKey)
if err != nil { if err != nil {
return resp, err return resp, false, err
} }
driverNumbers := uniqueDriverNumbers(drivers) driverNumbers := uniqueDriverNumbers(drivers)
if len(driverNumbers) == 0 { if len(driverNumbers) == 0 {
return resp, nil return resp, true, nil
} }
series, err := fetchReplayLocationSeries(ctx, client, sessionKey, driverNumbers) series, err := fetchReplayLocationSeries(ctx, client, sessionKey, driverNumbers)
if err != nil && len(series) == 0 { if err != nil && len(series) == 0 {
return resp, err return resp, false, err
} }
start, ok := earliestReplayLocationTime(series) start, ok := earliestReplayLocationTime(series)
if !ok { if !ok {
return resp, nil return resp, true, nil
} }
resp.StartTime = start.Format(time.RFC3339Nano) resp.StartTime = start.Format(time.RFC3339Nano)
resp.Frames = snapReplayFrames(series, start, intervalMS) resp.Frames = snapReplayFrames(series, start, intervalMS)
return resp, nil return resp, err != nil || len(series) < len(driverNumbers) || len(resp.Frames) == 0, nil
} }
func uniqueDriverNumbers(drivers []models.Driver) []int { func uniqueDriverNumbers(drivers []models.Driver) []int {

View File

@@ -3,6 +3,7 @@ package web
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync" "sync"
@@ -16,6 +17,7 @@ type fakeReplayClient struct {
drivers []models.Driver drivers []models.Driver
locs map[int][]models.Location locs map[int][]models.Location
err error err error
locErrs map[int]error
mu sync.Mutex mu sync.Mutex
inFlight int inFlight int
@@ -46,7 +48,38 @@ func (f *fakeReplayClient) GetLocation(sessionKey, driverNumber int) ([]models.L
f.inFlight-- f.inFlight--
f.mu.Unlock() f.mu.Unlock()
return f.locs[driverNumber], nil return f.locs[driverNumber], f.locErrs[driverNumber]
}
func TestAssembleReplayFramesReportsPartialDriverSeries(t *testing.T) {
start := time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC)
client := &fakeReplayClient{
drivers: []models.Driver{{DriverNumber: 1}, {DriverNumber: 4}},
locs: map[int][]models.Location{
1: {{Date: start.Format(time.RFC3339Nano), X: 1, Y: 2}},
},
locErrs: map[int]error{4: errors.New("location unavailable")},
}
resp, incomplete, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS)
if err != nil {
t.Fatalf("partial replay should remain usable: %v", err)
}
if !incomplete || len(resp.Frames) != 1 {
t.Fatalf("partial replay = incomplete %v, frames %+v", incomplete, resp.Frames)
}
if got := replayResponseFreshness(resp, incomplete); got != "partial" {
t.Fatalf("partial replay freshness = %q", got)
}
}
func TestAssembleReplayFramesEmptyDriverSetIsLimited(t *testing.T) {
resp, incomplete, err := assembleReplayFrames(context.Background(), &fakeReplayClient{}, 99, defaultReplayIntervalMS)
if err != nil {
t.Fatal(err)
}
if !incomplete || replayResponseFreshness(resp, incomplete) != "limited" {
t.Fatalf("empty replay = incomplete %v, freshness %q", incomplete, replayResponseFreshness(resp, incomplete))
}
} }
func TestAssembleReplayFramesSnapsNearestSamplesAndOmitsEmptyDrivers(t *testing.T) { func TestAssembleReplayFramesSnapsNearestSamplesAndOmitsEmptyDrivers(t *testing.T) {
@@ -70,10 +103,16 @@ func TestAssembleReplayFramesSnapsNearestSamplesAndOmitsEmptyDrivers(t *testing.
}, },
} }
resp, err := assembleReplayFrames(context.Background(), client, 99, 5000) resp, incomplete, err := assembleReplayFrames(context.Background(), client, 99, 5000)
if err != nil { if err != nil {
t.Fatalf("assembleReplayFrames() error = %v", err) t.Fatalf("assembleReplayFrames() error = %v", err)
} }
if !incomplete {
t.Fatal("empty entrant location series was labelled complete")
}
if got := replayResponseFreshness(resp, incomplete); got != "partial" {
t.Fatalf("empty entrant freshness = %q", got)
}
if resp.SessionKey != 99 || resp.Interval != 5000 { if resp.SessionKey != 99 || resp.Interval != 5000 {
t.Fatalf("response metadata = %+v", resp) t.Fatalf("response metadata = %+v", resp)
} }
@@ -112,7 +151,7 @@ func TestAssembleReplayFramesCapsFrameCount(t *testing.T) {
locs: map[int][]models.Location{1: locs}, locs: map[int][]models.Location{1: locs},
} }
resp, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS) resp, _, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS)
if err != nil { if err != nil {
t.Fatalf("assembleReplayFrames() error = %v", err) t.Fatalf("assembleReplayFrames() error = %v", err)
} }
@@ -139,7 +178,7 @@ func TestAssembleReplayFramesBoundsLocationFanOut(t *testing.T) {
delay: 5 * time.Millisecond, delay: 5 * time.Millisecond,
} }
if _, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS); err != nil { if _, _, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS); err != nil {
t.Fatalf("assembleReplayFrames() error = %v", err) t.Fatalf("assembleReplayFrames() error = %v", err)
} }
if client.maxInFlight > replayFetchConcurrency { if client.maxInFlight > replayFetchConcurrency {
@@ -167,7 +206,7 @@ func TestHandleReplayFramesValidatesParamsAndFloorsInterval(t *testing.T) {
client := &fakeReplayClient{drivers: []models.Driver{{DriverNumber: 1}}, locs: map[int][]models.Location{ client := &fakeReplayClient{drivers: []models.Driver{{DriverNumber: 1}}, locs: map[int][]models.Location{
1: {{Date: time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC).Format(time.RFC3339Nano), X: 1, Y: 2}}, 1: {{Date: time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC).Format(time.RFC3339Nano), X: 1, Y: 2}},
}} }}
resp, err := assembleReplayFrames(context.Background(), client, 99, 1000) resp, _, err := assembleReplayFrames(context.Background(), client, 99, 1000)
if err != nil { if err != nil {
t.Fatalf("assembleReplayFrames() error = %v", err) t.Fatalf("assembleReplayFrames() error = %v", err)
} }

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 // REST API — /api/v1/laps/comparison must be registered before /api/v1/laps
// because Go's ServeMux uses longest-prefix matching. // 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/race-hub", s.handleRaceHub)
mux.HandleFunc("/api/v1/seasons", s.handleSeasons) mux.HandleFunc("/api/v1/seasons", s.handleSeasons)
mux.HandleFunc("/api/v1/weekend", s.handleWeekend) mux.HandleFunc("/api/v1/weekend", s.handleWeekend)
@@ -158,6 +159,7 @@ func withCORS(next http.Handler) http.Handler {
w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type") w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Access-Control-Expose-Headers", dataSourceHeader+", "+dataFreshnessHeader)
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return

View File

@@ -11,7 +11,12 @@
"test:visual": "playwright test --config playwright.visual.config.ts", "test:visual": "playwright test --config playwright.visual.config.ts",
"test:visual:prod": "playwright test --config playwright.visual.prod.config.ts", "test:visual:prod": "playwright test --config playwright.visual.prod.config.ts",
"test:visual:update": "playwright test --config playwright.visual.config.ts --update-snapshots", "test:visual:update": "playwright test --config playwright.visual.config.ts --update-snapshots",
"test:visual:prod:update": "playwright test --config playwright.visual.prod.config.ts --update-snapshots" "test:visual:prod:update": "playwright test --config playwright.visual.prod.config.ts --update-snapshots",
"release:fidelity:capture": "playwright test --config playwright.fidelity.config.ts",
"release:fidelity:packet": "node scripts/release-fidelity/generate.mjs",
"release:fidelity:verify": "node scripts/release-fidelity/verify.mjs",
"release:fidelity:gate": "npm run test:visual:prod && npm run release:fidelity:capture && npm run release:fidelity:packet && npm run release:fidelity:verify",
"test:release-fidelity": "node --test scripts/release-fidelity/*.test.mjs"
}, },
"type": "commonjs", "type": "commonjs",
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,37 @@
import { defineConfig } from '@playwright/test'
import { VIEWPORTS } from './tests/visual/helpers'
const E2E_DB = '.playwright/boxbox-fidelity.db'
const API_PORT = process.env.BOXBOX_API_PORT ?? '18080'
const WEB_PORT = process.env.BOXBOX_WEB_PORT ?? '15173'
// Candidate captures are evidence for owner review, not regression baselines.
export default defineConfig({
testDir: './tests/release-fidelity',
fullyParallel: true,
forbidOnly: !!process.env.CI,
reporter: process.env.CI ? 'github' : 'list',
use: {
baseURL: `http://localhost:${WEB_PORT}`,
colorScheme: 'dark',
trace: 'on-first-retry',
},
projects: [
{ name: 'desktop', use: { browserName: 'chromium', viewport: VIEWPORTS.desktop } },
{ name: 'mobile', use: { browserName: 'chromium', viewport: VIEWPORTS.mobile } },
],
webServer: [
{
command: `go run ./scripts/seed-e2e-db/main.go --db ${E2E_DB} && BOXBOX_DISABLE_LIVE=1 BOXBOX_OPENF1_BASE_URL=http://127.0.0.1:9 go run ./cmd/main.go --web --db ${E2E_DB} --port ${API_PORT}`,
url: `http://localhost:${API_PORT}/api/v1/race-hub?session_key=9472`,
reuseExistingServer: false,
timeout: 120_000,
},
{
command: `BOXBOX_API_PORT=${API_PORT} npm run dev --prefix frontend -- --port ${WEB_PORT} --strictPort`,
url: `http://localhost:${WEB_PORT}`,
reuseExistingServer: false,
timeout: 120_000,
},
],
})

View File

@@ -7,10 +7,11 @@ const WEB_PORT = process.env.BOXBOX_WEB_PORT ?? '15173'
export default defineConfig({ export default defineConfig({
testDir: './tests/visual', testDir: './tests/visual',
fullyParallel: true, // All projects share one seeded SQLite database behind the same Go server.
fullyParallel: false,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined, workers: 1,
reporter: process.env.CI ? 'github' : 'html', reporter: process.env.CI ? 'github' : 'html',
snapshotPathTemplate: '{testDir}/{testFileDir}/__snapshots__/{projectName}/{arg}{ext}', snapshotPathTemplate: '{testDir}/{testFileDir}/__snapshots__/{projectName}/{arg}{ext}',
expect: { expect: {

View File

@@ -0,0 +1,86 @@
import { readdir, readFile, stat, writeFile } from 'node:fs/promises'
import { join, relative, resolve } from 'node:path'
const references = [
{ viewport: 'desktop', name: 'weekend-between-races' },
{ viewport: 'desktop', name: 'weekend-live' },
{ viewport: 'mobile', name: 'weekend-between-sessions-mobile' },
]
function option(name, fallback) {
const index = process.argv.indexOf(`--${name}`)
return index === -1 ? fallback : process.argv[index + 1]
}
async function filesBelow(directory) {
const entries = await readdir(directory, { withFileTypes: true })
const files = await Promise.all(entries.map(async (entry) => {
const path = join(directory, entry.name)
return entry.isDirectory() ? filesBelow(path) : [path]
}))
return files.flat()
}
async function locateMockup(root, name) {
const preferred = join(root, `${name}.png`)
try {
await stat(preferred)
return preferred
} catch {
const matches = (await filesBelow(root)).filter((path) => path.endsWith(`${name}.png`))
return matches.length === 1 ? matches[0] : null
}
}
function imageData(path) {
return readFile(path).then((data) => `data:image/png;base64,${data.toString('base64')}`)
}
export async function generatePacket({ version, evidence, mockups }) {
const candidateRoot = join(evidence, 'candidate')
const pairs = []
const missing = []
try {
await stat(mockups)
} catch {
throw new Error(`Approved mockups directory is missing: ${mockups}. It is supplied by the product reference integration.`)
}
for (const { viewport, name } of references) {
const candidate = join(candidateRoot, viewport, `${name}.png`)
const reference = await locateMockup(mockups, name)
try {
await stat(candidate)
} catch {
missing.push(`candidate: ${candidate}`)
}
if (!reference) missing.push(`mockup: ${join(mockups, `${name}.png`)}`)
if (reference) pairs.push({ viewport, name, candidate, reference })
}
if (missing.length) {
throw new Error(`Release-fidelity packet is incomplete:\n${missing.join('\n')}`)
}
const cards = await Promise.all(pairs.map(async ({ viewport, name, candidate, reference }) => `
<section><h2>${viewport}: ${name}</h2><div class="pair">
<figure><figcaption>Approved mockup</figcaption><img src="${await imageData(reference)}"></figure>
<figure><figcaption>Candidate</figcaption><img src="${await imageData(candidate)}"></figure>
</div><p>Reference: <code>${relative(process.cwd(), reference)}</code></p></section>`))
const summary = `# Release Fidelity Evidence: ${version}\n\n- Candidate screenshots: candidate/\n- Approved mockups: ${relative(process.cwd(), mockups)}\n- Review: human owner decision required; this packet does not approve the release.\n\nOpen index.html for side-by-side evidence.\n`
const html = `<!doctype html><title>${version} release-fidelity review</title><style>body{background:#111;color:#eee;font:16px system-ui;margin:2rem}section{margin:3rem 0}.pair{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}figure{margin:0}img{max-width:100%;border:1px solid #555}figcaption{font-weight:700;margin-bottom:.5rem}code{color:#9fd}@media(max-width:700px){.pair{grid-template-columns:1fr}}</style><h1>${version} Release-Fidelity Review</h1><p>Visual regression is a separate automated gate. This packet is evidence for a subjective owner review and is not approval.</p>${cards.join('')}</html>`
await writeFile(join(evidence, 'summary.md'), summary)
await writeFile(join(evidence, 'index.html'), html)
}
if (import.meta.url === `file://${process.argv[1]}`) {
const version = option('version', process.env.RELEASE_FIDELITY_VERSION ?? 'v0.4.0')
const output = process.env.RELEASE_FIDELITY_OUTPUT ?? 'release-fidelity'
const evidence = resolve(option('evidence', join(output, version)))
const mockups = resolve(option('mockups', join('docs/product', version, 'mockups')))
generatePacket({ version, evidence, mockups }).then(
() => console.log(`Release-fidelity packet: ${join(evidence, 'index.html')}`),
(error) => { console.error(error.message); process.exitCode = 1 },
)
}

View File

@@ -0,0 +1,27 @@
import assert from 'node:assert/strict'
import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { generatePacket } from './generate.mjs'
test('generates a self-contained side-by-side packet for desktop and mobile', async () => {
const root = await mkdtemp(join(tmpdir(), 'boxbox-fidelity-'))
const evidence = join(root, 'evidence')
const mockups = join(root, 'mockups')
const references = [
['desktop', 'weekend-between-races'],
['desktop', 'weekend-live'],
['mobile', 'weekend-between-sessions-mobile'],
]
for (const [viewport, name] of references) {
await mkdir(join(evidence, 'candidate', viewport), { recursive: true })
await mkdir(mockups, { recursive: true })
await writeFile(join(evidence, 'candidate', viewport, `${name}.png`), 'candidate')
await writeFile(join(mockups, `${name}.png`), 'mockup')
}
await generatePacket({ version: 'v-test', evidence, mockups })
assert.match(await readFile(join(evidence, 'index.html'), 'utf8'), /Approved mockup/)
assert.match(await readFile(join(evidence, 'summary.md'), 'utf8'), /human owner decision required/)
})

View File

@@ -0,0 +1,50 @@
import { readFile } from 'node:fs/promises'
import { execFileSync } from 'node:child_process'
import { join, resolve } from 'node:path'
function option(name, fallback) {
const index = process.argv.indexOf(`--${name}`)
return index === -1 ? fallback : process.argv[index + 1]
}
function git(...args) {
return execFileSync('git', args, { encoding: 'utf8' }).trim()
}
const version = option('version', process.env.RELEASE_FIDELITY_VERSION ?? 'v0.4.0')
const output = process.env.RELEASE_FIDELITY_OUTPUT ?? 'release-fidelity'
const evidence = resolve(option('evidence', join(output, version)))
const signoff = option('signoff', join('docs/release/owner-reviews', `${version}.md`))
const required = ['index.html', 'summary.md']
try {
for (const file of required) await readFile(join(evidence, file))
git('cat-file', '-e', `HEAD:${signoff}`)
const text = git('show', `HEAD:${signoff}`)
const candidate = text.match(/^- Candidate commit: ([0-9a-f]{40})$/mi)?.[1]
if (!candidate) throw new Error(`${signoff} must contain a full candidate commit SHA`)
git('cat-file', '-e', `${candidate}^{commit}`)
const parent = git('rev-parse', 'HEAD^')
const changed = git('diff', '--name-only', 'HEAD^', 'HEAD').split('\n').filter(Boolean)
if (changed.length !== 1 || changed[0] !== signoff) {
throw new Error(`HEAD must contain only the sign-off file change: ${signoff}`)
}
if (candidate.toLowerCase() !== parent.toLowerCase()) {
throw new Error(`${signoff} candidate must equal HEAD^ (${parent})`)
}
const fields = [
['Version', version],
['Reviewed by', '.+'],
['Reviewed on', '\\d{4}-\\d{2}-\\d{2}'],
['Decision', 'approved'],
]
for (const [name, value] of fields) {
if (!new RegExp(`^- ${name}: ${value}$`, 'm').test(text)) {
throw new Error(`${signoff} must contain "- ${name}: ${value}"`)
}
}
console.log(`Release-fidelity evidence and committed owner approval verified for ${version} at ${candidate}.`)
} catch (error) {
console.error(`Release-fidelity gate blocked: ${error.message}`)
process.exitCode = 1
}

View File

@@ -0,0 +1,80 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import test from 'node:test'
const verifier = resolve('scripts/release-fidelity/verify.mjs')
function git(directory, ...args) {
return execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim()
}
function verify(root, evidence) {
return execFileSync(process.execPath, [verifier, '--version', 'v-test', '--evidence', evidence], {
cwd: root,
encoding: 'utf8',
})
}
async function approvedCandidate({ changeCodeWithSignoff = false, decision = 'approved' } = {}) {
const root = await mkdtemp(join(tmpdir(), 'boxbox-fidelity-verify-'))
git(root, 'init')
git(root, 'config', 'user.email', 'test@example.com')
git(root, 'config', 'user.name', 'Test')
await writeFile(join(root, 'candidate.txt'), 'candidate')
git(root, 'add', 'candidate.txt')
git(root, 'commit', '-m', 'candidate')
const candidate = git(root, 'rev-parse', 'HEAD')
await mkdir(join(root, 'docs/release/owner-reviews'), { recursive: true })
await writeFile(join(root, 'docs/release/owner-reviews/v-test.md'), `- Version: v-test\n- Candidate commit: ${candidate}\n- Reviewed by: Owner\n- Reviewed on: 2026-07-30\n- Decision: ${decision}\n`)
if (changeCodeWithSignoff) await writeFile(join(root, 'candidate.txt'), 'changed with approval')
git(root, 'add', 'docs/release/owner-reviews/v-test.md')
if (changeCodeWithSignoff) git(root, 'add', 'candidate.txt')
git(root, 'commit', '-m', 'owner sign-off')
const evidence = join(root, 'evidence')
await mkdir(evidence)
await writeFile(join(evidence, 'index.html'), '')
await writeFile(join(evidence, 'summary.md'), '')
return { candidate, evidence, root }
}
function assertBlocked(root, evidence) {
assert.throws(() => verify(root, evidence), (error) => {
assert.match(String(error.stderr), /HEAD must contain only the sign-off file change/)
return true
})
}
test('accepts a sign-off-only HEAD for its direct parent candidate', async () => {
const { candidate, evidence, root } = await approvedCandidate()
const output = verify(root, evidence)
assert.match(output, new RegExp(candidate))
})
test('rejects code committed after approval', async () => {
const { evidence, root } = await approvedCandidate()
await writeFile(join(root, 'candidate.txt'), 'changed after review')
git(root, 'add', 'candidate.txt')
git(root, 'commit', '-m', 'code after approval')
assertBlocked(root, evidence)
})
test('rejects a sign-off commit that also changes code', async () => {
const { evidence, root } = await approvedCandidate({ changeCodeWithSignoff: true })
assertBlocked(root, evidence)
})
test('rejects a dirty working-tree edit that spoofs approval', async () => {
const { candidate, evidence, root } = await approvedCandidate({ decision: 'rejected' })
await writeFile(join(root, 'docs/release/owner-reviews/v-test.md'), `- Version: v-test\n- Candidate commit: ${candidate}\n- Reviewed by: Owner\n- Reviewed on: 2026-07-30\n- Decision: approved\n`)
assert.throws(() => verify(root, evidence), (error) => {
assert.match(String(error.stderr), /Decision: approved/)
return true
})
})

View File

@@ -12,6 +12,30 @@ test.describe('Command Center', () => {
await expect(page.getByTestId('hero-last-race-link')).toBeVisible() await expect(page.getByTestId('hero-last-race-link')).toBeVisible()
}) })
test('falls back to the local calendar when season metadata fails', async ({ page }) => {
await page.route(/\/api\/v1\/meetings(?:\?.*)?$/, async (route) => {
const url = new URL(route.request().url())
if (url.searchParams.get('year') !== '2025' || url.searchParams.get('source') !== 'openf1') {
await route.continue()
return
}
await route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: 'season calendar unavailable' }),
})
})
await page.goto('/')
await expect(page.getByTestId('command-center')).toBeVisible()
await expect(page.getByTestId('cc-calendar-1229')).toBeVisible()
await expect(
page.getByText('Using local meetings because the full calendar could not load.'),
).toBeVisible({ timeout: 15_000 })
})
test('nav link reaches command center from race hub', async ({ page }) => { test('nav link reaches command center from race hub', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`) await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await page.getByRole('link', { name: 'Command' }).click() await page.getByRole('link', { name: 'Command' }).click()

View File

@@ -147,6 +147,58 @@ const sprintQualifyingSnapshot = {
}, },
} }
// Practice: the feed sets best laps but no GapToLeader/Interval, and there is
// no race lap-total concept. Exercises computed gaps, the non-race banner, the
// collapsed-by-default tyre panel, and the 390px core-field layout.
const practiceSnapshot = {
is_live: true,
data: {
...raceSnapshot.data,
Drivers: Object.fromEntries(
Array.from({ length: 10 }, (_, index) => {
const num = String(index + 1)
return [
num,
driver(num, index + 1, '', '', {
// Distinct, increasing best laps; P1 fastest, +0.200s per position.
BestLapTime: `1:${(45.9 + index * 0.2).toFixed(3).padStart(6, '0')}`,
LastLapTime: '1:46.500',
NumberOfLaps: 12,
Sectors: index === 0
? [
{ Value: '28.500', PersonalFastest: true, OverallFastest: false },
{ Value: '52.100', PersonalFastest: false, OverallFastest: false },
{ Value: '25.344', PersonalFastest: false, OverallFastest: false },
]
: [],
}),
]
}),
),
DriverInfo: Object.fromEntries(
Array.from({ length: 10 }, (_, index) => {
const num = String(index + 1)
return [num, info(num, `D${index + 1}`, 'Driver', String(index + 1), 'Test Team', index % 2 ? 'FF8000' : '27F4D2')]
}),
),
Tyres: Object.fromEntries(
Array.from({ length: 10 }, (_, index) => [String(index + 1), { Compound: 'SOFT', New: false, Age: index % 4 }]),
),
Session: {
MeetingName: 'Belgian Grand Prix',
CircuitName: 'Spa-Francorchamps',
SessionType: 'Practice',
SessionName: 'Practice 2',
},
TrackStatus: '1',
CurrentLap: 0,
TotalLaps: 0,
Clock: '00:45:00',
ClockRefTime: '2026-07-17T13:00:00Z',
ClockExtrapolating: false,
},
}
test.describe('Live Timing (mocked snapshot)', () => { test.describe('Live Timing (mocked snapshot)', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.route('**/api/v1/live/state', (route) => await page.route('**/api/v1/live/state', (route) =>
@@ -233,6 +285,94 @@ test.describe('Live Timing (mocked Sprint Qualifying)', () => {
}) })
}) })
test.describe('Live Timing (mocked Practice)', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/v1/live/state', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(practiceSnapshot) }),
)
await page.route('**/api/v1/live/stream', (route) =>
route.fulfill({ contentType: 'text/event-stream', body: 'event: heartbeat\ndata: {}\n\n' }),
)
await page.goto('/live')
})
test('derives a gap to P1 from best laps and marks the leader', async ({ page }) => {
const tower = page.locator('.live-tower')
await expect(tower).toBeVisible()
// P1 shows a leader marker, not a fabricated gap.
await expect(tower.locator('tbody tr').first()).toContainText('—')
// P2 shows the computed +0.200 delta.
await expect(tower).toContainText('+0.200')
})
test('omits the race lap counter and keeps the session clock for practice', async ({ page }) => {
await expect(page.getByTestId('live-lap-counter')).toHaveCount(0)
await expect(page.getByTestId('live-clock')).toContainText('00:45:00')
await expect(page.locator('.live-banner')).not.toContainText('L-/-')
})
test('collapses the tyre panel by default so the tower is above the fold', async ({ page }) => {
await expect(page.getByTestId('tyredeg-panel')).toBeVisible()
await expect(page.getByTestId('tyredeg-row')).toHaveCount(0)
await expect(page.locator('.live-tower')).toBeVisible()
})
test('labels Race Control times as UTC', async ({ page }) => {
await expect(page.getByTestId('rc-timezone')).toContainText('UTC')
})
test('marks Live as active in the primary navigation', async ({ page }) => {
await expect(page.getByTestId('nav-live')).toHaveAttribute('data-live-active', 'true')
await expect(page.getByTestId('nav-live').locator('.nav-live-dot')).toBeVisible()
})
})
test.describe('Live Timing (390px practice viewport)', () => {
test.use({ viewport: { width: 390, height: 844 } })
test.beforeEach(async ({ page }) => {
await page.route('**/api/v1/live/state', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(practiceSnapshot) }),
)
await page.route('**/api/v1/live/stream', (route) =>
route.fulfill({ contentType: 'text/event-stream', body: 'event: heartbeat\ndata: {}\n\n' }),
)
await page.goto('/live')
})
test('shows the five core fields with no horizontal overflow', async ({ page }) => {
const tower = page.locator('.live-tower')
await expect(tower).toBeVisible()
// Core comparison fields are present in the header.
await expect(tower.locator('thead')).toContainText('Pos')
await expect(tower.locator('thead')).toContainText('Driver')
await expect(tower.locator('thead')).toContainText('Tyre')
await expect(tower.locator('thead')).toContainText('Best')
await expect(tower.locator('thead')).toContainText('Gap to P1')
// Sectors are hidden from the initial mobile tower (reachable via expand).
const firstSector = tower.locator('thead th', { hasText: 'S1' })
await expect(firstSector).toBeHidden()
// The document must not scroll horizontally.
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
)
expect(overflow).toBeLessThanOrEqual(0)
})
test('exposes sectors through the row detail interaction', async ({ page }) => {
// Sector columns are not in the initial mobile tower...
await expect(page.locator('.live-tower thead th', { hasText: 'S1' })).toBeHidden()
// ...but the P1 row's sectors are reachable by expanding the row.
await page.locator('.live-tower tbody tr', { hasText: 'D1' }).first().click()
const sectors = page.getByTestId('expanded-sectors')
await expect(sectors).toBeVisible()
await expect(sectors).toContainText('28.500')
})
})
test.describe('Live Timing (no session)', () => { test.describe('Live Timing (no session)', () => {
test('shows the empty state when the feed has no snapshot', async ({ page }) => { test('shows the empty state when the feed has no snapshot', async ({ page }) => {
await page.goto('/live') await page.goto('/live')

View File

@@ -2,6 +2,42 @@ import { test, expect } from '@playwright/test'
const FULL_SESSION = 9472 const FULL_SESSION = 9472
const CORE_ONLY_SESSION = 9000 const CORE_ONLY_SESSION = 9000
const CONTEXT_MEETING = {
meeting_key: 1229,
meeting_name: 'Monaco',
country_code: 'MON',
}
const CONTEXT_AVAILABILITY = {
source: 'local', schedule: 'available', live_transport: 'unknown', live_session: 'inactive',
archive: 'unavailable', local_analysis: 'complete', freshness: 'local', limitations: [],
}
function completedContext(refreshAt?: string) {
return {
temporal_state: 'between_weekends',
race_hub_default_session: {
session: { session_key: FULL_SESSION }, meeting: CONTEXT_MEETING, availability: CONTEXT_AVAILABILITY,
},
race_hub_pre_session: false,
race_hub_refresh_at: refreshAt,
}
}
function pendingContext(refreshAt: string) {
return {
temporal_state: 'pre_session',
race_hub_default_session: {
session: {
session_key: 9473, meeting_key: 1229, session_name: 'Practice 1', session_type: 'Practice',
date_start: '2030-01-01T00:00:01Z', date_end: '2030-01-01T01:00:01Z', gmt_offset: '00:00:00',
},
meeting: CONTEXT_MEETING,
availability: { ...CONTEXT_AVAILABILITY, local_analysis: 'not_applicable' },
},
race_hub_pre_session: true,
race_hub_refresh_at: refreshAt,
}
}
test.describe('Race Hub Weekend Workspace', () => { test.describe('Race Hub Weekend Workspace', () => {
test('lands on the Overview tab with workspace identity', async ({ page }) => { test('lands on the Overview tab with workspace identity', async ({ page }) => {
@@ -39,7 +75,17 @@ test.describe('Race Hub Weekend Workspace', () => {
await expect( await expect(
page.getByRole('img', { name: 'Position evolution chart' }), page.getByRole('img', { name: 'Position evolution chart' }),
).toBeVisible() ).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 }) => { test('strategy tab renders stint chart when stints are available', async ({ page }) => {
@@ -58,13 +104,15 @@ test.describe('Race Hub Weekend Workspace', () => {
await expect(page.locator('[data-testid="strategy-chart"]')).not.toBeVisible() 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, page,
}) => { }) => {
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`) await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
await page.getByRole('tab', { name: 'Race Story' }).click() 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() await expect(page.locator('[data-testid="position-chart"]')).not.toBeVisible()
}) })
@@ -90,9 +138,87 @@ test.describe('Race Hub Weekend Workspace', () => {
) )
}) })
test('bare /race-hub redirects to the focus session', async ({ page }) => { test('bare /race-hub shows server-selected completed analysis without changing the URL', async ({ page }) => {
await page.route('**/api/v1/weekend-context', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(completedContext()) }),
)
await page.goto('/race-hub') await page.goto('/race-hub')
await expect(page).toHaveURL(/session_key=\d+/) await expect(page).toHaveURL(/\/race-hub$/)
await expect(page.getByTestId('race-hub')).toBeVisible() await expect(page.getByTestId('race-hub')).toBeVisible()
}) })
test('bare /race-hub hands off to pending pre-session state at the refresh deadline', async ({ page }) => {
await page.clock.install({ time: new Date('2030-01-01T00:00:00Z') })
let requests = 0
const raceHubRequests: number[] = []
page.on('request', (request) => {
const url = new URL(request.url())
if (url.pathname === '/api/v1/race-hub') {
raceHubRequests.push(Number(url.searchParams.get('session_key')))
}
})
await page.route('**/api/v1/weekend-context', (route) => {
requests += 1
const body = requests === 1
? completedContext('2030-01-01T00:00:01Z')
: pendingContext('2030-01-01T00:00:16Z')
return route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
})
await page.goto('/race-hub')
await expect(page.getByTestId('race-hub')).toBeVisible()
await page.clock.fastForward(1_000)
await expect(page.getByTestId('race-hub-pre-session')).toBeVisible()
await expect(page).toHaveURL(/\/race-hub$/)
expect(raceHubRequests).toContain(FULL_SESSION)
expect(raceHubRequests).not.toContain(9473)
})
test('pre-session state opens the weekend switcher and navigates to an explicit session', async ({ page }) => {
await page.route('**/api/v1/weekend-context', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify(pendingContext('2030-01-01T00:00:16Z')),
}),
)
await page.goto('/race-hub')
await expect(page.getByTestId('race-hub-pre-session')).toBeVisible()
const switchWeekend = page.getByTestId('rh-switch-weekend')
await expect(switchWeekend).toHaveAttribute('aria-expanded', 'false')
await switchWeekend.click()
await expect(page.getByTestId('rh-switcher')).toBeVisible()
await expect(switchWeekend).toHaveAttribute('aria-expanded', 'true')
await page.getByTestId(`rh-switcher-session-${FULL_SESSION}`).click()
await expect(page).toHaveURL(new RegExp(`/race-hub\\?session_key=${FULL_SESSION}`))
await expect(page.getByTestId('race-hub')).toBeVisible()
})
test('bare /race-hub recovers when no completed local analysis exists', async ({ page }) => {
await page.route('**/api/v1/weekend-context', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ temporal_state: 'between_weekends', race_hub_pre_session: false }),
}),
)
await page.goto('/race-hub')
await expect(page.getByTestId('race-hub-empty')).toContainText('No completed local analysis yet')
})
test('an explicit session URL remains stable when canonical context would refresh', async ({ page }) => {
let contextRequested = false
await page.route('**/api/v1/weekend-context', (route) => {
contextRequested = true
return route.fulfill({ contentType: 'application/json', body: JSON.stringify(pendingContext('2030-01-01T00:00:01Z')) })
})
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await expect(page.getByTestId('race-hub')).toBeVisible()
await expect(page).toHaveURL(new RegExp(`session_key=${FULL_SESSION}`))
expect(contextRequested).toBe(false)
})
}) })

View File

@@ -0,0 +1,73 @@
import { mkdir } from 'node:fs/promises'
import { join } from 'node:path'
import { expect, test, type Page } from '@playwright/test'
import { waitForScreenshotReady } from '../visual/helpers'
const version = process.env.RELEASE_FIDELITY_VERSION ?? 'v0.4.0'
const output = process.env.RELEASE_FIDELITY_OUTPUT ?? 'release-fidelity'
const activeLiveState = {
is_live: true,
data: {
Drivers: {
'1': { RacingNumber: '1', Position: 1, Interval: '' },
'44': { RacingNumber: '44', Position: 2, Interval: '+2.314' },
},
DriverInfo: {
'1': { RacingNumber: '1', Tla: 'VER', TeamColour: '3671C6' },
'44': { RacingNumber: '44', Tla: 'HAM', TeamColour: 'E8002D' },
},
Tyres: {},
RCMessages: [],
Weather: {},
Session: { MeetingName: 'Monaco', SessionName: 'Race', SessionType: 'Race' },
TrackStatus: '1',
},
}
async function capture(page: Page, name: string, project: string): Promise<void> {
const directory = join(output, version, 'candidate', project)
await mkdir(directory, { recursive: true })
await page.screenshot({ fullPage: true, path: join(directory, `${name}.png`) })
}
async function useSeededCalendar(page: Page): Promise<void> {
await page.route(/\/api\/v1\/meetings\?year=2025&source=openf1$/, (route) =>
route.fulfill({ contentType: 'application/json', body: '[]' }),
)
}
async function gotoCommandCenterFidelityReady(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-calendar-1229')).toBeVisible()
await page.waitForLoadState('networkidle')
await waitForScreenshotReady(page)
}
test.describe('release-fidelity candidate captures', () => {
test('approved screen set', async ({ page }, testInfo) => {
const project = testInfo.project.name
await useSeededCalendar(page)
if (project === 'desktop') {
await page.clock.install({ time: new Date('2025-06-01T12:00:00Z') })
await gotoCommandCenterFidelityReady(page)
await expect(page.getByTestId('hero-last-race')).toBeVisible()
await capture(page, 'weekend-between-races', project)
await page.route('**/api/v1/live/state', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(activeLiveState) }),
)
await page.clock.setFixedTime(new Date('2025-05-25T14:00:00Z'))
await gotoCommandCenterFidelityReady(page)
await expect(page.getByTestId('hero-live-timing')).toBeVisible()
await capture(page, 'weekend-live', project)
} else {
await page.clock.install({ time: new Date('2025-05-25T12:00:00Z') })
await gotoCommandCenterFidelityReady(page)
await expect(page.getByTestId('hero-countdown')).toContainText('Next')
await capture(page, 'weekend-between-sessions-mobile', project)
}
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -8,6 +8,20 @@ export const VIEWPORTS = {
export const FULL_SESSION = 9472 export const FULL_SESSION = 9472
async function routeSeededSeasonCalendar(page: Page): Promise<void> {
await page.route(/\/api\/v1\/meetings(?:\?.*)?$/, async (route) => {
const url = new URL(route.request().url())
if (url.searchParams.get('year') !== '2025' || url.searchParams.get('source') !== 'openf1') {
await route.continue()
return
}
url.searchParams.set('source', 'local')
const response = await route.fetch({ url: url.toString() })
await route.fulfill({ response })
})
}
/** Wait for web fonts and layout to settle before screenshots. */ /** Wait for web fonts and layout to settle before screenshots. */
export async function waitForScreenshotReady(page: Page): Promise<void> { export async function waitForScreenshotReady(page: Page): Promise<void> {
await page.evaluate(() => document.fonts.ready) await page.evaluate(() => document.fonts.ready)
@@ -15,15 +29,11 @@ export async function waitForScreenshotReady(page: Page): Promise<void> {
} }
export async function gotoCommandCenterReady(page: Page): Promise<void> { export async function gotoCommandCenterReady(page: Page): Promise<void> {
await routeSeededSeasonCalendar(page)
await page.goto('/') await page.goto('/')
await expect(page.getByTestId('command-center')).toBeVisible() await expect(page.getByTestId('command-center')).toBeVisible()
await expect(page.getByTestId('cc-focus')).toBeVisible() await expect(page.getByTestId('cc-focus')).toBeVisible()
await expect(page.getByTestId('cc-session-9472')).toBeVisible() await expect(page.getByTestId('cc-calendar-1229')).toBeVisible({ timeout: 15_000 })
// 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 waitForScreenshotReady(page) await waitForScreenshotReady(page)
} }
@@ -36,6 +46,14 @@ export async function gotoRaceHubReady(page: Page, sessionKey = FULL_SESSION): P
await waitForScreenshotReady(page) 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> { export async function gotoDataLibraryReady(page: Page): Promise<void> {
await page.goto('/admin') await page.goto('/admin')
await expect(page.getByTestId('data-library')).toBeVisible() await expect(page.getByTestId('data-library')).toBeVisible()

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