Compare commits
21 Commits
feat/issue
...
c475011c49
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c475011c49 | ||
|
|
5a6323d3b4 | ||
|
|
ed3b8cf628 | ||
|
|
0a42c05487 | ||
|
|
08ff75b469 | ||
|
|
16c035b037 | ||
|
|
1dac73cdc5 | ||
|
|
02a84f67b4 | ||
|
|
f0a8e4e631 | ||
|
|
519053d340 | ||
|
|
e0d7b9981e | ||
|
|
79acf0579a | ||
|
|
faa173578c | ||
|
|
39f1560282 | ||
|
|
d3f7bab6dc | ||
|
|
5b112fb012 | ||
|
|
96d741424c | ||
|
|
c2617d39bb | ||
|
|
beacda278d | ||
|
|
f24ec4be94 | ||
|
|
3cc6157337 |
BIN
docs/phase-2/19-21-20-race-story.png
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
docs/phase-2/22-driver-profile.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
docs/phase-2/23-teammate-h2h.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
docs/phase-2/24-rivalry-compare.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
docs/phase-2/25-race-preview.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
docs/phase-2/26-briefing-digest.png
Normal file
|
After Width: | Height: | Size: 683 KiB |
@@ -11,6 +11,7 @@ import type {
|
||||
Meeting,
|
||||
NewsItem,
|
||||
RaceHub,
|
||||
ReplayFramesResponse,
|
||||
Session,
|
||||
TrackOutline,
|
||||
Weekend,
|
||||
@@ -81,6 +82,21 @@ export async function fetchTrackOutline(circuitKey: number, year: number): Promi
|
||||
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) {
|
||||
|
||||
116
frontend/src/components/ChapterStrip.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { BookOpen } from 'lucide-react'
|
||||
import type { Chapter } from '../types'
|
||||
import {
|
||||
activeChapterIndex,
|
||||
chapterKindLabel,
|
||||
chapterLapRange,
|
||||
chapterStartScrub,
|
||||
} from '../lib/chapters'
|
||||
import { EmptyStateCard } from './EmptyStateCard'
|
||||
import '../styles/chapters.css'
|
||||
|
||||
interface Props {
|
||||
chapters: Chapter[]
|
||||
scrubTime: number | null
|
||||
tMin: number
|
||||
tRange: number
|
||||
tourActive: boolean
|
||||
tourChapterIndex: number | null
|
||||
/** Explicit selection from a chapter click; wins over scrub-derived active. */
|
||||
selectedChapterIndex?: number | null
|
||||
onChapterClick: (index: number, scrub: number) => void
|
||||
onTourToggle: () => void
|
||||
}
|
||||
|
||||
export function ChapterStrip({
|
||||
chapters,
|
||||
scrubTime,
|
||||
tMin,
|
||||
tRange,
|
||||
tourActive,
|
||||
tourChapterIndex,
|
||||
selectedChapterIndex = null,
|
||||
onChapterClick,
|
||||
onTourToggle,
|
||||
}: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange)
|
||||
const highlightedIndex = tourActive
|
||||
? tourChapterIndex
|
||||
: (selectedChapterIndex ?? activeIndex)
|
||||
|
||||
useEffect(() => {
|
||||
if (highlightedIndex === null || !scrollRef.current) return
|
||||
const card = scrollRef.current.querySelector<HTMLElement>(
|
||||
`[data-testid="chapter-card-${highlightedIndex}"]`,
|
||||
)
|
||||
card?.scrollIntoView?.({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||
}, [highlightedIndex])
|
||||
|
||||
if (chapters.length === 0) {
|
||||
return (
|
||||
<div className="chapter-strip" data-testid="chapter-strip">
|
||||
<EmptyStateCard
|
||||
icon={BookOpen}
|
||||
title="No story chapters"
|
||||
hint="This session does not have enough race-control or position data to build narrative chapters."
|
||||
testId="chapter-strip-empty"
|
||||
className="chapter-strip-empty-card"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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-wrap">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
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 = highlightedIndex === 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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
frontend/src/components/EmptyStateCard.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
hint: ReactNode
|
||||
testId?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EmptyStateCard({ icon: Icon, title, hint, testId, className = '' }: Props) {
|
||||
return (
|
||||
<div className={`empty-state ui-card ${className}`.trim()} data-testid={testId}>
|
||||
<Icon size={32} className="empty-state-icon" aria-hidden />
|
||||
<div className="empty-state-title">{title}</div>
|
||||
<div className="empty-state-desc">{hint}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,17 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchLiveState } from '../api'
|
||||
import { isLiveSessionActive } from '../lib/live'
|
||||
|
||||
export function Nav() {
|
||||
const { data: liveState } = useQuery({
|
||||
queryKey: ['live-state'],
|
||||
queryFn: fetchLiveState,
|
||||
staleTime: 5_000,
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
const liveActive = isLiveSessionActive(liveState)
|
||||
|
||||
return (
|
||||
<nav className="app-nav">
|
||||
<Link to="/" className="nav-logo">
|
||||
@@ -10,8 +21,16 @@ export function Nav() {
|
||||
<Link to="/" activeProps={{ className: 'active' }} activeOptions={{ exact: true }}>
|
||||
Command
|
||||
</Link>
|
||||
<Link to="/live" activeProps={{ className: 'active' }}>
|
||||
<Link
|
||||
to="/live"
|
||||
className={liveActive ? 'nav-live nav-live-on' : 'nav-live'}
|
||||
activeProps={{ className: 'active' }}
|
||||
data-testid="nav-live"
|
||||
data-live-active={liveActive ? 'true' : 'false'}
|
||||
>
|
||||
{liveActive && <span className="nav-live-dot" aria-hidden="true" />}
|
||||
Live
|
||||
{liveActive && <span className="sr-only"> session active</span>}
|
||||
</Link>
|
||||
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
||||
Race Hub
|
||||
|
||||
@@ -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,36 @@
|
||||
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 { LineChart } from 'lucide-react'
|
||||
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 { EmptyStateCard } from './EmptyStateCard'
|
||||
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
|
||||
import {
|
||||
chapterBandFill,
|
||||
chapterEndScrub,
|
||||
chapterStartScrub,
|
||||
chapterTourDurations,
|
||||
deCollideYPositions,
|
||||
decimatedPositionLabels,
|
||||
} from '../lib/chapters'
|
||||
import { isReplayMapAvailable } from '../lib/replayMap'
|
||||
import '../styles/race-story.css'
|
||||
|
||||
const CHAPTER_TOUR_MS = 90_000
|
||||
const CHART_W = 640
|
||||
const CHART_H = 180
|
||||
const CHART_PL = 40
|
||||
const CHART_PR = 48
|
||||
const CHART_PT = 8
|
||||
const CHART_PB = 20
|
||||
|
||||
interface ChartTiming {
|
||||
tMin: number
|
||||
tMax: number
|
||||
tRange: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
@@ -12,38 +42,256 @@ interface Props {
|
||||
race_control?: any[]
|
||||
pit_stops?: any[]
|
||||
session?: Session
|
||||
meeting?: Meeting
|
||||
drivers?: Driver[]
|
||||
chapters?: Chapter[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Position samples are recorded for the entire session, including the pre-race
|
||||
* grid period. Use the winning driver's laps to define the race window so
|
||||
* that the x-axis starts at lights-out rather than at the earliest sample.
|
||||
*/
|
||||
function raceChartTiming(
|
||||
positions: PositionSample[],
|
||||
laps: Lap[],
|
||||
results: EnrichedResult[],
|
||||
): ChartTiming | null {
|
||||
const positionTimes = positions
|
||||
.map((position) => new Date(position.date).getTime())
|
||||
.filter(Number.isFinite)
|
||||
if (positionTimes.length === 0) return null
|
||||
|
||||
const fallbackMin = Math.min(...positionTimes)
|
||||
const fallbackMax = Math.max(...positionTimes)
|
||||
const fallback = {
|
||||
tMin: fallbackMin,
|
||||
tMax: fallbackMax,
|
||||
tRange: Math.max(fallbackMax - fallbackMin, 1),
|
||||
}
|
||||
|
||||
const winner = results.find((result) => result.position === 1)
|
||||
if (!winner) return fallback
|
||||
|
||||
const winnerLaps = laps
|
||||
.filter((lap) => lap.driver_number === winner.driver_number && lap.lap_number > 0)
|
||||
.map((lap) => ({ ...lap, start: new Date(lap.date_start).getTime() }))
|
||||
.filter((lap) => Number.isFinite(lap.start))
|
||||
.sort((a, b) => a.lap_number - b.lap_number)
|
||||
if (winnerLaps.length === 0) return fallback
|
||||
|
||||
const firstLap = winnerLaps[0]
|
||||
const lastLap = winnerLaps[winnerLaps.length - 1]
|
||||
const finalLapDuration = lastLap.lap_duration ?? 0
|
||||
const tMax = lastLap.start + (finalLapDuration > 0 ? finalLapDuration * 1000 : 0)
|
||||
if (tMax <= firstLap.start) return fallback
|
||||
|
||||
return { tMin: firstLap.start, tMax, tRange: tMax - firstLap.start }
|
||||
}
|
||||
|
||||
export function RaceStoryCanvas({ data }: Props) {
|
||||
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 [selectedChapterIndex, setSelectedChapterIndex] = 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(() => raceChartTiming(positions, laps, results), [laps, positions, results])
|
||||
const hasChartData = hasPositions && chartTiming !== null
|
||||
const circuitKey = session?.circuit_key ?? meeting?.circuit_key ?? 0
|
||||
const outlineYear = meeting?.year ?? (session?.date_start ? new Date(session.date_start).getFullYear() : 0)
|
||||
const canProbeMap = Boolean(session?.session_key) && circuitKey > 0 && outlineYear > 0
|
||||
|
||||
const replayQuery = useQuery({
|
||||
queryKey: ['replay-frames', session?.session_key, 5000],
|
||||
queryFn: () => fetchReplayFrames(session!.session_key, 5000),
|
||||
enabled: canProbeMap,
|
||||
})
|
||||
const outlineQuery = useQuery({
|
||||
queryKey: ['track-outline', circuitKey, outlineYear],
|
||||
queryFn: () => fetchTrackOutline(circuitKey, outlineYear),
|
||||
enabled: canProbeMap,
|
||||
})
|
||||
|
||||
const mapProbeSettled = !canProbeMap || (!replayQuery.isLoading && !outlineQuery.isLoading)
|
||||
const mapAvailable = useMemo(
|
||||
() =>
|
||||
canProbeMap &&
|
||||
isReplayMapAvailable(
|
||||
replayQuery.data,
|
||||
outlineQuery.data,
|
||||
replayQuery.isError || outlineQuery.isError,
|
||||
),
|
||||
[canProbeMap, outlineQuery.data, outlineQuery.isError, replayQuery.data, replayQuery.isError],
|
||||
)
|
||||
const showMapPanel = mapOpen && mapAvailable
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapAvailable && mapOpen) {
|
||||
setMapOpen(false)
|
||||
}
|
||||
}, [mapAvailable, mapOpen])
|
||||
|
||||
useEffect(() => {
|
||||
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()
|
||||
setSelectedChapterIndex(index)
|
||||
setScrubTime(scrub)
|
||||
}
|
||||
|
||||
const clearChapterSelection = () => {
|
||||
setSelectedChapterIndex(null)
|
||||
}
|
||||
|
||||
const toggleChapterTour = () => {
|
||||
if (chapterTourActive) {
|
||||
stopChapterTour()
|
||||
return
|
||||
}
|
||||
if (!chartTiming || chapters.length === 0) return
|
||||
setIsPlaying(false)
|
||||
setSelectedChapterIndex(null)
|
||||
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 normaliseTime = (time: number) => Math.max(0, Math.min(1, (time - tMin) / tRange))
|
||||
|
||||
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
|
||||
for (const p of positions) {
|
||||
const time = new Date(p.date).getTime()
|
||||
if (!Number.isFinite(time)) continue
|
||||
if (!byDriver.has(p.driver_number)) byDriver.set(p.driver_number, [])
|
||||
byDriver.get(p.driver_number)!.push({
|
||||
t: (new Date(p.date).getTime() - tMin) / tRange,
|
||||
t: normaliseTime(time),
|
||||
pos: p.position,
|
||||
})
|
||||
}
|
||||
const dnfSet = new Set(results.filter(r => r.dnf || r.dns || r.dsq).map(r => r.driver_number))
|
||||
const dnfSet = new Set(results.filter((r) => r.dnf || r.dns || r.dsq).map((r) => r.driver_number))
|
||||
for (const [dNum, samples] of byDriver.entries()) {
|
||||
samples.sort((a, b) => a.t - b.t)
|
||||
if (samples.length > 0 && !dnfSet.has(dNum)) {
|
||||
@@ -51,16 +299,16 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const getInterpPos = (samples: {t: number, pos: number}[], t: number) => {
|
||||
const getInterpPos = (samples: { t: number; pos: number }[], t: number) => {
|
||||
if (!samples || samples.length === 0) return null
|
||||
if (t <= samples[0].t) return samples[0].pos
|
||||
if (t >= samples[samples.length - 1].t) return samples[samples.length - 1].pos
|
||||
for (let i = 0; i < samples.length - 1; i++) {
|
||||
if (samples[i].t <= t && samples[i+1].t >= t) {
|
||||
const dt = samples[i+1].t - samples[i].t
|
||||
if (samples[i].t <= t && samples[i + 1].t >= t) {
|
||||
const dt = samples[i + 1].t - samples[i].t
|
||||
if (dt === 0) return samples[i].pos
|
||||
const frac = (t - samples[i].t) / dt
|
||||
return samples[i].pos + (samples[i+1].pos - samples[i].pos) * frac
|
||||
return samples[i].pos + (samples[i + 1].pos - samples[i].pos) * frac
|
||||
}
|
||||
}
|
||||
return null
|
||||
@@ -84,43 +332,46 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
const maxPos = Math.max(...positions.map((p) => p.position), results.length, 2)
|
||||
const colorByDriver = new Map(results.map((r) => [r.driver_number, r.team_colour]))
|
||||
const acronymByDriver = new Map(results.map((r) => [r.driver_number, r.name_acronym]))
|
||||
const positionLabels = decimatedPositionLabels(maxPos)
|
||||
|
||||
const W = 640
|
||||
const H = 180
|
||||
const PL = 40
|
||||
const PR = 48
|
||||
const PT = 8
|
||||
const PB = 20
|
||||
const W = CHART_W
|
||||
const H = CHART_H
|
||||
const PL = CHART_PL
|
||||
const PR = CHART_PR
|
||||
const PT = CHART_PT
|
||||
const PB = CHART_PB
|
||||
const plotW = W - PL - PR
|
||||
const plotH = H - PT - PB
|
||||
|
||||
const toX = (t: number) => PL + t * plotW
|
||||
const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH
|
||||
|
||||
const winner = results.find(r => r.position === 1)
|
||||
const winnerLaps = winner ? laps.filter(l => l.driver_number === winner.driver_number) : []
|
||||
const lapTicks: { lap: number, t: number }[] = []
|
||||
const winner = results.find((r) => r.position === 1)
|
||||
const winnerLaps = winner ? laps.filter((l) => l.driver_number === winner.driver_number) : []
|
||||
const lapTicks: { lap: number; t: number }[] = []
|
||||
const lapInterval = winnerLaps.length < 30 ? 5 : 10
|
||||
|
||||
for (const lap of winnerLaps) {
|
||||
|
||||
for (let i = 0; i < winnerLaps.length; i++) {
|
||||
const lap = winnerLaps[i]
|
||||
if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) {
|
||||
const t = (new Date(lap.date_start).getTime() - tMin) / tRange
|
||||
if (t >= 0 && t <= 1) {
|
||||
lapTicks.push({ lap: lap.lap_number, t })
|
||||
}
|
||||
const lapStart = new Date(lap.date_start).getTime()
|
||||
const nextLapStart = winnerLaps[i + 1] ? new Date(winnerLaps[i + 1].date_start).getTime() : NaN
|
||||
const lapEnd = lap.lap_duration && lap.lap_duration > 0
|
||||
? lapStart + lap.lap_duration * 1000
|
||||
: nextLapStart
|
||||
if (Number.isFinite(lapEnd)) lapTicks.push({ lap: lap.lap_number, t: normaliseTime(lapEnd) })
|
||||
}
|
||||
}
|
||||
|
||||
// Safety Car / VSC periods
|
||||
const scPeriods: { start: number; end: number | null; type: 'SC' | 'VSC' }[] = []
|
||||
let activeSC: { start: number; type: 'SC' | 'VSC' } | null = null
|
||||
const rc = [...race_control].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
|
||||
|
||||
for (const msg of rc) {
|
||||
const t = new Date(msg.date).getTime()
|
||||
const m = msg.message?.toUpperCase() || ''
|
||||
const cat = msg.category?.toUpperCase() || ''
|
||||
|
||||
|
||||
if (m.includes('VIRTUAL SAFETY CAR DEPLOYED') || cat === 'VIRTUALSAFETYCAR') {
|
||||
if (!activeSC) activeSC = { start: t, type: 'VSC' }
|
||||
} else if (m.includes('SAFETY CAR DEPLOYED') || cat === 'SAFETYCAR') {
|
||||
@@ -136,23 +387,73 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
scPeriods.push({ start: activeSC.start, end: null, type: activeSC.type })
|
||||
}
|
||||
|
||||
const chapterBands = chapters
|
||||
.map((chapter, index) => {
|
||||
const startT = chapterStartScrub(chapter, tMin, tRange)
|
||||
const endT = chapterEndScrub(chapter, tMin, tRange)
|
||||
if (startT === null) return null
|
||||
const end = endT ?? startT
|
||||
return {
|
||||
key: `${chapter.kind}-${index}`,
|
||||
start: Math.min(startT, end),
|
||||
end: Math.max(startT, end),
|
||||
fill: chapterBandFill(chapter.kind),
|
||||
}
|
||||
})
|
||||
.filter((band): band is NonNullable<typeof band> => band !== null)
|
||||
|
||||
const labelCandidates = Array.from(byDriver.entries())
|
||||
.map(([dNum, samples]) => {
|
||||
const last = samples[samples.length - 1]
|
||||
if (!last) return null
|
||||
return { key: dNum, y: toY(last.pos) }
|
||||
})
|
||||
.filter((item): item is { key: number; y: number } => item !== null)
|
||||
const labelYByDriver = deCollideYPositions(labelCandidates, 12)
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
|
||||
setIsPlaying(false)
|
||||
stopChapterTour()
|
||||
clearChapterSelection()
|
||||
if (!svgRef.current) return
|
||||
const rect = svgRef.current.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
if (rect.width <= 0) return
|
||||
// Pointer coordinates are CSS pixels; convert them to the SVG viewBox
|
||||
// before comparing with the fixed chart margins and plot width.
|
||||
const x = ((e.clientX - rect.left) / rect.width) * W
|
||||
const t = Math.max(0, Math.min(1, (x - PL) / plotW))
|
||||
setScrubTime(t)
|
||||
}
|
||||
|
||||
chartContent = (
|
||||
<div className="rs-chart-container scroll-x" data-testid="position-chart">
|
||||
<div
|
||||
className={`rs-chart-container scroll-x${showMapPanel ? '' : ' rs-chart-container--full'}`}
|
||||
data-testid="position-chart"
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="rs-position-chart-svg"
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
style={{ width: '100%', minWidth: 280, maxWidth: W, display: 'block' }}
|
||||
role="img"
|
||||
aria-label="Position evolution chart"
|
||||
>
|
||||
{chapterBands.map((band) => {
|
||||
const x1 = toX(Math.max(0, band.start))
|
||||
const x2 = toX(Math.min(1, band.end))
|
||||
if (x2 <= PL || x1 >= W - PR) return null
|
||||
return (
|
||||
<rect
|
||||
key={band.key}
|
||||
x={x1}
|
||||
y={PT}
|
||||
width={Math.max(0, x2 - x1)}
|
||||
height={plotH}
|
||||
fill={band.fill}
|
||||
data-testid="chapter-band"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{scPeriods.map((sc, i) => {
|
||||
const startT = (sc.start - tMin) / tRange
|
||||
const endT = sc.end ? (sc.end - tMin) / tRange : 1
|
||||
@@ -171,33 +472,41 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
)
|
||||
})}
|
||||
|
||||
{Array.from({ length: maxPos }, (_, i) => i + 1).map((pos) => (
|
||||
{positionLabels.map((pos) => (
|
||||
<g key={pos}>
|
||||
<line
|
||||
x1={PL}
|
||||
x2={W - PR}
|
||||
y1={toY(pos)}
|
||||
y2={toY(pos)}
|
||||
stroke="var(--border)"
|
||||
strokeWidth={0.5}
|
||||
className="rs-chart-grid-line"
|
||||
/>
|
||||
<text
|
||||
x={PL - 4}
|
||||
y={toY(pos) + 4}
|
||||
textAnchor="end"
|
||||
fill="var(--text-3)"
|
||||
fontSize={8}
|
||||
fontFamily="var(--f-mono)"
|
||||
className="rs-chart-axis-label"
|
||||
>
|
||||
P{pos}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{lapTicks.map(tick => (
|
||||
{lapTicks.map((tick) => (
|
||||
<g key={`lap-${tick.lap}`}>
|
||||
<line x1={toX(tick.t)} x2={toX(tick.t)} y1={H - PB} y2={H - PB + 4} stroke="var(--border)" strokeWidth={1} />
|
||||
<text x={toX(tick.t)} y={H - PB + 14} textAnchor="middle" fill="var(--text-3)" fontSize={9} fontFamily="var(--f-mono)">
|
||||
<line
|
||||
x1={toX(tick.t)}
|
||||
x2={toX(tick.t)}
|
||||
y1={H - PB}
|
||||
y2={H - PB + 4}
|
||||
className="rs-chart-lap-tick"
|
||||
/>
|
||||
<text
|
||||
x={toX(tick.t)}
|
||||
y={H - PB + 14}
|
||||
textAnchor="middle"
|
||||
className="rs-chart-axis-label"
|
||||
>
|
||||
L{tick.lap}
|
||||
</text>
|
||||
</g>
|
||||
@@ -210,11 +519,12 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
const last = samples[samples.length - 1]
|
||||
const isHovered = hoverDriver === dNum
|
||||
const isFaded = hoverDriver !== null && !isHovered
|
||||
const labelY = last ? (labelYByDriver.get(dNum) ?? toY(last.pos)) : 0
|
||||
|
||||
const driverPits = pit_stops.filter(p => p.driver_number === dNum)
|
||||
const driverPits = pit_stops.filter((p) => p.driver_number === dNum)
|
||||
|
||||
return (
|
||||
<g
|
||||
<g
|
||||
key={dNum}
|
||||
style={{ opacity: isFaded ? 0.2 : 1, transition: 'opacity 0.2s' }}
|
||||
onMouseEnter={() => setHoverDriver(dNum)}
|
||||
@@ -230,30 +540,31 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
className="rs-driver-line"
|
||||
pathLength={1}
|
||||
/>
|
||||
|
||||
|
||||
{driverPits.map((p, i) => {
|
||||
const t = (new Date(p.date).getTime() - tMin) / tRange
|
||||
if (t < 0 || t > 1) return null
|
||||
const time = new Date(p.date).getTime()
|
||||
if (!Number.isFinite(time) || time < tMin || time > tMin + tRange) return null
|
||||
const t = normaliseTime(time)
|
||||
const pos = getInterpPos(samples, t)
|
||||
if (pos === null) return null
|
||||
return (
|
||||
<circle
|
||||
key={`pit-${i}`}
|
||||
cx={toX(t)}
|
||||
cy={toY(pos)}
|
||||
r={3}
|
||||
fill="var(--bg)"
|
||||
stroke={color}
|
||||
<circle
|
||||
key={`pit-${i}`}
|
||||
cx={toX(t)}
|
||||
cy={toY(pos)}
|
||||
r={3}
|
||||
fill="var(--bg)"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
className="rs-pit-dot"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
|
||||
{last && (
|
||||
<text
|
||||
x={toX(last.t) + 6}
|
||||
y={toY(last.pos) + 4}
|
||||
y={labelY + 4}
|
||||
fill={color}
|
||||
fontSize={isHovered ? 11 : 9}
|
||||
fontFamily="var(--f-mono)"
|
||||
@@ -287,25 +598,110 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
width={plotW}
|
||||
height={plotH}
|
||||
fill="transparent"
|
||||
data-testid="position-chart-interaction"
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={() => setScrubTime(null)}
|
||||
onPointerLeave={() => {
|
||||
if (!isPlaying) {
|
||||
clearChapterSelection()
|
||||
setScrubTime(null)
|
||||
}
|
||||
}}
|
||||
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="rs-segmented-control" aria-label="Race replay controls" data-testid="replay-controls">
|
||||
<button
|
||||
type="button"
|
||||
className={`rs-segment ${isPlaying ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setScrubTime((current) => current ?? 0)
|
||||
stopChapterTour()
|
||||
clearChapterSelection()
|
||||
setIsPlaying((current) => !current)
|
||||
}}
|
||||
aria-pressed={isPlaying}
|
||||
>
|
||||
{isPlaying ? 'Pause' : 'Play'}
|
||||
</button>
|
||||
<span className="rs-segment-divider" aria-hidden />
|
||||
{[1, 10, 30].map((speed) => (
|
||||
<button
|
||||
key={speed}
|
||||
type="button"
|
||||
className={`rs-segment ${playbackSpeed === speed ? 'active' : ''}`}
|
||||
onClick={() => setPlaybackSpeed(speed)}
|
||||
aria-pressed={playbackSpeed === speed}
|
||||
>
|
||||
{speed}x
|
||||
</button>
|
||||
))}
|
||||
{mapProbeSettled && mapAvailable && (
|
||||
<>
|
||||
<span className="rs-segment-divider" aria-hidden />
|
||||
<button
|
||||
type="button"
|
||||
className={`rs-segment ${mapOpen ? 'active' : ''}`}
|
||||
onClick={() => setMapOpen((current) => !current)}
|
||||
aria-pressed={mapOpen}
|
||||
data-testid="replay-map-toggle"
|
||||
>
|
||||
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}
|
||||
selectedChapterIndex={selectedChapterIndex}
|
||||
onChapterClick={jumpToChapter}
|
||||
onTourToggle={toggleChapterTour}
|
||||
/>
|
||||
)}
|
||||
<div className={`rs-replay-shell${showMapPanel ? ' rs-replay-shell--split' : ''}`}>
|
||||
<div className="rs-replay-main">
|
||||
{hasChartData ? (
|
||||
chartContent
|
||||
) : (
|
||||
<EmptyStateCard
|
||||
icon={LineChart}
|
||||
title="Lap-by-lap positions not available"
|
||||
hint={
|
||||
<>
|
||||
This session does not have ingested position samples in{' '}
|
||||
<code>/api/v1/race-hub</code>.
|
||||
</>
|
||||
}
|
||||
testId="race-story-no-positions"
|
||||
className="race-story-empty-card"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showMapPanel && (
|
||||
<div className="rs-replay-map-slot" data-testid="replay-map-slot">
|
||||
<ReplayTrackMap
|
||||
outline={outlineQuery.data}
|
||||
replay={replayQuery.data}
|
||||
tMs={replayTMs}
|
||||
drivers={drivers}
|
||||
results={results}
|
||||
loading={outlineQuery.isLoading || replayQuery.isLoading}
|
||||
error={outlineQuery.isError || replayQuery.isError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{displayResults.length > 0 && (
|
||||
<div className="rs-field-list">
|
||||
@@ -313,30 +709,29 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
|
||||
const currentPos = scrubTime !== null ? i + 1 : r.position
|
||||
const isWinner = i === 0 && r.position === 1
|
||||
const pClass = currentPos === 1 ? 'rs-pos-p1' : currentPos === 2 ? 'rs-pos-p2' : currentPos === 3 ? 'rs-pos-p3' : ''
|
||||
|
||||
const pClass =
|
||||
currentPos === 1 ? 'rs-pos-p1' : currentPos === 2 ? 'rs-pos-p2' : currentPos === 3 ? 'rs-pos-p3' : ''
|
||||
|
||||
let currentPoints: number | string = r.points
|
||||
if (scrubTime !== null) {
|
||||
const isSprint = data.session?.session_type?.toLowerCase().includes('sprint')
|
||||
const ptsArray = isSprint ? [8, 7, 6, 5, 4, 3, 2, 1] : [25, 18, 15, 12, 10, 8, 6, 4, 2, 1]
|
||||
currentPoints = currentPos <= ptsArray.length ? ptsArray[currentPos - 1] : 0
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
key={r.driver_number}
|
||||
<div
|
||||
key={r.driver_number}
|
||||
className={`rs-driver-row ${hoverDriver === r.driver_number ? 'rs-driver-row-hover' : ''}`}
|
||||
onMouseEnter={() => setHoverDriver(r.driver_number)}
|
||||
onMouseLeave={() => setHoverDriver(null)}
|
||||
>
|
||||
<div className="rs-driver-left">
|
||||
<div className={`rs-pos-col ${pClass}`}>
|
||||
{currentPos}
|
||||
</div>
|
||||
<div className={`rs-pos-col ${pClass}`}>{currentPos}</div>
|
||||
<div className="rs-driver-cell">
|
||||
<div
|
||||
className="rs-driver-color"
|
||||
style={{ background: r.team_colour ? `#${r.team_colour}` : 'var(--border)' }}
|
||||
<div
|
||||
className="rs-driver-color"
|
||||
style={{ background: r.team_colour ? `#${r.team_colour}` : 'var(--border)' }}
|
||||
/>
|
||||
<div className="rs-driver-identity">
|
||||
<span className="rs-driver-name">{r.name_acronym || r.driver_number}</span>
|
||||
@@ -354,12 +749,12 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
</span>
|
||||
<span className="rs-metric-label">Grid</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="rs-metric" style={{ width: '80px', opacity: scrubTime !== null ? 0.3 : 1 }}>
|
||||
<span>{isWinner ? formatDuration(r.duration) : formatGap(r.gap_to_leader)}</span>
|
||||
<span className="rs-metric-label">{isWinner ? 'Time' : 'Gap'}</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="rs-metric" style={{ width: '40px' }}>
|
||||
<span style={{ color: Number(currentPoints) > 0 ? 'var(--text)' : 'var(--text-3)' }}>
|
||||
{currentPoints}
|
||||
|
||||
159
frontend/src/components/ReplayTrackMap.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Loader2, MapPin, Satellite } from 'lucide-react'
|
||||
import type { Driver, EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types'
|
||||
import { buildOutlinePath } from '../lib/trackmap'
|
||||
import { interpolateReplayCars, replayCarToSvg } from '../lib/replay'
|
||||
import { EmptyStateCard } from './EmptyStateCard'
|
||||
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">
|
||||
<EmptyStateCard
|
||||
icon={Loader2}
|
||||
title="Loading replay map"
|
||||
hint="Fetching track outline and historical GPS frames."
|
||||
testId="replay-map-loading"
|
||||
className="replay-map-empty-card"
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||
<EmptyStateCard
|
||||
icon={Satellite}
|
||||
title="Replay GPS unavailable"
|
||||
hint="This session does not have ingested location samples for the replay map."
|
||||
testId="replay-map-error"
|
||||
className="replay-map-empty-card"
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (!outline || !outlinePath) {
|
||||
return (
|
||||
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||
<EmptyStateCard
|
||||
icon={MapPin}
|
||||
title="Track outline unavailable"
|
||||
hint="Circuit GPS outline data is missing for this session."
|
||||
testId="replay-map-no-outline"
|
||||
className="replay-map-empty-card"
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (!replay?.frames?.length || cars.length === 0) {
|
||||
return (
|
||||
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||
<EmptyStateCard
|
||||
icon={Satellite}
|
||||
title="Historical GPS unavailable"
|
||||
hint="Fewer than two replay frames were returned for this session."
|
||||
testId="replay-map-no-frames"
|
||||
className="replay-map-empty-card"
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
55
frontend/src/components/TeammateH2H.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { teamColor } from '../utils'
|
||||
import '../styles/h2h.css'
|
||||
|
||||
export interface TeammateH2HProps {
|
||||
teamName: string
|
||||
teamColour: string
|
||||
driverATla: string
|
||||
driverBTla: string
|
||||
winsA: number
|
||||
winsB: number
|
||||
/** e.g. "+1" when more than two drivers share the team. */
|
||||
extraNote?: string
|
||||
}
|
||||
|
||||
export function TeammateH2H({
|
||||
teamName,
|
||||
teamColour,
|
||||
driverATla,
|
||||
driverBTla,
|
||||
winsA,
|
||||
winsB,
|
||||
extraNote,
|
||||
}: TeammateH2HProps) {
|
||||
const total = winsA + winsB || 1
|
||||
const pctA = (winsA / total) * 100
|
||||
const color = teamColor(teamColour)
|
||||
|
||||
return (
|
||||
<div className="h2h-row" data-testid="teammate-h2h">
|
||||
<div className="h2h-team">
|
||||
<span className="h2h-team-name">{teamName}</span>
|
||||
{extraNote ? <span className="h2h-extra mono">{extraNote}</span> : null}
|
||||
</div>
|
||||
<div className="h2h-bar-wrap">
|
||||
<span className="h2h-tla h2h-tla--left mono">{driverATla}</span>
|
||||
<div className="h2h-bar" data-testid="h2h-bar">
|
||||
<span
|
||||
className="h2h-bar-seg"
|
||||
data-testid="h2h-bar-a"
|
||||
style={{ width: `${pctA}%`, background: color }}
|
||||
/>
|
||||
<span
|
||||
className="h2h-bar-seg h2h-bar-seg--muted"
|
||||
data-testid="h2h-bar-b"
|
||||
style={{ width: `${100 - pctA}%`, background: color }}
|
||||
/>
|
||||
<span className="h2h-score mono" data-testid="h2h-score">
|
||||
{winsA}–{winsB}
|
||||
</span>
|
||||
</div>
|
||||
<span className="h2h-tla h2h-tla--right mono">{driverBTla}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -80,7 +80,9 @@ export function RaceControlFeed({ messages, driverInfo }: Props) {
|
||||
<section className="live-rc panel-glass">
|
||||
<div className="sec-header sticky-header">
|
||||
<span className="sec-title">Race Control</span>
|
||||
{messages.length > 0 && <span className="sec-meta">{messages.length} messages</span>}
|
||||
<span className="sec-meta" data-testid="rc-timezone">
|
||||
{messages.length > 0 ? `${messages.length} messages · ` : ''}times UTC
|
||||
</span>
|
||||
</div>
|
||||
{latest.length === 0 ? (
|
||||
<div className="missing-notice">No race control messages in the current live snapshot.</div>
|
||||
@@ -102,7 +104,7 @@ export function RaceControlFeed({ messages, driverInfo }: Props) {
|
||||
|
||||
return (
|
||||
<div className={`live-rc-row${flashClass}`} key={key}>
|
||||
<span className="rc-time">{message.Time || '--:--'}</span>
|
||||
<span className="rc-time" title="UTC">{message.Time || '--:--'}</span>
|
||||
{message.Lap > 0 && <span className="rc-lap">L{message.Lap}</span>}
|
||||
{message.Flag
|
||||
? <span className={`rc-flag ${rcFlagClass(message.Flag)}`}>{message.Flag}</span>
|
||||
|
||||
@@ -38,9 +38,11 @@ export function SessionBanner({ isLive, isArchive = false, snapshot, rows, conne
|
||||
<div className="live-banner-meta">
|
||||
{display.advanceCount && <span>{display.advanceCount} advance</span>}
|
||||
{atRiskLabel && <span>{atRiskLabel}</span>}
|
||||
<span>
|
||||
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
|
||||
</span>
|
||||
{display.isRace && (
|
||||
<span data-testid="live-lap-counter">
|
||||
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
|
||||
</span>
|
||||
)}
|
||||
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{stateLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, Fragment } from 'react'
|
||||
import { useState, useMemo, Fragment } from 'react'
|
||||
import { teamColor } from '../../utils'
|
||||
import { useAutoAnimate } from '@formkit/auto-animate/react'
|
||||
import type { LiveSessionMeta, LiveStintData } from '../../types'
|
||||
import type { LiveTimingRow } from '../../lib/live'
|
||||
import {
|
||||
bestLapGaps,
|
||||
driverCode,
|
||||
liveSessionDisplay,
|
||||
positionDelta,
|
||||
@@ -62,6 +63,10 @@ export function TimingTower({
|
||||
const isQuali = sessionDisplay.isQualifying || !isRace
|
||||
const columnCount = 7 + (isRace ? 3 : 0) + (isQuali ? 3 : 0)
|
||||
|
||||
// Practice/qualifying: derive a display-only gap to P1 from valid best laps
|
||||
// when the upstream feed omits GapToLeader. Empty for races (feed is truth).
|
||||
const practiceGaps = useMemo(() => bestLapGaps(isRace ? [] : rows), [isRace, rows])
|
||||
|
||||
return (
|
||||
<div className="scroll-x">
|
||||
<table className="data-table live-tower" style={{ minWidth: 760 }}>
|
||||
@@ -71,9 +76,9 @@ export function TimingTower({
|
||||
{isRace && <th>Δ</th>}
|
||||
<th>Driver</th>
|
||||
<th>Tyre</th>
|
||||
<th>Last Lap</th>
|
||||
<th
|
||||
className="interactive"
|
||||
<th className={isRace ? undefined : 'hide-mobile'}>Last Lap</th>
|
||||
<th
|
||||
className="interactive"
|
||||
onClick={() => setGapMode(g => g === 'interval' ? 'leader' : 'interval')}
|
||||
title="Click to toggle Gap vs Interval"
|
||||
style={{ cursor: 'pointer', textDecoration: 'underline' }}
|
||||
@@ -81,10 +86,10 @@ export function TimingTower({
|
||||
{gapMode === 'interval' && isRace ? 'Interval' : 'Gap to P1'}
|
||||
</th>
|
||||
{isRace && <th>Trend</th>}
|
||||
{isQuali && <th>S1</th>}
|
||||
{isQuali && <th>S2</th>}
|
||||
{isQuali && <th>S3</th>}
|
||||
<th className="hide-mobile">Best</th>
|
||||
{isQuali && <th className="hide-mobile">S1</th>}
|
||||
{isQuali && <th className="hide-mobile">S2</th>}
|
||||
{isQuali && <th className="hide-mobile">S3</th>}
|
||||
<th className={isRace ? 'hide-mobile' : undefined}>Best</th>
|
||||
{isRace && <th className="hide-mobile r">Laps</th>}
|
||||
<th className="r"></th>
|
||||
</tr>
|
||||
@@ -102,7 +107,15 @@ export function TimingTower({
|
||||
driver.Cutoff
|
||||
const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition
|
||||
|
||||
const gapText = gapMode === 'interval' && isRace ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader
|
||||
let gapText: string
|
||||
if (isRace) {
|
||||
gapText = gapMode === 'interval' ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader
|
||||
} else if (driver.GapToLeader) {
|
||||
gapText = driver.GapToLeader
|
||||
} else {
|
||||
const computed = practiceGaps[row.RacingNumber]
|
||||
gapText = computed ? (computed.isLeader ? '—' : computed.gap) : ''
|
||||
}
|
||||
const intervalAnnotation =
|
||||
gapMode === 'interval' && isRace && row.Position > 1
|
||||
? intervalMeaning(parseIntervalSeconds(gapText))
|
||||
@@ -153,7 +166,10 @@ export function TimingTower({
|
||||
<td>
|
||||
<span className={`tyre-badge ${tyreClass(row.Tyre)}`}>{tyreLabel(row.Tyre)}</span>
|
||||
</td>
|
||||
<td className={driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono'}>
|
||||
<td className={[
|
||||
isRace ? '' : 'hide-mobile',
|
||||
driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono',
|
||||
].filter(Boolean).join(' ')}>
|
||||
{driver.LastLapTime || '-'}
|
||||
</td>
|
||||
<td className="mono">
|
||||
@@ -171,11 +187,14 @@ export function TimingTower({
|
||||
</td>
|
||||
)}
|
||||
|
||||
{isQuali && <td>{renderSector(0)}</td>}
|
||||
{isQuali && <td>{renderSector(1)}</td>}
|
||||
{isQuali && <td>{renderSector(2)}</td>}
|
||||
{isQuali && <td className="hide-mobile">{renderSector(0)}</td>}
|
||||
{isQuali && <td className="hide-mobile">{renderSector(1)}</td>}
|
||||
{isQuali && <td className="hide-mobile">{renderSector(2)}</td>}
|
||||
|
||||
<td className={`hide-mobile ${driver.BestLapOB ? 'mono lap-ob' : 'mono'}`}>
|
||||
<td className={[
|
||||
isRace ? 'hide-mobile' : '',
|
||||
driver.BestLapOB ? 'mono lap-ob' : 'mono',
|
||||
].filter(Boolean).join(' ')}>
|
||||
{driver.BestLapTime || '-'}
|
||||
</td>
|
||||
|
||||
@@ -208,6 +227,16 @@ export function TimingTower({
|
||||
<div className="mono">{driver.NumberOfLaps || 0}</div>
|
||||
</div>
|
||||
)}
|
||||
{!isRace && driver.Sectors?.some((sec) => sec?.Value) && (
|
||||
<div data-testid="expanded-sectors">
|
||||
<div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>SECTORS</div>
|
||||
<div className="mono" style={{ display: 'flex', gap: '10px' }}>
|
||||
{renderSector(0)}
|
||||
{renderSector(1)}
|
||||
{renderSector(2)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{driver.SpeedTrap && (
|
||||
<div>
|
||||
<div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>SPEED TRAP</div>
|
||||
|
||||
@@ -52,7 +52,10 @@ function StintSparkline({ seconds }: { seconds: number[] }) {
|
||||
}
|
||||
|
||||
export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const isRace = isRaceSession(sessionType)
|
||||
// In practice/qualifying deg trends are secondary — collapse by default so
|
||||
// the Timing Tower stays above the fold. Races keep it open.
|
||||
const [collapsed, setCollapsed] = useState(!isRace)
|
||||
const [stints, setStints] = useState<StintHistoryMap>({})
|
||||
|
||||
// One lap-history update per received snapshot (rows is rebuilt per snapshot).
|
||||
@@ -61,8 +64,6 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
||||
setStints((prev) => recordStintSamples(prev, rows.map(stintInputFromRow)))
|
||||
}, [rows])
|
||||
|
||||
const isRace = isRaceSession(sessionType)
|
||||
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
rows.filter(
|
||||
|
||||
132
frontend/src/lib/chapters.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
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.
|
||||
* Uses the same 0–1 clamped bounds as chapterStartScrub/chapterEndScrub so
|
||||
* chapters whose timestamps fall outside the position-sample window still
|
||||
* activate when the scrubber is parked at the clamped edge. */
|
||||
export function activeChapterIndex(
|
||||
chapters: Chapter[],
|
||||
scrubTime: number | null,
|
||||
tMin: number,
|
||||
tRange: number,
|
||||
): number | null {
|
||||
if (scrubTime === null || chapters.length === 0 || tRange <= 0) return null
|
||||
for (let i = 0; i < chapters.length; i++) {
|
||||
const start = chapterStartScrub(chapters[i], tMin, tRange)
|
||||
const end = chapterEndScrub(chapters[i], tMin, tRange)
|
||||
if (start === null || end === null) continue
|
||||
const lo = Math.min(start, end)
|
||||
const hi = Math.max(start, end)
|
||||
if (scrubTime >= lo && scrubTime <= hi) 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)
|
||||
}
|
||||
|
||||
/** Decimated position axis labels, e.g. P1, P5, P10, P15, P20. */
|
||||
export function decimatedPositionLabels(maxPos: number): number[] {
|
||||
if (maxPos <= 1) return [1]
|
||||
const labels = new Set<number>([1])
|
||||
for (let pos = 5; pos < maxPos; pos += 5) {
|
||||
labels.add(pos)
|
||||
}
|
||||
labels.add(maxPos)
|
||||
return [...labels].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
/** Subtle background fill for chapter time-bands on the position graph. */
|
||||
export function chapterBandFill(kind: string): string {
|
||||
switch (kind) {
|
||||
case 'safety_car':
|
||||
return 'rgba(255, 153, 0, 0.14)'
|
||||
case 'virtual_safety_car':
|
||||
return 'rgba(255, 204, 0, 0.12)'
|
||||
case 'red_flag':
|
||||
return 'rgba(230, 36, 41, 0.12)'
|
||||
case 'pit_phase':
|
||||
return 'rgba(96, 165, 250, 0.08)'
|
||||
case 'decisive_swing':
|
||||
return 'rgba(34, 197, 94, 0.08)'
|
||||
case 'finish':
|
||||
return 'rgba(255, 204, 0, 0.06)'
|
||||
default:
|
||||
return 'rgba(255, 255, 255, 0.03)'
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimum vertical spacing between colliding labels (SVG units). */
|
||||
export function deCollideYPositions(
|
||||
items: ReadonlyArray<{ key: string | number; y: number }>,
|
||||
minGap = 12,
|
||||
): Map<string | number, number> {
|
||||
if (items.length === 0) return new Map()
|
||||
const sorted = [...items].sort((a, b) => a.y - b.y)
|
||||
const adjusted = sorted.map((item) => ({ ...item }))
|
||||
for (let i = 1; i < adjusted.length; i++) {
|
||||
const prev = adjusted[i - 1]
|
||||
const curr = adjusted[i]
|
||||
if (curr.y - prev.y < minGap) {
|
||||
curr.y = prev.y + minGap
|
||||
}
|
||||
}
|
||||
return new Map(adjusted.map((item) => [item.key, item.y]))
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
47
frontend/src/lib/h2h.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { ChampHubDriver } from '../types'
|
||||
|
||||
export interface TeammatePair {
|
||||
teamName: string
|
||||
teamColour: string
|
||||
driverA: ChampHubDriver
|
||||
driverB: ChampHubDriver
|
||||
/** Drivers on the team beyond the top two (shown as +N). */
|
||||
extraCount: number
|
||||
/** Absolute win gap — smaller means a closer battle. */
|
||||
closeness: number
|
||||
}
|
||||
|
||||
/** Pair teammates per team for H2H display; skip single-driver teams. */
|
||||
export function teammatePairs(drivers: ReadonlyArray<ChampHubDriver>): TeammatePair[] {
|
||||
const byTeam = new Map<string, ChampHubDriver[]>()
|
||||
for (const d of drivers) {
|
||||
const list = byTeam.get(d.team_name) ?? []
|
||||
list.push(d)
|
||||
byTeam.set(d.team_name, list)
|
||||
}
|
||||
|
||||
const pairs: TeammatePair[] = []
|
||||
for (const [teamName, members] of byTeam) {
|
||||
if (members.length < 2) continue
|
||||
|
||||
const sorted = [...members].sort((a, b) => b.points - a.points)
|
||||
const driverA = sorted[0]
|
||||
const driverB = sorted[1]
|
||||
const winsA = driverA.teammate_wins
|
||||
const winsB = driverB.teammate_wins
|
||||
|
||||
pairs.push({
|
||||
teamName,
|
||||
teamColour: driverA.team_colour,
|
||||
driverA,
|
||||
driverB,
|
||||
extraCount: members.length - 2,
|
||||
closeness: Math.abs(winsA - winsB),
|
||||
})
|
||||
}
|
||||
|
||||
return pairs.sort((a, b) => {
|
||||
if (a.closeness !== b.closeness) return a.closeness - b.closeness
|
||||
return a.teamName.localeCompare(b.teamName)
|
||||
})
|
||||
}
|
||||
@@ -268,6 +268,63 @@ export function driverCode(row: LiveTimingRow): string {
|
||||
return row.Info?.Tla || row.RacingNumber
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an F1 lap-time string ("1:45.944", "45.944") into total seconds.
|
||||
* Returns null for empty/invalid values so callers never invent a gap.
|
||||
*/
|
||||
export function parseLapTimeSeconds(value: string | null | undefined): number | null {
|
||||
if (!value) return null
|
||||
const match = value.trim().match(/^(?:(\d+):)?([0-5]?\d(?:\.\d+)?)$/)
|
||||
if (!match) return null
|
||||
const minutes = match[1] ? Number(match[1]) : 0
|
||||
const seconds = Number(match[2])
|
||||
if (!Number.isFinite(minutes) || !Number.isFinite(seconds)) return null
|
||||
return minutes * 60 + seconds
|
||||
}
|
||||
|
||||
export interface BestLapGap {
|
||||
isLeader: boolean
|
||||
gap: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Display-only gap-to-P1 for practice/qualifying, derived from each driver's
|
||||
* valid best lap. Only drivers with a parseable best lap get an entry, and the
|
||||
* fastest is flagged as the leader. Never fabricates a gap from a missing or
|
||||
* invalid lap — the upstream interval remains the source of truth for races.
|
||||
*/
|
||||
export function bestLapGaps(rows: ReadonlyArray<LiveTimingRow>): Record<string, BestLapGap> {
|
||||
let leaderNumber = ''
|
||||
let best = Infinity
|
||||
for (const row of rows) {
|
||||
const seconds = parseLapTimeSeconds(row.Driver.BestLapTime)
|
||||
if (seconds === null) continue
|
||||
if (seconds < best) {
|
||||
best = seconds
|
||||
leaderNumber = row.RacingNumber
|
||||
}
|
||||
}
|
||||
|
||||
const out: Record<string, BestLapGap> = {}
|
||||
if (!Number.isFinite(best)) return out
|
||||
for (const row of rows) {
|
||||
const seconds = parseLapTimeSeconds(row.Driver.BestLapTime)
|
||||
if (seconds === null) continue
|
||||
out[row.RacingNumber] =
|
||||
row.RacingNumber === leaderNumber
|
||||
? { isLeader: true, gap: '' }
|
||||
: { isLeader: false, gap: `+${(seconds - best).toFixed(3)}` }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** True when the live feed reports an in-progress session with timing data. */
|
||||
export function isLiveSessionActive(
|
||||
state: { is_live?: boolean; data?: unknown } | null | undefined,
|
||||
): boolean {
|
||||
return Boolean(state?.is_live && state.data)
|
||||
}
|
||||
|
||||
export function trackStatusLabel(status: string): string {
|
||||
return TRACK_STATUS_LABELS[status] || status || 'UNKNOWN'
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
14
frontend/src/lib/replayMap.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ReplayFramesResponse, TrackOutline } from '../types'
|
||||
|
||||
/** Whether replay map can render (probe uses existing frames + outline queries). */
|
||||
export function isReplayMapAvailable(
|
||||
replay: ReplayFramesResponse | null | undefined,
|
||||
outline: TrackOutline | null | undefined,
|
||||
hasError: boolean,
|
||||
): boolean {
|
||||
if (hasError) return false
|
||||
const frames = replay?.frames ?? []
|
||||
if (frames.length < 2) return false
|
||||
if (!outline?.points?.length) return false
|
||||
return true
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { ChampHubDriver, ChampionshipHub } from '../types'
|
||||
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
|
||||
import { RivalryCompare } from '../components/RivalryCompare'
|
||||
import { Meaning } from '../components/Meaning'
|
||||
import { TeammateH2H } from '../components/TeammateH2H'
|
||||
import { teammatePairs } from '../lib/h2h'
|
||||
import { pointsGapMeaning } from '../lib/meaning'
|
||||
|
||||
type View = 'drivers' | 'constructors' | 'progression' | 'rivalry' | 'simulator'
|
||||
@@ -252,6 +254,7 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
||||
leaderPoints={leader.points}
|
||||
titleMath={titleMath}
|
||||
roundsLeft={hub.rounds_left}
|
||||
drivers={drivers}
|
||||
season={hub.season}
|
||||
/>
|
||||
)}
|
||||
@@ -283,15 +286,19 @@ function DriversView({
|
||||
leaderPoints,
|
||||
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])
|
||||
|
||||
return (
|
||||
<div data-testid="champ-view-drivers">
|
||||
<div className="champ-podium">
|
||||
@@ -350,6 +357,29 @@ function DriversView({
|
||||
<span className="champ-titlemath-text">{titleMath}</span>
|
||||
</div>
|
||||
|
||||
{h2hPairs.length > 0 && (
|
||||
<section className="h2h-section" data-testid="champ-teammate-battles">
|
||||
<div className="h2h-section-head">
|
||||
<h2 className="h2h-section-title">Teammate battles</h2>
|
||||
<span className="h2h-section-sub">race finishes · closest first</span>
|
||||
</div>
|
||||
<div className="h2h-list">
|
||||
{h2hPairs.map((pair) => (
|
||||
<TeammateH2H
|
||||
key={pair.teamName}
|
||||
teamName={pair.teamName}
|
||||
teamColour={pair.teamColour}
|
||||
driverATla={pair.driverA.name_acronym}
|
||||
driverBTla={pair.driverB.name_acronym}
|
||||
winsA={pair.driverA.teammate_wins}
|
||||
winsB={pair.driverB.teammate_wins}
|
||||
extraNote={pair.extraCount > 0 ? `+${pair.extraCount}` : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="champ-scroll">
|
||||
<table className="champ-table champ-table-drivers">
|
||||
<thead>
|
||||
|
||||
@@ -112,6 +112,30 @@ a { color: inherit; text-decoration: none; }
|
||||
.nav-links a:hover { color: var(--text); background: var(--surface-h); }
|
||||
.nav-links a.active { color: var(--text); background: var(--surface-2); }
|
||||
|
||||
/* Live nav gets a pulsing marker only while a session is on air. */
|
||||
.nav-live { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.nav-links a.nav-live-on { color: var(--text); font-weight: 600; }
|
||||
.nav-live-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--red);
|
||||
box-shadow: 0 0 0 0 rgba(225, 6, 0, 0.4);
|
||||
animation: pulse-live 1.6s infinite;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.nav-utility {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
|
||||
208
frontend/src/styles/chapters.css
Normal file
@@ -0,0 +1,208 @@
|
||||
.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);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.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-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chapter-strip-scroll-wrap::before,
|
||||
.chapter-strip-scroll-wrap::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: var(--s2);
|
||||
width: 28px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.chapter-strip-scroll-wrap::before {
|
||||
left: 0;
|
||||
background: linear-gradient(to right, var(--bg), transparent);
|
||||
}
|
||||
|
||||
.chapter-strip-scroll-wrap::after {
|
||||
right: 0;
|
||||
background: linear-gradient(to left, var(--bg), transparent);
|
||||
}
|
||||
|
||||
.chapter-strip-scroll {
|
||||
display: flex;
|
||||
gap: var(--s3);
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--s2);
|
||||
scroll-snap-type: x mandatory;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-2) transparent;
|
||||
}
|
||||
|
||||
.chapter-strip-scroll::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.chapter-strip-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chapter-strip-scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--border-2);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.chapter-card {
|
||||
flex: 0 0 min(240px, 72vw);
|
||||
min-height: 88px;
|
||||
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: center;
|
||||
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;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chapter-strip-empty-card {
|
||||
padding: var(--s6) var(--s5);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chapter-strip-empty-card .empty-state-icon {
|
||||
display: block;
|
||||
margin: 0 auto var(--s3);
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.chapter-strip-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
130
frontend/src/styles/h2h.css
Normal file
@@ -0,0 +1,130 @@
|
||||
/* Teammate head-to-head bars (issue #23). */
|
||||
|
||||
.h2h-section {
|
||||
margin: 20px 0 24px;
|
||||
padding: 16px 18px;
|
||||
background: var(--surface-1, rgba(255, 255, 255, 0.03));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.h2h-section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.h2h-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.h2h-section-sub {
|
||||
font-size: 11px;
|
||||
font-family: var(--f-mono);
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.h2h-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.h2h-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(100px, 140px) 1fr;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.h2h-team {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.h2h-team-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.h2h-extra {
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.h2h-bar-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.h2h-tla {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.h2h-tla--left {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.h2h-tla--right {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.h2h-bar {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.h2h-bar-seg {
|
||||
height: 100%;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.h2h-bar-seg--muted {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.h2h-score {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.h2h-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.h2h-bar-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
157
frontend/src/styles/race-story.css
Normal file
@@ -0,0 +1,157 @@
|
||||
.race-story-empty-card {
|
||||
padding: var(--s6) var(--s5);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.race-story-empty-card .empty-state-icon {
|
||||
display: block;
|
||||
margin: 0 auto var(--s3);
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.rs-replay-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--s5);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.rs-replay-shell--split {
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.8fr);
|
||||
}
|
||||
|
||||
.rs-replay-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rs-chart-container {
|
||||
--rs-chart-max-width: 100%;
|
||||
}
|
||||
|
||||
.rs-chart-container--full {
|
||||
--rs-chart-max-width: 100%;
|
||||
}
|
||||
|
||||
.rs-segmented-control {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
margin-top: var(--s3);
|
||||
border: 1px solid var(--border-2);
|
||||
border-radius: var(--s1);
|
||||
background: var(--surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rs-segment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 30px;
|
||||
padding: 0 var(--s4);
|
||||
font-family: var(--f-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-3);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.rs-segment:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.rs-segment:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
background: var(--surface-h);
|
||||
}
|
||||
|
||||
.rs-segment:focus-visible {
|
||||
outline: 2px solid var(--red);
|
||||
outline-offset: -2px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.rs-segment.active {
|
||||
color: var(--text);
|
||||
background: rgba(230, 36, 41, 0.12);
|
||||
box-shadow: inset 0 -2px 0 var(--red);
|
||||
}
|
||||
|
||||
.rs-segment:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.rs-segment-divider {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
background: var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rs-replay-map-slot {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rs-position-chart-svg {
|
||||
width: 100%;
|
||||
min-width: 280px;
|
||||
max-width: var(--rs-chart-max-width, 640px);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rs-chart-axis-label {
|
||||
fill: var(--text-3);
|
||||
font-family: var(--f-mono);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.rs-chart-grid-line {
|
||||
stroke: var(--border);
|
||||
stroke-width: 0.5;
|
||||
}
|
||||
|
||||
.rs-chart-lap-tick {
|
||||
stroke: var(--border);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.rs-replay-shell--split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.rs-segmented-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rs-segment {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.rs-segmented-control {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.rs-segment {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.rs-segment:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.rs-segment-divider {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
91
frontend/src/styles/replay-map.css
Normal file
@@ -0,0 +1,91 @@
|
||||
.replay-map-panel {
|
||||
min-height: 244px;
|
||||
height: 100%;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: var(--s2);
|
||||
}
|
||||
|
||||
.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-card {
|
||||
min-height: 244px;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
padding: var(--s6);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.replay-map-empty-card .empty-state-icon {
|
||||
display: block;
|
||||
margin: 0 auto var(--s3);
|
||||
color: var(--text-3);
|
||||
}
|
||||
211
frontend/src/test/BriefingPage.test.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
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,
|
||||
round_positions: [],
|
||||
},
|
||||
{
|
||||
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,
|
||||
round_positions: [],
|
||||
},
|
||||
]
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -169,4 +169,61 @@ describe('ChampionshipPage', () => {
|
||||
})
|
||||
expect(screen.getByText('No championship data')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders teammate battles section ordered by closest split', async () => {
|
||||
const h2hDrivers: ChampHubDriver[] = [
|
||||
driver({ driver_number: 1, name_acronym: 'VER', team_name: 'Red Bull', points: 200, position: 1 }),
|
||||
driver({
|
||||
driver_number: 4,
|
||||
name_acronym: 'NOR',
|
||||
full_name: 'Lando Norris',
|
||||
team_name: 'McLaren',
|
||||
team_colour: 'ff8000',
|
||||
points: 160,
|
||||
position: 2,
|
||||
teammate_wins: 6,
|
||||
teammate_losses: 5,
|
||||
cumulative: [18, 36, 54, 80, 120, 160],
|
||||
}),
|
||||
driver({
|
||||
driver_number: 81,
|
||||
name_acronym: 'PIA',
|
||||
full_name: 'Oscar Piastri',
|
||||
team_name: 'McLaren',
|
||||
team_colour: 'ff8000',
|
||||
points: 140,
|
||||
position: 3,
|
||||
teammate_wins: 5,
|
||||
teammate_losses: 6,
|
||||
cumulative: [12, 28, 45, 70, 110, 140],
|
||||
}),
|
||||
driver({
|
||||
driver_number: 11,
|
||||
name_acronym: 'PER',
|
||||
full_name: 'Sergio Perez',
|
||||
team_name: 'Red Bull',
|
||||
team_colour: '3671c6',
|
||||
points: 60,
|
||||
position: 4,
|
||||
teammate_wins: 1,
|
||||
teammate_losses: 9,
|
||||
cumulative: [5, 12, 20, 35, 50, 60],
|
||||
}),
|
||||
]
|
||||
mockFetchHub.mockResolvedValue({ ...hub, drivers: h2hDrivers })
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('champ-teammate-battles')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText('Teammate battles')).toBeInTheDocument()
|
||||
const rows = screen.getAllByTestId('teammate-h2h')
|
||||
expect(rows).toHaveLength(2)
|
||||
// McLaren 6–5 is closer than Red Bull 9–1 — McLaren row first.
|
||||
expect(rows[0]).toHaveTextContent('McLaren')
|
||||
expect(rows[0]).toHaveTextContent('6–5')
|
||||
expect(rows[1]).toHaveTextContent('Red Bull')
|
||||
expect(rows[1]).toHaveTextContent('9–1')
|
||||
})
|
||||
})
|
||||
|
||||
128
frontend/src/test/ChapterStrip.test.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
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('renders an empty-state card when there are no chapters', () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ChapterStrip
|
||||
chapters={[]}
|
||||
scrubTime={null}
|
||||
tMin={tMin}
|
||||
tRange={tRange}
|
||||
tourActive={false}
|
||||
tourChapterIndex={null}
|
||||
onChapterClick={vi.fn()}
|
||||
onTourToggle={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
expect(screen.getByTestId('chapter-strip-empty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('highlights the active chapter from scrub time', () => {
|
||||
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)
|
||||
})
|
||||
|
||||
it('highlights an explicitly selected chapter even when scrub is outside its raw window', () => {
|
||||
// Scrub parked at chart start; chapter 1's raw times are mid-race, but selection wins.
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ChapterStrip
|
||||
chapters={chapters}
|
||||
scrubTime={0}
|
||||
tMin={tMin}
|
||||
tRange={tRange}
|
||||
tourActive={false}
|
||||
tourChapterIndex={null}
|
||||
selectedChapterIndex={1}
|
||||
onChapterClick={vi.fn()}
|
||||
onTourToggle={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
expect(screen.getByTestId('chapter-card-1')).toHaveClass('active')
|
||||
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
|
||||
})
|
||||
})
|
||||
@@ -209,6 +209,32 @@ describe('TimingTower', () => {
|
||||
expect(screen.getByText('DRS range')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('derives a gap to P1 from best laps when the practice feed omits GapToLeader', () => {
|
||||
const practiceRows = [
|
||||
makeRow('1', 1, 'VER', { BestLapTime: '1:45.944', GapToLeader: '' }),
|
||||
makeRow('4', 2, 'NOR', { BestLapTime: '1:46.134', GapToLeader: '' }),
|
||||
makeRow('16', 3, 'LEC', { BestLapTime: '', GapToLeader: '' }),
|
||||
]
|
||||
render(
|
||||
<TimingTower
|
||||
rows={practiceRows}
|
||||
session={{
|
||||
MeetingName: 'Belgian Grand Prix',
|
||||
CircuitName: 'Spa',
|
||||
SessionType: 'Practice',
|
||||
SessionName: 'Practice 2',
|
||||
Path: '',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
// Leader shows a clear leader marker, not a fabricated gap.
|
||||
expect(screen.getByText('VER').closest('tr')).toHaveTextContent('—')
|
||||
expect(screen.getByText('+0.190')).toBeInTheDocument()
|
||||
// A driver without a valid best lap gets no invented gap.
|
||||
const lecRow = screen.getByText('LEC').closest('tr')!
|
||||
expect(lecRow).not.toHaveTextContent('+')
|
||||
})
|
||||
|
||||
it('renders the SQ1 cutoff after P17 and marks rows below as at risk', () => {
|
||||
const sprintRows = Array.from({ length: 22 }, (_, index) =>
|
||||
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
||||
|
||||
@@ -153,6 +153,29 @@ describe('LiveTimingPage archive mode', () => {
|
||||
expect(screen.queryByRole('button', { name: /view last session/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the live session for a red-flag paused snapshot instead of the inactive empty state', async () => {
|
||||
renderPage({
|
||||
is_live: true,
|
||||
data: {
|
||||
...archivedSnapshot,
|
||||
SessionStatus: 'Inactive',
|
||||
TrackStatus: '2',
|
||||
Clock: '00:03:27',
|
||||
Session: {
|
||||
MeetingName: 'Belgian Grand Prix',
|
||||
CircuitName: 'Spa-Francorchamps',
|
||||
SessionType: 'Practice',
|
||||
SessionName: 'Practice 2',
|
||||
Path: '',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Timing Tower')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('live-empty')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('live-archive-strip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('temporarily omits the track map while live GPS is unavailable', async () => {
|
||||
renderPage({
|
||||
is_live: true,
|
||||
|
||||
291
frontend/src/test/RaceStoryCanvas.test.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
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 { Chapter, 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 } } },
|
||||
{ t: 5000, cars: { '1': { x: 50, y: 50 } } },
|
||||
],
|
||||
}
|
||||
|
||||
const chapters: Chapter[] = [
|
||||
{
|
||||
kind: 'start',
|
||||
title: 'Start',
|
||||
headline: 'Lights out',
|
||||
start_lap: 1,
|
||||
end_lap: 1,
|
||||
start_time: '2025-05-25T13:00:00Z',
|
||||
end_time: '2025-05-25T13:01:00Z',
|
||||
driver_numbers: [],
|
||||
},
|
||||
{
|
||||
kind: 'safety_car',
|
||||
title: 'Safety Car',
|
||||
headline: 'Incident brings out the Safety Car',
|
||||
start_lap: 2,
|
||||
end_lap: 2,
|
||||
start_time: '2025-05-25T13:05:00Z',
|
||||
end_time: '2025-05-25T13:05:00Z',
|
||||
driver_numbers: [],
|
||||
},
|
||||
]
|
||||
|
||||
const raceHub: RaceHub = {
|
||||
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(overrides: Partial<RaceHub> = {}) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RaceStoryCanvas data={{ ...raceHub, ...overrides }} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('RaceStoryCanvas replay map', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchReplayFrames.mockResolvedValue(replay)
|
||||
mockFetchTrackOutline.mockResolvedValue(outline)
|
||||
})
|
||||
|
||||
it('probes replay frames on mount and opens the map when data is available', async () => {
|
||||
renderCanvas()
|
||||
|
||||
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalledWith(99, 5000))
|
||||
expect(mockFetchTrackOutline).toHaveBeenCalledWith(1, 2025)
|
||||
|
||||
const mapToggle = await screen.findByTestId('replay-map-toggle')
|
||||
fireEvent.click(mapToggle)
|
||||
|
||||
expect(await screen.findByTestId('replay-track-map')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('replay-map-slot')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the map toggle when replay frames are unavailable', async () => {
|
||||
mockFetchReplayFrames.mockResolvedValue({ ...replay, frames: [] })
|
||||
renderCanvas()
|
||||
|
||||
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalled())
|
||||
await waitFor(() => expect(screen.queryByTestId('replay-map-toggle')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('uses full-width chart layout when the map is closed', async () => {
|
||||
renderCanvas()
|
||||
|
||||
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalled())
|
||||
const chart = screen.getByTestId('position-chart')
|
||||
expect(chart).toHaveClass('rs-chart-container--full')
|
||||
expect(screen.queryByTestId('replay-map-slot')).not.toBeInTheDocument()
|
||||
expect(document.querySelector('.rs-replay-shell--split')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses lap timing rather than pre-race position samples for the chart scale', () => {
|
||||
const laps = Array.from({ length: 20 }, (_, index) => ({
|
||||
session_key: 99,
|
||||
driver_number: 1,
|
||||
meeting_key: 1,
|
||||
lap_number: index + 1,
|
||||
date_start: `2025-05-25T13:${String(index).padStart(2, '0')}:00Z`,
|
||||
lap_duration: 60,
|
||||
is_pit_out_lap: false,
|
||||
}))
|
||||
|
||||
renderCanvas({
|
||||
results: [{ ...raceHub.results[0], number_of_laps: 20 }],
|
||||
laps,
|
||||
positions: [
|
||||
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T12:00:00Z', position: 1 },
|
||||
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:20:00Z', position: 1 },
|
||||
],
|
||||
})
|
||||
|
||||
// L10 is the end of the tenth 60s lap, exactly halfway through the race.
|
||||
const lapTenLabel = screen.getByText('L10')
|
||||
expect(lapTenLabel.previousElementSibling).toHaveAttribute('x1', '316')
|
||||
// The pre-race position is retained as the starting grid at the left edge.
|
||||
expect(document.querySelector('.rs-driver-line')).toHaveAttribute('points', '40,8 592,8 592,8')
|
||||
})
|
||||
|
||||
it('maps cursor positions from rendered pixels to the SVG viewBox', () => {
|
||||
renderCanvas()
|
||||
|
||||
const svg = document.querySelector('.rs-position-chart-svg')!
|
||||
vi.spyOn(svg, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1280,
|
||||
height: 360,
|
||||
top: 0,
|
||||
right: 1280,
|
||||
bottom: 360,
|
||||
left: 0,
|
||||
toJSON: () => ({}),
|
||||
})
|
||||
|
||||
const pointerMove = new Event('pointermove', { bubbles: true })
|
||||
Object.defineProperty(pointerMove, 'clientX', { value: 640 })
|
||||
fireEvent(screen.getByTestId('position-chart-interaction'), pointerMove)
|
||||
|
||||
// 640px is the middle of a 1280px rendered chart, which is x=320 in its 640-unit viewBox.
|
||||
expect(document.querySelector('.rs-playhead')).toHaveAttribute('x1', '320')
|
||||
})
|
||||
|
||||
it('syncs active chapter highlight when a chapter card is clicked', async () => {
|
||||
renderCanvas()
|
||||
|
||||
fireEvent.click(screen.getByTestId('chapter-card-1'))
|
||||
await waitFor(() => expect(screen.getByTestId('chapter-card-1')).toHaveClass('active'))
|
||||
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
|
||||
})
|
||||
|
||||
it('highlights out-of-window chapters after click (clamped scrub + selection)', async () => {
|
||||
// Position samples start at 13:05; start chapter ends at 13:01 — outside the window.
|
||||
renderCanvas({
|
||||
positions: [
|
||||
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:05:00Z', position: 1 },
|
||||
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:10:00Z', position: 1 },
|
||||
],
|
||||
chapters: [
|
||||
{
|
||||
kind: 'start',
|
||||
title: 'Start',
|
||||
headline: 'Lights out before samples',
|
||||
start_lap: 1,
|
||||
end_lap: 1,
|
||||
start_time: '2025-05-25T13:00:00Z',
|
||||
end_time: '2025-05-25T13:01:00Z',
|
||||
driver_numbers: [],
|
||||
},
|
||||
{
|
||||
kind: 'finish',
|
||||
title: 'Finish',
|
||||
headline: 'Flag after samples',
|
||||
start_lap: 78,
|
||||
end_lap: 78,
|
||||
start_time: '2025-05-25T13:20:00Z',
|
||||
end_time: '2025-05-25T13:21:00Z',
|
||||
driver_numbers: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('chapter-card-0'))
|
||||
await waitFor(() => expect(screen.getByTestId('chapter-card-0')).toHaveClass('active'))
|
||||
expect(screen.getByTestId('chapter-card-1')).not.toHaveClass('active')
|
||||
|
||||
fireEvent.click(screen.getByTestId('chapter-card-1'))
|
||||
await waitFor(() => expect(screen.getByTestId('chapter-card-1')).toHaveClass('active'))
|
||||
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
|
||||
})
|
||||
|
||||
it('renders the empty-state card when positions are unavailable', () => {
|
||||
renderCanvas({
|
||||
datasets: { positions: { status: 'missing', source: 'local', count: 0 } },
|
||||
positions: [],
|
||||
chapters: [],
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('race-story-no-positions')).toBeInTheDocument()
|
||||
expect(screen.getByText('Lap-by-lap positions not available')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
57
frontend/src/test/ReplayTrackMap.test.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
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 card when replay frames are missing', () => {
|
||||
render(<ReplayTrackMap outline={outline} replay={{ ...replay, frames: [] }} tMs={0} drivers={[]} results={results} />)
|
||||
expect(screen.getByTestId('replay-map-no-frames')).toBeInTheDocument()
|
||||
expect(screen.getByText('Historical GPS unavailable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
61
frontend/src/test/SessionBanner.test.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { SessionBanner } from '../components/live/SessionBanner'
|
||||
import type { LiveStreamData } from '../types'
|
||||
|
||||
function makeSnapshot(sessionType: string, sessionName: string): LiveStreamData {
|
||||
return {
|
||||
Drivers: {},
|
||||
DriverInfo: {},
|
||||
Tyres: {},
|
||||
RCMessages: [],
|
||||
Weather: { AirTemp: 0, TrackTemp: 0, Humidity: 0, WindSpeed: 0, WindDir: 0, Rainfall: false },
|
||||
Session: {
|
||||
MeetingName: 'Belgian Grand Prix',
|
||||
CircuitName: 'Spa-Francorchamps',
|
||||
SessionType: sessionType,
|
||||
SessionName: sessionName,
|
||||
Path: '',
|
||||
},
|
||||
TeamRadio: [],
|
||||
TrackStatus: '1',
|
||||
CurrentLap: 0,
|
||||
TotalLaps: 0,
|
||||
Clock: '00:45:00',
|
||||
ClockRefTime: '',
|
||||
ClockExtrapolating: false,
|
||||
Stints: {},
|
||||
}
|
||||
}
|
||||
|
||||
describe('SessionBanner', () => {
|
||||
it('never renders a race lap counter for a practice session', () => {
|
||||
render(
|
||||
<SessionBanner
|
||||
isLive
|
||||
snapshot={makeSnapshot('Practice', 'Practice 2')}
|
||||
rows={[]}
|
||||
connection="connected"
|
||||
now={0}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByTestId('live-lap-counter')).not.toBeInTheDocument()
|
||||
// Session identity and clock stay intact.
|
||||
expect(screen.getByText('Belgian Grand Prix')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('live-clock')).toHaveTextContent('00:45:00')
|
||||
})
|
||||
|
||||
it('shows the lap counter for a race session', () => {
|
||||
const snapshot = { ...makeSnapshot('Race', 'Race'), CurrentLap: 12, TotalLaps: 44 }
|
||||
render(
|
||||
<SessionBanner
|
||||
isLive
|
||||
snapshot={snapshot}
|
||||
rows={[]}
|
||||
connection="connected"
|
||||
now={0}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('live-lap-counter')).toHaveTextContent('L12/44')
|
||||
})
|
||||
})
|
||||
54
frontend/src/test/TeammateH2H.test.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { TeammateH2H } from '../components/TeammateH2H'
|
||||
|
||||
describe('TeammateH2H', () => {
|
||||
it('renders score and TLAs', () => {
|
||||
render(
|
||||
<TeammateH2H
|
||||
teamName="Red Bull"
|
||||
teamColour="3671c6"
|
||||
driverATla="VER"
|
||||
driverBTla="PER"
|
||||
winsA={9}
|
||||
winsB={1}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('VER')).toBeInTheDocument()
|
||||
expect(screen.getByText('PER')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('h2h-score')).toHaveTextContent('9–1')
|
||||
expect(screen.getByText('Red Bull')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sets proportional bar segment widths from wins', () => {
|
||||
render(
|
||||
<TeammateH2H
|
||||
teamName="McLaren"
|
||||
teamColour="ff8000"
|
||||
driverATla="NOR"
|
||||
driverBTla="PIA"
|
||||
winsA={6}
|
||||
winsB={4}
|
||||
/>,
|
||||
)
|
||||
const barA = screen.getByTestId('h2h-bar-a')
|
||||
const barB = screen.getByTestId('h2h-bar-b')
|
||||
expect(barA).toHaveStyle({ width: '60%' })
|
||||
expect(barB).toHaveStyle({ width: '40%' })
|
||||
})
|
||||
|
||||
it('shows extra driver note when provided', () => {
|
||||
render(
|
||||
<TeammateH2H
|
||||
teamName="Red Bull"
|
||||
teamColour="3671c6"
|
||||
driverATla="VER"
|
||||
driverBTla="PER"
|
||||
winsA={5}
|
||||
winsB={3}
|
||||
extraNote="+1"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('+1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -81,14 +81,20 @@ describe('TyreDegPanel', () => {
|
||||
expect(panel).toHaveTextContent('→ ~P2')
|
||||
})
|
||||
|
||||
it('hides the rejoin estimate outside race sessions and collapses on toggle', () => {
|
||||
it('collapses by default outside race sessions and hides the rejoin estimate when expanded', () => {
|
||||
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Qualifying" pinned={[]} />)
|
||||
const panel = screen.getByTestId('tyredeg-panel')
|
||||
expect(panel).not.toHaveTextContent('~P')
|
||||
|
||||
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
|
||||
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
||||
// Practice/qualifying starts collapsed so the Timing Tower stays above the fold.
|
||||
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
||||
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
||||
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
|
||||
expect(panel).not.toHaveTextContent('~P')
|
||||
})
|
||||
|
||||
it('starts expanded during a race', () => {
|
||||
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
|
||||
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('limits rows to the top ten plus pinned drivers', () => {
|
||||
|
||||
124
frontend/src/test/chapters.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
activeChapterIndex,
|
||||
chapterBandFill,
|
||||
chapterKindLabel,
|
||||
chapterLapRange,
|
||||
chapterStartScrub,
|
||||
chapterTourDurations,
|
||||
deCollideYPositions,
|
||||
decimatedPositionLabels,
|
||||
} 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('activates chapters whose timestamps clamp outside the position window', () => {
|
||||
// Position samples only cover 13:05–13:10; chapters sit before/after that window.
|
||||
const tMin = new Date('2025-05-25T13:05:00Z').getTime()
|
||||
const tMax = new Date('2025-05-25T13:10:00Z').getTime()
|
||||
const tRange = tMax - tMin
|
||||
const outOfWindow: Chapter[] = [
|
||||
{
|
||||
kind: 'start',
|
||||
title: 'Start',
|
||||
headline: 'Lights out',
|
||||
start_lap: 1,
|
||||
end_lap: 1,
|
||||
start_time: '2025-05-25T13:00:00Z',
|
||||
end_time: '2025-05-25T13:01:00Z',
|
||||
driver_numbers: [],
|
||||
},
|
||||
{
|
||||
kind: 'finish',
|
||||
title: 'Finish',
|
||||
headline: 'Chequered flag',
|
||||
start_lap: 78,
|
||||
end_lap: 78,
|
||||
start_time: '2025-05-25T13:20:00Z',
|
||||
end_time: '2025-05-25T13:21:00Z',
|
||||
driver_numbers: [],
|
||||
},
|
||||
]
|
||||
|
||||
expect(chapterStartScrub(outOfWindow[0], tMin, tRange)).toBe(0)
|
||||
expect(chapterStartScrub(outOfWindow[1], tMin, tRange)).toBe(1)
|
||||
expect(activeChapterIndex(outOfWindow, 0, tMin, tRange)).toBe(0)
|
||||
expect(activeChapterIndex(outOfWindow, 1, tMin, tRange)).toBe(1)
|
||||
expect(activeChapterIndex(outOfWindow, 0.5, tMin, tRange)).toBeNull()
|
||||
})
|
||||
|
||||
it('splits 90s evenly across chapters', () => {
|
||||
expect(chapterTourDurations(sampleChapters)).toEqual([45_000, 45_000])
|
||||
})
|
||||
|
||||
it('decimates position axis labels', () => {
|
||||
expect(decimatedPositionLabels(22)).toEqual([1, 5, 10, 15, 20, 22])
|
||||
})
|
||||
|
||||
it('returns chapter band fills by kind', () => {
|
||||
expect(chapterBandFill('safety_car')).toContain('rgba')
|
||||
expect(chapterBandFill('virtual_safety_car')).toContain('rgba')
|
||||
})
|
||||
|
||||
it('de-collides overlapping label y positions', () => {
|
||||
const adjusted = deCollideYPositions(
|
||||
[
|
||||
{ key: 'a', y: 10 },
|
||||
{ key: 'b', y: 12 },
|
||||
{ key: 'c', y: 30 },
|
||||
],
|
||||
12,
|
||||
)
|
||||
expect(adjusted.get('a')).toBe(10)
|
||||
expect(adjusted.get('b')).toBe(22)
|
||||
expect(adjusted.get('c')).toBe(34)
|
||||
})
|
||||
})
|
||||
266
frontend/src/test/digest.test.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
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,
|
||||
round_positions: [],
|
||||
...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)
|
||||
})
|
||||
})
|
||||
133
frontend/src/test/h2h.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { teammatePairs } from '../lib/h2h'
|
||||
import type { ChampHubDriver } from '../types'
|
||||
|
||||
function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
|
||||
return {
|
||||
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: [],
|
||||
cumulative: [],
|
||||
teammate_wins: 9,
|
||||
teammate_losses: 1,
|
||||
round_positions: [],
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('teammatePairs', () => {
|
||||
it('returns empty for no drivers', () => {
|
||||
expect(teammatePairs([])).toEqual([])
|
||||
})
|
||||
|
||||
it('skips teams with a single driver', () => {
|
||||
const drivers = [
|
||||
driver({ driver_number: 1, team_name: 'Red Bull' }),
|
||||
driver({ driver_number: 44, name_acronym: 'HAM', team_name: 'Mercedes', points: 50 }),
|
||||
]
|
||||
expect(teammatePairs(drivers)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('pairs two-driver teams', () => {
|
||||
const drivers = [
|
||||
driver({ driver_number: 1, name_acronym: 'VER', points: 200, teammate_wins: 9, teammate_losses: 1 }),
|
||||
driver({
|
||||
driver_number: 11,
|
||||
name_acronym: 'PER',
|
||||
full_name: 'Sergio Perez',
|
||||
points: 60,
|
||||
teammate_wins: 1,
|
||||
teammate_losses: 9,
|
||||
}),
|
||||
]
|
||||
const pairs = teammatePairs(drivers)
|
||||
expect(pairs).toHaveLength(1)
|
||||
expect(pairs[0].teamName).toBe('Red Bull')
|
||||
expect(pairs[0].driverA.name_acronym).toBe('VER')
|
||||
expect(pairs[0].driverB.name_acronym).toBe('PER')
|
||||
expect(pairs[0].extraCount).toBe(0)
|
||||
expect(pairs[0].closeness).toBe(8)
|
||||
})
|
||||
|
||||
it('uses top two by points for teams with 3+ drivers', () => {
|
||||
const drivers = [
|
||||
driver({ driver_number: 1, name_acronym: 'VER', team_name: 'Red Bull', points: 200 }),
|
||||
driver({
|
||||
driver_number: 11,
|
||||
name_acronym: 'PER',
|
||||
team_name: 'Red Bull',
|
||||
points: 60,
|
||||
teammate_wins: 1,
|
||||
teammate_losses: 9,
|
||||
}),
|
||||
driver({
|
||||
driver_number: 99,
|
||||
name_acronym: 'LAW',
|
||||
full_name: 'Liam Lawson',
|
||||
team_name: 'Red Bull',
|
||||
points: 10,
|
||||
teammate_wins: 0,
|
||||
teammate_losses: 0,
|
||||
}),
|
||||
]
|
||||
const pairs = teammatePairs(drivers)
|
||||
expect(pairs).toHaveLength(1)
|
||||
expect(pairs[0].driverA.name_acronym).toBe('VER')
|
||||
expect(pairs[0].driverB.name_acronym).toBe('PER')
|
||||
expect(pairs[0].extraCount).toBe(1)
|
||||
})
|
||||
|
||||
it('sorts by closest battle first', () => {
|
||||
const drivers = [
|
||||
driver({
|
||||
driver_number: 1,
|
||||
name_acronym: 'VER',
|
||||
team_name: 'Red Bull',
|
||||
points: 200,
|
||||
teammate_wins: 10,
|
||||
teammate_losses: 0,
|
||||
}),
|
||||
driver({
|
||||
driver_number: 11,
|
||||
name_acronym: 'PER',
|
||||
team_name: 'Red Bull',
|
||||
points: 60,
|
||||
teammate_wins: 0,
|
||||
teammate_losses: 10,
|
||||
}),
|
||||
driver({
|
||||
driver_number: 4,
|
||||
name_acronym: 'NOR',
|
||||
team_name: 'McLaren',
|
||||
team_colour: 'ff8000',
|
||||
points: 160,
|
||||
teammate_wins: 6,
|
||||
teammate_losses: 5,
|
||||
}),
|
||||
driver({
|
||||
driver_number: 81,
|
||||
name_acronym: 'PIA',
|
||||
full_name: 'Oscar Piastri',
|
||||
team_name: 'McLaren',
|
||||
team_colour: 'ff8000',
|
||||
points: 140,
|
||||
teammate_wins: 5,
|
||||
teammate_losses: 6,
|
||||
}),
|
||||
]
|
||||
const pairs = teammatePairs(drivers)
|
||||
expect(pairs).toHaveLength(2)
|
||||
expect(pairs[0].teamName).toBe('McLaren')
|
||||
expect(pairs[0].closeness).toBe(1)
|
||||
expect(pairs[1].teamName).toBe('Red Bull')
|
||||
expect(pairs[1].closeness).toBe(10)
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
bestLapGaps,
|
||||
compoundClass,
|
||||
compoundLetter,
|
||||
extrapolateClock,
|
||||
isLiveSessionActive,
|
||||
latestRaceControl,
|
||||
liveSessionDisplay,
|
||||
loadPinnedDrivers,
|
||||
mergeVisibleSectors,
|
||||
parseLapTimeSeconds,
|
||||
positionDeltaClass,
|
||||
parseLiveStateEvent,
|
||||
rcFlagClass,
|
||||
@@ -279,6 +282,43 @@ describe('live qualifying display', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('practice/qualifying computed gaps', () => {
|
||||
it('parses lap-time strings into seconds and rejects invalid input', () => {
|
||||
expect(parseLapTimeSeconds('1:45.944')).toBeCloseTo(105.944, 3)
|
||||
expect(parseLapTimeSeconds('45.944')).toBeCloseTo(45.944, 3)
|
||||
expect(parseLapTimeSeconds('')).toBeNull()
|
||||
expect(parseLapTimeSeconds(undefined)).toBeNull()
|
||||
expect(parseLapTimeSeconds('-')).toBeNull()
|
||||
expect(parseLapTimeSeconds('nope')).toBeNull()
|
||||
})
|
||||
|
||||
it('derives a gap to P1 from valid best laps only, flagging the leader', () => {
|
||||
const gaps = bestLapGaps([
|
||||
timingRow('1', 1, { BestLapTime: '1:45.944' }),
|
||||
timingRow('4', 2, { BestLapTime: '1:46.134' }),
|
||||
timingRow('16', 3, { BestLapTime: '' }),
|
||||
])
|
||||
expect(gaps['1']).toEqual({ isLeader: true, gap: '' })
|
||||
expect(gaps['4']).toEqual({ isLeader: false, gap: '+0.190' })
|
||||
// No valid best lap → no fabricated gap.
|
||||
expect(gaps['16']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns no gaps when nobody has set a lap', () => {
|
||||
expect(bestLapGaps([timingRow('1', 1, { BestLapTime: '' })])).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('live session activity', () => {
|
||||
it('is active only when the feed reports a live session with data', () => {
|
||||
expect(isLiveSessionActive({ is_live: true, data: snapshot })).toBe(true)
|
||||
expect(isLiveSessionActive({ is_live: true, data: null })).toBe(false)
|
||||
expect(isLiveSessionActive({ is_live: false, data: snapshot })).toBe(false)
|
||||
expect(isLiveSessionActive(null)).toBe(false)
|
||||
expect(isLiveSessionActive(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('visible sector display', () => {
|
||||
it('holds S1 and S2 through temporary blanks while a flying lap is active', () => {
|
||||
const first = [timingRow('4', 1, {
|
||||
|
||||
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 })
|
||||
})
|
||||
})
|
||||
33
frontend/src/test/replayMap.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isReplayMapAvailable } from '../lib/replayMap'
|
||||
import type { ReplayFramesResponse, TrackOutline } from '../types'
|
||||
|
||||
const outline: TrackOutline = {
|
||||
circuit_key: 1,
|
||||
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
|
||||
points: [{ x: 0, y: 0 }],
|
||||
}
|
||||
|
||||
const replay: ReplayFramesResponse = {
|
||||
session_key: 1,
|
||||
interval_ms: 5000,
|
||||
start_time: '2025-05-25T13:00:00Z',
|
||||
frames: [
|
||||
{ t: 0, cars: {} },
|
||||
{ t: 5000, cars: {} },
|
||||
],
|
||||
}
|
||||
|
||||
describe('isReplayMapAvailable', () => {
|
||||
it('returns true when frames and outline are present', () => {
|
||||
expect(isReplayMapAvailable(replay, outline, false)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when frames are sparse', () => {
|
||||
expect(isReplayMapAvailable({ ...replay, frames: [{ t: 0, cars: {} }] }, outline, false)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false on query error', () => {
|
||||
expect(isReplayMapAvailable(replay, outline, true)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -97,6 +97,7 @@ export interface RaceHub {
|
||||
export interface Chapter {
|
||||
kind: 'start' | 'safety_car' | 'virtual_safety_car' | 'red_flag' | 'pit_phase' | 'decisive_swing' | 'finish' | string
|
||||
title: string
|
||||
headline: string
|
||||
start_lap: number
|
||||
end_lap: number
|
||||
start_time?: string
|
||||
@@ -360,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
|
||||
|
||||
@@ -38,6 +38,7 @@ type Lap = models.Lap
|
||||
type Chapter struct {
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Headline string `json:"headline"`
|
||||
StartLap int `json:"start_lap"`
|
||||
EndLap int `json:"end_lap"`
|
||||
StartTime string `json:"start_time,omitempty"`
|
||||
|
||||
233
internal/chapters/headlines.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package chapters
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
)
|
||||
|
||||
// DriverIdentityInput carries session driver fields used for headline templates.
|
||||
type DriverIdentityInput struct {
|
||||
DriverNumber int
|
||||
NameAcronym string
|
||||
FullName string
|
||||
TeamName string
|
||||
}
|
||||
|
||||
type driverIdentity struct {
|
||||
display string
|
||||
team string
|
||||
acronym string
|
||||
}
|
||||
|
||||
// BuildDriverMap indexes driver identity from session drivers and enriched results.
|
||||
func BuildDriverMap(drivers []models.Driver, results []DriverIdentityInput) map[int]driverIdentity {
|
||||
out := map[int]driverIdentity{}
|
||||
for _, d := range drivers {
|
||||
if d.DriverNumber <= 0 {
|
||||
continue
|
||||
}
|
||||
out[d.DriverNumber] = driverIdentity{
|
||||
display: driverDisplayName(d.LastName, d.FullName, d.NameAcronym, d.BroadcastName),
|
||||
team: d.TeamName,
|
||||
acronym: firstNonEmpty(d.NameAcronym, d.BroadcastName),
|
||||
}
|
||||
}
|
||||
for _, r := range results {
|
||||
if r.DriverNumber <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := out[r.DriverNumber]; ok {
|
||||
continue
|
||||
}
|
||||
out[r.DriverNumber] = driverIdentity{
|
||||
display: driverDisplayName("", r.FullName, r.NameAcronym, ""),
|
||||
team: r.TeamName,
|
||||
acronym: r.NameAcronym,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ApplyHeadlines fills Headline on each chapter using deterministic templates.
|
||||
func ApplyHeadlines(chapters []Chapter, drivers map[int]driverIdentity, rc []RaceControl, winnerNumber int) []Chapter {
|
||||
out := make([]Chapter, len(chapters))
|
||||
copy(out, chapters)
|
||||
for i := range out {
|
||||
out[i].Headline = headlineFor(out[i], drivers, rc, winnerNumber)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func headlineFor(ch Chapter, drivers map[int]driverIdentity, rc []RaceControl, winnerNumber int) string {
|
||||
switch ch.Kind {
|
||||
case KindStart:
|
||||
return pickVariant(ch.StartLap,
|
||||
"Lights out — the field charges into Turn 1",
|
||||
"Race start — Lap 1 shuffle at the front",
|
||||
)
|
||||
case KindSafetyCar:
|
||||
if name := driverName(ch, drivers, incidentDriver(rc, ch.StartLap)); name != "" {
|
||||
return pickVariant(ch.StartLap,
|
||||
fmt.Sprintf("%s incident brings out the Safety Car — leaders dive for the pits", name),
|
||||
fmt.Sprintf("Safety Car deployed after %s stops on track", name),
|
||||
)
|
||||
}
|
||||
case KindVirtualSafetyCar:
|
||||
if name := driverName(ch, drivers, incidentDriver(rc, ch.StartLap)); name != "" {
|
||||
return pickVariant(ch.StartLap,
|
||||
fmt.Sprintf("%s off track triggers the Virtual Safety Car", name),
|
||||
fmt.Sprintf("Virtual Safety Car — %s loses control on Lap %d", name, ch.StartLap),
|
||||
)
|
||||
}
|
||||
return pickVariant(ch.StartLap,
|
||||
fmt.Sprintf("Virtual Safety Car deployed on Lap %d", ch.StartLap),
|
||||
fmt.Sprintf("VSC period — field backs off on L%d–L%d", ch.StartLap, ch.EndLap),
|
||||
)
|
||||
case KindRedFlag:
|
||||
if name := driverName(ch, drivers, incidentDriver(rc, ch.StartLap)); name != "" {
|
||||
return pickVariant(ch.StartLap,
|
||||
fmt.Sprintf("%s crash forces a red flag on Lap %d", name, ch.StartLap),
|
||||
fmt.Sprintf("Red flag after %s incident on Lap %d", name, ch.StartLap),
|
||||
)
|
||||
}
|
||||
return pickVariant(ch.StartLap,
|
||||
fmt.Sprintf("Red flag — session halted on Lap %d", ch.StartLap),
|
||||
fmt.Sprintf("Race suspended under red flag on L%d–L%d", ch.StartLap, ch.EndLap),
|
||||
)
|
||||
case KindPitPhase:
|
||||
names := driverNames(ch.DriverNumbers, drivers, 3)
|
||||
if len(names) >= 2 {
|
||||
return pickVariant(ch.StartLap,
|
||||
fmt.Sprintf("Mass pit-window scramble — %s and %s box on L%d–L%d", names[0], names[1], ch.StartLap, ch.EndLap),
|
||||
fmt.Sprintf("Undercut window opens — %s leads the pit rush on L%d–L%d", names[0], ch.StartLap, ch.EndLap),
|
||||
)
|
||||
}
|
||||
if len(names) == 1 {
|
||||
return pickVariant(ch.StartLap,
|
||||
fmt.Sprintf("%s pits under green — strategy window opens on L%d", names[0], ch.StartLap),
|
||||
fmt.Sprintf("Pit phase on L%d–L%d — %s among the first to stop", ch.StartLap, ch.EndLap, names[0]),
|
||||
)
|
||||
}
|
||||
case KindDecisiveSwing:
|
||||
if len(ch.DriverNumbers) >= 2 {
|
||||
attacker := driverName(ch, drivers, ch.DriverNumbers[0])
|
||||
defender := driverName(ch, drivers, ch.DriverNumbers[1])
|
||||
pos := swingPosition(ch.Title)
|
||||
if attacker != "" && defender != "" && pos > 0 {
|
||||
return pickVariant(ch.StartLap,
|
||||
fmt.Sprintf("%s overtakes %s for P%d", attacker, defender, pos),
|
||||
fmt.Sprintf("%s charges past %s into P%d on Lap %d", attacker, defender, pos, ch.StartLap),
|
||||
)
|
||||
}
|
||||
}
|
||||
if len(ch.DriverNumbers) >= 1 {
|
||||
attacker := driverName(ch, drivers, ch.DriverNumbers[0])
|
||||
pos := swingPosition(ch.Title)
|
||||
if attacker != "" && pos > 0 {
|
||||
return pickVariant(ch.StartLap,
|
||||
fmt.Sprintf("%s moves up to P%d on Lap %d", attacker, pos, ch.StartLap),
|
||||
fmt.Sprintf("Decisive swing — %s climbs to P%d", attacker, pos),
|
||||
)
|
||||
}
|
||||
}
|
||||
case KindFinish:
|
||||
winner := driverName(ch, drivers, winnerNumber)
|
||||
if winner != "" {
|
||||
return pickVariant(ch.StartLap,
|
||||
fmt.Sprintf("%s crosses the line to take the win", winner),
|
||||
fmt.Sprintf("Chequered flag — %s wins the race", winner),
|
||||
)
|
||||
}
|
||||
}
|
||||
return ch.Title
|
||||
}
|
||||
|
||||
func pickVariant(seed int, variants ...string) string {
|
||||
if len(variants) == 0 {
|
||||
return ""
|
||||
}
|
||||
if seed < 0 {
|
||||
seed = -seed
|
||||
}
|
||||
return variants[seed%len(variants)]
|
||||
}
|
||||
|
||||
func driverName(_ Chapter, drivers map[int]driverIdentity, number int) string {
|
||||
if number <= 0 {
|
||||
return ""
|
||||
}
|
||||
if id, ok := drivers[number]; ok && id.display != "" {
|
||||
return id.display
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func driverNames(numbers []int, drivers map[int]driverIdentity, limit int) []string {
|
||||
out := make([]string, 0, limit)
|
||||
for _, number := range numbers {
|
||||
if name := driverName(Chapter{}, drivers, number); name != "" {
|
||||
out = append(out, name)
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func driverDisplayName(lastName, fullName, acronym, broadcast string) string {
|
||||
if lastName != "" {
|
||||
return lastName
|
||||
}
|
||||
if fullName != "" {
|
||||
parts := strings.Fields(fullName)
|
||||
if len(parts) > 0 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return fullName
|
||||
}
|
||||
return firstNonEmpty(acronym, broadcast)
|
||||
}
|
||||
|
||||
func incidentDriver(rc []RaceControl, startLap int) int {
|
||||
for _, msg := range rc {
|
||||
if msg.DriverNumber == nil || *msg.DriverNumber <= 0 {
|
||||
continue
|
||||
}
|
||||
lap := 0
|
||||
if msg.LapNumber != nil {
|
||||
lap = *msg.LapNumber
|
||||
}
|
||||
if lap < startLap-1 || lap > startLap+1 {
|
||||
continue
|
||||
}
|
||||
text := upperText(msg.Message, string(msg.Category))
|
||||
if strings.Contains(text, "DEPLOY") ||
|
||||
strings.Contains(text, "CLEAR") ||
|
||||
strings.Contains(text, "ENDING") ||
|
||||
strings.Contains(text, "GREEN") ||
|
||||
strings.Contains(text, "CHEQUER") {
|
||||
continue
|
||||
}
|
||||
return *msg.DriverNumber
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func swingPosition(title string) int {
|
||||
// Title format: "Decisive swing: #16 to P3 (L6)"
|
||||
idx := strings.Index(title, "P")
|
||||
if idx < 0 || idx+1 >= len(title) {
|
||||
return 0
|
||||
}
|
||||
pos := 0
|
||||
for i := idx + 1; i < len(title); i++ {
|
||||
if title[i] < '0' || title[i] > '9' {
|
||||
break
|
||||
}
|
||||
pos = pos*10 + int(title[i]-'0')
|
||||
}
|
||||
return pos
|
||||
}
|
||||
199
internal/chapters/headlines_test.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package chapters
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
)
|
||||
|
||||
func TestHeadlineStart(t *testing.T) {
|
||||
ch := Chapter{Kind: KindStart, Title: "Start", StartLap: 1, EndLap: 1}
|
||||
got := headlineFor(ch, nil, nil, 0)
|
||||
want := "Race start — Lap 1 shuffle at the front"
|
||||
if got != want {
|
||||
t.Fatalf("headline = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlineStartVariantByLap(t *testing.T) {
|
||||
ch := Chapter{Kind: KindStart, Title: "Start", StartLap: 2, EndLap: 2}
|
||||
got := headlineFor(ch, nil, nil, 0)
|
||||
want := "Lights out — the field charges into Turn 1"
|
||||
if got != want {
|
||||
t.Fatalf("headline = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlineSafetyCarWithDriver(t *testing.T) {
|
||||
drivers := BuildDriverMap(nil, []DriverIdentityInput{
|
||||
{DriverNumber: 55, NameAcronym: "SAI", FullName: "Carlos Sainz", TeamName: "Ferrari"},
|
||||
})
|
||||
rc := []RaceControl{
|
||||
{
|
||||
DriverNumber: intPtr(55),
|
||||
LapNumber: intPtr(12),
|
||||
Message: "CAR 55 STOPPED ON TRACK",
|
||||
},
|
||||
rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
|
||||
rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
|
||||
}
|
||||
ch := Chapter{
|
||||
Kind: KindSafetyCar,
|
||||
Title: "Safety Car (L12-L15)",
|
||||
StartLap: 12,
|
||||
EndLap: 15,
|
||||
}
|
||||
got := headlineFor(ch, drivers, rc, 0)
|
||||
want := "Sainz incident brings out the Safety Car — leaders dive for the pits"
|
||||
if got != want {
|
||||
t.Fatalf("headline = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlineSafetyCarFallbackWithoutDriver(t *testing.T) {
|
||||
ch := Chapter{
|
||||
Kind: KindSafetyCar,
|
||||
Title: "Safety Car (L12-L15)",
|
||||
StartLap: 12,
|
||||
EndLap: 15,
|
||||
}
|
||||
got := headlineFor(ch, nil, nil, 0)
|
||||
if got != ch.Title {
|
||||
t.Fatalf("headline = %q, want fallback %q", got, ch.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlineVirtualSafetyCar(t *testing.T) {
|
||||
drivers := BuildDriverMap(nil, []DriverIdentityInput{
|
||||
{DriverNumber: 16, NameAcronym: "LEC", FullName: "Charles Leclerc", TeamName: "Ferrari"},
|
||||
})
|
||||
rc := []RaceControl{
|
||||
{
|
||||
DriverNumber: intPtr(16),
|
||||
LapNumber: intPtr(22),
|
||||
Message: "CAR 16 OFF TRACK",
|
||||
},
|
||||
rc(22, models.CategoryOther, "", "VSC DEPLOYED"),
|
||||
rc(24, models.CategoryOther, "", "VSC ENDING"),
|
||||
}
|
||||
ch := Chapter{
|
||||
Kind: KindVirtualSafetyCar,
|
||||
Title: "Virtual Safety Car (L22-L24)",
|
||||
StartLap: 22,
|
||||
EndLap: 24,
|
||||
}
|
||||
got := headlineFor(ch, drivers, rc, 0)
|
||||
want := "Leclerc off track triggers the Virtual Safety Car"
|
||||
if got != want {
|
||||
t.Fatalf("headline = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlineRedFlag(t *testing.T) {
|
||||
drivers := BuildDriverMap(nil, []DriverIdentityInput{
|
||||
{DriverNumber: 63, NameAcronym: "RUS", FullName: "George Russell", TeamName: "Mercedes"},
|
||||
})
|
||||
rc := []RaceControl{
|
||||
{
|
||||
DriverNumber: intPtr(63),
|
||||
LapNumber: intPtr(31),
|
||||
Message: "INCIDENT INVOLVING CAR 63",
|
||||
},
|
||||
rc(31, models.CategoryFlag, models.FlagRed, "RED FLAG"),
|
||||
rc(33, models.CategoryFlag, models.FlagGreen, "GREEN FLAG"),
|
||||
}
|
||||
ch := Chapter{
|
||||
Kind: KindRedFlag,
|
||||
Title: "Red Flag (L31-L33)",
|
||||
StartLap: 31,
|
||||
EndLap: 33,
|
||||
}
|
||||
got := headlineFor(ch, drivers, rc, 0)
|
||||
want := "Red flag after Russell incident on Lap 31"
|
||||
if got != want {
|
||||
t.Fatalf("headline = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlinePitPhase(t *testing.T) {
|
||||
drivers := BuildDriverMap(nil, []DriverIdentityInput{
|
||||
{DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull"},
|
||||
{DriverNumber: 44, NameAcronym: "HAM", FullName: "Lewis Hamilton", TeamName: "Mercedes"},
|
||||
{DriverNumber: 16, NameAcronym: "LEC", FullName: "Charles Leclerc", TeamName: "Ferrari"},
|
||||
})
|
||||
ch := Chapter{
|
||||
Kind: KindPitPhase,
|
||||
Title: "Pit phase (L20-L22)",
|
||||
StartLap: 20,
|
||||
EndLap: 22,
|
||||
DriverNumbers: []int{1, 44, 16},
|
||||
}
|
||||
got := headlineFor(ch, drivers, nil, 0)
|
||||
want := "Mass pit-window scramble — Verstappen and Hamilton box on L20–L22"
|
||||
if got != want {
|
||||
t.Fatalf("headline = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlineDecisiveSwing(t *testing.T) {
|
||||
drivers := BuildDriverMap(nil, []DriverIdentityInput{
|
||||
{DriverNumber: 16, NameAcronym: "LEC", FullName: "Charles Leclerc", TeamName: "Ferrari"},
|
||||
{DriverNumber: 55, NameAcronym: "SAI", FullName: "Carlos Sainz", TeamName: "Ferrari"},
|
||||
})
|
||||
ch := Chapter{
|
||||
Kind: KindDecisiveSwing,
|
||||
Title: "Decisive swing: #16 to P3 (L6)",
|
||||
StartLap: 6,
|
||||
EndLap: 6,
|
||||
DriverNumbers: []int{16, 55},
|
||||
}
|
||||
got := headlineFor(ch, drivers, nil, 0)
|
||||
want := "Leclerc overtakes Sainz for P3"
|
||||
if got != want {
|
||||
t.Fatalf("headline = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlineFinish(t *testing.T) {
|
||||
drivers := BuildDriverMap(nil, []DriverIdentityInput{
|
||||
{DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull"},
|
||||
})
|
||||
ch := Chapter{
|
||||
Kind: KindFinish,
|
||||
Title: "Finish (L57-L58)",
|
||||
StartLap: 57,
|
||||
EndLap: 58,
|
||||
}
|
||||
got := headlineFor(ch, drivers, nil, 1)
|
||||
want := "Chequered flag — Verstappen wins the race"
|
||||
if got != want {
|
||||
t.Fatalf("headline = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlineFinishFallbackWithoutWinner(t *testing.T) {
|
||||
ch := Chapter{
|
||||
Kind: KindFinish,
|
||||
Title: "Finish (L57-L58)",
|
||||
StartLap: 57,
|
||||
EndLap: 58,
|
||||
}
|
||||
got := headlineFor(ch, nil, nil, 0)
|
||||
if got != ch.Title {
|
||||
t.Fatalf("headline = %q, want fallback %q", got, ch.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyHeadlinesPreservesChapterFields(t *testing.T) {
|
||||
input := []Chapter{
|
||||
{Kind: KindStart, Title: "Start", StartLap: 1, EndLap: 1},
|
||||
}
|
||||
got := ApplyHeadlines(input, nil, nil, 0)
|
||||
if len(got) != 1 || got[0].StartLap != 1 || got[0].Headline == "" {
|
||||
t.Fatalf("ApplyHeadlines = %+v, want headline on start chapter", got)
|
||||
}
|
||||
}
|
||||
|
||||
func intPtr(v int) *int {
|
||||
return &v
|
||||
}
|
||||
@@ -307,6 +307,28 @@ func TestSessionStatusIsActive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStatusIsTerminal(t *testing.T) {
|
||||
tests := []struct {
|
||||
status string
|
||||
want bool
|
||||
}{
|
||||
{"Finished", true},
|
||||
{"Finalised", true},
|
||||
{"Finalized", true},
|
||||
{"Ends", true},
|
||||
{"Aborted", true},
|
||||
{"Started", false},
|
||||
{"Resumed", false},
|
||||
{"Inactive", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := live.SessionStatusIsTerminal(tt.status); got != tt.want {
|
||||
t.Errorf("SessionStatusIsTerminal(%q) = %v, want %v", tt.status, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessTopicRaceControlMessages(t *testing.T) {
|
||||
state := live.NewState()
|
||||
data := json.RawMessage(`{
|
||||
|
||||
@@ -186,6 +186,19 @@ func SessionStatusIsActive(status string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// SessionStatusIsTerminal reports whether a raw F1 live timing SessionStatus
|
||||
// value represents a session that has ended and will not resume. A temporarily
|
||||
// inactive session (e.g. a red-flag pause reported as "Inactive") is neither
|
||||
// active nor terminal.
|
||||
func SessionStatusIsTerminal(status string) bool {
|
||||
switch normalizeSessionStatus(status) {
|
||||
case "finished", "finalised", "finalized", "ends", "aborted":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSessionStatus(status string) string {
|
||||
out := make([]rune, 0, len(status))
|
||||
for _, r := range status {
|
||||
|
||||
@@ -252,10 +252,38 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
|
||||
}
|
||||
|
||||
hub.Chapters = chapters.Detect(hub.RaceControl, hub.Positions, hub.Laps, totalLaps(hub.Results, hub.Laps))
|
||||
hub.Chapters = chapters.ApplyHeadlines(
|
||||
hub.Chapters,
|
||||
chapters.BuildDriverMap(hub.Drivers, driverIdentityInputs(hub.Results)),
|
||||
hub.RaceControl,
|
||||
winnerDriverNumber(hub.Results),
|
||||
)
|
||||
hub.Source = responseSource(hub.Datasets)
|
||||
return hub, nil
|
||||
}
|
||||
|
||||
func driverIdentityInputs(results []EnrichedResult) []chapters.DriverIdentityInput {
|
||||
out := make([]chapters.DriverIdentityInput, 0, len(results))
|
||||
for _, r := range results {
|
||||
out = append(out, chapters.DriverIdentityInput{
|
||||
DriverNumber: r.DriverNumber,
|
||||
NameAcronym: r.NameAcronym,
|
||||
FullName: r.FullName,
|
||||
TeamName: r.TeamName,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func winnerDriverNumber(results []EnrichedResult) int {
|
||||
for _, r := range results {
|
||||
if r.Position == 1 {
|
||||
return r.DriverNumber
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func totalLaps(results []EnrichedResult, laps []models.Lap) int {
|
||||
total := 0
|
||||
for _, result := range results {
|
||||
|
||||
@@ -134,7 +134,7 @@ func (h *SSEHub) applySnapshot(data live.LiveStreamData, now time.Time) liveStat
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if live.SessionStatusIsActive(data.SessionStatus) {
|
||||
if snapshotIsLive(data) {
|
||||
h.isLive = true
|
||||
h.activeSnapshot = &data
|
||||
if data.PositionUpdated && len(data.Positions) > 0 {
|
||||
@@ -208,6 +208,40 @@ func (h *SSEHub) stateLocked() liveStatePayload {
|
||||
return payload
|
||||
}
|
||||
|
||||
// snapshotIsLive reports whether the current live-timing snapshot represents an
|
||||
// ongoing session that should be surfaced as live.
|
||||
//
|
||||
// An actively running session (Started/Resumed) is always live. A non-terminal
|
||||
// but temporarily inactive session — e.g. a red-flag pause where SessionStatus
|
||||
// drops to "Inactive" while the session is still in progress — is also live when
|
||||
// the snapshot itself carries live evidence: a session clock with time remaining
|
||||
// plus session metadata. This keeps the paused track state visible instead of
|
||||
// collapsing to the inactive empty state.
|
||||
//
|
||||
// Terminal sessions (Finished/Finalised/Ends/Aborted) are never live. A generic
|
||||
// inactive/no-session stale snapshot is not live either: the predicate keys off
|
||||
// the current snapshot's clock and session, so old metadata alone is not enough.
|
||||
func snapshotIsLive(data live.LiveStreamData) bool {
|
||||
if live.SessionStatusIsActive(data.SessionStatus) {
|
||||
return true
|
||||
}
|
||||
if live.SessionStatusIsTerminal(data.SessionStatus) {
|
||||
return false
|
||||
}
|
||||
return clockHasTimeRemaining(data.Clock) && data.Session.SessionName != ""
|
||||
}
|
||||
|
||||
// clockHasTimeRemaining reports whether an "HH:MM:SS" session clock has any time
|
||||
// left. Empty or all-zero clocks (a spent or absent session) return false.
|
||||
func clockHasTimeRemaining(clock string) bool {
|
||||
for _, r := range clock {
|
||||
if r >= '1' && r <= '9' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasLiveSnapshotData(data live.LiveStreamData) bool {
|
||||
return len(data.Drivers) > 0 ||
|
||||
len(data.DriverInfo) > 0 ||
|
||||
|
||||
@@ -51,6 +51,101 @@ func TestSSEHubArchivesTerminalSessionSnapshot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotIsLive(t *testing.T) {
|
||||
redFlag := live.LiveStreamData{
|
||||
SessionStatus: "Inactive",
|
||||
TrackStatus: "2",
|
||||
Clock: "00:03:27",
|
||||
Session: live.LiveSessionMeta{MeetingName: "Belgian Grand Prix", SessionName: "Practice 2", SessionType: "Practice"},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
data live.LiveStreamData
|
||||
want bool
|
||||
}{
|
||||
{"started", live.LiveStreamData{SessionStatus: "Started"}, true},
|
||||
{"resumed", live.LiveStreamData{SessionStatus: "Resumed"}, true},
|
||||
{"red flag inactive with clock and session", redFlag, true},
|
||||
{"terminal finished with clock", func() live.LiveStreamData {
|
||||
d := redFlag
|
||||
d.SessionStatus = "Finished"
|
||||
return d
|
||||
}(), false},
|
||||
{"inactive spent clock", func() live.LiveStreamData {
|
||||
d := redFlag
|
||||
d.Clock = "00:00:00"
|
||||
return d
|
||||
}(), false},
|
||||
{"inactive no clock", func() live.LiveStreamData {
|
||||
d := redFlag
|
||||
d.Clock = ""
|
||||
return d
|
||||
}(), false},
|
||||
{"inactive no session metadata", func() live.LiveStreamData {
|
||||
d := redFlag
|
||||
d.Session = live.LiveSessionMeta{}
|
||||
return d
|
||||
}(), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := snapshotIsLive(tt.data); got != tt.want {
|
||||
t.Errorf("%s: snapshotIsLive = %v, want %v", tt.name, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEHubKeepsRedFlagSessionLive(t *testing.T) {
|
||||
hub := newSSEHub()
|
||||
now := time.Date(2026, 7, 26, 14, 0, 0, 0, time.UTC)
|
||||
|
||||
redFlag := live.LiveStreamData{
|
||||
SessionStatus: "Inactive",
|
||||
TrackStatus: "2",
|
||||
Clock: "00:03:27",
|
||||
Session: live.LiveSessionMeta{MeetingName: "Belgian Grand Prix", SessionName: "Practice 2", SessionType: "Practice"},
|
||||
SnapshotUpdated: true,
|
||||
}
|
||||
state := hub.applySnapshot(redFlag, now)
|
||||
|
||||
if !state.IsLive {
|
||||
t.Fatal("red-flag paused session should report is_live=true")
|
||||
}
|
||||
if state.Data == nil {
|
||||
t.Fatal("red-flag paused session must still send the active snapshot")
|
||||
}
|
||||
if state.Data.TrackStatus != "2" {
|
||||
t.Fatalf("track status = %q, want the paused state \"2\"", state.Data.TrackStatus)
|
||||
}
|
||||
if state.LastSnapshot != nil {
|
||||
t.Fatalf("red-flag session should not be archived: %+v", state.LastSnapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEHubStaleInactiveSnapshotNotLive(t *testing.T) {
|
||||
hub := newSSEHub()
|
||||
now := time.Date(2026, 7, 26, 14, 0, 0, 0, time.UTC)
|
||||
|
||||
// Inactive snapshot with session metadata but no remaining clock: this is a
|
||||
// stale/no-live-evidence snapshot and must not be surfaced as live.
|
||||
stale := live.LiveStreamData{
|
||||
SessionStatus: "Inactive",
|
||||
Clock: "00:00:00",
|
||||
Session: live.LiveSessionMeta{MeetingName: "Belgian Grand Prix", SessionName: "Practice 2"},
|
||||
SnapshotUpdated: true,
|
||||
}
|
||||
state := hub.applySnapshot(stale, now)
|
||||
|
||||
if state.IsLive {
|
||||
t.Fatal("stale inactive snapshot should report is_live=false")
|
||||
}
|
||||
if state.Data != nil {
|
||||
t.Fatalf("stale inactive snapshot leaked into active data: %+v", state.Data)
|
||||
}
|
||||
if state.LastSnapshot == nil {
|
||||
t.Fatal("stale inactive snapshot with metadata should be archived")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLiveStateKeepsArchiveOutOfActiveData(t *testing.T) {
|
||||
hub := newSSEHub()
|
||||
now := time.Date(2026, 7, 4, 14, 0, 0, 0, time.UTC)
|
||||
|
||||
259
internal/web/replay.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultReplayIntervalMS = 5000
|
||||
maxReplayFrames = 3000
|
||||
replayFetchConcurrency = 4
|
||||
)
|
||||
|
||||
type replayDataClient interface {
|
||||
GetDriversForSession(sessionKey int) ([]models.Driver, error)
|
||||
GetLocation(sessionKey, driverNumber int) ([]models.Location, error)
|
||||
}
|
||||
|
||||
type replayFramesResponse struct {
|
||||
SessionKey int `json:"session_key"`
|
||||
Interval int `json:"interval_ms"`
|
||||
StartTime string `json:"start_time"`
|
||||
Frames []replayFrame `json:"frames"`
|
||||
}
|
||||
|
||||
type replayFrame struct {
|
||||
T int64 `json:"t"`
|
||||
Cars map[string]replayCar `json:"cars"`
|
||||
}
|
||||
|
||||
type replayCar struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}
|
||||
|
||||
func (s *Server) handleReplayFrames(w http.ResponseWriter, r *http.Request) {
|
||||
sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key"))
|
||||
if err != nil || sessionKey == 0 {
|
||||
http.Error(w, "session_key required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
intervalMS := defaultReplayIntervalMS
|
||||
rawInterval := r.URL.Query().Get("interval_ms")
|
||||
if rawInterval != "" {
|
||||
parsed, err := strconv.Atoi(rawInterval)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid interval_ms", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if parsed > intervalMS {
|
||||
intervalMS = parsed
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := assembleReplayFrames(r.Context(), s.client, sessionKey, intervalMS)
|
||||
if err != nil {
|
||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
||||
return
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
func assembleReplayFrames(ctx context.Context, client replayDataClient, sessionKey, intervalMS int) (replayFramesResponse, error) {
|
||||
if intervalMS < defaultReplayIntervalMS {
|
||||
intervalMS = defaultReplayIntervalMS
|
||||
}
|
||||
|
||||
resp := replayFramesResponse{
|
||||
SessionKey: sessionKey,
|
||||
Interval: intervalMS,
|
||||
Frames: []replayFrame{},
|
||||
}
|
||||
|
||||
drivers, err := client.GetDriversForSession(sessionKey)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
driverNumbers := uniqueDriverNumbers(drivers)
|
||||
if len(driverNumbers) == 0 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
series, err := fetchReplayLocationSeries(ctx, client, sessionKey, driverNumbers)
|
||||
if err != nil && len(series) == 0 {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
start, ok := earliestReplayLocationTime(series)
|
||||
if !ok {
|
||||
return resp, nil
|
||||
}
|
||||
resp.StartTime = start.Format(time.RFC3339Nano)
|
||||
resp.Frames = snapReplayFrames(series, start, intervalMS)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func uniqueDriverNumbers(drivers []models.Driver) []int {
|
||||
seen := make(map[int]bool, len(drivers))
|
||||
numbers := make([]int, 0, len(drivers))
|
||||
for _, driver := range drivers {
|
||||
if driver.DriverNumber <= 0 || seen[driver.DriverNumber] {
|
||||
continue
|
||||
}
|
||||
seen[driver.DriverNumber] = true
|
||||
numbers = append(numbers, driver.DriverNumber)
|
||||
}
|
||||
sort.Ints(numbers)
|
||||
return numbers
|
||||
}
|
||||
|
||||
func fetchReplayLocationSeries(ctx context.Context, client replayDataClient, sessionKey int, driverNumbers []int) (map[int][]models.Location, error) {
|
||||
type result struct {
|
||||
driver int
|
||||
locs []models.Location
|
||||
err error
|
||||
}
|
||||
|
||||
sem := make(chan struct{}, replayFetchConcurrency)
|
||||
results := make(chan result, len(driverNumbers))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, driverNumber := range driverNumbers {
|
||||
driverNumber := driverNumber
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
case <-ctx.Done():
|
||||
results <- result{driver: driverNumber, err: ctx.Err()}
|
||||
return
|
||||
}
|
||||
|
||||
locs, err := client.GetLocation(sessionKey, driverNumber)
|
||||
results <- result{driver: driverNumber, locs: locs, err: err}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
series := make(map[int][]models.Location, len(driverNumbers))
|
||||
var joined error
|
||||
for result := range results {
|
||||
if result.err != nil {
|
||||
joined = errors.Join(joined, result.err)
|
||||
continue
|
||||
}
|
||||
if len(result.locs) > 0 {
|
||||
series[result.driver] = result.locs
|
||||
}
|
||||
}
|
||||
return series, joined
|
||||
}
|
||||
|
||||
func earliestReplayLocationTime(series map[int][]models.Location) (time.Time, bool) {
|
||||
var start time.Time
|
||||
for _, locs := range series {
|
||||
for _, loc := range locs {
|
||||
t, err := time.Parse(time.RFC3339Nano, loc.Date)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if start.IsZero() || t.Before(start) {
|
||||
start = t
|
||||
}
|
||||
}
|
||||
}
|
||||
if start.IsZero() {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return start, true
|
||||
}
|
||||
|
||||
func snapReplayFrames(series map[int][]models.Location, start time.Time, intervalMS int) []replayFrame {
|
||||
type accumulator struct {
|
||||
t int64
|
||||
cars map[string]replayCar
|
||||
nearest map[string]int64
|
||||
}
|
||||
|
||||
interval := int64(intervalMS)
|
||||
framesByIndex := make(map[int]*accumulator)
|
||||
|
||||
for driverNumber, locs := range series {
|
||||
driverKey := strconv.Itoa(driverNumber)
|
||||
for _, loc := range locs {
|
||||
if !isFiniteFloat(loc.X) || !isFiniteFloat(loc.Y) {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, loc.Date)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
offset := t.Sub(start).Milliseconds()
|
||||
if offset < 0 {
|
||||
continue
|
||||
}
|
||||
index := int((offset + interval/2) / interval)
|
||||
if index < 0 || index >= maxReplayFrames {
|
||||
continue
|
||||
}
|
||||
frameT := int64(index) * interval
|
||||
distance := absInt64(offset - frameT)
|
||||
|
||||
acc, ok := framesByIndex[index]
|
||||
if !ok {
|
||||
acc = &accumulator{
|
||||
t: frameT,
|
||||
cars: make(map[string]replayCar),
|
||||
nearest: make(map[string]int64),
|
||||
}
|
||||
framesByIndex[index] = acc
|
||||
}
|
||||
if prev, ok := acc.nearest[driverKey]; ok && prev <= distance {
|
||||
continue
|
||||
}
|
||||
acc.nearest[driverKey] = distance
|
||||
acc.cars[driverKey] = replayCar{X: loc.X, Y: loc.Y}
|
||||
}
|
||||
}
|
||||
|
||||
indexes := make([]int, 0, len(framesByIndex))
|
||||
for index, acc := range framesByIndex {
|
||||
if len(acc.cars) > 0 {
|
||||
indexes = append(indexes, index)
|
||||
}
|
||||
}
|
||||
sort.Ints(indexes)
|
||||
|
||||
frames := make([]replayFrame, 0, len(indexes))
|
||||
for _, index := range indexes {
|
||||
acc := framesByIndex[index]
|
||||
frames = append(frames, replayFrame{T: acc.t, Cars: acc.cars})
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
func absInt64(v int64) int64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func isFiniteFloat(v float64) bool {
|
||||
return !math.IsNaN(v) && !math.IsInf(v, 0)
|
||||
}
|
||||
181
internal/web/replay_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
)
|
||||
|
||||
type fakeReplayClient struct {
|
||||
drivers []models.Driver
|
||||
locs map[int][]models.Location
|
||||
err error
|
||||
|
||||
mu sync.Mutex
|
||||
inFlight int
|
||||
maxInFlight int
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func (f *fakeReplayClient) GetDriversForSession(sessionKey int) ([]models.Driver, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.drivers, nil
|
||||
}
|
||||
|
||||
func (f *fakeReplayClient) GetLocation(sessionKey, driverNumber int) ([]models.Location, error) {
|
||||
f.mu.Lock()
|
||||
f.inFlight++
|
||||
if f.inFlight > f.maxInFlight {
|
||||
f.maxInFlight = f.inFlight
|
||||
}
|
||||
f.mu.Unlock()
|
||||
|
||||
if f.delay > 0 {
|
||||
time.Sleep(f.delay)
|
||||
}
|
||||
|
||||
f.mu.Lock()
|
||||
f.inFlight--
|
||||
f.mu.Unlock()
|
||||
|
||||
return f.locs[driverNumber], nil
|
||||
}
|
||||
|
||||
func TestAssembleReplayFramesSnapsNearestSamplesAndOmitsEmptyDrivers(t *testing.T) {
|
||||
start := time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC)
|
||||
client := &fakeReplayClient{
|
||||
drivers: []models.Driver{
|
||||
{DriverNumber: 1},
|
||||
{DriverNumber: 4},
|
||||
{DriverNumber: 16},
|
||||
},
|
||||
locs: map[int][]models.Location{
|
||||
1: {
|
||||
{Date: start.Add(1 * time.Second).Format(time.RFC3339Nano), X: 10, Y: 20},
|
||||
{Date: start.Add(4 * time.Second).Format(time.RFC3339Nano), X: 40, Y: 80},
|
||||
{Date: start.Add(6 * time.Second).Format(time.RFC3339Nano), X: 60, Y: 120},
|
||||
},
|
||||
4: {
|
||||
{Date: start.Add(5 * time.Second).Format(time.RFC3339Nano), X: 100, Y: 200},
|
||||
},
|
||||
16: {},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := assembleReplayFrames(context.Background(), client, 99, 5000)
|
||||
if err != nil {
|
||||
t.Fatalf("assembleReplayFrames() error = %v", err)
|
||||
}
|
||||
if resp.SessionKey != 99 || resp.Interval != 5000 {
|
||||
t.Fatalf("response metadata = %+v", resp)
|
||||
}
|
||||
if resp.StartTime != start.Add(1*time.Second).Format(time.RFC3339Nano) {
|
||||
t.Fatalf("start_time = %q", resp.StartTime)
|
||||
}
|
||||
if len(resp.Frames) != 2 {
|
||||
t.Fatalf("frames len = %d, want 2: %+v", len(resp.Frames), resp.Frames)
|
||||
}
|
||||
if _, ok := resp.Frames[0].Cars["16"]; ok {
|
||||
t.Fatalf("empty driver included in frame: %+v", resp.Frames[0].Cars)
|
||||
}
|
||||
if got := resp.Frames[0].Cars["1"]; got.X != 10 || got.Y != 20 {
|
||||
t.Fatalf("frame 0 car 1 = %+v, want first nearest sample", got)
|
||||
}
|
||||
if got := resp.Frames[1].Cars["1"]; got.X != 60 || got.Y != 120 {
|
||||
t.Fatalf("frame 1 car 1 = %+v, want later nearest sample", got)
|
||||
}
|
||||
if got := resp.Frames[1].Cars["4"]; got.X != 100 || got.Y != 200 {
|
||||
t.Fatalf("frame 1 car 4 = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleReplayFramesCapsFrameCount(t *testing.T) {
|
||||
start := time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC)
|
||||
locs := make([]models.Location, maxReplayFrames+250)
|
||||
for i := range locs {
|
||||
locs[i] = models.Location{
|
||||
Date: start.Add(time.Duration(i*defaultReplayIntervalMS) * time.Millisecond).Format(time.RFC3339Nano),
|
||||
X: float64(i),
|
||||
Y: float64(i * 2),
|
||||
}
|
||||
}
|
||||
client := &fakeReplayClient{
|
||||
drivers: []models.Driver{{DriverNumber: 1}},
|
||||
locs: map[int][]models.Location{1: locs},
|
||||
}
|
||||
|
||||
resp, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS)
|
||||
if err != nil {
|
||||
t.Fatalf("assembleReplayFrames() error = %v", err)
|
||||
}
|
||||
if len(resp.Frames) > maxReplayFrames {
|
||||
t.Fatalf("frames len = %d, want <= %d", len(resp.Frames), maxReplayFrames)
|
||||
}
|
||||
if len(resp.Frames) != maxReplayFrames {
|
||||
t.Fatalf("frames len = %d, want hard cap %d", len(resp.Frames), maxReplayFrames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleReplayFramesBoundsLocationFanOut(t *testing.T) {
|
||||
drivers := make([]models.Driver, 10)
|
||||
locs := make(map[int][]models.Location, len(drivers))
|
||||
now := time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC)
|
||||
for i := range drivers {
|
||||
number := i + 1
|
||||
drivers[i] = models.Driver{DriverNumber: number}
|
||||
locs[number] = []models.Location{{Date: now.Format(time.RFC3339Nano), X: float64(number), Y: float64(number)}}
|
||||
}
|
||||
client := &fakeReplayClient{
|
||||
drivers: drivers,
|
||||
locs: locs,
|
||||
delay: 5 * time.Millisecond,
|
||||
}
|
||||
|
||||
if _, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS); err != nil {
|
||||
t.Fatalf("assembleReplayFrames() error = %v", err)
|
||||
}
|
||||
if client.maxInFlight > replayFetchConcurrency {
|
||||
t.Fatalf("max in-flight location calls = %d, want <= %d", client.maxInFlight, replayFetchConcurrency)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReplayFramesValidatesParamsAndFloorsInterval(t *testing.T) {
|
||||
srv := testServer(t, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/replay/frames", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleReplayFrames(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing session_key status = %d, want 400", rec.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/replay/frames?session_key=1&interval_ms=nope", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
srv.handleReplayFrames(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid interval status = %d, want 400", rec.Code)
|
||||
}
|
||||
|
||||
client := &fakeReplayClient{drivers: []models.Driver{{DriverNumber: 1}}, locs: map[int][]models.Location{
|
||||
1: {{Date: time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC).Format(time.RFC3339Nano), X: 1, Y: 2}},
|
||||
}}
|
||||
resp, err := assembleReplayFrames(context.Background(), client, 99, 1000)
|
||||
if err != nil {
|
||||
t.Fatalf("assembleReplayFrames() error = %v", err)
|
||||
}
|
||||
body, err := json.Marshal(resp)
|
||||
if err != nil || len(body) == 0 {
|
||||
t.Fatalf("marshal response = %d bytes, %v", len(body), err)
|
||||
}
|
||||
if resp.Interval != defaultReplayIntervalMS {
|
||||
t.Fatalf("interval = %d, want floor %d", resp.Interval, defaultReplayIntervalMS)
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,7 @@ func (s *Server) routes() (http.Handler, error) {
|
||||
mux.HandleFunc("/api/v1/grid", s.handleGrid)
|
||||
mux.HandleFunc("/api/v1/laps/comparison", s.handleLapsComparison)
|
||||
mux.HandleFunc("/api/v1/laps", s.handleLaps)
|
||||
mux.HandleFunc("/api/v1/replay/frames", s.handleReplayFrames)
|
||||
mux.HandleFunc("/api/v1/weather", s.handleWeather)
|
||||
mux.HandleFunc("/api/v1/race-control", s.handleRaceControl)
|
||||
mux.HandleFunc("/api/v1/telemetry", s.handleTelemetry)
|
||||
|
||||
@@ -147,6 +147,58 @@ const sprintQualifyingSnapshot = {
|
||||
},
|
||||
}
|
||||
|
||||
// Practice: the feed sets best laps but no GapToLeader/Interval, and there is
|
||||
// no race lap-total concept. Exercises computed gaps, the non-race banner, the
|
||||
// collapsed-by-default tyre panel, and the 390px core-field layout.
|
||||
const practiceSnapshot = {
|
||||
is_live: true,
|
||||
data: {
|
||||
...raceSnapshot.data,
|
||||
Drivers: Object.fromEntries(
|
||||
Array.from({ length: 10 }, (_, index) => {
|
||||
const num = String(index + 1)
|
||||
return [
|
||||
num,
|
||||
driver(num, index + 1, '', '', {
|
||||
// Distinct, increasing best laps; P1 fastest, +0.200s per position.
|
||||
BestLapTime: `1:${(45.9 + index * 0.2).toFixed(3).padStart(6, '0')}`,
|
||||
LastLapTime: '1:46.500',
|
||||
NumberOfLaps: 12,
|
||||
Sectors: index === 0
|
||||
? [
|
||||
{ Value: '28.500', PersonalFastest: true, OverallFastest: false },
|
||||
{ Value: '52.100', PersonalFastest: false, OverallFastest: false },
|
||||
{ Value: '25.344', PersonalFastest: false, OverallFastest: false },
|
||||
]
|
||||
: [],
|
||||
}),
|
||||
]
|
||||
}),
|
||||
),
|
||||
DriverInfo: Object.fromEntries(
|
||||
Array.from({ length: 10 }, (_, index) => {
|
||||
const num = String(index + 1)
|
||||
return [num, info(num, `D${index + 1}`, 'Driver', String(index + 1), 'Test Team', index % 2 ? 'FF8000' : '27F4D2')]
|
||||
}),
|
||||
),
|
||||
Tyres: Object.fromEntries(
|
||||
Array.from({ length: 10 }, (_, index) => [String(index + 1), { Compound: 'SOFT', New: false, Age: index % 4 }]),
|
||||
),
|
||||
Session: {
|
||||
MeetingName: 'Belgian Grand Prix',
|
||||
CircuitName: 'Spa-Francorchamps',
|
||||
SessionType: 'Practice',
|
||||
SessionName: 'Practice 2',
|
||||
},
|
||||
TrackStatus: '1',
|
||||
CurrentLap: 0,
|
||||
TotalLaps: 0,
|
||||
Clock: '00:45:00',
|
||||
ClockRefTime: '2026-07-17T13:00:00Z',
|
||||
ClockExtrapolating: false,
|
||||
},
|
||||
}
|
||||
|
||||
test.describe('Live Timing (mocked snapshot)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/v1/live/state', (route) =>
|
||||
@@ -233,6 +285,94 @@ test.describe('Live Timing (mocked Sprint Qualifying)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Live Timing (mocked Practice)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/v1/live/state', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify(practiceSnapshot) }),
|
||||
)
|
||||
await page.route('**/api/v1/live/stream', (route) =>
|
||||
route.fulfill({ contentType: 'text/event-stream', body: 'event: heartbeat\ndata: {}\n\n' }),
|
||||
)
|
||||
await page.goto('/live')
|
||||
})
|
||||
|
||||
test('derives a gap to P1 from best laps and marks the leader', async ({ page }) => {
|
||||
const tower = page.locator('.live-tower')
|
||||
await expect(tower).toBeVisible()
|
||||
// P1 shows a leader marker, not a fabricated gap.
|
||||
await expect(tower.locator('tbody tr').first()).toContainText('—')
|
||||
// P2 shows the computed +0.200 delta.
|
||||
await expect(tower).toContainText('+0.200')
|
||||
})
|
||||
|
||||
test('omits the race lap counter and keeps the session clock for practice', async ({ page }) => {
|
||||
await expect(page.getByTestId('live-lap-counter')).toHaveCount(0)
|
||||
await expect(page.getByTestId('live-clock')).toContainText('00:45:00')
|
||||
await expect(page.locator('.live-banner')).not.toContainText('L-/-')
|
||||
})
|
||||
|
||||
test('collapses the tyre panel by default so the tower is above the fold', async ({ page }) => {
|
||||
await expect(page.getByTestId('tyredeg-panel')).toBeVisible()
|
||||
await expect(page.getByTestId('tyredeg-row')).toHaveCount(0)
|
||||
await expect(page.locator('.live-tower')).toBeVisible()
|
||||
})
|
||||
|
||||
test('labels Race Control times as UTC', async ({ page }) => {
|
||||
await expect(page.getByTestId('rc-timezone')).toContainText('UTC')
|
||||
})
|
||||
|
||||
test('marks Live as active in the primary navigation', async ({ page }) => {
|
||||
await expect(page.getByTestId('nav-live')).toHaveAttribute('data-live-active', 'true')
|
||||
await expect(page.getByTestId('nav-live').locator('.nav-live-dot')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Live Timing (390px practice viewport)', () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/v1/live/state', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify(practiceSnapshot) }),
|
||||
)
|
||||
await page.route('**/api/v1/live/stream', (route) =>
|
||||
route.fulfill({ contentType: 'text/event-stream', body: 'event: heartbeat\ndata: {}\n\n' }),
|
||||
)
|
||||
await page.goto('/live')
|
||||
})
|
||||
|
||||
test('shows the five core fields with no horizontal overflow', async ({ page }) => {
|
||||
const tower = page.locator('.live-tower')
|
||||
await expect(tower).toBeVisible()
|
||||
|
||||
// Core comparison fields are present in the header.
|
||||
await expect(tower.locator('thead')).toContainText('Pos')
|
||||
await expect(tower.locator('thead')).toContainText('Driver')
|
||||
await expect(tower.locator('thead')).toContainText('Tyre')
|
||||
await expect(tower.locator('thead')).toContainText('Best')
|
||||
await expect(tower.locator('thead')).toContainText('Gap to P1')
|
||||
|
||||
// Sectors are hidden from the initial mobile tower (reachable via expand).
|
||||
const firstSector = tower.locator('thead th', { hasText: 'S1' })
|
||||
await expect(firstSector).toBeHidden()
|
||||
|
||||
// The document must not scroll horizontally.
|
||||
const overflow = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
)
|
||||
expect(overflow).toBeLessThanOrEqual(0)
|
||||
})
|
||||
|
||||
test('exposes sectors through the row detail interaction', async ({ page }) => {
|
||||
// Sector columns are not in the initial mobile tower...
|
||||
await expect(page.locator('.live-tower thead th', { hasText: 'S1' })).toBeHidden()
|
||||
// ...but the P1 row's sectors are reachable by expanding the row.
|
||||
await page.locator('.live-tower tbody tr', { hasText: 'D1' }).first().click()
|
||||
const sectors = page.getByTestId('expanded-sectors')
|
||||
await expect(sectors).toBeVisible()
|
||||
await expect(sectors).toContainText('28.500')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Live Timing (no session)', () => {
|
||||
test('shows the empty state when the feed has no snapshot', async ({ page }) => {
|
||||
await page.goto('/live')
|
||||
|
||||
@@ -39,7 +39,17 @@ test.describe('Race Hub Weekend Workspace', () => {
|
||||
await expect(
|
||||
page.getByRole('img', { name: 'Position evolution chart' }),
|
||||
).toBeVisible()
|
||||
await expect(page.getByText('Lap-by-lap positions not available.')).not.toBeVisible()
|
||||
await expect(page.getByTestId('race-story-no-positions')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Race Story highlights a chapter card when clicked', async ({ page }) => {
|
||||
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||
await page.getByRole('tab', { name: 'Race Story' }).click()
|
||||
|
||||
const firstCard = page.getByTestId('chapter-card-0')
|
||||
await expect(firstCard).toBeVisible()
|
||||
await firstCard.click()
|
||||
await expect(firstCard).toHaveClass(/active/)
|
||||
})
|
||||
|
||||
test('strategy tab renders stint chart when stints are available', async ({ page }) => {
|
||||
@@ -58,13 +68,15 @@ test.describe('Race Hub Weekend Workspace', () => {
|
||||
await expect(page.locator('[data-testid="strategy-chart"]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Race Story shows missing notice when positions are unavailable', async ({
|
||||
test('Race Story shows empty-state card when positions are unavailable', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
|
||||
await page.getByRole('tab', { name: 'Race Story' }).click()
|
||||
|
||||
await expect(page.getByText('Lap-by-lap positions not available.')).toBeVisible()
|
||||
const empty = page.getByTestId('race-story-no-positions')
|
||||
await expect(empty).toBeVisible()
|
||||
await expect(empty.getByText('Lap-by-lap positions not available')).toBeVisible()
|
||||
await expect(page.locator('[data-testid="position-chart"]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
|
||||
BIN
tests/visual/__snapshots__/desktop/race-story.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
tests/visual/__snapshots__/mobile/race-story.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
tests/visual/__snapshots__/tablet/race-story.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
@@ -36,6 +36,14 @@ export async function gotoRaceHubReady(page: Page, sessionKey = FULL_SESSION): P
|
||||
await waitForScreenshotReady(page)
|
||||
}
|
||||
|
||||
export async function gotoRaceStoryReady(page: Page, sessionKey = FULL_SESSION): Promise<void> {
|
||||
await page.goto(`/race-hub?session_key=${sessionKey}`)
|
||||
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||
await page.getByRole('tab', { name: 'Race Story' }).click()
|
||||
await expect(page.getByTestId('position-chart')).toBeVisible()
|
||||
await waitForScreenshotReady(page)
|
||||
}
|
||||
|
||||
export async function gotoDataLibraryReady(page: Page): Promise<void> {
|
||||
await page.goto('/admin')
|
||||
await expect(page.getByTestId('data-library')).toBeVisible()
|
||||
|
||||
9
tests/visual/race-story.spec.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { test } from '@playwright/test'
|
||||
import { gotoRaceStoryReady, screenshotPage } from './helpers'
|
||||
|
||||
test.describe('Race Story visual regression', () => {
|
||||
test('race-story', async ({ page }) => {
|
||||
await gotoRaceStoryReady(page)
|
||||
await screenshotPage(page, 'race-story')
|
||||
})
|
||||
})
|
||||