mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
Compare commits
7 Commits
feat/issue
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b112fb012 | ||
|
|
96d741424c | ||
|
|
c2617d39bb | ||
|
|
beacda278d | ||
|
|
09104451d0 | ||
|
|
f8bd183c26 | ||
|
|
01ec1f24e7 |
@@ -2,6 +2,7 @@ import type {
|
|||||||
ArticleContent,
|
ArticleContent,
|
||||||
CarDataSample,
|
CarDataSample,
|
||||||
ChampionshipHub,
|
ChampionshipHub,
|
||||||
|
DriverSummary,
|
||||||
EnrichedGrid,
|
EnrichedGrid,
|
||||||
EnrichedResult,
|
EnrichedResult,
|
||||||
LapsComparisonResponse,
|
LapsComparisonResponse,
|
||||||
@@ -10,6 +11,7 @@ import type {
|
|||||||
Meeting,
|
Meeting,
|
||||||
NewsItem,
|
NewsItem,
|
||||||
RaceHub,
|
RaceHub,
|
||||||
|
ReplayFramesResponse,
|
||||||
Session,
|
Session,
|
||||||
TrackOutline,
|
TrackOutline,
|
||||||
Weekend,
|
Weekend,
|
||||||
@@ -80,6 +82,21 @@ export async function fetchTrackOutline(circuitKey: number, year: number): Promi
|
|||||||
return data as TrackOutline
|
return data as TrackOutline
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchReplayFrames(
|
||||||
|
sessionKey: number,
|
||||||
|
intervalMs = 5000,
|
||||||
|
): Promise<ReplayFramesResponse> {
|
||||||
|
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<Session[]> {
|
export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise<Session[]> {
|
||||||
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
|
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -108,6 +125,19 @@ export async function fetchChampionshipHub(year?: number): Promise<ChampionshipH
|
|||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchDriverSummary(
|
||||||
|
driverNumber: number,
|
||||||
|
year?: number,
|
||||||
|
): Promise<DriverSummary> {
|
||||||
|
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<LiveStateResponse> {
|
export async function fetchLiveState(): Promise<LiveStateResponse> {
|
||||||
const res = await fetch('/api/v1/live/state')
|
const res = await fetch('/api/v1/live/state')
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
87
frontend/src/components/ChapterStrip.tsx
Normal file
87
frontend/src/components/ChapterStrip.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="chapter-strip" data-testid="chapter-strip">
|
||||||
|
<p className="chapter-strip-empty">No story chapters for this session.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chapter-strip" data-testid="chapter-strip">
|
||||||
|
<div className="chapter-strip-header">
|
||||||
|
<span className="chapter-strip-title">Race chapters</span>
|
||||||
|
<div className="chapter-strip-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`chapter-tour-btn ${tourActive ? 'active' : ''}`}
|
||||||
|
onClick={onTourToggle}
|
||||||
|
aria-pressed={tourActive}
|
||||||
|
>
|
||||||
|
{tourActive ? 'Exit 90s' : '90s tour'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="chapter-strip-scroll" role="list" aria-label="Race story 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 (
|
||||||
|
<button
|
||||||
|
key={`${chapter.kind}-${chapter.start_lap}-${index}`}
|
||||||
|
type="button"
|
||||||
|
role="listitem"
|
||||||
|
className={`chapter-card ${isActive ? (tourActive ? 'tour-active' : 'active') : ''}`}
|
||||||
|
onClick={() => onChapterClick(index, scrub)}
|
||||||
|
aria-current={isActive ? 'true' : undefined}
|
||||||
|
data-testid={`chapter-card-${index}`}
|
||||||
|
>
|
||||||
|
<div className="chapter-card-top">
|
||||||
|
<span className={`chapter-kind chapter-kind--${chapter.kind}`}>
|
||||||
|
{chapterKindLabel(chapter.kind)}
|
||||||
|
</span>
|
||||||
|
<span className="chapter-lap-range">{chapterLapRange(chapter)}</span>
|
||||||
|
</div>
|
||||||
|
<span className="chapter-headline">{headline}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import { useState, useMemo, useRef, useCallback } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { EnrichedResult, EnrichedGrid, PositionSample, Lap, Session } from '../types'
|
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 { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
|
||||||
|
import { chapterEndScrub, chapterStartScrub, chapterTourDurations } from '../lib/chapters'
|
||||||
|
|
||||||
|
const CHAPTER_TOUR_MS = 90_000
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: {
|
data: {
|
||||||
@@ -12,28 +19,181 @@ interface Props {
|
|||||||
race_control?: any[]
|
race_control?: any[]
|
||||||
pit_stops?: any[]
|
pit_stops?: any[]
|
||||||
session?: Session
|
session?: Session
|
||||||
|
meeting?: Meeting
|
||||||
|
drivers?: Driver[]
|
||||||
|
chapters?: Chapter[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RaceStoryCanvas({ data }: Props) {
|
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 hasPositions = datasets['positions']?.status === 'available'
|
||||||
|
|
||||||
const [scrubTime, setScrubTime] = useState<number | null>(null)
|
const [scrubTime, setScrubTime] = useState<number | null>(null)
|
||||||
const [hoverDriver, setHoverDriver] = useState<number | null>(null)
|
const [hoverDriver, setHoverDriver] = useState<number | null>(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<number | null>(null)
|
||||||
const svgRef = useRef<SVGSVGElement>(null)
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
|
const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 })
|
||||||
|
|
||||||
// Position Evolution Chart Logic
|
// Position Evolution Chart Logic
|
||||||
const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions])
|
const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions])
|
||||||
const hasChartData = hasPositions && allTimes.length > 0
|
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 chartContent = null
|
||||||
let displayResults = results
|
let displayResults = results
|
||||||
|
|
||||||
if (hasChartData) {
|
if (hasChartData && chartTiming) {
|
||||||
const tMin = new Date(allTimes[0]).getTime()
|
const { tMin, tRange } = chartTiming
|
||||||
const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
|
|
||||||
const tRange = Math.max(tMax - tMin, 1)
|
|
||||||
|
|
||||||
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
|
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
|
||||||
for (const p of positions) {
|
for (const p of positions) {
|
||||||
@@ -137,6 +297,8 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
|
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
|
||||||
|
setIsPlaying(false)
|
||||||
|
stopChapterTour()
|
||||||
if (!svgRef.current) return
|
if (!svgRef.current) return
|
||||||
const rect = svgRef.current.getBoundingClientRect()
|
const rect = svgRef.current.getBoundingClientRect()
|
||||||
const x = e.clientX - rect.left
|
const x = e.clientX - rect.left
|
||||||
@@ -288,16 +450,64 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
height={plotH}
|
height={plotH}
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerLeave={() => setScrubTime(null)}
|
onPointerLeave={() => {
|
||||||
|
if (!isPlaying) setScrubTime(null)
|
||||||
|
}}
|
||||||
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<div className="rs-replay-tools" aria-label="Race replay controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rs-tool-btn ${isPlaying ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setScrubTime((current) => current ?? 0)
|
||||||
|
stopChapterTour()
|
||||||
|
setIsPlaying((current) => !current)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPlaying ? 'Pause' : 'Play'}
|
||||||
|
</button>
|
||||||
|
<div className="rs-speed-group" aria-label="Playback speed">
|
||||||
|
{[1, 10, 30].map((speed) => (
|
||||||
|
<button
|
||||||
|
key={speed}
|
||||||
|
type="button"
|
||||||
|
className={`rs-speed-btn ${playbackSpeed === speed ? 'active' : ''}`}
|
||||||
|
onClick={() => setPlaybackSpeed(speed)}
|
||||||
|
>
|
||||||
|
{speed}x
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rs-tool-btn ${mapOpen ? 'active' : ''}`}
|
||||||
|
onClick={() => setMapOpen((current) => !current)}
|
||||||
|
>
|
||||||
|
Map
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="race-story-canvas">
|
<div className="race-story-canvas">
|
||||||
|
{hasChartData && chartTiming && chapters.length > 0 && (
|
||||||
|
<ChapterStrip
|
||||||
|
chapters={chapters}
|
||||||
|
scrubTime={scrubTime}
|
||||||
|
tMin={chartTiming.tMin}
|
||||||
|
tRange={chartTiming.tRange}
|
||||||
|
tourActive={chapterTourActive}
|
||||||
|
tourChapterIndex={tourChapterIndex}
|
||||||
|
onChapterClick={jumpToChapter}
|
||||||
|
onTourToggle={toggleChapterTour}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="rs-replay-shell">
|
||||||
|
<div className="rs-replay-main">
|
||||||
{hasChartData ? (
|
{hasChartData ? (
|
||||||
chartContent
|
chartContent
|
||||||
) : (
|
) : (
|
||||||
@@ -306,6 +516,25 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
have ingested position samples in <code>/api/v1/race-hub</code>.
|
have ingested position samples in <code>/api/v1/race-hub</code>.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
{mapOpen && (
|
||||||
|
<div className="rs-replay-map-slot">
|
||||||
|
{circuitKey > 0 && outlineYear > 0 ? (
|
||||||
|
<ReplayTrackMap
|
||||||
|
outline={outlineQuery.data}
|
||||||
|
replay={replayQuery.data}
|
||||||
|
tMs={replayTMs}
|
||||||
|
drivers={drivers}
|
||||||
|
results={results}
|
||||||
|
loading={outlineQuery.isLoading || replayQuery.isLoading}
|
||||||
|
error={outlineQuery.isError || replayQuery.isError}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="rs-replay-map-placeholder">track identity unavailable for replay map</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{displayResults.length > 0 && (
|
{displayResults.length > 0 && (
|
||||||
<div className="rs-field-list">
|
<div className="rs-field-list">
|
||||||
|
|||||||
133
frontend/src/components/ReplayTrackMap.tsx
Normal file
133
frontend/src/components/ReplayTrackMap.tsx
Normal file
@@ -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<string | null>(null)
|
||||||
|
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
|
||||||
|
const driverInfo = useMemo(() => {
|
||||||
|
const info = new Map<string, { label: string; color: string }>()
|
||||||
|
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 (
|
||||||
|
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||||
|
<div className="replay-map-empty">loading replay GPS...</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||||
|
<div className="replay-map-empty">replay GPS unavailable for this session</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!outline || !outlinePath) {
|
||||||
|
return (
|
||||||
|
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||||
|
<div className="replay-map-empty">track outline unavailable for this session</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!replay?.frames?.length || cars.length === 0) {
|
||||||
|
return (
|
||||||
|
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||||
|
<div className="replay-map-empty">historical GPS unavailable for this session</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||||
|
<div className="replay-map-stage">
|
||||||
|
<svg className="replay-map-svg" viewBox="0 0 100 100" role="img" aria-label="Replay track map">
|
||||||
|
<path className="replay-map-outline-shadow" d={outlinePath} />
|
||||||
|
<path className="replay-map-outline" d={outlinePath} />
|
||||||
|
{cars.map((car) => {
|
||||||
|
const label = car.info?.label ?? car.number
|
||||||
|
const selected = pinned === car.number
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={car.number}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`${label} replay position`}
|
||||||
|
className={`replay-car ${selected ? 'replay-car-pinned' : ''}`}
|
||||||
|
transform={`translate(${car.svg.x.toFixed(2)} ${car.svg.y.toFixed(2)})`}
|
||||||
|
onClick={() => setPinned(selected ? null : car.number)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
setPinned(selected ? null : car.number)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<title>{label}</title>
|
||||||
|
<circle r="2.7" fill={car.info?.color ?? '#777777'} />
|
||||||
|
<text y="0.85">{label}</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeColor(color: string | undefined): string {
|
||||||
|
const raw = color?.trim()
|
||||||
|
if (!raw) return '#777777'
|
||||||
|
return raw.startsWith('#') ? raw : `#${raw}`
|
||||||
|
}
|
||||||
82
frontend/src/lib/chapters.ts
Normal file
82
frontend/src/lib/chapters.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
69
frontend/src/lib/driverProfile.ts
Normal file
69
frontend/src/lib/driverProfile.ts
Normal file
@@ -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 '='
|
||||||
|
}
|
||||||
@@ -47,7 +47,9 @@ export function countryDecal(meeting: Meeting | undefined | null): string {
|
|||||||
return name.slice(0, 3).toUpperCase() || '—'
|
return name.slice(0, 3).toUpperCase() || '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function countryFlag(meeting: Meeting | undefined | null): string {
|
export function countryFlag(
|
||||||
|
meeting: Pick<Meeting, 'country_code' | 'country_flag'> | undefined | null,
|
||||||
|
): string {
|
||||||
if (meeting?.country_flag && !/^https?:\/\//i.test(meeting.country_flag)) return meeting.country_flag
|
if (meeting?.country_flag && !/^https?:\/\//i.test(meeting.country_flag)) return meeting.country_flag
|
||||||
const code = meeting?.country_code?.toUpperCase()
|
const code = meeting?.country_code?.toUpperCase()
|
||||||
const iso2 = code ? CODE3_TO_2[code] : ''
|
const iso2 = code ? CODE3_TO_2[code] : ''
|
||||||
|
|||||||
75
frontend/src/lib/replay.ts
Normal file
75
frontend/src/lib/replay.ts
Normal file
@@ -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<ReplayFrame>,
|
||||||
|
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<ReplayFrame>,
|
||||||
|
tMs: number,
|
||||||
|
): Record<string, ReplayCarPosition> {
|
||||||
|
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<string, ReplayCarPosition> = {}
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
import { fetchChampionshipHub, fetchSeasons } from '../api'
|
import { fetchChampionshipHub, fetchSeasons } from '../api'
|
||||||
import { teamColor } from '../utils'
|
import { teamColor } from '../utils'
|
||||||
import type { ChampHubDriver, ChampionshipHub } from '../types'
|
import type { ChampHubDriver, ChampionshipHub } from '../types'
|
||||||
@@ -242,6 +243,7 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
|||||||
leaderPoints={leader.points}
|
leaderPoints={leader.points}
|
||||||
titleMath={titleMath}
|
titleMath={titleMath}
|
||||||
roundsLeft={hub.rounds_left}
|
roundsLeft={hub.rounds_left}
|
||||||
|
season={hub.season}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{view === 'constructors' && <ConstructorsView hub={hub} />}
|
{view === 'constructors' && <ConstructorsView hub={hub} />}
|
||||||
@@ -271,11 +273,13 @@ function DriversView({
|
|||||||
leaderPoints,
|
leaderPoints,
|
||||||
titleMath,
|
titleMath,
|
||||||
roundsLeft,
|
roundsLeft,
|
||||||
|
season,
|
||||||
}: {
|
}: {
|
||||||
enriched: EnrichedDriver[]
|
enriched: EnrichedDriver[]
|
||||||
leaderPoints: number
|
leaderPoints: number
|
||||||
titleMath: string
|
titleMath: string
|
||||||
roundsLeft: number
|
roundsLeft: number
|
||||||
|
season: number
|
||||||
}) {
|
}) {
|
||||||
const podium = enriched.slice(0, 3)
|
const podium = enriched.slice(0, 3)
|
||||||
return (
|
return (
|
||||||
@@ -360,12 +364,19 @@ function DriversView({
|
|||||||
P{e.pos}
|
P{e.pos}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="champ-drv">
|
<Link
|
||||||
|
to="/drivers/$driverNumber"
|
||||||
|
params={{ driverNumber: String(e.d.driver_number) }}
|
||||||
|
search={{ year: season }}
|
||||||
|
className="champ-drv"
|
||||||
|
style={{ color: 'inherit', textDecoration: 'none' }}
|
||||||
|
data-testid={`champ-driver-link-${e.d.driver_number}`}
|
||||||
|
>
|
||||||
<span className="champ-drv-bar" style={{ background: e.color }} />
|
<span className="champ-drv-bar" style={{ background: e.color }} />
|
||||||
<span className="champ-drv-code mono">{e.d.name_acronym}</span>
|
<span className="champ-drv-code mono">{e.d.name_acronym}</span>
|
||||||
<span className="champ-drv-name">{e.d.full_name}</span>
|
<span className="champ-drv-name">{e.d.full_name}</span>
|
||||||
<span className="champ-drv-num mono">#{e.d.driver_number}</span>
|
<span className="champ-drv-num mono">#{e.d.driver_number}</span>
|
||||||
</div>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="champ-td-team">{e.d.team_name}</td>
|
<td className="champ-td-team">{e.d.team_name}</td>
|
||||||
<td className="r mono champ-td-pts">{fmtPts(e.d.points)}</td>
|
<td className="r mono champ-td-pts">{fmtPts(e.d.points)}</td>
|
||||||
|
|||||||
369
frontend/src/pages/DriverProfilePage.tsx
Normal file
369
frontend/src/pages/DriverProfilePage.tsx
Normal file
@@ -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 <div className="page error-box">Invalid driver number</div>
|
||||||
|
}
|
||||||
|
if (!seasonsSettled || summaryQuery.isLoading) {
|
||||||
|
return <div className="page loading-state">loading driver profile…</div>
|
||||||
|
}
|
||||||
|
if (summaryQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="page error-box">
|
||||||
|
{summaryQuery.error instanceof Error
|
||||||
|
? summaryQuery.error.message
|
||||||
|
: 'Failed to load driver profile'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const summary = summaryQuery.data
|
||||||
|
if (!summary) {
|
||||||
|
return <div className="page error-box">No driver data</div>
|
||||||
|
}
|
||||||
|
return <DriverProfileBody summary={summary} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DriverProfileBody({ summary }: { summary: DriverSummary }) {
|
||||||
|
const color = teamColor(summary.team_colour)
|
||||||
|
const deltas = gridFinishDeltas(summary.rounds)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dp-page" data-testid="driver-profile">
|
||||||
|
<header className="dp-header" data-testid="dp-header" style={{ borderLeftColor: color }}>
|
||||||
|
<div className="dp-identity">
|
||||||
|
<div className="dp-title-row">
|
||||||
|
<span className="dp-code mono">{summary.name_acronym || `#${summary.driver_number}`}</span>
|
||||||
|
<span className="dp-num mono" style={{ color }}>
|
||||||
|
#{summary.driver_number}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="dp-name">{summary.full_name || `Driver ${summary.driver_number}`}</h1>
|
||||||
|
<div className="dp-team">
|
||||||
|
<span className="dp-team-swatch" style={{ background: color }} />
|
||||||
|
{summary.team_name || 'Unknown team'}
|
||||||
|
<span className="dp-season mono">{summary.season}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="dp-stat-rail">
|
||||||
|
<div className="dp-stat">
|
||||||
|
<div className="dp-stat-value mono">{summary.position > 0 ? `P${summary.position}` : '—'}</div>
|
||||||
|
<div className="dp-stat-label">Championship</div>
|
||||||
|
</div>
|
||||||
|
<div className="dp-stat">
|
||||||
|
<div className="dp-stat-value mono">{fmtPts(summary.points)}</div>
|
||||||
|
<div className="dp-stat-label">Points</div>
|
||||||
|
</div>
|
||||||
|
<div className="dp-stat">
|
||||||
|
<div className="dp-stat-value mono">{summary.wins}</div>
|
||||||
|
<div className="dp-stat-label">Wins</div>
|
||||||
|
</div>
|
||||||
|
<div className="dp-stat">
|
||||||
|
<div className="dp-stat-value mono">{summary.podiums}</div>
|
||||||
|
<div className="dp-stat-label">Podiums</div>
|
||||||
|
</div>
|
||||||
|
<div className="dp-stat">
|
||||||
|
<div className="dp-stat-value mono">{summary.poles}</div>
|
||||||
|
<div className="dp-stat-label">Poles</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link to="/championship" className="dp-back mono">
|
||||||
|
← Championship
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="dp-section" data-testid="dp-form">
|
||||||
|
<h2 className="dp-section-title mono">Season form</h2>
|
||||||
|
{summary.cumulative.length > 0 ? (
|
||||||
|
<div className="dp-form-grid">
|
||||||
|
<CumulativeLine
|
||||||
|
cumulative={summary.cumulative}
|
||||||
|
labels={summary.round_labels}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
<FormStrip form={summary.form} color={color} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="dp-empty">No completed rounds yet.</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="dp-section" data-testid="dp-quali-race">
|
||||||
|
<h2 className="dp-section-title mono">Quali vs race</h2>
|
||||||
|
{deltas.some((d) => d.delta != null) ? (
|
||||||
|
<GridVsRaceChart deltas={deltas} color={color} />
|
||||||
|
) : (
|
||||||
|
<div className="dp-empty">No grid-vs-finish data yet.</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="dp-section" data-testid="dp-rounds">
|
||||||
|
<h2 className="dp-section-title mono">Track by track</h2>
|
||||||
|
{summary.rounds.length > 0 ? (
|
||||||
|
<RoundsTable summary={summary} deltas={deltas} />
|
||||||
|
) : (
|
||||||
|
<div className="dp-empty">No completed rounds yet.</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<figure className="dp-chart">
|
||||||
|
<figcaption className="dp-chart-caption mono">Cumulative points</figcaption>
|
||||||
|
<svg viewBox={`0 0 ${LINE_W} ${LINE_H}`} className="dp-chart-svg" role="img" aria-label="Cumulative points per round">
|
||||||
|
{[0, 0.5, 1].map((f) => {
|
||||||
|
const v = Math.round(maxY * f)
|
||||||
|
return (
|
||||||
|
<g key={f}>
|
||||||
|
<line x1={LINE_PL} y1={y(v)} x2={LINE_W - LINE_PR} y2={y(v)} className="dp-gridline" />
|
||||||
|
<text x={LINE_PL - 6} y={y(v) + 3} textAnchor="end" className="dp-axis">
|
||||||
|
{v}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{ticks.map((t) => (
|
||||||
|
<text key={t.label} x={t.x} y={LINE_H - 8} textAnchor="middle" className="dp-axis">
|
||||||
|
{t.label}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
<polyline
|
||||||
|
points={points}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth="2.2"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<circle cx={x(n - 1)} cy={y(cumulative[n - 1])} r="3.2" fill={color} />
|
||||||
|
</svg>
|
||||||
|
</figure>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormStrip({ form, color }: { form: number[]; color: string }) {
|
||||||
|
const max = Math.max(25, ...form)
|
||||||
|
return (
|
||||||
|
<div className="dp-form-strip">
|
||||||
|
<div className="dp-chart-caption mono">Last {form.length} races</div>
|
||||||
|
<div className="dp-form-bars">
|
||||||
|
{form.map((pts, i) => (
|
||||||
|
<div className="dp-form-bar" key={i} title={`${fmtPts(pts)} pts`}>
|
||||||
|
<div
|
||||||
|
className="dp-form-bar-fill"
|
||||||
|
style={{ height: `${Math.max(4, (pts / max) * 100)}%`, background: color }}
|
||||||
|
/>
|
||||||
|
<span className="dp-form-bar-val mono">{fmtPts(pts)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<figure className="dp-chart">
|
||||||
|
<figcaption className="dp-chart-caption mono">
|
||||||
|
Positions gained (▲) / lost (▼) from grid to flag, per round
|
||||||
|
</figcaption>
|
||||||
|
<div className="dp-chart-scroll">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${w} ${DELTA_H}`}
|
||||||
|
width={w}
|
||||||
|
height={DELTA_H}
|
||||||
|
className="dp-delta-svg"
|
||||||
|
role="img"
|
||||||
|
aria-label="Positions gained or lost per round"
|
||||||
|
>
|
||||||
|
<line x1={0} y1={zeroY} x2={w} y2={zeroY} className="dp-zeroline" />
|
||||||
|
{deltas.map((d, i) => {
|
||||||
|
const cx = i * colW + colW / 2
|
||||||
|
if (d.delta == null) {
|
||||||
|
return (
|
||||||
|
<g key={d.round}>
|
||||||
|
<text x={cx} y={zeroY - 6} textAnchor="middle" className="dp-axis">
|
||||||
|
·
|
||||||
|
</text>
|
||||||
|
<text x={cx} y={DELTA_H - 8} textAnchor="middle" className="dp-axis">
|
||||||
|
R{d.round}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const h = Math.abs(d.delta) * scale
|
||||||
|
const yTop = d.delta >= 0 ? zeroY - h : zeroY
|
||||||
|
return (
|
||||||
|
<g key={d.round}>
|
||||||
|
<title>{`${d.label}: ${formatPosition(d.grid)} → ${formatPosition(d.finish)} (${formatDelta(d.delta)})`}</title>
|
||||||
|
<rect
|
||||||
|
x={cx - 8}
|
||||||
|
y={yTop}
|
||||||
|
width={16}
|
||||||
|
height={Math.max(1.5, h)}
|
||||||
|
rx={2}
|
||||||
|
fill={color}
|
||||||
|
opacity={d.delta >= 0 ? 0.95 : 0.45}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={cx}
|
||||||
|
y={d.delta >= 0 ? yTop - 4 : yTop + h + 11}
|
||||||
|
textAnchor="middle"
|
||||||
|
className="dp-delta-val mono"
|
||||||
|
>
|
||||||
|
{formatDelta(d.delta)}
|
||||||
|
</text>
|
||||||
|
<text x={cx} y={DELTA_H - 8} textAnchor="middle" className="dp-axis">
|
||||||
|
R{d.round}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="dp-scroll">
|
||||||
|
<table className="dp-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="l">Rnd</th>
|
||||||
|
<th className="l">Grand Prix</th>
|
||||||
|
<th className="r">Grid</th>
|
||||||
|
<th className="r">Race</th>
|
||||||
|
<th className="r">Δ</th>
|
||||||
|
<th className="r">Pts</th>
|
||||||
|
<th className="l"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{summary.rounds.map((r, i) => {
|
||||||
|
const d = deltas[i]
|
||||||
|
return (
|
||||||
|
<tr key={r.meeting_key}>
|
||||||
|
<td className="mono dp-td-dim">R{i + 1}</td>
|
||||||
|
<td>
|
||||||
|
<span className="dp-flag" aria-hidden="true">
|
||||||
|
{countryFlag({ country_code: r.country_code, country_flag: '' })}
|
||||||
|
</span>
|
||||||
|
{r.meeting_name}
|
||||||
|
</td>
|
||||||
|
<td className="r mono">{formatPosition(d.grid)}</td>
|
||||||
|
<td className="r mono">{formatPosition(d.finish)}</td>
|
||||||
|
<td
|
||||||
|
className="r mono"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
d.delta == null
|
||||||
|
? 'var(--text-3)'
|
||||||
|
: d.delta > 0
|
||||||
|
? 'var(--green)'
|
||||||
|
: d.delta < 0
|
||||||
|
? 'var(--red)'
|
||||||
|
: 'var(--text-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatDelta(d.delta)}
|
||||||
|
</td>
|
||||||
|
<td className="r mono">{fmtPts(r.points)}</td>
|
||||||
|
<td className="dp-td-dim mono">{statusNote(d)}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,12 +6,17 @@ import { DataLibraryPage } from './pages/DataLibraryPage'
|
|||||||
import { LiveTimingPage } from './pages/LiveTimingPage'
|
import { LiveTimingPage } from './pages/LiveTimingPage'
|
||||||
import { BriefingPage } from './pages/BriefingPage'
|
import { BriefingPage } from './pages/BriefingPage'
|
||||||
import { ChampionshipPage } from './pages/ChampionshipPage'
|
import { ChampionshipPage } from './pages/ChampionshipPage'
|
||||||
|
import { DriverProfilePage } from './pages/DriverProfilePage'
|
||||||
import { RacePreviewPage } from './pages/RacePreviewPage'
|
import { RacePreviewPage } from './pages/RacePreviewPage'
|
||||||
|
|
||||||
type RaceHubSearch = {
|
type RaceHubSearch = {
|
||||||
session_key?: number
|
session_key?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DriverProfileSearch = {
|
||||||
|
year?: number
|
||||||
|
}
|
||||||
|
|
||||||
const rootRoute = createRootRoute({
|
const rootRoute = createRootRoute({
|
||||||
component: () => (
|
component: () => (
|
||||||
<>
|
<>
|
||||||
@@ -65,6 +70,20 @@ export const championshipRoute = createRoute({
|
|||||||
component: ChampionshipPage,
|
component: ChampionshipPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const driverProfileRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/drivers/$driverNumber',
|
||||||
|
validateSearch: (search: Record<string, unknown>): 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 <DriverProfilePage driverNumber={Number(driverNumber) || 0} year={year} />
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const briefingRoute = createRoute({
|
export const briefingRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/briefing',
|
path: '/briefing',
|
||||||
@@ -84,6 +103,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
dataLibraryRoute,
|
dataLibraryRoute,
|
||||||
liveTimingRoute,
|
liveTimingRoute,
|
||||||
championshipRoute,
|
championshipRoute,
|
||||||
|
driverProfileRoute,
|
||||||
briefingRoute,
|
briefingRoute,
|
||||||
previewRoute,
|
previewRoute,
|
||||||
])
|
])
|
||||||
|
|||||||
154
frontend/src/styles/chapters.css
Normal file
154
frontend/src/styles/chapters.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
289
frontend/src/styles/driver-profile.css
Normal file
289
frontend/src/styles/driver-profile.css
Normal file
@@ -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);
|
||||||
|
}
|
||||||
161
frontend/src/styles/replay-map.css
Normal file
161
frontend/src/styles/replay-map.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
87
frontend/src/test/ChapterStrip.test.tsx
Normal file
87
frontend/src/test/ChapterStrip.test.tsx
Normal file
@@ -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(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ChapterStrip
|
||||||
|
chapters={chapters}
|
||||||
|
scrubTime={overrides.scrubTime ?? null}
|
||||||
|
tMin={tMin}
|
||||||
|
tRange={tRange}
|
||||||
|
tourActive={false}
|
||||||
|
tourChapterIndex={null}
|
||||||
|
onChapterClick={onChapterClick}
|
||||||
|
onTourToggle={vi.fn()}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
160
frontend/src/test/DriverProfilePage.test.tsx
Normal file
160
frontend/src/test/DriverProfilePage.test.tsx
Normal file
@@ -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: () => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<DriverProfilePage driverNumber={props.driverNumber} year={props.year} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
const indexRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/',
|
||||||
|
component: () => null,
|
||||||
|
})
|
||||||
|
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
|
||||||
|
return render(<RouterProvider router={router} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
135
frontend/src/test/RaceStoryCanvas.test.tsx
Normal file
135
frontend/src/test/RaceStoryCanvas.test.tsx
Normal file
@@ -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(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RaceStoryCanvas data={raceHub} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
56
frontend/src/test/ReplayTrackMap.test.tsx
Normal file
56
frontend/src/test/ReplayTrackMap.test.tsx
Normal file
@@ -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(<ReplayTrackMap outline={outline} replay={{ ...replay, frames: [] }} tMs={0} drivers={[]} results={results} />)
|
||||||
|
expect(screen.getByTestId('replay-track-map')).toHaveTextContent(/historical GPS unavailable/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders car labels from result metadata', () => {
|
||||||
|
render(<ReplayTrackMap outline={outline} replay={replay} tMs={2500} drivers={[]} results={results} />)
|
||||||
|
expect(screen.getByLabelText(/VER replay position/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
63
frontend/src/test/chapters.test.ts
Normal file
63
frontend/src/test/chapters.test.ts
Normal file
@@ -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])
|
||||||
|
})
|
||||||
|
})
|
||||||
104
frontend/src/test/driverProfile.test.ts
Normal file
104
frontend/src/test/driverProfile.test.ts
Normal file
@@ -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>): 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('—')
|
||||||
|
})
|
||||||
|
})
|
||||||
33
frontend/src/test/replay.test.ts
Normal file
33
frontend/src/test/replay.test.ts
Normal file
@@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -97,6 +97,7 @@ export interface RaceHub {
|
|||||||
export interface Chapter {
|
export interface Chapter {
|
||||||
kind: 'start' | 'safety_car' | 'virtual_safety_car' | 'red_flag' | 'pit_phase' | 'decisive_swing' | 'finish' | string
|
kind: 'start' | 'safety_car' | 'virtual_safety_car' | 'red_flag' | 'pit_phase' | 'decisive_swing' | 'finish' | string
|
||||||
title: string
|
title: string
|
||||||
|
headline: string
|
||||||
start_lap: number
|
start_lap: number
|
||||||
end_lap: number
|
end_lap: number
|
||||||
start_time?: string
|
start_time?: string
|
||||||
@@ -360,6 +361,23 @@ export interface TrackOutline {
|
|||||||
bounds: TrackBounds
|
bounds: TrackBounds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReplayCarPosition {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayFrame {
|
||||||
|
t: number
|
||||||
|
cars: Record<string, ReplayCarPosition>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayFramesResponse {
|
||||||
|
session_key: number
|
||||||
|
interval_ms: number
|
||||||
|
start_time: string
|
||||||
|
frames: ReplayFrame[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChampHubDriver {
|
export interface ChampHubDriver {
|
||||||
driver_number: number
|
driver_number: number
|
||||||
name_acronym: string
|
name_acronym: string
|
||||||
@@ -396,6 +414,39 @@ export interface ChampionshipHub {
|
|||||||
teams: ChampHubTeam[]
|
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 {
|
export interface NewsItem {
|
||||||
source: string
|
source: string
|
||||||
title: string
|
title: string
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type Lap = models.Lap
|
|||||||
type Chapter struct {
|
type Chapter struct {
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
Headline string `json:"headline"`
|
||||||
StartLap int `json:"start_lap"`
|
StartLap int `json:"start_lap"`
|
||||||
EndLap int `json:"end_lap"`
|
EndLap int `json:"end_lap"`
|
||||||
StartTime string `json:"start_time,omitempty"`
|
StartTime string `json:"start_time,omitempty"`
|
||||||
|
|||||||
233
internal/chapters/headlines.go
Normal file
233
internal/chapters/headlines.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
199
internal/chapters/headlines_test.go
Normal file
199
internal/chapters/headlines_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -252,10 +252,38 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hub.Chapters = chapters.Detect(hub.RaceControl, hub.Positions, hub.Laps, totalLaps(hub.Results, hub.Laps))
|
hub.Chapters = chapters.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)
|
hub.Source = responseSource(hub.Datasets)
|
||||||
return hub, nil
|
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 {
|
func totalLaps(results []EnrichedResult, laps []models.Lap) int {
|
||||||
total := 0
|
total := 0
|
||||||
for _, result := range results {
|
for _, result := range results {
|
||||||
|
|||||||
@@ -797,11 +797,6 @@ func (s *Server) localChampionshipHub(year int) (champHubResponse, bool, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, 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)
|
champ, err := s.client.GetDriverChampionshipForYear(year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return champHubResponse{}, err
|
return champHubResponse{}, err
|
||||||
@@ -816,16 +811,39 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
|
|||||||
driverInfo = buildDriverMapFirst(ds)
|
driverInfo = buildDriverMapFirst(ds)
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(meetings, func(i, j int) bool { return meetings[i].DateStart < meetings[j].DateStart })
|
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
|
// Any per-meeting fetch failure (network, rate limit) yields an incomplete
|
||||||
// aggregate: serve it so the page still renders, but cache it only briefly
|
// 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.
|
// so a partial view of the season doesn't stick around for the full TTL.
|
||||||
var incomplete atomic.Bool
|
ttl = champHubIncompleteTTL
|
||||||
races := fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) {
|
}
|
||||||
|
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 })
|
||||||
|
|
||||||
|
var failed atomic.Bool
|
||||||
|
races = fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) {
|
||||||
sessions, serr := s.client.GetSessionsForMeeting(int(m.MeetingKey))
|
sessions, serr := s.client.GetSessionsForMeeting(int(m.MeetingKey))
|
||||||
if serr != nil {
|
if serr != nil {
|
||||||
incomplete.Store(true)
|
failed.Store(true)
|
||||||
return meetingRace{}, false
|
return meetingRace{}, false
|
||||||
}
|
}
|
||||||
raceKey := 0
|
raceKey := 0
|
||||||
@@ -841,18 +859,11 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
|
|||||||
results, rerr := s.client.GetSessionResult(raceKey)
|
results, rerr := s.client.GetSessionResult(raceKey)
|
||||||
grid, gerr := s.client.GetStartingGrid(raceKey)
|
grid, gerr := s.client.GetStartingGrid(raceKey)
|
||||||
if rerr != nil || gerr != nil {
|
if rerr != nil || gerr != nil {
|
||||||
incomplete.Store(true)
|
failed.Store(true)
|
||||||
}
|
}
|
||||||
return meetingRace{Meeting: m, RaceSessionKey: raceKey, Results: results, Grid: grid}, true
|
return meetingRace{Meeting: m, RaceSessionKey: raceKey, Results: results, Grid: grid}, true
|
||||||
})
|
})
|
||||||
|
return races, failed.Load(), nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// aggregateChampionshipHub is the pure aggregation core (no network) so it can be
|
// aggregateChampionshipHub is the pure aggregation core (no network) so it can be
|
||||||
|
|||||||
177
internal/web/driversummary.go
Normal file
177
internal/web/driversummary.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
136
internal/web/driversummary_test.go
Normal file
136
internal/web/driversummary_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
259
internal/web/replay.go
Normal file
259
internal/web/replay.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
181
internal/web/replay_test.go
Normal file
181
internal/web/replay_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,11 +70,15 @@ func (s *Server) routes() (http.Handler, error) {
|
|||||||
mux.HandleFunc("/api/v1/news", s.handleNews)
|
mux.HandleFunc("/api/v1/news", s.handleNews)
|
||||||
mux.HandleFunc("/api/v1/meetings", s.handleMeetings)
|
mux.HandleFunc("/api/v1/meetings", s.handleMeetings)
|
||||||
mux.HandleFunc("/api/v1/sessions", s.handleSessions)
|
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/drivers", s.handleDrivers)
|
||||||
mux.HandleFunc("/api/v1/results", s.handleResults)
|
mux.HandleFunc("/api/v1/results", s.handleResults)
|
||||||
mux.HandleFunc("/api/v1/grid", s.handleGrid)
|
mux.HandleFunc("/api/v1/grid", s.handleGrid)
|
||||||
mux.HandleFunc("/api/v1/laps/comparison", s.handleLapsComparison)
|
mux.HandleFunc("/api/v1/laps/comparison", s.handleLapsComparison)
|
||||||
mux.HandleFunc("/api/v1/laps", s.handleLaps)
|
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/weather", s.handleWeather)
|
||||||
mux.HandleFunc("/api/v1/race-control", s.handleRaceControl)
|
mux.HandleFunc("/api/v1/race-control", s.handleRaceControl)
|
||||||
mux.HandleFunc("/api/v1/telemetry", s.handleTelemetry)
|
mux.HandleFunc("/api/v1/telemetry", s.handleTelemetry)
|
||||||
|
|||||||
Reference in New Issue
Block a user