Compare commits

..

4 Commits

Author SHA1 Message Date
AmanTahiliani
643af2355a fix: make weekend context transitions truthful 2026-07-12 18:15:14 -04:00
AmanTahiliani
45084558cd feat: add canonical weekend context API 2026-07-12 18:05:13 -04:00
AmanTahiliani
90c24ad6aa chore: make sprint harness models explicit 2026-07-12 17:25:19 -04:00
AmanTahiliani
e028e3e3fe docs: add v0.4.0 product direction and mockups 2026-07-12 17:24:42 -04:00
67 changed files with 286 additions and 3729 deletions

View File

@@ -25,7 +25,8 @@ EOF
harness_claude() { # Claude Code — print mode, auto-accept edits harness_claude() { # Claude Code — print mode, auto-accept edits
local dir="$1" prompt="$2" local dir="$1" prompt="$2"
( cd "$dir" && claude -p "$(cat "$prompt")" --permission-mode acceptEdits ) local model="${BOXBOX_CLAUDE_MODEL:-opus}"
( cd "$dir" && claude -p "$(cat "$prompt")" --model "$model" --effort high --permission-mode acceptEdits )
} }
harness_codex() { # OpenAI Codex CLI — non-interactive exec, full auto harness_codex() { # OpenAI Codex CLI — non-interactive exec, full auto
@@ -40,7 +41,8 @@ harness_opencode() { # opencode — non-interactive run
harness_cursor() { # Cursor CLI agent — composer-2.5, headless full-auto harness_cursor() { # Cursor CLI agent — composer-2.5, headless full-auto
local dir="$1" prompt="$2" local dir="$1" prompt="$2"
( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --model composer-2.5 --force --trust ) local model="${BOXBOX_CURSOR_MODEL:-composer-2.5}"
( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --model "$model" --force --trust )
} }
harness_agy() { # Antigravity CLI — disabled until headless is verified harness_agy() { # Antigravity CLI — disabled until headless is verified

1
.gitignore vendored
View File

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

View File

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

View File

@@ -15,7 +15,6 @@ import type {
Session, Session,
TrackOutline, TrackOutline,
Weekend, Weekend,
WeekendContext,
} from './types' } from './types'
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> { export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
@@ -115,14 +114,6 @@ export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
return res.json() return res.json()
} }
export async function fetchWeekendContext(): Promise<WeekendContext> {
const res = await fetch('/api/v1/weekend-context')
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}
export async function fetchChampionshipHub(year?: number): Promise<ChampionshipHub> { export async function fetchChampionshipHub(year?: number): Promise<ChampionshipHub> {
const params = new URLSearchParams({ source: 'auto' }) const params = new URLSearchParams({ source: 'auto' })
if (year) params.set('year', year.toString()) if (year) params.set('year', year.toString())

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,6 @@ const (
preSessionWindow = 48 * time.Hour preSessionWindow = 48 * time.Hour
postWeekendWindow = 48 * time.Hour postWeekendWindow = 48 * time.Hour
raceHubPendingPollInterval = 15 * time.Second
) )
// LiveEvidence is the small, transport-independent subset of FIA state needed // LiveEvidence is the small, transport-independent subset of FIA state needed
@@ -41,7 +40,6 @@ type LiveEvidence struct {
// ContextAvailability is structured source state for a referenced session. // ContextAvailability is structured source state for a referenced session.
type ContextAvailability struct { type ContextAvailability struct {
Source string `json:"source"`
Schedule string `json:"schedule"` Schedule string `json:"schedule"`
LiveTransport string `json:"live_transport"` LiveTransport string `json:"live_transport"`
LiveSession string `json:"live_session"` LiveSession string `json:"live_session"`
@@ -70,9 +68,6 @@ type WeekendContext struct {
ActiveSession *ContextSession `json:"active_session,omitempty"` ActiveSession *ContextSession `json:"active_session,omitempty"`
NextSession *ContextSession `json:"next_session,omitempty"` NextSession *ContextSession `json:"next_session,omitempty"`
DefaultAnalysisSession *ContextSession `json:"default_analysis_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"` ChampionshipRound int `json:"championship_round"`
TotalChampionshipRounds int `json:"total_championship_rounds"` TotalChampionshipRounds int `json:"total_championship_rounds"`
} }
@@ -154,7 +149,7 @@ func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext,
} }
} }
var previous, next, defaultAnalysis, pending *contextCandidate var previous, next, defaultAnalysis *contextCandidate
for i := range candidates { for i := range candidates {
c := &candidates[i] c := &candidates[i]
isActive := active != nil && active.session.SessionKey != 0 && c.session.SessionKey == active.session.SessionKey isActive := active != nil && active.session.SessionKey != 0 && c.session.SessionKey == active.session.SessionKey
@@ -168,10 +163,6 @@ func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext,
if !isActive && !c.start.IsZero() && !c.start.Before(now) && (next == nil || c.start.Before(next.start)) { if !isActive && !c.start.IsZero() && !c.start.Before(now) && (next == nil || c.start.Before(next.start)) {
next = c 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 { if previous != nil {
@@ -196,40 +187,9 @@ func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext,
if out.FocusMeeting != nil { if out.FocusMeeting != nil {
out.ChampionshipRound = championshipRound(champMeetings, int(out.FocusMeeting.MeetingKey)) out.ChampionshipRound = championshipRound(champMeetings, int(out.FocusMeeting.MeetingKey))
} }
applyRaceHubDefault(&out, active, defaultAnalysis, next, pending, now)
return out, nil 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 { func currentLocalSeason(years []int, current int) int {
for _, year := range years { for _, year := range years {
if year == current { if year == current {
@@ -338,28 +298,21 @@ func meetingModelByKey(meetings []store.Meeting, key int) *models.Meeting {
func sessionRef(c contextCandidate, evidence LiveEvidence, now time.Time) *ContextSession { func sessionRef(c contextCandidate, evidence LiveEvidence, now time.Time) *ContextSession {
session := sessionToModel(c.session) session := sessionToModel(c.session)
meeting := meetingToModel(c.meeting) meeting := meetingToModel(c.meeting)
// Schedule and analysis are domain-store facts. Without an ingestion availability := ContextAvailability{Schedule: "available", LiveSession: "inactive", Archive: "unavailable", Freshness: "fresh", Limitations: []string{}}
// 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" availability.LiveTransport = "unknown"
if c.session.SessionKey == 0 { if c.session.SessionKey == 0 {
availability.Schedule = "unavailable" availability.Schedule = "unavailable"
availability.Limitations = append(availability.Limitations, "schedule_identity_unmatched") availability.Limitations = append(availability.Limitations, "schedule_identity_unmatched")
} }
if evidence.Active && liveMatches(evidence, c.meeting, c.session) { if evidence.Active && liveMatches(evidence, c.meeting, c.session) {
availability.Source = "mixed"
availability.LiveTransport = "connected" availability.LiveTransport = "connected"
availability.LiveSession = "active" availability.LiveSession = "active"
availability.Freshness = "live"
if !evidence.ObservedAt.IsZero() { if !evidence.ObservedAt.IsZero() {
availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339) availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339)
} }
} }
if c.archived { if c.archived {
availability.Source = "mixed"
availability.Archive = "available" availability.Archive = "available"
availability.Freshness = "archive"
if !evidence.ObservedAt.IsZero() { if !evidence.ObservedAt.IsZero() {
availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339) availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339)
} }
@@ -373,12 +326,6 @@ func sessionRef(c contextCandidate, evidence LiveEvidence, now time.Time) *Conte
} else { } else {
availability.LocalAnalysis = "pending" 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} return &ContextSession{Session: session, Meeting: &meeting, Availability: availability}
} }

View File

@@ -160,54 +160,6 @@ func TestResolveWeekendContextPassedTimeDoesNotCompleteSession(t *testing.T) {
} }
} }
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) { func TestResolveWeekendContextNeverUsesFutureAnalysis(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z") now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z")
svc := contextService(t, now) svc := contextService(t, now)
@@ -223,38 +175,6 @@ func TestResolveWeekendContextNeverUsesFutureAnalysis(t *testing.T) {
} }
} }
// 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) { func TestResolveWeekendContextSprintWeekendHandoff(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-04T12:00:00Z") now, _ := time.Parse(time.RFC3339, "2026-07-04T12:00:00Z")
svc := contextService(t, now) svc := contextService(t, now)
@@ -399,88 +319,3 @@ func TestResolveWeekendContextMissingScheduleDoesNotClaimSeasonComplete(t *testi
t.Fatalf("total rounds = %d, want scheduled round retained", got.TotalChampionshipRounds) 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

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

View File

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

View File

@@ -1,120 +0,0 @@
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())
}
})
}

View File

@@ -10,7 +10,6 @@ import (
func (s *Server) handleWeekendContext(w http.ResponseWriter, _ *http.Request) { func (s *Server) handleWeekendContext(w http.ResponseWriter, _ *http.Request) {
if !s.hasLocalQuery() { if !s.hasLocalQuery() {
markDataResponse(w, "none", "limited")
writeJSON(w, query.WeekendContext{TemporalState: query.TemporalNoSeason}) writeJSON(w, query.WeekendContext{TemporalState: query.TemporalNoSeason})
return return
} }
@@ -30,33 +29,9 @@ func (s *Server) handleWeekendContext(w http.ResponseWriter, _ *http.Request) {
writeError(w, err, http.StatusInternalServerError, false) writeError(w, err, http.StatusInternalServerError, false)
return return
} }
if focus := focusedContextSession(context); focus != nil {
markDataResponse(w, focus.Availability.Source, focus.Availability.Freshness)
} else {
markDataResponse(w, "none", "limited")
}
writeJSON(w, context) 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 { 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} return query.LiveEvidence{Active: active, Final: final, MeetingName: data.Session.MeetingName, CircuitName: data.Session.CircuitName, SessionName: data.Session.SessionName, SessionType: data.Session.SessionType}
} }

View File

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

View File

@@ -1,16 +1,11 @@
package web package web
import ( import (
"encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/store"
) )
func driverSummaryFixtures() ([]meetingRace, []models.ChampionshipDriver, map[int]models.Driver) { func driverSummaryFixtures() ([]meetingRace, []models.ChampionshipDriver, map[int]models.Driver) {
@@ -139,234 +134,3 @@ 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))
}
})
}
}

View File

@@ -1,65 +0,0 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -159,7 +159,6 @@ func withCORS(next http.Handler) http.Handler {
w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type") w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Access-Control-Expose-Headers", dataSourceHeader+", "+dataFreshnessHeader)
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return

View File

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

View File

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

View File

@@ -1,86 +0,0 @@
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

@@ -1,27 +0,0 @@
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

@@ -1,50 +0,0 @@
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

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

View File

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

View File

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

View File

@@ -1,73 +0,0 @@
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: 180 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 129 KiB

View File

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