Compare commits

..

32 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
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
57 changed files with 3981 additions and 248 deletions

1
.gitignore vendored
View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -57,3 +57,58 @@ npm run test:visual:prod:update
```
Snapshots live under `tests/visual/__snapshots__/`.
## 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

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

@@ -53,9 +53,8 @@ function StintSparkline({ seconds }: { seconds: number[] }) {
export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
const isRace = isRaceSession(sessionType)
// In practice/qualifying deg trends are secondary — collapse by default so
// the Timing Tower stays above the fold. Races keep it open.
const [collapsed, setCollapsed] = useState(!isRace)
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).
@@ -75,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 (
@@ -82,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

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

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

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

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

@@ -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()
})
@@ -92,16 +104,44 @@ describe('TyreDegPanel', () => {
expect(panel).not.toHaveTextContent('~P')
})
it('starts expanded during a race', () => {
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
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)
})
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)
})
it('limits rows to the top ten plus pinned drivers', () => {
const rows = Array.from({ length: 15 }, (_, index) =>
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

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

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

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

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)
}
@@ -671,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
@@ -693,12 +762,26 @@ func (c *champHubCache) get(year int, now time.Time) (champHubResponse, bool) {
}
func (c *champHubCache) put(year int, resp champHubResponse, now time.Time, ttl time.Duration) {
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.
@@ -740,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 {
@@ -754,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)
}
@@ -797,25 +885,35 @@ func (s *Server) localChampionshipHub(year int) (champHubResponse, bool, error)
return aggregateChampionshipHub(year, races, inputs.Champ, inputs.Teams, inputs.DriverMap), true, nil
}
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())
@@ -825,16 +923,22 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
// so a partial view of the season doesn't stick around for the full TTL.
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
}
@@ -842,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
@@ -855,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)
}
@@ -867,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).
@@ -1077,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()
@@ -1273,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 {
@@ -1299,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
@@ -1342,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{
@@ -1396,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,
@@ -1491,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 {
@@ -1543,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,
@@ -1558,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}
}

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

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

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

@@ -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 }) => {
@@ -102,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)
})
})

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 154 KiB

View File

@@ -8,6 +8,20 @@ export const VIEWPORTS = {
export const FULL_SESSION = 9472
async function routeSeededSeasonCalendar(page: Page): Promise<void> {
await page.route(/\/api\/v1\/meetings(?:\?.*)?$/, async (route) => {
const url = new URL(route.request().url())
if (url.searchParams.get('year') !== '2025' || url.searchParams.get('source') !== 'openf1') {
await route.continue()
return
}
url.searchParams.set('source', 'local')
const response = await route.fetch({ url: url.toString() })
await route.fulfill({ response })
})
}
/** Wait for web fonts and layout to settle before screenshots. */
export async function waitForScreenshotReady(page: Page): Promise<void> {
await page.evaluate(() => document.fonts.ready)
@@ -15,15 +29,11 @@ export async function waitForScreenshotReady(page: Page): Promise<void> {
}
export async function gotoCommandCenterReady(page: Page): Promise<void> {
await routeSeededSeasonCalendar(page)
await page.goto('/')
await expect(page.getByTestId('command-center')).toBeVisible()
await expect(page.getByTestId('cc-focus')).toBeVisible()
await expect(page.getByTestId('cc-session-9472')).toBeVisible()
// The e2e stack runs with an unreachable OpenF1 base URL, so wait for the
// season-calendar query to settle on its local fallback before screenshotting.
await expect(
page.getByText('Using local meetings because the full calendar could not load.'),
).toBeVisible()
await expect(page.getByTestId('cc-calendar-1229')).toBeVisible({ timeout: 15_000 })
await waitForScreenshotReady(page)
}