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: --- diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 1b7d972..d749b5f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -2,12 +2,16 @@ import type { ArticleContent, CarDataSample, ChampionshipHub, + DriverSummary, + EnrichedGrid, + EnrichedResult, LapsComparisonResponse, LiveStateResponse, LiveSessionMeta, Meeting, NewsItem, RaceHub, + ReplayFramesResponse, Session, TrackOutline, Weekend, @@ -40,7 +44,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 +56,47 @@ 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 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) { @@ -76,6 +125,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/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 +
+ +
+
+
+ {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 ( + + ) + })} +
+
+ ) +} 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/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/components/RaceStoryCanvas.tsx b/frontend/src/components/RaceStoryCanvas.tsx index 229ff49..df54579 100644 --- a/frontend/src/components/RaceStoryCanvas.tsx +++ b/frontend/src/components/RaceStoryCanvas.tsx @@ -1,6 +1,13 @@ -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, 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: { @@ -12,28 +19,181 @@ interface Props { race_control?: any[] pit_stops?: any[] session?: Session + meeting?: Meeting + drivers?: Driver[] + chapters?: Chapter[] } } 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 = [], + chapters = [], + } = 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 [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]) 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 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 ?? [] + 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 +297,8 @@ 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 @@ -288,24 +450,91 @@ export function RaceStoryCanvas({ data }: Props) { height={plotH} fill="transparent" onPointerMove={handlePointerMove} - onPointerLeave={() => setScrubTime(null)} + onPointerLeave={() => { + if (!isPlaying) setScrubTime(null) + }} style={{ cursor: 'crosshair', touchAction: 'none' }} /> +
+ +
+ {[1, 10, 30].map((speed) => ( + + ))} +
+ +
) } return (
- {hasChartData ? ( - chartContent - ) : ( -
- Lap-by-lap positions not available. This session does not - have ingested position samples in /api/v1/race-hub. -
+ {hasChartData && chartTiming && chapters.length > 0 && ( + )} +
+
+ {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/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/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/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/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/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/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 ( + + ) +} + 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) => ( + + ))} +
+ )}
) @@ -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 && ( + + )} +
+ )} +
+ )} + + {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} - -
- - - Page {safePage} / {pageCount} - - + {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 && ( -
- - - Page {safePage} / {pageCount} - - -
+ {recentTagged.length > 0 && ( +
+
+

Recent

+ {recentTagged.length} +
+
+ {recentTagged.map((item) => ( + + ))} +
+
)} )} diff --git a/frontend/src/pages/ChampionshipPage.tsx b/frontend/src/pages/ChampionshipPage.tsx index 32a7e99..fdfd21b 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' @@ -245,6 +246,7 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) { titleMath={titleMath} roundsLeft={hub.rounds_left} drivers={drivers} + season={hub.season} /> )} {view === 'constructors' && } @@ -275,12 +277,14 @@ function DriversView({ titleMath, roundsLeft, drivers, + season, }: { enriched: EnrichedDriver[] leaderPoints: number titleMath: string roundsLeft: number drivers: ChampHubDriver[] + season: number }) { const podium = enriched.slice(0, 3) const h2hPairs = useMemo(() => teammatePairs(drivers), [drivers]) @@ -390,12 +394,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 ( +
+
+
+
+ {summary.name_acronym || `#${summary.driver_number}`} + + #{summary.driver_number} + +
+

{summary.full_name || `Driver ${summary.driver_number}`}

+
+ + {summary.team_name || 'Unknown team'} + {summary.season} +
+
+
+
+
{summary.position > 0 ? `P${summary.position}` : '—'}
+
Championship
+
+
+
{fmtPts(summary.points)}
+
Points
+
+
+
{summary.wins}
+
Wins
+
+
+
{summary.podiums}
+
Podiums
+
+
+
{summary.poles}
+
Poles
+
+
+ + ← Championship + +
+ +
+

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) => ( +
+
+ {fmtPts(pts)} +
+ ))} +
+
+ ) +} + +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 ( +
+ + + + + + + + + + + + + + {summary.rounds.map((r, i) => { + const d = deltas[i] + return ( + + + + + + + + + + ) + })} + +
RndGrand PrixGridRaceΔPts
R{i + 1} + + {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/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 ( +
+

Circuit

+ +
+ + + + +
+
+
+ ) +} + +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

} + + + + + + + + + + + + {drivers.map((d) => ( + + + + + + + ))} + +
DriverPtsGapIf win (+{RACE_WIN_POINTS})
+ {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 ( +
+
+
+
+
+

+ {countryFlag(meeting) && {countryFlag(meeting)}} + {meeting.meeting_name} +

+

+ {meeting.circuit_short_name} + {meeting.location ? ` · ${meeting.location}` : ''} + {formatGpDateRange(meeting) ? ` · ${formatGpDateRange(meeting)}` : ''} +

+
+ {countdownTarget && ( +
+ + {countdownSession?.session_name ?? 'Next session'} + + {formatCountdown(countdownTarget, now)} +
+ )} +
+ {sessions.length > 0 && ( +
+ {sessions.map((session) => ( +
+ {session.session_name} + {formatSessionScheduleTime(session.date_start)} +
+ ))} +
+ )} +
+
+ ) +} + +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..c60c792 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -6,11 +6,17 @@ 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' +import { RacePreviewPage } from './pages/RacePreviewPage' type RaceHubSearch = { session_key?: number } +type DriverProfileSearch = { + year?: number +} + const rootRoute = createRootRoute({ component: () => ( <> @@ -64,12 +70,32 @@ 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', component: BriefingPage, }) +export const previewRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/preview', + component: RacePreviewPage, +}) + const routeTree = rootRoute.addChildren([ commandCenterRoute, raceHubRoute, @@ -77,7 +103,9 @@ const routeTree = rootRoute.addChildren([ dataLibraryRoute, liveTimingRoute, championshipRoute, + driverProfileRoute, briefingRoute, + previewRoute, ]) export const router = createRouter({ routeTree }) 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/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/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/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/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/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/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/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/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/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/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/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/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/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) + }) +}) 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/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/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 c0b2fcf..1b7cacd 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 @@ -89,6 +91,18 @@ 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 + headline: string + start_lap: number + end_lap: number + start_time?: string + end_time?: string + driver_numbers: number[] } export interface Stint { @@ -347,6 +361,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 @@ -383,6 +414,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/chapters/chapters.go b/internal/chapters/chapters.go new file mode 100644 index 0000000..b7e50de --- /dev/null +++ b/internal/chapters/chapters.go @@ -0,0 +1,629 @@ +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"` + Headline string `json:"headline"` + 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/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 1924599..c5b8f1e 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,50 @@ 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.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 { + 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/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/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) 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..f748c03 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -70,11 +70,15 @@ 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) 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)