From e8de2068c13764b5ba933d338397438e088ec341 Mon Sep 17 00:00:00 2001
From: AmanTahiliani
Date: Sat, 11 Jul 2026 17:59:00 -0400
Subject: [PATCH 1/7] feat(frontend): add next-race preview page (#25)
Assemble /preview from existing schedule, track-outline, results, and
championship hub endpoints with client-side preview.ts helpers, section
loading/empty states, and Vitest coverage for lib + page render paths.
Co-authored-by: Cursor
---
frontend/src/api.ts | 34 +-
frontend/src/components/Nav.tsx | 3 +
frontend/src/lib/preview.ts | 123 ++++++
frontend/src/pages/RacePreviewPage.tsx | 442 +++++++++++++++++++++
frontend/src/router.tsx | 8 +
frontend/src/styles/preview.css | 309 ++++++++++++++
frontend/src/test/RacePreviewPage.test.tsx | 276 +++++++++++++
frontend/src/test/preview.test.ts | 226 +++++++++++
frontend/src/types.ts | 2 +
9 files changed, 1422 insertions(+), 1 deletion(-)
create mode 100644 frontend/src/lib/preview.ts
create mode 100644 frontend/src/pages/RacePreviewPage.tsx
create mode 100644 frontend/src/styles/preview.css
create mode 100644 frontend/src/test/RacePreviewPage.test.tsx
create mode 100644 frontend/src/test/preview.test.ts
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 1b7d972..7baae46 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -2,6 +2,8 @@ import type {
ArticleContent,
CarDataSample,
ChampionshipHub,
+ EnrichedGrid,
+ EnrichedResult,
LapsComparisonResponse,
LiveStateResponse,
LiveSessionMeta,
@@ -40,7 +42,11 @@ export async function fetchLocalMeetings(year: number): Promise {
}
export async function fetchSeasonMeetings(year: number): Promise {
- const res = await fetch(`/api/v1/meetings?year=${year}&source=openf1`)
+ return fetchMeetings(year, 'openf1')
+}
+
+export async function fetchMeetings(year: number, source = 'auto'): Promise {
+ const res = await fetch(`/api/v1/meetings?year=${year}&source=${source}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
@@ -48,6 +54,32 @@ export async function fetchSeasonMeetings(year: number): Promise {
return Array.isArray(meetings) ? meetings : []
}
+export async function fetchResults(sessionKey: number, source = 'auto'): Promise {
+ const res = await fetch(`/api/v1/results?session_key=${sessionKey}&source=${source}`)
+ if (!res.ok) {
+ throw new Error(`API ${res.status}: ${res.statusText}`)
+ }
+ const results = await res.json()
+ return Array.isArray(results) ? results : []
+}
+
+export async function fetchStartingGrid(sessionKey: number, source = 'auto'): Promise {
+ const res = await fetch(`/api/v1/grid?session_key=${sessionKey}&source=${source}`)
+ if (!res.ok) {
+ throw new Error(`API ${res.status}: ${res.statusText}`)
+ }
+ const grid = await res.json()
+ return Array.isArray(grid) ? grid : []
+}
+
+export async function fetchTrackOutline(circuitKey: number, year: number): Promise {
+ const res = await fetch(`/api/v1/track-outline?circuit_key=${circuitKey}&year=${year}`)
+ if (!res.ok) return null
+ const data = await res.json()
+ if (data?.error || !Array.isArray(data?.points) || data.points.length < 2) return null
+ return data as TrackOutline
+}
+
export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise {
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
if (!res.ok) {
diff --git a/frontend/src/components/Nav.tsx b/frontend/src/components/Nav.tsx
index f337c1c..5542858 100644
--- a/frontend/src/components/Nav.tsx
+++ b/frontend/src/components/Nav.tsx
@@ -16,6 +16,9 @@ export function Nav() {
Race Hub
+
+ Preview
+
Championship
diff --git a/frontend/src/lib/preview.ts b/frontend/src/lib/preview.ts
new file mode 100644
index 0000000..15eec53
--- /dev/null
+++ b/frontend/src/lib/preview.ts
@@ -0,0 +1,123 @@
+import type { ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session } from '../types'
+import {
+ currentAndNextSession,
+ nextUpcomingMeeting,
+ sessionStartTime,
+ sortSessionsByStart,
+} from './schedule'
+
+export const RACE_WIN_POINTS = 25
+export const SPRINT_WIN_POINTS = 8
+export const SPRINT_WEEKEND_MAX_POINTS = RACE_WIN_POINTS + SPRINT_WIN_POINTS
+
+export function pickPreviewMeeting(meetings: Meeting[], now: Date): Meeting | null {
+ const active = meetings.filter((m) => !m.is_cancelled)
+ return nextUpcomingMeeting(active, now)
+}
+
+export function findPriorYearMeetingByCircuit(
+ priorYearMeetings: Meeting[],
+ circuitKey: number | undefined,
+): Meeting | null {
+ if (!circuitKey) return null
+ return priorYearMeetings.find((m) => m.circuit_key === circuitKey && !m.is_cancelled) ?? null
+}
+
+export function findRaceSession(sessions: Session[]): Session | null {
+ const sorted = sortSessionsByStart(sessions)
+ return (
+ sorted.find((s) => {
+ const type = s.session_type?.toLowerCase() ?? ''
+ return type.includes('race') && !type.includes('sprint')
+ }) ?? null
+ )
+}
+
+export function isSprintWeekend(sessions: Session[]): boolean {
+ return sessions.some((s) => {
+ const label = `${s.session_type} ${s.session_name}`.toLowerCase()
+ return /\bsprint\b/.test(label) && !label.includes('sprint qualifying') && !label.includes('sprint shootout')
+ })
+}
+
+export interface PodiumPlace {
+ position: number
+ name_acronym: string
+ full_name: string
+ team_name: string
+ team_colour: string
+}
+
+export interface PolePosition {
+ name_acronym: string
+ full_name: string
+ team_name: string
+ team_colour: string
+}
+
+export function extractPodium(results: EnrichedResult[]): PodiumPlace[] {
+ return results
+ .filter((r) => r.position >= 1 && r.position <= 3 && !r.dns && !r.dsq)
+ .sort((a, b) => a.position - b.position)
+ .map((r) => ({
+ position: r.position,
+ name_acronym: r.name_acronym,
+ full_name: r.full_name,
+ team_name: r.team_name,
+ team_colour: r.team_colour,
+ }))
+}
+
+export function extractPole(grid: EnrichedGrid[]): PolePosition | null {
+ const pole = grid.find((g) => g.position === 1)
+ if (!pole) return null
+ return {
+ name_acronym: pole.name_acronym,
+ full_name: pole.full_name,
+ team_name: pole.team_name,
+ team_colour: pole.team_colour,
+ }
+}
+
+export interface TitleFightDriver {
+ name_acronym: string
+ full_name: string
+ team_colour: string
+ points: number
+ position: number
+ gapToLeader: number
+ gapAfterRaceWin: number
+ gapAfterSprintWeekendMax: number
+}
+
+export function buildTitleFightContext(hub: ChampionshipHub): TitleFightDriver[] {
+ if (hub.drivers.length === 0) return []
+ const leader = hub.drivers[0]
+ return hub.drivers.slice(0, 3).map((d) => ({
+ name_acronym: d.name_acronym,
+ full_name: d.full_name,
+ team_colour: d.team_colour,
+ points: d.points,
+ position: d.position,
+ gapToLeader: leader.points - d.points,
+ gapAfterRaceWin: leader.points - (d.points + RACE_WIN_POINTS),
+ gapAfterSprintWeekendMax: leader.points - (d.points + SPRINT_WEEKEND_MAX_POINTS),
+ }))
+}
+
+export function countdownTargetSession(sessions: Session[], now: Date): Session | null {
+ const sorted = sortSessionsByStart(sessions)
+ const { next } = currentAndNextSession(sorted, now)
+ if (next) return next
+ for (const session of sorted) {
+ const start = sessionStartTime(session)
+ if (start && start > now) return session
+ }
+ return null
+}
+
+export function formatPointsGap(gap: number): string {
+ if (gap === 0) return 'LEADER'
+ const sign = gap > 0 ? '+' : ''
+ return `${sign}${Number.isInteger(gap) ? gap : gap.toFixed(1)}`
+}
diff --git a/frontend/src/pages/RacePreviewPage.tsx b/frontend/src/pages/RacePreviewPage.tsx
new file mode 100644
index 0000000..ca6d68a
--- /dev/null
+++ b/frontend/src/pages/RacePreviewPage.tsx
@@ -0,0 +1,442 @@
+import { useEffect, useMemo, useState, type ReactNode } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import {
+ fetchChampionshipHub,
+ fetchMeetings,
+ fetchResults,
+ fetchSeasons,
+ fetchSessions,
+ fetchStartingGrid,
+ fetchTrackOutline,
+} from '../api'
+import { countryAccent, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
+import {
+ buildTitleFightContext,
+ countdownTargetSession,
+ extractPodium,
+ extractPole,
+ findPriorYearMeetingByCircuit,
+ findRaceSession,
+ formatPointsGap,
+ isSprintWeekend,
+ pickPreviewMeeting,
+ RACE_WIN_POINTS,
+ SPRINT_WEEKEND_MAX_POINTS,
+} from '../lib/preview'
+import { formatCountdown, formatSessionScheduleTime, sessionStartTime, sortSessionsByStart } from '../lib/schedule'
+import { buildOutlinePath } from '../lib/trackmap'
+import { teamColor } from '../utils'
+import type { Meeting, Session, TrackOutline } from '../types'
+import '../styles/preview.css'
+
+function SectionState({
+ loading,
+ error,
+ empty,
+ emptyMessage,
+ children,
+}: {
+ loading?: boolean
+ error?: Error | null
+ empty?: boolean
+ emptyMessage?: string
+ children: ReactNode
+}) {
+ if (loading) {
+ return Loading…
+ }
+ if (error) {
+ return (
+
+ {error instanceof Error ? error.message : 'Failed to load'}
+
+ )
+ }
+ if (empty) {
+ return {emptyMessage ?? 'No data available'}
+ }
+ return <>{children}>
+}
+
+function TrackOutlineCard({
+ outline,
+ loading,
+ error,
+ accent,
+}: {
+ outline: TrackOutline | null | undefined
+ loading: boolean
+ error: Error | null
+ accent: string
+}) {
+ const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
+
+ return (
+
+ )
+}
+
+function LastYearCard({
+ year,
+ loading,
+ error,
+ podium,
+ pole,
+ isFirstTime,
+}: {
+ year: number | null
+ loading: boolean
+ error: Error | null
+ podium: ReturnType
+ pole: ReturnType
+ isFirstTime: boolean
+}) {
+ return (
+
+ Last year here
+ {year != null && !isFirstTime && {year} race weekend
}
+
+
+ {podium.map((place) => (
+
+ P{place.position}
+
+ {place.name_acronym}
+
+ {place.team_name}
+
+ ))}
+
+ {pole && (
+
+ Pole: {pole.name_acronym} {' '}
+ ({pole.team_name})
+
+ )}
+
+
+ )
+}
+
+function TitleFightCard({
+ loading,
+ error,
+ drivers,
+ sprintWeekend,
+ season,
+}: {
+ loading: boolean
+ error: Error | null
+ drivers: ReturnType
+ sprintWeekend: boolean
+ season: number | null
+}) {
+ return (
+
+ Title fight context
+ {season != null && {season} drivers' championship · top 3
}
+
+
+
+
+ Driver
+ Pts
+ Gap
+ If win (+{RACE_WIN_POINTS})
+
+
+
+ {drivers.map((d) => (
+
+
+ {d.name_acronym}
+
+ {d.points}
+ {formatPointsGap(d.gapToLeader)}
+
+ {d.position === 1 ? 'LEADER' : formatPointsGap(d.gapAfterRaceWin)}
+
+
+ ))}
+
+
+ {sprintWeekend && (
+
+ Sprint weekend: up to {SPRINT_WEEKEND_MAX_POINTS} pts available (race win {RACE_WIN_POINTS} + sprint win
+ 8). Max gain would reduce gaps by {SPRINT_WEEKEND_MAX_POINTS} vs a standard race win.
+
+ )}
+
+
+ )
+}
+
+function PreviewHeader({
+ meeting,
+ sessions,
+ countdownSession,
+ now,
+ accent,
+}: {
+ meeting: Meeting
+ sessions: Session[]
+ countdownSession: Session | null
+ now: Date
+ accent: string
+}) {
+ const countdownTarget = countdownSession ? sessionStartTime(countdownSession) : null
+
+ return (
+
+ )
+}
+
+export function RacePreviewPage() {
+ const [now, setNow] = useState(() => Date.now())
+
+ useEffect(() => {
+ const timer = window.setInterval(() => setNow(Date.now()), 1000)
+ return () => window.clearInterval(timer)
+ }, [])
+
+ const nowDate = useMemo(() => new Date(now), [now])
+
+ const seasonsQuery = useQuery({
+ queryKey: ['seasons'],
+ queryFn: fetchSeasons,
+ })
+
+ const latestSeason = seasonsQuery.data?.[0] ?? null
+
+ const meetingsQuery = useQuery({
+ queryKey: ['meetings', latestSeason, 'auto'],
+ queryFn: () => fetchMeetings(latestSeason!, 'auto'),
+ enabled: latestSeason != null,
+ })
+
+ const previewMeeting = useMemo(
+ () => pickPreviewMeeting(meetingsQuery.data ?? [], nowDate),
+ [meetingsQuery.data, nowDate],
+ )
+
+ const sessionsQuery = useQuery({
+ queryKey: ['sessions', previewMeeting?.meeting_key, 'auto'],
+ queryFn: () => fetchSessions(previewMeeting!.meeting_key, 'auto'),
+ enabled: previewMeeting != null,
+ })
+
+ const sessions = useMemo(
+ () => sortSessionsByStart(sessionsQuery.data ?? []),
+ [sessionsQuery.data],
+ )
+
+ const countdownSession = useMemo(() => countdownTargetSession(sessions, nowDate), [sessions, nowDate])
+ const sprintWeekend = useMemo(() => isSprintWeekend(sessions), [sessions])
+
+ const priorYear = previewMeeting ? previewMeeting.year - 1 : null
+
+ const priorMeetingsQuery = useQuery({
+ queryKey: ['meetings', priorYear, 'auto'],
+ queryFn: () => fetchMeetings(priorYear!, 'auto'),
+ enabled: priorYear != null && previewMeeting != null,
+ })
+
+ const priorMeeting = useMemo(
+ () => findPriorYearMeetingByCircuit(priorMeetingsQuery.data ?? [], previewMeeting?.circuit_key),
+ [priorMeetingsQuery.data, previewMeeting?.circuit_key],
+ )
+
+ const priorSessionsQuery = useQuery({
+ queryKey: ['sessions', priorMeeting?.meeting_key, 'auto'],
+ queryFn: () => fetchSessions(priorMeeting!.meeting_key, 'auto'),
+ enabled: priorMeeting != null,
+ })
+
+ const priorRaceSession = useMemo(
+ () => findRaceSession(priorSessionsQuery.data ?? []),
+ [priorSessionsQuery.data],
+ )
+
+ const priorResultsQuery = useQuery({
+ queryKey: ['results', priorRaceSession?.session_key, 'auto'],
+ queryFn: () => fetchResults(priorRaceSession!.session_key, 'auto'),
+ enabled: priorRaceSession != null,
+ })
+
+ const priorGridQuery = useQuery({
+ queryKey: ['grid', priorRaceSession?.session_key, 'auto'],
+ queryFn: () => fetchStartingGrid(priorRaceSession!.session_key, 'auto'),
+ enabled: priorRaceSession != null,
+ })
+
+ const trackOutlineQuery = useQuery({
+ queryKey: ['track-outline', previewMeeting?.circuit_key, previewMeeting?.year],
+ queryFn: () => fetchTrackOutline(previewMeeting!.circuit_key!, previewMeeting!.year),
+ enabled: previewMeeting?.circuit_key != null && previewMeeting.circuit_key > 0,
+ })
+
+ const championshipQuery = useQuery({
+ queryKey: ['championship-hub', latestSeason],
+ queryFn: () => fetchChampionshipHub(latestSeason!),
+ enabled: latestSeason != null,
+ })
+
+ const podium = useMemo(() => extractPodium(priorResultsQuery.data ?? []), [priorResultsQuery.data])
+ const pole = useMemo(() => extractPole(priorGridQuery.data ?? []), [priorGridQuery.data])
+ const titleFight = useMemo(
+ () => (championshipQuery.data ? buildTitleFightContext(championshipQuery.data) : []),
+ [championshipQuery.data],
+ )
+
+ const accent = countryAccent(previewMeeting)
+ const isFirstTimeCircuit = priorMeeting == null && priorYear != null && !priorMeetingsQuery.isLoading
+
+ if (seasonsQuery.isLoading || meetingsQuery.isLoading) {
+ return loading preview…
+ }
+
+ if (seasonsQuery.isError || meetingsQuery.isError) {
+ const err = seasonsQuery.error ?? meetingsQuery.error
+ return (
+
+ {err instanceof Error ? err.message : 'Failed to load preview'}
+
+ )
+ }
+
+ if (!previewMeeting) {
+ return (
+
+
+
box-box · race preview
+
Season complete
+
+ No upcoming races on the {latestSeason ?? 'current'} calendar. Check back when the next season schedule is
+ published.
+
+
+ {titleFight.length > 0 && (
+
+ )}
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx
index a0227d3..052ec80 100644
--- a/frontend/src/router.tsx
+++ b/frontend/src/router.tsx
@@ -6,6 +6,7 @@ import { DataLibraryPage } from './pages/DataLibraryPage'
import { LiveTimingPage } from './pages/LiveTimingPage'
import { BriefingPage } from './pages/BriefingPage'
import { ChampionshipPage } from './pages/ChampionshipPage'
+import { RacePreviewPage } from './pages/RacePreviewPage'
type RaceHubSearch = {
session_key?: number
@@ -70,6 +71,12 @@ export const briefingRoute = createRoute({
component: BriefingPage,
})
+export const previewRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/preview',
+ component: RacePreviewPage,
+})
+
const routeTree = rootRoute.addChildren([
commandCenterRoute,
raceHubRoute,
@@ -78,6 +85,7 @@ const routeTree = rootRoute.addChildren([
liveTimingRoute,
championshipRoute,
briefingRoute,
+ previewRoute,
])
export const router = createRouter({ routeTree })
diff --git a/frontend/src/styles/preview.css b/frontend/src/styles/preview.css
new file mode 100644
index 0000000..e83f4bd
--- /dev/null
+++ b/frontend/src/styles/preview.css
@@ -0,0 +1,309 @@
+.preview-page {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s6);
+ padding: var(--s6);
+ max-width: 1200px;
+ margin: 0 auto;
+}
+
+.preview-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--s4);
+ min-height: 320px;
+ text-align: center;
+ padding: var(--s8);
+ background: var(--surface);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 8px;
+}
+
+.preview-empty-eyebrow {
+ font-size: 0.7rem;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--text-3);
+}
+
+.preview-empty-title {
+ font-size: 1.5rem;
+ font-weight: 600;
+ margin: 0;
+}
+
+.preview-empty-sub {
+ color: var(--text-2);
+ max-width: 36rem;
+ margin: 0;
+}
+
+.preview-header {
+ display: flex;
+ background: var(--surface);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 8px;
+ overflow: hidden;
+ min-height: 140px;
+}
+
+.preview-header-accent {
+ width: 4px;
+ flex-shrink: 0;
+ background: var(--preview-accent, var(--gp-accent));
+ box-shadow: 0 0 12px var(--preview-accent, var(--gp-accent));
+}
+
+.preview-header-body {
+ flex: 1;
+ padding: var(--s5);
+ display: flex;
+ flex-direction: column;
+ gap: var(--s4);
+ min-width: 0;
+}
+
+.preview-header-top {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--s4);
+}
+
+.preview-gp-name {
+ font-size: 1.4rem;
+ font-weight: 600;
+ margin: 0;
+ display: flex;
+ align-items: center;
+ gap: var(--s3);
+}
+
+.preview-gp-flag {
+ font-size: 1.5rem;
+ line-height: 1;
+}
+
+.preview-circuit {
+ color: var(--text-2);
+ font-size: 0.9rem;
+ margin: 0;
+}
+
+.preview-countdown {
+ text-align: right;
+}
+
+.preview-countdown-label {
+ display: block;
+ font-size: 0.7rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--text-3);
+ margin-bottom: var(--s1);
+}
+
+.preview-countdown-value {
+ font-family: var(--mono);
+ font-size: 1.1rem;
+ font-variant-numeric: tabular-nums;
+ color: var(--text-1);
+}
+
+.preview-schedule {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+ gap: var(--s2);
+}
+
+.preview-schedule-item {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: var(--s2) var(--s3);
+ background: rgba(255, 255, 255, 0.03);
+ border-radius: 4px;
+ font-size: 0.8rem;
+}
+
+.preview-schedule-name {
+ font-weight: 600;
+ color: var(--text-1);
+}
+
+.preview-schedule-time {
+ font-family: var(--mono);
+ font-size: 0.75rem;
+ color: var(--text-2);
+}
+
+.preview-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: var(--s5);
+}
+
+.preview-card {
+ background: var(--surface);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 8px;
+ padding: var(--s5);
+ display: flex;
+ flex-direction: column;
+ gap: var(--s4);
+ min-height: 200px;
+}
+
+.preview-card-title {
+ font-size: 0.75rem;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--text-3);
+ margin: 0;
+}
+
+.preview-card-sub {
+ font-size: 0.85rem;
+ color: var(--text-2);
+ margin: calc(-1 * var(--s2)) 0 0;
+}
+
+.preview-track-stage {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 1;
+ min-height: 180px;
+ padding: var(--s4);
+}
+
+.preview-track-svg {
+ width: 100%;
+ max-width: 280px;
+ height: auto;
+}
+
+.preview-track-outline {
+ fill: none;
+ stroke: var(--preview-accent, var(--gp-accent));
+ stroke-width: 1.8;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ opacity: 0.9;
+}
+
+.preview-track-shadow {
+ fill: none;
+ stroke: rgba(0, 0, 0, 0.5);
+ stroke-width: 3;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.preview-podium {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s3);
+}
+
+.preview-podium-row {
+ display: flex;
+ align-items: center;
+ gap: var(--s3);
+ font-size: 0.9rem;
+}
+
+.preview-podium-pos {
+ width: 1.5rem;
+ font-family: var(--mono);
+ font-weight: 600;
+ color: var(--text-3);
+}
+
+.preview-podium-code {
+ font-weight: 600;
+ min-width: 2.5rem;
+}
+
+.preview-podium-team {
+ color: var(--text-2);
+ font-size: 0.8rem;
+}
+
+.preview-pole {
+ padding-top: var(--s3);
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
+ font-size: 0.85rem;
+ color: var(--text-2);
+}
+
+.preview-pole strong {
+ color: var(--text-1);
+}
+
+.preview-title-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.85rem;
+}
+
+.preview-title-table th,
+.preview-title-table td {
+ padding: var(--s2) var(--s3);
+ text-align: left;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.preview-title-table th {
+ font-size: 0.7rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--text-3);
+ font-weight: 500;
+}
+
+.preview-title-table td.mono {
+ font-family: var(--mono);
+ font-variant-numeric: tabular-nums;
+}
+
+.preview-sprint-note {
+ font-size: 0.8rem;
+ color: var(--text-2);
+ margin: 0;
+ padding: var(--s3);
+ background: rgba(255, 255, 255, 0.03);
+ border-radius: 4px;
+}
+
+.preview-section-state {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-3);
+ font-size: 0.85rem;
+ text-align: center;
+ padding: var(--s5);
+}
+
+.preview-section-state.error {
+ color: var(--red, #e74c3c);
+}
+
+@media (max-width: 640px) {
+ .preview-page {
+ padding: var(--s4);
+ }
+
+ .preview-header-top {
+ flex-direction: column;
+ }
+
+ .preview-countdown {
+ text-align: left;
+ }
+}
diff --git a/frontend/src/test/RacePreviewPage.test.tsx b/frontend/src/test/RacePreviewPage.test.tsx
new file mode 100644
index 0000000..1ceee9d
--- /dev/null
+++ b/frontend/src/test/RacePreviewPage.test.tsx
@@ -0,0 +1,276 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
+import { RacePreviewPage } from '../pages/RacePreviewPage'
+import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session, TrackOutline } from '../types'
+
+vi.mock('../api', () => ({
+ fetchSeasons: vi.fn(),
+ fetchMeetings: vi.fn(),
+ fetchSessions: vi.fn(),
+ fetchResults: vi.fn(),
+ fetchStartingGrid: vi.fn(),
+ fetchTrackOutline: vi.fn(),
+ fetchChampionshipHub: vi.fn(),
+}))
+
+import {
+ fetchSeasons,
+ fetchMeetings,
+ fetchSessions,
+ fetchResults,
+ fetchStartingGrid,
+ fetchTrackOutline,
+ fetchChampionshipHub,
+} from '../api'
+
+const mockFetchSeasons = vi.mocked(fetchSeasons)
+const mockFetchMeetings = vi.mocked(fetchMeetings)
+const mockFetchSessions = vi.mocked(fetchSessions)
+const mockFetchResults = vi.mocked(fetchResults)
+const mockFetchStartingGrid = vi.mocked(fetchStartingGrid)
+const mockFetchTrackOutline = vi.mocked(fetchTrackOutline)
+const mockFetchChampionshipHub = vi.mocked(fetchChampionshipHub)
+
+const upcomingMeeting: Meeting = {
+ meeting_key: 100,
+ meeting_name: 'Monaco',
+ meeting_official_name: 'Monaco GP',
+ location: 'Monaco',
+ country_name: 'Monaco',
+ country_code: 'MON',
+ country_flag: '',
+ circuit_key: 10,
+ circuit_short_name: 'Monaco',
+ date_start: '2099-05-22T00:00:00+00:00',
+ date_end: '2099-05-24T23:59:59+00:00',
+ year: 2099,
+}
+
+const priorMeeting: Meeting = {
+ ...upcomingMeeting,
+ meeting_key: 90,
+ year: 2098,
+ date_start: '2098-05-22T00:00:00+00:00',
+ date_end: '2098-05-24T23:59:59+00:00',
+}
+
+const sessions: Session[] = [
+ {
+ session_key: 1,
+ session_name: 'FP1',
+ session_type: 'Practice',
+ meeting_key: 100,
+ date_start: '2099-05-22T10:00:00+00:00',
+ date_end: '2099-05-22T11:00:00+00:00',
+ gmt_offset: '02:00:00',
+ },
+ {
+ session_key: 2,
+ session_name: 'Sprint',
+ session_type: 'Sprint',
+ meeting_key: 100,
+ date_start: '2099-05-23T10:00:00+00:00',
+ date_end: '2099-05-23T11:00:00+00:00',
+ gmt_offset: '02:00:00',
+ },
+ {
+ session_key: 3,
+ session_name: 'Race',
+ session_type: 'Race',
+ meeting_key: 100,
+ date_start: '2099-05-24T13:00:00+00:00',
+ date_end: '2099-05-24T15:00:00+00:00',
+ gmt_offset: '02:00:00',
+ },
+]
+
+const priorRaceSession: Session = {
+ session_key: 50,
+ session_name: 'Race',
+ session_type: 'Race',
+ meeting_key: 90,
+ date_start: '2098-05-24T13:00:00+00:00',
+ date_end: '2098-05-24T15:00:00+00:00',
+ gmt_offset: '02:00:00',
+}
+
+const hubDriver = (over: Partial): ChampHubDriver => ({
+ driver_number: 1,
+ name_acronym: 'VER',
+ full_name: 'Max Verstappen',
+ team_name: 'Red Bull',
+ team_colour: '3671c6',
+ points: 200,
+ position: 1,
+ wins: 5,
+ podiums: 8,
+ poles: 4,
+ form: [25],
+ cumulative: [200],
+ teammate_wins: 9,
+ teammate_losses: 1,
+ ...over,
+})
+
+const hub: ChampionshipHub = {
+ season: 2099,
+ round: 5,
+ total_rounds: 24,
+ rounds_left: 19,
+ last_race: 'Monaco GP',
+ round_labels: ['R1'],
+ drivers: [
+ hubDriver({}),
+ hubDriver({ driver_number: 4, name_acronym: 'NOR', points: 160, position: 2 }),
+ hubDriver({ driver_number: 16, name_acronym: 'LEC', points: 120, position: 3 }),
+ ],
+ teams: [],
+}
+
+const results: EnrichedResult[] = [
+ {
+ driver_number: 1,
+ position: 1,
+ name_acronym: 'VER',
+ full_name: 'Max Verstappen',
+ team_name: 'Red Bull',
+ team_colour: '3671c6',
+ dnf: false,
+ dns: false,
+ dsq: false,
+ duration: 100,
+ gap_to_leader: 0,
+ number_of_laps: 78,
+ points: 25,
+ session_key: 50,
+ meeting_key: 90,
+ },
+]
+
+const grid: EnrichedGrid[] = [
+ {
+ driver_number: 4,
+ position: 1,
+ name_acronym: 'NOR',
+ full_name: 'Lando Norris',
+ team_name: 'McLaren',
+ team_colour: 'ff8000',
+ session_key: 50,
+ meeting_key: 90,
+ lap_duration: 70,
+ },
+]
+
+const outline: TrackOutline = {
+ circuit_key: 10,
+ points: [
+ { x: 0.1, y: 0.2 },
+ { x: 0.5, y: 0.5 },
+ { x: 0.9, y: 0.8 },
+ ],
+ bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
+}
+
+function renderPage() {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ const rootRoute = createRootRoute({
+ component: () => (
+
+
+
+ ),
+ })
+ const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: RacePreviewPage })
+ const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
+ return render( )
+}
+
+describe('RacePreviewPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockFetchSeasons.mockResolvedValue([2099])
+ mockFetchChampionshipHub.mockResolvedValue(hub)
+ mockFetchTrackOutline.mockResolvedValue(outline)
+ mockFetchResults.mockResolvedValue(results)
+ mockFetchStartingGrid.mockResolvedValue(grid)
+ })
+
+ it('renders upcoming race preview with sections', async () => {
+ mockFetchMeetings.mockImplementation(async (year: number) => {
+ if (year === 2099) return [upcomingMeeting]
+ if (year === 2098) return [priorMeeting]
+ return []
+ })
+ mockFetchSessions.mockImplementation(async (meetingKey: number) => {
+ if (meetingKey === 100) return sessions
+ if (meetingKey === 90) return [priorRaceSession]
+ return []
+ })
+
+ renderPage()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('preview-page')).toBeInTheDocument()
+ })
+
+ await waitFor(() => {
+ expect(screen.getByTestId('preview-countdown')).toBeInTheDocument()
+ })
+
+ expect(screen.getByTestId('preview-header')).toHaveTextContent('Monaco')
+ expect(screen.getByTestId('preview-schedule')).toHaveTextContent('FP1')
+ expect(screen.getByTestId('preview-track-card')).toBeInTheDocument()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('VER')
+ })
+
+ expect(screen.getByTestId('preview-title-fight-card')).toHaveTextContent('NOR')
+ expect(screen.getByTestId('preview-sprint-note')).toBeInTheDocument()
+ })
+
+ it('renders season-over state when no upcoming meeting', async () => {
+ mockFetchMeetings.mockResolvedValue([
+ {
+ ...upcomingMeeting,
+ date_start: '2020-05-22T00:00:00+00:00',
+ date_end: '2020-05-24T23:59:59+00:00',
+ },
+ ])
+
+ renderPage()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('preview-season-over')).toBeInTheDocument()
+ })
+
+ expect(screen.getByText('Season complete')).toBeInTheDocument()
+ expect(screen.getByTestId('preview-title-fight-card')).toBeInTheDocument()
+ expect(screen.queryByTestId('preview-header')).not.toBeInTheDocument()
+ })
+
+ it('shows first-time circuit empty state', async () => {
+ const newCircuit: Meeting = {
+ ...upcomingMeeting,
+ meeting_key: 200,
+ circuit_key: 999,
+ meeting_name: 'New GP',
+ }
+
+ mockFetchMeetings.mockImplementation(async (year: number) => {
+ if (year === 2099) return [newCircuit]
+ if (year === 2098) return [priorMeeting]
+ return []
+ })
+ mockFetchSessions.mockResolvedValue(sessions)
+ mockFetchTrackOutline.mockResolvedValue(null)
+
+ renderPage()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('First time on the calendar')
+ })
+ })
+})
diff --git a/frontend/src/test/preview.test.ts b/frontend/src/test/preview.test.ts
new file mode 100644
index 0000000..9429511
--- /dev/null
+++ b/frontend/src/test/preview.test.ts
@@ -0,0 +1,226 @@
+import { describe, it, expect } from 'vitest'
+import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session } from '../types'
+import {
+ buildTitleFightContext,
+ countdownTargetSession,
+ extractPodium,
+ extractPole,
+ findPriorYearMeetingByCircuit,
+ findRaceSession,
+ formatPointsGap,
+ isSprintWeekend,
+ pickPreviewMeeting,
+ RACE_WIN_POINTS,
+ SPRINT_WEEKEND_MAX_POINTS,
+} from '../lib/preview'
+
+const meeting = (overrides: Partial = {}): Meeting => ({
+ meeting_key: 1,
+ meeting_name: 'Monaco',
+ meeting_official_name: 'Monaco GP',
+ location: 'Monaco',
+ country_name: 'Monaco',
+ country_code: 'MON',
+ country_flag: '',
+ circuit_key: 10,
+ circuit_short_name: 'Monaco',
+ date_start: '2026-05-22T00:00:00+00:00',
+ date_end: '2026-05-24T23:59:59+00:00',
+ year: 2026,
+ ...overrides,
+})
+
+const session = (overrides: Partial = {}): Session => ({
+ session_key: 100,
+ session_name: 'Race',
+ session_type: 'Race',
+ meeting_key: 1,
+ date_start: '2026-05-24T13:00:00+00:00',
+ date_end: '2026-05-24T15:00:00+00:00',
+ gmt_offset: '02:00:00',
+ ...overrides,
+})
+
+const hubDriver = (over: Partial): ChampHubDriver => ({
+ driver_number: 1,
+ name_acronym: 'VER',
+ full_name: 'Max Verstappen',
+ team_name: 'Red Bull',
+ team_colour: '3671c6',
+ points: 200,
+ position: 1,
+ wins: 5,
+ podiums: 8,
+ poles: 4,
+ form: [25],
+ cumulative: [200],
+ teammate_wins: 9,
+ teammate_losses: 1,
+ ...over,
+})
+
+describe('preview lib', () => {
+ it('picks the next upcoming meeting', () => {
+ const now = new Date('2026-01-01T00:00:00Z')
+ const meetings = [
+ meeting({ meeting_key: 1, date_start: '2026-05-22T00:00:00+00:00' }),
+ meeting({ meeting_key: 2, meeting_name: 'Spain', date_start: '2026-06-12T00:00:00+00:00' }),
+ ]
+ expect(pickPreviewMeeting(meetings, now)?.meeting_key).toBe(1)
+ })
+
+ it('returns null when no upcoming meeting exists', () => {
+ const now = new Date('2027-01-01T00:00:00Z')
+ const meetings = [meeting({ date_start: '2026-05-22T00:00:00+00:00' })]
+ expect(pickPreviewMeeting(meetings, now)).toBeNull()
+ })
+
+ it('skips cancelled meetings', () => {
+ const now = new Date('2026-01-01T00:00:00Z')
+ const meetings = [meeting({ is_cancelled: true })]
+ expect(pickPreviewMeeting(meetings, now)).toBeNull()
+ })
+
+ it('matches prior-year meeting by circuit_key', () => {
+ const prior = [
+ meeting({ meeting_key: 50, year: 2025, circuit_key: 10 }),
+ meeting({ meeting_key: 51, year: 2025, circuit_key: 20, meeting_name: 'Spain' }),
+ ]
+ expect(findPriorYearMeetingByCircuit(prior, 10)?.meeting_key).toBe(50)
+ expect(findPriorYearMeetingByCircuit(prior, 99)).toBeNull()
+ expect(findPriorYearMeetingByCircuit(prior, undefined)).toBeNull()
+ })
+
+ it('finds the grand prix race session', () => {
+ const sessions = [
+ session({ session_key: 1, session_name: 'FP1', session_type: 'Practice', date_start: '2026-05-22T10:00:00+00:00' }),
+ session({ session_key: 2, session_name: 'Sprint', session_type: 'Sprint', date_start: '2026-05-23T10:00:00+00:00' }),
+ session({ session_key: 3, session_name: 'Race', session_type: 'Race', date_start: '2026-05-24T13:00:00+00:00' }),
+ ]
+ expect(findRaceSession(sessions)?.session_key).toBe(3)
+ })
+
+ it('detects sprint weekends', () => {
+ const sprint = [
+ session({ session_type: 'Practice' }),
+ session({ session_name: 'Sprint', session_type: 'Sprint' }),
+ session({ session_type: 'Race' }),
+ ]
+ const normal = [session({ session_type: 'Practice' }), session({ session_type: 'Race' })]
+ expect(isSprintWeekend(sprint)).toBe(true)
+ expect(isSprintWeekend(normal)).toBe(false)
+ })
+
+ it('extracts podium and pole', () => {
+ const results: EnrichedResult[] = [
+ {
+ driver_number: 1,
+ position: 1,
+ name_acronym: 'VER',
+ full_name: 'Max Verstappen',
+ team_name: 'Red Bull',
+ team_colour: '3671c6',
+ dnf: false,
+ dns: false,
+ dsq: false,
+ duration: 100,
+ gap_to_leader: 0,
+ number_of_laps: 78,
+ points: 25,
+ session_key: 1,
+ meeting_key: 1,
+ },
+ {
+ driver_number: 4,
+ position: 2,
+ name_acronym: 'NOR',
+ full_name: 'Lando Norris',
+ team_name: 'McLaren',
+ team_colour: 'ff8000',
+ dnf: false,
+ dns: false,
+ dsq: false,
+ duration: 101,
+ gap_to_leader: 1,
+ number_of_laps: 78,
+ points: 18,
+ session_key: 1,
+ meeting_key: 1,
+ },
+ {
+ driver_number: 16,
+ position: 3,
+ name_acronym: 'LEC',
+ full_name: 'Charles Leclerc',
+ team_name: 'Ferrari',
+ team_colour: 'e8002d',
+ dnf: false,
+ dns: false,
+ dsq: false,
+ duration: 102,
+ gap_to_leader: 2,
+ number_of_laps: 78,
+ points: 15,
+ session_key: 1,
+ meeting_key: 1,
+ },
+ ]
+
+ const grid: EnrichedGrid[] = [
+ {
+ driver_number: 4,
+ position: 1,
+ name_acronym: 'NOR',
+ full_name: 'Lando Norris',
+ team_name: 'McLaren',
+ team_colour: 'ff8000',
+ session_key: 1,
+ meeting_key: 1,
+ lap_duration: 70,
+ },
+ ]
+
+ const podium = extractPodium(results)
+ expect(podium.map((p) => p.name_acronym)).toEqual(['VER', 'NOR', 'LEC'])
+ expect(extractPole(grid)?.name_acronym).toBe('NOR')
+ })
+
+ it('builds title fight gaps for top 3', () => {
+ const hub: ChampionshipHub = {
+ season: 2026,
+ round: 5,
+ total_rounds: 24,
+ rounds_left: 19,
+ last_race: 'Monaco GP',
+ round_labels: ['R1'],
+ drivers: [
+ hubDriver({ points: 200, position: 1 }),
+ hubDriver({ driver_number: 4, name_acronym: 'NOR', points: 160, position: 2 }),
+ hubDriver({ driver_number: 16, name_acronym: 'LEC', points: 120, position: 3 }),
+ ],
+ teams: [],
+ }
+
+ const rows = buildTitleFightContext(hub)
+ expect(rows).toHaveLength(3)
+ expect(rows[0].gapToLeader).toBe(0)
+ expect(rows[1].gapToLeader).toBe(40)
+ expect(rows[1].gapAfterRaceWin).toBe(15)
+ expect(rows[2].gapAfterSprintWeekendMax).toBe(200 - (120 + SPRINT_WEEKEND_MAX_POINTS))
+ expect(RACE_WIN_POINTS).toBe(25)
+ })
+
+ it('formats points gaps', () => {
+ expect(formatPointsGap(0)).toBe('LEADER')
+ expect(formatPointsGap(40)).toBe('+40')
+ })
+
+ it('picks countdown target as next future session', () => {
+ const sessions = [
+ session({ session_key: 1, session_name: 'FP1', session_type: 'Practice', date_start: '2026-05-22T10:00:00+00:00', date_end: '2026-05-22T11:00:00+00:00' }),
+ session({ session_key: 2, session_name: 'Race', session_type: 'Race', date_start: '2026-05-24T13:00:00+00:00', date_end: '2026-05-24T15:00:00+00:00' }),
+ ]
+ const now = new Date('2026-05-23T12:00:00Z')
+ expect(countdownTargetSession(sessions, now)?.session_key).toBe(2)
+ })
+})
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index c0b2fcf..22befb4 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -12,6 +12,7 @@ export interface Meeting {
country_name: string
country_code: string
country_flag: string
+ circuit_key?: number
circuit_short_name: string
date_start: string
date_end: string
@@ -23,6 +24,7 @@ export interface Session {
session_key: number
session_name: string
session_type: string
+ circuit_key?: number
meeting_key: number
date_start: string
date_end: string
From 7212eb9b44ec724e0a1b60fbdb2fe2b7a30d91ce Mon Sep 17 00:00:00 2001
From: AmanTahiliani
Date: Sat, 11 Jul 2026 18:04:44 -0400
Subject: [PATCH 2/7] feat: detect race replay chapters
Add deterministic server-side chapter detection for starts, flag periods, pit phases, decisive top-five swings, and finishes, then expose chapters on the race-hub payload.
Spike result: race-hub already loads race control, positions, and laps in one read model. The requested Detect signature does not include pit stops, so pit phases use IsPitOutLap clusters as the local deterministic pit-stop proxy.
---
frontend/src/test/CommandCenterPage.test.tsx | 1 +
frontend/src/test/RaceHubPage.test.tsx | 1 +
frontend/src/types.ts | 11 +
internal/chapters/chapters.go | 628 +++++++++++++++++++
internal/chapters/chapters_test.go | 218 +++++++
internal/query/racehub.go | 19 +
internal/web/racehub.go | 2 +
internal/web/racehub_test.go | 60 ++
8 files changed, 940 insertions(+)
create mode 100644 internal/chapters/chapters.go
create mode 100644 internal/chapters/chapters_test.go
diff --git a/frontend/src/test/CommandCenterPage.test.tsx b/frontend/src/test/CommandCenterPage.test.tsx
index 1519c59..5fbf1c8 100644
--- a/frontend/src/test/CommandCenterPage.test.tsx
+++ b/frontend/src/test/CommandCenterPage.test.tsx
@@ -156,6 +156,7 @@ describe('CommandCenterPage', () => {
race_control: [],
weather: [],
laps: [],
+ chapters: [],
})
})
diff --git a/frontend/src/test/RaceHubPage.test.tsx b/frontend/src/test/RaceHubPage.test.tsx
index 4e9eb1c..3e90681 100644
--- a/frontend/src/test/RaceHubPage.test.tsx
+++ b/frontend/src/test/RaceHubPage.test.tsx
@@ -155,6 +155,7 @@ const raceHub: RaceHub = {
is_pit_out_lap: false,
},
],
+ chapters: [],
}
const weekend: Weekend = {
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index c0b2fcf..5fe5c56 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -89,6 +89,17 @@ export interface RaceHub {
race_control: RaceControlMessage[]
weather: WeatherSample[]
laps: Lap[]
+ chapters: Chapter[]
+}
+
+export interface Chapter {
+ kind: 'start' | 'safety_car' | 'virtual_safety_car' | 'red_flag' | 'pit_phase' | 'decisive_swing' | 'finish' | string
+ title: string
+ start_lap: number
+ end_lap: number
+ start_time?: string
+ end_time?: string
+ driver_numbers: number[]
}
export interface Stint {
diff --git a/internal/chapters/chapters.go b/internal/chapters/chapters.go
new file mode 100644
index 0000000..d5a937c
--- /dev/null
+++ b/internal/chapters/chapters.go
@@ -0,0 +1,628 @@
+package chapters
+
+import (
+ "fmt"
+ "math"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/AmanTahiliani/box-box/internal/models"
+)
+
+const (
+ KindStart = "start"
+ KindSafetyCar = "safety_car"
+ KindVirtualSafetyCar = "virtual_safety_car"
+ KindRedFlag = "red_flag"
+ KindPitPhase = "pit_phase"
+ KindDecisiveSwing = "decisive_swing"
+ KindFinish = "finish"
+
+ pitPhaseWindowLaps = 3
+ pitPhaseShare = 0.30
+ minPitPhaseStops = 2
+ maxDecisiveSwings = 3
+ decisiveAfterLap = 5
+ structuralPriority = 110
+ flagPriority = 100
+ pitPhasePriority = 50
+ decisivePriority = 40
+)
+
+type RaceControl = models.RaceControl
+type PositionSample = models.Position
+type Lap = models.Lap
+
+// Chapter is a deterministic replay segment derived from timing and race-control data.
+type Chapter struct {
+ Kind string `json:"kind"`
+ Title string `json:"title"`
+ StartLap int `json:"start_lap"`
+ EndLap int `json:"end_lap"`
+ StartTime string `json:"start_time,omitempty"`
+ EndTime string `json:"end_time,omitempty"`
+ DriverNumbers []int `json:"driver_numbers"`
+}
+
+// Detect builds replay chapters from already-loaded race-hub datasets.
+func Detect(rc []RaceControl, positions []PositionSample, laps []Lap, totalLaps int) []Chapter {
+ totalLaps = normalizeTotalLaps(totalLaps, laps, rc)
+ if totalLaps <= 0 && len(rc) == 0 && len(positions) == 0 && len(laps) == 0 {
+ return []Chapter{}
+ }
+ if totalLaps <= 0 {
+ totalLaps = 1
+ }
+
+ lapIndex := buildLapIndex(laps)
+ chapters := []Chapter{
+ {
+ Kind: KindStart,
+ Title: "Start",
+ StartLap: 1,
+ EndLap: minInt(1, totalLaps),
+ StartTime: lapIndex.lapStart(1),
+ EndTime: lapIndex.lapEnd(1),
+ },
+ }
+ chapters = append(chapters, detectFlagPeriods(rc, lapIndex, totalLaps)...)
+ chapters = append(chapters, detectPitPhases(laps, lapIndex)...)
+ chapters = append(chapters, detectDecisiveSwings(positions, lapIndex, totalLaps)...)
+ chapters = append(chapters, detectFinish(rc, lapIndex, totalLaps))
+
+ return resolveConflicts(chapters)
+}
+
+func normalizeTotalLaps(totalLaps int, laps []Lap, rc []RaceControl) int {
+ for _, l := range laps {
+ if l.LapNumber > totalLaps {
+ totalLaps = l.LapNumber
+ }
+ }
+ for _, msg := range rc {
+ if msg.LapNumber != nil && *msg.LapNumber > totalLaps {
+ totalLaps = *msg.LapNumber
+ }
+ }
+ return totalLaps
+}
+
+type lapIndex struct {
+ byLap map[int]string
+ events []lapEvent
+}
+
+type lapEvent struct {
+ lap int
+ at time.Time
+}
+
+func buildLapIndex(laps []Lap) lapIndex {
+ idx := lapIndex{byLap: map[int]string{}}
+ for _, l := range laps {
+ if l.LapNumber <= 0 || l.DateStart == "" {
+ continue
+ }
+ if _, ok := idx.byLap[l.LapNumber]; !ok {
+ idx.byLap[l.LapNumber] = l.DateStart
+ }
+ at, ok := parseTime(l.DateStart)
+ if ok {
+ idx.events = append(idx.events, lapEvent{lap: l.LapNumber, at: at})
+ }
+ }
+ sort.Slice(idx.events, func(i, j int) bool {
+ if idx.events[i].at.Equal(idx.events[j].at) {
+ return idx.events[i].lap < idx.events[j].lap
+ }
+ return idx.events[i].at.Before(idx.events[j].at)
+ })
+ return idx
+}
+
+func (idx lapIndex) lapStart(lap int) string {
+ return idx.byLap[lap]
+}
+
+func (idx lapIndex) lapEnd(lap int) string {
+ if v := idx.byLap[lap+1]; v != "" {
+ return v
+ }
+ return idx.byLap[lap]
+}
+
+func (idx lapIndex) lapForTime(raw string) int {
+ at, ok := parseTime(raw)
+ if !ok || len(idx.events) == 0 {
+ return 0
+ }
+ lap := 0
+ for _, event := range idx.events {
+ if event.at.After(at) {
+ break
+ }
+ lap = event.lap
+ }
+ if lap == 0 {
+ return idx.events[0].lap
+ }
+ return lap
+}
+
+type flagState struct {
+ startLap int
+ startTime string
+}
+
+func detectFlagPeriods(rc []RaceControl, idx lapIndex, totalLaps int) []Chapter {
+ var chapters []Chapter
+ active := map[string]flagState{}
+ for _, msg := range rc {
+ kind, ok := flagKind(msg)
+ if !ok && greenFlagClear(msg) {
+ for activeKind, st := range active {
+ lap := messageLap(msg, idx)
+ if lap <= 0 {
+ lap = st.startLap
+ }
+ endLap := clampLap(lap, st.startLap, totalLaps)
+ chapters = append(chapters, Chapter{
+ Kind: activeKind,
+ Title: flagTitle(activeKind, st.startLap, endLap),
+ StartLap: st.startLap,
+ EndLap: endLap,
+ StartTime: st.startTime,
+ EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)),
+ })
+ delete(active, activeKind)
+ }
+ continue
+ }
+ if !ok {
+ continue
+ }
+ lap := messageLap(msg, idx)
+ if lap <= 0 {
+ lap = 1
+ }
+ if flagCleared(msg) {
+ st, ok := active[kind]
+ if !ok {
+ continue
+ }
+ endLap := clampLap(lap, st.startLap, totalLaps)
+ chapters = append(chapters, Chapter{
+ Kind: kind,
+ Title: flagTitle(kind, st.startLap, endLap),
+ StartLap: st.startLap,
+ EndLap: endLap,
+ StartTime: st.startTime,
+ EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)),
+ })
+ delete(active, kind)
+ continue
+ }
+ if flagStarted(msg) {
+ active[kind] = flagState{
+ startLap: clampLap(lap, 1, totalLaps),
+ startTime: firstNonEmpty(msg.Date, idx.lapStart(lap)),
+ }
+ }
+ }
+ for kind, st := range active {
+ endLap := totalLaps
+ chapters = append(chapters, Chapter{
+ Kind: kind,
+ Title: flagTitle(kind, st.startLap, endLap),
+ StartLap: st.startLap,
+ EndLap: endLap,
+ StartTime: st.startTime,
+ EndTime: idx.lapEnd(endLap),
+ })
+ }
+ return chapters
+}
+
+func flagKind(msg RaceControl) (string, bool) {
+ text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
+ if strings.Contains(text, "VSC") || strings.Contains(text, "VIRTUAL SAFETY CAR") {
+ return KindVirtualSafetyCar, true
+ }
+ if strings.Contains(text, "RED FLAG") || string(msg.Flag) == string(models.FlagRed) {
+ return KindRedFlag, true
+ }
+ if strings.Contains(text, "SAFETY CAR") || msg.Category == models.CategorySafetyCar {
+ return KindSafetyCar, true
+ }
+ return "", false
+}
+
+func flagStarted(msg RaceControl) bool {
+ text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
+ if strings.Contains(text, "CLEAR") || strings.Contains(text, "ENDING") || strings.Contains(text, "IN THIS LAP") || strings.Contains(text, "GREEN") {
+ return false
+ }
+ return strings.Contains(text, "DEPLOY") ||
+ strings.Contains(text, "RED FLAG") ||
+ strings.Contains(text, "VIRTUAL SAFETY CAR") ||
+ strings.Contains(text, "VSC") ||
+ strings.Contains(text, "SAFETY CAR") ||
+ string(msg.Flag) == string(models.FlagRed)
+}
+
+func flagCleared(msg RaceControl) bool {
+ text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
+ return strings.Contains(text, "CLEAR") ||
+ strings.Contains(text, "ENDING") ||
+ strings.Contains(text, "IN THIS LAP") ||
+ strings.Contains(text, "GREEN")
+}
+
+func greenFlagClear(msg RaceControl) bool {
+ text := upperText(string(msg.Flag), msg.Message)
+ return strings.Contains(text, "GREEN")
+}
+
+func flagTitle(kind string, startLap, endLap int) string {
+ name := "Flag period"
+ switch kind {
+ case KindSafetyCar:
+ name = "Safety Car"
+ case KindVirtualSafetyCar:
+ name = "Virtual Safety Car"
+ case KindRedFlag:
+ name = "Red Flag"
+ }
+ return fmt.Sprintf("%s (L%d-L%d)", name, startLap, endLap)
+}
+
+func detectPitPhases(laps []Lap, idx lapIndex) []Chapter {
+ type pitOut struct {
+ lap int
+ driver int
+ }
+ var stops []pitOut
+ for _, l := range laps {
+ if l.IsPitOutLap && l.LapNumber > 0 {
+ stops = append(stops, pitOut{lap: l.LapNumber, driver: l.DriverNumber})
+ }
+ }
+ if len(stops) < minPitPhaseStops {
+ return nil
+ }
+ sort.Slice(stops, func(i, j int) bool {
+ if stops[i].lap == stops[j].lap {
+ return stops[i].driver < stops[j].driver
+ }
+ return stops[i].lap < stops[j].lap
+ })
+ needed := int(math.Ceil(float64(len(stops)) * pitPhaseShare))
+ if needed < minPitPhaseStops {
+ needed = minPitPhaseStops
+ }
+
+ var windows []Chapter
+ for i := 0; i < len(stops); i++ {
+ start := stops[i].lap
+ end := start + pitPhaseWindowLaps - 1
+ drivers := map[int]bool{}
+ count := 0
+ for _, stop := range stops {
+ if stop.lap < start || stop.lap > end {
+ continue
+ }
+ count++
+ drivers[stop.driver] = true
+ }
+ if count < needed {
+ continue
+ }
+ ch := Chapter{
+ Kind: KindPitPhase,
+ Title: fmt.Sprintf("Pit phase (L%d-L%d)", start, end),
+ StartLap: start,
+ EndLap: end,
+ StartTime: idx.lapStart(start),
+ EndTime: idx.lapEnd(end),
+ DriverNumbers: sortedDriverNumbers(drivers),
+ }
+ if len(windows) > 0 && ch.StartLap <= windows[len(windows)-1].EndLap+1 {
+ last := &windows[len(windows)-1]
+ if ch.EndLap > last.EndLap {
+ last.EndLap = ch.EndLap
+ last.EndTime = idx.lapEnd(last.EndLap)
+ }
+ drivers := sliceToSet(last.DriverNumbers)
+ for _, driver := range ch.DriverNumbers {
+ drivers[driver] = true
+ }
+ last.DriverNumbers = sortedDriverNumbers(drivers)
+ last.Title = fmt.Sprintf("Pit phase (L%d-L%d)", last.StartLap, last.EndLap)
+ continue
+ }
+ windows = append(windows, ch)
+ }
+ return windows
+}
+
+type swingCandidate struct {
+ chapter Chapter
+ significance int
+}
+
+func detectDecisiveSwings(positions []PositionSample, idx lapIndex, totalLaps int) []Chapter {
+ if len(positions) == 0 || len(idx.events) == 0 || totalLaps <= decisiveAfterLap {
+ return nil
+ }
+ snapshots := buildPositionSnapshots(positions, idx, totalLaps)
+ if len(snapshots) == 0 {
+ return nil
+ }
+ final := snapshots[totalLaps]
+ if len(final) == 0 {
+ for lap := totalLaps - 1; lap >= 1; lap-- {
+ if len(snapshots[lap]) > 0 {
+ final = snapshots[lap]
+ break
+ }
+ }
+ }
+ var candidates []swingCandidate
+ seenDriver := map[int]bool{}
+ for lap := decisiveAfterLap + 1; lap <= totalLaps; lap++ {
+ prev := snapshots[lap-1]
+ curr := snapshots[lap]
+ if len(prev) == 0 || len(curr) == 0 {
+ continue
+ }
+ for driver, pos := range curr {
+ prevPos, ok := prev[driver]
+ if !ok || prevPos <= pos || pos > 5 || pos <= 0 || seenDriver[driver] {
+ continue
+ }
+ finalPos, ok := final[driver]
+ if !ok || finalPos > pos {
+ continue
+ }
+ overtaken := driverAtPosition(curr, prevPos, driver)
+ drivers := []int{driver}
+ if overtaken != 0 {
+ drivers = append(drivers, overtaken)
+ }
+ candidates = append(candidates, swingCandidate{
+ chapter: Chapter{
+ Kind: KindDecisiveSwing,
+ Title: fmt.Sprintf("Decisive swing: #%d to P%d (L%d)", driver, pos, lap),
+ StartLap: lap,
+ EndLap: lap,
+ StartTime: idx.lapStart(lap),
+ EndTime: idx.lapEnd(lap),
+ DriverNumbers: drivers,
+ },
+ significance: (prevPos-pos)*10 + (6 - pos),
+ })
+ seenDriver[driver] = true
+ }
+ }
+ sort.Slice(candidates, func(i, j int) bool {
+ if candidates[i].significance == candidates[j].significance {
+ return candidates[i].chapter.StartLap < candidates[j].chapter.StartLap
+ }
+ return candidates[i].significance > candidates[j].significance
+ })
+ if len(candidates) > maxDecisiveSwings {
+ candidates = candidates[:maxDecisiveSwings]
+ }
+ out := make([]Chapter, 0, len(candidates))
+ for _, c := range candidates {
+ out = append(out, c.chapter)
+ }
+ return out
+}
+
+func buildPositionSnapshots(positions []PositionSample, idx lapIndex, totalLaps int) map[int]map[int]int {
+ byLap := map[int][]PositionSample{}
+ for _, p := range positions {
+ if p.Position <= 0 {
+ continue
+ }
+ lap := idx.lapForTime(p.Date)
+ if lap <= 0 || lap > totalLaps {
+ continue
+ }
+ byLap[lap] = append(byLap[lap], p)
+ }
+ last := map[int]int{}
+ snapshots := map[int]map[int]int{}
+ for lap := 1; lap <= totalLaps; lap++ {
+ for _, p := range byLap[lap] {
+ last[p.DriverNumber] = p.Position
+ }
+ if len(last) == 0 {
+ continue
+ }
+ cp := make(map[int]int, len(last))
+ for driver, pos := range last {
+ cp[driver] = pos
+ }
+ snapshots[lap] = cp
+ }
+ return snapshots
+}
+
+func driverAtPosition(snapshot map[int]int, pos int, exclude int) int {
+ for driver, driverPos := range snapshot {
+ if driver != exclude && driverPos == pos {
+ return driver
+ }
+ }
+ return 0
+}
+
+func detectFinish(rc []RaceControl, idx lapIndex, totalLaps int) Chapter {
+ finishLap := totalLaps
+ finishTime := idx.lapEnd(totalLaps)
+ for _, msg := range rc {
+ text := upperText(string(msg.Flag), msg.Message)
+ if strings.Contains(text, "CHEQUER") || string(msg.Flag) == string(models.FlagChequered) {
+ if lap := messageLap(msg, idx); lap > 0 {
+ finishLap = lap
+ }
+ finishTime = firstNonEmpty(msg.Date, finishTime)
+ }
+ }
+ startLap := finishLap - 1
+ if startLap < 1 {
+ startLap = 1
+ }
+ return Chapter{
+ Kind: KindFinish,
+ Title: fmt.Sprintf("Finish (L%d-L%d)", startLap, finishLap),
+ StartLap: startLap,
+ EndLap: finishLap,
+ StartTime: idx.lapStart(startLap),
+ EndTime: finishTime,
+ }
+}
+
+func resolveConflicts(chapters []Chapter) []Chapter {
+ normalized := make([]Chapter, 0, len(chapters))
+ for _, ch := range chapters {
+ if ch.StartLap <= 0 {
+ ch.StartLap = 1
+ }
+ if ch.EndLap <= 0 {
+ ch.EndLap = ch.StartLap
+ }
+ if ch.EndLap < ch.StartLap {
+ ch.EndLap = ch.StartLap
+ }
+ if ch.DriverNumbers == nil {
+ ch.DriverNumbers = []int{}
+ }
+ normalized = append(normalized, ch)
+ }
+ sort.SliceStable(normalized, func(i, j int) bool {
+ if normalized[i].StartLap == normalized[j].StartLap {
+ return priority(normalized[i].Kind) > priority(normalized[j].Kind)
+ }
+ return normalized[i].StartLap < normalized[j].StartLap
+ })
+
+ out := make([]Chapter, 0, len(normalized))
+ for _, ch := range normalized {
+ if len(out) == 0 {
+ out = append(out, ch)
+ continue
+ }
+ last := &out[len(out)-1]
+ if ch.StartLap > last.EndLap {
+ out = append(out, ch)
+ continue
+ }
+ if isFlag(last.Kind) && priority(ch.Kind) < priority(last.Kind) {
+ continue
+ }
+ if priority(ch.Kind) > priority(last.Kind) {
+ if last.StartLap < ch.StartLap {
+ last.EndLap = ch.StartLap - 1
+ out = append(out, ch)
+ } else {
+ *last = ch
+ }
+ continue
+ }
+ if ch.EndLap > last.EndLap {
+ ch.StartLap = last.EndLap + 1
+ if ch.StartLap <= ch.EndLap {
+ out = append(out, ch)
+ }
+ }
+ }
+ return out
+}
+
+func isFlag(kind string) bool {
+ return kind == KindSafetyCar || kind == KindVirtualSafetyCar || kind == KindRedFlag
+}
+
+func priority(kind string) int {
+ switch kind {
+ case KindStart, KindFinish:
+ return structuralPriority
+ case KindSafetyCar, KindVirtualSafetyCar, KindRedFlag:
+ return flagPriority
+ case KindPitPhase:
+ return pitPhasePriority
+ case KindDecisiveSwing:
+ return decisivePriority
+ default:
+ return 0
+ }
+}
+
+func messageLap(msg RaceControl, idx lapIndex) int {
+ if msg.LapNumber != nil && *msg.LapNumber > 0 {
+ return *msg.LapNumber
+ }
+ return idx.lapForTime(msg.Date)
+}
+
+func clampLap(lap, minLap, maxLap int) int {
+ if lap < minLap {
+ return minLap
+ }
+ if maxLap > 0 && lap > maxLap {
+ return maxLap
+ }
+ return lap
+}
+
+func parseTime(raw string) (time.Time, bool) {
+ if raw == "" {
+ return time.Time{}, false
+ }
+ at, err := time.Parse(time.RFC3339, raw)
+ if err != nil {
+ return time.Time{}, false
+ }
+ return at, true
+}
+
+func upperText(parts ...string) string {
+ return strings.ToUpper(strings.Join(parts, " "))
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func sortedDriverNumbers(drivers map[int]bool) []int {
+ out := make([]int, 0, len(drivers))
+ for driver := range drivers {
+ out = append(out, driver)
+ }
+ sort.Ints(out)
+ return out
+}
+
+func sliceToSet(values []int) map[int]bool {
+ out := make(map[int]bool, len(values))
+ for _, value := range values {
+ out[value] = true
+ }
+ return out
+}
+
+func minInt(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
diff --git a/internal/chapters/chapters_test.go b/internal/chapters/chapters_test.go
new file mode 100644
index 0000000..fb58637
--- /dev/null
+++ b/internal/chapters/chapters_test.go
@@ -0,0 +1,218 @@
+package chapters
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/AmanTahiliani/box-box/internal/models"
+)
+
+func TestDetectStructuralChapters(t *testing.T) {
+ chapters := Detect(nil, nil, testLaps(10, nil), 10)
+
+ if len(chapters) < 2 {
+ t.Fatalf("chapters len = %d, want at least start and finish", len(chapters))
+ }
+ if got := chapters[0]; got.Kind != KindStart || got.StartLap != 1 || got.EndLap != 1 {
+ t.Fatalf("start chapter = %+v, want L1-L1", got)
+ }
+ got := chapters[len(chapters)-1]
+ if got.Kind != KindFinish || got.StartLap != 9 || got.EndLap != 10 {
+ t.Fatalf("finish chapter = %+v, want L9-L10", got)
+ }
+}
+
+func TestDetectReturnsEmptyWithoutData(t *testing.T) {
+ if chapters := Detect(nil, nil, nil, 0); len(chapters) != 0 {
+ t.Fatalf("chapters = %+v, want empty", chapters)
+ }
+}
+
+func TestDetectFlagPeriods(t *testing.T) {
+ tests := []struct {
+ name string
+ start RaceControl
+ end RaceControl
+ wantKind string
+ wantTitle string
+ }{
+ {
+ name: "safety car",
+ start: rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
+ end: rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
+ wantKind: KindSafetyCar,
+ wantTitle: "Safety Car (L12-L15)",
+ },
+ {
+ name: "virtual safety car",
+ start: rc(22, models.CategoryOther, "", "VSC DEPLOYED"),
+ end: rc(24, models.CategoryOther, "", "VSC ENDING"),
+ wantKind: KindVirtualSafetyCar,
+ wantTitle: "Virtual Safety Car (L22-L24)",
+ },
+ {
+ name: "red flag",
+ start: rc(31, models.CategoryFlag, models.FlagRed, "RED FLAG"),
+ end: rc(33, models.CategoryFlag, models.FlagGreen, "GREEN FLAG"),
+ wantKind: KindRedFlag,
+ wantTitle: "Red Flag (L31-L33)",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ chapters := Detect([]RaceControl{tt.start, tt.end}, nil, testLaps(40, nil), 40)
+ got := findKind(chapters, tt.wantKind)
+ if got == nil {
+ t.Fatalf("chapters = %+v, want %s", chapters, tt.wantKind)
+ }
+ if got.StartLap != rcLap(tt.start) || got.EndLap != rcLap(tt.end) || got.Title != tt.wantTitle {
+ t.Fatalf("flag chapter = %+v, want %s", *got, tt.wantTitle)
+ }
+ })
+ }
+}
+
+func TestDetectPitPhaseFromPitOutLapCluster(t *testing.T) {
+ pitOuts := map[int][]int{
+ 5: {1},
+ 10: {2},
+ 20: {3},
+ 21: {4},
+ 22: {5},
+ 30: {6},
+ 35: {7},
+ 40: {8},
+ 45: {9},
+ 50: {10},
+ }
+ chapters := Detect(nil, nil, testLaps(55, pitOuts), 55)
+
+ got := findKind(chapters, KindPitPhase)
+ if got == nil {
+ t.Fatalf("chapters = %+v, want pit phase", chapters)
+ }
+ if got.StartLap != 20 || got.EndLap != 22 {
+ t.Fatalf("pit phase = %+v, want L20-L22", *got)
+ }
+ if len(got.DriverNumbers) != 3 || got.DriverNumbers[0] != 3 || got.DriverNumbers[2] != 5 {
+ t.Fatalf("pit phase drivers = %v, want [3 4 5]", got.DriverNumbers)
+ }
+}
+
+func TestDetectDecisiveSwingPersistsToFinish(t *testing.T) {
+ positions := []PositionSample{
+ pos(1, 1, 1),
+ pos(1, 16, 4),
+ pos(1, 55, 3),
+ pos(6, 16, 3),
+ pos(6, 55, 4),
+ pos(8, 44, 5),
+ pos(8, 63, 6),
+ pos(10, 44, 6),
+ }
+
+ chapters := Detect(nil, positions, testLaps(12, nil), 12)
+ got := findKind(chapters, KindDecisiveSwing)
+ if got == nil {
+ t.Fatalf("chapters = %+v, want decisive swing", chapters)
+ }
+ if got.StartLap != 6 || got.EndLap != 6 {
+ t.Fatalf("swing lap = %+v, want L6", *got)
+ }
+ if len(got.DriverNumbers) != 2 || got.DriverNumbers[0] != 16 || got.DriverNumbers[1] != 55 {
+ t.Fatalf("swing drivers = %v, want [16 55]", got.DriverNumbers)
+ }
+}
+
+func TestDetectFlagPeriodsWinOverConflictingChapters(t *testing.T) {
+ pitOuts := map[int][]int{
+ 12: {1, 2},
+ 13: {3, 4},
+ 14: {5, 6},
+ }
+ rcs := []RaceControl{
+ rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
+ rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
+ }
+
+ chapters := Detect(rcs, nil, testLaps(20, pitOuts), 20)
+ if got := findKind(chapters, KindSafetyCar); got == nil || got.StartLap != 12 || got.EndLap != 15 {
+ t.Fatalf("chapters = %+v, want safety car L12-L15", chapters)
+ }
+ if got := findKind(chapters, KindPitPhase); got != nil {
+ t.Fatalf("pit phase = %+v, want omitted under safety car", *got)
+ }
+ for i := 1; i < len(chapters); i++ {
+ if chapters[i].StartLap <= chapters[i-1].EndLap {
+ t.Fatalf("chapters overlap at %d: %+v then %+v", i, chapters[i-1], chapters[i])
+ }
+ }
+}
+
+func rcLap(r RaceControl) int {
+ if r.LapNumber == nil {
+ return 0
+ }
+ return *r.LapNumber
+}
+
+func findKind(chapters []Chapter, kind string) *Chapter {
+ for i := range chapters {
+ if chapters[i].Kind == kind {
+ return &chapters[i]
+ }
+ }
+ return nil
+}
+
+func testLaps(total int, pitOuts map[int][]int) []Lap {
+ var laps []Lap
+ for lap := 1; lap <= total; lap++ {
+ drivers := []int{1}
+ if pitDrivers := pitOuts[lap]; len(pitDrivers) > 0 {
+ drivers = pitDrivers
+ }
+ for _, driver := range drivers {
+ laps = append(laps, Lap{
+ DriverNumber: driver,
+ LapNumber: lap,
+ DateStart: lapTime(lap),
+ IsPitOutLap: containsDriver(pitOuts[lap], driver),
+ })
+ }
+ }
+ return laps
+}
+
+func rc(lap int, category models.RaceControlCategory, flag models.Flag, message string) RaceControl {
+ return RaceControl{
+ Category: category,
+ Flag: flag,
+ Message: message,
+ LapNumber: &lap,
+ Date: lapTime(lap),
+ }
+}
+
+func pos(lap int, driver int, position int) PositionSample {
+ return PositionSample{
+ DriverNumber: driver,
+ Position: position,
+ Date: lapTime(lap),
+ }
+}
+
+func lapTime(lap int) string {
+ minute := lap - 1
+ return fmt.Sprintf("2025-05-25T13:%02d:00Z", minute)
+}
+
+func containsDriver(drivers []int, driver int) bool {
+ for _, candidate := range drivers {
+ if candidate == driver {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/query/racehub.go b/internal/query/racehub.go
index 1924599..5d15884 100644
--- a/internal/query/racehub.go
+++ b/internal/query/racehub.go
@@ -4,6 +4,7 @@ import (
"database/sql"
"errors"
+ "github.com/AmanTahiliani/box-box/internal/chapters"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/store"
)
@@ -52,6 +53,7 @@ type RaceHub struct {
RaceControl []models.RaceControl `json:"race_control"`
Weather []models.Weather `json:"weather"`
Laps []models.Lap `json:"laps"`
+ Chapters []chapters.Chapter `json:"chapters"`
}
// GetRaceHub loads ingested Race Hub datasets for a session from the local store.
@@ -80,6 +82,7 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
RaceControl: []models.RaceControl{},
Weather: []models.Weather{},
Laps: []models.Lap{},
+ Chapters: []chapters.Chapter{},
}
sess, err := s.store.GetSession(sessionKey)
@@ -248,6 +251,22 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
hub.Datasets["laps"] = availableLocal(len(hub.Laps))
}
+ hub.Chapters = chapters.Detect(hub.RaceControl, hub.Positions, hub.Laps, totalLaps(hub.Results, hub.Laps))
hub.Source = responseSource(hub.Datasets)
return hub, nil
}
+
+func totalLaps(results []EnrichedResult, laps []models.Lap) int {
+ total := 0
+ for _, result := range results {
+ if result.NumberOfLaps > total {
+ total = result.NumberOfLaps
+ }
+ }
+ for _, lap := range laps {
+ if lap.LapNumber > total {
+ total = lap.LapNumber
+ }
+ }
+ return total
+}
diff --git a/internal/web/racehub.go b/internal/web/racehub.go
index 3f43955..db8725e 100644
--- a/internal/web/racehub.go
+++ b/internal/web/racehub.go
@@ -4,6 +4,7 @@ import (
"net/http"
"strconv"
+ "github.com/AmanTahiliani/box-box/internal/chapters"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/query"
)
@@ -42,5 +43,6 @@ func emptyRaceHub(sessionKey int) query.RaceHub {
Drivers: []models.Driver{},
Results: []query.EnrichedResult{},
StartingGrid: []query.EnrichedGrid{},
+ Chapters: []chapters.Chapter{},
}
}
diff --git a/internal/web/racehub_test.go b/internal/web/racehub_test.go
index fd95b7f..50755d5 100644
--- a/internal/web/racehub_test.go
+++ b/internal/web/racehub_test.go
@@ -119,6 +119,66 @@ func TestHandleRaceHubWithLocalData(t *testing.T) {
}
}
+func TestHandleRaceHubIncludesChapters(t *testing.T) {
+ st := openTestStore(t)
+ seedRaceHubStore(t, st)
+ sessionKey := 9472
+ meetingKey := 1229
+
+ for lap := 1; lap <= 12; lap++ {
+ if err := st.UpsertLap(store.Lap{
+ SessionKey: sessionKey,
+ DriverNumber: 1,
+ MeetingKey: meetingKey,
+ LapNumber: lap,
+ DateStart: time.Date(2025, 5, 25, 13, lap-1, 0, 0, time.UTC).Format(time.RFC3339),
+ LapDuration: 75,
+ }); err != nil {
+ t.Fatalf("UpsertLap(%d) error = %v", lap, err)
+ }
+ }
+ for _, sample := range []store.PositionSample{
+ {SessionKey: sessionKey, DriverNumber: 1, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 1},
+ {SessionKey: sessionKey, DriverNumber: 16, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 4},
+ {SessionKey: sessionKey, DriverNumber: 55, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 3},
+ {SessionKey: sessionKey, DriverNumber: 16, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00Z", Position: 3},
+ {SessionKey: sessionKey, DriverNumber: 55, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00Z", Position: 4},
+ } {
+ if err := st.UpsertPositionSample(sample); err != nil {
+ t.Fatalf("UpsertPositionSample() error = %v", err)
+ }
+ }
+
+ srv := testServer(t, st)
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/race-hub?session_key=9472", nil)
+ rec := httptest.NewRecorder()
+ srv.handleRaceHub(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+
+ var hub query.RaceHub
+ if err := json.Unmarshal(rec.Body.Bytes(), &hub); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(hub.Chapters) == 0 {
+ t.Fatalf("chapters = %+v, want generated chapters", hub.Chapters)
+ }
+ if hub.Chapters[0].Kind != "start" {
+ t.Fatalf("first chapter = %+v, want start", hub.Chapters[0])
+ }
+ foundSwing := false
+ for _, chapter := range hub.Chapters {
+ if chapter.Kind == "decisive_swing" {
+ foundSwing = true
+ }
+ }
+ if !foundSwing {
+ t.Fatalf("chapters = %+v, want decisive_swing", hub.Chapters)
+ }
+}
+
func TestHandleMeetingsSourceLocal(t *testing.T) {
st := openTestStore(t)
seedRaceHubStore(t, st)
From 3f022a0cf931038915dcf7c63fa1eb1874721ee1 Mon Sep 17 00:00:00 2001
From: AmanTahiliani
Date: Sat, 11 Jul 2026 18:10:34 -0400
Subject: [PATCH 3/7] =?UTF-8?q?fix(agents):=20quote=20skill=20YAML=20descr?=
=?UTF-8?q?iptions=20=E2=80=94=20strict=20parsers=20(cursor=20CLI)=20fail?=
=?UTF-8?q?=20on=20embedded=20quotes/colons?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Fable 5
---
.agents/skills/groom/SKILL.md | 2 +-
.agents/skills/implement/SKILL.md | 2 +-
.agents/skills/lens-architect/SKILL.md | 2 +-
.agents/skills/review/SKILL.md | 2 +-
.agents/skills/write-spec/SKILL.md | 2 +-
5 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/.agents/skills/groom/SKILL.md b/.agents/skills/groom/SKILL.md
index bc52d18..578db53 100644
--- a/.agents/skills/groom/SKILL.md
+++ b/.agents/skills/groom/SKILL.md
@@ -1,6 +1,6 @@
---
name: groom
-description: Groom a box-box GitHub issue into a Ready spec via a seeded grill-me interrogation. Use when the user asks to groom an issue, for example "/groom " or "$groom " with optional "--lens architect". Single-issue path: asks targeted questions, writes a structured spec into the issue body, sets Effort/Priority, and leaves Stage at Research for approval.
+description: 'Groom a box-box GitHub issue into a Ready spec via a seeded grill-me interrogation. Use when the user asks to groom an issue, for example "/groom " or "$groom " with optional "--lens architect". Single-issue path: asks targeted questions, writes a structured spec into the issue body, sets Effort/Priority, and leaves Stage at Research for approval.'
argument-hint: [--lens architect]
---
diff --git a/.agents/skills/implement/SKILL.md b/.agents/skills/implement/SKILL.md
index fd173fe..91386bd 100644
--- a/.agents/skills/implement/SKILL.md
+++ b/.agents/skills/implement/SKILL.md
@@ -1,6 +1,6 @@
---
name: implement
-description: Dispatch a Ready box-box issue to a coding harness (claude/codex/opencode/pi/cursor) in an isolated git worktree, run the build gate, and open a PR. Use when supervising implementation via "/implement --harness [--dry-run]" or from any harness terminal with .agents/bin/dev.
+description: 'Dispatch a Ready box-box issue to a coding harness (claude/codex/opencode/pi/cursor) in an isolated git worktree, run the build gate, and open a PR. Use when supervising implementation via "/implement --harness [--dry-run]" or from any harness terminal with .agents/bin/dev.'
argument-hint: --harness [--dry-run]
---
diff --git a/.agents/skills/lens-architect/SKILL.md b/.agents/skills/lens-architect/SKILL.md
index ab88289..d01a464 100644
--- a/.agents/skills/lens-architect/SKILL.md
+++ b/.agents/skills/lens-architect/SKILL.md
@@ -1,6 +1,6 @@
---
name: lens-architect
-description: Grill/analyze a box-box issue or epic from a software-architecture perspective and post the findings as a comment. Use standalone as "/lens-architect " for an on-the-fly architecture review, or let /groom compose it via "--lens architect". Reads the base grill + architect persona and focuses on reuse, data flow, seams, testability, and risk.
+description: 'Grill/analyze a box-box issue or epic from a software-architecture perspective and post the findings as a comment. Use standalone as "/lens-architect " for an on-the-fly architecture review, or let /groom compose it via "--lens architect". Reads the base grill + architect persona and focuses on reuse, data flow, seams, testability, and risk.'
argument-hint:
---
diff --git a/.agents/skills/review/SKILL.md b/.agents/skills/review/SKILL.md
index d68a859..50b8d01 100644
--- a/.agents/skills/review/SKILL.md
+++ b/.agents/skills/review/SKILL.md
@@ -1,6 +1,6 @@
---
name: review
-description: Locally review a box-box PR against its linked GitHub issue spec, run tests, capture visual screenshots when applicable, create a .review packet, and post a GitHub PR comment. Use when a ticket implementation is ready for independent local review before the human merge gate.
+description: 'Locally review a box-box PR against its linked GitHub issue spec, run tests, capture visual screenshots when applicable, create a .review packet, and post a GitHub PR comment. Use when a ticket implementation is ready for independent local review before the human merge gate.'
argument-hint: [--harness ] [--publish-screenshots]
---
diff --git a/.agents/skills/write-spec/SKILL.md b/.agents/skills/write-spec/SKILL.md
index c3dce0d..0393f3a 100644
--- a/.agents/skills/write-spec/SKILL.md
+++ b/.agents/skills/write-spec/SKILL.md
@@ -1,6 +1,6 @@
---
name: write-spec
-description: Render a groomed design into the box-box Ready-spec template and write it into a GitHub issue body, then set Effort and Priority. Called by /groom after a grill session, or run standalone as "/write-spec " to (re)write an issue's spec from agreed decisions. Does not change Stage.
+description: 'Render a groomed design into the box-box Ready-spec template and write it into a GitHub issue body, then set Effort and Priority. Called by /groom after a grill session, or run standalone as "/write-spec " to (re)write an issue''s spec from agreed decisions. Does not change Stage.'
argument-hint:
---
From 01ec1f24e72364affa9c16945d80158fa8428bed Mon Sep 17 00:00:00 2001
From: AmanTahiliani
Date: Sat, 11 Jul 2026 18:12:00 -0400
Subject: [PATCH 4/7] feat(#22): Driver profile page
Implemented by claude via .agents/dev dispatch.
---
frontend/src/api.ts | 14 +
frontend/src/lib/driverProfile.ts | 69 ++++
frontend/src/lib/gpIdentity.ts | 4 +-
frontend/src/pages/ChampionshipPage.tsx | 15 +-
frontend/src/pages/DriverProfilePage.tsx | 369 +++++++++++++++++++
frontend/src/router.tsx | 20 +
frontend/src/styles/driver-profile.css | 289 +++++++++++++++
frontend/src/test/DriverProfilePage.test.tsx | 160 ++++++++
frontend/src/test/driverProfile.test.ts | 104 ++++++
frontend/src/types.ts | 33 ++
internal/web/api.go | 51 ++-
internal/web/driversummary.go | 177 +++++++++
internal/web/driversummary_test.go | 136 +++++++
internal/web/server.go | 3 +
14 files changed, 1421 insertions(+), 23 deletions(-)
create mode 100644 frontend/src/lib/driverProfile.ts
create mode 100644 frontend/src/pages/DriverProfilePage.tsx
create mode 100644 frontend/src/styles/driver-profile.css
create mode 100644 frontend/src/test/DriverProfilePage.test.tsx
create mode 100644 frontend/src/test/driverProfile.test.ts
create mode 100644 internal/web/driversummary.go
create mode 100644 internal/web/driversummary_test.go
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 1b7d972..9a84579 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -2,6 +2,7 @@ import type {
ArticleContent,
CarDataSample,
ChampionshipHub,
+ DriverSummary,
LapsComparisonResponse,
LiveStateResponse,
LiveSessionMeta,
@@ -76,6 +77,19 @@ export async function fetchChampionshipHub(year?: number): Promise {
+ const params = new URLSearchParams({ driver_number: String(driverNumber) })
+ if (year) params.set('year', String(year))
+ const res = await fetch(`/api/v1/driver/summary?${params.toString()}`)
+ if (!res.ok) {
+ throw new Error(`API ${res.status}: ${res.statusText}`)
+ }
+ return res.json()
+}
+
export async function fetchLiveState(): Promise {
const res = await fetch('/api/v1/live/state')
if (!res.ok) {
diff --git a/frontend/src/lib/driverProfile.ts b/frontend/src/lib/driverProfile.ts
new file mode 100644
index 0000000..4b08690
--- /dev/null
+++ b/frontend/src/lib/driverProfile.ts
@@ -0,0 +1,69 @@
+import type { DriverSummaryRound } from '../types'
+
+/** One round mapped for the grid-vs-finish chart and results table. */
+export interface RoundDelta {
+ round: number // 1-based round index within the completed season
+ label: string // short GP label, e.g. "Bahrain"
+ grid: number | null
+ finish: number | null
+ /** grid − finish: positive = places gained on Sunday, null when unknowable. */
+ delta: number | null
+ points: number
+ status: 'classified' | 'dnf' | 'dns' | 'dsq' | 'absent'
+}
+
+/** Strip the "Grand Prix" boilerplate so round labels fit chart axes. */
+export function shortGpLabel(meetingName: string): string {
+ const short = meetingName
+ .replace(/\s*grand prix\s*/i, ' ')
+ .replace(/\s+gp\s*$/i, ' ')
+ .trim()
+ return short || meetingName
+}
+
+function roundStatus(r: DriverSummaryRound): RoundDelta['status'] {
+ if (r.dns) return 'dns'
+ if (r.dsq) return 'dsq'
+ if (r.dnf) return 'dnf'
+ if (r.race_position <= 0 && r.grid_position <= 0) return 'absent'
+ return 'classified'
+}
+
+/**
+ * Map summary rounds to grid-vs-finish deltas. Positions of 0 mean "no data"
+ * (driver absent, pit-lane start, …) and become null; a delta is only computed
+ * when both ends are known and the driver actually started the race.
+ */
+export function gridFinishDeltas(rounds: DriverSummaryRound[]): RoundDelta[] {
+ return rounds.map((r, i) => {
+ const status = roundStatus(r)
+ const grid = r.grid_position > 0 ? r.grid_position : null
+ const finish = r.race_position > 0 ? r.race_position : null
+ const delta =
+ grid != null && finish != null && status !== 'dns' && status !== 'absent'
+ ? grid - finish
+ : null
+ return {
+ round: i + 1,
+ label: shortGpLabel(r.meeting_name),
+ grid,
+ finish,
+ delta,
+ points: r.points,
+ status,
+ }
+ })
+}
+
+/** 'P4' for known positions, '—' when unknown. */
+export function formatPosition(pos: number | null): string {
+ return pos != null && pos > 0 ? `P${pos}` : '—'
+}
+
+/** '+3' (gained), '−2' (lost), '=' (held), '—' (unknown). */
+export function formatDelta(delta: number | null): string {
+ if (delta == null) return '—'
+ if (delta > 0) return `+${delta}`
+ if (delta < 0) return `−${Math.abs(delta)}`
+ return '='
+}
diff --git a/frontend/src/lib/gpIdentity.ts b/frontend/src/lib/gpIdentity.ts
index 24cca81..f1dcbeb 100644
--- a/frontend/src/lib/gpIdentity.ts
+++ b/frontend/src/lib/gpIdentity.ts
@@ -47,7 +47,9 @@ export function countryDecal(meeting: Meeting | undefined | null): string {
return name.slice(0, 3).toUpperCase() || '—'
}
-export function countryFlag(meeting: Meeting | undefined | null): string {
+export function countryFlag(
+ meeting: Pick | undefined | null,
+): string {
if (meeting?.country_flag && !/^https?:\/\//i.test(meeting.country_flag)) return meeting.country_flag
const code = meeting?.country_code?.toUpperCase()
const iso2 = code ? CODE3_TO_2[code] : ''
diff --git a/frontend/src/pages/ChampionshipPage.tsx b/frontend/src/pages/ChampionshipPage.tsx
index c004eb1..b922202 100644
--- a/frontend/src/pages/ChampionshipPage.tsx
+++ b/frontend/src/pages/ChampionshipPage.tsx
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
+import { Link } from '@tanstack/react-router'
import { fetchChampionshipHub, fetchSeasons } from '../api'
import { teamColor } from '../utils'
import type { ChampHubDriver, ChampionshipHub } from '../types'
@@ -242,6 +243,7 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
leaderPoints={leader.points}
titleMath={titleMath}
roundsLeft={hub.rounds_left}
+ season={hub.season}
/>
)}
{view === 'constructors' && }
@@ -271,11 +273,13 @@ function DriversView({
leaderPoints,
titleMath,
roundsLeft,
+ season,
}: {
enriched: EnrichedDriver[]
leaderPoints: number
titleMath: string
roundsLeft: number
+ season: number
}) {
const podium = enriched.slice(0, 3)
return (
@@ -360,12 +364,19 @@ function DriversView({
P{e.pos}
-
+
{e.d.name_acronym}
{e.d.full_name}
#{e.d.driver_number}
-
+
{e.d.team_name}
{fmtPts(e.d.points)}
diff --git a/frontend/src/pages/DriverProfilePage.tsx b/frontend/src/pages/DriverProfilePage.tsx
new file mode 100644
index 0000000..b6294e7
--- /dev/null
+++ b/frontend/src/pages/DriverProfilePage.tsx
@@ -0,0 +1,369 @@
+import { useQuery } from '@tanstack/react-query'
+import { Link } from '@tanstack/react-router'
+import { fetchDriverSummary, fetchSeasons } from '../api'
+import { teamColor } from '../utils'
+import { countryFlag } from '../lib/gpIdentity'
+import {
+ formatDelta,
+ formatPosition,
+ gridFinishDeltas,
+ type RoundDelta,
+} from '../lib/driverProfile'
+import type { DriverSummary } from '../types'
+import '../styles/driver-profile.css'
+
+interface Props {
+ driverNumber: number
+ year?: number
+}
+
+function fmtPts(n: number): string {
+ return Number.isInteger(n) ? String(n) : n.toFixed(1)
+}
+
+export function DriverProfilePage({ driverNumber, year }: Props) {
+ // Without an explicit ?year=, default to the latest ingested season; if the
+ // seasons list is empty/unavailable the backend falls back to the current year.
+ const seasonsQuery = useQuery({
+ queryKey: ['seasons'],
+ queryFn: fetchSeasons,
+ enabled: year == null,
+ })
+ const resolvedYear = year ?? seasonsQuery.data?.[0]
+ const seasonsSettled = year != null || !seasonsQuery.isLoading
+
+ const summaryQuery = useQuery({
+ queryKey: ['driver-summary', driverNumber, resolvedYear ?? 'latest'],
+ queryFn: () => fetchDriverSummary(driverNumber, resolvedYear),
+ enabled: seasonsSettled && driverNumber > 0,
+ staleTime: 5 * 60_000,
+ })
+
+ if (driverNumber <= 0) {
+ return Invalid driver number
+ }
+ if (!seasonsSettled || summaryQuery.isLoading) {
+ return loading driver profile…
+ }
+ if (summaryQuery.isError) {
+ return (
+
+ {summaryQuery.error instanceof Error
+ ? summaryQuery.error.message
+ : 'Failed to load driver profile'}
+
+ )
+ }
+ const summary = summaryQuery.data
+ if (!summary) {
+ return No driver data
+ }
+ return
+}
+
+function DriverProfileBody({ summary }: { summary: DriverSummary }) {
+ const color = teamColor(summary.team_colour)
+ const deltas = gridFinishDeltas(summary.rounds)
+
+ return (
+
+
+
+
+ Season form
+ {summary.cumulative.length > 0 ? (
+
+
+
+
+ ) : (
+ No completed rounds yet.
+ )}
+
+
+
+ Quali vs race
+ {deltas.some((d) => d.delta != null) ? (
+
+ ) : (
+ No grid-vs-finish data yet.
+ )}
+
+
+
+ Track by track
+ {summary.rounds.length > 0 ? (
+
+ ) : (
+ No completed rounds yet.
+ )}
+
+
+ )
+}
+
+const LINE_W = 640
+const LINE_H = 180
+const LINE_PL = 44
+const LINE_PR = 16
+const LINE_PT = 12
+const LINE_PB = 24
+
+function CumulativeLine({
+ cumulative,
+ labels,
+ color,
+}: {
+ cumulative: number[]
+ labels: string[]
+ color: string
+}) {
+ const n = cumulative.length
+ const maxY = Math.max(25, ...cumulative)
+ const plotW = LINE_W - LINE_PL - LINE_PR
+ const plotH = LINE_H - LINE_PT - LINE_PB
+ const x = (i: number) => (n <= 1 ? LINE_PL + plotW / 2 : LINE_PL + (i * plotW) / (n - 1))
+ const y = (v: number) => LINE_PT + plotH - (v / maxY) * plotH
+ const points = cumulative.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ')
+
+ const tickStep = Math.max(1, Math.ceil(n / 8))
+ const ticks: { x: number; label: string }[] = []
+ for (let i = 0; i < n; i += tickStep) {
+ ticks.push({ x: x(i), label: labels[i] ?? `R${i + 1}` })
+ }
+
+ return (
+
+ Cumulative points
+
+ {[0, 0.5, 1].map((f) => {
+ const v = Math.round(maxY * f)
+ return (
+
+
+
+ {v}
+
+
+ )
+ })}
+ {ticks.map((t) => (
+
+ {t.label}
+
+ ))}
+
+
+
+
+ )
+}
+
+function FormStrip({ form, color }: { form: number[]; color: string }) {
+ const max = Math.max(25, ...form)
+ return (
+
+
Last {form.length} races
+
+ {form.map((pts, i) => (
+
+ ))}
+
+
+ )
+}
+
+const DELTA_H = 190
+const DELTA_PT = 14
+const DELTA_PB = 30
+
+function GridVsRaceChart({ deltas, color }: { deltas: RoundDelta[]; color: string }) {
+ const maxAbs = Math.max(1, ...deltas.map((d) => Math.abs(d.delta ?? 0)))
+ const colW = 34
+ const w = Math.max(240, deltas.length * colW)
+ const plotH = DELTA_H - DELTA_PT - DELTA_PB
+ const zeroY = DELTA_PT + plotH / 2
+ const scale = plotH / 2 / maxAbs
+
+ return (
+
+
+ Positions gained (▲) / lost (▼) from grid to flag, per round
+
+
+
+
+ {deltas.map((d, i) => {
+ const cx = i * colW + colW / 2
+ if (d.delta == null) {
+ return (
+
+
+ ·
+
+
+ R{d.round}
+
+
+ )
+ }
+ const h = Math.abs(d.delta) * scale
+ const yTop = d.delta >= 0 ? zeroY - h : zeroY
+ return (
+
+ {`${d.label}: ${formatPosition(d.grid)} → ${formatPosition(d.finish)} (${formatDelta(d.delta)})`}
+ = 0 ? 0.95 : 0.45}
+ />
+ = 0 ? yTop - 4 : yTop + h + 11}
+ textAnchor="middle"
+ className="dp-delta-val mono"
+ >
+ {formatDelta(d.delta)}
+
+
+ R{d.round}
+
+
+ )
+ })}
+
+
+
+ )
+}
+
+function statusNote(d: RoundDelta): string {
+ if (d.status === 'dnf') return 'DNF'
+ if (d.status === 'dns') return 'DNS'
+ if (d.status === 'dsq') return 'DSQ'
+ if (d.status === 'absent') return '—'
+ return ''
+}
+
+function RoundsTable({ summary, deltas }: { summary: DriverSummary; deltas: RoundDelta[] }) {
+ return (
+
+
+
+
+ Rnd
+ Grand Prix
+ Grid
+ Race
+ Δ
+ Pts
+
+
+
+
+ {summary.rounds.map((r, i) => {
+ const d = deltas[i]
+ return (
+
+ R{i + 1}
+
+
+ {countryFlag({ country_code: r.country_code, country_flag: '' })}
+
+ {r.meeting_name}
+
+ {formatPosition(d.grid)}
+ {formatPosition(d.finish)}
+ 0
+ ? 'var(--green)'
+ : d.delta < 0
+ ? 'var(--red)'
+ : 'var(--text-2)',
+ }}
+ >
+ {formatDelta(d.delta)}
+
+ {fmtPts(r.points)}
+ {statusNote(d)}
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx
index a0227d3..304579d 100644
--- a/frontend/src/router.tsx
+++ b/frontend/src/router.tsx
@@ -6,11 +6,16 @@ import { DataLibraryPage } from './pages/DataLibraryPage'
import { LiveTimingPage } from './pages/LiveTimingPage'
import { BriefingPage } from './pages/BriefingPage'
import { ChampionshipPage } from './pages/ChampionshipPage'
+import { DriverProfilePage } from './pages/DriverProfilePage'
type RaceHubSearch = {
session_key?: number
}
+type DriverProfileSearch = {
+ year?: number
+}
+
const rootRoute = createRootRoute({
component: () => (
<>
@@ -64,6 +69,20 @@ export const championshipRoute = createRoute({
component: ChampionshipPage,
})
+export const driverProfileRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/drivers/$driverNumber',
+ validateSearch: (search: Record): DriverProfileSearch => {
+ const year = Number(search.year)
+ return Number.isFinite(year) && year > 0 ? { year } : {}
+ },
+ component: function DriverProfileRoute() {
+ const { driverNumber } = driverProfileRoute.useParams()
+ const { year } = driverProfileRoute.useSearch()
+ return
+ },
+})
+
export const briefingRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/briefing',
@@ -77,6 +96,7 @@ const routeTree = rootRoute.addChildren([
dataLibraryRoute,
liveTimingRoute,
championshipRoute,
+ driverProfileRoute,
briefingRoute,
])
diff --git a/frontend/src/styles/driver-profile.css b/frontend/src/styles/driver-profile.css
new file mode 100644
index 0000000..956fffd
--- /dev/null
+++ b/frontend/src/styles/driver-profile.css
@@ -0,0 +1,289 @@
+/* Driver profile page (/drivers/$driverNumber) */
+
+.dp-page {
+ max-width: 1100px;
+ margin: 0 auto;
+ padding: var(--s6) var(--s5);
+ display: flex;
+ flex-direction: column;
+ gap: var(--s6);
+}
+
+/* --- header --- */
+
+.dp-header {
+ position: relative;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: var(--s5);
+ padding: var(--s5);
+ padding-left: var(--s6);
+ border: 1px solid var(--border);
+ border-left: 4px solid var(--red);
+ border-radius: var(--r2);
+ background: var(--surface);
+}
+
+.dp-title-row {
+ display: flex;
+ align-items: baseline;
+ gap: var(--s3);
+}
+
+.dp-code {
+ font-size: 13px;
+ letter-spacing: 0.12em;
+ color: var(--text-2);
+ text-transform: uppercase;
+}
+
+.dp-num {
+ font-size: 13px;
+ font-weight: 700;
+}
+
+.dp-name {
+ margin: var(--s2) 0;
+ font-size: 30px;
+ line-height: 1.1;
+ letter-spacing: -0.01em;
+}
+
+.dp-team {
+ display: flex;
+ align-items: center;
+ gap: var(--s3);
+ font-size: 13px;
+ color: var(--text-2);
+}
+
+.dp-team-swatch {
+ width: 10px;
+ height: 10px;
+ border-radius: 2px;
+}
+
+.dp-season {
+ color: var(--text-3);
+}
+
+.dp-stat-rail {
+ display: flex;
+ gap: var(--s5);
+}
+
+.dp-stat {
+ min-width: 72px;
+}
+
+.dp-stat-value {
+ font-size: 22px;
+ font-weight: 700;
+}
+
+.dp-stat-label {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-3);
+}
+
+.dp-back {
+ position: absolute;
+ top: var(--s4);
+ right: var(--s5);
+ font-size: 12px;
+ color: var(--text-3);
+ text-decoration: none;
+}
+
+.dp-back:hover {
+ color: var(--text);
+}
+
+/* --- sections --- */
+
+.dp-section {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s4);
+}
+
+.dp-section-title {
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--text-2);
+}
+
+.dp-empty {
+ padding: var(--s5);
+ border: 1px dashed var(--border);
+ border-radius: var(--r2);
+ color: var(--text-3);
+ font-size: 13px;
+}
+
+/* --- charts --- */
+
+.dp-form-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 2fr) minmax(200px, 1fr);
+ gap: var(--s5);
+ align-items: stretch;
+}
+
+@media (max-width: 760px) {
+ .dp-form-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.dp-chart {
+ margin: 0;
+ padding: var(--s4);
+ border: 1px solid var(--border);
+ border-radius: var(--r2);
+ background: var(--surface);
+ min-width: 0;
+}
+
+.dp-chart-caption {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-3);
+ margin-bottom: var(--s3);
+}
+
+.dp-chart-svg {
+ display: block;
+ width: 100%;
+ height: auto;
+}
+
+.dp-chart-scroll {
+ overflow-x: auto;
+}
+
+.dp-gridline {
+ stroke: var(--border);
+ stroke-width: 1;
+}
+
+.dp-zeroline {
+ stroke: var(--text-3);
+ stroke-width: 1;
+ stroke-dasharray: 3 3;
+}
+
+.dp-axis {
+ fill: var(--text-3);
+ font-family: var(--f-mono);
+ font-size: 10px;
+}
+
+.dp-delta-svg {
+ display: block;
+}
+
+.dp-delta-val {
+ fill: var(--text-2);
+ font-size: 10px;
+}
+
+/* --- form strip --- */
+
+.dp-form-strip {
+ padding: var(--s4);
+ border: 1px solid var(--border);
+ border-radius: var(--r2);
+ background: var(--surface);
+ display: flex;
+ flex-direction: column;
+}
+
+.dp-form-bars {
+ flex: 1;
+ display: flex;
+ align-items: flex-end;
+ gap: var(--s3);
+ min-height: 110px;
+}
+
+.dp-form-bar {
+ flex: 1;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ align-items: center;
+ gap: var(--s2);
+}
+
+.dp-form-bar-fill {
+ width: 100%;
+ max-width: 34px;
+ border-radius: 2px 2px 0 0;
+ opacity: 0.9;
+}
+
+.dp-form-bar-val {
+ font-size: 11px;
+ color: var(--text-2);
+}
+
+/* --- rounds table --- */
+
+.dp-scroll {
+ overflow-x: auto;
+ border: 1px solid var(--border);
+ border-radius: var(--r2);
+ background: var(--surface);
+}
+
+.dp-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+}
+
+.dp-table th {
+ padding: var(--s3) var(--s4);
+ font-family: var(--f-mono);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-3);
+ border-bottom: 1px solid var(--border);
+}
+
+.dp-table td {
+ padding: var(--s3) var(--s4);
+ border-bottom: 1px solid var(--border);
+ white-space: nowrap;
+}
+
+.dp-table tbody tr:last-child td {
+ border-bottom: none;
+}
+
+.dp-table th.l,
+.dp-table td.l {
+ text-align: left;
+}
+
+.dp-table th.r,
+.dp-table td.r {
+ text-align: right;
+}
+
+.dp-td-dim {
+ color: var(--text-3);
+}
+
+.dp-flag {
+ margin-right: var(--s3);
+}
diff --git a/frontend/src/test/DriverProfilePage.test.tsx b/frontend/src/test/DriverProfilePage.test.tsx
new file mode 100644
index 0000000..29b4841
--- /dev/null
+++ b/frontend/src/test/DriverProfilePage.test.tsx
@@ -0,0 +1,160 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
+import { DriverProfilePage } from '../pages/DriverProfilePage'
+import type { DriverSummary } from '../types'
+
+vi.mock('../api', () => ({
+ fetchSeasons: vi.fn(),
+ fetchDriverSummary: vi.fn(),
+}))
+
+import { fetchSeasons, fetchDriverSummary } from '../api'
+
+const mockFetchSeasons = vi.mocked(fetchSeasons)
+const mockFetchSummary = vi.mocked(fetchDriverSummary)
+
+const summary: DriverSummary = {
+ season: 2025,
+ driver_number: 1,
+ name_acronym: 'VER',
+ full_name: 'Max Verstappen',
+ team_name: 'Red Bull',
+ team_colour: '3671c6',
+ headshot_url: '',
+ points: 50,
+ position: 1,
+ wins: 2,
+ podiums: 2,
+ poles: 1,
+ form: [25, 25],
+ cumulative: [25, 50],
+ round_labels: ['R1', 'R2'],
+ rounds: [
+ {
+ meeting_key: 1201,
+ meeting_name: 'Bahrain Grand Prix',
+ country_code: 'BHR',
+ country_name: 'Bahrain',
+ race_position: 1,
+ grid_position: 1,
+ quali_position: 1,
+ points: 25,
+ dnf: false,
+ dns: false,
+ dsq: false,
+ },
+ {
+ meeting_key: 1202,
+ meeting_name: 'Saudi Arabian Grand Prix',
+ country_code: 'SAU',
+ country_name: 'Saudi Arabia',
+ race_position: 1,
+ grid_position: 4,
+ quali_position: 4,
+ points: 25,
+ dnf: false,
+ dns: false,
+ dsq: false,
+ },
+ ],
+}
+
+function renderPage(props: { driverNumber: number; year?: number }) {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ const rootRoute = createRootRoute({
+ component: () => (
+
+
+
+ ),
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => null,
+ })
+ const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
+ return render( )
+}
+
+describe('DriverProfilePage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockFetchSeasons.mockResolvedValue([2025])
+ mockFetchSummary.mockResolvedValue(summary)
+ })
+
+ it('renders header, form, quali-vs-race, and rounds sections', async () => {
+ renderPage({ driverNumber: 1, year: 2025 })
+
+ await waitFor(() => {
+ expect(screen.getByTestId('driver-profile')).toBeInTheDocument()
+ })
+
+ // Header: identity + season stats.
+ expect(screen.getByTestId('dp-header')).toBeInTheDocument()
+ expect(screen.getByText('Max Verstappen')).toBeInTheDocument()
+ expect(screen.getByText('#1')).toBeInTheDocument()
+ expect(screen.getByText('Red Bull', { exact: false })).toBeInTheDocument()
+ // 'P1' / '50' also appear in the rounds table and chart axis.
+ expect(screen.getAllByText('P1').length).toBeGreaterThan(0)
+ expect(screen.getAllByText('50').length).toBeGreaterThan(0)
+
+ // Season form strip.
+ expect(screen.getByTestId('dp-form')).toBeInTheDocument()
+ expect(screen.getByText('Cumulative points')).toBeInTheDocument()
+
+ // Quali-vs-race chart shows the delta from the Saudi round (P4 → P1 = +3).
+ expect(screen.getByTestId('dp-quali-race')).toBeInTheDocument()
+ expect(screen.getAllByText('+3').length).toBeGreaterThan(0)
+
+ // Track-by-track table rows with flags.
+ const rounds = screen.getByTestId('dp-rounds')
+ expect(rounds).toBeInTheDocument()
+ expect(screen.getByText('Bahrain Grand Prix')).toBeInTheDocument()
+ expect(screen.getByText('Saudi Arabian Grand Prix')).toBeInTheDocument()
+ expect(rounds.textContent).toContain('🇧🇭')
+ expect(rounds.textContent).toContain('🇸🇦')
+
+ // With an explicit year, the seasons list is not needed.
+ expect(mockFetchSeasons).not.toHaveBeenCalled()
+ expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025)
+ })
+
+ it('defaults to the latest season when no year is given', async () => {
+ renderPage({ driverNumber: 1 })
+
+ await waitFor(() => {
+ expect(screen.getByTestId('driver-profile')).toBeInTheDocument()
+ })
+ expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025)
+ })
+
+ it('shows the API error message on failure', async () => {
+ mockFetchSummary.mockRejectedValue(new Error('API 404: Not Found'))
+ renderPage({ driverNumber: 99, year: 2025 })
+
+ await waitFor(() => {
+ expect(screen.getByText('API 404: Not Found')).toBeInTheDocument()
+ })
+ })
+
+ it('shows empty states when no rounds are completed', async () => {
+ mockFetchSummary.mockResolvedValue({
+ ...summary,
+ form: [],
+ cumulative: [],
+ round_labels: [],
+ rounds: [],
+ })
+ renderPage({ driverNumber: 1, year: 2025 })
+
+ await waitFor(() => {
+ expect(screen.getByTestId('driver-profile')).toBeInTheDocument()
+ })
+ expect(screen.getAllByText('No completed rounds yet.').length).toBe(2)
+ expect(screen.getByText('No grid-vs-finish data yet.')).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/test/driverProfile.test.ts b/frontend/src/test/driverProfile.test.ts
new file mode 100644
index 0000000..93ebe6e
--- /dev/null
+++ b/frontend/src/test/driverProfile.test.ts
@@ -0,0 +1,104 @@
+import { describe, it, expect } from 'vitest'
+import {
+ formatDelta,
+ formatPosition,
+ gridFinishDeltas,
+ shortGpLabel,
+} from '../lib/driverProfile'
+import type { DriverSummaryRound } from '../types'
+
+function round(over: Partial): DriverSummaryRound {
+ return {
+ meeting_key: 1201,
+ meeting_name: 'Bahrain Grand Prix',
+ country_code: 'BHR',
+ country_name: 'Bahrain',
+ race_position: 1,
+ grid_position: 1,
+ quali_position: 1,
+ points: 25,
+ dnf: false,
+ dns: false,
+ dsq: false,
+ ...over,
+ }
+}
+
+describe('gridFinishDeltas', () => {
+ it('computes positive delta for places gained', () => {
+ const [d] = gridFinishDeltas([round({ grid_position: 5, race_position: 2, points: 18 })])
+ expect(d.grid).toBe(5)
+ expect(d.finish).toBe(2)
+ expect(d.delta).toBe(3)
+ expect(d.points).toBe(18)
+ expect(d.status).toBe('classified')
+ expect(d.round).toBe(1)
+ })
+
+ it('computes negative delta for places lost', () => {
+ const [d] = gridFinishDeltas([round({ grid_position: 1, race_position: 4 })])
+ expect(d.delta).toBe(-3)
+ })
+
+ it('returns null delta when the grid slot is unknown (position 0)', () => {
+ const [d] = gridFinishDeltas([round({ grid_position: 0, race_position: 6 })])
+ expect(d.grid).toBeNull()
+ expect(d.finish).toBe(6)
+ expect(d.delta).toBeNull()
+ })
+
+ it('flags DNS rounds and never computes a delta for them', () => {
+ const [d] = gridFinishDeltas([round({ dns: true, grid_position: 8, race_position: 0 })])
+ expect(d.status).toBe('dns')
+ expect(d.delta).toBeNull()
+ })
+
+ it('marks rounds the driver did not enter as absent', () => {
+ const [d] = gridFinishDeltas([round({ grid_position: 0, race_position: 0, points: 0 })])
+ expect(d.status).toBe('absent')
+ expect(d.grid).toBeNull()
+ expect(d.finish).toBeNull()
+ expect(d.delta).toBeNull()
+ })
+
+ it('still computes the delta for a classified DNF with known positions', () => {
+ const [d] = gridFinishDeltas([round({ dnf: true, grid_position: 3, race_position: 15 })])
+ expect(d.status).toBe('dnf')
+ expect(d.delta).toBe(-12)
+ })
+
+ it('numbers rounds sequentially and shortens labels', () => {
+ const ds = gridFinishDeltas([
+ round({}),
+ round({ meeting_name: 'Saudi Arabian Grand Prix', meeting_key: 1202 }),
+ ])
+ expect(ds.map((d) => d.round)).toEqual([1, 2])
+ expect(ds[1].label).toBe('Saudi Arabian')
+ })
+})
+
+describe('shortGpLabel', () => {
+ it('strips "Grand Prix" wherever it appears', () => {
+ expect(shortGpLabel('Bahrain Grand Prix')).toBe('Bahrain')
+ expect(shortGpLabel('Grand Prix of Monaco')).toBe('of Monaco')
+ })
+
+ it('falls back to the original name when stripping empties it', () => {
+ expect(shortGpLabel('Grand Prix')).toBe('Grand Prix')
+ })
+})
+
+describe('formatPosition / formatDelta', () => {
+ it('formats positions', () => {
+ expect(formatPosition(4)).toBe('P4')
+ expect(formatPosition(null)).toBe('—')
+ expect(formatPosition(0)).toBe('—')
+ })
+
+ it('formats deltas', () => {
+ expect(formatDelta(3)).toBe('+3')
+ expect(formatDelta(-2)).toBe('−2')
+ expect(formatDelta(0)).toBe('=')
+ expect(formatDelta(null)).toBe('—')
+ })
+})
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index c0b2fcf..85c8264 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -383,6 +383,39 @@ export interface ChampionshipHub {
teams: ChampHubTeam[]
}
+export interface DriverSummaryRound {
+ meeting_key: number
+ meeting_name: string
+ country_code: string
+ country_name: string
+ race_position: number
+ grid_position: number
+ quali_position?: number
+ points: number
+ dnf: boolean
+ dns: boolean
+ dsq: boolean
+}
+
+export interface DriverSummary {
+ season: number
+ driver_number: number
+ name_acronym: string
+ full_name: string
+ team_name: string
+ team_colour: string
+ headshot_url: string
+ points: number
+ position: number
+ wins: number
+ podiums: number
+ poles: number
+ form: number[]
+ cumulative: number[]
+ round_labels: string[]
+ rounds: DriverSummaryRound[]
+}
+
export interface NewsItem {
source: string
title: string
diff --git a/internal/web/api.go b/internal/web/api.go
index 01a61bc..7433993 100644
--- a/internal/web/api.go
+++ b/internal/web/api.go
@@ -797,11 +797,6 @@ func (s *Server) localChampionshipHub(year int) (champHubResponse, bool, error)
}
func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
- meetings, err := s.client.GetMeetingsForYear(year)
- if err != nil {
- return champHubResponse{}, err
- }
-
champ, err := s.client.GetDriverChampionshipForYear(year)
if err != nil {
return champHubResponse{}, err
@@ -816,16 +811,39 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
driverInfo = buildDriverMapFirst(ds)
}
+ races, incomplete, err := s.fetchSeasonRaces(year)
+ if err != nil {
+ return champHubResponse{}, err
+ }
+
+ resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo)
+ ttl := champHubTTL(year, time.Now())
+ if incomplete {
+ // Any per-meeting fetch failure (network, rate limit) yields an incomplete
+ // aggregate: serve it so the page still renders, but cache it only briefly
+ // so a partial view of the season doesn't stick around for the full TTL.
+ ttl = champHubIncompleteTTL
+ }
+ s.hubCache.put(year, resp, time.Now(), ttl)
+ return resp, nil
+}
+
+// fetchSeasonRaces returns a season's GP meetings in date order, each bundled
+// with its race results and starting grid fetched from OpenF1. incomplete
+// reports whether any per-meeting fetch failed, so callers can avoid caching a
+// partial view of the season for long.
+func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete bool, err error) {
+ meetings, err := s.client.GetMeetingsForYear(year)
+ if err != nil {
+ return nil, false, err
+ }
sort.Slice(meetings, func(i, j int) bool { return meetings[i].DateStart < meetings[j].DateStart })
- // Any per-meeting fetch failure (network, rate limit) yields an incomplete
- // aggregate: serve it so the page still renders, but cache it only briefly
- // so a partial view of the season doesn't stick around for the full TTL.
- var incomplete atomic.Bool
- races := fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) {
+ var failed atomic.Bool
+ races = fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) {
sessions, serr := s.client.GetSessionsForMeeting(int(m.MeetingKey))
if serr != nil {
- incomplete.Store(true)
+ failed.Store(true)
return meetingRace{}, false
}
raceKey := 0
@@ -841,18 +859,11 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
results, rerr := s.client.GetSessionResult(raceKey)
grid, gerr := s.client.GetStartingGrid(raceKey)
if rerr != nil || gerr != nil {
- incomplete.Store(true)
+ failed.Store(true)
}
return meetingRace{Meeting: m, RaceSessionKey: raceKey, Results: results, Grid: grid}, true
})
-
- resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo)
- ttl := champHubTTL(year, time.Now())
- if incomplete.Load() {
- ttl = champHubIncompleteTTL
- }
- s.hubCache.put(year, resp, time.Now(), ttl)
- return resp, nil
+ return races, failed.Load(), nil
}
// aggregateChampionshipHub is the pure aggregation core (no network) so it can be
diff --git a/internal/web/driversummary.go b/internal/web/driversummary.go
new file mode 100644
index 0000000..7c87e59
--- /dev/null
+++ b/internal/web/driversummary.go
@@ -0,0 +1,177 @@
+package web
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/AmanTahiliani/box-box/internal/models"
+)
+
+// --- /api/v1/driver/summary ---
+// Per-driver season summary: championship standing plus per-round race results,
+// aggregated server-side from the same sources as the championship hub. Caching
+// relies on the OpenF1 client's HTTP cache TTLs — no extra layer here.
+
+type driverSummaryRound struct {
+ MeetingKey int `json:"meeting_key"`
+ MeetingName string `json:"meeting_name"`
+ CountryCode string `json:"country_code"`
+ CountryName string `json:"country_name"`
+ // RacePosition and GridPosition are 0 when the driver has no entry for the
+ // round (did not enter, or data missing).
+ RacePosition int `json:"race_position"`
+ GridPosition int `json:"grid_position"`
+ // QualiPosition is derived from the starting grid in v1 (full qualifying
+ // session results are deferred); omitted when unknown.
+ QualiPosition int `json:"quali_position,omitempty"`
+ Points float64 `json:"points"`
+ DNF bool `json:"dnf"`
+ DNS bool `json:"dns"`
+ DSQ bool `json:"dsq"`
+}
+
+type driverSummaryResponse struct {
+ Season int `json:"season"`
+ DriverNumber int `json:"driver_number"`
+ NameAcronym string `json:"name_acronym"`
+ FullName string `json:"full_name"`
+ TeamName string `json:"team_name"`
+ TeamColour string `json:"team_colour"`
+ HeadshotURL string `json:"headshot_url"`
+ Points float64 `json:"points"`
+ Position int `json:"position"`
+ Wins int `json:"wins"`
+ Podiums int `json:"podiums"`
+ Poles int `json:"poles"`
+ Form []float64 `json:"form"`
+ Cumulative []float64 `json:"cumulative"`
+ RoundLabels []string `json:"round_labels"`
+ Rounds []driverSummaryRound `json:"rounds"`
+}
+
+func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
+ driverNumber, err := strconv.Atoi(r.URL.Query().Get("driver_number"))
+ if err != nil || driverNumber <= 0 {
+ http.Error(w, "driver_number required", http.StatusBadRequest)
+ return
+ }
+ year, _ := strconv.Atoi(r.URL.Query().Get("year"))
+ if year == 0 {
+ year = time.Now().Year()
+ }
+
+ champ, err := s.client.GetDriverChampionshipForYear(year)
+ if err != nil {
+ writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ return
+ }
+ var entry *models.ChampionshipDriver
+ for i := range champ {
+ if champ[i].DriverNumber == driverNumber {
+ entry = &champ[i]
+ break
+ }
+ }
+ if entry == nil {
+ http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
+ return
+ }
+
+ driverInfo := map[int]models.Driver{}
+ if ds, derr := s.client.GetDriversForSession(champ[0].SessionKey); derr == nil {
+ driverInfo = buildDriverMapFirst(ds)
+ }
+ if d, ok := s.championshipDriverInfo(entry.SessionKey, driverNumber, driverInfo); ok {
+ driverInfo[driverNumber] = d
+ }
+
+ races, _, err := s.fetchSeasonRaces(year)
+ if err != nil {
+ writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ return
+ }
+
+ resp, ok := aggregateDriverSummary(year, driverNumber, races, champ, driverInfo)
+ if !ok {
+ http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
+ return
+ }
+ writeJSON(w, resp)
+}
+
+// aggregateDriverSummary is the pure aggregation core (no network) so it can be
+// unit-tested with synthetic data. It reuses the championship-hub aggregation
+// for the derived season stats (wins, podiums, poles, form, cumulative) and
+// adds the per-round result rows. races must be ordered ascending by date and
+// contain only GP meetings. Returns ok=false when the driver is not in champ.
+func aggregateDriverSummary(
+ year, driverNumber int,
+ races []meetingRace,
+ champ []models.ChampionshipDriver,
+ driverInfo map[int]models.Driver,
+) (driverSummaryResponse, bool) {
+ hub := aggregateChampionshipHub(year, races, champ, nil, driverInfo)
+
+ var hd *champHubDriver
+ for i := range hub.Drivers {
+ if hub.Drivers[i].DriverNumber == driverNumber {
+ hd = &hub.Drivers[i]
+ break
+ }
+ }
+ if hd == nil {
+ return driverSummaryResponse{}, false
+ }
+
+ rounds := make([]driverSummaryRound, 0, len(races))
+ for _, mr := range races {
+ if len(mr.Results) == 0 {
+ continue // round not completed yet
+ }
+ round := driverSummaryRound{
+ MeetingKey: int(mr.Meeting.MeetingKey),
+ MeetingName: mr.Meeting.MeetingName,
+ CountryCode: mr.Meeting.CountryCode,
+ CountryName: mr.Meeting.CountryName,
+ }
+ for _, res := range mr.Results {
+ if res.DriverNumber == driverNumber {
+ round.RacePosition = res.Position
+ round.Points = res.Points
+ round.DNF = res.DNF
+ round.DNS = res.DNS
+ round.DSQ = res.DSQ
+ break
+ }
+ }
+ for _, g := range mr.Grid {
+ if g.DriverNumber == driverNumber {
+ round.GridPosition = g.Position
+ round.QualiPosition = g.Position
+ break
+ }
+ }
+ rounds = append(rounds, round)
+ }
+
+ return driverSummaryResponse{
+ Season: year,
+ DriverNumber: hd.DriverNumber,
+ NameAcronym: hd.NameAcronym,
+ FullName: hd.FullName,
+ TeamName: hd.TeamName,
+ TeamColour: hd.TeamColour,
+ HeadshotURL: driverInfo[driverNumber].HeadshotURL,
+ Points: hd.Points,
+ Position: hd.Position,
+ Wins: hd.Wins,
+ Podiums: hd.Podiums,
+ Poles: hd.Poles,
+ Form: hd.Form,
+ Cumulative: hd.Cumulative,
+ RoundLabels: hub.RoundLabels,
+ Rounds: rounds,
+ }, true
+}
diff --git a/internal/web/driversummary_test.go b/internal/web/driversummary_test.go
new file mode 100644
index 0000000..16e1dec
--- /dev/null
+++ b/internal/web/driversummary_test.go
@@ -0,0 +1,136 @@
+package web
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/AmanTahiliani/box-box/internal/models"
+)
+
+func driverSummaryFixtures() ([]meetingRace, []models.ChampionshipDriver, map[int]models.Driver) {
+ driverInfo := map[int]models.Driver{
+ 1: {DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull", TeamColour: "3671c6", HeadshotURL: "https://example.com/ver.png"},
+ 3: {DriverNumber: 3, NameAcronym: "HAM", FullName: "Lewis Hamilton", TeamName: "Mercedes", TeamColour: "27f4d2"},
+ }
+
+ champ := []models.ChampionshipDriver{
+ {DriverNumber: 1, PointsCurrent: 50, PositionCurrent: 1, SessionKey: 99},
+ {DriverNumber: 3, PointsCurrent: 33, PositionCurrent: 2, SessionKey: 99},
+ }
+
+ // Round 1: VER P1 from pole, HAM P2 from P3.
+ // Round 2: VER P1 from P2, HAM P3 from pole.
+ // Round 3: not yet run — must not appear in rounds.
+ races := []meetingRace{
+ {
+ Meeting: models.Meeting{MeetingKey: 1201, MeetingName: "Bahrain GP", CountryCode: "BHR", CountryName: "Bahrain"},
+ Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(3, 2, 18)},
+ Grid: []models.StartingGrid{{DriverNumber: 1, Position: 1}, {DriverNumber: 3, Position: 3}},
+ },
+ {
+ Meeting: models.Meeting{MeetingKey: 1202, MeetingName: "Saudi GP", CountryCode: "SAU", CountryName: "Saudi Arabia"},
+ Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(3, 3, 15)},
+ Grid: []models.StartingGrid{{DriverNumber: 3, Position: 1}, {DriverNumber: 1, Position: 2}},
+ },
+ {Meeting: models.Meeting{MeetingKey: 1203, MeetingName: "Australia GP"}},
+ }
+ return races, champ, driverInfo
+}
+
+func TestAggregateDriverSummary(t *testing.T) {
+ races, champ, driverInfo := driverSummaryFixtures()
+
+ resp, ok := aggregateDriverSummary(2025, 1, races, champ, driverInfo)
+ if !ok {
+ t.Fatal("aggregateDriverSummary ok = false, want true")
+ }
+
+ if resp.Season != 2025 || resp.DriverNumber != 1 {
+ t.Errorf("season/driver = %d/%d, want 2025/1", resp.Season, resp.DriverNumber)
+ }
+ if resp.NameAcronym != "VER" || resp.FullName != "Max Verstappen" || resp.TeamName != "Red Bull" || resp.TeamColour != "3671c6" {
+ t.Errorf("driver identity = %s %s %s %s", resp.NameAcronym, resp.FullName, resp.TeamName, resp.TeamColour)
+ }
+ if resp.HeadshotURL != "https://example.com/ver.png" {
+ t.Errorf("headshot = %q", resp.HeadshotURL)
+ }
+ if resp.Points != 50 || resp.Position != 1 {
+ t.Errorf("points/position = %v/%d, want 50/1", resp.Points, resp.Position)
+ }
+ if resp.Wins != 2 || resp.Podiums != 2 || resp.Poles != 1 {
+ t.Errorf("wins/podiums/poles = %d/%d/%d, want 2/2/1", resp.Wins, resp.Podiums, resp.Poles)
+ }
+ if len(resp.Form) != 2 || resp.Form[0] != 25 || resp.Form[1] != 25 {
+ t.Errorf("form = %v, want [25 25]", resp.Form)
+ }
+ // Cumulative reconciles the last value to the official total.
+ if len(resp.Cumulative) != 2 || resp.Cumulative[0] != 25 || resp.Cumulative[1] != 50 {
+ t.Errorf("cumulative = %v, want [25 50]", resp.Cumulative)
+ }
+ if len(resp.RoundLabels) != 2 || resp.RoundLabels[0] != "R1" {
+ t.Errorf("round labels = %v, want [R1 R2]", resp.RoundLabels)
+ }
+
+ // Only completed rounds appear.
+ if len(resp.Rounds) != 2 {
+ t.Fatalf("rounds = %d, want 2", len(resp.Rounds))
+ }
+ r1 := resp.Rounds[0]
+ if r1.MeetingKey != 1201 || r1.MeetingName != "Bahrain GP" || r1.CountryCode != "BHR" || r1.CountryName != "Bahrain" {
+ t.Errorf("round 1 meeting = %+v", r1)
+ }
+ if r1.RacePosition != 1 || r1.GridPosition != 1 || r1.QualiPosition != 1 || r1.Points != 25 {
+ t.Errorf("round 1 result = %+v, want P1 from P1 with 25 pts", r1)
+ }
+ r2 := resp.Rounds[1]
+ if r2.RacePosition != 1 || r2.GridPosition != 2 || r2.Points != 25 {
+ t.Errorf("round 2 result = %+v, want P1 from P2 with 25 pts", r2)
+ }
+}
+
+func TestAggregateDriverSummaryDriverMissingFromRound(t *testing.T) {
+ races, champ, driverInfo := driverSummaryFixtures()
+ // Drop HAM from round 2's results and grid: the round still appears with
+ // zero-valued positions so round indexing stays aligned with the season.
+ races[1].Results = []models.SessionResult{raceResult(1, 1, 25)}
+ races[1].Grid = []models.StartingGrid{{DriverNumber: 1, Position: 2}}
+
+ resp, ok := aggregateDriverSummary(2025, 3, races, champ, driverInfo)
+ if !ok {
+ t.Fatal("aggregateDriverSummary ok = false, want true")
+ }
+ if len(resp.Rounds) != 2 {
+ t.Fatalf("rounds = %d, want 2", len(resp.Rounds))
+ }
+ r2 := resp.Rounds[1]
+ if r2.RacePosition != 0 || r2.GridPosition != 0 || r2.QualiPosition != 0 || r2.Points != 0 {
+ t.Errorf("round 2 for absent driver = %+v, want zero values", r2)
+ }
+}
+
+func TestAggregateDriverSummaryUnknownDriver(t *testing.T) {
+ races, champ, driverInfo := driverSummaryFixtures()
+ if _, ok := aggregateDriverSummary(2025, 44, races, champ, driverInfo); ok {
+ t.Error("unknown driver should return ok = false")
+ }
+}
+
+func TestHandleDriverSummaryBadRequest(t *testing.T) {
+ srv := testServer(t, nil)
+
+ // Missing, non-numeric, zero, and negative driver_number are all rejected.
+ for _, url := range []string{
+ "/api/v1/driver/summary?year=2025",
+ "/api/v1/driver/summary?year=2025&driver_number=abc",
+ "/api/v1/driver/summary?year=2025&driver_number=0",
+ "/api/v1/driver/summary?year=2025&driver_number=-4",
+ } {
+ req := httptest.NewRequest(http.MethodGet, url, nil)
+ rec := httptest.NewRecorder()
+ srv.handleDriverSummary(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Errorf("%s: status = %d, want 400", url, rec.Code)
+ }
+ }
+}
diff --git a/internal/web/server.go b/internal/web/server.go
index 729c44a..68245b3 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -70,6 +70,9 @@ func (s *Server) routes() (http.Handler, error) {
mux.HandleFunc("/api/v1/news", s.handleNews)
mux.HandleFunc("/api/v1/meetings", s.handleMeetings)
mux.HandleFunc("/api/v1/sessions", s.handleSessions)
+ // /api/v1/driver/summary before /api/v1/drivers: distinct prefixes today,
+ // but keep the more specific driver/* paths registered first.
+ mux.HandleFunc("/api/v1/driver/summary", s.handleDriverSummary)
mux.HandleFunc("/api/v1/drivers", s.handleDrivers)
mux.HandleFunc("/api/v1/results", s.handleResults)
mux.HandleFunc("/api/v1/grid", s.handleGrid)
From f24ec4be948bb9c20cfb2ad0a3776b4448b01b9f Mon Sep 17 00:00:00 2001
From: AmanTahiliani
Date: Sat, 11 Jul 2026 18:14:55 -0400
Subject: [PATCH 5/7] feat(frontend): calendar-aware paddock briefing digest
(#26)
Reframe Briefing into GP-window sections with driver/team tag chips, a sticky
"since last race" header, and client-side digest helpers (gpWindows, groupByWindow,
tagItems). Overlapping inter-race windows resolve to the latest GP; undated items
land in a Recent bucket. Vitest covers digest lib + page render/filter behaviour.
Co-authored-by: Cursor
---
frontend/src/components/PaddockBriefing.tsx | 80 ++++-
frontend/src/lib/digest.ts | 293 +++++++++++++++++
frontend/src/pages/BriefingPage.tsx | 336 ++++++++++++++------
frontend/src/styles/digest.css | 178 +++++++++++
frontend/src/test/BriefingPage.test.tsx | 209 ++++++++++++
frontend/src/test/digest.test.ts | 265 +++++++++++++++
6 files changed, 1269 insertions(+), 92 deletions(-)
create mode 100644 frontend/src/lib/digest.ts
create mode 100644 frontend/src/styles/digest.css
create mode 100644 frontend/src/test/BriefingPage.test.tsx
create mode 100644 frontend/src/test/digest.test.ts
diff --git a/frontend/src/components/PaddockBriefing.tsx b/frontend/src/components/PaddockBriefing.tsx
index 83d9ee8..3f44aeb 100644
--- a/frontend/src/components/PaddockBriefing.tsx
+++ b/frontend/src/components/PaddockBriefing.tsx
@@ -1,7 +1,23 @@
+import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
-import { fetchNews } from '../api'
+import {
+ fetchChampionshipHub,
+ fetchNews,
+ fetchSeasonMeetings,
+ fetchSeasons,
+} from '../api'
+import {
+ activeDigestWindow,
+ gpWindows,
+ itemsForWindow,
+ sinceLastLabel,
+ tagColour,
+ tagItems,
+ topTags,
+} from '../lib/digest'
import { timeAgo } from '../utils'
+import '../styles/digest.css'
const SOURCE_DISPLAY: Record = {
'fia': 'FIA',
@@ -14,14 +30,53 @@ const SOURCE_DISPLAY: Record = {
}
export function PaddockBriefing() {
+ const now = useMemo(() => new Date(), [])
+
const { data: news, isLoading, isError } = useQuery({
queryKey: ['news'],
queryFn: () => fetchNews(100),
staleTime: 60_000,
})
+ const seasonsQuery = useQuery({
+ queryKey: ['seasons'],
+ queryFn: fetchSeasons,
+ })
+ const latestSeason = seasonsQuery.data?.[0] ?? null
+
+ const meetingsQuery = useQuery({
+ queryKey: ['season-meetings', latestSeason],
+ queryFn: () => fetchSeasonMeetings(latestSeason!),
+ enabled: latestSeason != null,
+ })
+
+ const hubQuery = useQuery({
+ queryKey: ['championship-hub', latestSeason],
+ queryFn: () => fetchChampionshipHub(latestSeason!),
+ enabled: latestSeason != null,
+ })
+
+ const meetings = meetingsQuery.data ?? []
+ const hub = hubQuery.data
+ const tagged = useMemo(
+ () => tagItems(news ?? [], hub?.drivers ?? [], hub?.teams ?? []),
+ [news, hub],
+ )
+
+ const windows = useMemo(() => gpWindows(meetings, now), [meetings, now])
+ const activeWindow = useMemo(
+ () => activeDigestWindow(windows, meetings, now),
+ [windows, meetings, now],
+ )
+ const sinceItems = useMemo(
+ () => itemsForWindow(tagged, activeWindow),
+ [tagged, activeWindow],
+ )
+ const sinceTags = useMemo(() => topTags(sinceItems, 4), [sinceItems])
+ const sinceLabel = sinceLastLabel(meetings, now)
+
const unreadCount = news?.filter((i) => !i.read_at).length ?? 0
- const preview = news?.slice(0, 5) ?? []
+ const preview = sinceItems.length > 0 ? sinceItems.slice(0, 5) : (tagged.slice(0, 5))
return (
@@ -37,6 +92,27 @@ export function PaddockBriefing() {
+ {meetings.length > 0 && (
+
+
Since {sinceLabel}
+
·
+
{sinceItems.length} items
+ {sinceTags.length > 0 && (
+
+ {sinceTags.map((tag) => (
+
+ {tag.label}
+
+ ))}
+
+ )}
+
+ )}
+
{isLoading && loading…
}
{isError && Failed to load briefing
}
diff --git a/frontend/src/lib/digest.ts b/frontend/src/lib/digest.ts
new file mode 100644
index 0000000..ec01629
--- /dev/null
+++ b/frontend/src/lib/digest.ts
@@ -0,0 +1,293 @@
+import { meetingEndTime, meetingStartTime, mostRecentPastMeeting, nextUpcomingMeeting } from './schedule'
+import type { ChampHubDriver, ChampHubTeam, Meeting, NewsItem } from '../types'
+
+export interface GPWindow {
+ meeting_key: number
+ meeting_name: string
+ /** Inclusive lower bound: end of previous GP, or open for season opener. */
+ start: Date | null
+ /** Exclusive upper bound: start of next GP, or open after the final round. */
+ end: Date | null
+}
+
+export interface DigestTag {
+ kind: 'driver' | 'team'
+ key: string
+ label: string
+ colour: string
+}
+
+export type TaggedNewsItem = NewsItem & { tags: DigestTag[] }
+
+export const RECENT_BUCKET_KEY = '__recent__'
+
+interface MatchCandidate {
+ start: number
+ end: number
+ tag: DigestTag
+}
+
+interface TagPattern {
+ pattern: string
+ tag: DigestTag
+}
+
+function sortMeetings(meetings: Meeting[]): Meeting[] {
+ return [...meetings].sort((a, b) => {
+ const left = meetingStartTime(a)?.getTime() ?? 0
+ const right = meetingStartTime(b)?.getTime() ?? 0
+ if (left !== right) return left - right
+ return a.meeting_name.localeCompare(b.meeting_name)
+ })
+}
+
+/** Derive inter-race windows [prev GP end → next GP start] for each round. */
+export function gpWindows(meetings: Meeting[], _now: Date): GPWindow[] {
+ const sorted = sortMeetings(meetings)
+ return sorted.map((meeting, index) => ({
+ meeting_key: meeting.meeting_key,
+ meeting_name: meeting.meeting_name,
+ start: index > 0 ? meetingEndTime(sorted[index - 1]) : null,
+ end: index < sorted.length - 1 ? meetingStartTime(sorted[index + 1]) : null,
+ }))
+}
+
+function itemPublishedAt(item: NewsItem): Date | null {
+ if (!item.published_at) return null
+ const parsed = Date.parse(item.published_at)
+ return Number.isNaN(parsed) ? null : new Date(parsed)
+}
+
+function itemInWindow(published: Date, window: GPWindow): boolean {
+ if (window.start && published < window.start) return false
+ if (window.end && published >= window.end) return false
+ return true
+}
+
+export function windowForDate(windows: GPWindow[], published: Date): GPWindow | null {
+ const matches = windows.filter((window) => itemInWindow(published, window))
+ if (matches.length === 0) return null
+ return matches.reduce((best, window) => {
+ const bestStart = best.start?.getTime() ?? Number.NEGATIVE_INFINITY
+ const windowStart = window.start?.getTime() ?? Number.NEGATIVE_INFINITY
+ return windowStart >= bestStart ? window : best
+ })
+}
+
+export interface GroupedDigest {
+ windows: Array<{ window: GPWindow; items: NewsItem[] }>
+ recent: NewsItem[]
+}
+
+/** Bucket news items into GP windows; undated items land in `recent`. */
+export function groupByWindow(items: NewsItem[], windows: GPWindow[]): GroupedDigest {
+ const buckets = new Map()
+ const recent: NewsItem[] = []
+
+ for (const item of items) {
+ const published = itemPublishedAt(item)
+ if (!published) {
+ recent.push(item)
+ continue
+ }
+ const match = windowForDate(windows, published)
+ if (!match) {
+ recent.push(item)
+ continue
+ }
+ const list = buckets.get(match.meeting_key) ?? []
+ list.push(item)
+ buckets.set(match.meeting_key, list)
+ }
+
+ const grouped = windows
+ .map((window) => ({
+ window,
+ items: buckets.get(window.meeting_key) ?? [],
+ }))
+ .filter((entry) => entry.items.length > 0)
+
+ return { windows: grouped, recent }
+}
+
+function driverLastName(fullName: string): string | null {
+ const parts = fullName.trim().split(/\s+/).filter(Boolean)
+ if (parts.length < 2) return null
+ return parts[parts.length - 1] ?? null
+}
+
+function buildTagPatterns(drivers: ChampHubDriver[], teams: ChampHubTeam[]): TagPattern[] {
+ const patterns: TagPattern[] = []
+
+ for (const team of teams) {
+ if (!team.team_name.trim()) continue
+ patterns.push({
+ pattern: team.team_name.toLowerCase(),
+ tag: {
+ kind: 'team',
+ key: `team:${team.team_name}`,
+ label: team.team_name,
+ colour: team.team_colour,
+ },
+ })
+ }
+
+ for (const driver of drivers) {
+ const colour = driver.team_colour
+ const base = {
+ kind: 'driver' as const,
+ colour,
+ }
+ if (driver.full_name.trim()) {
+ patterns.push({
+ pattern: driver.full_name.toLowerCase(),
+ tag: {
+ ...base,
+ key: `driver:${driver.name_acronym}`,
+ label: driver.name_acronym,
+ },
+ })
+ const lastName = driverLastName(driver.full_name)
+ if (lastName) {
+ patterns.push({
+ pattern: lastName.toLowerCase(),
+ tag: {
+ ...base,
+ key: `driver:${driver.name_acronym}`,
+ label: driver.name_acronym,
+ },
+ })
+ }
+ }
+ if (driver.name_acronym.trim()) {
+ patterns.push({
+ pattern: driver.name_acronym.toLowerCase(),
+ tag: {
+ ...base,
+ key: `driver:${driver.name_acronym}`,
+ label: driver.name_acronym,
+ },
+ })
+ }
+ }
+
+ patterns.sort((a, b) => b.pattern.length - a.pattern.length)
+ return patterns
+}
+
+function overlaps(a: MatchCandidate, b: MatchCandidate): boolean {
+ return a.start < b.end && b.start < a.end
+}
+
+/**
+ * Tag items via case-insensitive substring match on title + summary.
+ * Longest pattern wins on overlapping spans (v1 string match — no NLP).
+ *
+ * Known limitations: common-word last names, team names inside other words,
+ * and driver TLAs that appear in unrelated acronyms can false-positive.
+ */
+export function tagItems(
+ items: NewsItem[],
+ drivers: ChampHubDriver[],
+ teams: ChampHubTeam[],
+): TaggedNewsItem[] {
+ const patterns = buildTagPatterns(drivers, teams)
+
+ return items.map((item) => {
+ const haystack = `${item.title} ${item.summary ?? ''}`.toLowerCase()
+ const accepted: MatchCandidate[] = []
+
+ for (const { pattern, tag } of patterns) {
+ if (!pattern) continue
+ let from = 0
+ while (from <= haystack.length - pattern.length) {
+ const index = haystack.indexOf(pattern, from)
+ if (index === -1) break
+ const candidate: MatchCandidate = {
+ start: index,
+ end: index + pattern.length,
+ tag,
+ }
+ if (!accepted.some((match) => overlaps(match, candidate))) {
+ accepted.push(candidate)
+ }
+ from = index + 1
+ }
+ }
+
+ const tagsByKey = new Map()
+ for (const match of accepted) {
+ tagsByKey.set(match.tag.key, match.tag)
+ }
+
+ return {
+ ...item,
+ tags: [...tagsByKey.values()].sort((a, b) => a.label.localeCompare(b.label)),
+ }
+ })
+}
+
+export function activeDigestWindow(
+ windows: GPWindow[],
+ meetings: Meeting[],
+ now: Date,
+): GPWindow | null {
+ const next = nextUpcomingMeeting(meetings, now)
+ if (next) {
+ return windows.find((window) => window.meeting_key === next.meeting_key) ?? null
+ }
+ const recent = mostRecentPastMeeting(meetings, now)
+ if (recent) {
+ return windows.find((window) => window.meeting_key === recent.meeting_key) ?? null
+ }
+ return windows[0] ?? null
+}
+
+export function sinceLastLabel(meetings: Meeting[], now: Date): string {
+ const last = mostRecentPastMeeting(meetings, now)
+ if (last) return last.meeting_name
+ const next = nextUpcomingMeeting(meetings, now)
+ if (next) return `Before ${next.meeting_name}`
+ return 'Pre-season'
+}
+
+export function itemsForWindow(items: TaggedNewsItem[], window: GPWindow | null): TaggedNewsItem[] {
+ if (!window) return []
+ return items.filter((item) => {
+ const published = itemPublishedAt(item)
+ return published != null && itemInWindow(published, window)
+ })
+}
+
+export function topTags(items: TaggedNewsItem[], limit = 5): DigestTag[] {
+ const counts = new Map()
+ for (const item of items) {
+ for (const tag of item.tags) {
+ const current = counts.get(tag.key)
+ if (current) current.count += 1
+ else counts.set(tag.key, { tag, count: 1 })
+ }
+ }
+ return [...counts.values()]
+ .sort((a, b) => b.count - a.count || a.tag.label.localeCompare(b.tag.label))
+ .slice(0, limit)
+ .map((entry) => entry.tag)
+}
+
+export function filterByTag(items: TaggedNewsItem[], tagKey: string | null): TaggedNewsItem[] {
+ if (!tagKey) return items
+ return items.filter((item) => item.tags.some((tag) => tag.key === tagKey))
+}
+
+export function tagColour(colour: string): string {
+ if (!colour) return 'var(--text-3)'
+ return colour.startsWith('#') ? colour : `#${colour}`
+}
+
+export function sortWindowBucketsNewestFirst(buckets: T[]): T[] {
+ return [...buckets].sort((a, b) => {
+ const left = a.window.start?.getTime() ?? 0
+ const right = b.window.start?.getTime() ?? 0
+ return right - left
+ })
+}
diff --git a/frontend/src/pages/BriefingPage.tsx b/frontend/src/pages/BriefingPage.tsx
index a283f05..d0ceda3 100644
--- a/frontend/src/pages/BriefingPage.tsx
+++ b/frontend/src/pages/BriefingPage.tsx
@@ -1,13 +1,33 @@
-import { useCallback, useEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
-import { fetchNews, fetchNewsArticle, markNewsRead } from '../api'
+import {
+ fetchChampionshipHub,
+ fetchNews,
+ fetchNewsArticle,
+ fetchSeasonMeetings,
+ fetchSeasons,
+ markNewsRead,
+} from '../api'
+import {
+ activeDigestWindow,
+ filterByTag,
+ groupByWindow,
+ gpWindows,
+ itemsForWindow,
+ sinceLastLabel,
+ sortWindowBucketsNewestFirst,
+ tagColour,
+ tagItems,
+ topTags,
+ type DigestTag,
+ type TaggedNewsItem,
+} from '../lib/digest'
import { stripHtml, timeAgo } from '../utils'
import type { ArticleContent, NewsItem } from '../types'
+import '../styles/digest.css'
type Category = 'all' | 'official' | 'news' | 'video'
-const PAGE_SIZE = 16
-
const CATEGORY_LABELS: Record = {
all: 'All',
official: 'Official',
@@ -71,6 +91,31 @@ function CategoryTabs({
)
}
+function TagChip({
+ tag,
+ active,
+ onClick,
+ className = 'digest-tag-chip',
+}: {
+ tag: DigestTag
+ active?: boolean
+ onClick?: () => void
+ className?: string
+}) {
+ return (
+
+
+ {tag.label}
+
+ )
+}
+
function OGImage({ url, title }: { url?: string; title: string }) {
const [failed, setFailed] = useState(false)
const initial = (title[0] ?? '?').toUpperCase()
@@ -98,11 +143,15 @@ function OGImage({ url, title }: { url?: string; title: string }) {
function BriefingCard({
item,
isActive,
+ activeTag,
onSelect,
+ onTagClick,
}: {
- item: NewsItem
+ item: TaggedNewsItem
isActive: boolean
+ activeTag: string | null
onSelect: (item: NewsItem) => void
+ onTagClick: (tag: DigestTag) => void
}) {
const isRead = !!item.read_at
const isVideo = categoryOf(item) === 'video'
@@ -129,6 +178,25 @@ function BriefingCard({
{stripHtml(item.og_description || item.summary || '')}
)}
+ {item.tags.length > 0 && (
+
+ {item.tags.map((tag) => (
+ {
+ e.stopPropagation()
+ onTagClick(tag)
+ }}
+ >
+
+ {tag.label}
+
+ ))}
+
+ )}
)
@@ -146,7 +214,6 @@ function ReaderPanel({
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
- // Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
@@ -155,7 +222,6 @@ function ReaderPanel({
return () => document.removeEventListener('keydown', handler)
}, [onClose])
- // Fetch article when item changes
useEffect(() => {
if (!item) {
setArticle(null)
@@ -175,9 +241,14 @@ function ReaderPanel({
.catch((err) => { setError(String(err)); setLoading(false) })
}, [item?.url, item ? categoryOf(item) : ''])
- // Scroll panel to top when item changes
useEffect(() => {
- panelRef.current?.scrollTo({ top: 0 })
+ const panel = panelRef.current
+ if (!panel) return
+ if (typeof panel.scrollTo === 'function') {
+ panel.scrollTo({ top: 0 })
+ } else {
+ panel.scrollTop = 0
+ }
}, [item?.url])
const isOpen = item !== null
@@ -297,7 +368,6 @@ function ReaderPanel({
{article && !loading && article.content && (
@@ -332,8 +402,9 @@ function ReaderPanel({
export function BriefingPage() {
const [activeCategory, setActiveCategory] = useState('all')
const [selectedItem, setSelectedItem] = useState(null)
- const [page, setPage] = useState(1)
+ const [activeTag, setActiveTag] = useState(null)
const queryClient = useQueryClient()
+ const now = useMemo(() => new Date(), [])
const { data: allNews = [], isLoading, isError } = useQuery({
queryKey: ['news'],
@@ -341,13 +412,74 @@ export function BriefingPage() {
staleTime: 60_000,
})
- const filtered = allNews.filter(
- (item) => activeCategory === 'all' || categoryOf(item) === activeCategory,
+ const seasonsQuery = useQuery({
+ queryKey: ['seasons'],
+ queryFn: fetchSeasons,
+ })
+ const latestSeason = seasonsQuery.data?.[0] ?? null
+
+ const meetingsQuery = useQuery({
+ queryKey: ['season-meetings', latestSeason],
+ queryFn: () => fetchSeasonMeetings(latestSeason!),
+ enabled: latestSeason != null,
+ })
+
+ const hubQuery = useQuery({
+ queryKey: ['championship-hub', latestSeason],
+ queryFn: () => fetchChampionshipHub(latestSeason!),
+ enabled: latestSeason != null,
+ })
+
+ const meetings = meetingsQuery.data ?? []
+ const hub = hubQuery.data
+
+ const taggedNews = useMemo(
+ () => tagItems(allNews, hub?.drivers ?? [], hub?.teams ?? []),
+ [allNews, hub],
)
- const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
- const safePage = Math.min(page, pageCount)
- const pageStart = (safePage - 1) * PAGE_SIZE
- const pagedItems = filtered.slice(pageStart, pageStart + PAGE_SIZE)
+
+ const categoryFiltered = useMemo(
+ () => taggedNews.filter(
+ (item) => activeCategory === 'all' || categoryOf(item) === activeCategory,
+ ),
+ [taggedNews, activeCategory],
+ )
+
+ const tagFiltered = useMemo(
+ () => filterByTag(categoryFiltered, activeTag),
+ [categoryFiltered, activeTag],
+ )
+
+ const windows = useMemo(() => gpWindows(meetings, now), [meetings, now])
+ const grouped = useMemo(() => groupByWindow(tagFiltered, windows), [tagFiltered, windows])
+ const windowSections = useMemo(
+ () => sortWindowBucketsNewestFirst(grouped.windows),
+ [grouped.windows],
+ )
+
+ const activeWindow = useMemo(
+ () => activeDigestWindow(windows, meetings, now),
+ [windows, meetings, now],
+ )
+
+ const sinceLastItems = useMemo(
+ () => itemsForWindow(tagFiltered, activeWindow),
+ [tagFiltered, activeWindow],
+ )
+
+ const sinceLastTags = useMemo(() => topTags(sinceLastItems), [sinceLastItems])
+ const sinceLabel = sinceLastLabel(meetings, now)
+
+ const taggedByUrl = useMemo(() => {
+ const map = new Map()
+ for (const item of tagFiltered) map.set(item.url, item)
+ return map
+ }, [tagFiltered])
+
+ const recentTagged = useMemo(() => {
+ const urls = new Set(grouped.recent.map((item) => item.url))
+ return tagFiltered.filter((item) => urls.has(item.url))
+ }, [grouped.recent, tagFiltered])
const counts: Record = {
all: allNews.length,
@@ -374,22 +506,14 @@ export function BriefingPage() {
const handleClose = useCallback(() => setSelectedItem(null), [])
- useEffect(() => {
- setPage((current) => Math.min(current, pageCount))
- }, [pageCount])
-
- const handlePageChange = useCallback(
- (nextPage: number) => {
- setPage(Math.min(Math.max(nextPage, 1), pageCount))
- setSelectedItem(null)
- window.scrollTo({ top: 0, behavior: 'smooth' })
- },
- [pageCount],
- )
+ const handleTagClick = useCallback((tag: DigestTag) => {
+ setActiveTag((current) => (current === tag.key ? null : tag.key))
+ setSelectedItem(null)
+ }, [])
const unreadCount = allNews.filter((i) => !i.read_at).length
- const pageEnd = Math.min(pageStart + pagedItems.length, filtered.length)
- const showPagination = filtered.length > PAGE_SIZE
+ const hasDigest = meetings.length > 0
+ const showEmpty = tagFiltered.length === 0 && grouped.recent.length === 0
return (
@@ -408,74 +532,106 @@ export function BriefingPage() {
{ setActiveCategory(c); setSelectedItem(null); setPage(1) }}
+ onChange={(c) => {
+ setActiveCategory(c)
+ setSelectedItem(null)
+ setActiveTag(null)
+ }}
/>
- {filtered.length === 0 ? (
+ {hasDigest && (
+
+
+
Since {sinceLabel}
+
+ {sinceLastItems.length} item{sinceLastItems.length === 1 ? '' : 's'}
+
+
+ {(sinceLastTags.length > 0 || activeTag) && (
+
+ {sinceLastTags.map((tag) => (
+ handleTagClick(tag)}
+ />
+ ))}
+ {activeTag && (
+ setActiveTag(null)}
+ data-testid="digest-filter-clear"
+ >
+ Clear filter
+
+ )}
+
+ )}
+
+ )}
+
+ {showEmpty ? (
No {activeCategory !== 'all' ? activeCategory : ''} items available.
- Run box-box --ingest-news to refresh feeds.
+ {activeTag ? ' Try clearing the tag filter.' : ''}
+ {!activeTag && (
+ <>
+ {' '}
+ Run box-box --ingest-news to refresh feeds.
+ >
+ )}
) : (
<>
- {showPagination && (
-
-
- {pageStart + 1}-{pageEnd} of {filtered.length}
-
-
-
handlePageChange(safePage - 1)}
- disabled={safePage === 1}
- >
- Previous
-
-
- Page {safePage} / {pageCount}
-
-
handlePageChange(safePage + 1)}
- disabled={safePage === pageCount}
- >
- Next
-
+ {windowSections.map(({ window, items }) => (
+
+
+
{window.meeting_name}
+ {items.length}
-
- )}
+
+ {items.map((item) => {
+ const tagged = taggedByUrl.get(item.url) ?? { ...item, tags: [] }
+ return (
+
+ )
+ })}
+
+
+ ))}
-
- {pagedItems.map((item) => (
-
- ))}
-
-
- {showPagination && (
-
- handlePageChange(safePage - 1)}
- disabled={safePage === 1}
- >
- Previous
-
-
- Page {safePage} / {pageCount}
-
- handlePageChange(safePage + 1)}
- disabled={safePage === pageCount}
- >
- Next
-
-
+ {recentTagged.length > 0 && (
+
+
+
Recent
+ {recentTagged.length}
+
+
+ {recentTagged.map((item) => (
+
+ ))}
+
+
)}
>
)}
diff --git a/frontend/src/styles/digest.css b/frontend/src/styles/digest.css
new file mode 100644
index 0000000..bc7be90
--- /dev/null
+++ b/frontend/src/styles/digest.css
@@ -0,0 +1,178 @@
+/* ── Calendar-aware paddock digest ── */
+
+.digest-sticky {
+ position: sticky;
+ top: 0;
+ z-index: 20;
+ margin-bottom: var(--s5);
+ padding: var(--s4) var(--s5);
+ background: color-mix(in srgb, var(--bg) 92%, transparent);
+ backdrop-filter: blur(8px);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+}
+
+.digest-sticky-head {
+ display: flex;
+ align-items: baseline;
+ gap: var(--s3);
+ flex-wrap: wrap;
+ margin-bottom: var(--s3);
+}
+
+.digest-sticky-title {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text);
+ margin: 0;
+}
+
+.digest-sticky-count {
+ font-size: 11px;
+ color: var(--text-3);
+ font-family: var(--f-mono);
+ letter-spacing: 0.04em;
+}
+
+.digest-sticky-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--s2);
+ align-items: center;
+}
+
+.digest-tag-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--s2);
+ padding: 2px 8px;
+ border-radius: 999px;
+ border: 1px solid var(--border-2);
+ background: var(--surface);
+ color: var(--text-2);
+ font-size: 11px;
+ font-family: var(--f-mono);
+ cursor: pointer;
+ transition: border-color 0.1s, color 0.1s, background 0.1s;
+}
+
+.digest-tag-chip:hover {
+ color: var(--text);
+ background: var(--surface-h);
+}
+
+.digest-tag-chip.active {
+ color: var(--text);
+ border-color: var(--tag-colour, var(--red));
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--tag-colour, var(--red)) 35%, transparent);
+}
+
+.digest-tag-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--tag-colour, var(--text-3));
+ flex-shrink: 0;
+}
+
+.digest-filter-clear {
+ padding: 2px 8px;
+ border: 1px dashed var(--border-2);
+ border-radius: 999px;
+ background: none;
+ color: var(--text-3);
+ font-size: 11px;
+ font-family: var(--f-mono);
+ cursor: pointer;
+}
+
+.digest-filter-clear:hover {
+ color: var(--text);
+ border-color: var(--border-2);
+}
+
+.digest-section {
+ margin-bottom: var(--s6);
+}
+
+.digest-section-head {
+ display: flex;
+ align-items: baseline;
+ gap: var(--s3);
+ margin-bottom: var(--s4);
+ padding-bottom: var(--s3);
+ border-bottom: 1px solid var(--border);
+}
+
+.digest-section-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text);
+ margin: 0;
+ letter-spacing: 0.02em;
+}
+
+.digest-section-count {
+ font-size: 11px;
+ color: var(--text-3);
+ font-family: var(--f-mono);
+}
+
+.digest-recent-section .digest-section-title {
+ color: var(--text-2);
+}
+
+.bp-card-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--s2);
+ margin-top: var(--s2);
+}
+
+.bp-card-tag {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 1px 6px;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ background: var(--bg);
+ color: var(--text-2);
+ font-size: 10px;
+ font-family: var(--f-mono);
+ cursor: pointer;
+}
+
+.bp-card-tag:hover {
+ color: var(--text);
+ border-color: var(--border-2);
+}
+
+.bp-card-tag.active {
+ border-color: var(--tag-colour, var(--red));
+}
+
+.cc-brief-digest-meta {
+ display: flex;
+ align-items: center;
+ gap: var(--s2);
+ margin-bottom: var(--s3);
+ font-size: 11px;
+ color: var(--text-3);
+ font-family: var(--f-mono);
+}
+
+.cc-brief-digest-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--s2);
+}
+
+.cc-brief-tag {
+ font-size: 10px;
+ padding: 1px 6px;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ color: var(--text-2);
+ font-family: var(--f-mono);
+}
diff --git a/frontend/src/test/BriefingPage.test.tsx b/frontend/src/test/BriefingPage.test.tsx
new file mode 100644
index 0000000..59ba5b9
--- /dev/null
+++ b/frontend/src/test/BriefingPage.test.tsx
@@ -0,0 +1,209 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { BriefingPage } from '../pages/BriefingPage'
+import type { ChampHubDriver, ChampHubTeam, ChampionshipHub, Meeting, NewsItem } from '../types'
+
+vi.mock('../api', () => ({
+ fetchNews: vi.fn(),
+ fetchNewsArticle: vi.fn(),
+ markNewsRead: vi.fn(),
+ fetchSeasons: vi.fn(),
+ fetchSeasonMeetings: vi.fn(),
+ fetchChampionshipHub: vi.fn(),
+}))
+
+import {
+ fetchNews,
+ fetchNewsArticle,
+ fetchSeasons,
+ fetchSeasonMeetings,
+ fetchChampionshipHub,
+ markNewsRead,
+} from '../api'
+
+const mockFetchNews = vi.mocked(fetchNews)
+const mockFetchNewsArticle = vi.mocked(fetchNewsArticle)
+const mockFetchSeasons = vi.mocked(fetchSeasons)
+const mockFetchSeasonMeetings = vi.mocked(fetchSeasonMeetings)
+const mockFetchHub = vi.mocked(fetchChampionshipHub)
+const mockMarkNewsRead = vi.mocked(markNewsRead)
+
+const bahrain: Meeting = {
+ meeting_key: 1,
+ meeting_name: 'Bahrain',
+ meeting_official_name: 'Bahrain GP',
+ location: 'Sakhir',
+ country_name: 'Bahrain',
+ country_code: 'BRN',
+ country_flag: '',
+ circuit_short_name: 'Sakhir',
+ date_start: '2025-03-14T00:00:00+00:00',
+ date_end: '2025-03-16T23:59:59+00:00',
+ year: 2025,
+}
+
+const monaco: Meeting = {
+ meeting_key: 2,
+ meeting_name: 'Monaco',
+ meeting_official_name: 'Monaco GP',
+ location: 'Monaco',
+ country_name: 'Monaco',
+ country_code: 'MON',
+ country_flag: '',
+ circuit_short_name: 'Monaco',
+ date_start: '2025-05-23T00:00:00+00:00',
+ date_end: '2025-05-25T23:59:59+00:00',
+ year: 2025,
+}
+
+const drivers: ChampHubDriver[] = [
+ {
+ driver_number: 1,
+ name_acronym: 'VER',
+ full_name: 'Max Verstappen',
+ team_name: 'Red Bull',
+ team_colour: '3671c6',
+ points: 100,
+ position: 1,
+ wins: 3,
+ podiums: 5,
+ poles: 2,
+ form: [25, 18, 25],
+ cumulative: [25, 43, 68, 100],
+ teammate_wins: 4,
+ teammate_losses: 1,
+ },
+ {
+ driver_number: 4,
+ name_acronym: 'NOR',
+ full_name: 'Lando Norris',
+ team_name: 'McLaren',
+ team_colour: 'ff8000',
+ points: 80,
+ position: 2,
+ wins: 1,
+ podiums: 4,
+ poles: 1,
+ form: [18, 25, 18],
+ cumulative: [18, 36, 54, 80],
+ teammate_wins: 3,
+ teammate_losses: 2,
+ },
+]
+
+const teams: ChampHubTeam[] = [
+ { team_name: 'Red Bull', team_colour: '3671c6', points: 150, position: 1, wins: 4 },
+ { team_name: 'McLaren', team_colour: 'ff8000', points: 120, position: 2, wins: 2 },
+]
+
+const hub: ChampionshipHub = {
+ season: 2025,
+ round: 2,
+ total_rounds: 2,
+ rounds_left: 0,
+ last_race: 'Monaco GP',
+ round_labels: ['R1', 'R2'],
+ drivers,
+ teams,
+}
+
+const newsItems: NewsItem[] = [
+ {
+ source: 'autosport-f1',
+ title: 'Verstappen sets the pace in Bahrain',
+ url: 'https://example.com/bahrain-ver',
+ published_at: '2025-03-15T10:00:00Z',
+ fetched_at: '2025-03-15T11:00:00Z',
+ summary: 'Red Bull dominate opening practice',
+ },
+ {
+ source: 'racefans-f1',
+ title: 'Norris targets Monaco upgrade',
+ url: 'https://example.com/monaco-nor',
+ published_at: '2025-04-20T10:00:00Z',
+ fetched_at: '2025-04-20T11:00:00Z',
+ summary: 'McLaren bring new floor',
+ },
+ {
+ source: 'bbc-f1',
+ title: 'Undated paddock rumour',
+ url: 'https://example.com/undated',
+ fetched_at: '2025-04-21T11:00:00Z',
+ },
+]
+
+function renderPage() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ })
+
+ return render(
+
+
+ ,
+ )
+}
+
+describe('BriefingPage digest layout', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.setSystemTime(new Date('2025-04-10T12:00:00Z'))
+ mockFetchSeasons.mockResolvedValue([2025])
+ mockFetchSeasonMeetings.mockResolvedValue([bahrain, monaco])
+ mockFetchHub.mockResolvedValue(hub)
+ mockFetchNews.mockResolvedValue(newsItems)
+ mockFetchNewsArticle.mockResolvedValue({ title: 'Article', content: '
Body
' })
+ mockMarkNewsRead.mockResolvedValue(undefined)
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('renders sticky since-last header, GP sections, and recent bucket', async () => {
+ renderPage()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('digest-sticky-header')).toHaveTextContent('Since Bahrain')
+ })
+
+ expect(screen.getByTestId('digest-window-2')).toHaveTextContent('Monaco')
+ expect(screen.getByTestId('digest-window-1')).toHaveTextContent('Bahrain')
+ expect(screen.getByTestId('digest-recent')).toHaveTextContent('Undated paddock rumour')
+ expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
+ expect(screen.getByText('Norris targets Monaco upgrade')).toBeInTheDocument()
+ })
+
+ it('filters the digest when a tag chip is clicked', async () => {
+ renderPage()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('digest-sticky-header')).toBeInTheDocument()
+ })
+
+ const sticky = screen.getByTestId('digest-sticky-header')
+ const norTag = within(sticky).getByRole('button', { name: /^NOR$/i })
+ fireEvent.click(norTag)
+
+ await waitFor(() => {
+ expect(screen.queryByText('Verstappen sets the pace in Bahrain')).not.toBeInTheDocument()
+ })
+ expect(screen.getByText('Norris targets Monaco upgrade')).toBeInTheDocument()
+ expect(screen.getByTestId('digest-filter-clear')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByTestId('digest-filter-clear'))
+ await waitFor(() => {
+ expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
+ })
+ })
+
+ it('preserves category tabs', async () => {
+ renderPage()
+
+ await waitFor(() => {
+ expect(screen.getByRole('tab', { name: /All/i })).toBeInTheDocument()
+ })
+ expect(screen.getByRole('tab', { name: /News/i })).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/test/digest.test.ts b/frontend/src/test/digest.test.ts
new file mode 100644
index 0000000..c052339
--- /dev/null
+++ b/frontend/src/test/digest.test.ts
@@ -0,0 +1,265 @@
+import { describe, it, expect } from 'vitest'
+import {
+ activeDigestWindow,
+ filterByTag,
+ groupByWindow,
+ gpWindows,
+ itemsForWindow,
+ sinceLastLabel,
+ sortWindowBucketsNewestFirst,
+ tagItems,
+ topTags,
+ windowForDate,
+} from '../lib/digest'
+import type { ChampHubDriver, ChampHubTeam, Meeting, NewsItem } from '../types'
+
+const meeting = (overrides: Partial
= {}): Meeting => ({
+ meeting_key: 1,
+ meeting_name: 'Bahrain',
+ meeting_official_name: 'Bahrain GP',
+ location: 'Sakhir',
+ country_name: 'Bahrain',
+ country_code: 'BRN',
+ country_flag: '',
+ circuit_short_name: 'Sakhir',
+ date_start: '2025-03-14T00:00:00+00:00',
+ date_end: '2025-03-16T23:59:59+00:00',
+ year: 2025,
+ ...overrides,
+})
+
+const news = (overrides: Partial = {}): NewsItem => ({
+ source: 'autosport-f1',
+ title: 'Headline',
+ url: `https://example.com/${Math.random()}`,
+ fetched_at: '2025-04-01T12:00:00Z',
+ ...overrides,
+})
+
+const driver = (over: Partial): ChampHubDriver => ({
+ driver_number: 1,
+ name_acronym: 'VER',
+ full_name: 'Max Verstappen',
+ team_name: 'Red Bull',
+ team_colour: '3671c6',
+ points: 100,
+ position: 1,
+ wins: 3,
+ podiums: 5,
+ poles: 2,
+ form: [25, 18, 25],
+ cumulative: [25, 43, 68, 100],
+ teammate_wins: 4,
+ teammate_losses: 1,
+ ...over,
+})
+
+const team = (over: Partial): ChampHubTeam => ({
+ team_name: 'Red Bull',
+ team_colour: '3671c6',
+ points: 150,
+ position: 1,
+ wins: 4,
+ ...over,
+})
+
+describe('gpWindows', () => {
+ const bahrain = meeting({ meeting_key: 1, meeting_name: 'Bahrain' })
+ const monaco = meeting({
+ meeting_key: 2,
+ meeting_name: 'Monaco',
+ date_start: '2025-05-23T00:00:00+00:00',
+ date_end: '2025-05-25T23:59:59+00:00',
+ })
+ const canada = meeting({
+ meeting_key: 3,
+ meeting_name: 'Canada',
+ date_start: '2025-06-13T00:00:00+00:00',
+ date_end: '2025-06-15T23:59:59+00:00',
+ })
+
+ it('derives inter-race windows mid-season', () => {
+ const now = new Date('2025-04-10T12:00:00Z')
+ const windows = gpWindows([bahrain, monaco, canada], now)
+
+ expect(windows).toHaveLength(3)
+ expect(windows[0].start).toBeNull()
+ expect(windows[0].end?.toISOString()).toBe(new Date('2025-05-23T00:00:00+00:00').toISOString())
+ expect(windows[1].start?.toISOString()).toBe(new Date('2025-03-16T23:59:59+00:00').toISOString())
+ expect(windows[1].end?.toISOString()).toBe(new Date('2025-06-13T00:00:00+00:00').toISOString())
+ expect(windows[2].start?.toISOString()).toBe(new Date('2025-05-25T23:59:59+00:00').toISOString())
+ expect(windows[2].end).toBeNull()
+ })
+
+ it('handles before the first race of the season', () => {
+ const now = new Date('2025-01-01T00:00:00Z')
+ const windows = gpWindows([bahrain, monaco], now)
+ expect(windows[0].start).toBeNull()
+ expect(activeDigestWindow(windows, [bahrain, monaco], now)?.meeting_name).toBe('Bahrain')
+ expect(sinceLastLabel([bahrain, monaco], now)).toBe('Before Bahrain')
+ })
+
+ it('handles after the final race of the season', () => {
+ const now = new Date('2025-12-01T00:00:00Z')
+ const windows = gpWindows([bahrain, monaco], now)
+ expect(activeDigestWindow(windows, [bahrain, monaco], now)?.meeting_name).toBe('Monaco')
+ expect(sinceLastLabel([bahrain, monaco], now)).toBe('Monaco')
+ expect(windows[1].end).toBeNull()
+ })
+})
+
+describe('groupByWindow', () => {
+ const bahrain = meeting({ meeting_key: 1 })
+ const monaco = meeting({
+ meeting_key: 2,
+ date_start: '2025-05-23T00:00:00+00:00',
+ date_end: '2025-05-25T23:59:59+00:00',
+ })
+ const windows = gpWindows([bahrain, monaco], new Date('2025-04-10T12:00:00Z'))
+
+ it('buckets dated items into the matching GP window', () => {
+ const items = [
+ news({ url: 'https://a', published_at: '2025-03-10T10:00:00Z', title: 'Pre-Bahrain' }),
+ news({ url: 'https://b', published_at: '2025-04-05T10:00:00Z', title: 'Between races' }),
+ news({ url: 'https://c', published_at: '2025-05-24T10:00:00Z', title: 'Monaco weekend' }),
+ ]
+
+ const grouped = groupByWindow(items, windows)
+ const bahrainItems = grouped.windows.find((entry) => entry.window.meeting_key === 1)?.items ?? []
+ const monacoItems = grouped.windows.find((entry) => entry.window.meeting_key === 2)?.items ?? []
+
+ expect(bahrainItems).toHaveLength(1)
+ expect(bahrainItems[0].title).toBe('Pre-Bahrain')
+ expect(monacoItems).toHaveLength(2)
+ expect(monacoItems.map((item) => item.title)).toEqual(['Between races', 'Monaco weekend'])
+ })
+
+ it('puts undated items in the recent bucket', () => {
+ const items = [
+ news({ url: 'https://u', title: 'Undated story' }),
+ news({ url: 'https://d', published_at: '2025-04-05T10:00:00Z' }),
+ ]
+ const grouped = groupByWindow(items, windows)
+ expect(grouped.recent).toHaveLength(1)
+ expect(grouped.recent[0].url).toBe('https://u')
+ })
+
+ it('sorts window buckets newest first for display', () => {
+ const items = [
+ news({ published_at: '2025-03-10T10:00:00Z' }),
+ news({ published_at: '2025-05-24T10:00:00Z' }),
+ ]
+ const grouped = groupByWindow(items, windows)
+ const sorted = sortWindowBucketsNewestFirst(grouped.windows)
+ expect(sorted[0].window.meeting_key).toBe(2)
+ expect(sorted[1].window.meeting_key).toBe(1)
+ })
+})
+
+describe('tagItems', () => {
+ const drivers = [
+ driver({ name_acronym: 'VER', full_name: 'Max Verstappen', team_name: 'Red Bull' }),
+ driver({
+ driver_number: 4,
+ name_acronym: 'NOR',
+ full_name: 'Lando Norris',
+ team_name: 'McLaren',
+ team_colour: 'ff8000',
+ }),
+ ]
+ const teams = [
+ team({ team_name: 'Red Bull' }),
+ team({ team_name: 'McLaren', team_colour: 'ff8000' }),
+ ]
+
+ it('tags drivers and teams case-insensitively', () => {
+ const [item] = tagItems(
+ [news({ title: 'ver leads mclaren in practice', summary: 'Norris close behind' })],
+ drivers,
+ teams,
+ )
+ const labels = item.tags.map((tag) => tag.label).sort()
+ expect(labels).toEqual(['McLaren', 'NOR', 'VER'])
+ })
+
+ it('returns no tags when nothing matches', () => {
+ const [item] = tagItems(
+ [news({ title: 'Generic paddock update' })],
+ drivers,
+ teams,
+ )
+ expect(item.tags).toEqual([])
+ })
+
+ it('prefers the longest overlapping match', () => {
+ const [item] = tagItems(
+ [news({ title: 'Max Verstappen extends championship lead' })],
+ drivers,
+ teams,
+ )
+ expect(item.tags).toHaveLength(1)
+ expect(item.tags[0].label).toBe('VER')
+ })
+
+ it('matches a driver last name inside prose', () => {
+ const [item] = tagItems(
+ [news({ title: 'Verstappen was untouchable in qualifying' })],
+ drivers,
+ teams,
+ )
+ expect(item.tags.some((tag) => tag.label === 'VER')).toBe(true)
+ })
+})
+
+describe('digest helpers', () => {
+ it('collects top tags by frequency', () => {
+ const tagged = tagItems(
+ [
+ news({ title: 'VER wins', url: 'https://a' }),
+ news({ title: 'VER dominates', url: 'https://b' }),
+ news({ title: 'Norris podium', url: 'https://c' }),
+ ],
+ [driver({}), driver({ driver_number: 4, name_acronym: 'NOR', full_name: 'Lando Norris', team_name: 'McLaren' })],
+ [team({}), team({ team_name: 'McLaren', team_colour: 'ff8000' })],
+ )
+ expect(topTags(tagged, 2).map((tag) => tag.label)).toEqual(['VER', 'NOR'])
+ })
+
+ it('filters items by tag key', () => {
+ const tagged = tagItems(
+ [
+ news({ title: 'VER wins', url: 'https://a' }),
+ news({ title: 'Neutral headline', url: 'https://b' }),
+ ],
+ [driver({})],
+ [team({})],
+ )
+ const filtered = filterByTag(tagged, 'driver:VER')
+ expect(filtered).toHaveLength(1)
+ expect(filtered[0].url).toBe('https://a')
+ })
+
+ it('selects items for the active digest window', () => {
+ const bahrain = meeting({ meeting_key: 1 })
+ const monaco = meeting({
+ meeting_key: 2,
+ date_start: '2025-05-23T00:00:00+00:00',
+ date_end: '2025-05-25T23:59:59+00:00',
+ })
+ const now = new Date('2025-04-10T12:00:00Z')
+ const windows = gpWindows([bahrain, monaco], now)
+ const active = activeDigestWindow(windows, [bahrain, monaco], now)
+ const tagged = tagItems(
+ [
+ news({ published_at: '2025-04-05T10:00:00Z', url: 'https://in' }),
+ news({ published_at: '2025-03-10T10:00:00Z', url: 'https://out' }),
+ ],
+ [],
+ [],
+ )
+ const inWindow = itemsForWindow(tagged, active)
+ expect(inWindow).toHaveLength(1)
+ expect(inWindow[0].url).toBe('https://in')
+ expect(windowForDate(windows, new Date('2025-04-05T10:00:00Z'))?.meeting_key).toBe(2)
+ })
+})
From c2617d39bbd589f66d3be5e51aeead87c0c67b42 Mon Sep 17 00:00:00 2001
From: AmanTahiliani
Date: Sat, 11 Jul 2026 18:17:15 -0400
Subject: [PATCH 6/7] feat: add replay track map scrubber
Spike: replay location fan-out is bounded with a semaphore at 4 concurrent GetLocation calls, covered by TestAssembleReplayFramesBoundsLocationFanOut.
Adds /api/v1/replay/frames with 5s interval floor, 3000-frame cap, nearest-sample snapping, and empty-driver omission. Wires RaceStoryCanvas to lazy-load replay frames plus cached track outlines when the map panel opens, sharing the existing scrubber/playback state across chart and map.
Tests: HOME=/private/tmp/box-box-home GOCACHE=/private/tmp/box-box-go-cache GOMODCACHE=/Users/aman/go/pkg/mod go test ./internal/web; go build ./... (same env, passed with read-only module stat-cache warning); npm run test; npx tsc --noEmit. Full go test ./... is blocked in this sandbox by existing network/listener-dependent internal/api and internal/news tests.
---
frontend/src/api.ts | 16 ++
frontend/src/components/RaceStoryCanvas.tsx | 157 ++++++++++--
frontend/src/components/ReplayTrackMap.tsx | 133 ++++++++++
frontend/src/lib/replay.ts | 75 ++++++
frontend/src/styles/replay-map.css | 161 ++++++++++++
frontend/src/test/RaceStoryCanvas.test.tsx | 135 ++++++++++
frontend/src/test/ReplayTrackMap.test.tsx | 56 +++++
frontend/src/test/replay.test.ts | 33 +++
frontend/src/types.ts | 17 ++
internal/web/replay.go | 259 ++++++++++++++++++++
internal/web/replay_test.go | 181 ++++++++++++++
internal/web/server.go | 1 +
12 files changed, 1209 insertions(+), 15 deletions(-)
create mode 100644 frontend/src/components/ReplayTrackMap.tsx
create mode 100644 frontend/src/lib/replay.ts
create mode 100644 frontend/src/styles/replay-map.css
create mode 100644 frontend/src/test/RaceStoryCanvas.test.tsx
create mode 100644 frontend/src/test/ReplayTrackMap.test.tsx
create mode 100644 frontend/src/test/replay.test.ts
create mode 100644 internal/web/replay.go
create mode 100644 internal/web/replay_test.go
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 7baae46..5b9d0ac 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -10,6 +10,7 @@ import type {
Meeting,
NewsItem,
RaceHub,
+ ReplayFramesResponse,
Session,
TrackOutline,
Weekend,
@@ -80,6 +81,21 @@ export async function fetchTrackOutline(circuitKey: number, year: number): Promi
return data as TrackOutline
}
+export async function fetchReplayFrames(
+ sessionKey: number,
+ intervalMs = 5000,
+): Promise {
+ const params = new URLSearchParams({
+ session_key: String(sessionKey),
+ interval_ms: String(intervalMs),
+ })
+ const res = await fetch(`/api/v1/replay/frames?${params}`)
+ if (!res.ok) {
+ throw new Error(`API ${res.status}: ${res.statusText}`)
+ }
+ return res.json()
+}
+
export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise {
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
if (!res.ok) {
diff --git a/frontend/src/components/RaceStoryCanvas.tsx b/frontend/src/components/RaceStoryCanvas.tsx
index 229ff49..4380b19 100644
--- a/frontend/src/components/RaceStoryCanvas.tsx
+++ b/frontend/src/components/RaceStoryCanvas.tsx
@@ -1,5 +1,8 @@
-import { useState, useMemo, useRef, useCallback } from 'react'
-import type { EnrichedResult, EnrichedGrid, PositionSample, Lap, Session } from '../types'
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session } from '../types'
+import { fetchReplayFrames, fetchTrackOutline } from '../api'
+import { ReplayTrackMap } from './ReplayTrackMap'
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
interface Props {
@@ -12,28 +15,97 @@ interface Props {
race_control?: any[]
pit_stops?: any[]
session?: Session
+ meeting?: Meeting
+ drivers?: Driver[]
}
}
export function RaceStoryCanvas({ data }: Props) {
- const { results, starting_grid: grid, positions, datasets, race_control = [], pit_stops = [], laps = [] } = data
+ const {
+ results,
+ starting_grid: grid,
+ positions,
+ datasets,
+ race_control = [],
+ pit_stops = [],
+ laps = [],
+ session,
+ meeting,
+ drivers = [],
+ } = data
const hasPositions = datasets['positions']?.status === 'available'
const [scrubTime, setScrubTime] = useState(null)
const [hoverDriver, setHoverDriver] = useState(null)
+ const [mapOpen, setMapOpen] = useState(false)
+ const [isPlaying, setIsPlaying] = useState(false)
+ const [playbackSpeed, setPlaybackSpeed] = useState(10)
const svgRef = useRef(null)
// Position Evolution Chart Logic
const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions])
const hasChartData = hasPositions && allTimes.length > 0
+ const chartTiming = useMemo(() => {
+ if (!hasChartData) return null
+ const tMin = new Date(allTimes[0]).getTime()
+ const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
+ return { tMin, tMax, tRange: Math.max(tMax - tMin, 1) }
+ }, [allTimes, hasChartData])
+ const circuitKey = session?.circuit_key ?? meeting?.circuit_key ?? 0
+ const outlineYear = meeting?.year ?? (session?.date_start ? new Date(session.date_start).getFullYear() : 0)
+
+ const replayQuery = useQuery({
+ queryKey: ['replay-frames', session?.session_key, 5000],
+ queryFn: () => fetchReplayFrames(session!.session_key, 5000),
+ enabled: mapOpen && Boolean(session?.session_key),
+ })
+ const outlineQuery = useQuery({
+ queryKey: ['track-outline', circuitKey, outlineYear],
+ queryFn: () => fetchTrackOutline(circuitKey, outlineYear),
+ enabled: mapOpen && circuitKey > 0 && outlineYear > 0,
+ })
+
+ useEffect(() => {
+ if (!isPlaying || !chartTiming) return
+
+ let frame = 0
+ let last = performance.now()
+ const tick = (now: number) => {
+ const delta = now - last
+ last = now
+ setScrubTime((current) => {
+ const next = Math.min(1, (current ?? 0) + (delta * playbackSpeed) / chartTiming.tRange)
+ if (next >= 1) {
+ setIsPlaying(false)
+ }
+ return next
+ })
+ frame = requestAnimationFrame(tick)
+ }
+
+ frame = requestAnimationFrame(tick)
+ return () => cancelAnimationFrame(frame)
+ }, [chartTiming, isPlaying, playbackSpeed])
+
+ const replayTMs = useMemo(() => {
+ const replay = replayQuery.data
+ const frames = replay?.frames ?? []
+ if (frames.length === 0) return 0
+ const lastFrameT = frames[frames.length - 1].t
+ const progress = scrubTime ?? 0
+ if (!chartTiming || !replay?.start_time) {
+ return Math.max(0, Math.min(lastFrameT, Math.round(progress * lastFrameT)))
+ }
+ const replayStart = new Date(replay.start_time).getTime()
+ const chartTime = chartTiming.tMin + progress * chartTiming.tRange
+ return Math.max(0, Math.min(lastFrameT, Math.round(chartTime - replayStart)))
+ }, [chartTiming, replayQuery.data, scrubTime])
let chartContent = null
let displayResults = results
- if (hasChartData) {
- const tMin = new Date(allTimes[0]).getTime()
- const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
- const tRange = Math.max(tMax - tMin, 1)
+ if (hasChartData && chartTiming) {
+ const { tMin, tRange } = chartTiming
const byDriver = new Map>()
for (const p of positions) {
@@ -137,6 +209,7 @@ export function RaceStoryCanvas({ data }: Props) {
}
const handlePointerMove = (e: React.PointerEvent) => {
+ setIsPlaying(false)
if (!svgRef.current) return
const rect = svgRef.current.getBoundingClientRect()
const x = e.clientX - rect.left
@@ -288,24 +361,78 @@ export function RaceStoryCanvas({ data }: Props) {
height={plotH}
fill="transparent"
onPointerMove={handlePointerMove}
- onPointerLeave={() => setScrubTime(null)}
+ onPointerLeave={() => {
+ if (!isPlaying) setScrubTime(null)
+ }}
style={{ cursor: 'crosshair', touchAction: 'none' }}
/>
+
+
{
+ setScrubTime((current) => current ?? 0)
+ setIsPlaying((current) => !current)
+ }}
+ >
+ {isPlaying ? 'Pause' : 'Play'}
+
+
+ {[1, 10, 30].map((speed) => (
+ setPlaybackSpeed(speed)}
+ >
+ {speed}x
+
+ ))}
+
+
setMapOpen((current) => !current)}
+ >
+ Map
+
+
)
}
return (
- {hasChartData ? (
- chartContent
- ) : (
-
-
Lap-by-lap positions not available. This session does not
- have ingested position samples in
/api/v1/race-hub.
+
+
+ {hasChartData ? (
+ chartContent
+ ) : (
+
+ Lap-by-lap positions not available. This session does not
+ have ingested position samples in /api/v1/race-hub.
+
+ )}
- )}
+ {mapOpen && (
+
+ {circuitKey > 0 && outlineYear > 0 ? (
+
+ ) : (
+
track identity unavailable for replay map
+ )}
+
+ )}
+
{displayResults.length > 0 && (
diff --git a/frontend/src/components/ReplayTrackMap.tsx b/frontend/src/components/ReplayTrackMap.tsx
new file mode 100644
index 0000000..d3ec274
--- /dev/null
+++ b/frontend/src/components/ReplayTrackMap.tsx
@@ -0,0 +1,133 @@
+import { useMemo, useState } from 'react'
+import type { Driver, EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types'
+import { buildOutlinePath } from '../lib/trackmap'
+import { interpolateReplayCars, replayCarToSvg } from '../lib/replay'
+import '../styles/replay-map.css'
+
+interface Props {
+ outline?: TrackOutline | null
+ replay?: ReplayFramesResponse | null
+ tMs: number
+ drivers: Driver[]
+ results: EnrichedResult[]
+ loading?: boolean
+ error?: boolean
+}
+
+export function ReplayTrackMap({
+ outline,
+ replay,
+ tMs,
+ drivers,
+ results,
+ loading = false,
+ error = false,
+}: Props) {
+ const [pinned, setPinned] = useState
(null)
+ const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
+ const driverInfo = useMemo(() => {
+ const info = new Map()
+ for (const driver of drivers) {
+ info.set(String(driver.driver_number), {
+ label: driver.name_acronym || String(driver.driver_number),
+ color: normalizeColor(driver.team_colour),
+ })
+ }
+ for (const result of results) {
+ const key = String(result.driver_number)
+ if (!info.has(key)) {
+ info.set(key, {
+ label: result.name_acronym || key,
+ color: normalizeColor(result.team_colour),
+ })
+ }
+ }
+ return info
+ }, [drivers, results])
+
+ const cars = useMemo(() => {
+ if (!outline?.bounds || !replay?.frames?.length) return []
+ const positions = interpolateReplayCars(replay.frames, tMs)
+ return Object.entries(positions)
+ .map(([number, car]) => ({
+ number,
+ svg: replayCarToSvg(car, outline.bounds),
+ info: driverInfo.get(number),
+ }))
+ .sort((a, b) => Number(a.number) - Number(b.number))
+ }, [driverInfo, outline?.bounds, replay?.frames, tMs])
+
+ if (loading) {
+ return (
+
+ loading replay GPS...
+
+ )
+ }
+
+ if (error) {
+ return (
+
+ replay GPS unavailable for this session
+
+ )
+ }
+
+ if (!outline || !outlinePath) {
+ return (
+
+ track outline unavailable for this session
+
+ )
+ }
+
+ if (!replay?.frames?.length || cars.length === 0) {
+ return (
+
+ historical GPS unavailable for this session
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ {cars.map((car) => {
+ const label = car.info?.label ?? car.number
+ const selected = pinned === car.number
+ return (
+ setPinned(selected ? null : car.number)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ setPinned(selected ? null : car.number)
+ }
+ }}
+ >
+ {label}
+
+ {label}
+
+ )
+ })}
+
+
+
+ )
+}
+
+function normalizeColor(color: string | undefined): string {
+ const raw = color?.trim()
+ if (!raw) return '#777777'
+ return raw.startsWith('#') ? raw : `#${raw}`
+}
diff --git a/frontend/src/lib/replay.ts b/frontend/src/lib/replay.ts
new file mode 100644
index 0000000..5699016
--- /dev/null
+++ b/frontend/src/lib/replay.ts
@@ -0,0 +1,75 @@
+import type { ReplayCarPosition, ReplayFrame, TrackBounds } from '../types'
+import { canvasToSvg, normalizeRawPoint } from './trackmap'
+
+export interface ReplayFramePair {
+ previous: ReplayFrame | null
+ next: ReplayFrame | null
+}
+
+export function lookupReplayFramePair(
+ frames: ReadonlyArray,
+ tMs: number,
+): ReplayFramePair {
+ if (frames.length === 0) return { previous: null, next: null }
+ if (tMs <= frames[0].t) return { previous: frames[0], next: frames[0] }
+ const last = frames[frames.length - 1]
+ if (tMs >= last.t) return { previous: last, next: last }
+
+ let lo = 0
+ let hi = frames.length - 1
+ while (lo <= hi) {
+ const mid = Math.floor((lo + hi) / 2)
+ const frame = frames[mid]
+ if (frame.t === tMs) return { previous: frame, next: frame }
+ if (frame.t < tMs) {
+ lo = mid + 1
+ } else {
+ hi = mid - 1
+ }
+ }
+
+ return { previous: frames[Math.max(0, hi)], next: frames[Math.min(frames.length - 1, lo)] }
+}
+
+export function interpolateReplayCars(
+ frames: ReadonlyArray,
+ tMs: number,
+): Record {
+ const { previous, next } = lookupReplayFramePair(frames, tMs)
+ if (!previous && !next) return {}
+ if (!previous) return next?.cars ?? {}
+ if (!next || previous.t === next.t) return previous.cars
+
+ const span = next.t - previous.t
+ const fraction = span <= 0 ? 0 : clamp01((tMs - previous.t) / span)
+ const cars: Record = {}
+ const numbers = new Set([...Object.keys(previous.cars), ...Object.keys(next.cars)])
+
+ for (const number of numbers) {
+ const before = previous.cars[number]
+ const after = next.cars[number]
+ if (before && after) {
+ cars[number] = {
+ x: before.x + (after.x - before.x) * fraction,
+ y: before.y + (after.y - before.y) * fraction,
+ }
+ } else if (before) {
+ cars[number] = before
+ } else if (after) {
+ cars[number] = after
+ }
+ }
+ return cars
+}
+
+export function replayCarToSvg(
+ car: ReplayCarPosition,
+ bounds: TrackBounds,
+): ReplayCarPosition {
+ return canvasToSvg(normalizeRawPoint({ x: car.x, y: car.y }, bounds))
+}
+
+function clamp01(value: number): number {
+ if (!Number.isFinite(value)) return 0
+ return Math.min(1, Math.max(0, value))
+}
diff --git a/frontend/src/styles/replay-map.css b/frontend/src/styles/replay-map.css
new file mode 100644
index 0000000..3915e6b
--- /dev/null
+++ b/frontend/src/styles/replay-map.css
@@ -0,0 +1,161 @@
+.rs-replay-shell {
+ display: grid;
+ grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.8fr);
+ gap: var(--s5);
+ align-items: stretch;
+}
+
+.rs-replay-main {
+ min-width: 0;
+}
+
+.rs-replay-tools {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--s3);
+ margin-top: var(--s3);
+}
+
+.rs-tool-btn,
+.rs-speed-btn {
+ height: 28px;
+ padding: 0 var(--s4);
+ font-family: var(--f-mono);
+ font-size: 10px;
+ font-weight: 700;
+ color: var(--text-2);
+ background: var(--surface);
+ border: 1px solid var(--border-2);
+ border-radius: 2px;
+ cursor: pointer;
+}
+
+.rs-tool-btn:hover,
+.rs-speed-btn:hover {
+ color: var(--text);
+ border-color: var(--text-3);
+}
+
+.rs-tool-btn.active,
+.rs-speed-btn.active {
+ color: var(--text);
+ border-color: var(--red);
+ background: rgba(230, 36, 41, 0.12);
+}
+
+.rs-speed-group {
+ display: inline-flex;
+ gap: 2px;
+}
+
+.rs-replay-map-slot {
+ min-width: 0;
+}
+
+.rs-replay-map-placeholder {
+ min-height: 244px;
+ display: grid;
+ place-items: center;
+ padding: var(--s5);
+ color: var(--text-3);
+ font-family: var(--f-mono);
+ font-size: 11px;
+ text-align: center;
+ border: 1px dashed var(--border-2);
+ background: var(--surface);
+}
+
+.replay-map-panel {
+ min-height: 244px;
+ height: 100%;
+ border: 1px solid var(--border);
+ background: var(--surface);
+}
+
+.replay-map-stage {
+ position: relative;
+ min-height: 244px;
+ height: 100%;
+}
+
+.replay-map-svg {
+ width: 100%;
+ height: 100%;
+ min-height: 244px;
+ display: block;
+ background: radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.04), transparent 62%);
+}
+
+.replay-map-outline-shadow,
+.replay-map-outline {
+ fill: none;
+ vector-effect: non-scaling-stroke;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.replay-map-outline-shadow {
+ stroke: rgba(255, 255, 255, 0.08);
+ stroke-width: 5;
+}
+
+.replay-map-outline {
+ stroke: var(--text-2);
+ stroke-width: 1.8;
+}
+
+.replay-car {
+ cursor: pointer;
+ outline: none;
+}
+
+.replay-car circle {
+ stroke: var(--bg);
+ stroke-width: 0.8;
+ filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.55));
+}
+
+.replay-car text {
+ fill: #fff;
+ font-family: var(--f-mono);
+ font-size: 2.6px;
+ font-weight: 800;
+ text-anchor: middle;
+ pointer-events: none;
+ paint-order: stroke;
+ stroke: rgba(0, 0, 0, 0.75);
+ stroke-width: 0.7;
+}
+
+.replay-car:not(.replay-car-pinned) text {
+ opacity: 0;
+}
+
+.replay-car:hover text,
+.replay-car:focus-visible text,
+.replay-car-pinned text {
+ opacity: 1;
+}
+
+.replay-car-pinned circle {
+ stroke: #fff;
+ stroke-width: 1.2;
+}
+
+.replay-map-empty {
+ 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;
+}
+
+@media (max-width: 860px) {
+ .rs-replay-shell {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/frontend/src/test/RaceStoryCanvas.test.tsx b/frontend/src/test/RaceStoryCanvas.test.tsx
new file mode 100644
index 0000000..161cf56
--- /dev/null
+++ b/frontend/src/test/RaceStoryCanvas.test.tsx
@@ -0,0 +1,135 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
+import type { RaceHub, ReplayFramesResponse, TrackOutline } from '../types'
+
+vi.mock('../api', () => ({
+ fetchReplayFrames: vi.fn(),
+ fetchTrackOutline: vi.fn(),
+}))
+
+import { fetchReplayFrames, fetchTrackOutline } from '../api'
+
+const mockFetchReplayFrames = vi.mocked(fetchReplayFrames)
+const mockFetchTrackOutline = vi.mocked(fetchTrackOutline)
+
+const outline: TrackOutline = {
+ circuit_key: 1,
+ bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
+ points: [
+ { x: 0, y: 0 },
+ { x: 1, y: 0 },
+ { x: 1, y: 1 },
+ ],
+}
+
+const replay: ReplayFramesResponse = {
+ session_key: 99,
+ interval_ms: 5000,
+ start_time: '2025-05-25T13:00:00Z',
+ frames: [{ t: 0, cars: { '1': { x: 10, y: 20 } } }],
+}
+
+const raceHub: RaceHub = {
+ source: 'local',
+ session_key: 99,
+ datasets: {
+ positions: { status: 'available', source: 'local', count: 2 },
+ },
+ meeting: {
+ meeting_key: 1,
+ meeting_name: 'Monaco Grand Prix',
+ meeting_official_name: 'FORMULA 1 GRAND PRIX DE MONACO 2025',
+ location: 'Monaco',
+ country_name: 'Monaco',
+ country_code: 'MON',
+ country_flag: '',
+ circuit_key: 1,
+ circuit_short_name: 'Monaco',
+ date_start: '2025-05-23T00:00:00Z',
+ date_end: '2025-05-25T00:00:00Z',
+ year: 2025,
+ },
+ session: {
+ session_key: 99,
+ session_name: 'Race',
+ session_type: 'Race',
+ circuit_key: 1,
+ meeting_key: 1,
+ date_start: '2025-05-25T13:00:00Z',
+ date_end: '2025-05-25T15:00:00Z',
+ gmt_offset: '02:00:00',
+ },
+ drivers: [],
+ results: [
+ {
+ driver_number: 1,
+ position: 1,
+ name_acronym: 'VER',
+ full_name: 'Max Verstappen',
+ team_name: 'Red Bull Racing',
+ team_colour: '3671C6',
+ dnf: false,
+ dns: false,
+ dsq: false,
+ duration: null,
+ gap_to_leader: null,
+ number_of_laps: 78,
+ points: 25,
+ session_key: 99,
+ meeting_key: 1,
+ },
+ ],
+ starting_grid: [
+ {
+ driver_number: 1,
+ position: 1,
+ name_acronym: 'VER',
+ full_name: 'Max Verstappen',
+ team_name: 'Red Bull Racing',
+ team_colour: '3671C6',
+ session_key: 99,
+ meeting_key: 1,
+ lap_duration: null,
+ },
+ ],
+ stints: [],
+ pit_stops: [],
+ positions: [
+ { session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:00:00Z', position: 1 },
+ { session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:05:00Z', position: 1 },
+ ],
+ race_control: [],
+ weather: [],
+ laps: [],
+ chapters: [],
+}
+
+function renderCanvas() {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ return render(
+
+
+ ,
+ )
+}
+
+describe('RaceStoryCanvas replay map', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockFetchReplayFrames.mockResolvedValue(replay)
+ mockFetchTrackOutline.mockResolvedValue(outline)
+ })
+
+ it('fetches replay frames lazily when the map panel opens', async () => {
+ renderCanvas()
+
+ expect(mockFetchReplayFrames).not.toHaveBeenCalled()
+ fireEvent.click(screen.getByRole('button', { name: 'Map' }))
+
+ await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalledWith(99, 5000))
+ expect(mockFetchTrackOutline).toHaveBeenCalledWith(1, 2025)
+ expect(await screen.findByTestId('replay-track-map')).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/test/ReplayTrackMap.test.tsx b/frontend/src/test/ReplayTrackMap.test.tsx
new file mode 100644
index 0000000..2aa46bf
--- /dev/null
+++ b/frontend/src/test/ReplayTrackMap.test.tsx
@@ -0,0 +1,56 @@
+import { describe, expect, it } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { ReplayTrackMap } from '../components/ReplayTrackMap'
+import type { EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types'
+
+const outline: TrackOutline = {
+ circuit_key: 1,
+ bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
+ points: [
+ { x: 0, y: 0 },
+ { x: 1, y: 0 },
+ { x: 1, y: 1 },
+ ],
+}
+
+const replay: ReplayFramesResponse = {
+ session_key: 99,
+ interval_ms: 5000,
+ start_time: '2025-05-25T13:00:00Z',
+ frames: [
+ { t: 0, cars: { '1': { x: 0, y: 0 } } },
+ { t: 5000, cars: { '1': { x: 50, y: 50 } } },
+ ],
+}
+
+const results: EnrichedResult[] = [
+ {
+ driver_number: 1,
+ position: 1,
+ name_acronym: 'VER',
+ full_name: 'Max Verstappen',
+ team_name: 'Red Bull Racing',
+ team_colour: '3671C6',
+ dnf: false,
+ dns: false,
+ dsq: false,
+ duration: null,
+ gap_to_leader: null,
+ number_of_laps: 78,
+ points: 25,
+ session_key: 99,
+ meeting_key: 1,
+ },
+]
+
+describe('ReplayTrackMap', () => {
+ it('renders an empty state when replay frames are missing', () => {
+ render( )
+ expect(screen.getByTestId('replay-track-map')).toHaveTextContent(/historical GPS unavailable/i)
+ })
+
+ it('renders car labels from result metadata', () => {
+ render( )
+ expect(screen.getByLabelText(/VER replay position/i)).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/test/replay.test.ts b/frontend/src/test/replay.test.ts
new file mode 100644
index 0000000..53d9bcc
--- /dev/null
+++ b/frontend/src/test/replay.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest'
+import type { ReplayFrame, TrackBounds } from '../types'
+import { interpolateReplayCars, lookupReplayFramePair, replayCarToSvg } from '../lib/replay'
+
+const frames: ReplayFrame[] = [
+ { t: 0, cars: { '1': { x: 0, y: 0 } } },
+ { t: 5000, cars: { '1': { x: 10, y: 20 }, '4': { x: 40, y: 80 } } },
+ { t: 10000, cars: { '1': { x: 20, y: 40 } } },
+]
+
+describe('replay helpers', () => {
+ it('looks up boundary frame pairs', () => {
+ expect(lookupReplayFramePair(frames, -1)).toEqual({ previous: frames[0], next: frames[0] })
+ expect(lookupReplayFramePair(frames, 10000)).toEqual({ previous: frames[2], next: frames[2] })
+ expect(lookupReplayFramePair([], 1000)).toEqual({ previous: null, next: null })
+ })
+
+ it('interpolates cars between frames and keeps one-sided samples visible', () => {
+ const cars = interpolateReplayCars(frames, 2500)
+ expect(cars['1']).toEqual({ x: 5, y: 10 })
+ expect(cars['4']).toEqual({ x: 40, y: 80 })
+ })
+
+ it('clamps interpolation outside the replay range', () => {
+ expect(interpolateReplayCars(frames, -500)['1']).toEqual({ x: 0, y: 0 })
+ expect(interpolateReplayCars(frames, 12000)['1']).toEqual({ x: 20, y: 40 })
+ })
+
+ it('maps replay GPS through the shared track-map coordinate transform', () => {
+ const bounds: TrackBounds = { minX: 0, maxX: 100, minY: 0, maxY: 200 }
+ expect(replayCarToSvg({ x: 50, y: 50 }, bounds)).toEqual({ x: 50, y: 75 })
+ })
+})
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 81d9a56..fd60ba2 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -360,6 +360,23 @@ export interface TrackOutline {
bounds: TrackBounds
}
+export interface ReplayCarPosition {
+ x: number
+ y: number
+}
+
+export interface ReplayFrame {
+ t: number
+ cars: Record
+}
+
+export interface ReplayFramesResponse {
+ session_key: number
+ interval_ms: number
+ start_time: string
+ frames: ReplayFrame[]
+}
+
export interface ChampHubDriver {
driver_number: number
name_acronym: string
diff --git a/internal/web/replay.go b/internal/web/replay.go
new file mode 100644
index 0000000..9caa7bc
--- /dev/null
+++ b/internal/web/replay.go
@@ -0,0 +1,259 @@
+package web
+
+import (
+ "context"
+ "errors"
+ "math"
+ "net/http"
+ "sort"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/AmanTahiliani/box-box/internal/models"
+)
+
+const (
+ defaultReplayIntervalMS = 5000
+ maxReplayFrames = 3000
+ replayFetchConcurrency = 4
+)
+
+type replayDataClient interface {
+ GetDriversForSession(sessionKey int) ([]models.Driver, error)
+ GetLocation(sessionKey, driverNumber int) ([]models.Location, error)
+}
+
+type replayFramesResponse struct {
+ SessionKey int `json:"session_key"`
+ Interval int `json:"interval_ms"`
+ StartTime string `json:"start_time"`
+ Frames []replayFrame `json:"frames"`
+}
+
+type replayFrame struct {
+ T int64 `json:"t"`
+ Cars map[string]replayCar `json:"cars"`
+}
+
+type replayCar struct {
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+}
+
+func (s *Server) handleReplayFrames(w http.ResponseWriter, r *http.Request) {
+ sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key"))
+ if err != nil || sessionKey == 0 {
+ http.Error(w, "session_key required", http.StatusBadRequest)
+ return
+ }
+
+ intervalMS := defaultReplayIntervalMS
+ rawInterval := r.URL.Query().Get("interval_ms")
+ if rawInterval != "" {
+ parsed, err := strconv.Atoi(rawInterval)
+ if err != nil {
+ http.Error(w, "invalid interval_ms", http.StatusBadRequest)
+ return
+ }
+ if parsed > intervalMS {
+ intervalMS = parsed
+ }
+ }
+
+ resp, err := assembleReplayFrames(r.Context(), s.client, sessionKey, intervalMS)
+ if err != nil {
+ writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ return
+ }
+ writeJSON(w, resp)
+}
+
+func assembleReplayFrames(ctx context.Context, client replayDataClient, sessionKey, intervalMS int) (replayFramesResponse, error) {
+ if intervalMS < defaultReplayIntervalMS {
+ intervalMS = defaultReplayIntervalMS
+ }
+
+ resp := replayFramesResponse{
+ SessionKey: sessionKey,
+ Interval: intervalMS,
+ Frames: []replayFrame{},
+ }
+
+ drivers, err := client.GetDriversForSession(sessionKey)
+ if err != nil {
+ return resp, err
+ }
+
+ driverNumbers := uniqueDriverNumbers(drivers)
+ if len(driverNumbers) == 0 {
+ return resp, nil
+ }
+
+ series, err := fetchReplayLocationSeries(ctx, client, sessionKey, driverNumbers)
+ if err != nil && len(series) == 0 {
+ return resp, err
+ }
+
+ start, ok := earliestReplayLocationTime(series)
+ if !ok {
+ return resp, nil
+ }
+ resp.StartTime = start.Format(time.RFC3339Nano)
+ resp.Frames = snapReplayFrames(series, start, intervalMS)
+ return resp, nil
+}
+
+func uniqueDriverNumbers(drivers []models.Driver) []int {
+ seen := make(map[int]bool, len(drivers))
+ numbers := make([]int, 0, len(drivers))
+ for _, driver := range drivers {
+ if driver.DriverNumber <= 0 || seen[driver.DriverNumber] {
+ continue
+ }
+ seen[driver.DriverNumber] = true
+ numbers = append(numbers, driver.DriverNumber)
+ }
+ sort.Ints(numbers)
+ return numbers
+}
+
+func fetchReplayLocationSeries(ctx context.Context, client replayDataClient, sessionKey int, driverNumbers []int) (map[int][]models.Location, error) {
+ type result struct {
+ driver int
+ locs []models.Location
+ err error
+ }
+
+ sem := make(chan struct{}, replayFetchConcurrency)
+ results := make(chan result, len(driverNumbers))
+ var wg sync.WaitGroup
+
+ for _, driverNumber := range driverNumbers {
+ driverNumber := driverNumber
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ select {
+ case sem <- struct{}{}:
+ defer func() { <-sem }()
+ case <-ctx.Done():
+ results <- result{driver: driverNumber, err: ctx.Err()}
+ return
+ }
+
+ locs, err := client.GetLocation(sessionKey, driverNumber)
+ results <- result{driver: driverNumber, locs: locs, err: err}
+ }()
+ }
+
+ wg.Wait()
+ close(results)
+
+ series := make(map[int][]models.Location, len(driverNumbers))
+ var joined error
+ for result := range results {
+ if result.err != nil {
+ joined = errors.Join(joined, result.err)
+ continue
+ }
+ if len(result.locs) > 0 {
+ series[result.driver] = result.locs
+ }
+ }
+ return series, joined
+}
+
+func earliestReplayLocationTime(series map[int][]models.Location) (time.Time, bool) {
+ var start time.Time
+ for _, locs := range series {
+ for _, loc := range locs {
+ t, err := time.Parse(time.RFC3339Nano, loc.Date)
+ if err != nil {
+ continue
+ }
+ if start.IsZero() || t.Before(start) {
+ start = t
+ }
+ }
+ }
+ if start.IsZero() {
+ return time.Time{}, false
+ }
+ return start, true
+}
+
+func snapReplayFrames(series map[int][]models.Location, start time.Time, intervalMS int) []replayFrame {
+ type accumulator struct {
+ t int64
+ cars map[string]replayCar
+ nearest map[string]int64
+ }
+
+ interval := int64(intervalMS)
+ framesByIndex := make(map[int]*accumulator)
+
+ for driverNumber, locs := range series {
+ driverKey := strconv.Itoa(driverNumber)
+ for _, loc := range locs {
+ if !isFiniteFloat(loc.X) || !isFiniteFloat(loc.Y) {
+ continue
+ }
+ t, err := time.Parse(time.RFC3339Nano, loc.Date)
+ if err != nil {
+ continue
+ }
+ offset := t.Sub(start).Milliseconds()
+ if offset < 0 {
+ continue
+ }
+ index := int((offset + interval/2) / interval)
+ if index < 0 || index >= maxReplayFrames {
+ continue
+ }
+ frameT := int64(index) * interval
+ distance := absInt64(offset - frameT)
+
+ acc, ok := framesByIndex[index]
+ if !ok {
+ acc = &accumulator{
+ t: frameT,
+ cars: make(map[string]replayCar),
+ nearest: make(map[string]int64),
+ }
+ framesByIndex[index] = acc
+ }
+ if prev, ok := acc.nearest[driverKey]; ok && prev <= distance {
+ continue
+ }
+ acc.nearest[driverKey] = distance
+ acc.cars[driverKey] = replayCar{X: loc.X, Y: loc.Y}
+ }
+ }
+
+ indexes := make([]int, 0, len(framesByIndex))
+ for index, acc := range framesByIndex {
+ if len(acc.cars) > 0 {
+ indexes = append(indexes, index)
+ }
+ }
+ sort.Ints(indexes)
+
+ frames := make([]replayFrame, 0, len(indexes))
+ for _, index := range indexes {
+ acc := framesByIndex[index]
+ frames = append(frames, replayFrame{T: acc.t, Cars: acc.cars})
+ }
+ return frames
+}
+
+func absInt64(v int64) int64 {
+ if v < 0 {
+ return -v
+ }
+ return v
+}
+
+func isFiniteFloat(v float64) bool {
+ return !math.IsNaN(v) && !math.IsInf(v, 0)
+}
diff --git a/internal/web/replay_test.go b/internal/web/replay_test.go
new file mode 100644
index 0000000..25e7437
--- /dev/null
+++ b/internal/web/replay_test.go
@@ -0,0 +1,181 @@
+package web
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/AmanTahiliani/box-box/internal/models"
+)
+
+type fakeReplayClient struct {
+ drivers []models.Driver
+ locs map[int][]models.Location
+ err error
+
+ mu sync.Mutex
+ inFlight int
+ maxInFlight int
+ delay time.Duration
+}
+
+func (f *fakeReplayClient) GetDriversForSession(sessionKey int) ([]models.Driver, error) {
+ if f.err != nil {
+ return nil, f.err
+ }
+ return f.drivers, nil
+}
+
+func (f *fakeReplayClient) GetLocation(sessionKey, driverNumber int) ([]models.Location, error) {
+ f.mu.Lock()
+ f.inFlight++
+ if f.inFlight > f.maxInFlight {
+ f.maxInFlight = f.inFlight
+ }
+ f.mu.Unlock()
+
+ if f.delay > 0 {
+ time.Sleep(f.delay)
+ }
+
+ f.mu.Lock()
+ f.inFlight--
+ f.mu.Unlock()
+
+ return f.locs[driverNumber], nil
+}
+
+func TestAssembleReplayFramesSnapsNearestSamplesAndOmitsEmptyDrivers(t *testing.T) {
+ start := time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC)
+ client := &fakeReplayClient{
+ drivers: []models.Driver{
+ {DriverNumber: 1},
+ {DriverNumber: 4},
+ {DriverNumber: 16},
+ },
+ locs: map[int][]models.Location{
+ 1: {
+ {Date: start.Add(1 * time.Second).Format(time.RFC3339Nano), X: 10, Y: 20},
+ {Date: start.Add(4 * time.Second).Format(time.RFC3339Nano), X: 40, Y: 80},
+ {Date: start.Add(6 * time.Second).Format(time.RFC3339Nano), X: 60, Y: 120},
+ },
+ 4: {
+ {Date: start.Add(5 * time.Second).Format(time.RFC3339Nano), X: 100, Y: 200},
+ },
+ 16: {},
+ },
+ }
+
+ resp, err := assembleReplayFrames(context.Background(), client, 99, 5000)
+ if err != nil {
+ t.Fatalf("assembleReplayFrames() error = %v", err)
+ }
+ if resp.SessionKey != 99 || resp.Interval != 5000 {
+ t.Fatalf("response metadata = %+v", resp)
+ }
+ if resp.StartTime != start.Add(1*time.Second).Format(time.RFC3339Nano) {
+ t.Fatalf("start_time = %q", resp.StartTime)
+ }
+ if len(resp.Frames) != 2 {
+ t.Fatalf("frames len = %d, want 2: %+v", len(resp.Frames), resp.Frames)
+ }
+ if _, ok := resp.Frames[0].Cars["16"]; ok {
+ t.Fatalf("empty driver included in frame: %+v", resp.Frames[0].Cars)
+ }
+ if got := resp.Frames[0].Cars["1"]; got.X != 10 || got.Y != 20 {
+ t.Fatalf("frame 0 car 1 = %+v, want first nearest sample", got)
+ }
+ if got := resp.Frames[1].Cars["1"]; got.X != 60 || got.Y != 120 {
+ t.Fatalf("frame 1 car 1 = %+v, want later nearest sample", got)
+ }
+ if got := resp.Frames[1].Cars["4"]; got.X != 100 || got.Y != 200 {
+ t.Fatalf("frame 1 car 4 = %+v", got)
+ }
+}
+
+func TestAssembleReplayFramesCapsFrameCount(t *testing.T) {
+ start := time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC)
+ locs := make([]models.Location, maxReplayFrames+250)
+ for i := range locs {
+ locs[i] = models.Location{
+ Date: start.Add(time.Duration(i*defaultReplayIntervalMS) * time.Millisecond).Format(time.RFC3339Nano),
+ X: float64(i),
+ Y: float64(i * 2),
+ }
+ }
+ client := &fakeReplayClient{
+ drivers: []models.Driver{{DriverNumber: 1}},
+ locs: map[int][]models.Location{1: locs},
+ }
+
+ resp, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS)
+ if err != nil {
+ t.Fatalf("assembleReplayFrames() error = %v", err)
+ }
+ if len(resp.Frames) > maxReplayFrames {
+ t.Fatalf("frames len = %d, want <= %d", len(resp.Frames), maxReplayFrames)
+ }
+ if len(resp.Frames) != maxReplayFrames {
+ t.Fatalf("frames len = %d, want hard cap %d", len(resp.Frames), maxReplayFrames)
+ }
+}
+
+func TestAssembleReplayFramesBoundsLocationFanOut(t *testing.T) {
+ drivers := make([]models.Driver, 10)
+ locs := make(map[int][]models.Location, len(drivers))
+ now := time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC)
+ for i := range drivers {
+ number := i + 1
+ drivers[i] = models.Driver{DriverNumber: number}
+ locs[number] = []models.Location{{Date: now.Format(time.RFC3339Nano), X: float64(number), Y: float64(number)}}
+ }
+ client := &fakeReplayClient{
+ drivers: drivers,
+ locs: locs,
+ delay: 5 * time.Millisecond,
+ }
+
+ if _, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS); err != nil {
+ t.Fatalf("assembleReplayFrames() error = %v", err)
+ }
+ if client.maxInFlight > replayFetchConcurrency {
+ t.Fatalf("max in-flight location calls = %d, want <= %d", client.maxInFlight, replayFetchConcurrency)
+ }
+}
+
+func TestHandleReplayFramesValidatesParamsAndFloorsInterval(t *testing.T) {
+ srv := testServer(t, nil)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/replay/frames", nil)
+ rec := httptest.NewRecorder()
+ srv.handleReplayFrames(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("missing session_key status = %d, want 400", rec.Code)
+ }
+
+ req = httptest.NewRequest(http.MethodGet, "/api/v1/replay/frames?session_key=1&interval_ms=nope", nil)
+ rec = httptest.NewRecorder()
+ srv.handleReplayFrames(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("invalid interval status = %d, want 400", rec.Code)
+ }
+
+ client := &fakeReplayClient{drivers: []models.Driver{{DriverNumber: 1}}, locs: map[int][]models.Location{
+ 1: {{Date: time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC).Format(time.RFC3339Nano), X: 1, Y: 2}},
+ }}
+ resp, err := assembleReplayFrames(context.Background(), client, 99, 1000)
+ if err != nil {
+ t.Fatalf("assembleReplayFrames() error = %v", err)
+ }
+ body, err := json.Marshal(resp)
+ if err != nil || len(body) == 0 {
+ t.Fatalf("marshal response = %d bytes, %v", len(body), err)
+ }
+ if resp.Interval != defaultReplayIntervalMS {
+ t.Fatalf("interval = %d, want floor %d", resp.Interval, defaultReplayIntervalMS)
+ }
+}
diff --git a/internal/web/server.go b/internal/web/server.go
index 729c44a..b2e310a 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -75,6 +75,7 @@ func (s *Server) routes() (http.Handler, error) {
mux.HandleFunc("/api/v1/grid", s.handleGrid)
mux.HandleFunc("/api/v1/laps/comparison", s.handleLapsComparison)
mux.HandleFunc("/api/v1/laps", s.handleLaps)
+ mux.HandleFunc("/api/v1/replay/frames", s.handleReplayFrames)
mux.HandleFunc("/api/v1/weather", s.handleWeather)
mux.HandleFunc("/api/v1/race-control", s.handleRaceControl)
mux.HandleFunc("/api/v1/telemetry", s.handleTelemetry)
From 5b112fb012f982cf9018f90c5cdeae77d063e1e0 Mon Sep 17 00:00:00 2001
From: AmanTahiliani
Date: Sat, 11 Jul 2026 18:23:11 -0400
Subject: [PATCH 7/7] feat: add narrative chapter headlines and race-hub
chapter strip (#21)
Generate deterministic template headlines server-side for each replay chapter
kind, expose Chapter.headline in the race-hub payload, and render a horizontal
chapter strip on the story view with active-chapter highlighting, click-to-jump,
and a 90-second tour mode built on the existing scrubber playback.
Co-authored-by: Cursor
---
frontend/src/components/ChapterStrip.tsx | 87 ++++++++
frontend/src/components/RaceStoryCanvas.tsx | 104 ++++++++-
frontend/src/lib/chapters.ts | 82 +++++++
frontend/src/styles/chapters.css | 154 +++++++++++++
frontend/src/test/ChapterStrip.test.tsx | 87 ++++++++
frontend/src/test/chapters.test.ts | 63 ++++++
frontend/src/types.ts | 1 +
internal/chapters/chapters.go | 1 +
internal/chapters/headlines.go | 233 ++++++++++++++++++++
internal/chapters/headlines_test.go | 199 +++++++++++++++++
internal/query/racehub.go | 28 +++
11 files changed, 1038 insertions(+), 1 deletion(-)
create mode 100644 frontend/src/components/ChapterStrip.tsx
create mode 100644 frontend/src/lib/chapters.ts
create mode 100644 frontend/src/styles/chapters.css
create mode 100644 frontend/src/test/ChapterStrip.test.tsx
create mode 100644 frontend/src/test/chapters.test.ts
create mode 100644 internal/chapters/headlines.go
create mode 100644 internal/chapters/headlines_test.go
diff --git a/frontend/src/components/ChapterStrip.tsx b/frontend/src/components/ChapterStrip.tsx
new file mode 100644
index 0000000..13b504e
--- /dev/null
+++ b/frontend/src/components/ChapterStrip.tsx
@@ -0,0 +1,87 @@
+import type { Chapter } from '../types'
+import {
+ activeChapterIndex,
+ chapterKindLabel,
+ chapterLapRange,
+ chapterStartScrub,
+} from '../lib/chapters'
+import '../styles/chapters.css'
+
+interface Props {
+ chapters: Chapter[]
+ scrubTime: number | null
+ tMin: number
+ tRange: number
+ tourActive: boolean
+ tourChapterIndex: number | null
+ onChapterClick: (index: number, scrub: number) => void
+ onTourToggle: () => void
+}
+
+export function ChapterStrip({
+ chapters,
+ scrubTime,
+ tMin,
+ tRange,
+ tourActive,
+ tourChapterIndex,
+ onChapterClick,
+ onTourToggle,
+}: Props) {
+ if (chapters.length === 0) {
+ return (
+
+
No story chapters for this session.
+
+ )
+ }
+
+ const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange)
+
+ return (
+
+
+
Race chapters
+
+
+ {tourActive ? 'Exit 90s' : '90s tour'}
+
+
+
+
+ {chapters.map((chapter, index) => {
+ const scrub = chapterStartScrub(chapter, tMin, tRange) ?? index / Math.max(chapters.length - 1, 1)
+ const isActive = tourActive
+ ? tourChapterIndex === index
+ : activeIndex === index
+ const headline = chapter.headline || chapter.title
+
+ return (
+
onChapterClick(index, scrub)}
+ aria-current={isActive ? 'true' : undefined}
+ data-testid={`chapter-card-${index}`}
+ >
+
+
+ {chapterKindLabel(chapter.kind)}
+
+ {chapterLapRange(chapter)}
+
+ {headline}
+
+ )
+ })}
+
+
+ )
+}
diff --git a/frontend/src/components/RaceStoryCanvas.tsx b/frontend/src/components/RaceStoryCanvas.tsx
index 4380b19..df54579 100644
--- a/frontend/src/components/RaceStoryCanvas.tsx
+++ b/frontend/src/components/RaceStoryCanvas.tsx
@@ -1,9 +1,13 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
-import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session } from '../types'
+import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session, Chapter } from '../types'
import { fetchReplayFrames, fetchTrackOutline } from '../api'
import { ReplayTrackMap } from './ReplayTrackMap'
+import { ChapterStrip } from './ChapterStrip'
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
+import { chapterEndScrub, chapterStartScrub, chapterTourDurations } from '../lib/chapters'
+
+const CHAPTER_TOUR_MS = 90_000
interface Props {
data: {
@@ -17,6 +21,7 @@ interface Props {
session?: Session
meeting?: Meeting
drivers?: Driver[]
+ chapters?: Chapter[]
}
}
@@ -32,6 +37,7 @@ export function RaceStoryCanvas({ data }: Props) {
session,
meeting,
drivers = [],
+ chapters = [],
} = data
const hasPositions = datasets['positions']?.status === 'available'
@@ -40,7 +46,10 @@ export function RaceStoryCanvas({ data }: Props) {
const [mapOpen, setMapOpen] = useState(false)
const [isPlaying, setIsPlaying] = useState(false)
const [playbackSpeed, setPlaybackSpeed] = useState(10)
+ const [chapterTourActive, setChapterTourActive] = useState(false)
+ const [tourChapterIndex, setTourChapterIndex] = useState(null)
const svgRef = useRef(null)
+ const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 })
// Position Evolution Chart Logic
const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions])
@@ -87,6 +96,85 @@ export function RaceStoryCanvas({ data }: Props) {
return () => cancelAnimationFrame(frame)
}, [chartTiming, isPlaying, playbackSpeed])
+ const stopChapterTour = () => {
+ setChapterTourActive(false)
+ setTourChapterIndex(null)
+ }
+
+ const jumpToChapter = (index: number, scrub: number) => {
+ setIsPlaying(false)
+ stopChapterTour()
+ setScrubTime(scrub)
+ }
+
+ const toggleChapterTour = () => {
+ if (chapterTourActive) {
+ stopChapterTour()
+ return
+ }
+ if (!chartTiming || chapters.length === 0) return
+ setIsPlaying(false)
+ setChapterTourActive(true)
+ setTourChapterIndex(0)
+ const startScrub = chapterStartScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? 0
+ setScrubTime(startScrub)
+ const durations = chapterTourDurations(chapters, CHAPTER_TOUR_MS)
+ tourRef.current = {
+ chapterIndex: 0,
+ startedAt: performance.now(),
+ durationMs: durations[0] ?? CHAPTER_TOUR_MS / chapters.length,
+ startScrub,
+ endScrub: chapterEndScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? startScrub,
+ }
+ }
+
+ useEffect(() => {
+ if (!chapterTourActive || !chartTiming || chapters.length === 0) return
+
+ let frame = 0
+ const tick = (now: number) => {
+ const state = tourRef.current
+ const elapsed = now - state.startedAt
+ const progress = Math.min(1, elapsed / Math.max(state.durationMs, 1))
+ const scrub = state.startScrub + (state.endScrub - state.startScrub) * progress
+ setScrubTime(scrub)
+ setTourChapterIndex(state.chapterIndex)
+
+ if (progress >= 1) {
+ const nextIndex = state.chapterIndex + 1
+ if (nextIndex >= chapters.length) {
+ stopChapterTour()
+ return
+ }
+ const durations = chapterTourDurations(chapters, CHAPTER_TOUR_MS)
+ const startScrub = chapterStartScrub(chapters[nextIndex], chartTiming.tMin, chartTiming.tRange) ?? 0
+ const endScrub = chapterEndScrub(chapters[nextIndex], chartTiming.tMin, chartTiming.tRange) ?? startScrub
+ tourRef.current = {
+ chapterIndex: nextIndex,
+ startedAt: now,
+ durationMs: durations[nextIndex] ?? CHAPTER_TOUR_MS / chapters.length,
+ startScrub,
+ endScrub,
+ }
+ }
+ frame = requestAnimationFrame(tick)
+ }
+
+ frame = requestAnimationFrame(tick)
+ return () => cancelAnimationFrame(frame)
+ }, [chapterTourActive, chartTiming, chapters])
+
+ useEffect(() => {
+ if (!chapterTourActive) return
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ stopChapterTour()
+ }
+ }
+ window.addEventListener('keydown', onKeyDown)
+ return () => window.removeEventListener('keydown', onKeyDown)
+ }, [chapterTourActive])
+
const replayTMs = useMemo(() => {
const replay = replayQuery.data
const frames = replay?.frames ?? []
@@ -210,6 +298,7 @@ export function RaceStoryCanvas({ data }: Props) {
const handlePointerMove = (e: React.PointerEvent) => {
setIsPlaying(false)
+ stopChapterTour()
if (!svgRef.current) return
const rect = svgRef.current.getBoundingClientRect()
const x = e.clientX - rect.left
@@ -373,6 +462,7 @@ export function RaceStoryCanvas({ data }: Props) {
className={`rs-tool-btn ${isPlaying ? 'active' : ''}`}
onClick={() => {
setScrubTime((current) => current ?? 0)
+ stopChapterTour()
setIsPlaying((current) => !current)
}}
>
@@ -404,6 +494,18 @@ export function RaceStoryCanvas({ data }: Props) {
return (
+ {hasChartData && chartTiming && chapters.length > 0 && (
+
+ )}
{hasChartData ? (
diff --git a/frontend/src/lib/chapters.ts b/frontend/src/lib/chapters.ts
new file mode 100644
index 0000000..2ced668
--- /dev/null
+++ b/frontend/src/lib/chapters.ts
@@ -0,0 +1,82 @@
+import type { Chapter } from '../types'
+
+export function chapterKindLabel(kind: string): string {
+ switch (kind) {
+ case 'start':
+ return 'GO'
+ case 'safety_car':
+ return 'SC'
+ case 'virtual_safety_car':
+ return 'VSC'
+ case 'red_flag':
+ return 'RF'
+ case 'pit_phase':
+ return 'PIT'
+ case 'decisive_swing':
+ return '▲'
+ case 'finish':
+ return 'FIN'
+ default:
+ return kind.slice(0, 3).toUpperCase()
+ }
+}
+
+export function chapterLapRange(chapter: Chapter): string {
+ if (chapter.start_lap === chapter.end_lap) {
+ return `L${chapter.start_lap}`
+ }
+ return `L${chapter.start_lap}–L${chapter.end_lap}`
+}
+
+/** Normalized scrub position (0–1) for a chapter's start time on the chart axis. */
+export function chapterStartScrub(
+ chapter: Chapter,
+ tMin: number,
+ tRange: number,
+): number | null {
+ if (!chapter.start_time || tRange <= 0) return null
+ const ms = new Date(chapter.start_time).getTime()
+ if (Number.isNaN(ms)) return null
+ return Math.max(0, Math.min(1, (ms - tMin) / tRange))
+}
+
+/** Normalized scrub position (0–1) for a chapter's end time on the chart axis. */
+export function chapterEndScrub(
+ chapter: Chapter,
+ tMin: number,
+ tRange: number,
+): number | null {
+ const raw = chapter.end_time ?? chapter.start_time
+ if (!raw || tRange <= 0) return null
+ const ms = new Date(raw).getTime()
+ if (Number.isNaN(ms)) return null
+ return Math.max(0, Math.min(1, (ms - tMin) / tRange))
+}
+
+/** Index of the chapter containing the current scrub position, if any. */
+export function activeChapterIndex(
+ chapters: Chapter[],
+ scrubTime: number | null,
+ tMin: number,
+ tRange: number,
+): number | null {
+ if (scrubTime === null || chapters.length === 0 || tRange <= 0) return null
+ const chartMs = tMin + scrubTime * tRange
+ for (let i = 0; i < chapters.length; i++) {
+ const ch = chapters[i]
+ const startMs = ch.start_time ? new Date(ch.start_time).getTime() : NaN
+ const endRaw = ch.end_time ?? ch.start_time
+ const endMs = endRaw ? new Date(endRaw).getTime() : NaN
+ if (!Number.isNaN(startMs) && !Number.isNaN(endMs) && chartMs >= startMs && chartMs <= endMs) {
+ return i
+ }
+ }
+ return null
+}
+
+/** Duration in ms each chapter should play during a ~90s tour. */
+export function chapterTourDurations(chapters: Chapter[], totalMs = 90_000): number[] {
+ if (chapters.length === 0) return []
+ const perChapter = totalMs / chapters.length
+ return chapters.map(() => perChapter)
+}
diff --git a/frontend/src/styles/chapters.css b/frontend/src/styles/chapters.css
new file mode 100644
index 0000000..f841341
--- /dev/null
+++ b/frontend/src/styles/chapters.css
@@ -0,0 +1,154 @@
+.chapter-strip {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s3);
+}
+
+.chapter-strip-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--s4);
+}
+
+.chapter-strip-title {
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--text-3);
+}
+
+.chapter-strip-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--s2);
+}
+
+.chapter-tour-btn {
+ font-family: var(--f-mono);
+ font-size: 11px;
+ padding: var(--s1) var(--s3);
+ border: 1px solid var(--border);
+ border-radius: var(--s1);
+ background: var(--surface);
+ color: var(--text-2);
+ cursor: pointer;
+ transition: background 0.15s, border-color 0.15s, color 0.15s;
+}
+
+.chapter-tour-btn:hover,
+.chapter-tour-btn.active {
+ background: var(--surface-h);
+ border-color: var(--border-2);
+ color: var(--text);
+}
+
+.chapter-strip-scroll {
+ display: flex;
+ gap: var(--s3);
+ overflow-x: auto;
+ padding-bottom: var(--s2);
+ scroll-snap-type: x mandatory;
+ -webkit-overflow-scrolling: touch;
+}
+
+.chapter-card {
+ flex: 0 0 min(240px, 72vw);
+ display: flex;
+ flex-direction: column;
+ gap: var(--s2);
+ padding: var(--s3) var(--s4);
+ border: 1px solid var(--border);
+ border-radius: var(--s2);
+ background: var(--surface);
+ text-align: left;
+ cursor: pointer;
+ scroll-snap-align: start;
+ transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
+}
+
+.chapter-card:hover {
+ background: var(--surface-h);
+ border-color: var(--border-2);
+}
+
+.chapter-card.active {
+ border-color: var(--accent, var(--red));
+ background: var(--surface-h);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, var(--red)) 35%, transparent);
+}
+
+.chapter-card.tour-active {
+ border-color: var(--yellow);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--yellow) 40%, transparent);
+}
+
+.chapter-card-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--s3);
+}
+
+.chapter-kind {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 28px;
+ height: 20px;
+ padding: 0 var(--s2);
+ border-radius: var(--s1);
+ font-family: var(--f-mono);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ background: var(--bg);
+ color: var(--text-2);
+ border: 1px solid var(--border);
+}
+
+.chapter-card.active .chapter-kind {
+ color: var(--text);
+ border-color: var(--border-2);
+}
+
+.chapter-kind--safety_car,
+.chapter-kind--virtual_safety_car {
+ color: var(--orange, #f90);
+}
+
+.chapter-kind--red_flag {
+ color: var(--red);
+}
+
+.chapter-kind--decisive_swing {
+ color: var(--green, #22c55e);
+}
+
+.chapter-kind--finish {
+ color: var(--yellow);
+}
+
+.chapter-lap-range {
+ font-family: var(--f-mono);
+ font-size: 10px;
+ color: var(--text-3);
+ white-space: nowrap;
+}
+
+.chapter-headline {
+ font-size: 13px;
+ line-height: 1.35;
+ color: var(--text);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.chapter-strip-empty {
+ font-size: 12px;
+ color: var(--text-3);
+ padding: var(--s3) 0;
+}
diff --git a/frontend/src/test/ChapterStrip.test.tsx b/frontend/src/test/ChapterStrip.test.tsx
new file mode 100644
index 0000000..bbd5907
--- /dev/null
+++ b/frontend/src/test/ChapterStrip.test.tsx
@@ -0,0 +1,87 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ChapterStrip } from '../components/ChapterStrip'
+import type { Chapter } from '../types'
+
+const chapters: Chapter[] = [
+ {
+ kind: 'start',
+ title: 'Start',
+ headline: 'Lights out — the field charges into Turn 1',
+ 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 (L12-L15)',
+ headline: 'Sainz incident brings out the Safety Car — leaders dive for the pits',
+ start_lap: 12,
+ end_lap: 15,
+ start_time: '2025-05-25T13:12:00Z',
+ end_time: '2025-05-25T13:15:00Z',
+ driver_numbers: [55],
+ },
+]
+
+const tMin = new Date('2025-05-25T13:00:00Z').getTime()
+const tMax = new Date('2025-05-25T13:20:00Z').getTime()
+const tRange = tMax - tMin
+
+function renderStrip(
+ overrides: Partial<{
+ scrubTime: number | null
+ onChapterClick: (index: number, scrub: number) => void
+ }> = {},
+) {
+ const onChapterClick = overrides.onChapterClick ?? vi.fn()
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ render(
+
+
+ ,
+ )
+ return { onChapterClick }
+}
+
+describe('ChapterStrip', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('renders chapter headlines and lap ranges', () => {
+ renderStrip()
+ expect(screen.getByTestId('chapter-strip')).toBeInTheDocument()
+ expect(screen.getByText('Lights out — the field charges into Turn 1')).toBeInTheDocument()
+ expect(screen.getByText('L12–L15')).toBeInTheDocument()
+ })
+
+ it('highlights the active chapter from scrub time', () => {
+ const scrub = (new Date('2025-05-25T13:13:00Z').getTime() - tMin) / tRange
+ renderStrip({ scrubTime: scrub })
+ expect(screen.getByTestId('chapter-card-1')).toHaveClass('active')
+ expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
+ })
+
+ it('calls click handler to jump scrubber', () => {
+ const onChapterClick = vi.fn<(index: number, scrub: number) => void>()
+ renderStrip({ onChapterClick })
+ fireEvent.click(screen.getByTestId('chapter-card-1'))
+ expect(onChapterClick).toHaveBeenCalledTimes(1)
+ const [index, scrub] = onChapterClick.mock.calls[0]
+ expect(index).toBe(1)
+ expect(scrub).toBeCloseTo(0.6, 2)
+ })
+})
diff --git a/frontend/src/test/chapters.test.ts b/frontend/src/test/chapters.test.ts
new file mode 100644
index 0000000..4fb0ed6
--- /dev/null
+++ b/frontend/src/test/chapters.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from 'vitest'
+import {
+ activeChapterIndex,
+ chapterKindLabel,
+ chapterLapRange,
+ chapterStartScrub,
+ chapterTourDurations,
+} from '../lib/chapters'
+import type { Chapter } from '../types'
+
+const sampleChapters: Chapter[] = [
+ {
+ kind: 'start',
+ title: 'Start',
+ headline: 'Lights out — the field charges into Turn 1',
+ 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 (L12-L15)',
+ headline: 'Sainz incident brings out the Safety Car — leaders dive for the pits',
+ start_lap: 12,
+ end_lap: 15,
+ start_time: '2025-05-25T13:12:00Z',
+ end_time: '2025-05-25T13:15:00Z',
+ driver_numbers: [55],
+ },
+]
+
+describe('chapters lib', () => {
+ it('labels chapter kinds', () => {
+ expect(chapterKindLabel('safety_car')).toBe('SC')
+ expect(chapterKindLabel('finish')).toBe('FIN')
+ })
+
+ it('formats lap ranges', () => {
+ expect(chapterLapRange(sampleChapters[0])).toBe('L1')
+ expect(chapterLapRange(sampleChapters[1])).toBe('L12–L15')
+ })
+
+ it('maps chapter start time to scrub position', () => {
+ const tMin = new Date('2025-05-25T13:00:00Z').getTime()
+ const tMax = new Date('2025-05-25T13:20:00Z').getTime()
+ const scrub = chapterStartScrub(sampleChapters[1], tMin, tMax - tMin)
+ expect(scrub).toBeCloseTo(0.6, 2)
+ })
+
+ it('finds active chapter from scrub time', () => {
+ const tMin = new Date('2025-05-25T13:00:00Z').getTime()
+ const tMax = new Date('2025-05-25T13:20:00Z').getTime()
+ const tRange = tMax - tMin
+ const scrub = (new Date('2025-05-25T13:13:00Z').getTime() - tMin) / tRange
+ expect(activeChapterIndex(sampleChapters, scrub, tMin, tRange)).toBe(1)
+ })
+
+ it('splits 90s evenly across chapters', () => {
+ expect(chapterTourDurations(sampleChapters)).toEqual([45_000, 45_000])
+ })
+})
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index c14f54c..1b7cacd 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -97,6 +97,7 @@ export interface RaceHub {
export interface Chapter {
kind: 'start' | 'safety_car' | 'virtual_safety_car' | 'red_flag' | 'pit_phase' | 'decisive_swing' | 'finish' | string
title: string
+ headline: string
start_lap: number
end_lap: number
start_time?: string
diff --git a/internal/chapters/chapters.go b/internal/chapters/chapters.go
index d5a937c..b7e50de 100644
--- a/internal/chapters/chapters.go
+++ b/internal/chapters/chapters.go
@@ -38,6 +38,7 @@ type Lap = models.Lap
type Chapter struct {
Kind string `json:"kind"`
Title string `json:"title"`
+ Headline string `json:"headline"`
StartLap int `json:"start_lap"`
EndLap int `json:"end_lap"`
StartTime string `json:"start_time,omitempty"`
diff --git a/internal/chapters/headlines.go b/internal/chapters/headlines.go
new file mode 100644
index 0000000..76ae653
--- /dev/null
+++ b/internal/chapters/headlines.go
@@ -0,0 +1,233 @@
+package chapters
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/AmanTahiliani/box-box/internal/models"
+)
+
+// DriverIdentityInput carries session driver fields used for headline templates.
+type DriverIdentityInput struct {
+ DriverNumber int
+ NameAcronym string
+ FullName string
+ TeamName string
+}
+
+type driverIdentity struct {
+ display string
+ team string
+ acronym string
+}
+
+// BuildDriverMap indexes driver identity from session drivers and enriched results.
+func BuildDriverMap(drivers []models.Driver, results []DriverIdentityInput) map[int]driverIdentity {
+ out := map[int]driverIdentity{}
+ for _, d := range drivers {
+ if d.DriverNumber <= 0 {
+ continue
+ }
+ out[d.DriverNumber] = driverIdentity{
+ display: driverDisplayName(d.LastName, d.FullName, d.NameAcronym, d.BroadcastName),
+ team: d.TeamName,
+ acronym: firstNonEmpty(d.NameAcronym, d.BroadcastName),
+ }
+ }
+ for _, r := range results {
+ if r.DriverNumber <= 0 {
+ continue
+ }
+ if _, ok := out[r.DriverNumber]; ok {
+ continue
+ }
+ out[r.DriverNumber] = driverIdentity{
+ display: driverDisplayName("", r.FullName, r.NameAcronym, ""),
+ team: r.TeamName,
+ acronym: r.NameAcronym,
+ }
+ }
+ return out
+}
+
+// ApplyHeadlines fills Headline on each chapter using deterministic templates.
+func ApplyHeadlines(chapters []Chapter, drivers map[int]driverIdentity, rc []RaceControl, winnerNumber int) []Chapter {
+ out := make([]Chapter, len(chapters))
+ copy(out, chapters)
+ for i := range out {
+ out[i].Headline = headlineFor(out[i], drivers, rc, winnerNumber)
+ }
+ return out
+}
+
+func headlineFor(ch Chapter, drivers map[int]driverIdentity, rc []RaceControl, winnerNumber int) string {
+ switch ch.Kind {
+ case KindStart:
+ return pickVariant(ch.StartLap,
+ "Lights out — the field charges into Turn 1",
+ "Race start — Lap 1 shuffle at the front",
+ )
+ case KindSafetyCar:
+ if name := driverName(ch, drivers, incidentDriver(rc, ch.StartLap)); name != "" {
+ return pickVariant(ch.StartLap,
+ fmt.Sprintf("%s incident brings out the Safety Car — leaders dive for the pits", name),
+ fmt.Sprintf("Safety Car deployed after %s stops on track", name),
+ )
+ }
+ case KindVirtualSafetyCar:
+ if name := driverName(ch, drivers, incidentDriver(rc, ch.StartLap)); name != "" {
+ return pickVariant(ch.StartLap,
+ fmt.Sprintf("%s off track triggers the Virtual Safety Car", name),
+ fmt.Sprintf("Virtual Safety Car — %s loses control on Lap %d", name, ch.StartLap),
+ )
+ }
+ return pickVariant(ch.StartLap,
+ fmt.Sprintf("Virtual Safety Car deployed on Lap %d", ch.StartLap),
+ fmt.Sprintf("VSC period — field backs off on L%d–L%d", ch.StartLap, ch.EndLap),
+ )
+ case KindRedFlag:
+ if name := driverName(ch, drivers, incidentDriver(rc, ch.StartLap)); name != "" {
+ return pickVariant(ch.StartLap,
+ fmt.Sprintf("%s crash forces a red flag on Lap %d", name, ch.StartLap),
+ fmt.Sprintf("Red flag after %s incident on Lap %d", name, ch.StartLap),
+ )
+ }
+ return pickVariant(ch.StartLap,
+ fmt.Sprintf("Red flag — session halted on Lap %d", ch.StartLap),
+ fmt.Sprintf("Race suspended under red flag on L%d–L%d", ch.StartLap, ch.EndLap),
+ )
+ case KindPitPhase:
+ names := driverNames(ch.DriverNumbers, drivers, 3)
+ if len(names) >= 2 {
+ return pickVariant(ch.StartLap,
+ fmt.Sprintf("Mass pit-window scramble — %s and %s box on L%d–L%d", names[0], names[1], ch.StartLap, ch.EndLap),
+ fmt.Sprintf("Undercut window opens — %s leads the pit rush on L%d–L%d", names[0], ch.StartLap, ch.EndLap),
+ )
+ }
+ if len(names) == 1 {
+ return pickVariant(ch.StartLap,
+ fmt.Sprintf("%s pits under green — strategy window opens on L%d", names[0], ch.StartLap),
+ fmt.Sprintf("Pit phase on L%d–L%d — %s among the first to stop", ch.StartLap, ch.EndLap, names[0]),
+ )
+ }
+ case KindDecisiveSwing:
+ if len(ch.DriverNumbers) >= 2 {
+ attacker := driverName(ch, drivers, ch.DriverNumbers[0])
+ defender := driverName(ch, drivers, ch.DriverNumbers[1])
+ pos := swingPosition(ch.Title)
+ if attacker != "" && defender != "" && pos > 0 {
+ return pickVariant(ch.StartLap,
+ fmt.Sprintf("%s overtakes %s for P%d", attacker, defender, pos),
+ fmt.Sprintf("%s charges past %s into P%d on Lap %d", attacker, defender, pos, ch.StartLap),
+ )
+ }
+ }
+ if len(ch.DriverNumbers) >= 1 {
+ attacker := driverName(ch, drivers, ch.DriverNumbers[0])
+ pos := swingPosition(ch.Title)
+ if attacker != "" && pos > 0 {
+ return pickVariant(ch.StartLap,
+ fmt.Sprintf("%s moves up to P%d on Lap %d", attacker, pos, ch.StartLap),
+ fmt.Sprintf("Decisive swing — %s climbs to P%d", attacker, pos),
+ )
+ }
+ }
+ case KindFinish:
+ winner := driverName(ch, drivers, winnerNumber)
+ if winner != "" {
+ return pickVariant(ch.StartLap,
+ fmt.Sprintf("%s crosses the line to take the win", winner),
+ fmt.Sprintf("Chequered flag — %s wins the race", winner),
+ )
+ }
+ }
+ return ch.Title
+}
+
+func pickVariant(seed int, variants ...string) string {
+ if len(variants) == 0 {
+ return ""
+ }
+ if seed < 0 {
+ seed = -seed
+ }
+ return variants[seed%len(variants)]
+}
+
+func driverName(_ Chapter, drivers map[int]driverIdentity, number int) string {
+ if number <= 0 {
+ return ""
+ }
+ if id, ok := drivers[number]; ok && id.display != "" {
+ return id.display
+ }
+ return ""
+}
+
+func driverNames(numbers []int, drivers map[int]driverIdentity, limit int) []string {
+ out := make([]string, 0, limit)
+ for _, number := range numbers {
+ if name := driverName(Chapter{}, drivers, number); name != "" {
+ out = append(out, name)
+ if len(out) >= limit {
+ break
+ }
+ }
+ }
+ return out
+}
+
+func driverDisplayName(lastName, fullName, acronym, broadcast string) string {
+ if lastName != "" {
+ return lastName
+ }
+ if fullName != "" {
+ parts := strings.Fields(fullName)
+ if len(parts) > 0 {
+ return parts[len(parts)-1]
+ }
+ return fullName
+ }
+ return firstNonEmpty(acronym, broadcast)
+}
+
+func incidentDriver(rc []RaceControl, startLap int) int {
+ for _, msg := range rc {
+ if msg.DriverNumber == nil || *msg.DriverNumber <= 0 {
+ continue
+ }
+ lap := 0
+ if msg.LapNumber != nil {
+ lap = *msg.LapNumber
+ }
+ if lap < startLap-1 || lap > startLap+1 {
+ continue
+ }
+ text := upperText(msg.Message, string(msg.Category))
+ if strings.Contains(text, "DEPLOY") ||
+ strings.Contains(text, "CLEAR") ||
+ strings.Contains(text, "ENDING") ||
+ strings.Contains(text, "GREEN") ||
+ strings.Contains(text, "CHEQUER") {
+ continue
+ }
+ return *msg.DriverNumber
+ }
+ return 0
+}
+
+func swingPosition(title string) int {
+ // Title format: "Decisive swing: #16 to P3 (L6)"
+ idx := strings.Index(title, "P")
+ if idx < 0 || idx+1 >= len(title) {
+ return 0
+ }
+ pos := 0
+ for i := idx + 1; i < len(title); i++ {
+ if title[i] < '0' || title[i] > '9' {
+ break
+ }
+ pos = pos*10 + int(title[i]-'0')
+ }
+ return pos
+}
diff --git a/internal/chapters/headlines_test.go b/internal/chapters/headlines_test.go
new file mode 100644
index 0000000..006ec41
--- /dev/null
+++ b/internal/chapters/headlines_test.go
@@ -0,0 +1,199 @@
+package chapters
+
+import (
+ "testing"
+
+ "github.com/AmanTahiliani/box-box/internal/models"
+)
+
+func TestHeadlineStart(t *testing.T) {
+ ch := Chapter{Kind: KindStart, Title: "Start", StartLap: 1, EndLap: 1}
+ got := headlineFor(ch, nil, nil, 0)
+ want := "Race start — Lap 1 shuffle at the front"
+ if got != want {
+ t.Fatalf("headline = %q, want %q", got, want)
+ }
+}
+
+func TestHeadlineStartVariantByLap(t *testing.T) {
+ ch := Chapter{Kind: KindStart, Title: "Start", StartLap: 2, EndLap: 2}
+ got := headlineFor(ch, nil, nil, 0)
+ want := "Lights out — the field charges into Turn 1"
+ if got != want {
+ t.Fatalf("headline = %q, want %q", got, want)
+ }
+}
+
+func TestHeadlineSafetyCarWithDriver(t *testing.T) {
+ drivers := BuildDriverMap(nil, []DriverIdentityInput{
+ {DriverNumber: 55, NameAcronym: "SAI", FullName: "Carlos Sainz", TeamName: "Ferrari"},
+ })
+ rc := []RaceControl{
+ {
+ DriverNumber: intPtr(55),
+ LapNumber: intPtr(12),
+ Message: "CAR 55 STOPPED ON TRACK",
+ },
+ rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
+ rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
+ }
+ ch := Chapter{
+ Kind: KindSafetyCar,
+ Title: "Safety Car (L12-L15)",
+ StartLap: 12,
+ EndLap: 15,
+ }
+ got := headlineFor(ch, drivers, rc, 0)
+ want := "Sainz incident brings out the Safety Car — leaders dive for the pits"
+ if got != want {
+ t.Fatalf("headline = %q, want %q", got, want)
+ }
+}
+
+func TestHeadlineSafetyCarFallbackWithoutDriver(t *testing.T) {
+ ch := Chapter{
+ Kind: KindSafetyCar,
+ Title: "Safety Car (L12-L15)",
+ StartLap: 12,
+ EndLap: 15,
+ }
+ got := headlineFor(ch, nil, nil, 0)
+ if got != ch.Title {
+ t.Fatalf("headline = %q, want fallback %q", got, ch.Title)
+ }
+}
+
+func TestHeadlineVirtualSafetyCar(t *testing.T) {
+ drivers := BuildDriverMap(nil, []DriverIdentityInput{
+ {DriverNumber: 16, NameAcronym: "LEC", FullName: "Charles Leclerc", TeamName: "Ferrari"},
+ })
+ rc := []RaceControl{
+ {
+ DriverNumber: intPtr(16),
+ LapNumber: intPtr(22),
+ Message: "CAR 16 OFF TRACK",
+ },
+ rc(22, models.CategoryOther, "", "VSC DEPLOYED"),
+ rc(24, models.CategoryOther, "", "VSC ENDING"),
+ }
+ ch := Chapter{
+ Kind: KindVirtualSafetyCar,
+ Title: "Virtual Safety Car (L22-L24)",
+ StartLap: 22,
+ EndLap: 24,
+ }
+ got := headlineFor(ch, drivers, rc, 0)
+ want := "Leclerc off track triggers the Virtual Safety Car"
+ if got != want {
+ t.Fatalf("headline = %q, want %q", got, want)
+ }
+}
+
+func TestHeadlineRedFlag(t *testing.T) {
+ drivers := BuildDriverMap(nil, []DriverIdentityInput{
+ {DriverNumber: 63, NameAcronym: "RUS", FullName: "George Russell", TeamName: "Mercedes"},
+ })
+ rc := []RaceControl{
+ {
+ DriverNumber: intPtr(63),
+ LapNumber: intPtr(31),
+ Message: "INCIDENT INVOLVING CAR 63",
+ },
+ rc(31, models.CategoryFlag, models.FlagRed, "RED FLAG"),
+ rc(33, models.CategoryFlag, models.FlagGreen, "GREEN FLAG"),
+ }
+ ch := Chapter{
+ Kind: KindRedFlag,
+ Title: "Red Flag (L31-L33)",
+ StartLap: 31,
+ EndLap: 33,
+ }
+ got := headlineFor(ch, drivers, rc, 0)
+ want := "Red flag after Russell incident on Lap 31"
+ if got != want {
+ t.Fatalf("headline = %q, want %q", got, want)
+ }
+}
+
+func TestHeadlinePitPhase(t *testing.T) {
+ drivers := BuildDriverMap(nil, []DriverIdentityInput{
+ {DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull"},
+ {DriverNumber: 44, NameAcronym: "HAM", FullName: "Lewis Hamilton", TeamName: "Mercedes"},
+ {DriverNumber: 16, NameAcronym: "LEC", FullName: "Charles Leclerc", TeamName: "Ferrari"},
+ })
+ ch := Chapter{
+ Kind: KindPitPhase,
+ Title: "Pit phase (L20-L22)",
+ StartLap: 20,
+ EndLap: 22,
+ DriverNumbers: []int{1, 44, 16},
+ }
+ got := headlineFor(ch, drivers, nil, 0)
+ want := "Mass pit-window scramble — Verstappen and Hamilton box on L20–L22"
+ if got != want {
+ t.Fatalf("headline = %q, want %q", got, want)
+ }
+}
+
+func TestHeadlineDecisiveSwing(t *testing.T) {
+ drivers := BuildDriverMap(nil, []DriverIdentityInput{
+ {DriverNumber: 16, NameAcronym: "LEC", FullName: "Charles Leclerc", TeamName: "Ferrari"},
+ {DriverNumber: 55, NameAcronym: "SAI", FullName: "Carlos Sainz", TeamName: "Ferrari"},
+ })
+ ch := Chapter{
+ Kind: KindDecisiveSwing,
+ Title: "Decisive swing: #16 to P3 (L6)",
+ StartLap: 6,
+ EndLap: 6,
+ DriverNumbers: []int{16, 55},
+ }
+ got := headlineFor(ch, drivers, nil, 0)
+ want := "Leclerc overtakes Sainz for P3"
+ if got != want {
+ t.Fatalf("headline = %q, want %q", got, want)
+ }
+}
+
+func TestHeadlineFinish(t *testing.T) {
+ drivers := BuildDriverMap(nil, []DriverIdentityInput{
+ {DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull"},
+ })
+ ch := Chapter{
+ Kind: KindFinish,
+ Title: "Finish (L57-L58)",
+ StartLap: 57,
+ EndLap: 58,
+ }
+ got := headlineFor(ch, drivers, nil, 1)
+ want := "Chequered flag — Verstappen wins the race"
+ if got != want {
+ t.Fatalf("headline = %q, want %q", got, want)
+ }
+}
+
+func TestHeadlineFinishFallbackWithoutWinner(t *testing.T) {
+ ch := Chapter{
+ Kind: KindFinish,
+ Title: "Finish (L57-L58)",
+ StartLap: 57,
+ EndLap: 58,
+ }
+ got := headlineFor(ch, nil, nil, 0)
+ if got != ch.Title {
+ t.Fatalf("headline = %q, want fallback %q", got, ch.Title)
+ }
+}
+
+func TestApplyHeadlinesPreservesChapterFields(t *testing.T) {
+ input := []Chapter{
+ {Kind: KindStart, Title: "Start", StartLap: 1, EndLap: 1},
+ }
+ got := ApplyHeadlines(input, nil, nil, 0)
+ if len(got) != 1 || got[0].StartLap != 1 || got[0].Headline == "" {
+ t.Fatalf("ApplyHeadlines = %+v, want headline on start chapter", got)
+ }
+}
+
+func intPtr(v int) *int {
+ return &v
+}
diff --git a/internal/query/racehub.go b/internal/query/racehub.go
index 5d15884..c5b8f1e 100644
--- a/internal/query/racehub.go
+++ b/internal/query/racehub.go
@@ -252,10 +252,38 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
}
hub.Chapters = chapters.Detect(hub.RaceControl, hub.Positions, hub.Laps, totalLaps(hub.Results, hub.Laps))
+ hub.Chapters = chapters.ApplyHeadlines(
+ hub.Chapters,
+ chapters.BuildDriverMap(hub.Drivers, driverIdentityInputs(hub.Results)),
+ hub.RaceControl,
+ winnerDriverNumber(hub.Results),
+ )
hub.Source = responseSource(hub.Datasets)
return hub, nil
}
+func driverIdentityInputs(results []EnrichedResult) []chapters.DriverIdentityInput {
+ out := make([]chapters.DriverIdentityInput, 0, len(results))
+ for _, r := range results {
+ out = append(out, chapters.DriverIdentityInput{
+ DriverNumber: r.DriverNumber,
+ NameAcronym: r.NameAcronym,
+ FullName: r.FullName,
+ TeamName: r.TeamName,
+ })
+ }
+ return out
+}
+
+func winnerDriverNumber(results []EnrichedResult) int {
+ for _, r := range results {
+ if r.Position == 1 {
+ return r.DriverNumber
+ }
+ }
+ return 0
+}
+
func totalLaps(results []EnrichedResult, laps []models.Lap) int {
total := 0
for _, result := range results {