mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
merge phase-2 — keep both DriversView props (drivers for H2H, season for profile links)
This commit is contained in:
@@ -2,12 +2,16 @@ import type {
|
||||
ArticleContent,
|
||||
CarDataSample,
|
||||
ChampionshipHub,
|
||||
DriverSummary,
|
||||
EnrichedGrid,
|
||||
EnrichedResult,
|
||||
LapsComparisonResponse,
|
||||
LiveStateResponse,
|
||||
LiveSessionMeta,
|
||||
Meeting,
|
||||
NewsItem,
|
||||
RaceHub,
|
||||
ReplayFramesResponse,
|
||||
Session,
|
||||
TrackOutline,
|
||||
Weekend,
|
||||
@@ -40,7 +44,11 @@ export async function fetchLocalMeetings(year: number): Promise<Meeting[]> {
|
||||
}
|
||||
|
||||
export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
|
||||
const res = await fetch(`/api/v1/meetings?year=${year}&source=openf1`)
|
||||
return fetchMeetings(year, 'openf1')
|
||||
}
|
||||
|
||||
export async function fetchMeetings(year: number, source = 'auto'): Promise<Meeting[]> {
|
||||
const res = await fetch(`/api/v1/meetings?year=${year}&source=${source}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
@@ -48,6 +56,47 @@ export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
|
||||
return Array.isArray(meetings) ? meetings : []
|
||||
}
|
||||
|
||||
export async function fetchResults(sessionKey: number, source = 'auto'): Promise<EnrichedResult[]> {
|
||||
const res = await fetch(`/api/v1/results?session_key=${sessionKey}&source=${source}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
const results = await res.json()
|
||||
return Array.isArray(results) ? results : []
|
||||
}
|
||||
|
||||
export async function fetchStartingGrid(sessionKey: number, source = 'auto'): Promise<EnrichedGrid[]> {
|
||||
const res = await fetch(`/api/v1/grid?session_key=${sessionKey}&source=${source}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
const grid = await res.json()
|
||||
return Array.isArray(grid) ? grid : []
|
||||
}
|
||||
|
||||
export async function fetchTrackOutline(circuitKey: number, year: number): Promise<TrackOutline | null> {
|
||||
const res = await fetch(`/api/v1/track-outline?circuit_key=${circuitKey}&year=${year}`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
if (data?.error || !Array.isArray(data?.points) || data.points.length < 2) return null
|
||||
return data as TrackOutline
|
||||
}
|
||||
|
||||
export async function fetchReplayFrames(
|
||||
sessionKey: number,
|
||||
intervalMs = 5000,
|
||||
): Promise<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[]> {
|
||||
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
|
||||
if (!res.ok) {
|
||||
@@ -76,6 +125,19 @@ export async function fetchChampionshipHub(year?: number): Promise<ChampionshipH
|
||||
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> {
|
||||
const res = await fetch('/api/v1/live/state')
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,9 @@ export function Nav() {
|
||||
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
||||
Race Hub
|
||||
</Link>
|
||||
<Link to="/preview" activeProps={{ className: 'active' }}>
|
||||
Preview
|
||||
</Link>
|
||||
<Link to="/championship" activeProps={{ className: 'active' }}>
|
||||
Championship
|
||||
</Link>
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { fetchNews } from '../api'
|
||||
import {
|
||||
fetchChampionshipHub,
|
||||
fetchNews,
|
||||
fetchSeasonMeetings,
|
||||
fetchSeasons,
|
||||
} from '../api'
|
||||
import {
|
||||
activeDigestWindow,
|
||||
gpWindows,
|
||||
itemsForWindow,
|
||||
sinceLastLabel,
|
||||
tagColour,
|
||||
tagItems,
|
||||
topTags,
|
||||
} from '../lib/digest'
|
||||
import { timeAgo } from '../utils'
|
||||
import '../styles/digest.css'
|
||||
|
||||
const SOURCE_DISPLAY: Record<string, string> = {
|
||||
'fia': 'FIA',
|
||||
@@ -14,14 +30,53 @@ const SOURCE_DISPLAY: Record<string, string> = {
|
||||
}
|
||||
|
||||
export function PaddockBriefing() {
|
||||
const now = useMemo(() => new Date(), [])
|
||||
|
||||
const { data: news, isLoading, isError } = useQuery({
|
||||
queryKey: ['news'],
|
||||
queryFn: () => fetchNews(100),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const seasonsQuery = useQuery({
|
||||
queryKey: ['seasons'],
|
||||
queryFn: fetchSeasons,
|
||||
})
|
||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||
|
||||
const meetingsQuery = useQuery({
|
||||
queryKey: ['season-meetings', latestSeason],
|
||||
queryFn: () => fetchSeasonMeetings(latestSeason!),
|
||||
enabled: latestSeason != null,
|
||||
})
|
||||
|
||||
const hubQuery = useQuery({
|
||||
queryKey: ['championship-hub', latestSeason],
|
||||
queryFn: () => fetchChampionshipHub(latestSeason!),
|
||||
enabled: latestSeason != null,
|
||||
})
|
||||
|
||||
const meetings = meetingsQuery.data ?? []
|
||||
const hub = hubQuery.data
|
||||
const tagged = useMemo(
|
||||
() => tagItems(news ?? [], hub?.drivers ?? [], hub?.teams ?? []),
|
||||
[news, hub],
|
||||
)
|
||||
|
||||
const windows = useMemo(() => gpWindows(meetings, now), [meetings, now])
|
||||
const activeWindow = useMemo(
|
||||
() => activeDigestWindow(windows, meetings, now),
|
||||
[windows, meetings, now],
|
||||
)
|
||||
const sinceItems = useMemo(
|
||||
() => itemsForWindow(tagged, activeWindow),
|
||||
[tagged, activeWindow],
|
||||
)
|
||||
const sinceTags = useMemo(() => topTags(sinceItems, 4), [sinceItems])
|
||||
const sinceLabel = sinceLastLabel(meetings, now)
|
||||
|
||||
const unreadCount = news?.filter((i) => !i.read_at).length ?? 0
|
||||
const preview = news?.slice(0, 5) ?? []
|
||||
const preview = sinceItems.length > 0 ? sinceItems.slice(0, 5) : (tagged.slice(0, 5))
|
||||
|
||||
return (
|
||||
<section className="cc-briefing" data-testid="paddock-briefing">
|
||||
@@ -37,6 +92,27 @@ export function PaddockBriefing() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{meetings.length > 0 && (
|
||||
<div className="cc-brief-digest-meta" data-testid="cc-brief-digest-meta">
|
||||
<span>Since {sinceLabel}</span>
|
||||
<span>·</span>
|
||||
<span>{sinceItems.length} items</span>
|
||||
{sinceTags.length > 0 && (
|
||||
<div className="cc-brief-digest-tags">
|
||||
{sinceTags.map((tag) => (
|
||||
<span
|
||||
key={tag.key}
|
||||
className="cc-brief-tag"
|
||||
style={{ borderColor: tagColour(tag.colour) }}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <div className="briefing-state loading-state">loading…</div>}
|
||||
{isError && <div className="briefing-state error-box">Failed to load briefing</div>}
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useState, useMemo, useRef, useCallback } from 'react'
|
||||
import type { EnrichedResult, EnrichedGrid, PositionSample, Lap, Session } from '../types'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session, Chapter } from '../types'
|
||||
import { fetchReplayFrames, fetchTrackOutline } from '../api'
|
||||
import { ReplayTrackMap } from './ReplayTrackMap'
|
||||
import { ChapterStrip } from './ChapterStrip'
|
||||
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
|
||||
import { chapterEndScrub, chapterStartScrub, chapterTourDurations } from '../lib/chapters'
|
||||
|
||||
const CHAPTER_TOUR_MS = 90_000
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
@@ -12,28 +19,181 @@ interface Props {
|
||||
race_control?: any[]
|
||||
pit_stops?: any[]
|
||||
session?: Session
|
||||
meeting?: Meeting
|
||||
drivers?: Driver[]
|
||||
chapters?: Chapter[]
|
||||
}
|
||||
}
|
||||
|
||||
export function RaceStoryCanvas({ data }: Props) {
|
||||
const { results, starting_grid: grid, positions, datasets, race_control = [], pit_stops = [], laps = [] } = data
|
||||
const {
|
||||
results,
|
||||
starting_grid: grid,
|
||||
positions,
|
||||
datasets,
|
||||
race_control = [],
|
||||
pit_stops = [],
|
||||
laps = [],
|
||||
session,
|
||||
meeting,
|
||||
drivers = [],
|
||||
chapters = [],
|
||||
} = data
|
||||
const hasPositions = datasets['positions']?.status === 'available'
|
||||
|
||||
const [scrubTime, setScrubTime] = useState<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 tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 })
|
||||
|
||||
// Position Evolution Chart Logic
|
||||
const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions])
|
||||
const hasChartData = hasPositions && allTimes.length > 0
|
||||
const chartTiming = useMemo(() => {
|
||||
if (!hasChartData) return null
|
||||
const tMin = new Date(allTimes[0]).getTime()
|
||||
const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
|
||||
return { tMin, tMax, tRange: Math.max(tMax - tMin, 1) }
|
||||
}, [allTimes, hasChartData])
|
||||
const circuitKey = session?.circuit_key ?? meeting?.circuit_key ?? 0
|
||||
const outlineYear = meeting?.year ?? (session?.date_start ? new Date(session.date_start).getFullYear() : 0)
|
||||
|
||||
const replayQuery = useQuery({
|
||||
queryKey: ['replay-frames', session?.session_key, 5000],
|
||||
queryFn: () => fetchReplayFrames(session!.session_key, 5000),
|
||||
enabled: mapOpen && Boolean(session?.session_key),
|
||||
})
|
||||
const outlineQuery = useQuery({
|
||||
queryKey: ['track-outline', circuitKey, outlineYear],
|
||||
queryFn: () => fetchTrackOutline(circuitKey, outlineYear),
|
||||
enabled: mapOpen && circuitKey > 0 && outlineYear > 0,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying || !chartTiming) return
|
||||
|
||||
let frame = 0
|
||||
let last = performance.now()
|
||||
const tick = (now: number) => {
|
||||
const delta = now - last
|
||||
last = now
|
||||
setScrubTime((current) => {
|
||||
const next = Math.min(1, (current ?? 0) + (delta * playbackSpeed) / chartTiming.tRange)
|
||||
if (next >= 1) {
|
||||
setIsPlaying(false)
|
||||
}
|
||||
return next
|
||||
})
|
||||
frame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
frame = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [chartTiming, isPlaying, playbackSpeed])
|
||||
|
||||
const stopChapterTour = () => {
|
||||
setChapterTourActive(false)
|
||||
setTourChapterIndex(null)
|
||||
}
|
||||
|
||||
const jumpToChapter = (index: number, scrub: number) => {
|
||||
setIsPlaying(false)
|
||||
stopChapterTour()
|
||||
setScrubTime(scrub)
|
||||
}
|
||||
|
||||
const toggleChapterTour = () => {
|
||||
if (chapterTourActive) {
|
||||
stopChapterTour()
|
||||
return
|
||||
}
|
||||
if (!chartTiming || chapters.length === 0) return
|
||||
setIsPlaying(false)
|
||||
setChapterTourActive(true)
|
||||
setTourChapterIndex(0)
|
||||
const startScrub = chapterStartScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? 0
|
||||
setScrubTime(startScrub)
|
||||
const durations = chapterTourDurations(chapters, CHAPTER_TOUR_MS)
|
||||
tourRef.current = {
|
||||
chapterIndex: 0,
|
||||
startedAt: performance.now(),
|
||||
durationMs: durations[0] ?? CHAPTER_TOUR_MS / chapters.length,
|
||||
startScrub,
|
||||
endScrub: chapterEndScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? startScrub,
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!chapterTourActive || !chartTiming || chapters.length === 0) return
|
||||
|
||||
let frame = 0
|
||||
const tick = (now: number) => {
|
||||
const state = tourRef.current
|
||||
const elapsed = now - state.startedAt
|
||||
const progress = Math.min(1, elapsed / Math.max(state.durationMs, 1))
|
||||
const scrub = state.startScrub + (state.endScrub - state.startScrub) * progress
|
||||
setScrubTime(scrub)
|
||||
setTourChapterIndex(state.chapterIndex)
|
||||
|
||||
if (progress >= 1) {
|
||||
const nextIndex = state.chapterIndex + 1
|
||||
if (nextIndex >= chapters.length) {
|
||||
stopChapterTour()
|
||||
return
|
||||
}
|
||||
const durations = chapterTourDurations(chapters, CHAPTER_TOUR_MS)
|
||||
const startScrub = chapterStartScrub(chapters[nextIndex], chartTiming.tMin, chartTiming.tRange) ?? 0
|
||||
const endScrub = chapterEndScrub(chapters[nextIndex], chartTiming.tMin, chartTiming.tRange) ?? startScrub
|
||||
tourRef.current = {
|
||||
chapterIndex: nextIndex,
|
||||
startedAt: now,
|
||||
durationMs: durations[nextIndex] ?? CHAPTER_TOUR_MS / chapters.length,
|
||||
startScrub,
|
||||
endScrub,
|
||||
}
|
||||
}
|
||||
frame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
frame = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [chapterTourActive, chartTiming, chapters])
|
||||
|
||||
useEffect(() => {
|
||||
if (!chapterTourActive) return
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
stopChapterTour()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [chapterTourActive])
|
||||
|
||||
const replayTMs = useMemo(() => {
|
||||
const replay = replayQuery.data
|
||||
const frames = replay?.frames ?? []
|
||||
if (frames.length === 0) return 0
|
||||
const lastFrameT = frames[frames.length - 1].t
|
||||
const progress = scrubTime ?? 0
|
||||
if (!chartTiming || !replay?.start_time) {
|
||||
return Math.max(0, Math.min(lastFrameT, Math.round(progress * lastFrameT)))
|
||||
}
|
||||
const replayStart = new Date(replay.start_time).getTime()
|
||||
const chartTime = chartTiming.tMin + progress * chartTiming.tRange
|
||||
return Math.max(0, Math.min(lastFrameT, Math.round(chartTime - replayStart)))
|
||||
}, [chartTiming, replayQuery.data, scrubTime])
|
||||
|
||||
let chartContent = null
|
||||
let displayResults = results
|
||||
|
||||
if (hasChartData) {
|
||||
const tMin = new Date(allTimes[0]).getTime()
|
||||
const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
|
||||
const tRange = Math.max(tMax - tMin, 1)
|
||||
if (hasChartData && chartTiming) {
|
||||
const { tMin, tRange } = chartTiming
|
||||
|
||||
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
|
||||
for (const p of positions) {
|
||||
@@ -137,6 +297,8 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
}
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
|
||||
setIsPlaying(false)
|
||||
stopChapterTour()
|
||||
if (!svgRef.current) return
|
||||
const rect = svgRef.current.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
@@ -288,24 +450,91 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
height={plotH}
|
||||
fill="transparent"
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={() => setScrubTime(null)}
|
||||
onPointerLeave={() => {
|
||||
if (!isPlaying) setScrubTime(null)
|
||||
}}
|
||||
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="race-story-canvas">
|
||||
{hasChartData ? (
|
||||
chartContent
|
||||
) : (
|
||||
<div className="analysis-notice">
|
||||
<strong>Lap-by-lap positions not available.</strong> This session does not
|
||||
have ingested position samples in <code>/api/v1/race-hub</code>.
|
||||
</div>
|
||||
{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 ? (
|
||||
chartContent
|
||||
) : (
|
||||
<div className="analysis-notice">
|
||||
<strong>Lap-by-lap positions not available.</strong> This session does not
|
||||
have ingested position samples in <code>/api/v1/race-hub</code>.
|
||||
</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 && (
|
||||
<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)
|
||||
}
|
||||
293
frontend/src/lib/digest.ts
Normal file
293
frontend/src/lib/digest.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { meetingEndTime, meetingStartTime, mostRecentPastMeeting, nextUpcomingMeeting } from './schedule'
|
||||
import type { ChampHubDriver, ChampHubTeam, Meeting, NewsItem } from '../types'
|
||||
|
||||
export interface GPWindow {
|
||||
meeting_key: number
|
||||
meeting_name: string
|
||||
/** Inclusive lower bound: end of previous GP, or open for season opener. */
|
||||
start: Date | null
|
||||
/** Exclusive upper bound: start of next GP, or open after the final round. */
|
||||
end: Date | null
|
||||
}
|
||||
|
||||
export interface DigestTag {
|
||||
kind: 'driver' | 'team'
|
||||
key: string
|
||||
label: string
|
||||
colour: string
|
||||
}
|
||||
|
||||
export type TaggedNewsItem = NewsItem & { tags: DigestTag[] }
|
||||
|
||||
export const RECENT_BUCKET_KEY = '__recent__'
|
||||
|
||||
interface MatchCandidate {
|
||||
start: number
|
||||
end: number
|
||||
tag: DigestTag
|
||||
}
|
||||
|
||||
interface TagPattern {
|
||||
pattern: string
|
||||
tag: DigestTag
|
||||
}
|
||||
|
||||
function sortMeetings(meetings: Meeting[]): Meeting[] {
|
||||
return [...meetings].sort((a, b) => {
|
||||
const left = meetingStartTime(a)?.getTime() ?? 0
|
||||
const right = meetingStartTime(b)?.getTime() ?? 0
|
||||
if (left !== right) return left - right
|
||||
return a.meeting_name.localeCompare(b.meeting_name)
|
||||
})
|
||||
}
|
||||
|
||||
/** Derive inter-race windows [prev GP end → next GP start] for each round. */
|
||||
export function gpWindows(meetings: Meeting[], _now: Date): GPWindow[] {
|
||||
const sorted = sortMeetings(meetings)
|
||||
return sorted.map((meeting, index) => ({
|
||||
meeting_key: meeting.meeting_key,
|
||||
meeting_name: meeting.meeting_name,
|
||||
start: index > 0 ? meetingEndTime(sorted[index - 1]) : null,
|
||||
end: index < sorted.length - 1 ? meetingStartTime(sorted[index + 1]) : null,
|
||||
}))
|
||||
}
|
||||
|
||||
function itemPublishedAt(item: NewsItem): Date | null {
|
||||
if (!item.published_at) return null
|
||||
const parsed = Date.parse(item.published_at)
|
||||
return Number.isNaN(parsed) ? null : new Date(parsed)
|
||||
}
|
||||
|
||||
function itemInWindow(published: Date, window: GPWindow): boolean {
|
||||
if (window.start && published < window.start) return false
|
||||
if (window.end && published >= window.end) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function windowForDate(windows: GPWindow[], published: Date): GPWindow | null {
|
||||
const matches = windows.filter((window) => itemInWindow(published, window))
|
||||
if (matches.length === 0) return null
|
||||
return matches.reduce((best, window) => {
|
||||
const bestStart = best.start?.getTime() ?? Number.NEGATIVE_INFINITY
|
||||
const windowStart = window.start?.getTime() ?? Number.NEGATIVE_INFINITY
|
||||
return windowStart >= bestStart ? window : best
|
||||
})
|
||||
}
|
||||
|
||||
export interface GroupedDigest {
|
||||
windows: Array<{ window: GPWindow; items: NewsItem[] }>
|
||||
recent: NewsItem[]
|
||||
}
|
||||
|
||||
/** Bucket news items into GP windows; undated items land in `recent`. */
|
||||
export function groupByWindow(items: NewsItem[], windows: GPWindow[]): GroupedDigest {
|
||||
const buckets = new Map<number, NewsItem[]>()
|
||||
const recent: NewsItem[] = []
|
||||
|
||||
for (const item of items) {
|
||||
const published = itemPublishedAt(item)
|
||||
if (!published) {
|
||||
recent.push(item)
|
||||
continue
|
||||
}
|
||||
const match = windowForDate(windows, published)
|
||||
if (!match) {
|
||||
recent.push(item)
|
||||
continue
|
||||
}
|
||||
const list = buckets.get(match.meeting_key) ?? []
|
||||
list.push(item)
|
||||
buckets.set(match.meeting_key, list)
|
||||
}
|
||||
|
||||
const grouped = windows
|
||||
.map((window) => ({
|
||||
window,
|
||||
items: buckets.get(window.meeting_key) ?? [],
|
||||
}))
|
||||
.filter((entry) => entry.items.length > 0)
|
||||
|
||||
return { windows: grouped, recent }
|
||||
}
|
||||
|
||||
function driverLastName(fullName: string): string | null {
|
||||
const parts = fullName.trim().split(/\s+/).filter(Boolean)
|
||||
if (parts.length < 2) return null
|
||||
return parts[parts.length - 1] ?? null
|
||||
}
|
||||
|
||||
function buildTagPatterns(drivers: ChampHubDriver[], teams: ChampHubTeam[]): TagPattern[] {
|
||||
const patterns: TagPattern[] = []
|
||||
|
||||
for (const team of teams) {
|
||||
if (!team.team_name.trim()) continue
|
||||
patterns.push({
|
||||
pattern: team.team_name.toLowerCase(),
|
||||
tag: {
|
||||
kind: 'team',
|
||||
key: `team:${team.team_name}`,
|
||||
label: team.team_name,
|
||||
colour: team.team_colour,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
for (const driver of drivers) {
|
||||
const colour = driver.team_colour
|
||||
const base = {
|
||||
kind: 'driver' as const,
|
||||
colour,
|
||||
}
|
||||
if (driver.full_name.trim()) {
|
||||
patterns.push({
|
||||
pattern: driver.full_name.toLowerCase(),
|
||||
tag: {
|
||||
...base,
|
||||
key: `driver:${driver.name_acronym}`,
|
||||
label: driver.name_acronym,
|
||||
},
|
||||
})
|
||||
const lastName = driverLastName(driver.full_name)
|
||||
if (lastName) {
|
||||
patterns.push({
|
||||
pattern: lastName.toLowerCase(),
|
||||
tag: {
|
||||
...base,
|
||||
key: `driver:${driver.name_acronym}`,
|
||||
label: driver.name_acronym,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
if (driver.name_acronym.trim()) {
|
||||
patterns.push({
|
||||
pattern: driver.name_acronym.toLowerCase(),
|
||||
tag: {
|
||||
...base,
|
||||
key: `driver:${driver.name_acronym}`,
|
||||
label: driver.name_acronym,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
patterns.sort((a, b) => b.pattern.length - a.pattern.length)
|
||||
return patterns
|
||||
}
|
||||
|
||||
function overlaps(a: MatchCandidate, b: MatchCandidate): boolean {
|
||||
return a.start < b.end && b.start < a.end
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag items via case-insensitive substring match on title + summary.
|
||||
* Longest pattern wins on overlapping spans (v1 string match — no NLP).
|
||||
*
|
||||
* Known limitations: common-word last names, team names inside other words,
|
||||
* and driver TLAs that appear in unrelated acronyms can false-positive.
|
||||
*/
|
||||
export function tagItems(
|
||||
items: NewsItem[],
|
||||
drivers: ChampHubDriver[],
|
||||
teams: ChampHubTeam[],
|
||||
): TaggedNewsItem[] {
|
||||
const patterns = buildTagPatterns(drivers, teams)
|
||||
|
||||
return items.map((item) => {
|
||||
const haystack = `${item.title} ${item.summary ?? ''}`.toLowerCase()
|
||||
const accepted: MatchCandidate[] = []
|
||||
|
||||
for (const { pattern, tag } of patterns) {
|
||||
if (!pattern) continue
|
||||
let from = 0
|
||||
while (from <= haystack.length - pattern.length) {
|
||||
const index = haystack.indexOf(pattern, from)
|
||||
if (index === -1) break
|
||||
const candidate: MatchCandidate = {
|
||||
start: index,
|
||||
end: index + pattern.length,
|
||||
tag,
|
||||
}
|
||||
if (!accepted.some((match) => overlaps(match, candidate))) {
|
||||
accepted.push(candidate)
|
||||
}
|
||||
from = index + 1
|
||||
}
|
||||
}
|
||||
|
||||
const tagsByKey = new Map<string, DigestTag>()
|
||||
for (const match of accepted) {
|
||||
tagsByKey.set(match.tag.key, match.tag)
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
tags: [...tagsByKey.values()].sort((a, b) => a.label.localeCompare(b.label)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function activeDigestWindow(
|
||||
windows: GPWindow[],
|
||||
meetings: Meeting[],
|
||||
now: Date,
|
||||
): GPWindow | null {
|
||||
const next = nextUpcomingMeeting(meetings, now)
|
||||
if (next) {
|
||||
return windows.find((window) => window.meeting_key === next.meeting_key) ?? null
|
||||
}
|
||||
const recent = mostRecentPastMeeting(meetings, now)
|
||||
if (recent) {
|
||||
return windows.find((window) => window.meeting_key === recent.meeting_key) ?? null
|
||||
}
|
||||
return windows[0] ?? null
|
||||
}
|
||||
|
||||
export function sinceLastLabel(meetings: Meeting[], now: Date): string {
|
||||
const last = mostRecentPastMeeting(meetings, now)
|
||||
if (last) return last.meeting_name
|
||||
const next = nextUpcomingMeeting(meetings, now)
|
||||
if (next) return `Before ${next.meeting_name}`
|
||||
return 'Pre-season'
|
||||
}
|
||||
|
||||
export function itemsForWindow(items: TaggedNewsItem[], window: GPWindow | null): TaggedNewsItem[] {
|
||||
if (!window) return []
|
||||
return items.filter((item) => {
|
||||
const published = itemPublishedAt(item)
|
||||
return published != null && itemInWindow(published, window)
|
||||
})
|
||||
}
|
||||
|
||||
export function topTags(items: TaggedNewsItem[], limit = 5): DigestTag[] {
|
||||
const counts = new Map<string, { tag: DigestTag; count: number }>()
|
||||
for (const item of items) {
|
||||
for (const tag of item.tags) {
|
||||
const current = counts.get(tag.key)
|
||||
if (current) current.count += 1
|
||||
else counts.set(tag.key, { tag, count: 1 })
|
||||
}
|
||||
}
|
||||
return [...counts.values()]
|
||||
.sort((a, b) => b.count - a.count || a.tag.label.localeCompare(b.tag.label))
|
||||
.slice(0, limit)
|
||||
.map((entry) => entry.tag)
|
||||
}
|
||||
|
||||
export function filterByTag(items: TaggedNewsItem[], tagKey: string | null): TaggedNewsItem[] {
|
||||
if (!tagKey) return items
|
||||
return items.filter((item) => item.tags.some((tag) => tag.key === tagKey))
|
||||
}
|
||||
|
||||
export function tagColour(colour: string): string {
|
||||
if (!colour) return 'var(--text-3)'
|
||||
return colour.startsWith('#') ? colour : `#${colour}`
|
||||
}
|
||||
|
||||
export function sortWindowBucketsNewestFirst<T extends { window: GPWindow }>(buckets: T[]): T[] {
|
||||
return [...buckets].sort((a, b) => {
|
||||
const left = a.window.start?.getTime() ?? 0
|
||||
const right = b.window.start?.getTime() ?? 0
|
||||
return right - left
|
||||
})
|
||||
}
|
||||
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() || '—'
|
||||
}
|
||||
|
||||
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
|
||||
const code = meeting?.country_code?.toUpperCase()
|
||||
const iso2 = code ? CODE3_TO_2[code] : ''
|
||||
|
||||
123
frontend/src/lib/preview.ts
Normal file
123
frontend/src/lib/preview.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session } from '../types'
|
||||
import {
|
||||
currentAndNextSession,
|
||||
nextUpcomingMeeting,
|
||||
sessionStartTime,
|
||||
sortSessionsByStart,
|
||||
} from './schedule'
|
||||
|
||||
export const RACE_WIN_POINTS = 25
|
||||
export const SPRINT_WIN_POINTS = 8
|
||||
export const SPRINT_WEEKEND_MAX_POINTS = RACE_WIN_POINTS + SPRINT_WIN_POINTS
|
||||
|
||||
export function pickPreviewMeeting(meetings: Meeting[], now: Date): Meeting | null {
|
||||
const active = meetings.filter((m) => !m.is_cancelled)
|
||||
return nextUpcomingMeeting(active, now)
|
||||
}
|
||||
|
||||
export function findPriorYearMeetingByCircuit(
|
||||
priorYearMeetings: Meeting[],
|
||||
circuitKey: number | undefined,
|
||||
): Meeting | null {
|
||||
if (!circuitKey) return null
|
||||
return priorYearMeetings.find((m) => m.circuit_key === circuitKey && !m.is_cancelled) ?? null
|
||||
}
|
||||
|
||||
export function findRaceSession(sessions: Session[]): Session | null {
|
||||
const sorted = sortSessionsByStart(sessions)
|
||||
return (
|
||||
sorted.find((s) => {
|
||||
const type = s.session_type?.toLowerCase() ?? ''
|
||||
return type.includes('race') && !type.includes('sprint')
|
||||
}) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
export function isSprintWeekend(sessions: Session[]): boolean {
|
||||
return sessions.some((s) => {
|
||||
const label = `${s.session_type} ${s.session_name}`.toLowerCase()
|
||||
return /\bsprint\b/.test(label) && !label.includes('sprint qualifying') && !label.includes('sprint shootout')
|
||||
})
|
||||
}
|
||||
|
||||
export interface PodiumPlace {
|
||||
position: number
|
||||
name_acronym: string
|
||||
full_name: string
|
||||
team_name: string
|
||||
team_colour: string
|
||||
}
|
||||
|
||||
export interface PolePosition {
|
||||
name_acronym: string
|
||||
full_name: string
|
||||
team_name: string
|
||||
team_colour: string
|
||||
}
|
||||
|
||||
export function extractPodium(results: EnrichedResult[]): PodiumPlace[] {
|
||||
return results
|
||||
.filter((r) => r.position >= 1 && r.position <= 3 && !r.dns && !r.dsq)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((r) => ({
|
||||
position: r.position,
|
||||
name_acronym: r.name_acronym,
|
||||
full_name: r.full_name,
|
||||
team_name: r.team_name,
|
||||
team_colour: r.team_colour,
|
||||
}))
|
||||
}
|
||||
|
||||
export function extractPole(grid: EnrichedGrid[]): PolePosition | null {
|
||||
const pole = grid.find((g) => g.position === 1)
|
||||
if (!pole) return null
|
||||
return {
|
||||
name_acronym: pole.name_acronym,
|
||||
full_name: pole.full_name,
|
||||
team_name: pole.team_name,
|
||||
team_colour: pole.team_colour,
|
||||
}
|
||||
}
|
||||
|
||||
export interface TitleFightDriver {
|
||||
name_acronym: string
|
||||
full_name: string
|
||||
team_colour: string
|
||||
points: number
|
||||
position: number
|
||||
gapToLeader: number
|
||||
gapAfterRaceWin: number
|
||||
gapAfterSprintWeekendMax: number
|
||||
}
|
||||
|
||||
export function buildTitleFightContext(hub: ChampionshipHub): TitleFightDriver[] {
|
||||
if (hub.drivers.length === 0) return []
|
||||
const leader = hub.drivers[0]
|
||||
return hub.drivers.slice(0, 3).map((d) => ({
|
||||
name_acronym: d.name_acronym,
|
||||
full_name: d.full_name,
|
||||
team_colour: d.team_colour,
|
||||
points: d.points,
|
||||
position: d.position,
|
||||
gapToLeader: leader.points - d.points,
|
||||
gapAfterRaceWin: leader.points - (d.points + RACE_WIN_POINTS),
|
||||
gapAfterSprintWeekendMax: leader.points - (d.points + SPRINT_WEEKEND_MAX_POINTS),
|
||||
}))
|
||||
}
|
||||
|
||||
export function countdownTargetSession(sessions: Session[], now: Date): Session | null {
|
||||
const sorted = sortSessionsByStart(sessions)
|
||||
const { next } = currentAndNextSession(sorted, now)
|
||||
if (next) return next
|
||||
for (const session of sorted) {
|
||||
const start = sessionStartTime(session)
|
||||
if (start && start > now) return session
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function formatPointsGap(gap: number): string {
|
||||
if (gap === 0) return 'LEADER'
|
||||
const sign = gap > 0 ? '+' : ''
|
||||
return `${sign}${Number.isInteger(gap) ? gap : gap.toFixed(1)}`
|
||||
}
|
||||
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,13 +1,33 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { fetchNews, fetchNewsArticle, markNewsRead } from '../api'
|
||||
import {
|
||||
fetchChampionshipHub,
|
||||
fetchNews,
|
||||
fetchNewsArticle,
|
||||
fetchSeasonMeetings,
|
||||
fetchSeasons,
|
||||
markNewsRead,
|
||||
} from '../api'
|
||||
import {
|
||||
activeDigestWindow,
|
||||
filterByTag,
|
||||
groupByWindow,
|
||||
gpWindows,
|
||||
itemsForWindow,
|
||||
sinceLastLabel,
|
||||
sortWindowBucketsNewestFirst,
|
||||
tagColour,
|
||||
tagItems,
|
||||
topTags,
|
||||
type DigestTag,
|
||||
type TaggedNewsItem,
|
||||
} from '../lib/digest'
|
||||
import { stripHtml, timeAgo } from '../utils'
|
||||
import type { ArticleContent, NewsItem } from '../types'
|
||||
import '../styles/digest.css'
|
||||
|
||||
type Category = 'all' | 'official' | 'news' | 'video'
|
||||
|
||||
const PAGE_SIZE = 16
|
||||
|
||||
const CATEGORY_LABELS: Record<Category, string> = {
|
||||
all: 'All',
|
||||
official: 'Official',
|
||||
@@ -71,6 +91,31 @@ function CategoryTabs({
|
||||
)
|
||||
}
|
||||
|
||||
function TagChip({
|
||||
tag,
|
||||
active,
|
||||
onClick,
|
||||
className = 'digest-tag-chip',
|
||||
}: {
|
||||
tag: DigestTag
|
||||
active?: boolean
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`${className}${active ? ' active' : ''}`}
|
||||
style={{ '--tag-colour': tagColour(tag.colour) } as React.CSSProperties}
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<span className="digest-tag-dot" aria-hidden="true" />
|
||||
{tag.label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function OGImage({ url, title }: { url?: string; title: string }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
const initial = (title[0] ?? '?').toUpperCase()
|
||||
@@ -98,11 +143,15 @@ function OGImage({ url, title }: { url?: string; title: string }) {
|
||||
function BriefingCard({
|
||||
item,
|
||||
isActive,
|
||||
activeTag,
|
||||
onSelect,
|
||||
onTagClick,
|
||||
}: {
|
||||
item: NewsItem
|
||||
item: TaggedNewsItem
|
||||
isActive: boolean
|
||||
activeTag: string | null
|
||||
onSelect: (item: NewsItem) => void
|
||||
onTagClick: (tag: DigestTag) => void
|
||||
}) {
|
||||
const isRead = !!item.read_at
|
||||
const isVideo = categoryOf(item) === 'video'
|
||||
@@ -129,6 +178,25 @@ function BriefingCard({
|
||||
{stripHtml(item.og_description || item.summary || '')}
|
||||
</p>
|
||||
)}
|
||||
{item.tags.length > 0 && (
|
||||
<div className="bp-card-tags">
|
||||
{item.tags.map((tag) => (
|
||||
<button
|
||||
key={tag.key}
|
||||
type="button"
|
||||
className={`bp-card-tag${activeTag === tag.key ? ' active' : ''}`}
|
||||
style={{ '--tag-colour': tagColour(tag.colour) } as React.CSSProperties}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onTagClick(tag)
|
||||
}}
|
||||
>
|
||||
<span className="digest-tag-dot" aria-hidden="true" />
|
||||
{tag.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
@@ -146,7 +214,6 @@ function ReaderPanel({
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
@@ -155,7 +222,6 @@ function ReaderPanel({
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [onClose])
|
||||
|
||||
// Fetch article when item changes
|
||||
useEffect(() => {
|
||||
if (!item) {
|
||||
setArticle(null)
|
||||
@@ -175,9 +241,14 @@ function ReaderPanel({
|
||||
.catch((err) => { setError(String(err)); setLoading(false) })
|
||||
}, [item?.url, item ? categoryOf(item) : ''])
|
||||
|
||||
// Scroll panel to top when item changes
|
||||
useEffect(() => {
|
||||
panelRef.current?.scrollTo({ top: 0 })
|
||||
const panel = panelRef.current
|
||||
if (!panel) return
|
||||
if (typeof panel.scrollTo === 'function') {
|
||||
panel.scrollTo({ top: 0 })
|
||||
} else {
|
||||
panel.scrollTop = 0
|
||||
}
|
||||
}, [item?.url])
|
||||
|
||||
const isOpen = item !== null
|
||||
@@ -297,7 +368,6 @@ function ReaderPanel({
|
||||
{article && !loading && article.content && (
|
||||
<div
|
||||
className="bp-reader-body"
|
||||
// readability strips scripts/iframes; sources are all known news outlets
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: article.content }}
|
||||
/>
|
||||
@@ -332,8 +402,9 @@ function ReaderPanel({
|
||||
export function BriefingPage() {
|
||||
const [activeCategory, setActiveCategory] = useState<Category>('all')
|
||||
const [selectedItem, setSelectedItem] = useState<NewsItem | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [activeTag, setActiveTag] = useState<string | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
const now = useMemo(() => new Date(), [])
|
||||
|
||||
const { data: allNews = [], isLoading, isError } = useQuery({
|
||||
queryKey: ['news'],
|
||||
@@ -341,13 +412,74 @@ export function BriefingPage() {
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const filtered = allNews.filter(
|
||||
(item) => activeCategory === 'all' || categoryOf(item) === activeCategory,
|
||||
const seasonsQuery = useQuery({
|
||||
queryKey: ['seasons'],
|
||||
queryFn: fetchSeasons,
|
||||
})
|
||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||
|
||||
const meetingsQuery = useQuery({
|
||||
queryKey: ['season-meetings', latestSeason],
|
||||
queryFn: () => fetchSeasonMeetings(latestSeason!),
|
||||
enabled: latestSeason != null,
|
||||
})
|
||||
|
||||
const hubQuery = useQuery({
|
||||
queryKey: ['championship-hub', latestSeason],
|
||||
queryFn: () => fetchChampionshipHub(latestSeason!),
|
||||
enabled: latestSeason != null,
|
||||
})
|
||||
|
||||
const meetings = meetingsQuery.data ?? []
|
||||
const hub = hubQuery.data
|
||||
|
||||
const taggedNews = useMemo(
|
||||
() => tagItems(allNews, hub?.drivers ?? [], hub?.teams ?? []),
|
||||
[allNews, hub],
|
||||
)
|
||||
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
||||
const safePage = Math.min(page, pageCount)
|
||||
const pageStart = (safePage - 1) * PAGE_SIZE
|
||||
const pagedItems = filtered.slice(pageStart, pageStart + PAGE_SIZE)
|
||||
|
||||
const categoryFiltered = useMemo(
|
||||
() => taggedNews.filter(
|
||||
(item) => activeCategory === 'all' || categoryOf(item) === activeCategory,
|
||||
),
|
||||
[taggedNews, activeCategory],
|
||||
)
|
||||
|
||||
const tagFiltered = useMemo(
|
||||
() => filterByTag(categoryFiltered, activeTag),
|
||||
[categoryFiltered, activeTag],
|
||||
)
|
||||
|
||||
const windows = useMemo(() => gpWindows(meetings, now), [meetings, now])
|
||||
const grouped = useMemo(() => groupByWindow(tagFiltered, windows), [tagFiltered, windows])
|
||||
const windowSections = useMemo(
|
||||
() => sortWindowBucketsNewestFirst(grouped.windows),
|
||||
[grouped.windows],
|
||||
)
|
||||
|
||||
const activeWindow = useMemo(
|
||||
() => activeDigestWindow(windows, meetings, now),
|
||||
[windows, meetings, now],
|
||||
)
|
||||
|
||||
const sinceLastItems = useMemo(
|
||||
() => itemsForWindow(tagFiltered, activeWindow),
|
||||
[tagFiltered, activeWindow],
|
||||
)
|
||||
|
||||
const sinceLastTags = useMemo(() => topTags(sinceLastItems), [sinceLastItems])
|
||||
const sinceLabel = sinceLastLabel(meetings, now)
|
||||
|
||||
const taggedByUrl = useMemo(() => {
|
||||
const map = new Map<string, TaggedNewsItem>()
|
||||
for (const item of tagFiltered) map.set(item.url, item)
|
||||
return map
|
||||
}, [tagFiltered])
|
||||
|
||||
const recentTagged = useMemo(() => {
|
||||
const urls = new Set(grouped.recent.map((item) => item.url))
|
||||
return tagFiltered.filter((item) => urls.has(item.url))
|
||||
}, [grouped.recent, tagFiltered])
|
||||
|
||||
const counts: Record<Category, number> = {
|
||||
all: allNews.length,
|
||||
@@ -374,22 +506,14 @@ export function BriefingPage() {
|
||||
|
||||
const handleClose = useCallback(() => setSelectedItem(null), [])
|
||||
|
||||
useEffect(() => {
|
||||
setPage((current) => Math.min(current, pageCount))
|
||||
}, [pageCount])
|
||||
|
||||
const handlePageChange = useCallback(
|
||||
(nextPage: number) => {
|
||||
setPage(Math.min(Math.max(nextPage, 1), pageCount))
|
||||
setSelectedItem(null)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
},
|
||||
[pageCount],
|
||||
)
|
||||
const handleTagClick = useCallback((tag: DigestTag) => {
|
||||
setActiveTag((current) => (current === tag.key ? null : tag.key))
|
||||
setSelectedItem(null)
|
||||
}, [])
|
||||
|
||||
const unreadCount = allNews.filter((i) => !i.read_at).length
|
||||
const pageEnd = Math.min(pageStart + pagedItems.length, filtered.length)
|
||||
const showPagination = filtered.length > PAGE_SIZE
|
||||
const hasDigest = meetings.length > 0
|
||||
const showEmpty = tagFiltered.length === 0 && grouped.recent.length === 0
|
||||
|
||||
return (
|
||||
<div className="bp-page" data-testid="briefing-page">
|
||||
@@ -408,74 +532,106 @@ export function BriefingPage() {
|
||||
<CategoryTabs
|
||||
active={activeCategory}
|
||||
counts={counts}
|
||||
onChange={(c) => { setActiveCategory(c); setSelectedItem(null); setPage(1) }}
|
||||
onChange={(c) => {
|
||||
setActiveCategory(c)
|
||||
setSelectedItem(null)
|
||||
setActiveTag(null)
|
||||
}}
|
||||
/>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
{hasDigest && (
|
||||
<div className="digest-sticky" data-testid="digest-sticky-header">
|
||||
<div className="digest-sticky-head">
|
||||
<h2 className="digest-sticky-title">Since {sinceLabel}</h2>
|
||||
<span className="digest-sticky-count">
|
||||
{sinceLastItems.length} item{sinceLastItems.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
{(sinceLastTags.length > 0 || activeTag) && (
|
||||
<div className="digest-sticky-tags">
|
||||
{sinceLastTags.map((tag) => (
|
||||
<TagChip
|
||||
key={tag.key}
|
||||
tag={tag}
|
||||
active={activeTag === tag.key}
|
||||
onClick={() => handleTagClick(tag)}
|
||||
/>
|
||||
))}
|
||||
{activeTag && (
|
||||
<button
|
||||
type="button"
|
||||
className="digest-filter-clear"
|
||||
onClick={() => setActiveTag(null)}
|
||||
data-testid="digest-filter-clear"
|
||||
>
|
||||
Clear filter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEmpty ? (
|
||||
<div className="bp-empty">
|
||||
No {activeCategory !== 'all' ? activeCategory : ''} items available.
|
||||
Run <code>box-box --ingest-news</code> to refresh feeds.
|
||||
{activeTag ? ' Try clearing the tag filter.' : ''}
|
||||
{!activeTag && (
|
||||
<>
|
||||
{' '}
|
||||
Run <code>box-box --ingest-news</code> to refresh feeds.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{showPagination && (
|
||||
<div className="bp-pagination bp-pagination-top">
|
||||
<span className="bp-pagination-meta mono">
|
||||
{pageStart + 1}-{pageEnd} of {filtered.length}
|
||||
</span>
|
||||
<div className="bp-pagination-actions">
|
||||
<button
|
||||
className="bp-page-btn"
|
||||
onClick={() => handlePageChange(safePage - 1)}
|
||||
disabled={safePage === 1}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="bp-page-current mono">
|
||||
Page {safePage} / {pageCount}
|
||||
</span>
|
||||
<button
|
||||
className="bp-page-btn"
|
||||
onClick={() => handlePageChange(safePage + 1)}
|
||||
disabled={safePage === pageCount}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
{windowSections.map(({ window, items }) => (
|
||||
<section
|
||||
key={window.meeting_key}
|
||||
className="digest-section"
|
||||
data-testid={`digest-window-${window.meeting_key}`}
|
||||
>
|
||||
<div className="digest-section-head">
|
||||
<h3 className="digest-section-title">{window.meeting_name}</h3>
|
||||
<span className="digest-section-count">{items.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
|
||||
{items.map((item) => {
|
||||
const tagged = taggedByUrl.get(item.url) ?? { ...item, tags: [] }
|
||||
return (
|
||||
<BriefingCard
|
||||
key={item.url}
|
||||
item={tagged}
|
||||
isActive={selectedItem?.url === item.url}
|
||||
activeTag={activeTag}
|
||||
onSelect={handleSelect}
|
||||
onTagClick={handleTagClick}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
|
||||
{pagedItems.map((item) => (
|
||||
<BriefingCard
|
||||
key={item.url}
|
||||
item={item}
|
||||
isActive={selectedItem?.url === item.url}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showPagination && (
|
||||
<div className="bp-pagination bp-pagination-bottom">
|
||||
<button
|
||||
className="bp-page-btn"
|
||||
onClick={() => handlePageChange(safePage - 1)}
|
||||
disabled={safePage === 1}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="bp-page-current mono">
|
||||
Page {safePage} / {pageCount}
|
||||
</span>
|
||||
<button
|
||||
className="bp-page-btn"
|
||||
onClick={() => handlePageChange(safePage + 1)}
|
||||
disabled={safePage === pageCount}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
{recentTagged.length > 0 && (
|
||||
<section className="digest-section digest-recent-section" data-testid="digest-recent">
|
||||
<div className="digest-section-head">
|
||||
<h3 className="digest-section-title">Recent</h3>
|
||||
<span className="digest-section-count">{recentTagged.length}</span>
|
||||
</div>
|
||||
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
|
||||
{recentTagged.map((item) => (
|
||||
<BriefingCard
|
||||
key={item.url}
|
||||
item={item}
|
||||
isActive={selectedItem?.url === item.url}
|
||||
activeTag={activeTag}
|
||||
onSelect={handleSelect}
|
||||
onTagClick={handleTagClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { fetchChampionshipHub, fetchSeasons } from '../api'
|
||||
import { teamColor } from '../utils'
|
||||
import type { ChampHubDriver, ChampionshipHub } from '../types'
|
||||
@@ -245,6 +246,7 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
||||
titleMath={titleMath}
|
||||
roundsLeft={hub.rounds_left}
|
||||
drivers={drivers}
|
||||
season={hub.season}
|
||||
/>
|
||||
)}
|
||||
{view === 'constructors' && <ConstructorsView hub={hub} />}
|
||||
@@ -275,12 +277,14 @@ function DriversView({
|
||||
titleMath,
|
||||
roundsLeft,
|
||||
drivers,
|
||||
season,
|
||||
}: {
|
||||
enriched: EnrichedDriver[]
|
||||
leaderPoints: number
|
||||
titleMath: string
|
||||
roundsLeft: number
|
||||
drivers: ChampHubDriver[]
|
||||
season: number
|
||||
}) {
|
||||
const podium = enriched.slice(0, 3)
|
||||
const h2hPairs = useMemo(() => teammatePairs(drivers), [drivers])
|
||||
@@ -390,12 +394,19 @@ function DriversView({
|
||||
P{e.pos}
|
||||
</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-code mono">{e.d.name_acronym}</span>
|
||||
<span className="champ-drv-name">{e.d.full_name}</span>
|
||||
<span className="champ-drv-num mono">#{e.d.driver_number}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="champ-td-team">{e.d.team_name}</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>
|
||||
)
|
||||
}
|
||||
442
frontend/src/pages/RacePreviewPage.tsx
Normal file
442
frontend/src/pages/RacePreviewPage.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
fetchChampionshipHub,
|
||||
fetchMeetings,
|
||||
fetchResults,
|
||||
fetchSeasons,
|
||||
fetchSessions,
|
||||
fetchStartingGrid,
|
||||
fetchTrackOutline,
|
||||
} from '../api'
|
||||
import { countryAccent, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
|
||||
import {
|
||||
buildTitleFightContext,
|
||||
countdownTargetSession,
|
||||
extractPodium,
|
||||
extractPole,
|
||||
findPriorYearMeetingByCircuit,
|
||||
findRaceSession,
|
||||
formatPointsGap,
|
||||
isSprintWeekend,
|
||||
pickPreviewMeeting,
|
||||
RACE_WIN_POINTS,
|
||||
SPRINT_WEEKEND_MAX_POINTS,
|
||||
} from '../lib/preview'
|
||||
import { formatCountdown, formatSessionScheduleTime, sessionStartTime, sortSessionsByStart } from '../lib/schedule'
|
||||
import { buildOutlinePath } from '../lib/trackmap'
|
||||
import { teamColor } from '../utils'
|
||||
import type { Meeting, Session, TrackOutline } from '../types'
|
||||
import '../styles/preview.css'
|
||||
|
||||
function SectionState({
|
||||
loading,
|
||||
error,
|
||||
empty,
|
||||
emptyMessage,
|
||||
children,
|
||||
}: {
|
||||
loading?: boolean
|
||||
error?: Error | null
|
||||
empty?: boolean
|
||||
emptyMessage?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
if (loading) {
|
||||
return <div className="preview-section-state">Loading…</div>
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="preview-section-state error">
|
||||
{error instanceof Error ? error.message : 'Failed to load'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (empty) {
|
||||
return <div className="preview-section-state">{emptyMessage ?? 'No data available'}</div>
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function TrackOutlineCard({
|
||||
outline,
|
||||
loading,
|
||||
error,
|
||||
accent,
|
||||
}: {
|
||||
outline: TrackOutline | null | undefined
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
accent: string
|
||||
}) {
|
||||
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
|
||||
|
||||
return (
|
||||
<section className="preview-card" data-testid="preview-track-card">
|
||||
<h2 className="preview-card-title">Circuit</h2>
|
||||
<SectionState
|
||||
loading={loading}
|
||||
error={error}
|
||||
empty={!outline || !outlinePath}
|
||||
emptyMessage="Track outline unavailable for this circuit"
|
||||
>
|
||||
<div className="preview-track-stage" style={{ ['--preview-accent' as string]: accent }}>
|
||||
<svg className="preview-track-svg" viewBox="0 0 100 100" role="img" aria-label="Circuit outline">
|
||||
<path className="preview-track-shadow" d={outlinePath} />
|
||||
<path className="preview-track-outline" d={outlinePath} />
|
||||
</svg>
|
||||
</div>
|
||||
</SectionState>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function LastYearCard({
|
||||
year,
|
||||
loading,
|
||||
error,
|
||||
podium,
|
||||
pole,
|
||||
isFirstTime,
|
||||
}: {
|
||||
year: number | null
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
podium: ReturnType<typeof extractPodium>
|
||||
pole: ReturnType<typeof extractPole>
|
||||
isFirstTime: boolean
|
||||
}) {
|
||||
return (
|
||||
<section className="preview-card" data-testid="preview-last-year-card">
|
||||
<h2 className="preview-card-title">Last year here</h2>
|
||||
{year != null && !isFirstTime && <p className="preview-card-sub">{year} race weekend</p>}
|
||||
<SectionState
|
||||
loading={loading}
|
||||
error={error}
|
||||
empty={isFirstTime || podium.length === 0}
|
||||
emptyMessage={
|
||||
isFirstTime
|
||||
? 'First time on the calendar — no prior race at this circuit'
|
||||
: 'No race results available for the previous visit'
|
||||
}
|
||||
>
|
||||
<div className="preview-podium">
|
||||
{podium.map((place) => (
|
||||
<div key={place.position} className="preview-podium-row">
|
||||
<span className="preview-podium-pos">P{place.position}</span>
|
||||
<span className="preview-podium-code" style={{ color: teamColor(place.team_colour) }}>
|
||||
{place.name_acronym}
|
||||
</span>
|
||||
<span className="preview-podium-team">{place.team_name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{pole && (
|
||||
<p className="preview-pole">
|
||||
Pole: <strong style={{ color: teamColor(pole.team_colour) }}>{pole.name_acronym}</strong>{' '}
|
||||
({pole.team_name})
|
||||
</p>
|
||||
)}
|
||||
</SectionState>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function TitleFightCard({
|
||||
loading,
|
||||
error,
|
||||
drivers,
|
||||
sprintWeekend,
|
||||
season,
|
||||
}: {
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
drivers: ReturnType<typeof buildTitleFightContext>
|
||||
sprintWeekend: boolean
|
||||
season: number | null
|
||||
}) {
|
||||
return (
|
||||
<section className="preview-card preview-card--wide" data-testid="preview-title-fight-card">
|
||||
<h2 className="preview-card-title">Title fight context</h2>
|
||||
{season != null && <p className="preview-card-sub">{season} drivers' championship · top 3</p>}
|
||||
<SectionState
|
||||
loading={loading}
|
||||
error={error}
|
||||
empty={drivers.length === 0}
|
||||
emptyMessage="Championship standings not available"
|
||||
>
|
||||
<table className="preview-title-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Driver</th>
|
||||
<th>Pts</th>
|
||||
<th>Gap</th>
|
||||
<th>If win (+{RACE_WIN_POINTS})</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{drivers.map((d) => (
|
||||
<tr key={d.name_acronym}>
|
||||
<td>
|
||||
<span style={{ color: teamColor(d.team_colour), fontWeight: 600 }}>{d.name_acronym}</span>
|
||||
</td>
|
||||
<td className="mono">{d.points}</td>
|
||||
<td className="mono">{formatPointsGap(d.gapToLeader)}</td>
|
||||
<td className="mono">
|
||||
{d.position === 1 ? 'LEADER' : formatPointsGap(d.gapAfterRaceWin)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{sprintWeekend && (
|
||||
<p className="preview-sprint-note" data-testid="preview-sprint-note">
|
||||
Sprint weekend: up to {SPRINT_WEEKEND_MAX_POINTS} pts available (race win {RACE_WIN_POINTS} + sprint win
|
||||
8). Max gain would reduce gaps by {SPRINT_WEEKEND_MAX_POINTS} vs a standard race win.
|
||||
</p>
|
||||
)}
|
||||
</SectionState>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewHeader({
|
||||
meeting,
|
||||
sessions,
|
||||
countdownSession,
|
||||
now,
|
||||
accent,
|
||||
}: {
|
||||
meeting: Meeting
|
||||
sessions: Session[]
|
||||
countdownSession: Session | null
|
||||
now: Date
|
||||
accent: string
|
||||
}) {
|
||||
const countdownTarget = countdownSession ? sessionStartTime(countdownSession) : null
|
||||
|
||||
return (
|
||||
<header
|
||||
className="preview-header"
|
||||
data-testid="preview-header"
|
||||
style={{ ['--preview-accent' as string]: accent }}
|
||||
>
|
||||
<div className="preview-header-accent" />
|
||||
<div className="preview-header-body">
|
||||
<div className="preview-header-top">
|
||||
<div>
|
||||
<h1 className="preview-gp-name">
|
||||
{countryFlag(meeting) && <span className="preview-gp-flag">{countryFlag(meeting)}</span>}
|
||||
{meeting.meeting_name}
|
||||
</h1>
|
||||
<p className="preview-circuit">
|
||||
{meeting.circuit_short_name}
|
||||
{meeting.location ? ` · ${meeting.location}` : ''}
|
||||
{formatGpDateRange(meeting) ? ` · ${formatGpDateRange(meeting)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{countdownTarget && (
|
||||
<div className="preview-countdown" data-testid="preview-countdown">
|
||||
<span className="preview-countdown-label">
|
||||
{countdownSession?.session_name ?? 'Next session'}
|
||||
</span>
|
||||
<span className="preview-countdown-value">{formatCountdown(countdownTarget, now)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{sessions.length > 0 && (
|
||||
<div className="preview-schedule" data-testid="preview-schedule">
|
||||
{sessions.map((session) => (
|
||||
<div key={session.session_key} className="preview-schedule-item">
|
||||
<span className="preview-schedule-name">{session.session_name}</span>
|
||||
<span className="preview-schedule-time">{formatSessionScheduleTime(session.date_start)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export function RacePreviewPage() {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
const nowDate = useMemo(() => new Date(now), [now])
|
||||
|
||||
const seasonsQuery = useQuery({
|
||||
queryKey: ['seasons'],
|
||||
queryFn: fetchSeasons,
|
||||
})
|
||||
|
||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||
|
||||
const meetingsQuery = useQuery({
|
||||
queryKey: ['meetings', latestSeason, 'auto'],
|
||||
queryFn: () => fetchMeetings(latestSeason!, 'auto'),
|
||||
enabled: latestSeason != null,
|
||||
})
|
||||
|
||||
const previewMeeting = useMemo(
|
||||
() => pickPreviewMeeting(meetingsQuery.data ?? [], nowDate),
|
||||
[meetingsQuery.data, nowDate],
|
||||
)
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
queryKey: ['sessions', previewMeeting?.meeting_key, 'auto'],
|
||||
queryFn: () => fetchSessions(previewMeeting!.meeting_key, 'auto'),
|
||||
enabled: previewMeeting != null,
|
||||
})
|
||||
|
||||
const sessions = useMemo(
|
||||
() => sortSessionsByStart(sessionsQuery.data ?? []),
|
||||
[sessionsQuery.data],
|
||||
)
|
||||
|
||||
const countdownSession = useMemo(() => countdownTargetSession(sessions, nowDate), [sessions, nowDate])
|
||||
const sprintWeekend = useMemo(() => isSprintWeekend(sessions), [sessions])
|
||||
|
||||
const priorYear = previewMeeting ? previewMeeting.year - 1 : null
|
||||
|
||||
const priorMeetingsQuery = useQuery({
|
||||
queryKey: ['meetings', priorYear, 'auto'],
|
||||
queryFn: () => fetchMeetings(priorYear!, 'auto'),
|
||||
enabled: priorYear != null && previewMeeting != null,
|
||||
})
|
||||
|
||||
const priorMeeting = useMemo(
|
||||
() => findPriorYearMeetingByCircuit(priorMeetingsQuery.data ?? [], previewMeeting?.circuit_key),
|
||||
[priorMeetingsQuery.data, previewMeeting?.circuit_key],
|
||||
)
|
||||
|
||||
const priorSessionsQuery = useQuery({
|
||||
queryKey: ['sessions', priorMeeting?.meeting_key, 'auto'],
|
||||
queryFn: () => fetchSessions(priorMeeting!.meeting_key, 'auto'),
|
||||
enabled: priorMeeting != null,
|
||||
})
|
||||
|
||||
const priorRaceSession = useMemo(
|
||||
() => findRaceSession(priorSessionsQuery.data ?? []),
|
||||
[priorSessionsQuery.data],
|
||||
)
|
||||
|
||||
const priorResultsQuery = useQuery({
|
||||
queryKey: ['results', priorRaceSession?.session_key, 'auto'],
|
||||
queryFn: () => fetchResults(priorRaceSession!.session_key, 'auto'),
|
||||
enabled: priorRaceSession != null,
|
||||
})
|
||||
|
||||
const priorGridQuery = useQuery({
|
||||
queryKey: ['grid', priorRaceSession?.session_key, 'auto'],
|
||||
queryFn: () => fetchStartingGrid(priorRaceSession!.session_key, 'auto'),
|
||||
enabled: priorRaceSession != null,
|
||||
})
|
||||
|
||||
const trackOutlineQuery = useQuery({
|
||||
queryKey: ['track-outline', previewMeeting?.circuit_key, previewMeeting?.year],
|
||||
queryFn: () => fetchTrackOutline(previewMeeting!.circuit_key!, previewMeeting!.year),
|
||||
enabled: previewMeeting?.circuit_key != null && previewMeeting.circuit_key > 0,
|
||||
})
|
||||
|
||||
const championshipQuery = useQuery({
|
||||
queryKey: ['championship-hub', latestSeason],
|
||||
queryFn: () => fetchChampionshipHub(latestSeason!),
|
||||
enabled: latestSeason != null,
|
||||
})
|
||||
|
||||
const podium = useMemo(() => extractPodium(priorResultsQuery.data ?? []), [priorResultsQuery.data])
|
||||
const pole = useMemo(() => extractPole(priorGridQuery.data ?? []), [priorGridQuery.data])
|
||||
const titleFight = useMemo(
|
||||
() => (championshipQuery.data ? buildTitleFightContext(championshipQuery.data) : []),
|
||||
[championshipQuery.data],
|
||||
)
|
||||
|
||||
const accent = countryAccent(previewMeeting)
|
||||
const isFirstTimeCircuit = priorMeeting == null && priorYear != null && !priorMeetingsQuery.isLoading
|
||||
|
||||
if (seasonsQuery.isLoading || meetingsQuery.isLoading) {
|
||||
return <div className="page loading-state" data-testid="preview-loading">loading preview…</div>
|
||||
}
|
||||
|
||||
if (seasonsQuery.isError || meetingsQuery.isError) {
|
||||
const err = seasonsQuery.error ?? meetingsQuery.error
|
||||
return (
|
||||
<div className="page error-box" data-testid="preview-error">
|
||||
{err instanceof Error ? err.message : 'Failed to load preview'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!previewMeeting) {
|
||||
return (
|
||||
<div className="preview-page" data-testid="preview-season-over">
|
||||
<div className="preview-empty">
|
||||
<span className="preview-empty-eyebrow mono">box-box · race preview</span>
|
||||
<h1 className="preview-empty-title">Season complete</h1>
|
||||
<p className="preview-empty-sub">
|
||||
No upcoming races on the {latestSeason ?? 'current'} calendar. Check back when the next season schedule is
|
||||
published.
|
||||
</p>
|
||||
</div>
|
||||
{titleFight.length > 0 && (
|
||||
<TitleFightCard
|
||||
loading={championshipQuery.isLoading}
|
||||
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
|
||||
drivers={titleFight}
|
||||
sprintWeekend={false}
|
||||
season={latestSeason}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="preview-page" data-testid="preview-page">
|
||||
<PreviewHeader
|
||||
meeting={previewMeeting}
|
||||
sessions={sessions}
|
||||
countdownSession={countdownSession}
|
||||
now={nowDate}
|
||||
accent={accent}
|
||||
/>
|
||||
|
||||
<div className="preview-grid">
|
||||
<TrackOutlineCard
|
||||
outline={trackOutlineQuery.data}
|
||||
loading={trackOutlineQuery.isLoading}
|
||||
error={trackOutlineQuery.isError ? (trackOutlineQuery.error as Error) : null}
|
||||
accent={accent}
|
||||
/>
|
||||
|
||||
<LastYearCard
|
||||
year={priorMeeting?.year ?? priorYear}
|
||||
loading={priorMeetingsQuery.isLoading || priorSessionsQuery.isLoading || priorResultsQuery.isLoading}
|
||||
error={
|
||||
priorMeetingsQuery.isError
|
||||
? (priorMeetingsQuery.error as Error)
|
||||
: priorResultsQuery.isError
|
||||
? (priorResultsQuery.error as Error)
|
||||
: null
|
||||
}
|
||||
podium={podium}
|
||||
pole={pole}
|
||||
isFirstTime={isFirstTimeCircuit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TitleFightCard
|
||||
loading={championshipQuery.isLoading}
|
||||
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
|
||||
drivers={titleFight}
|
||||
sprintWeekend={sprintWeekend}
|
||||
season={latestSeason}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,11 +6,17 @@ import { DataLibraryPage } from './pages/DataLibraryPage'
|
||||
import { LiveTimingPage } from './pages/LiveTimingPage'
|
||||
import { BriefingPage } from './pages/BriefingPage'
|
||||
import { ChampionshipPage } from './pages/ChampionshipPage'
|
||||
import { DriverProfilePage } from './pages/DriverProfilePage'
|
||||
import { RacePreviewPage } from './pages/RacePreviewPage'
|
||||
|
||||
type RaceHubSearch = {
|
||||
session_key?: number
|
||||
}
|
||||
|
||||
type DriverProfileSearch = {
|
||||
year?: number
|
||||
}
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<>
|
||||
@@ -64,12 +70,32 @@ export const championshipRoute = createRoute({
|
||||
component: ChampionshipPage,
|
||||
})
|
||||
|
||||
export const driverProfileRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/drivers/$driverNumber',
|
||||
validateSearch: (search: Record<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({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/briefing',
|
||||
component: BriefingPage,
|
||||
})
|
||||
|
||||
export const previewRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/preview',
|
||||
component: RacePreviewPage,
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
commandCenterRoute,
|
||||
raceHubRoute,
|
||||
@@ -77,7 +103,9 @@ const routeTree = rootRoute.addChildren([
|
||||
dataLibraryRoute,
|
||||
liveTimingRoute,
|
||||
championshipRoute,
|
||||
driverProfileRoute,
|
||||
briefingRoute,
|
||||
previewRoute,
|
||||
])
|
||||
|
||||
export const router = createRouter({ routeTree })
|
||||
|
||||
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;
|
||||
}
|
||||
178
frontend/src/styles/digest.css
Normal file
178
frontend/src/styles/digest.css
Normal file
@@ -0,0 +1,178 @@
|
||||
/* ── Calendar-aware paddock digest ── */
|
||||
|
||||
.digest-sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
margin-bottom: var(--s5);
|
||||
padding: var(--s4) var(--s5);
|
||||
background: color-mix(in srgb, var(--bg) 92%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.digest-sticky-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s3);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--s3);
|
||||
}
|
||||
|
||||
.digest-sticky-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.digest-sticky-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
font-family: var(--f-mono);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.digest-sticky-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.digest-tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-2);
|
||||
background: var(--surface);
|
||||
color: var(--text-2);
|
||||
font-size: 11px;
|
||||
font-family: var(--f-mono);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.1s, color 0.1s, background 0.1s;
|
||||
}
|
||||
|
||||
.digest-tag-chip:hover {
|
||||
color: var(--text);
|
||||
background: var(--surface-h);
|
||||
}
|
||||
|
||||
.digest-tag-chip.active {
|
||||
color: var(--text);
|
||||
border-color: var(--tag-colour, var(--red));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--tag-colour, var(--red)) 35%, transparent);
|
||||
}
|
||||
|
||||
.digest-tag-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--tag-colour, var(--text-3));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.digest-filter-clear {
|
||||
padding: 2px 8px;
|
||||
border: 1px dashed var(--border-2);
|
||||
border-radius: 999px;
|
||||
background: none;
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
font-family: var(--f-mono);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.digest-filter-clear:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--border-2);
|
||||
}
|
||||
|
||||
.digest-section {
|
||||
margin-bottom: var(--s6);
|
||||
}
|
||||
|
||||
.digest-section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s3);
|
||||
margin-bottom: var(--s4);
|
||||
padding-bottom: var(--s3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.digest-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.digest-section-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
font-family: var(--f-mono);
|
||||
}
|
||||
|
||||
.digest-recent-section .digest-section-title {
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.bp-card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s2);
|
||||
margin-top: var(--s2);
|
||||
}
|
||||
|
||||
.bp-card-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text-2);
|
||||
font-size: 10px;
|
||||
font-family: var(--f-mono);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bp-card-tag:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--border-2);
|
||||
}
|
||||
|
||||
.bp-card-tag.active {
|
||||
border-color: var(--tag-colour, var(--red));
|
||||
}
|
||||
|
||||
.cc-brief-digest-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
margin-bottom: var(--s3);
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
font-family: var(--f-mono);
|
||||
}
|
||||
|
||||
.cc-brief-digest-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s2);
|
||||
}
|
||||
|
||||
.cc-brief-tag {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-2);
|
||||
font-family: var(--f-mono);
|
||||
}
|
||||
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);
|
||||
}
|
||||
309
frontend/src/styles/preview.css
Normal file
309
frontend/src/styles/preview.css
Normal file
@@ -0,0 +1,309 @@
|
||||
.preview-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s6);
|
||||
padding: var(--s6);
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s4);
|
||||
min-height: 320px;
|
||||
text-align: center;
|
||||
padding: var(--s8);
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.preview-empty-eyebrow {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.preview-empty-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-empty-sub {
|
||||
color: var(--text-2);
|
||||
max-width: 36rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
min-height: 140px;
|
||||
}
|
||||
|
||||
.preview-header-accent {
|
||||
width: 4px;
|
||||
flex-shrink: 0;
|
||||
background: var(--preview-accent, var(--gp-accent));
|
||||
box-shadow: 0 0 12px var(--preview-accent, var(--gp-accent));
|
||||
}
|
||||
|
||||
.preview-header-body {
|
||||
flex: 1;
|
||||
padding: var(--s5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preview-header-top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--s4);
|
||||
}
|
||||
|
||||
.preview-gp-name {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.preview-gp-flag {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.preview-circuit {
|
||||
color: var(--text-2);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-countdown {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.preview-countdown-label {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
margin-bottom: var(--s1);
|
||||
}
|
||||
|
||||
.preview-countdown-value {
|
||||
font-family: var(--mono);
|
||||
font-size: 1.1rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
.preview-schedule {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: var(--s2);
|
||||
}
|
||||
|
||||
.preview-schedule-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--s2) var(--s3);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.preview-schedule-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
.preview-schedule-time {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: var(--s5);
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
padding: var(--s5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s4);
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.preview-card-title {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-card-sub {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-2);
|
||||
margin: calc(-1 * var(--s2)) 0 0;
|
||||
}
|
||||
|
||||
.preview-track-stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
min-height: 180px;
|
||||
padding: var(--s4);
|
||||
}
|
||||
|
||||
.preview-track-svg {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.preview-track-outline {
|
||||
fill: none;
|
||||
stroke: var(--preview-accent, var(--gp-accent));
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.preview-track-shadow {
|
||||
fill: none;
|
||||
stroke: rgba(0, 0, 0, 0.5);
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.preview-podium {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.preview-podium-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.preview-podium-pos {
|
||||
width: 1.5rem;
|
||||
font-family: var(--mono);
|
||||
font-weight: 600;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.preview-podium-code {
|
||||
font-weight: 600;
|
||||
min-width: 2.5rem;
|
||||
}
|
||||
|
||||
.preview-podium-team {
|
||||
color: var(--text-2);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.preview-pole {
|
||||
padding-top: var(--s3);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.preview-pole strong {
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
.preview-title-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.preview-title-table th,
|
||||
.preview-title-table td {
|
||||
padding: var(--s2) var(--s3);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.preview-title-table th {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.preview-title-table td.mono {
|
||||
font-family: var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.preview-sprint-note {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-2);
|
||||
margin: 0;
|
||||
padding: var(--s3);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.preview-section-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-3);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
padding: var(--s5);
|
||||
}
|
||||
|
||||
.preview-section-state.error {
|
||||
color: var(--red, #e74c3c);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.preview-page {
|
||||
padding: var(--s4);
|
||||
}
|
||||
|
||||
.preview-header-top {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-countdown {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
209
frontend/src/test/BriefingPage.test.tsx
Normal file
209
frontend/src/test/BriefingPage.test.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BriefingPage } from '../pages/BriefingPage'
|
||||
import type { ChampHubDriver, ChampHubTeam, ChampionshipHub, Meeting, NewsItem } from '../types'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
fetchNews: vi.fn(),
|
||||
fetchNewsArticle: vi.fn(),
|
||||
markNewsRead: vi.fn(),
|
||||
fetchSeasons: vi.fn(),
|
||||
fetchSeasonMeetings: vi.fn(),
|
||||
fetchChampionshipHub: vi.fn(),
|
||||
}))
|
||||
|
||||
import {
|
||||
fetchNews,
|
||||
fetchNewsArticle,
|
||||
fetchSeasons,
|
||||
fetchSeasonMeetings,
|
||||
fetchChampionshipHub,
|
||||
markNewsRead,
|
||||
} from '../api'
|
||||
|
||||
const mockFetchNews = vi.mocked(fetchNews)
|
||||
const mockFetchNewsArticle = vi.mocked(fetchNewsArticle)
|
||||
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
||||
const mockFetchSeasonMeetings = vi.mocked(fetchSeasonMeetings)
|
||||
const mockFetchHub = vi.mocked(fetchChampionshipHub)
|
||||
const mockMarkNewsRead = vi.mocked(markNewsRead)
|
||||
|
||||
const bahrain: Meeting = {
|
||||
meeting_key: 1,
|
||||
meeting_name: 'Bahrain',
|
||||
meeting_official_name: 'Bahrain GP',
|
||||
location: 'Sakhir',
|
||||
country_name: 'Bahrain',
|
||||
country_code: 'BRN',
|
||||
country_flag: '',
|
||||
circuit_short_name: 'Sakhir',
|
||||
date_start: '2025-03-14T00:00:00+00:00',
|
||||
date_end: '2025-03-16T23:59:59+00:00',
|
||||
year: 2025,
|
||||
}
|
||||
|
||||
const monaco: Meeting = {
|
||||
meeting_key: 2,
|
||||
meeting_name: 'Monaco',
|
||||
meeting_official_name: 'Monaco GP',
|
||||
location: 'Monaco',
|
||||
country_name: 'Monaco',
|
||||
country_code: 'MON',
|
||||
country_flag: '',
|
||||
circuit_short_name: 'Monaco',
|
||||
date_start: '2025-05-23T00:00:00+00:00',
|
||||
date_end: '2025-05-25T23:59:59+00:00',
|
||||
year: 2025,
|
||||
}
|
||||
|
||||
const drivers: ChampHubDriver[] = [
|
||||
{
|
||||
driver_number: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull',
|
||||
team_colour: '3671c6',
|
||||
points: 100,
|
||||
position: 1,
|
||||
wins: 3,
|
||||
podiums: 5,
|
||||
poles: 2,
|
||||
form: [25, 18, 25],
|
||||
cumulative: [25, 43, 68, 100],
|
||||
teammate_wins: 4,
|
||||
teammate_losses: 1,
|
||||
},
|
||||
{
|
||||
driver_number: 4,
|
||||
name_acronym: 'NOR',
|
||||
full_name: 'Lando Norris',
|
||||
team_name: 'McLaren',
|
||||
team_colour: 'ff8000',
|
||||
points: 80,
|
||||
position: 2,
|
||||
wins: 1,
|
||||
podiums: 4,
|
||||
poles: 1,
|
||||
form: [18, 25, 18],
|
||||
cumulative: [18, 36, 54, 80],
|
||||
teammate_wins: 3,
|
||||
teammate_losses: 2,
|
||||
},
|
||||
]
|
||||
|
||||
const teams: ChampHubTeam[] = [
|
||||
{ team_name: 'Red Bull', team_colour: '3671c6', points: 150, position: 1, wins: 4 },
|
||||
{ team_name: 'McLaren', team_colour: 'ff8000', points: 120, position: 2, wins: 2 },
|
||||
]
|
||||
|
||||
const hub: ChampionshipHub = {
|
||||
season: 2025,
|
||||
round: 2,
|
||||
total_rounds: 2,
|
||||
rounds_left: 0,
|
||||
last_race: 'Monaco GP',
|
||||
round_labels: ['R1', 'R2'],
|
||||
drivers,
|
||||
teams,
|
||||
}
|
||||
|
||||
const newsItems: NewsItem[] = [
|
||||
{
|
||||
source: 'autosport-f1',
|
||||
title: 'Verstappen sets the pace in Bahrain',
|
||||
url: 'https://example.com/bahrain-ver',
|
||||
published_at: '2025-03-15T10:00:00Z',
|
||||
fetched_at: '2025-03-15T11:00:00Z',
|
||||
summary: 'Red Bull dominate opening practice',
|
||||
},
|
||||
{
|
||||
source: 'racefans-f1',
|
||||
title: 'Norris targets Monaco upgrade',
|
||||
url: 'https://example.com/monaco-nor',
|
||||
published_at: '2025-04-20T10:00:00Z',
|
||||
fetched_at: '2025-04-20T11:00:00Z',
|
||||
summary: 'McLaren bring new floor',
|
||||
},
|
||||
{
|
||||
source: 'bbc-f1',
|
||||
title: 'Undated paddock rumour',
|
||||
url: 'https://example.com/undated',
|
||||
fetched_at: '2025-04-21T11:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BriefingPage />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('BriefingPage digest layout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.setSystemTime(new Date('2025-04-10T12:00:00Z'))
|
||||
mockFetchSeasons.mockResolvedValue([2025])
|
||||
mockFetchSeasonMeetings.mockResolvedValue([bahrain, monaco])
|
||||
mockFetchHub.mockResolvedValue(hub)
|
||||
mockFetchNews.mockResolvedValue(newsItems)
|
||||
mockFetchNewsArticle.mockResolvedValue({ title: 'Article', content: '<p>Body</p>' })
|
||||
mockMarkNewsRead.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('renders sticky since-last header, GP sections, and recent bucket', async () => {
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('digest-sticky-header')).toHaveTextContent('Since Bahrain')
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('digest-window-2')).toHaveTextContent('Monaco')
|
||||
expect(screen.getByTestId('digest-window-1')).toHaveTextContent('Bahrain')
|
||||
expect(screen.getByTestId('digest-recent')).toHaveTextContent('Undated paddock rumour')
|
||||
expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
|
||||
expect(screen.getByText('Norris targets Monaco upgrade')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters the digest when a tag chip is clicked', async () => {
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('digest-sticky-header')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const sticky = screen.getByTestId('digest-sticky-header')
|
||||
const norTag = within(sticky).getByRole('button', { name: /^NOR$/i })
|
||||
fireEvent.click(norTag)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Verstappen sets the pace in Bahrain')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('Norris targets Monaco upgrade')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('digest-filter-clear')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('digest-filter-clear'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves category tabs', async () => {
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tab', { name: /All/i })).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByRole('tab', { name: /News/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -156,6 +156,7 @@ describe('CommandCenterPage', () => {
|
||||
race_control: [],
|
||||
weather: [],
|
||||
laps: [],
|
||||
chapters: [],
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -155,6 +155,7 @@ const raceHub: RaceHub = {
|
||||
is_pit_out_lap: false,
|
||||
},
|
||||
],
|
||||
chapters: [],
|
||||
}
|
||||
|
||||
const weekend: Weekend = {
|
||||
|
||||
276
frontend/src/test/RacePreviewPage.test.tsx
Normal file
276
frontend/src/test/RacePreviewPage.test.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
|
||||
import { RacePreviewPage } from '../pages/RacePreviewPage'
|
||||
import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session, TrackOutline } from '../types'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
fetchSeasons: vi.fn(),
|
||||
fetchMeetings: vi.fn(),
|
||||
fetchSessions: vi.fn(),
|
||||
fetchResults: vi.fn(),
|
||||
fetchStartingGrid: vi.fn(),
|
||||
fetchTrackOutline: vi.fn(),
|
||||
fetchChampionshipHub: vi.fn(),
|
||||
}))
|
||||
|
||||
import {
|
||||
fetchSeasons,
|
||||
fetchMeetings,
|
||||
fetchSessions,
|
||||
fetchResults,
|
||||
fetchStartingGrid,
|
||||
fetchTrackOutline,
|
||||
fetchChampionshipHub,
|
||||
} from '../api'
|
||||
|
||||
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
||||
const mockFetchMeetings = vi.mocked(fetchMeetings)
|
||||
const mockFetchSessions = vi.mocked(fetchSessions)
|
||||
const mockFetchResults = vi.mocked(fetchResults)
|
||||
const mockFetchStartingGrid = vi.mocked(fetchStartingGrid)
|
||||
const mockFetchTrackOutline = vi.mocked(fetchTrackOutline)
|
||||
const mockFetchChampionshipHub = vi.mocked(fetchChampionshipHub)
|
||||
|
||||
const upcomingMeeting: Meeting = {
|
||||
meeting_key: 100,
|
||||
meeting_name: 'Monaco',
|
||||
meeting_official_name: 'Monaco GP',
|
||||
location: 'Monaco',
|
||||
country_name: 'Monaco',
|
||||
country_code: 'MON',
|
||||
country_flag: '',
|
||||
circuit_key: 10,
|
||||
circuit_short_name: 'Monaco',
|
||||
date_start: '2099-05-22T00:00:00+00:00',
|
||||
date_end: '2099-05-24T23:59:59+00:00',
|
||||
year: 2099,
|
||||
}
|
||||
|
||||
const priorMeeting: Meeting = {
|
||||
...upcomingMeeting,
|
||||
meeting_key: 90,
|
||||
year: 2098,
|
||||
date_start: '2098-05-22T00:00:00+00:00',
|
||||
date_end: '2098-05-24T23:59:59+00:00',
|
||||
}
|
||||
|
||||
const sessions: Session[] = [
|
||||
{
|
||||
session_key: 1,
|
||||
session_name: 'FP1',
|
||||
session_type: 'Practice',
|
||||
meeting_key: 100,
|
||||
date_start: '2099-05-22T10:00:00+00:00',
|
||||
date_end: '2099-05-22T11:00:00+00:00',
|
||||
gmt_offset: '02:00:00',
|
||||
},
|
||||
{
|
||||
session_key: 2,
|
||||
session_name: 'Sprint',
|
||||
session_type: 'Sprint',
|
||||
meeting_key: 100,
|
||||
date_start: '2099-05-23T10:00:00+00:00',
|
||||
date_end: '2099-05-23T11:00:00+00:00',
|
||||
gmt_offset: '02:00:00',
|
||||
},
|
||||
{
|
||||
session_key: 3,
|
||||
session_name: 'Race',
|
||||
session_type: 'Race',
|
||||
meeting_key: 100,
|
||||
date_start: '2099-05-24T13:00:00+00:00',
|
||||
date_end: '2099-05-24T15:00:00+00:00',
|
||||
gmt_offset: '02:00:00',
|
||||
},
|
||||
]
|
||||
|
||||
const priorRaceSession: Session = {
|
||||
session_key: 50,
|
||||
session_name: 'Race',
|
||||
session_type: 'Race',
|
||||
meeting_key: 90,
|
||||
date_start: '2098-05-24T13:00:00+00:00',
|
||||
date_end: '2098-05-24T15:00:00+00:00',
|
||||
gmt_offset: '02:00:00',
|
||||
}
|
||||
|
||||
const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
|
||||
driver_number: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull',
|
||||
team_colour: '3671c6',
|
||||
points: 200,
|
||||
position: 1,
|
||||
wins: 5,
|
||||
podiums: 8,
|
||||
poles: 4,
|
||||
form: [25],
|
||||
cumulative: [200],
|
||||
teammate_wins: 9,
|
||||
teammate_losses: 1,
|
||||
...over,
|
||||
})
|
||||
|
||||
const hub: ChampionshipHub = {
|
||||
season: 2099,
|
||||
round: 5,
|
||||
total_rounds: 24,
|
||||
rounds_left: 19,
|
||||
last_race: 'Monaco GP',
|
||||
round_labels: ['R1'],
|
||||
drivers: [
|
||||
hubDriver({}),
|
||||
hubDriver({ driver_number: 4, name_acronym: 'NOR', points: 160, position: 2 }),
|
||||
hubDriver({ driver_number: 16, name_acronym: 'LEC', points: 120, position: 3 }),
|
||||
],
|
||||
teams: [],
|
||||
}
|
||||
|
||||
const results: EnrichedResult[] = [
|
||||
{
|
||||
driver_number: 1,
|
||||
position: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull',
|
||||
team_colour: '3671c6',
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
duration: 100,
|
||||
gap_to_leader: 0,
|
||||
number_of_laps: 78,
|
||||
points: 25,
|
||||
session_key: 50,
|
||||
meeting_key: 90,
|
||||
},
|
||||
]
|
||||
|
||||
const grid: EnrichedGrid[] = [
|
||||
{
|
||||
driver_number: 4,
|
||||
position: 1,
|
||||
name_acronym: 'NOR',
|
||||
full_name: 'Lando Norris',
|
||||
team_name: 'McLaren',
|
||||
team_colour: 'ff8000',
|
||||
session_key: 50,
|
||||
meeting_key: 90,
|
||||
lap_duration: 70,
|
||||
},
|
||||
]
|
||||
|
||||
const outline: TrackOutline = {
|
||||
circuit_key: 10,
|
||||
points: [
|
||||
{ x: 0.1, y: 0.2 },
|
||||
{ x: 0.5, y: 0.5 },
|
||||
{ x: 0.9, y: 0.8 },
|
||||
],
|
||||
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RacePreviewPage />
|
||||
</QueryClientProvider>
|
||||
),
|
||||
})
|
||||
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: RacePreviewPage })
|
||||
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
|
||||
return render(<RouterProvider router={router} />)
|
||||
}
|
||||
|
||||
describe('RacePreviewPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchSeasons.mockResolvedValue([2099])
|
||||
mockFetchChampionshipHub.mockResolvedValue(hub)
|
||||
mockFetchTrackOutline.mockResolvedValue(outline)
|
||||
mockFetchResults.mockResolvedValue(results)
|
||||
mockFetchStartingGrid.mockResolvedValue(grid)
|
||||
})
|
||||
|
||||
it('renders upcoming race preview with sections', async () => {
|
||||
mockFetchMeetings.mockImplementation(async (year: number) => {
|
||||
if (year === 2099) return [upcomingMeeting]
|
||||
if (year === 2098) return [priorMeeting]
|
||||
return []
|
||||
})
|
||||
mockFetchSessions.mockImplementation(async (meetingKey: number) => {
|
||||
if (meetingKey === 100) return sessions
|
||||
if (meetingKey === 90) return [priorRaceSession]
|
||||
return []
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('preview-page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('preview-countdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('preview-header')).toHaveTextContent('Monaco')
|
||||
expect(screen.getByTestId('preview-schedule')).toHaveTextContent('FP1')
|
||||
expect(screen.getByTestId('preview-track-card')).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('VER')
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('preview-title-fight-card')).toHaveTextContent('NOR')
|
||||
expect(screen.getByTestId('preview-sprint-note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders season-over state when no upcoming meeting', async () => {
|
||||
mockFetchMeetings.mockResolvedValue([
|
||||
{
|
||||
...upcomingMeeting,
|
||||
date_start: '2020-05-22T00:00:00+00:00',
|
||||
date_end: '2020-05-24T23:59:59+00:00',
|
||||
},
|
||||
])
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('preview-season-over')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText('Season complete')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('preview-title-fight-card')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('preview-header')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows first-time circuit empty state', async () => {
|
||||
const newCircuit: Meeting = {
|
||||
...upcomingMeeting,
|
||||
meeting_key: 200,
|
||||
circuit_key: 999,
|
||||
meeting_name: 'New GP',
|
||||
}
|
||||
|
||||
mockFetchMeetings.mockImplementation(async (year: number) => {
|
||||
if (year === 2099) return [newCircuit]
|
||||
if (year === 2098) return [priorMeeting]
|
||||
return []
|
||||
})
|
||||
mockFetchSessions.mockResolvedValue(sessions)
|
||||
mockFetchTrackOutline.mockResolvedValue(null)
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('First time on the calendar')
|
||||
})
|
||||
})
|
||||
})
|
||||
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])
|
||||
})
|
||||
})
|
||||
265
frontend/src/test/digest.test.ts
Normal file
265
frontend/src/test/digest.test.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
activeDigestWindow,
|
||||
filterByTag,
|
||||
groupByWindow,
|
||||
gpWindows,
|
||||
itemsForWindow,
|
||||
sinceLastLabel,
|
||||
sortWindowBucketsNewestFirst,
|
||||
tagItems,
|
||||
topTags,
|
||||
windowForDate,
|
||||
} from '../lib/digest'
|
||||
import type { ChampHubDriver, ChampHubTeam, Meeting, NewsItem } from '../types'
|
||||
|
||||
const meeting = (overrides: Partial<Meeting> = {}): Meeting => ({
|
||||
meeting_key: 1,
|
||||
meeting_name: 'Bahrain',
|
||||
meeting_official_name: 'Bahrain GP',
|
||||
location: 'Sakhir',
|
||||
country_name: 'Bahrain',
|
||||
country_code: 'BRN',
|
||||
country_flag: '',
|
||||
circuit_short_name: 'Sakhir',
|
||||
date_start: '2025-03-14T00:00:00+00:00',
|
||||
date_end: '2025-03-16T23:59:59+00:00',
|
||||
year: 2025,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const news = (overrides: Partial<NewsItem> = {}): NewsItem => ({
|
||||
source: 'autosport-f1',
|
||||
title: 'Headline',
|
||||
url: `https://example.com/${Math.random()}`,
|
||||
fetched_at: '2025-04-01T12:00:00Z',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const driver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
|
||||
driver_number: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull',
|
||||
team_colour: '3671c6',
|
||||
points: 100,
|
||||
position: 1,
|
||||
wins: 3,
|
||||
podiums: 5,
|
||||
poles: 2,
|
||||
form: [25, 18, 25],
|
||||
cumulative: [25, 43, 68, 100],
|
||||
teammate_wins: 4,
|
||||
teammate_losses: 1,
|
||||
...over,
|
||||
})
|
||||
|
||||
const team = (over: Partial<ChampHubTeam>): ChampHubTeam => ({
|
||||
team_name: 'Red Bull',
|
||||
team_colour: '3671c6',
|
||||
points: 150,
|
||||
position: 1,
|
||||
wins: 4,
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('gpWindows', () => {
|
||||
const bahrain = meeting({ meeting_key: 1, meeting_name: 'Bahrain' })
|
||||
const monaco = meeting({
|
||||
meeting_key: 2,
|
||||
meeting_name: 'Monaco',
|
||||
date_start: '2025-05-23T00:00:00+00:00',
|
||||
date_end: '2025-05-25T23:59:59+00:00',
|
||||
})
|
||||
const canada = meeting({
|
||||
meeting_key: 3,
|
||||
meeting_name: 'Canada',
|
||||
date_start: '2025-06-13T00:00:00+00:00',
|
||||
date_end: '2025-06-15T23:59:59+00:00',
|
||||
})
|
||||
|
||||
it('derives inter-race windows mid-season', () => {
|
||||
const now = new Date('2025-04-10T12:00:00Z')
|
||||
const windows = gpWindows([bahrain, monaco, canada], now)
|
||||
|
||||
expect(windows).toHaveLength(3)
|
||||
expect(windows[0].start).toBeNull()
|
||||
expect(windows[0].end?.toISOString()).toBe(new Date('2025-05-23T00:00:00+00:00').toISOString())
|
||||
expect(windows[1].start?.toISOString()).toBe(new Date('2025-03-16T23:59:59+00:00').toISOString())
|
||||
expect(windows[1].end?.toISOString()).toBe(new Date('2025-06-13T00:00:00+00:00').toISOString())
|
||||
expect(windows[2].start?.toISOString()).toBe(new Date('2025-05-25T23:59:59+00:00').toISOString())
|
||||
expect(windows[2].end).toBeNull()
|
||||
})
|
||||
|
||||
it('handles before the first race of the season', () => {
|
||||
const now = new Date('2025-01-01T00:00:00Z')
|
||||
const windows = gpWindows([bahrain, monaco], now)
|
||||
expect(windows[0].start).toBeNull()
|
||||
expect(activeDigestWindow(windows, [bahrain, monaco], now)?.meeting_name).toBe('Bahrain')
|
||||
expect(sinceLastLabel([bahrain, monaco], now)).toBe('Before Bahrain')
|
||||
})
|
||||
|
||||
it('handles after the final race of the season', () => {
|
||||
const now = new Date('2025-12-01T00:00:00Z')
|
||||
const windows = gpWindows([bahrain, monaco], now)
|
||||
expect(activeDigestWindow(windows, [bahrain, monaco], now)?.meeting_name).toBe('Monaco')
|
||||
expect(sinceLastLabel([bahrain, monaco], now)).toBe('Monaco')
|
||||
expect(windows[1].end).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupByWindow', () => {
|
||||
const bahrain = meeting({ meeting_key: 1 })
|
||||
const monaco = meeting({
|
||||
meeting_key: 2,
|
||||
date_start: '2025-05-23T00:00:00+00:00',
|
||||
date_end: '2025-05-25T23:59:59+00:00',
|
||||
})
|
||||
const windows = gpWindows([bahrain, monaco], new Date('2025-04-10T12:00:00Z'))
|
||||
|
||||
it('buckets dated items into the matching GP window', () => {
|
||||
const items = [
|
||||
news({ url: 'https://a', published_at: '2025-03-10T10:00:00Z', title: 'Pre-Bahrain' }),
|
||||
news({ url: 'https://b', published_at: '2025-04-05T10:00:00Z', title: 'Between races' }),
|
||||
news({ url: 'https://c', published_at: '2025-05-24T10:00:00Z', title: 'Monaco weekend' }),
|
||||
]
|
||||
|
||||
const grouped = groupByWindow(items, windows)
|
||||
const bahrainItems = grouped.windows.find((entry) => entry.window.meeting_key === 1)?.items ?? []
|
||||
const monacoItems = grouped.windows.find((entry) => entry.window.meeting_key === 2)?.items ?? []
|
||||
|
||||
expect(bahrainItems).toHaveLength(1)
|
||||
expect(bahrainItems[0].title).toBe('Pre-Bahrain')
|
||||
expect(monacoItems).toHaveLength(2)
|
||||
expect(monacoItems.map((item) => item.title)).toEqual(['Between races', 'Monaco weekend'])
|
||||
})
|
||||
|
||||
it('puts undated items in the recent bucket', () => {
|
||||
const items = [
|
||||
news({ url: 'https://u', title: 'Undated story' }),
|
||||
news({ url: 'https://d', published_at: '2025-04-05T10:00:00Z' }),
|
||||
]
|
||||
const grouped = groupByWindow(items, windows)
|
||||
expect(grouped.recent).toHaveLength(1)
|
||||
expect(grouped.recent[0].url).toBe('https://u')
|
||||
})
|
||||
|
||||
it('sorts window buckets newest first for display', () => {
|
||||
const items = [
|
||||
news({ published_at: '2025-03-10T10:00:00Z' }),
|
||||
news({ published_at: '2025-05-24T10:00:00Z' }),
|
||||
]
|
||||
const grouped = groupByWindow(items, windows)
|
||||
const sorted = sortWindowBucketsNewestFirst(grouped.windows)
|
||||
expect(sorted[0].window.meeting_key).toBe(2)
|
||||
expect(sorted[1].window.meeting_key).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tagItems', () => {
|
||||
const drivers = [
|
||||
driver({ name_acronym: 'VER', full_name: 'Max Verstappen', team_name: 'Red Bull' }),
|
||||
driver({
|
||||
driver_number: 4,
|
||||
name_acronym: 'NOR',
|
||||
full_name: 'Lando Norris',
|
||||
team_name: 'McLaren',
|
||||
team_colour: 'ff8000',
|
||||
}),
|
||||
]
|
||||
const teams = [
|
||||
team({ team_name: 'Red Bull' }),
|
||||
team({ team_name: 'McLaren', team_colour: 'ff8000' }),
|
||||
]
|
||||
|
||||
it('tags drivers and teams case-insensitively', () => {
|
||||
const [item] = tagItems(
|
||||
[news({ title: 'ver leads mclaren in practice', summary: 'Norris close behind' })],
|
||||
drivers,
|
||||
teams,
|
||||
)
|
||||
const labels = item.tags.map((tag) => tag.label).sort()
|
||||
expect(labels).toEqual(['McLaren', 'NOR', 'VER'])
|
||||
})
|
||||
|
||||
it('returns no tags when nothing matches', () => {
|
||||
const [item] = tagItems(
|
||||
[news({ title: 'Generic paddock update' })],
|
||||
drivers,
|
||||
teams,
|
||||
)
|
||||
expect(item.tags).toEqual([])
|
||||
})
|
||||
|
||||
it('prefers the longest overlapping match', () => {
|
||||
const [item] = tagItems(
|
||||
[news({ title: 'Max Verstappen extends championship lead' })],
|
||||
drivers,
|
||||
teams,
|
||||
)
|
||||
expect(item.tags).toHaveLength(1)
|
||||
expect(item.tags[0].label).toBe('VER')
|
||||
})
|
||||
|
||||
it('matches a driver last name inside prose', () => {
|
||||
const [item] = tagItems(
|
||||
[news({ title: 'Verstappen was untouchable in qualifying' })],
|
||||
drivers,
|
||||
teams,
|
||||
)
|
||||
expect(item.tags.some((tag) => tag.label === 'VER')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('digest helpers', () => {
|
||||
it('collects top tags by frequency', () => {
|
||||
const tagged = tagItems(
|
||||
[
|
||||
news({ title: 'VER wins', url: 'https://a' }),
|
||||
news({ title: 'VER dominates', url: 'https://b' }),
|
||||
news({ title: 'Norris podium', url: 'https://c' }),
|
||||
],
|
||||
[driver({}), driver({ driver_number: 4, name_acronym: 'NOR', full_name: 'Lando Norris', team_name: 'McLaren' })],
|
||||
[team({}), team({ team_name: 'McLaren', team_colour: 'ff8000' })],
|
||||
)
|
||||
expect(topTags(tagged, 2).map((tag) => tag.label)).toEqual(['VER', 'NOR'])
|
||||
})
|
||||
|
||||
it('filters items by tag key', () => {
|
||||
const tagged = tagItems(
|
||||
[
|
||||
news({ title: 'VER wins', url: 'https://a' }),
|
||||
news({ title: 'Neutral headline', url: 'https://b' }),
|
||||
],
|
||||
[driver({})],
|
||||
[team({})],
|
||||
)
|
||||
const filtered = filterByTag(tagged, 'driver:VER')
|
||||
expect(filtered).toHaveLength(1)
|
||||
expect(filtered[0].url).toBe('https://a')
|
||||
})
|
||||
|
||||
it('selects items for the active digest window', () => {
|
||||
const bahrain = meeting({ meeting_key: 1 })
|
||||
const monaco = meeting({
|
||||
meeting_key: 2,
|
||||
date_start: '2025-05-23T00:00:00+00:00',
|
||||
date_end: '2025-05-25T23:59:59+00:00',
|
||||
})
|
||||
const now = new Date('2025-04-10T12:00:00Z')
|
||||
const windows = gpWindows([bahrain, monaco], now)
|
||||
const active = activeDigestWindow(windows, [bahrain, monaco], now)
|
||||
const tagged = tagItems(
|
||||
[
|
||||
news({ published_at: '2025-04-05T10:00:00Z', url: 'https://in' }),
|
||||
news({ published_at: '2025-03-10T10:00:00Z', url: 'https://out' }),
|
||||
],
|
||||
[],
|
||||
[],
|
||||
)
|
||||
const inWindow = itemsForWindow(tagged, active)
|
||||
expect(inWindow).toHaveLength(1)
|
||||
expect(inWindow[0].url).toBe('https://in')
|
||||
expect(windowForDate(windows, new Date('2025-04-05T10:00:00Z'))?.meeting_key).toBe(2)
|
||||
})
|
||||
})
|
||||
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('—')
|
||||
})
|
||||
})
|
||||
226
frontend/src/test/preview.test.ts
Normal file
226
frontend/src/test/preview.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session } from '../types'
|
||||
import {
|
||||
buildTitleFightContext,
|
||||
countdownTargetSession,
|
||||
extractPodium,
|
||||
extractPole,
|
||||
findPriorYearMeetingByCircuit,
|
||||
findRaceSession,
|
||||
formatPointsGap,
|
||||
isSprintWeekend,
|
||||
pickPreviewMeeting,
|
||||
RACE_WIN_POINTS,
|
||||
SPRINT_WEEKEND_MAX_POINTS,
|
||||
} from '../lib/preview'
|
||||
|
||||
const meeting = (overrides: Partial<Meeting> = {}): Meeting => ({
|
||||
meeting_key: 1,
|
||||
meeting_name: 'Monaco',
|
||||
meeting_official_name: 'Monaco GP',
|
||||
location: 'Monaco',
|
||||
country_name: 'Monaco',
|
||||
country_code: 'MON',
|
||||
country_flag: '',
|
||||
circuit_key: 10,
|
||||
circuit_short_name: 'Monaco',
|
||||
date_start: '2026-05-22T00:00:00+00:00',
|
||||
date_end: '2026-05-24T23:59:59+00:00',
|
||||
year: 2026,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const session = (overrides: Partial<Session> = {}): Session => ({
|
||||
session_key: 100,
|
||||
session_name: 'Race',
|
||||
session_type: 'Race',
|
||||
meeting_key: 1,
|
||||
date_start: '2026-05-24T13:00:00+00:00',
|
||||
date_end: '2026-05-24T15:00:00+00:00',
|
||||
gmt_offset: '02:00:00',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
|
||||
driver_number: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull',
|
||||
team_colour: '3671c6',
|
||||
points: 200,
|
||||
position: 1,
|
||||
wins: 5,
|
||||
podiums: 8,
|
||||
poles: 4,
|
||||
form: [25],
|
||||
cumulative: [200],
|
||||
teammate_wins: 9,
|
||||
teammate_losses: 1,
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('preview lib', () => {
|
||||
it('picks the next upcoming meeting', () => {
|
||||
const now = new Date('2026-01-01T00:00:00Z')
|
||||
const meetings = [
|
||||
meeting({ meeting_key: 1, date_start: '2026-05-22T00:00:00+00:00' }),
|
||||
meeting({ meeting_key: 2, meeting_name: 'Spain', date_start: '2026-06-12T00:00:00+00:00' }),
|
||||
]
|
||||
expect(pickPreviewMeeting(meetings, now)?.meeting_key).toBe(1)
|
||||
})
|
||||
|
||||
it('returns null when no upcoming meeting exists', () => {
|
||||
const now = new Date('2027-01-01T00:00:00Z')
|
||||
const meetings = [meeting({ date_start: '2026-05-22T00:00:00+00:00' })]
|
||||
expect(pickPreviewMeeting(meetings, now)).toBeNull()
|
||||
})
|
||||
|
||||
it('skips cancelled meetings', () => {
|
||||
const now = new Date('2026-01-01T00:00:00Z')
|
||||
const meetings = [meeting({ is_cancelled: true })]
|
||||
expect(pickPreviewMeeting(meetings, now)).toBeNull()
|
||||
})
|
||||
|
||||
it('matches prior-year meeting by circuit_key', () => {
|
||||
const prior = [
|
||||
meeting({ meeting_key: 50, year: 2025, circuit_key: 10 }),
|
||||
meeting({ meeting_key: 51, year: 2025, circuit_key: 20, meeting_name: 'Spain' }),
|
||||
]
|
||||
expect(findPriorYearMeetingByCircuit(prior, 10)?.meeting_key).toBe(50)
|
||||
expect(findPriorYearMeetingByCircuit(prior, 99)).toBeNull()
|
||||
expect(findPriorYearMeetingByCircuit(prior, undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('finds the grand prix race session', () => {
|
||||
const sessions = [
|
||||
session({ session_key: 1, session_name: 'FP1', session_type: 'Practice', date_start: '2026-05-22T10:00:00+00:00' }),
|
||||
session({ session_key: 2, session_name: 'Sprint', session_type: 'Sprint', date_start: '2026-05-23T10:00:00+00:00' }),
|
||||
session({ session_key: 3, session_name: 'Race', session_type: 'Race', date_start: '2026-05-24T13:00:00+00:00' }),
|
||||
]
|
||||
expect(findRaceSession(sessions)?.session_key).toBe(3)
|
||||
})
|
||||
|
||||
it('detects sprint weekends', () => {
|
||||
const sprint = [
|
||||
session({ session_type: 'Practice' }),
|
||||
session({ session_name: 'Sprint', session_type: 'Sprint' }),
|
||||
session({ session_type: 'Race' }),
|
||||
]
|
||||
const normal = [session({ session_type: 'Practice' }), session({ session_type: 'Race' })]
|
||||
expect(isSprintWeekend(sprint)).toBe(true)
|
||||
expect(isSprintWeekend(normal)).toBe(false)
|
||||
})
|
||||
|
||||
it('extracts podium and pole', () => {
|
||||
const results: EnrichedResult[] = [
|
||||
{
|
||||
driver_number: 1,
|
||||
position: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull',
|
||||
team_colour: '3671c6',
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
duration: 100,
|
||||
gap_to_leader: 0,
|
||||
number_of_laps: 78,
|
||||
points: 25,
|
||||
session_key: 1,
|
||||
meeting_key: 1,
|
||||
},
|
||||
{
|
||||
driver_number: 4,
|
||||
position: 2,
|
||||
name_acronym: 'NOR',
|
||||
full_name: 'Lando Norris',
|
||||
team_name: 'McLaren',
|
||||
team_colour: 'ff8000',
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
duration: 101,
|
||||
gap_to_leader: 1,
|
||||
number_of_laps: 78,
|
||||
points: 18,
|
||||
session_key: 1,
|
||||
meeting_key: 1,
|
||||
},
|
||||
{
|
||||
driver_number: 16,
|
||||
position: 3,
|
||||
name_acronym: 'LEC',
|
||||
full_name: 'Charles Leclerc',
|
||||
team_name: 'Ferrari',
|
||||
team_colour: 'e8002d',
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
duration: 102,
|
||||
gap_to_leader: 2,
|
||||
number_of_laps: 78,
|
||||
points: 15,
|
||||
session_key: 1,
|
||||
meeting_key: 1,
|
||||
},
|
||||
]
|
||||
|
||||
const grid: EnrichedGrid[] = [
|
||||
{
|
||||
driver_number: 4,
|
||||
position: 1,
|
||||
name_acronym: 'NOR',
|
||||
full_name: 'Lando Norris',
|
||||
team_name: 'McLaren',
|
||||
team_colour: 'ff8000',
|
||||
session_key: 1,
|
||||
meeting_key: 1,
|
||||
lap_duration: 70,
|
||||
},
|
||||
]
|
||||
|
||||
const podium = extractPodium(results)
|
||||
expect(podium.map((p) => p.name_acronym)).toEqual(['VER', 'NOR', 'LEC'])
|
||||
expect(extractPole(grid)?.name_acronym).toBe('NOR')
|
||||
})
|
||||
|
||||
it('builds title fight gaps for top 3', () => {
|
||||
const hub: ChampionshipHub = {
|
||||
season: 2026,
|
||||
round: 5,
|
||||
total_rounds: 24,
|
||||
rounds_left: 19,
|
||||
last_race: 'Monaco GP',
|
||||
round_labels: ['R1'],
|
||||
drivers: [
|
||||
hubDriver({ points: 200, position: 1 }),
|
||||
hubDriver({ driver_number: 4, name_acronym: 'NOR', points: 160, position: 2 }),
|
||||
hubDriver({ driver_number: 16, name_acronym: 'LEC', points: 120, position: 3 }),
|
||||
],
|
||||
teams: [],
|
||||
}
|
||||
|
||||
const rows = buildTitleFightContext(hub)
|
||||
expect(rows).toHaveLength(3)
|
||||
expect(rows[0].gapToLeader).toBe(0)
|
||||
expect(rows[1].gapToLeader).toBe(40)
|
||||
expect(rows[1].gapAfterRaceWin).toBe(15)
|
||||
expect(rows[2].gapAfterSprintWeekendMax).toBe(200 - (120 + SPRINT_WEEKEND_MAX_POINTS))
|
||||
expect(RACE_WIN_POINTS).toBe(25)
|
||||
})
|
||||
|
||||
it('formats points gaps', () => {
|
||||
expect(formatPointsGap(0)).toBe('LEADER')
|
||||
expect(formatPointsGap(40)).toBe('+40')
|
||||
})
|
||||
|
||||
it('picks countdown target as next future session', () => {
|
||||
const sessions = [
|
||||
session({ session_key: 1, session_name: 'FP1', session_type: 'Practice', date_start: '2026-05-22T10:00:00+00:00', date_end: '2026-05-22T11:00:00+00:00' }),
|
||||
session({ session_key: 2, session_name: 'Race', session_type: 'Race', date_start: '2026-05-24T13:00:00+00:00', date_end: '2026-05-24T15:00:00+00:00' }),
|
||||
]
|
||||
const now = new Date('2026-05-23T12:00:00Z')
|
||||
expect(countdownTargetSession(sessions, now)?.session_key).toBe(2)
|
||||
})
|
||||
})
|
||||
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 })
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,7 @@ export interface Meeting {
|
||||
country_name: string
|
||||
country_code: string
|
||||
country_flag: string
|
||||
circuit_key?: number
|
||||
circuit_short_name: string
|
||||
date_start: string
|
||||
date_end: string
|
||||
@@ -23,6 +24,7 @@ export interface Session {
|
||||
session_key: number
|
||||
session_name: string
|
||||
session_type: string
|
||||
circuit_key?: number
|
||||
meeting_key: number
|
||||
date_start: string
|
||||
date_end: string
|
||||
@@ -89,6 +91,18 @@ export interface RaceHub {
|
||||
race_control: RaceControlMessage[]
|
||||
weather: WeatherSample[]
|
||||
laps: Lap[]
|
||||
chapters: Chapter[]
|
||||
}
|
||||
|
||||
export interface Chapter {
|
||||
kind: 'start' | 'safety_car' | 'virtual_safety_car' | 'red_flag' | 'pit_phase' | 'decisive_swing' | 'finish' | string
|
||||
title: string
|
||||
headline: string
|
||||
start_lap: number
|
||||
end_lap: number
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
driver_numbers: number[]
|
||||
}
|
||||
|
||||
export interface Stint {
|
||||
@@ -347,6 +361,23 @@ export interface TrackOutline {
|
||||
bounds: TrackBounds
|
||||
}
|
||||
|
||||
export interface ReplayCarPosition {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface ReplayFrame {
|
||||
t: number
|
||||
cars: Record<string, ReplayCarPosition>
|
||||
}
|
||||
|
||||
export interface ReplayFramesResponse {
|
||||
session_key: number
|
||||
interval_ms: number
|
||||
start_time: string
|
||||
frames: ReplayFrame[]
|
||||
}
|
||||
|
||||
export interface ChampHubDriver {
|
||||
driver_number: number
|
||||
name_acronym: string
|
||||
@@ -383,6 +414,39 @@ export interface ChampionshipHub {
|
||||
teams: ChampHubTeam[]
|
||||
}
|
||||
|
||||
export interface DriverSummaryRound {
|
||||
meeting_key: number
|
||||
meeting_name: string
|
||||
country_code: string
|
||||
country_name: string
|
||||
race_position: number
|
||||
grid_position: number
|
||||
quali_position?: number
|
||||
points: number
|
||||
dnf: boolean
|
||||
dns: boolean
|
||||
dsq: boolean
|
||||
}
|
||||
|
||||
export interface DriverSummary {
|
||||
season: number
|
||||
driver_number: number
|
||||
name_acronym: string
|
||||
full_name: string
|
||||
team_name: string
|
||||
team_colour: string
|
||||
headshot_url: string
|
||||
points: number
|
||||
position: number
|
||||
wins: number
|
||||
podiums: number
|
||||
poles: number
|
||||
form: number[]
|
||||
cumulative: number[]
|
||||
round_labels: string[]
|
||||
rounds: DriverSummaryRound[]
|
||||
}
|
||||
|
||||
export interface NewsItem {
|
||||
source: string
|
||||
title: string
|
||||
|
||||
Reference in New Issue
Block a user