Compare commits

..

13 Commits

Author SHA1 Message Date
AmanTahiliani
77a6b0f2dd feat(#76): harvest request-scoped availability and freshness truth
Backend-only re-cut of the #76 availability work onto main, stacked on the
canonical Weekend Context API. Adds request-scoped freshness reporting so
aggregate responses cannot report fresh when a component is stale, plus
local-first driver summary resolution and cache/pacing truth.

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 09:11:03 -04:00
AmanTahiliani
0b9c3fff0a fix: make weekend context transitions truthful 2026-07-26 09:08:38 -04:00
AmanTahiliani
adfa934f9d feat: add canonical weekend context API 2026-07-26 09:08:38 -04:00
Aman Tahiliani
c475011c49 Merge pull request #88 from AmanTahiliani/feat/issue-87-live-correct-fp-q-timing-truth-hierarchy
[Live] Correct FP/Q timing truth, hierarchy, and mobile navigation (#87)
2026-07-17 12:08:45 -04:00
AmanTahiliani
5a6323d3b4 fix(live): keep red-flagged sessions active 2026-07-17 12:04:13 -04:00
AmanTahiliani
ed3b8cf628 feat(#87): [Live] Correct FP/Q timing truth, hierarchy, and mobile navigation
Implemented by claude via .agents/dev dispatch.
2026-07-17 11:53:38 -04:00
AmanTahiliani
0a42c05487 fix: align race story cursor to chart 2026-07-12 16:11:26 -04:00
AmanTahiliani
08ff75b469 fix: align race story chart to lap timing 2026-07-12 16:05:04 -04:00
Aman Tahiliani
16c035b037 Merge pull request #69 from AmanTahiliani/feat/issue-68-polish-the-race-story-section-chapter-st
Polish the Race Story section (chapter strip, playback controls, map empty-state, graph) (#68)
2026-07-12 13:44:00 -04:00
AmanTahiliani
1dac73cdc5 fix(race-story): sync chapter highlight and empty-state E2E (#68)
Clamp active-chapter detection to the same scrub bounds as chapter clicks,
keep an explicit selection on click for out-of-window timestamps, point E2E
at the empty-state card, and move Race Story empty-state styles out of app.css.

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 13:23:07 -04:00
Aman Tahiliani
f0a8e4e631 Merge pull request #67 from AmanTahiliani/phase-2
Phase 2 delivery — Replay & Storytelling, Driver & Rivalry Hub, Between-Races
2026-07-12 13:05:53 -04:00
60 changed files with 4194 additions and 419 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,7 +52,10 @@ function StintSparkline({ seconds }: { seconds: number[] }) {
} }
export function TyreDegPanel({ rows, sessionType, pinned }: Props) { export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
const [collapsed, setCollapsed] = useState(false) const isRace = isRaceSession(sessionType)
// In practice/qualifying deg trends are secondary — collapse by default so
// the Timing Tower stays above the fold. Races keep it open.
const [collapsed, setCollapsed] = useState(!isRace)
const [stints, setStints] = useState<StintHistoryMap>({}) const [stints, setStints] = useState<StintHistoryMap>({})
// One lap-history update per received snapshot (rows is rebuilt per snapshot). // One lap-history update per received snapshot (rows is rebuilt per snapshot).
@@ -61,8 +64,6 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
setStints((prev) => recordStintSamples(prev, rows.map(stintInputFromRow))) setStints((prev) => recordStintSamples(prev, rows.map(stintInputFromRow)))
}, [rows]) }, [rows])
const isRace = isRaceSession(sessionType)
const visible = useMemo( const visible = useMemo(
() => () =>
rows.filter( rows.filter(

View File

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

View File

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

View File

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

View File

@@ -112,6 +112,30 @@ a { color: inherit; text-decoration: none; }
.nav-links a:hover { color: var(--text); background: var(--surface-h); } .nav-links a:hover { color: var(--text); background: var(--surface-h); }
.nav-links a.active { color: var(--text); background: var(--surface-2); } .nav-links a.active { color: var(--text); background: var(--surface-2); }
/* Live nav gets a pulsing marker only while a session is on air. */
.nav-live { display: inline-flex; align-items: center; gap: 6px; }
.nav-links a.nav-live-on { color: var(--text); font-weight: 600; }
.nav-live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--red);
box-shadow: 0 0 0 0 rgba(225, 6, 0, 0.4);
animation: pulse-live 1.6s infinite;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.nav-utility { .nav-utility {
margin-left: auto; margin-left: auto;
display: flex; display: flex;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -81,14 +81,20 @@ describe('TyreDegPanel', () => {
expect(panel).toHaveTextContent('→ ~P2') expect(panel).toHaveTextContent('→ ~P2')
}) })
it('hides the rejoin estimate outside race sessions and collapses on toggle', () => { it('collapses by default outside race sessions and hides the rejoin estimate when expanded', () => {
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Qualifying" pinned={[]} />) render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Qualifying" pinned={[]} />)
const panel = screen.getByTestId('tyredeg-panel') const panel = screen.getByTestId('tyredeg-panel')
expect(panel).not.toHaveTextContent('~P')
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2) // Practice/qualifying starts collapsed so the Timing Tower stays above the fold.
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0) expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
expect(panel).not.toHaveTextContent('~P')
})
it('starts expanded during a race', () => {
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
}) })
it('limits rows to the top ten plus pinned drivers', () => { it('limits rows to the top ten plus pinned drivers', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -307,6 +307,28 @@ func TestSessionStatusIsActive(t *testing.T) {
} }
} }
func TestSessionStatusIsTerminal(t *testing.T) {
tests := []struct {
status string
want bool
}{
{"Finished", true},
{"Finalised", true},
{"Finalized", true},
{"Ends", true},
{"Aborted", true},
{"Started", false},
{"Resumed", false},
{"Inactive", false},
{"", false},
}
for _, tt := range tests {
if got := live.SessionStatusIsTerminal(tt.status); got != tt.want {
t.Errorf("SessionStatusIsTerminal(%q) = %v, want %v", tt.status, got, tt.want)
}
}
}
func TestProcessTopicRaceControlMessages(t *testing.T) { func TestProcessTopicRaceControlMessages(t *testing.T) {
state := live.NewState() state := live.NewState()
data := json.RawMessage(`{ data := json.RawMessage(`{

View File

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

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

@@ -0,0 +1,449 @@
package query
import (
"sort"
"strings"
"time"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/store"
)
// TemporalState describes where the fan is in the current season/weekend.
type TemporalState string
const (
TemporalNoSeason TemporalState = "no_season"
TemporalBetweenWeekends TemporalState = "between_weekends"
TemporalPreSession TemporalState = "pre_session"
TemporalSessionLive TemporalState = "session_live"
TemporalSessionSettling TemporalState = "session_settling"
TemporalBetweenSessions TemporalState = "between_sessions"
TemporalPostWeekend TemporalState = "post_weekend"
TemporalSeasonComplete TemporalState = "season_complete"
preSessionWindow = 48 * time.Hour
postWeekendWindow = 48 * time.Hour
)
// LiveEvidence is the small, transport-independent subset of FIA state needed
// by the context resolver. Active identity is authoritative over the schedule.
type LiveEvidence struct {
Active bool
Final bool
MeetingName string
CircuitName string
SessionName string
SessionType string
ObservedAt time.Time
}
// ContextAvailability is structured source state for a referenced session.
type ContextAvailability struct {
Source string `json:"source"`
Schedule string `json:"schedule"`
LiveTransport string `json:"live_transport"`
LiveSession string `json:"live_session"`
Archive string `json:"archive"`
LocalAnalysis string `json:"local_analysis"`
Freshness string `json:"freshness"`
ObservedAt string `json:"observed_at,omitempty"`
Limitations []string `json:"limitations"`
}
// ContextSession couples a session identity with its availability contract.
type ContextSession struct {
Session models.Session `json:"session"`
Meeting *models.Meeting `json:"meeting,omitempty"`
Availability ContextAvailability `json:"availability"`
}
// WeekendContext is the canonical local-first previous/current/next model.
type WeekendContext struct {
Season int `json:"season,omitempty"`
TemporalState TemporalState `json:"temporal_state"`
PreviousMeeting *models.Meeting `json:"previous_meeting,omitempty"`
FocusMeeting *models.Meeting `json:"focus_meeting,omitempty"`
NextMeeting *models.Meeting `json:"next_meeting,omitempty"`
PreviousCompletedSession *ContextSession `json:"previous_completed_session,omitempty"`
ActiveSession *ContextSession `json:"active_session,omitempty"`
NextSession *ContextSession `json:"next_session,omitempty"`
DefaultAnalysisSession *ContextSession `json:"default_analysis_session,omitempty"`
ChampionshipRound int `json:"championship_round"`
TotalChampionshipRounds int `json:"total_championship_rounds"`
}
type contextCandidate struct {
meeting store.Meeting
session store.Session
start time.Time
end time.Time
counts store.SessionDatasetCounts
complete bool
archived bool
}
// ResolveWeekendContext computes the canonical context using only the domain
// store, the service clock, and optional in-memory FIA evidence.
func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext, error) {
now := s.now().UTC()
out := WeekendContext{TemporalState: TemporalNoSeason}
years, err := s.store.ListYears()
if err != nil || len(years) == 0 {
return out, err
}
out.Season = currentLocalSeason(years, now.Year())
meetings, err := s.store.ListMeetingsByYear(out.Season)
if err != nil {
return WeekendContext{}, err
}
if len(meetings) == 0 {
return out, nil
}
byMeeting := make(map[int][]store.Session, len(meetings))
var candidates []contextCandidate
for i := range meetings {
m := meetings[i]
sessions, listErr := s.store.ListSessionsByMeeting(m.MeetingKey)
if listErr != nil {
return WeekendContext{}, listErr
}
byMeeting[m.MeetingKey] = sessions
applySessionDisplayRange(&meetings[i], sessions)
m = meetings[i]
for _, sess := range sessions {
if m.IsCancelled || sess.IsCancelled {
continue
}
start, _ := parseContextTime(sess.DateStart)
end, _ := parseContextTime(sess.DateEnd)
if end.IsZero() && !start.IsZero() {
end = start.Add(3 * time.Hour)
}
counts, countErr := s.store.CountSessionDatasets(sess.SessionKey)
if countErr != nil {
return WeekendContext{}, countErr
}
archived := evidence.Final && liveMatches(evidence, m, sess)
candidates = append(candidates, contextCandidate{
meeting: m, session: sess, start: start, end: end, counts: counts,
complete: hasMeaningfulAnalysis(counts) || archived, archived: archived,
})
}
}
sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].start.Before(candidates[j].start) })
champMeetings := championshipMeetings(meetings, byMeeting)
out.TotalChampionshipRounds = len(champMeetings)
var active *contextCandidate
if evidence.Active {
for i := range candidates {
if liveMatches(evidence, candidates[i].meeting, candidates[i].session) {
active = &candidates[i]
break
}
}
if active == nil {
active = syntheticLiveCandidate(evidence, now)
}
}
var previous, next, defaultAnalysis *contextCandidate
for i := range candidates {
c := &candidates[i]
isActive := active != nil && active.session.SessionKey != 0 && c.session.SessionKey == active.session.SessionKey
completionEligible := c.start.IsZero() || !c.start.After(now) || c.archived
if !isActive && c.complete && completionEligible && (previous == nil || candidateTime(*c).After(candidateTime(*previous))) {
previous = c
}
if !isActive && c.complete && hasMeaningfulAnalysis(c.counts) && (c.start.IsZero() || !c.start.After(now)) && (defaultAnalysis == nil || candidateTime(*c).After(candidateTime(*defaultAnalysis))) {
defaultAnalysis = c
}
if !isActive && !c.start.IsZero() && !c.start.Before(now) && (next == nil || c.start.Before(next.start)) {
next = c
}
}
if previous != nil {
out.PreviousCompletedSession = sessionRef(*previous, evidence, now)
out.PreviousMeeting = meetingModelByKey(meetings, previous.meeting.MeetingKey)
}
if defaultAnalysis != nil {
out.DefaultAnalysisSession = sessionRef(*defaultAnalysis, evidence, now)
}
if next != nil {
out.NextSession = sessionRef(*next, evidence, now)
out.NextMeeting = meetingModelByKey(meetings, next.meeting.MeetingKey)
}
if active != nil {
out.ActiveSession = sessionRef(*active, evidence, now)
out.FocusMeeting = out.ActiveSession.Meeting
out.TemporalState = TemporalSessionLive
} else {
out.FocusMeeting = chooseFocusMeeting(meetings, previous, next)
out.TemporalState = classifyTemporalState(now, previous, next, candidates, champMeetings, championshipScheduleUnknown(champMeetings, byMeeting))
}
if out.FocusMeeting != nil {
out.ChampionshipRound = championshipRound(champMeetings, int(out.FocusMeeting.MeetingKey))
}
return out, nil
}
func currentLocalSeason(years []int, current int) int {
for _, year := range years {
if year == current {
return year
}
}
for _, year := range years {
if year < current {
return year
}
}
return years[len(years)-1]
}
func parseContextTime(value string) (time.Time, bool) {
if value == "" {
return time.Time{}, false
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05"} {
if parsed, err := time.Parse(layout, value); err == nil {
return parsed.UTC(), true
}
}
return time.Time{}, false
}
func hasMeaningfulAnalysis(c store.SessionDatasetCounts) bool {
return c.Results > 0 || c.Laps > 0 || c.Stints > 0 || c.Positions > 0 || c.RaceControl > 0
}
func candidateTime(c contextCandidate) time.Time {
if !c.end.IsZero() {
return c.end
}
return c.start
}
func liveMatches(e LiveEvidence, m store.Meeting, s store.Session) bool {
meetingMatch := normalizedContains(e.MeetingName, m.MeetingName) || normalizedContains(e.CircuitName, m.CircuitShortName)
sessionMatch := normalizedEqual(e.SessionName, s.SessionName)
if normalizeIdentity(e.SessionName) == "" {
sessionMatch = normalizedEqual(e.SessionType, s.SessionType)
}
return meetingMatch && sessionMatch
}
func normalizedEqual(a, b string) bool {
return normalizeIdentity(a) != "" && normalizeIdentity(a) == normalizeIdentity(b)
}
func normalizedContains(a, b string) bool {
a, b = normalizeIdentity(a), normalizeIdentity(b)
return a != "" && b != "" && (strings.Contains(a, b) || strings.Contains(b, a))
}
func normalizeIdentity(v string) string {
return strings.Map(func(r rune) rune {
if r >= 'A' && r <= 'Z' {
return r + ('a' - 'A')
}
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
return r
}
return -1
}, v)
}
func syntheticLiveCandidate(e LiveEvidence, now time.Time) *contextCandidate {
return &contextCandidate{meeting: store.Meeting{MeetingName: e.MeetingName, CircuitShortName: e.CircuitName}, session: store.Session{SessionName: e.SessionName, SessionType: e.SessionType, DateStart: now.Format(time.RFC3339)}, start: now}
}
func applySessionDisplayRange(m *store.Meeting, sessions []store.Session) {
var first, last time.Time
for _, sess := range sessions {
if sess.IsCancelled {
continue
}
start, ok := parseContextTime(sess.DateStart)
if ok && (first.IsZero() || start.Before(first)) {
first = start
}
end, ok := parseContextTime(sess.DateEnd)
if !ok {
end = start
}
if !end.IsZero() && (last.IsZero() || end.After(last)) {
last = end
}
}
if !first.IsZero() {
m.DateStart = first.Format(time.RFC3339)
}
if !last.IsZero() {
m.DateEnd = last.Format(time.RFC3339)
}
}
func meetingModelByKey(meetings []store.Meeting, key int) *models.Meeting {
for _, meeting := range meetings {
if meeting.MeetingKey == key {
model := meetingToModel(meeting)
return &model
}
}
return nil
}
func sessionRef(c contextCandidate, evidence LiveEvidence, now time.Time) *ContextSession {
session := sessionToModel(c.session)
meeting := meetingToModel(c.meeting)
// Schedule and analysis are domain-store facts. Without an ingestion
// timestamp the resolver cannot honestly call them network-fresh, so local
// is the baseline freshness vocabulary exposed to clients.
availability := ContextAvailability{Source: "local", Schedule: "available", LiveSession: "inactive", Archive: "unavailable", Freshness: "local", Limitations: []string{}}
availability.LiveTransport = "unknown"
if c.session.SessionKey == 0 {
availability.Schedule = "unavailable"
availability.Limitations = append(availability.Limitations, "schedule_identity_unmatched")
}
if evidence.Active && liveMatches(evidence, c.meeting, c.session) {
availability.Source = "mixed"
availability.LiveTransport = "connected"
availability.LiveSession = "active"
availability.Freshness = "live"
if !evidence.ObservedAt.IsZero() {
availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339)
}
}
if c.archived {
availability.Source = "mixed"
availability.Archive = "available"
availability.Freshness = "archive"
if !evidence.ObservedAt.IsZero() {
availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339)
}
}
if c.counts.Results > 0 && (c.counts.Laps+c.counts.Stints+c.counts.Positions+c.counts.RaceControl > 0) {
availability.LocalAnalysis = "complete"
} else if hasMeaningfulAnalysis(c.counts) || c.counts.Drivers+c.counts.StartingGrid+c.counts.Weather > 0 {
availability.LocalAnalysis = "partial"
} else if !c.start.IsZero() && c.start.After(now) {
availability.LocalAnalysis = "not_applicable"
} else {
availability.LocalAnalysis = "pending"
}
if availability.LocalAnalysis == "partial" && availability.Freshness == "local" {
availability.Freshness = "partial"
}
if c.session.SessionKey == 0 && availability.LiveSession == "active" {
availability.Source = "fia"
}
return &ContextSession{Session: session, Meeting: &meeting, Availability: availability}
}
func chooseFocusMeeting(meetings []store.Meeting, previous, next *contextCandidate) *models.Meeting {
if next != nil {
return meetingModelByKey(meetings, next.meeting.MeetingKey)
}
if previous != nil {
return meetingModelByKey(meetings, previous.meeting.MeetingKey)
}
return nil
}
func classifyTemporalState(now time.Time, previous, next *contextCandidate, candidates []contextCandidate, championship []store.Meeting, scheduleUnknown bool) TemporalState {
var latestStarted *contextCandidate
for i := range candidates {
if !candidates[i].start.IsZero() && !candidates[i].start.After(now) && (latestStarted == nil || candidates[i].start.After(latestStarted.start)) {
latestStarted = &candidates[i]
}
}
// Once a new meeting enters its preparation window, an ingest gap from an
// older meeting must not keep the product stuck in settling.
if next != nil && next.start.Sub(now) <= preSessionWindow && (latestStarted == nil || latestStarted.meeting.MeetingKey != next.meeting.MeetingKey) {
return TemporalPreSession
}
if latestStarted != nil && !latestStarted.complete && !latestStarted.end.IsZero() {
if now.Before(latestStarted.end) {
return TemporalPreSession
}
return TemporalSessionSettling
}
if previous != nil && next != nil && previous.meeting.MeetingKey == next.meeting.MeetingKey {
return TemporalBetweenSessions
}
if previous != nil && meetingFinalSession(*previous, candidates) && !candidateTime(*previous).After(now) && now.Sub(candidateTime(*previous)) <= postWeekendWindow {
return TemporalPostWeekend
}
if next != nil && next.start.Sub(now) <= preSessionWindow {
return TemporalPreSession
}
if next == nil && len(championship) > 0 && !scheduleUnknown {
return TemporalSeasonComplete
}
return TemporalBetweenWeekends
}
func championshipScheduleUnknown(meetings []store.Meeting, sessions map[int][]store.Session) bool {
for _, meeting := range meetings {
for _, session := range sessions[meeting.MeetingKey] {
if !session.IsCancelled && isChampionshipRace(session) {
if _, ok := parseContextTime(session.DateStart); !ok {
return true
}
}
}
}
return false
}
func meetingFinalSession(previous contextCandidate, candidates []contextCandidate) bool {
latest := previous.start
for _, c := range candidates {
if c.meeting.MeetingKey == previous.meeting.MeetingKey && c.start.After(latest) {
return false
}
}
return true
}
func championshipMeetings(meetings []store.Meeting, sessions map[int][]store.Session) []store.Meeting {
var out []store.Meeting
for _, m := range meetings {
if m.IsCancelled || isTestMeeting(m) {
continue
}
for _, sess := range sessions[m.MeetingKey] {
if !sess.IsCancelled && isChampionshipRace(sess) {
out = append(out, m)
break
}
}
}
sort.SliceStable(out, func(i, j int) bool {
a, _ := parseContextTime(out[i].DateStart)
b, _ := parseContextTime(out[j].DateStart)
return a.Before(b)
})
return out
}
func isTestMeeting(m store.Meeting) bool {
n := strings.ToLower(m.MeetingName + " " + m.MeetingOfficialName)
return strings.Contains(n, "test")
}
func isChampionshipRace(s store.Session) bool {
n := strings.ToLower(s.SessionName)
t := strings.ToLower(s.SessionType)
return (n == "race" || t == "race") && !strings.Contains(n, "sprint") && !strings.Contains(t, "sprint")
}
func championshipRound(meetings []store.Meeting, key int) int {
for i, m := range meetings {
if m.MeetingKey == key {
return i + 1
}
}
return 0
}

View File

@@ -0,0 +1,401 @@
package query
import (
"testing"
"time"
"github.com/AmanTahiliani/box-box/internal/store"
)
func contextService(t *testing.T, now time.Time) *Service {
t.Helper()
base := openTestService(t)
return NewServiceWithClock(base.store, func() time.Time { return now })
}
func addContextMeeting(t *testing.T, svc *Service, key int, name, start, end string, cancelled bool) {
t.Helper()
if err := svc.store.UpsertMeeting(store.Meeting{MeetingKey: key, MeetingName: name, MeetingOfficialName: name, CircuitShortName: name, Year: 2026, DateStart: start, DateEnd: end, IsCancelled: cancelled}); err != nil {
t.Fatal(err)
}
}
func addContextSession(t *testing.T, svc *Service, key, meeting int, name, start, end string, cancelled bool) {
t.Helper()
if err := svc.store.UpsertSession(store.Session{SessionKey: key, MeetingKey: meeting, SessionName: name, SessionType: name, DateStart: start, DateEnd: end, IsCancelled: cancelled}); err != nil {
t.Fatal(err)
}
}
func completeContextSession(t *testing.T, svc *Service, key, meeting int) {
t.Helper()
if err := svc.store.UpsertSessionResult(store.SessionResult{SessionKey: key, MeetingKey: meeting, DriverNumber: 1, Position: 1}); err != nil {
t.Fatal(err)
}
}
func TestResolveWeekendContextTemporalStates(t *testing.T) {
tests := []struct {
name string
now string
seed func(*testing.T, *Service)
evidence LiveEvidence
want TemporalState
}{
{name: "no season", now: "2026-06-01T12:00:00Z", want: TemporalNoSeason},
{name: "between weekends", now: "2026-06-10T12:00:00Z", seed: func(t *testing.T, s *Service) {
addContextMeeting(t, s, 1, "Monaco Grand Prix", "2026-06-01T09:00:00Z", "2026-06-02T16:00:00Z", false)
addContextSession(t, s, 11, 1, "Race", "2026-06-02T14:00:00Z", "2026-06-02T16:00:00Z", false)
completeContextSession(t, s, 11, 1)
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
}, want: TemporalBetweenWeekends},
{name: "pre session", now: "2026-06-19T12:00:00Z", seed: func(t *testing.T, s *Service) {
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
}, want: TemporalPreSession},
{name: "session live overrides schedule", now: "2026-06-20T12:00:00Z", seed: func(t *testing.T, s *Service) {
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
}, evidence: LiveEvidence{Active: true, MeetingName: "Canadian Grand Prix", CircuitName: "Canada Grand Prix", SessionName: "Practice 1", SessionType: "Practice 1"}, want: TemporalSessionLive},
{name: "session settling", now: "2026-06-20T11:00:00Z", seed: func(t *testing.T, s *Service) {
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
}, want: TemporalSessionSettling},
{name: "between sessions", now: "2026-06-20T11:00:00Z", seed: func(t *testing.T, s *Service) {
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
completeContextSession(t, s, 21, 2)
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
}, want: TemporalBetweenSessions},
{name: "post weekend", now: "2026-06-22T18:00:00Z", seed: func(t *testing.T, s *Service) {
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
completeContextSession(t, s, 22, 2)
}, want: TemporalPostWeekend},
{name: "season complete", now: "2026-06-30T12:00:00Z", seed: func(t *testing.T, s *Service) {
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
completeContextSession(t, s, 22, 2)
}, want: TemporalSeasonComplete},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, tt.now)
svc := contextService(t, now)
if tt.seed != nil {
tt.seed(t, svc)
}
got, err := svc.ResolveWeekendContext(tt.evidence)
if err != nil {
t.Fatal(err)
}
if got.TemporalState != tt.want {
t.Fatalf("state = %s, want %s; context=%+v", got.TemporalState, tt.want, got)
}
})
}
}
func TestResolveWeekendContextTruthRules(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-10T12:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1, "Pre-Season Testing", "2026-02-01T00:00:00Z", "2026-02-03T00:00:00Z", false)
addContextSession(t, svc, 10, 1, "Race", "2026-02-03T10:00:00Z", "2026-02-03T12:00:00Z", false)
addContextMeeting(t, svc, 2, "Cancelled Grand Prix", "2026-03-01T00:00:00Z", "2026-03-03T00:00:00Z", true)
addContextSession(t, svc, 20, 2, "Race", "2026-03-03T10:00:00Z", "2026-03-03T12:00:00Z", false)
addContextMeeting(t, svc, 3, "British Grand Prix", "2026-07-01T00:00:00Z", "2026-07-05T00:00:00Z", false)
addContextSession(t, svc, 30, 3, "Practice 1", "2026-07-03T09:00:00Z", "2026-07-03T10:00:00Z", false)
addContextSession(t, svc, 31, 3, "Sprint", "2026-07-04T10:00:00Z", "2026-07-04T11:00:00Z", false)
addContextSession(t, svc, 32, 3, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
completeContextSession(t, svc, 32, 3)
addContextMeeting(t, svc, 4, "Belgian Grand Prix", "2026-07-16T00:00:00Z", "2026-07-18T00:00:00Z", false)
addContextSession(t, svc, 40, 4, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
addContextSession(t, svc, 41, 4, "Cancelled Practice", "2026-07-18T09:00:00Z", "2026-07-18T10:00:00Z", true)
addContextSession(t, svc, 42, 4, "Race", "2026-07-19T14:00:00Z", "2026-07-19T16:00:00Z", false)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.TotalChampionshipRounds != 2 || got.ChampionshipRound != 2 {
t.Fatalf("rounds = %d/%d, want 2/2", got.ChampionshipRound, got.TotalChampionshipRounds)
}
if got.DefaultAnalysisSession == nil || got.DefaultAnalysisSession.Session.SessionKey != 32 {
t.Fatalf("default analysis = %+v, want completed race 32", got.DefaultAnalysisSession)
}
if got.NextSession == nil || got.NextSession.Session.SessionKey != 40 {
t.Fatalf("next = %+v, want 40", got.NextSession)
}
if got.NextMeeting.DateStart != "2026-07-17T09:00:00Z" || got.NextMeeting.DateEnd != "2026-07-19T16:00:00Z" {
t.Fatalf("display range = %s..%s", got.NextMeeting.DateStart, got.NextMeeting.DateEnd)
}
}
func TestResolveWeekendContextPassedTimeDoesNotCompleteSession(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-05T18:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T00:00:00Z", "2026-07-05T16:00:00Z", false)
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.PreviousCompletedSession != nil || got.DefaultAnalysisSession != nil {
t.Fatalf("passed schedule was treated complete: %+v", got)
}
archive, err := svc.ResolveWeekendContext(LiveEvidence{Final: true, MeetingName: "British Grand Prix", CircuitName: "British Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
if err != nil {
t.Fatal(err)
}
if archive.PreviousCompletedSession == nil || archive.PreviousCompletedSession.Availability.Archive != "available" {
t.Fatalf("final archive not used: %+v", archive)
}
if archive.DefaultAnalysisSession != nil {
t.Fatal("archive without local analysis must not become default analysis")
}
}
func TestResolveWeekendContextAvailabilityUsesTruthfulSources(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-05T18:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T00:00:00Z", "2026-07-05T16:00:00Z", false)
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
// Results without laps/stints/positions are meaningful but incomplete local
// analysis, so they must not be labelled universally fresh.
completeContextSession(t, svc, 11, 1)
addContextMeeting(t, svc, 2, "Belgian Grand Prix", "2026-07-17T00:00:00Z", "2026-07-19T16:00:00Z", false)
addContextSession(t, svc, 21, 2, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
addContextSession(t, svc, 22, 2, "Race", "2026-07-19T14:00:00Z", "2026-07-19T16:00:00Z", false)
local, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got := local.PreviousCompletedSession.Availability; got.Source != "local" || got.Freshness != "partial" || got.LocalAnalysis != "partial" {
t.Fatalf("partial local availability = %+v", got)
}
if got := local.NextSession.Availability; got.Source != "local" || got.Freshness != "local" {
t.Fatalf("future local availability = %+v", got)
}
liveContext, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "Belgian Grand Prix", CircuitName: "Belgian Grand Prix", SessionName: "Practice 1", SessionType: "Practice 1", ObservedAt: now})
if err != nil {
t.Fatal(err)
}
if got := liveContext.ActiveSession.Availability; got.Source != "mixed" || got.Freshness != "live" || got.LiveSession != "active" {
t.Fatalf("FIA + local availability = %+v", got)
}
archiveContext, err := svc.ResolveWeekendContext(LiveEvidence{Final: true, MeetingName: "British Grand Prix", CircuitName: "British Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
if err != nil {
t.Fatal(err)
}
if got := archiveContext.PreviousCompletedSession.Availability; got.Source != "mixed" || got.Freshness != "archive" || got.Archive != "available" {
t.Fatalf("FIA archive + local availability = %+v", got)
}
synthetic, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "Unscheduled Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
if err != nil {
t.Fatal(err)
}
if got := synthetic.ActiveSession.Availability; got.Source != "fia" || got.Freshness != "live" || got.Schedule != "unavailable" {
t.Fatalf("synthetic FIA availability = %+v", got)
}
}
func TestResolveWeekendContextNeverUsesFutureAnalysis(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T00:00:00Z", "2026-07-05T16:00:00Z", false)
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
completeContextSession(t, svc, 11, 1) // bad/preloaded data must not make a future session canonical
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.PreviousCompletedSession != nil || got.DefaultAnalysisSession != nil {
t.Fatalf("future analysis selected: %+v", got)
}
}
// Regression for the v0.4.0 release blocker (#90). Production held a run
// British race (11326) and an unrun Belgian race (11334) two weekends apart.
// Analysis must resolve to the race that actually happened, even when the
// later meeting carries preloaded rows.
func TestResolveWeekendContextPrefersRunRaceOverLaterUnrunRace(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-13T12:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1289, "British Grand Prix", "2026-07-03T11:30:00Z", "2026-07-05T16:00:00Z", false)
addContextSession(t, svc, 11326, 1289, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
completeContextSession(t, svc, 11326, 1289)
addContextMeeting(t, svc, 1290, "Belgian Grand Prix", "2026-07-17T11:30:00Z", "2026-07-19T15:00:00Z", false)
addContextSession(t, svc, 11334, 1290, "Race", "2026-07-19T13:00:00Z", "2026-07-19T15:00:00Z", false)
completeContextSession(t, svc, 11334, 1290)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.DefaultAnalysisSession == nil {
t.Fatal("no analysis session resolved")
}
if key := got.DefaultAnalysisSession.Session.SessionKey; key != 11326 {
t.Fatalf("analysis session = %d, want 11326 (British); 11334 is the unrun Belgian race", key)
}
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Session.SessionKey != 11326 {
t.Fatalf("previous completed = %+v, want 11326", got.PreviousCompletedSession)
}
if got.NextSession == nil || got.NextSession.Session.SessionKey != 11334 {
t.Fatalf("next session = %+v, want 11334", got.NextSession)
}
}
func TestResolveWeekendContextSprintWeekendHandoff(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-04T12:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
addContextSession(t, svc, 11, 1, "Sprint", "2026-07-04T10:00:00Z", "2026-07-04T11:00:00Z", false)
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
completeContextSession(t, svc, 11, 1)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.TemporalState != TemporalBetweenSessions || got.PreviousCompletedSession.Session.SessionKey != 11 || got.NextSession.Session.SessionKey != 12 {
t.Fatalf("sprint handoff = %+v", got)
}
if got.TotalChampionshipRounds != 1 {
t.Fatalf("sprint created extra championship round: %d", got.TotalChampionshipRounds)
}
}
func TestResolveWeekendContextBoundaryTimestamps(t *testing.T) {
t.Run("pre-session window is inclusive", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-01T09:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
addContextSession(t, svc, 11, 1, "Practice 1", "2026-07-03T09:00:00Z", "2026-07-03T10:00:00Z", false)
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.TemporalState != TemporalPreSession {
t.Fatalf("state = %s", got.TemporalState)
}
})
t.Run("scheduled end enters settling", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-03T10:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
addContextSession(t, svc, 11, 1, "Practice 1", "2026-07-03T09:00:00Z", "2026-07-03T10:00:00Z", false)
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.TemporalState != TemporalSessionSettling {
t.Fatalf("state = %s", got.TemporalState)
}
})
t.Run("post-weekend window is inclusive", func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-07T16:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
completeContextSession(t, svc, 12, 1)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.TemporalState != TemporalPostWeekend {
t.Fatalf("state = %s", got.TemporalState)
}
})
}
func TestResolveWeekendContextPartialFutureSchedule(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "", false)
addContextSession(t, svc, 11, 1, "Practice 1", "", "", false)
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "", false)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.NextSession == nil || got.NextSession.Session.SessionKey != 12 {
t.Fatalf("partial schedule next = %+v", got.NextSession)
}
if got.FocusMeeting == nil || got.FocusMeeting.DateStart != "2026-07-05T14:00:00Z" || got.FocusMeeting.DateEnd != "2026-07-05T14:00:00Z" {
t.Fatalf("partial display range = %+v", got.FocusMeeting)
}
}
func TestResolveWeekendContextActiveSessionIsNotCompletedOrDefault(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-05T15:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
addContextSession(t, svc, 10, 1, "Qualifying", "2026-07-04T14:00:00Z", "2026-07-04T15:00:00Z", false)
completeContextSession(t, svc, 10, 1)
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
completeContextSession(t, svc, 11, 1)
got, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "British Grand Prix", CircuitName: "British Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
if err != nil {
t.Fatal(err)
}
if got.ActiveSession == nil || got.ActiveSession.Session.SessionKey != 11 {
t.Fatalf("active = %+v", got.ActiveSession)
}
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Session.SessionKey != 10 {
t.Fatalf("previous = %+v, want earlier completed session", got.PreviousCompletedSession)
}
if got.DefaultAnalysisSession == nil || got.DefaultAnalysisSession.Session.SessionKey != 10 {
t.Fatalf("default = %+v, want earlier completed session", got.DefaultAnalysisSession)
}
}
func TestResolveWeekendContextOldIncompleteSessionDoesNotSuppressNextWeekend(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-16T12:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
addContextMeeting(t, svc, 2, "Belgian Grand Prix", "2026-07-17T09:00:00Z", "2026-07-19T16:00:00Z", false)
addContextSession(t, svc, 21, 2, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
addContextSession(t, svc, 22, 2, "Race", "2026-07-19T14:00:00Z", "2026-07-19T16:00:00Z", false)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.TemporalState != TemporalPreSession {
t.Fatalf("state = %s, want %s; context=%+v", got.TemporalState, TemporalPreSession, got)
}
if got.FocusMeeting == nil || got.FocusMeeting.MeetingKey != 2 {
t.Fatalf("focus = %+v, want Belgian weekend", got.FocusMeeting)
}
}
func TestResolveWeekendContextMissingScheduleDoesNotClaimSeasonComplete(t *testing.T) {
now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z")
svc := contextService(t, now)
addContextMeeting(t, svc, 1, "British Grand Prix", "", "", false)
addContextSession(t, svc, 11, 1, "Race", "", "", false)
got, err := svc.ResolveWeekendContext(LiveEvidence{})
if err != nil {
t.Fatal(err)
}
if got.TemporalState != TemporalBetweenWeekends {
t.Fatalf("state = %s, want limited %s context", got.TemporalState, TemporalBetweenWeekends)
}
if got.TotalChampionshipRounds != 1 {
t.Fatalf("total rounds = %d, want scheduled round retained", got.TotalChampionshipRounds)
}
}

View File

@@ -3,6 +3,7 @@ package query
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"time"
"github.com/AmanTahiliani/box-box/internal/chapters" "github.com/AmanTahiliani/box-box/internal/chapters"
"github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/models"
@@ -12,11 +13,20 @@ import (
// Service assembles store-backed read models. // Service assembles store-backed read models.
type Service struct { type Service struct {
store *store.Store store *store.Store
now func() time.Time
} }
// NewService creates a query service over a domain store. // NewService creates a query service over a domain store.
func NewService(st *store.Store) *Service { func NewService(st *store.Store) *Service {
return &Service{store: st} return NewServiceWithClock(st, time.Now)
}
// NewServiceWithClock creates a query service with an injected clock.
func NewServiceWithClock(st *store.Store, now func() time.Time) *Service {
if now == nil {
now = time.Now
}
return &Service{store: st, now: now}
} }
// EnrichedResult is a session result with driver identity fields. // EnrichedResult is a session result with driver identity fields.

View File

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

View File

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

View File

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

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

@@ -0,0 +1,77 @@
package web
import (
"net/http"
"strings"
"github.com/AmanTahiliani/box-box/internal/live"
"github.com/AmanTahiliani/box-box/internal/query"
)
func (s *Server) handleWeekendContext(w http.ResponseWriter, _ *http.Request) {
if !s.hasLocalQuery() {
markDataResponse(w, "none", "limited")
writeJSON(w, query.WeekendContext{TemporalState: query.TemporalNoSeason})
return
}
state := s.hub.State()
evidence := query.LiveEvidence{}
if state.IsLive && state.Data != nil {
evidence = liveEvidence(state.Data, true, false)
} else if state.LastSnapshot != nil && terminalSessionStatus(state.LastSnapshot.SessionStatus) {
evidence = liveEvidence(state.LastSnapshot, false, true)
if state.LastSnapshotAt != nil {
evidence.ObservedAt = *state.LastSnapshotAt
}
}
context, err := s.query.ResolveWeekendContext(evidence)
if err != nil {
writeError(w, err, http.StatusInternalServerError, false)
return
}
if focus := focusedContextSession(context); focus != nil {
markDataResponse(w, focus.Availability.Source, focus.Availability.Freshness)
} else {
markDataResponse(w, "none", "limited")
}
writeJSON(w, context)
}
// focusedContextSession selects the session whose state the Weekend shell is
// presenting. An older terminal/default session must never override an
// upcoming focus session's metadata.
func focusedContextSession(context query.WeekendContext) *query.ContextSession {
if context.ActiveSession != nil {
return context.ActiveSession
}
if context.FocusMeeting == nil {
return nil
}
focusKey := context.FocusMeeting.MeetingKey
for _, ref := range []*query.ContextSession{context.NextSession, context.PreviousCompletedSession, context.DefaultAnalysisSession} {
if ref != nil && ref.Meeting != nil && ref.Meeting.MeetingKey == focusKey {
return ref
}
}
return nil
}
func liveEvidence(data *live.LiveStreamData, active, final bool) query.LiveEvidence {
return query.LiveEvidence{Active: active, Final: final, MeetingName: data.Session.MeetingName, CircuitName: data.Session.CircuitName, SessionName: data.Session.SessionName, SessionType: data.Session.SessionType}
}
func terminalSessionStatus(status string) bool {
normalized := strings.ToLower(strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
return r
}
return -1
}, status))
switch normalized {
case "finished", "finalised", "finalized", "ended", "aborted":
return true
default:
return false
}
}

View File

@@ -0,0 +1,169 @@
package web
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/AmanTahiliani/box-box/internal/live"
"github.com/AmanTahiliani/box-box/internal/query"
"github.com/AmanTahiliani/box-box/internal/store"
)
func openContextStore(t *testing.T) *store.Store {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "context.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
return st
}
func seedContextHandler(t *testing.T, st *store.Store) {
t.Helper()
if err := st.UpsertMeeting(store.Meeting{MeetingKey: 1, MeetingName: "British Grand Prix", CircuitShortName: "Silverstone", Year: 2026, DateStart: "2026-07-03T09:00:00Z", DateEnd: "2026-07-05T16:00:00Z"}); err != nil {
t.Fatal(err)
}
if err := st.UpsertSession(store.Session{SessionKey: 11, MeetingKey: 1, SessionName: "Race", SessionType: "Race", DateStart: "2026-07-05T14:00:00Z", DateEnd: "2026-07-05T16:00:00Z"}); err != nil {
t.Fatal(err)
}
}
func TestWeekendContextHandlerWithoutStoreReturnsNoSeason(t *testing.T) {
s := NewServer(nil, 0, nil)
rr := httptest.NewRecorder()
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
var got query.WeekendContext
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.TemporalState != query.TemporalNoSeason {
t.Fatalf("state = %s", got.TemporalState)
}
if rr.Header().Get(dataSourceHeader) != "none" || rr.Header().Get(dataFreshnessHeader) != "limited" {
t.Fatalf("missing context metadata = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
}
}
func TestWeekendContextHandlerEmptyStoreReportsLimited(t *testing.T) {
st := openContextStore(t)
s := NewServer(nil, 0, st)
rr := httptest.NewRecorder()
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
if rr.Code != http.StatusOK || rr.Header().Get(dataSourceHeader) != "none" || rr.Header().Get(dataFreshnessHeader) != "limited" {
t.Fatalf("empty context = %d %q/%q body=%s", rr.Code, rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader), rr.Body.String())
}
}
func TestWeekendContextHandlerUsesLiveHubIdentityWithoutOpenF1(t *testing.T) {
st := openContextStore(t)
seedContextHandler(t, st)
now, _ := time.Parse(time.RFC3339, "2026-07-05T13:55:00Z")
s := NewServer(nil, 0, st) // a nil OpenF1 client makes any REST dependency panic
s.query = query.NewServiceWithClock(st, func() time.Time { return now })
s.hub.applySnapshot(live.LiveStreamData{SessionStatus: "Started", Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", CircuitName: "Silverstone", SessionName: "Race", SessionType: "Race"}}, now)
handler, err := s.routes()
if err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rr.Code, rr.Body.String())
}
var got query.WeekendContext
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.TemporalState != query.TemporalSessionLive || got.ActiveSession == nil || got.ActiveSession.Session.SessionKey != 11 {
t.Fatalf("live context = %+v", got)
}
if got.ActiveSession.Availability.LiveTransport != "connected" || got.ActiveSession.Availability.LiveSession != "active" {
t.Fatalf("availability = %+v", got.ActiveSession.Availability)
}
if got.ActiveSession.Availability.Source != "mixed" || got.ActiveSession.Availability.Freshness != "live" {
t.Fatalf("live source/freshness = %+v", got.ActiveSession.Availability)
}
if rr.Header().Get(dataSourceHeader) != "mixed" || rr.Header().Get(dataFreshnessHeader) != "live" {
t.Fatalf("response source/freshness = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
}
}
func TestWeekendContextHandlerUsesTerminalArchiveAsCompletionEvidence(t *testing.T) {
st := openContextStore(t)
seedContextHandler(t, st)
now, _ := time.Parse(time.RFC3339, "2026-07-05T16:05:00Z")
s := NewServer(nil, 0, st)
s.query = query.NewServiceWithClock(st, func() time.Time { return now })
s.hub.applySnapshot(live.LiveStreamData{SessionStatus: "Finished", Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", CircuitName: "Silverstone", SessionName: "Race", SessionType: "Race"}}, now)
rr := httptest.NewRecorder()
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
var got query.WeekendContext
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Availability.Archive != "available" {
t.Fatalf("archive context = %+v", got)
}
if got.PreviousCompletedSession.Availability.Source != "mixed" || got.PreviousCompletedSession.Availability.Freshness != "archive" {
t.Fatalf("archive source/freshness = %+v", got.PreviousCompletedSession.Availability)
}
if rr.Header().Get(dataSourceHeader) != "mixed" || rr.Header().Get(dataFreshnessHeader) != "archive" {
t.Fatalf("archive response source/freshness = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
}
if got.DefaultAnalysisSession != nil {
t.Fatal("archive-only session must not become local default analysis")
}
}
func TestWeekendContextMetadataFollowsUpcomingFocusNotTerminalPrevious(t *testing.T) {
st := openContextStore(t)
seedContextHandler(t, st)
if err := st.UpsertMeeting(store.Meeting{MeetingKey: 2, MeetingName: "Belgian Grand Prix", CircuitShortName: "Spa", Year: 2026, DateStart: "2026-07-10T09:00:00Z", DateEnd: "2026-07-12T16:00:00Z"}); err != nil {
t.Fatal(err)
}
if err := st.UpsertSession(store.Session{SessionKey: 21, MeetingKey: 2, SessionName: "Practice 1", SessionType: "Practice", DateStart: "2026-07-10T09:00:00Z", DateEnd: "2026-07-10T10:00:00Z"}); err != nil {
t.Fatal(err)
}
now, _ := time.Parse(time.RFC3339, "2026-07-05T16:05:00Z")
s := NewServer(nil, 0, st)
s.query = query.NewServiceWithClock(st, func() time.Time { return now })
s.hub.applySnapshot(live.LiveStreamData{SessionStatus: "Finished", Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", CircuitName: "Silverstone", SessionName: "Race", SessionType: "Race"}}, now)
rr := httptest.NewRecorder()
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
var got query.WeekendContext
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.FocusMeeting == nil || got.FocusMeeting.MeetingKey != 2 || got.NextSession == nil {
t.Fatalf("focus context = %+v", got)
}
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Availability.Freshness != "archive" {
t.Fatalf("terminal previous missing = %+v", got.PreviousCompletedSession)
}
if rr.Header().Get(dataSourceHeader) != "local" || rr.Header().Get(dataFreshnessHeader) != "local" {
t.Fatalf("focus metadata was overridden by archive = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
}
}
func TestTerminalSessionStatus(t *testing.T) {
for _, status := range []string{"Finished", "Finalised", "ENDED", "Aborted"} {
if !terminalSessionStatus(status) {
t.Errorf("%q should be terminal", status)
}
}
for _, status := range []string{"Started", "Resumed", "Inactive", ""} {
if terminalSessionStatus(status) {
t.Errorf("%q should not be terminal", status)
}
}
}

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -36,6 +36,14 @@ export async function gotoRaceHubReady(page: Page, sessionKey = FULL_SESSION): P
await waitForScreenshotReady(page) await waitForScreenshotReady(page)
} }
export async function gotoRaceStoryReady(page: Page, sessionKey = FULL_SESSION): Promise<void> {
await page.goto(`/race-hub?session_key=${sessionKey}`)
await expect(page.getByTestId('race-hub')).toBeVisible()
await page.getByRole('tab', { name: 'Race Story' }).click()
await expect(page.getByTestId('position-chart')).toBeVisible()
await waitForScreenshotReady(page)
}
export async function gotoDataLibraryReady(page: Page): Promise<void> { export async function gotoDataLibraryReady(page: Page): Promise<void> {
await page.goto('/admin') await page.goto('/admin')
await expect(page.getByTestId('data-library')).toBeVisible() await expect(page.getByTestId('data-library')).toBeVisible()

View File

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