Compare commits

...

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

1
.gitignore vendored
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 683 KiB

View File

@@ -0,0 +1,32 @@
# v0.4.0 — Current Weekend & Product Truth
Product research references for the v0.4.0 feature sprint.
## Direction
The sprint replaces route-first navigation with a state-aware Weekend experience:
- Weekend is the adaptive home for what happened, what is live, and what is next.
- Preview content folds into Weekend before a session.
- Live remains a stable deep link and becomes Weekend's active-session state.
- Race Hub remains explicit completed-session analysis rather than a primary landing destination.
- Championship and Briefing remain dedicated destinations.
- Explore owns secondary discovery; Admin moves to operator utility.
## Mockups
- `mockups/weekend-between-races.png` — desktop between-races/post-weekend state.
- `mockups/weekend-live.png` — desktop active-session state; the circuit is static sector context, not live GPS.
- `mockups/weekend-between-sessions-mobile.png` — 390×844 between-session state.
These are directional references, not pixel-perfect specifications. Implementations must preserve the established box-box visual language, accessibility, data constraints, and responsive behavior while satisfying their issue acceptance criteria.
## Constraints
- No OpenF1 REST dependency during active sessions.
- Public live GPS is not assumed to be available.
- Championship round numbers exclude tests and cancelled meetings.
- Connection health, live-session state, archive availability, and local-analysis readiness are separate concepts.
- Future sessions must not render empty post-session analysis.
The authoritative product decisions and research packet are recorded in GitHub issue #71 under epic #70.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -57,3 +57,58 @@ npm run test:visual:prod:update
```
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,
TrackOutline,
Weekend,
WeekendContext,
} from './types'
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
@@ -114,6 +115,14 @@ export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
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> {
const params = new URLSearchParams({ source: 'auto' })
if (year) params.set('year', year.toString())

View File

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

View File

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

View File

@@ -1,6 +1,17 @@
import { Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { fetchLiveState } from '../api'
import { isLiveSessionActive } from '../lib/live'
export function Nav() {
const { data: liveState } = useQuery({
queryKey: ['live-state'],
queryFn: fetchLiveState,
staleTime: 5_000,
refetchInterval: 30_000,
})
const liveActive = isLiveSessionActive(liveState)
return (
<nav className="app-nav">
<Link to="/" className="nav-logo">
@@ -10,8 +21,16 @@ export function Nav() {
<Link to="/" activeProps={{ className: 'active' }} activeOptions={{ exact: true }}>
Command
</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
{liveActive && <span className="sr-only"> session active</span>}
</Link>
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
Race Hub

View File

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

View File

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

View File

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

View File

@@ -47,7 +47,7 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
}
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">
<span className="sec-title">Switch Weekend</span>
<div className="rh-switcher-years">

View File

@@ -80,7 +80,9 @@ export function RaceControlFeed({ messages, driverInfo }: Props) {
<section className="live-rc panel-glass">
<div className="sec-header sticky-header">
<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>
{latest.length === 0 ? (
<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 (
<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.Flag
? <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">
{display.advanceCount && <span>{display.advanceCount} advance</span>}
{atRiskLabel && <span>{atRiskLabel}</span>}
<span>
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
</span>
{display.isRace && (
<span data-testid="live-lap-counter">
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
</span>
)}
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{stateLabel}</span>
</div>
</div>

View File

@@ -1,9 +1,10 @@
import { useState, Fragment } from 'react'
import { useState, useMemo, Fragment } from 'react'
import { teamColor } from '../../utils'
import { useAutoAnimate } from '@formkit/auto-animate/react'
import type { LiveSessionMeta, LiveStintData } from '../../types'
import type { LiveTimingRow } from '../../lib/live'
import {
bestLapGaps,
driverCode,
liveSessionDisplay,
positionDelta,
@@ -62,6 +63,10 @@ export function TimingTower({
const isQuali = sessionDisplay.isQualifying || !isRace
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 (
<div className="scroll-x">
<table className="data-table live-tower" style={{ minWidth: 760 }}>
@@ -71,9 +76,9 @@ export function TimingTower({
{isRace && <th>Δ</th>}
<th>Driver</th>
<th>Tyre</th>
<th>Last Lap</th>
<th
className="interactive"
<th className={isRace ? undefined : 'hide-mobile'}>Last Lap</th>
<th
className="interactive"
onClick={() => setGapMode(g => g === 'interval' ? 'leader' : 'interval')}
title="Click to toggle Gap vs Interval"
style={{ cursor: 'pointer', textDecoration: 'underline' }}
@@ -81,10 +86,10 @@ export function TimingTower({
{gapMode === 'interval' && isRace ? 'Interval' : 'Gap to P1'}
</th>
{isRace && <th>Trend</th>}
{isQuali && <th>S1</th>}
{isQuali && <th>S2</th>}
{isQuali && <th>S3</th>}
<th className="hide-mobile">Best</th>
{isQuali && <th className="hide-mobile">S1</th>}
{isQuali && <th className="hide-mobile">S2</th>}
{isQuali && <th className="hide-mobile">S3</th>}
<th className={isRace ? 'hide-mobile' : undefined}>Best</th>
{isRace && <th className="hide-mobile r">Laps</th>}
<th className="r"></th>
</tr>
@@ -102,7 +107,15 @@ export function TimingTower({
driver.Cutoff
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 =
gapMode === 'interval' && isRace && row.Position > 1
? intervalMeaning(parseIntervalSeconds(gapText))
@@ -153,7 +166,10 @@ export function TimingTower({
<td>
<span className={`tyre-badge ${tyreClass(row.Tyre)}`}>{tyreLabel(row.Tyre)}</span>
</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 || '-'}
</td>
<td className="mono">
@@ -171,11 +187,14 @@ export function TimingTower({
</td>
)}
{isQuali && <td>{renderSector(0)}</td>}
{isQuali && <td>{renderSector(1)}</td>}
{isQuali && <td>{renderSector(2)}</td>}
{isQuali && <td className="hide-mobile">{renderSector(0)}</td>}
{isQuali && <td className="hide-mobile">{renderSector(1)}</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 || '-'}
</td>
@@ -208,6 +227,16 @@ export function TimingTower({
<div className="mono">{driver.NumberOfLaps || 0}</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 && (
<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) {
const [collapsed, setCollapsed] = useState(false)
const isRace = isRaceSession(sessionType)
const [collapsed, setCollapsed] = useState(true)
const [readerChose, setReaderChose] = useState(false)
const [stints, setStints] = useState<StintHistoryMap>({})
// 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)))
}, [rows])
const isRace = isRaceSession(sessionType)
const visible = useMemo(
() =>
rows.filter(
@@ -74,6 +74,30 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
[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
return (
@@ -81,18 +105,25 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
<button
type="button"
className="sec-header tyredeg-toggle"
onClick={() => setCollapsed((prev) => !prev)}
onClick={() => {
setReaderChose(true)
setCollapsed((prev) => !prev)
}}
aria-expanded={!collapsed}
>
<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>
</button>
{!collapsed && (
<div className="tyredeg-rows">
{visible.map((row) => {
const model = degradationModel(stints[row.RacingNumber]?.samples ?? [])
const model = models[row.RacingNumber]
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age)
return (

View File

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

View File

@@ -268,6 +268,63 @@ export function driverCode(row: LiveTimingRow): string {
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 {
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

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

View File

@@ -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 function focusMeetingKind(meeting: Meeting, now: Date): FocusMeetingKind {

View File

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

View File

@@ -20,6 +20,7 @@ import {
currentAndNextSession,
focusMeetingKind,
formatSessionScheduleTime,
meetingEndTime,
meetingHasStarted,
mostRecentPastMeeting,
nextUpcomingMeeting,
@@ -34,6 +35,8 @@ const missingDatasets = Object.fromEntries(
) as WeekendSession['datasets']
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 (meetingHasStarted(meeting, now)) return 'past'
return 'future'
@@ -163,6 +166,12 @@ export function CommandCenterPage() {
const lastPastWeekend = lastPastMeeting ? weekendsByKey.get(lastPastMeeting.meeting_key) : undefined
const lastRaceAnalysis = pickAnalysisSession(lastPastWeekend)
const lastRaceSessionKey = lastRaceAnalysis?.session.session_key
const railSessions =
focusWeekendSessions.length > 0
? focusWeekendSessions
: heroStateKind === 'between'
? lastPastWeekend?.sessions ?? []
: []
const lastRaceHubQuery = useQuery({
queryKey: ['race-hub', lastRaceSessionKey, 'hero-podium'],
@@ -178,6 +187,16 @@ export function CommandCenterPage() {
const lastRacePodium = lastRaceHubQuery.data?.results ?? []
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) {
return <div className="page loading-state">loading command center</div>
@@ -226,7 +245,7 @@ export function CommandCenterPage() {
<div className="cc-topbar">
<span className="cc-topbar-label mono">box-box · command center</span>
<span className="cc-topbar-meta mono">
{latestSeason} season · {meetingStats.full}/{meetingStats.total || 0} weekends full
{latestSeason} season · weekend desk
</span>
<span className="cc-live-pill" data-testid="cc-live-status">
<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-main">
<div
@@ -298,7 +337,9 @@ export function CommandCenterPage() {
<div className="cc-calendar-accent" aria-hidden="true" />
<div className="cc-calendar-top mono">
<span className="cc-calendar-round">R{String(index + 1).padStart(2, '0')}</span>
<span className={`cc-cov-dot cc-cov-${weekend?.source ?? 'none'}`} aria-hidden="true" />
<span className={`cc-calendar-status cc-calendar-status-${status}`}>
{status === 'past' ? 'Archive' : status === 'focus' ? 'Now' : 'Ahead'}
</span>
</div>
<div className="cc-calendar-id">
{countryFlag(meeting) && <span className="cc-calendar-flag">{countryFlag(meeting)}</span>}
@@ -371,14 +412,14 @@ export function CommandCenterPage() {
</section>
)}
{focusWeekendSessions.length > 0 && (
{railSessions.length > 0 && (
<section className="cc-schedule" data-testid="cc-schedule">
<div className="sec-header">
<span className="sec-title">Weekend Schedule</span>
<span className="sec-meta mono">{focusWeekendSessions.length} sessions</span>
<span className="sec-meta mono">{railSessions.length} sessions</span>
</div>
<div className="cc-session-strip" role="list">
{focusWeekendSessions.map(({ session, source, datasets }) => {
{railSessions.map(({ session, source, datasets }) => {
const status = classifySessionStatus(session, nowDate)
const isNext = nextSession?.session_key === session.session_key
const isCurrent = currentSession?.session_key === session.session_key
@@ -404,9 +445,9 @@ export function CommandCenterPage() {
</div>
<div className="cc-session-name">{session.session_name}</div>
<div className="cc-session-time mono">{formatSessionScheduleTime(session.date_start)}</div>
<div className="cc-session-cov mono">
<div className="cc-session-cov mono" title={formatCoverageHint(datasets)}>
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
{formatCoverageHint(datasets)}
{source === 'local' ? 'Analysis ready' : formatCoverageHint(datasets)}
</div>
</Link>
)
@@ -416,6 +457,10 @@ export function CommandCenterPage() {
)}
<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>
@@ -448,4 +493,3 @@ function FormSparkline({ form, color }: { form: number[], color: string }) {
</svg>
)
}

View File

@@ -289,14 +289,16 @@ export function LiveTimingPage() {
session={snapshot.Session}
/>
</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">
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
<TeamRadioTicker
captures={snapshot.TeamRadio ?? []}
driverInfo={snapshot.DriverInfo}
session={snapshot.Session}
/>
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
</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 { useNavigate } from '@tanstack/react-router'
import {
fetchLocalMeetings,
fetchRaceHub,
fetchSeasons,
fetchWeekend,
} from '../api'
import { fetchRaceHub, fetchWeekend, fetchWeekendContext } from '../api'
import { DatasetStrip } from '../components/DatasetStrip'
import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
import { TabBar, type Tab } from '../components/TabBar'
@@ -22,76 +17,72 @@ import { SourceBadge } from '../components/SourceBadge'
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
import {
MAX_BROWSER_TIMEOUT,
formatCountdown,
formatSessionScheduleTime,
pickFocusMeeting,
refreshDeadlineDelay,
sortSessionsByStart,
} from '../lib/schedule'
import type { Weekend, WeekendSession } from '../types'
import type { ContextSession, Weekend } from '../types'
interface Props {
sessionKey: number
}
function pickAnalysisSession(weekend: Weekend | undefined): WeekendSession | undefined {
if (!weekend) return undefined
const local = weekend.sessions.filter((s) => s.source === 'local')
const partial = weekend.sessions.filter((s) => s.source === 'partial')
const pool = local.length > 0 ? local : partial.length > 0 ? partial : weekend.sessions
const race = pool.find((s) => s.session.session_type?.toLowerCase().includes('race'))
if (race) return race
const qual = pool.find((s) => s.session.session_type?.toLowerCase().includes('qualifying'))
if (qual) return qual
return pool[0]
}
export function RaceHubPage({ sessionKey }: Props) {
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<Tab>('overview')
const [switcherOpen, setSwitcherOpen] = useState(false)
const [now, setNow] = useState(() => Date.now())
const [refreshGeneration, setRefreshGeneration] = useState(0)
// ─── Auto-redirect when no session_key is supplied ───
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: fetchSeasons,
// The server owns bare Race Hub selection so every open tab crosses the
// one-hour handoff at the same instant.
const contextQuery = useQuery({
queryKey: ['weekend-context'],
queryFn: fetchWeekendContext,
enabled: sessionKey === 0,
})
const { refetch: refetchContext } = contextQuery
const latestSeason = seasonsQuery.data?.[0] ?? null
const meetingsQuery = useQuery({
queryKey: ['meetings', latestSeason],
queryFn: () => fetchLocalMeetings(latestSeason!),
enabled: sessionKey === 0 && latestSeason != null,
})
const focusMeeting = useMemo(() => {
if (sessionKey !== 0 || !meetingsQuery.data) return null
return pickFocusMeeting(meetingsQuery.data, new Date())
}, [sessionKey, meetingsQuery.data])
const fallbackWeekendQuery = useQuery({
queryKey: ['weekend', focusMeeting?.meeting_key],
queryFn: () => fetchWeekend(focusMeeting!.meeting_key),
enabled: sessionKey === 0 && focusMeeting != null,
const context = contextQuery.data
const preSession = sessionKey === 0 && context?.race_hub_pre_session === true
const preSessionRef = context?.race_hub_default_session
const preSessionMeetingKey = preSessionRef?.meeting?.meeting_key
const preSessionWeekendQuery = useQuery({
queryKey: ['weekend', preSessionMeetingKey],
queryFn: () => fetchWeekend(preSessionMeetingKey!),
enabled: preSession && preSessionMeetingKey != null && preSessionMeetingKey > 0,
})
useEffect(() => {
if (sessionKey !== 0) return
const weekend = fallbackWeekendQuery.data
if (!weekend) return
const target = pickAnalysisSession(weekend)?.session.session_key
?? weekend.default_session_key
?? weekend.sessions[0]?.session.session_key
if (target) {
navigate({ to: '/race-hub', search: { session_key: target }, replace: true })
}
}, [sessionKey, fallbackWeekendQuery.data, navigate])
const delay = refreshDeadlineDelay(context?.race_hub_refresh_at)
if (delay == null) return
const rearmAfterRefetch = delay === MAX_BROWSER_TIMEOUT
const timer = window.setTimeout(() => {
void refetchContext().finally(() => {
if (rearmAfterRefetch) setRefreshGeneration((generation) => generation + 1)
})
}, delay)
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 ───
const raceHubQuery = useQuery({
queryKey: ['race-hub', sessionKey],
queryFn: () => fetchRaceHub(sessionKey),
enabled: sessionKey > 0,
queryKey: ['race-hub', selectedSessionKey],
queryFn: () => fetchRaceHub(selectedSessionKey),
enabled: selectedSessionKey > 0 && (sessionKey > 0 || !preSession),
staleTime: 30_000,
})
@@ -108,25 +99,36 @@ export function RaceHubPage({ sessionKey }: Props) {
const accent = countryAccent(data?.meeting ?? null)
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
// ─── No session_key: show resolving state, fall back to switcher if no local data ───
// ─── No session_key: resolve exclusively through canonical Weekend Context ───
if (sessionKey === 0) {
if (seasonsQuery.isLoading || meetingsQuery.isLoading || fallbackWeekendQuery.isLoading) {
if (contextQuery.isLoading || (preSession && preSessionWeekendQuery.isLoading)) {
return (
<div className="rh-page" style={accentStyle}>
<div className="loading-state">resolving latest local weekend</div>
</div>
)
}
const seasons = seasonsQuery.data ?? []
if (seasons.length === 0) {
if (preSession && preSessionRef) {
return (
<RaceHubPreSession
session={preSessionRef}
weekend={preSessionWeekendQuery.data}
now={now}
switcherOpen={switcherOpen}
onToggleSwitcher={() => setSwitcherOpen((open) => !open)}
onCloseSwitcher={() => setSwitcherOpen(false)}
/>
)
}
if (!selectedSessionKey) {
return (
<div className="rh-page rh-empty" data-testid="race-hub-empty" style={accentStyle}>
<div className="rh-empty-band">
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
<h1 className="rh-empty-title">No local sessions yet</h1>
<h1 className="rh-empty-title">No completed local analysis yet</h1>
<p className="rh-empty-sub">
The Race Hub reads from local ingest only. Once a weekend is ingested
it will open here automatically.
Race Hub opens completed local analysis between weekends. Check Data Health
to ingest a completed session.
</p>
<div className="rh-empty-actions">
<a href="/admin" className="rh-empty-action">Open Admin · Data Health</a>
@@ -136,18 +138,13 @@ export function RaceHubPage({ sessionKey }: Props) {
</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) {
return (
<div className="rh-page" style={accentStyle}>
<div className="loading-state">loading session {sessionKey}</div>
<div className="loading-state">loading session {selectedSessionKey}</div>
</div>
)
}
@@ -157,7 +154,7 @@ export function RaceHubPage({ sessionKey }: Props) {
<div className="error-box">
{raceHubQuery.error instanceof Error
? raceHubQuery.error.message
: `Failed to load session ${sessionKey}.`}
: `Failed to load session ${selectedSessionKey}.`}
</div>
</div>
)
@@ -168,7 +165,7 @@ export function RaceHubPage({ sessionKey }: Props) {
const sessionMeta = weekend
? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w]))
: {}
const activeSessionMeta = sessionMeta[sessionKey]
const activeSessionMeta = sessionMeta[selectedSessionKey]
return (
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
@@ -194,7 +191,7 @@ export function RaceHubPage({ sessionKey }: Props) {
{switcherOpen && (
<WeekendSwitcher
currentMeetingKey={meetingKey}
currentSessionKey={sessionKey}
currentSessionKey={selectedSessionKey}
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">
{sessions.map((session) => {
const meta = sessionMeta[session.session_key]
const active = session.session_key === sessionKey
const active = session.session_key === selectedSessionKey
return (
<button
key={session.session_key}
@@ -278,7 +275,7 @@ export function RaceHubPage({ sessionKey }: Props) {
{formatCoverageHint(activeSessionMeta.datasets)} datasets local
</span>
)}
<span className="rh-active-key mono">key {sessionKey}</span>
<span className="rh-active-key mono">key {selectedSessionKey}</span>
</div>
)}
@@ -314,7 +311,7 @@ export function RaceHubPage({ sessionKey }: Props) {
<span className="sec-title">Driver Compare</span>
</div>
<CompareView
sessionKey={sessionKey}
sessionKey={selectedSessionKey}
results={data.results}
drivers={data.drivers}
/>
@@ -368,3 +365,72 @@ export function RaceHubPage({ sessionKey }: Props) {
</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; }
/* 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 {
font-family: var(--f-mono);
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.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 {
margin-left: auto;
display: flex;
@@ -4730,3 +4764,137 @@ a { color: inherit; text-decoration: none; }
background: #f82f34;
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;
justify-content: space-between;
gap: var(--s4);
flex-wrap: wrap;
}
.chapter-strip-title {
@@ -44,6 +45,31 @@
color: var(--text);
}
.chapter-strip-scroll-wrap {
position: relative;
}
.chapter-strip-scroll-wrap::before,
.chapter-strip-scroll-wrap::after {
content: '';
position: absolute;
top: 0;
bottom: var(--s2);
width: 28px;
pointer-events: none;
z-index: 1;
}
.chapter-strip-scroll-wrap::before {
left: 0;
background: linear-gradient(to right, var(--bg), transparent);
}
.chapter-strip-scroll-wrap::after {
right: 0;
background: linear-gradient(to left, var(--bg), transparent);
}
.chapter-strip-scroll {
display: flex;
gap: var(--s3);
@@ -51,10 +77,26 @@
padding-bottom: var(--s2);
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: var(--border-2) transparent;
}
.chapter-strip-scroll::-webkit-scrollbar {
height: 4px;
}
.chapter-strip-scroll::-webkit-scrollbar-track {
background: transparent;
}
.chapter-strip-scroll::-webkit-scrollbar-thumb {
background: var(--border-2);
border-radius: 999px;
}
.chapter-card {
flex: 0 0 min(240px, 72vw);
min-height: 88px;
display: flex;
flex-direction: column;
gap: var(--s2);
@@ -64,7 +106,7 @@
background: var(--surface);
text-align: left;
cursor: pointer;
scroll-snap-align: start;
scroll-snap-align: center;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
}
@@ -145,10 +187,22 @@
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
flex: 1;
}
.chapter-strip-empty {
font-size: 12px;
color: var(--text-3);
padding: var(--s3) 0;
.chapter-strip-empty-card {
padding: var(--s6) var(--s5);
margin: 0;
}
.chapter-strip-empty-card .empty-state-icon {
display: block;
margin: 0 auto var(--s3);
color: var(--text-3);
}
@media (max-width: 900px) {
.chapter-strip-header {
align-items: flex-start;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -189,6 +189,9 @@ describe('CommandCenterPage', () => {
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar')
expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01')
expect(screen.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.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-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')
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()
})
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', () => {
const sprintRows = Array.from({ length: 22 }, (_, index) =>
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()
})
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 () => {
renderPage({
is_live: true,

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { act, render, screen, waitFor, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
Outlet,
@@ -9,21 +9,24 @@ import {
createRoute,
} from '@tanstack/react-router'
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', () => ({
fetchRaceHub: vi.fn(),
fetchSeasons: vi.fn(),
fetchLocalMeetings: 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 mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
const mockFetchWeekend = vi.mocked(fetchWeekend)
const mockFetchWeekendContext = vi.mocked(fetchWeekendContext)
const meeting: Meeting = {
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) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
@@ -193,23 +216,28 @@ function renderRaceHub(sessionKey: number) {
return <RaceHubPage sessionKey={session_key ?? 0} />
},
})
window.history.pushState({}, '', sessionKey ? `/race-hub?session_key=${sessionKey}` : '/race-hub')
const router = createRouter({
routeTree: rootRoute.addChildren([raceHubRoute]),
history: undefined,
})
// Navigate to the URL before mounting
router.navigate({ to: '/race-hub', search: sessionKey ? { session_key: sessionKey } : {} })
return render(<RouterProvider router={router} />)
return { queryClient, ...render(<RouterProvider router={router} />) }
}
describe('RaceHubPage', () => {
beforeEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
mockFetchSeasons.mockResolvedValue([2025])
mockFetchLocalMeetings.mockResolvedValue([meeting])
mockFetchWeekend.mockResolvedValue(weekend)
mockFetchRaceHub.mockResolvedValue(raceHub)
mockFetchWeekendContext.mockResolvedValue(analysisContext)
})
afterEach(() => {
vi.useRealTimers()
})
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'))
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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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', () => {
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={[]} />)
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('M +5')
expect(panel).toHaveTextContent('fresh')
@@ -61,6 +72,7 @@ describe('TyreDegPanel', () => {
makeRow('1', 1, 'VER', { NumberOfLaps: 10, LastLapTime: '1:30.000' }, { Compound: 'MEDIUM', Age: 12 }),
]
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={[]} />)
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
expect(screen.getByText('mid-life')).toBeInTheDocument()
})
@@ -81,13 +93,46 @@ describe('TyreDegPanel', () => {
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={[]} />)
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')
})
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)
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)
})
@@ -96,6 +141,7 @@ describe('TyreDegPanel', () => {
makeRow(String(index + 1), index + 1, `D${index + 1}`),
)
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.getByText('D14')).toBeInTheDocument()
expect(screen.queryByText('D12')).not.toBeInTheDocument()

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,15 @@
import { describe, expect, it } from 'vitest'
import {
bestLapGaps,
compoundClass,
compoundLetter,
extrapolateClock,
isLiveSessionActive,
latestRaceControl,
liveSessionDisplay,
loadPinnedDrivers,
mergeVisibleSectors,
parseLapTimeSeconds,
positionDeltaClass,
parseLiveStateEvent,
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', () => {
it('holds S1 and S2 through temporary blanks while a flying lap is active', () => {
const first = [timingRow('4', 1, {

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,10 @@ import {
focusMeetingKind,
focusMeetingLabel,
formatCountdown,
MAX_BROWSER_TIMEOUT,
nextUpcomingMeeting,
pickFocusMeeting,
refreshDeadlineDelay,
} from '../lib/schedule'
import type { Meeting, Session } from '../types'
@@ -78,4 +80,15 @@ describe('schedule helpers', () => {
const target = new Date('2025-05-25T13:00:00+00:00')
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

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

View File

@@ -217,6 +217,41 @@ export interface Weekend {
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 {
is_live: boolean
data: LiveStreamData | null
@@ -391,6 +426,7 @@ export interface ChampHubDriver {
poles: number
form: number[]
cumulative: number[]
round_positions: number[]
teammate_wins: number
teammate_losses: number
}

View File

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

View File

@@ -1,6 +1,7 @@
package api
import (
"context"
"net/http"
"sync"
"sync/atomic"
@@ -26,8 +27,12 @@ type requestPacer struct {
// wait blocks until this caller's reserved slot arrives.
func (p *requestPacer) wait() {
_ = p.waitContext(context.Background())
}
func (p *requestPacer) waitContext(ctx context.Context) error {
if p == nil || p.interval <= 0 {
return
return nil
}
p.mu.Lock()
now := time.Now()
@@ -38,8 +43,18 @@ func (p *requestPacer) wait() {
p.next = p.next.Add(p.interval)
p.mu.Unlock()
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 {
@@ -56,6 +71,26 @@ type OpenF1Client struct {
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 {
return &OpenF1Client{
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 (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -59,8 +60,14 @@ func retryAfter429(resp *http.Response) time.Duration {
// Without this, concurrent fan-outs (championship hub, track prefetch) burst
// past the free-tier limit and callers silently treat 429s as missing data.
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++ {
c.pacer.wait()
if err := c.pacer.waitContext(ctx); err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
@@ -70,7 +77,14 @@ func (c *OpenF1Client) doPaced(req *http.Request) (*http.Response, error) {
}
delay := retryAfter429(resp)
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
// error. The client's staleFlag is set so the UI can show a disclaimer.
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.
if cachedData, ok := c.cache.Get(url); ok {
return io.NopCloser(bytes.NewReader(cachedData)), nil
}
// 2. Attempt a live network request.
req, err := http.NewRequest("GET", url, nil)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
// Even a request-construction failure warrants a stale fallback.
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)
}
resp, err := c.doPaced(req)
resp, err := c.doPacedContext(ctx, req)
if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
return c.tryStale(url, err)
}
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.
data, err := io.ReadAll(resp.Body)
if err != nil {
if ctx.Err() != nil {
return nil, ctx.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) {
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 {
return nil, err
}

View File

@@ -1,7 +1,9 @@
package api
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
@@ -49,6 +51,70 @@ func TestRequestPacerNilSafe(t *testing.T) {
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) {
var calls atomic.Int32
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) {
state := live.NewState()
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) {
state := live.NewState()
state.Drivers["55"] = live.LiveDriverData{RacingNumber: "55"}

View File

@@ -10,6 +10,7 @@ import (
"io"
"log"
"sort"
"strconv"
"strings"
"time"
)
@@ -404,22 +405,42 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
Stints json.RawMessage `json:"Stints"`
}
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) {
var st struct {
Compound string `json:"Compound"`
New string `json:"New"`
TotalLaps int `json:"TotalLaps"`
Compound *string `json:"Compound"`
New *string `json:"New"`
TotalLaps *int `json:"TotalLaps"`
}
if json.Unmarshal(sRaw.Raw, &st) == nil && st.Compound != "" {
driverStints = append(driverStints, LiveStintData{
Compound: st.Compound,
New: st.New == "true" || st.New == "True",
Laps: st.TotalLaps,
})
if json.Unmarshal(sRaw.Raw, &st) != nil {
continue
}
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
lastStint := driverStints[len(driverStints)-1]
t := s.Tyres[num]
@@ -874,8 +895,13 @@ func indexedRawValues(raw json.RawMessage) []indexedRaw {
if err := json.Unmarshal(raw, &obj); err == nil {
values := make([]indexedRaw, 0, len(obj))
for k, v := range obj {
i := 0
fmt.Sscanf(k, "%d", &i)
// Keys are array indices in the feed's delta form. Non-numeric keys
// 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})
}
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 {
out := make([]rune, 0, len(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 (
"database/sql"
"errors"
"time"
"github.com/AmanTahiliani/box-box/internal/chapters"
"github.com/AmanTahiliani/box-box/internal/models"
@@ -12,11 +13,20 @@ import (
// Service assembles store-backed read models.
type Service struct {
store *store.Store
now func() time.Time
}
// NewService creates a query service over a domain store.
func NewService(st *store.Store) *Service {
return &Service{store: st}
return NewServiceWithClock(st, time.Now)
}
// NewServiceWithClock creates a query service with an injected clock.
func NewServiceWithClock(st *store.Store, now func() time.Time) *Service {
if now == nil {
now = time.Now
}
return &Service{store: st, now: now}
}
// EnrichedResult is a session result with driver identity fields.

View File

@@ -16,6 +16,7 @@ import (
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/query"
)
@@ -43,6 +44,7 @@ func (s *Server) handleMeetings(w http.ResponseWriter, r *http.Request) {
switch parseSourceMode(r) {
case sourceLocal:
markLocalResponse(w, false)
if !s.hasLocalQuery() {
writeJSON(w, []models.Meeting{})
return
@@ -62,17 +64,20 @@ func (s *Server) handleMeetings(w http.ResponseWriter, r *http.Request) {
return
}
if len(meetings) > 0 {
markLocalResponse(w, false)
writeJSON(w, meetings)
return
}
}
}
meetings, err := s.client.GetMeetingsForYear(year)
client := s.client.Scoped()
meetings, err := client.GetMeetingsForYear(year)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
markOpenF1Response(w, client)
writeJSON(w, meetings)
}
@@ -87,6 +92,7 @@ func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
switch parseSourceMode(r) {
case sourceLocal:
markLocalResponse(w, false)
if !s.hasLocalQuery() {
writeJSON(w, []models.Session{})
return
@@ -106,23 +112,27 @@ func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
return
}
if len(sessions) > 0 {
markLocalResponse(w, false)
writeJSON(w, sessions)
return
}
}
}
sessions, err := s.client.GetSessionsForMeeting(meetingKey)
client := s.client.Scoped()
sessions, err := client.GetSessionsForMeeting(meetingKey)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
markOpenF1Response(w, client)
writeJSON(w, sessions)
}
// --- /api/v1/news ---
func (s *Server) handleNews(w http.ResponseWriter, r *http.Request) {
markLocalResponse(w, false)
if !s.hasLocalQuery() {
writeJSON(w, []query.NewsItem{})
return
@@ -223,6 +233,7 @@ func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) {
switch parseSourceMode(r) {
case sourceLocal:
markLocalResponse(w, false)
if !s.hasLocalQuery() {
writeJSON(w, []models.Driver{})
return
@@ -242,6 +253,7 @@ func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) {
if s.hasLocalQuery() {
drivers, err := s.query.ListDrivers(sessionKey)
if err == nil && len(drivers) > 0 {
markLocalResponse(w, false)
writeJSON(w, drivers)
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 {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
markOpenF1Response(w, client)
writeJSON(w, drivers)
}
@@ -279,6 +293,7 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
switch parseSourceMode(r) {
case sourceLocal:
markLocalResponse(w, false)
if !s.hasLocalQuery() {
writeJSON(w, []resultWithDriver{})
return
@@ -294,6 +309,7 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
if s.hasLocalQuery() {
results, err := s.query.ListResults(sessionKey)
if err == nil && len(results) > 0 {
markLocalResponse(w, false)
writeJSON(w, enrichedResultsToAPI(results))
return
}
@@ -308,30 +324,42 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
results []models.SessionResult
drivers []models.Driver
resultsErr error
driversErr error
wg sync.WaitGroup
)
client := s.client.Scoped()
wg.Add(2)
go func() { defer wg.Done(); results, resultsErr = s.client.GetSessionResult(sessionKey) }()
go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }()
go func() { defer wg.Done(); results, resultsErr = client.GetSessionResult(sessionKey) }()
go func() { defer wg.Done(); drivers, driversErr = client.GetDriversForSession(sessionKey) }()
wg.Wait()
if resultsErr != nil {
writeError(w, resultsErr, http.StatusInternalServerError, s.client.LastResponseWasStale())
writeError(w, resultsErr, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
driverMap := buildDriverMap(drivers)
incomplete := driversErr != nil
enriched := make([]resultWithDriver, 0, len(results))
for _, res := range results {
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.FullName = d.FullName
e.TeamName = d.TeamName
e.TeamColour = d.TeamColour
} else {
incomplete = true
}
enriched = append(enriched, e)
}
resultsFreshness := "fresh"
if len(results) == 0 {
resultsFreshness = "limited"
} else if incomplete {
resultsFreshness = "partial"
}
markOpenF1Availability(w, client, resultsFreshness)
writeJSON(w, enriched)
}
@@ -354,6 +382,7 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
switch parseSourceMode(r) {
case sourceLocal:
markLocalResponse(w, false)
if !s.hasLocalQuery() {
writeJSON(w, []gridWithDriver{})
return
@@ -369,6 +398,7 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
if s.hasLocalQuery() {
grid, err := s.query.ListStartingGrid(sessionKey)
if err == nil && len(grid) > 0 {
markLocalResponse(w, false)
writeJSON(w, enrichedGridToAPI(grid))
return
}
@@ -380,33 +410,45 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
}
var (
grid []models.StartingGrid
drivers []models.Driver
gridErr error
wg sync.WaitGroup
grid []models.StartingGrid
drivers []models.Driver
gridErr error
driversErr error
wg sync.WaitGroup
)
client := s.client.Scoped()
wg.Add(2)
go func() { defer wg.Done(); grid, gridErr = s.client.GetStartingGrid(sessionKey) }()
go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }()
go func() { defer wg.Done(); grid, gridErr = client.GetStartingGrid(sessionKey) }()
go func() { defer wg.Done(); drivers, driversErr = client.GetDriversForSession(sessionKey) }()
wg.Wait()
if gridErr != nil {
writeError(w, gridErr, http.StatusInternalServerError, s.client.LastResponseWasStale())
writeError(w, gridErr, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
driverMap := buildDriverMap(drivers)
incomplete := driversErr != nil
enriched := make([]gridWithDriver, 0, len(grid))
for _, g := range grid {
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.FullName = d.FullName
e.TeamName = d.TeamName
e.TeamColour = d.TeamColour
} else {
incomplete = true
}
enriched = append(enriched, e)
}
gridFreshness := "fresh"
if len(grid) == 0 {
gridFreshness = "limited"
} else if incomplete {
gridFreshness = "partial"
}
markOpenF1Availability(w, client, gridFreshness)
writeJSON(w, enriched)
}
@@ -419,26 +461,29 @@ func (s *Server) handleLaps(w http.ResponseWriter, r *http.Request) {
return
}
client := s.client.Scoped()
if dnStr := r.URL.Query().Get("driver_number"); dnStr != "" {
driverNumber, err := strconv.Atoi(dnStr)
if err != nil || driverNumber == 0 {
http.Error(w, "invalid driver_number", http.StatusBadRequest)
return
}
laps, err := s.client.GetLapsForDriver(sessionKey, driverNumber)
laps, err := client.GetLapsForDriver(sessionKey, driverNumber)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
markOpenF1Response(w, client)
writeJSON(w, laps)
return
}
laps, err := s.client.GetLapsForSession(sessionKey)
laps, err := client.GetLapsForSession(sessionKey)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
markOpenF1Response(w, client)
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)
return
}
weather, err := s.client.GetWeather(sessionKey)
client := s.client.Scoped()
weather, err := client.GetWeather(sessionKey)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
markOpenF1Response(w, client)
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)
return
}
rc, err := s.client.GetRaceControl(sessionKey)
client := s.client.Scoped()
rc, err := client.GetRaceControl(sessionKey)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
markOpenF1Response(w, client)
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)
return
}
carData, err := s.client.GetCarData(sessionKey, driverNumber)
client := s.client.Scoped()
carData, err := client.GetCarData(sessionKey, driverNumber)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
markOpenF1Response(w, client)
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)
return
}
overtakes, err := s.client.GetOvertakesForSession(sessionKey)
client := s.client.Scoped()
overtakes, err := client.GetOvertakesForSession(sessionKey)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
markOpenF1Response(w, client)
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)
return
}
radios, err := s.client.GetTeamRadio(sessionKey, driverNumber)
client := s.client.Scoped()
radios, err := client.GetTeamRadio(sessionKey, driverNumber)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
markOpenF1Response(w, client)
writeJSON(w, radios)
}
@@ -547,36 +602,42 @@ func (s *Server) handleChampionshipDrivers(w http.ResponseWriter, r *http.Reques
if year == 0 {
year = time.Now().Year()
}
champ, err := s.client.GetDriverChampionshipForYear(year)
client := s.client.Scoped()
champ, err := client.GetDriverChampionshipForYear(year)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
if len(champ) == 0 {
markOpenF1Availability(w, client, "limited")
writeJSON(w, []any{})
return
}
drivers, _ := s.client.GetDriversForSession(champ[0].SessionKey)
drivers, driversErr := client.GetDriversForSession(champ[0].SessionKey)
driverMap := buildDriverMapFirst(drivers)
incomplete := driversErr != nil
enriched := make([]champDriverWithInfo, 0, len(champ))
for _, c := range champ {
e := champDriverWithInfo{ChampionshipDriver: c}
d, ok := s.championshipDriverInfo(c.SessionKey, c.DriverNumber, driverMap)
if ok {
d, ok := championshipDriverInfo(client, c.SessionKey, c.DriverNumber, driverMap)
if ok && hasDriverPresentation(d) {
e.NameAcronym = d.NameAcronym
e.FullName = d.FullName
e.TeamName = d.TeamName
e.TeamColour = d.TeamColour
} else {
incomplete = true
}
enriched = append(enriched, e)
}
markOpenF1AggregateResponse(w, client, incomplete)
writeJSON(w, enriched)
}
func (s *Server) championshipDriverInfo(sessionKey, driverNumber int, fallback map[int]models.Driver) (models.Driver, bool) {
if d, err := s.client.GetDriver(sessionKey, driverNumber); err == nil && d != nil {
func championshipDriverInfo(client *api.OpenF1Client, sessionKey, driverNumber int, fallback map[int]models.Driver) (models.Driver, bool) {
if d, err := client.GetDriver(sessionKey, driverNumber); err == nil && d != nil {
return *d, true
}
d, ok := fallback[driverNumber]
@@ -590,11 +651,17 @@ func (s *Server) handleChampionshipTeams(w http.ResponseWriter, r *http.Request)
if year == 0 {
year = time.Now().Year()
}
teams, err := s.client.GetTeamChampionshipForYear(year)
client := s.client.Scoped()
teams, err := client.GetTeamChampionshipForYear(year)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
if len(teams) == 0 {
markOpenF1Availability(w, client, "limited")
} else {
markOpenF1Response(w, client)
}
writeJSON(w, teams)
}
@@ -614,8 +681,9 @@ type champHubDriver struct {
Wins int `json:"wins"`
Podiums int `json:"podiums"`
Poles int `json:"poles"`
Form []float64 `json:"form"` // last 5 races' points
Cumulative []float64 `json:"cumulative"` // running total per completed round
Form []float64 `json:"form"` // last 5 races' points
Cumulative []float64 `json:"cumulative"` // running total per completed round
RoundPositions []int `json:"round_positions"` // finishing position per completed round (0 = no result)
TeammateWins int `json:"teammate_wins"`
TeammateLosses int `json:"teammate_losses"`
}
@@ -670,8 +738,10 @@ func champHubTTL(year int, now time.Time) time.Duration {
}
type champHubEntry struct {
resp champHubResponse
expires time.Time
resp champHubResponse
source string
freshness string
expires time.Time
}
// champHubCache is an in-memory cache of aggregated hub responses keyed by
@@ -692,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) {
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()
defer c.mu.Unlock()
if c.entries == nil {
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.
@@ -739,13 +823,6 @@ func (s *Server) handleChampionshipHub(w http.ResponseWriter, r *http.Request) {
}
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 {
resp, ok, err := s.localChampionshipHub(year)
if err != nil {
@@ -753,21 +830,33 @@ func (s *Server) handleChampionshipHub(w http.ResponseWriter, r *http.Request) {
return
}
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)
return
}
if mode == sourceLocal {
markDataResponse(w, "none", "limited")
writeJSON(w, resp)
return
}
}
resp, err := s.openF1ChampionshipHub(year)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
// At this point an auto request has no usable domain aggregate and an
// explicit OpenF1 request must not be satisfied by a local cache entry.
if resp, source, freshness, ok := s.hubCache.getWithMetadata(year, time.Now()); ok && source == "openf1" {
markDataResponse(w, source, freshness)
writeJSON(w, resp)
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)
}
@@ -796,25 +885,35 @@ func (s *Server) localChampionshipHub(year int) (champHubResponse, bool, error)
return aggregateChampionshipHub(year, races, inputs.Champ, inputs.Teams, inputs.DriverMap), true, nil
}
func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
champ, err := s.client.GetDriverChampionshipForYear(year)
func (s *Server) openF1ChampionshipHub(client *api.OpenF1Client, year int) (champHubResponse, bool, error) {
champ, err := client.GetDriverChampionshipForYear(year)
if err != nil {
return champHubResponse{}, err
return champHubResponse{}, false, err
}
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{}
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)
} 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 {
return champHubResponse{}, err
return champHubResponse{}, false, err
}
incomplete = incomplete || teamsErr != nil || driversIncomplete
resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo)
ttl := champHubTTL(year, time.Now())
@@ -824,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.
ttl = champHubIncompleteTTL
}
s.hubCache.put(year, resp, time.Now(), ttl)
return resp, nil
freshness := "fresh"
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
// with its race results and starting grid fetched from OpenF1. incomplete
// reports whether any per-meeting fetch failed, so callers can avoid caching a
// partial view of the season for long.
func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete bool, err error) {
meetings, err := s.client.GetMeetingsForYear(year)
func fetchSeasonRaces(client *api.OpenF1Client, year int) (races []meetingRace, incomplete bool, err error) {
meetings, err := client.GetMeetingsForYear(year)
if err != nil {
return nil, false, err
}
@@ -841,7 +946,7 @@ func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete boo
var failed atomic.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 {
failed.Store(true)
return meetingRace{}, false
@@ -854,10 +959,17 @@ func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete boo
}
}
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)
grid, gerr := s.client.GetStartingGrid(raceKey)
results, rerr := client.GetSessionResult(raceKey)
grid, gerr := client.GetStartingGrid(raceKey)
if rerr != nil || gerr != nil {
failed.Store(true)
}
@@ -866,6 +978,29 @@ func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete boo
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
// unit-tested with synthetic data. races must be ordered ascending by date and
// contain only GP meetings (those with a Race session).
@@ -995,6 +1130,10 @@ func aggregateChampionshipHub(
if len(form) > 5 {
form = form[len(form)-5:]
}
roundPositions := make([]int, completed)
for round := 1; round <= completed; round++ {
roundPositions[round-1] = a.finishByRound[round]
}
info := driverInfo[c.DriverNumber]
drivers = append(drivers, champHubDriver{
DriverNumber: c.DriverNumber,
@@ -1009,6 +1148,7 @@ func aggregateChampionshipHub(
Poles: a.poles,
Form: form,
Cumulative: cumulative[c.DriverNumber],
RoundPositions: roundPositions,
TeammateWins: twins[c.DriverNumber],
TeammateLosses: tloss[c.DriverNumber],
})
@@ -1071,6 +1211,7 @@ type trackOutlineResponse struct {
}
func (s *Server) handleTrackOutline(w http.ResponseWriter, r *http.Request) {
markLocalResponse(w, false)
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
if year == 0 {
year = time.Now().Year()
@@ -1267,22 +1408,25 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
}
var (
stints []models.Stint
pits []models.Pit
results []models.SessionResult
drivers []models.Driver
rc []models.RaceControl
stintsErr error
pitsErr error
resErr error
wg sync.WaitGroup
stints []models.Stint
pits []models.Pit
results []models.SessionResult
drivers []models.Driver
rc []models.RaceControl
stintsErr error
pitsErr error
resErr error
driversErr error
rcErr error
wg sync.WaitGroup
)
client := s.client.Scoped()
wg.Add(5)
go func() { defer wg.Done(); stints, stintsErr = s.client.GetStintsForSession(sessionKey) }()
go func() { defer wg.Done(); pits, pitsErr = s.client.GetPitStopsForSession(sessionKey) }()
go func() { defer wg.Done(); results, resErr = s.client.GetSessionResult(sessionKey) }()
go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }()
go func() { defer wg.Done(); rc, _ = s.client.GetRaceControl(sessionKey) }()
go func() { defer wg.Done(); stints, stintsErr = client.GetStintsForSession(sessionKey) }()
go func() { defer wg.Done(); pits, pitsErr = client.GetPitStopsForSession(sessionKey) }()
go func() { defer wg.Done(); results, resErr = client.GetSessionResult(sessionKey) }()
go func() { defer wg.Done(); drivers, driversErr = client.GetDriversForSession(sessionKey) }()
go func() { defer wg.Done(); rc, rcErr = client.GetRaceControl(sessionKey) }()
wg.Wait()
if stintsErr != nil || pitsErr != nil || resErr != nil {
@@ -1293,17 +1437,21 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
if e == nil {
e = resErr
}
writeError(w, e, http.StatusInternalServerError, s.client.LastResponseWasStale())
writeError(w, e, http.StatusInternalServerError, client.LastResponseWasStale())
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 {
// 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{}})
return
}
driverMap := buildDriverMap(drivers)
incomplete := driversErr != nil || rcErr != nil
resultMap := make(map[int]models.SessionResult, len(results))
totalLaps := 0
@@ -1336,6 +1484,9 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
stratDrivers := make([]strategyDriver, 0, len(seenDrivers))
for dn := range seenDrivers {
d := driverMap[dn]
if !hasDriverPresentation(d) {
incomplete = true
}
res := resultMap[dn]
sd := strategyDriver{
@@ -1390,6 +1541,7 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
return pi < pj
})
markOpenF1AggregateResponse(w, client, incomplete)
writeJSON(w, strategyResponse{
SessionKey: sessionKey,
TotalLaps: totalLaps,
@@ -1485,21 +1637,31 @@ func (s *Server) handleLapsComparison(w http.ResponseWriter, r *http.Request) {
}
var (
allLaps []models.Lap
stints []models.Stint
pits []models.Pit
rc []models.RaceControl
wg sync.WaitGroup
allLaps []models.Lap
stints []models.Stint
pits []models.Pit
rc []models.RaceControl
lapsErr error
stintsErr error
pitsErr error
rcErr error
wg sync.WaitGroup
)
client := s.client.Scoped()
wg.Add(4)
go func() { defer wg.Done(); allLaps, _ = s.client.GetLapsForSession(sessionKey) }()
go func() { defer wg.Done(); stints, _ = s.client.GetStintsForSession(sessionKey) }()
go func() { defer wg.Done(); pits, _ = s.client.GetPitStopsForSession(sessionKey) }()
go func() { defer wg.Done(); rc, _ = s.client.GetRaceControl(sessionKey) }()
go func() { defer wg.Done(); allLaps, lapsErr = client.GetLapsForSession(sessionKey) }()
go func() { defer wg.Done(); stints, stintsErr = client.GetStintsForSession(sessionKey) }()
go func() { defer wg.Done(); pits, pitsErr = client.GetPitStopsForSession(sessionKey) }()
go func() { defer wg.Done(); rc, rcErr = client.GetRaceControl(sessionKey) }()
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)
incomplete := stintsErr != nil || pitsErr != nil || rcErr != nil || driversErr != nil
// If no filter, default to first 3 unique driver numbers from lap data.
if len(requestedDrivers) == 0 {
@@ -1537,6 +1699,9 @@ func (s *Server) handleLapsComparison(w http.ResponseWriter, r *http.Request) {
compDrivers := make([]comparisonDriver, 0, len(requestedDrivers))
for _, dn := range requestedDrivers {
d := driverMap[dn]
if !hasDriverPresentation(d) {
incomplete = true
}
cd := comparisonDriver{
DriverNumber: dn,
NameAcronym: d.NameAcronym,
@@ -1552,6 +1717,13 @@ func (s *Server) handleLapsComparison(w http.ResponseWriter, r *http.Request) {
compDrivers = append(compDrivers, cd)
}
freshness := "fresh"
if len(allLaps) == 0 {
freshness = "limited"
} else if incomplete {
freshness = "partial"
}
markOpenF1Availability(w, client, freshness)
writeJSON(w, lapsComparisonResponse{
SessionKey: sessionKey,
SCPeriods: extractSCPeriods(rc),

View File

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

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
import (
"context"
"fmt"
"net/http"
"strconv"
"time"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models"
)
// --- /api/v1/driver/summary ---
// Per-driver season summary: championship standing plus per-round race results,
// aggregated server-side from the same sources as the championship hub. Caching
// relies on the OpenF1 client's HTTP cache TTLs — no extra layer here.
// Per-driver season summary: championship standing plus per-round race results.
// Current-season identity/results are local-first from the domain DB. Optional
// 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 {
MeetingKey int `json:"meeting_key"`
@@ -49,6 +56,11 @@ type driverSummaryResponse struct {
Cumulative []float64 `json:"cumulative"`
RoundLabels []string `json:"round_labels"`
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) {
@@ -61,12 +73,102 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
if year == 0 {
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 {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
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
for i := range champ {
if champ[i].DriverNumber == driverNumber {
@@ -75,30 +177,83 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
}
}
if entry == nil {
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
return
return nil, false, nil
}
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)
}
if d, ok := s.championshipDriverInfo(entry.SessionKey, driverNumber, driverInfo); ok {
driverInfo[driverNumber] = d
d, directErr := client.GetDriver(entry.SessionKey, driverNumber)
if directErr == nil && d != nil {
driverInfo[driverNumber] = *d
} else if fallback, ok := driverInfo[driverNumber]; ok {
driverInfo[driverNumber] = fallback
}
races, _, err := s.fetchSeasonRaces(year)
identityIncomplete := !hasDriverPresentation(driverInfo[driverNumber])
races, racesIncomplete, err := fetchSeasonRaces(client, year)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
return
return nil, false, err
}
resp, ok := aggregateDriverSummary(year, driverNumber, races, champ, driverInfo)
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
}
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

View File

@@ -1,11 +1,16 @@
package web
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/store"
)
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()
defer h.mu.Unlock()
if live.SessionStatusIsActive(data.SessionStatus) {
if snapshotIsLive(data) {
h.isLive = true
h.activeSnapshot = &data
if data.PositionUpdated && len(data.Positions) > 0 {
@@ -208,6 +208,40 @@ func (h *SSEHub) stateLocked() liveStatePayload {
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 {
return len(data.Drivers) > 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.
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.

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) {
hub := newSSEHub()
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) {
markLocalResponse(w, false)
if !s.hasLocalQuery() {
writeJSON(w, []int{})
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) {
markLocalResponse(w, false)
meetingKey, err := strconv.Atoi(r.URL.Query().Get("meeting_key"))
if err != nil || meetingKey == 0 {
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)
return
}
markLocalResponse(w, weekend.Source == query.ResponseSourcePartial)
writeJSON(w, weekend)
}

View File

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

View File

@@ -86,11 +86,29 @@ func TestHandleRaceHubWithoutStore(t *testing.T) {
if 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 {
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) {
st := openTestStore(t)
seedRaceHubStore(t, st)
@@ -117,6 +135,12 @@ func TestHandleRaceHubWithLocalData(t *testing.T) {
if hub.Datasets["results"].Status != query.DatasetStatusMissing {
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) {

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 {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
markOpenF1Availability(w, client, replayResponseFreshness(resp, incomplete))
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 {
intervalMS = defaultReplayIntervalMS
}
@@ -82,26 +94,26 @@ func assembleReplayFrames(ctx context.Context, client replayDataClient, sessionK
drivers, err := client.GetDriversForSession(sessionKey)
if err != nil {
return resp, err
return resp, false, err
}
driverNumbers := uniqueDriverNumbers(drivers)
if len(driverNumbers) == 0 {
return resp, nil
return resp, true, nil
}
series, err := fetchReplayLocationSeries(ctx, client, sessionKey, driverNumbers)
if err != nil && len(series) == 0 {
return resp, err
return resp, false, err
}
start, ok := earliestReplayLocationTime(series)
if !ok {
return resp, nil
return resp, true, nil
}
resp.StartTime = start.Format(time.RFC3339Nano)
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 {

View File

@@ -3,6 +3,7 @@ package web
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync"
@@ -16,6 +17,7 @@ type fakeReplayClient struct {
drivers []models.Driver
locs map[int][]models.Location
err error
locErrs map[int]error
mu sync.Mutex
inFlight int
@@ -46,7 +48,38 @@ func (f *fakeReplayClient) GetLocation(sessionKey, driverNumber int) ([]models.L
f.inFlight--
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) {
@@ -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 {
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 {
t.Fatalf("response metadata = %+v", resp)
}
@@ -112,7 +151,7 @@ func TestAssembleReplayFramesCapsFrameCount(t *testing.T) {
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 {
t.Fatalf("assembleReplayFrames() error = %v", err)
}
@@ -139,7 +178,7 @@ func TestAssembleReplayFramesBoundsLocationFanOut(t *testing.T) {
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)
}
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{
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 {
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
// because Go's ServeMux uses longest-prefix matching.
mux.HandleFunc("/api/v1/weekend-context", s.handleWeekendContext)
mux.HandleFunc("/api/v1/race-hub", s.handleRaceHub)
mux.HandleFunc("/api/v1/seasons", s.handleSeasons)
mux.HandleFunc("/api/v1/weekend", s.handleWeekend)
@@ -158,6 +159,7 @@ func withCORS(next http.Handler) http.Handler {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Access-Control-Expose-Headers", dataSourceHeader+", "+dataFreshnessHeader)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return

View File

@@ -11,7 +11,12 @@
"test:visual": "playwright test --config playwright.visual.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: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",
"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({
testDir: './tests/visual',
fullyParallel: true,
// All projects share one seeded SQLite database behind the same Go server.
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
workers: 1,
reporter: process.env.CI ? 'github' : 'html',
snapshotPathTemplate: '{testDir}/{testFileDir}/__snapshots__/{projectName}/{arg}{ext}',
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()
})
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 }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
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.beforeEach(async ({ page }) => {
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('shows the empty state when the feed has no snapshot', async ({ page }) => {
await page.goto('/live')

View File

@@ -2,6 +2,42 @@ import { test, expect } from '@playwright/test'
const FULL_SESSION = 9472
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('lands on the Overview tab with workspace identity', async ({ page }) => {
@@ -39,7 +75,17 @@ test.describe('Race Hub Weekend Workspace', () => {
await expect(
page.getByRole('img', { name: 'Position evolution chart' }),
).toBeVisible()
await expect(page.getByText('Lap-by-lap positions not available.')).not.toBeVisible()
await expect(page.getByTestId('race-story-no-positions')).not.toBeVisible()
})
test('Race Story highlights a chapter card when clicked', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await page.getByRole('tab', { name: 'Race Story' }).click()
const firstCard = page.getByTestId('chapter-card-0')
await expect(firstCard).toBeVisible()
await firstCard.click()
await expect(firstCard).toHaveClass(/active/)
})
test('strategy tab renders stint chart when stints are available', async ({ page }) => {
@@ -58,13 +104,15 @@ test.describe('Race Hub Weekend Workspace', () => {
await expect(page.locator('[data-testid="strategy-chart"]')).not.toBeVisible()
})
test('Race Story shows missing notice when positions are unavailable', async ({
test('Race Story shows empty-state card when positions are unavailable', async ({
page,
}) => {
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
await page.getByRole('tab', { name: 'Race Story' }).click()
await expect(page.getByText('Lap-by-lap positions not available.')).toBeVisible()
const empty = page.getByTestId('race-story-no-positions')
await expect(empty).toBeVisible()
await expect(empty.getByText('Lap-by-lap positions not available')).toBeVisible()
await expect(page.locator('[data-testid="position-chart"]')).not.toBeVisible()
})
@@ -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 expect(page).toHaveURL(/session_key=\d+/)
await expect(page).toHaveURL(/\/race-hub$/)
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)
})
})

Some files were not shown because too many files have changed in this diff Show More