Compare commits
17 Commits
feat/issue
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d03e480e03 | ||
|
|
0b9c3fff0a | ||
|
|
adfa934f9d | ||
|
|
c475011c49 | ||
|
|
5a6323d3b4 | ||
|
|
ed3b8cf628 | ||
|
|
0a42c05487 | ||
|
|
08ff75b469 | ||
|
|
16c035b037 | ||
|
|
1dac73cdc5 | ||
|
|
02a84f67b4 | ||
|
|
f0a8e4e631 | ||
|
|
519053d340 | ||
|
|
e0d7b9981e | ||
|
|
79acf0579a | ||
|
|
82abbd6b35 | ||
|
|
faa173578c |
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 |
@@ -1,3 +1,5 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { BookOpen } from 'lucide-react'
|
||||||
import type { Chapter } from '../types'
|
import type { Chapter } from '../types'
|
||||||
import {
|
import {
|
||||||
activeChapterIndex,
|
activeChapterIndex,
|
||||||
@@ -5,6 +7,7 @@ import {
|
|||||||
chapterLapRange,
|
chapterLapRange,
|
||||||
chapterStartScrub,
|
chapterStartScrub,
|
||||||
} from '../lib/chapters'
|
} from '../lib/chapters'
|
||||||
|
import { EmptyStateCard } from './EmptyStateCard'
|
||||||
import '../styles/chapters.css'
|
import '../styles/chapters.css'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -14,6 +17,8 @@ interface Props {
|
|||||||
tRange: number
|
tRange: number
|
||||||
tourActive: boolean
|
tourActive: boolean
|
||||||
tourChapterIndex: number | null
|
tourChapterIndex: number | null
|
||||||
|
/** Explicit selection from a chapter click; wins over scrub-derived active. */
|
||||||
|
selectedChapterIndex?: number | null
|
||||||
onChapterClick: (index: number, scrub: number) => void
|
onChapterClick: (index: number, scrub: number) => void
|
||||||
onTourToggle: () => void
|
onTourToggle: () => void
|
||||||
}
|
}
|
||||||
@@ -25,19 +30,38 @@ export function ChapterStrip({
|
|||||||
tRange,
|
tRange,
|
||||||
tourActive,
|
tourActive,
|
||||||
tourChapterIndex,
|
tourChapterIndex,
|
||||||
|
selectedChapterIndex = null,
|
||||||
onChapterClick,
|
onChapterClick,
|
||||||
onTourToggle,
|
onTourToggle,
|
||||||
}: Props) {
|
}: 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) {
|
if (chapters.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="chapter-strip" data-testid="chapter-strip">
|
<div className="chapter-strip" data-testid="chapter-strip">
|
||||||
<p className="chapter-strip-empty">No story chapters for this session.</p>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chapter-strip" data-testid="chapter-strip">
|
<div className="chapter-strip" data-testid="chapter-strip">
|
||||||
<div className="chapter-strip-header">
|
<div className="chapter-strip-header">
|
||||||
@@ -53,12 +77,16 @@ export function ChapterStrip({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="chapter-strip-scroll" role="list" aria-label="Race story chapters">
|
<div className="chapter-strip-scroll-wrap">
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="chapter-strip-scroll"
|
||||||
|
role="list"
|
||||||
|
aria-label="Race story chapters"
|
||||||
|
>
|
||||||
{chapters.map((chapter, index) => {
|
{chapters.map((chapter, index) => {
|
||||||
const scrub = chapterStartScrub(chapter, tMin, tRange) ?? index / Math.max(chapters.length - 1, 1)
|
const scrub = chapterStartScrub(chapter, tMin, tRange) ?? index / Math.max(chapters.length - 1, 1)
|
||||||
const isActive = tourActive
|
const isActive = highlightedIndex === index
|
||||||
? tourChapterIndex === index
|
|
||||||
: activeIndex === index
|
|
||||||
const headline = chapter.headline || chapter.title
|
const headline = chapter.headline || chapter.title
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -83,5 +111,6 @@ export function ChapterStrip({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</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 { Link } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { fetchLiveState } from '../api'
|
||||||
|
import { isLiveSessionActive } from '../lib/live'
|
||||||
|
|
||||||
export function Nav() {
|
export function Nav() {
|
||||||
|
const { data: liveState } = useQuery({
|
||||||
|
queryKey: ['live-state'],
|
||||||
|
queryFn: fetchLiveState,
|
||||||
|
staleTime: 5_000,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
})
|
||||||
|
const liveActive = isLiveSessionActive(liveState)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="app-nav">
|
<nav className="app-nav">
|
||||||
<Link to="/" className="nav-logo">
|
<Link to="/" className="nav-logo">
|
||||||
@@ -10,8 +21,16 @@ export function Nav() {
|
|||||||
<Link to="/" activeProps={{ className: 'active' }} activeOptions={{ exact: true }}>
|
<Link to="/" activeProps={{ className: 'active' }} activeOptions={{ exact: true }}>
|
||||||
Command
|
Command
|
||||||
</Link>
|
</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
|
Live
|
||||||
|
{liveActive && <span className="sr-only"> session active</span>}
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
||||||
Race Hub
|
Race Hub
|
||||||
|
|||||||
@@ -1,13 +1,36 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { LineChart } from 'lucide-react'
|
||||||
import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session, Chapter } from '../types'
|
import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session, Chapter } from '../types'
|
||||||
import { fetchReplayFrames, fetchTrackOutline } from '../api'
|
import { fetchReplayFrames, fetchTrackOutline } from '../api'
|
||||||
import { ReplayTrackMap } from './ReplayTrackMap'
|
import { ReplayTrackMap } from './ReplayTrackMap'
|
||||||
import { ChapterStrip } from './ChapterStrip'
|
import { ChapterStrip } from './ChapterStrip'
|
||||||
|
import { EmptyStateCard } from './EmptyStateCard'
|
||||||
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
|
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
|
||||||
import { chapterEndScrub, chapterStartScrub, chapterTourDurations } from '../lib/chapters'
|
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 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 {
|
interface Props {
|
||||||
data: {
|
data: {
|
||||||
@@ -25,6 +48,48 @@ interface Props {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
export function RaceStoryCanvas({ data }: Props) {
|
||||||
const {
|
const {
|
||||||
results,
|
results,
|
||||||
@@ -48,32 +113,46 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
const [playbackSpeed, setPlaybackSpeed] = useState(10)
|
const [playbackSpeed, setPlaybackSpeed] = useState(10)
|
||||||
const [chapterTourActive, setChapterTourActive] = useState(false)
|
const [chapterTourActive, setChapterTourActive] = useState(false)
|
||||||
const [tourChapterIndex, setTourChapterIndex] = useState<number | null>(null)
|
const [tourChapterIndex, setTourChapterIndex] = useState<number | null>(null)
|
||||||
|
const [selectedChapterIndex, setSelectedChapterIndex] = useState<number | null>(null)
|
||||||
const svgRef = useRef<SVGSVGElement>(null)
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 })
|
const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 })
|
||||||
|
|
||||||
// Position Evolution Chart Logic
|
const chartTiming = useMemo(() => raceChartTiming(positions, laps, results), [laps, positions, results])
|
||||||
const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions])
|
const hasChartData = hasPositions && chartTiming !== null
|
||||||
const hasChartData = hasPositions && allTimes.length > 0
|
|
||||||
const chartTiming = useMemo(() => {
|
|
||||||
if (!hasChartData) return null
|
|
||||||
const tMin = new Date(allTimes[0]).getTime()
|
|
||||||
const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
|
|
||||||
return { tMin, tMax, tRange: Math.max(tMax - tMin, 1) }
|
|
||||||
}, [allTimes, hasChartData])
|
|
||||||
const circuitKey = session?.circuit_key ?? meeting?.circuit_key ?? 0
|
const circuitKey = session?.circuit_key ?? meeting?.circuit_key ?? 0
|
||||||
const outlineYear = meeting?.year ?? (session?.date_start ? new Date(session.date_start).getFullYear() : 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({
|
const replayQuery = useQuery({
|
||||||
queryKey: ['replay-frames', session?.session_key, 5000],
|
queryKey: ['replay-frames', session?.session_key, 5000],
|
||||||
queryFn: () => fetchReplayFrames(session!.session_key, 5000),
|
queryFn: () => fetchReplayFrames(session!.session_key, 5000),
|
||||||
enabled: mapOpen && Boolean(session?.session_key),
|
enabled: canProbeMap,
|
||||||
})
|
})
|
||||||
const outlineQuery = useQuery({
|
const outlineQuery = useQuery({
|
||||||
queryKey: ['track-outline', circuitKey, outlineYear],
|
queryKey: ['track-outline', circuitKey, outlineYear],
|
||||||
queryFn: () => fetchTrackOutline(circuitKey, outlineYear),
|
queryFn: () => fetchTrackOutline(circuitKey, outlineYear),
|
||||||
enabled: mapOpen && circuitKey > 0 && outlineYear > 0,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isPlaying || !chartTiming) return
|
if (!isPlaying || !chartTiming) return
|
||||||
|
|
||||||
@@ -104,9 +183,14 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
const jumpToChapter = (index: number, scrub: number) => {
|
const jumpToChapter = (index: number, scrub: number) => {
|
||||||
setIsPlaying(false)
|
setIsPlaying(false)
|
||||||
stopChapterTour()
|
stopChapterTour()
|
||||||
|
setSelectedChapterIndex(index)
|
||||||
setScrubTime(scrub)
|
setScrubTime(scrub)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearChapterSelection = () => {
|
||||||
|
setSelectedChapterIndex(null)
|
||||||
|
}
|
||||||
|
|
||||||
const toggleChapterTour = () => {
|
const toggleChapterTour = () => {
|
||||||
if (chapterTourActive) {
|
if (chapterTourActive) {
|
||||||
stopChapterTour()
|
stopChapterTour()
|
||||||
@@ -114,6 +198,7 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
}
|
}
|
||||||
if (!chartTiming || chapters.length === 0) return
|
if (!chartTiming || chapters.length === 0) return
|
||||||
setIsPlaying(false)
|
setIsPlaying(false)
|
||||||
|
setSelectedChapterIndex(null)
|
||||||
setChapterTourActive(true)
|
setChapterTourActive(true)
|
||||||
setTourChapterIndex(0)
|
setTourChapterIndex(0)
|
||||||
const startScrub = chapterStartScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? 0
|
const startScrub = chapterStartScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? 0
|
||||||
@@ -194,16 +279,19 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
|
|
||||||
if (hasChartData && chartTiming) {
|
if (hasChartData && chartTiming) {
|
||||||
const { tMin, tRange } = 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 }>>()
|
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
|
||||||
for (const p of positions) {
|
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, [])
|
if (!byDriver.has(p.driver_number)) byDriver.set(p.driver_number, [])
|
||||||
byDriver.get(p.driver_number)!.push({
|
byDriver.get(p.driver_number)!.push({
|
||||||
t: (new Date(p.date).getTime() - tMin) / tRange,
|
t: normaliseTime(time),
|
||||||
pos: p.position,
|
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()) {
|
for (const [dNum, samples] of byDriver.entries()) {
|
||||||
samples.sort((a, b) => a.t - b.t)
|
samples.sort((a, b) => a.t - b.t)
|
||||||
if (samples.length > 0 && !dnfSet.has(dNum)) {
|
if (samples.length > 0 && !dnfSet.has(dNum)) {
|
||||||
@@ -211,7 +299,7 @@ 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 (!samples || samples.length === 0) return null
|
||||||
if (t <= samples[0].t) return samples[0].pos
|
if (t <= samples[0].t) return samples[0].pos
|
||||||
if (t >= samples[samples.length - 1].t) return samples[samples.length - 1].pos
|
if (t >= samples[samples.length - 1].t) return samples[samples.length - 1].pos
|
||||||
@@ -244,34 +332,37 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
const maxPos = Math.max(...positions.map((p) => p.position), results.length, 2)
|
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 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 acronymByDriver = new Map(results.map((r) => [r.driver_number, r.name_acronym]))
|
||||||
|
const positionLabels = decimatedPositionLabels(maxPos)
|
||||||
|
|
||||||
const W = 640
|
const W = CHART_W
|
||||||
const H = 180
|
const H = CHART_H
|
||||||
const PL = 40
|
const PL = CHART_PL
|
||||||
const PR = 48
|
const PR = CHART_PR
|
||||||
const PT = 8
|
const PT = CHART_PT
|
||||||
const PB = 20
|
const PB = CHART_PB
|
||||||
const plotW = W - PL - PR
|
const plotW = W - PL - PR
|
||||||
const plotH = H - PT - PB
|
const plotH = H - PT - PB
|
||||||
|
|
||||||
const toX = (t: number) => PL + t * plotW
|
const toX = (t: number) => PL + t * plotW
|
||||||
const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH
|
const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH
|
||||||
|
|
||||||
const winner = results.find(r => r.position === 1)
|
const winner = results.find((r) => r.position === 1)
|
||||||
const winnerLaps = winner ? laps.filter(l => l.driver_number === winner.driver_number) : []
|
const winnerLaps = winner ? laps.filter((l) => l.driver_number === winner.driver_number) : []
|
||||||
const lapTicks: { lap: number, t: number }[] = []
|
const lapTicks: { lap: number; t: number }[] = []
|
||||||
const lapInterval = winnerLaps.length < 30 ? 5 : 10
|
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) {
|
if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) {
|
||||||
const t = (new Date(lap.date_start).getTime() - tMin) / tRange
|
const lapStart = new Date(lap.date_start).getTime()
|
||||||
if (t >= 0 && t <= 1) {
|
const nextLapStart = winnerLaps[i + 1] ? new Date(winnerLaps[i + 1].date_start).getTime() : NaN
|
||||||
lapTicks.push({ lap: lap.lap_number, t })
|
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' }[] = []
|
const scPeriods: { start: number; end: number | null; type: 'SC' | 'VSC' }[] = []
|
||||||
let activeSC: { start: number; type: 'SC' | 'VSC' } | null = null
|
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())
|
const rc = [...race_control].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||||
@@ -296,25 +387,73 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
scPeriods.push({ start: activeSC.start, end: null, type: activeSC.type })
|
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>) => {
|
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
|
||||||
setIsPlaying(false)
|
setIsPlaying(false)
|
||||||
stopChapterTour()
|
stopChapterTour()
|
||||||
|
clearChapterSelection()
|
||||||
if (!svgRef.current) return
|
if (!svgRef.current) return
|
||||||
const rect = svgRef.current.getBoundingClientRect()
|
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))
|
const t = Math.max(0, Math.min(1, (x - PL) / plotW))
|
||||||
setScrubTime(t)
|
setScrubTime(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
chartContent = (
|
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
|
<svg
|
||||||
ref={svgRef}
|
ref={svgRef}
|
||||||
|
className="rs-position-chart-svg"
|
||||||
viewBox={`0 0 ${W} ${H}`}
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
style={{ width: '100%', minWidth: 280, maxWidth: W, display: 'block' }}
|
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Position evolution chart"
|
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) => {
|
{scPeriods.map((sc, i) => {
|
||||||
const startT = (sc.start - tMin) / tRange
|
const startT = (sc.start - tMin) / tRange
|
||||||
const endT = sc.end ? (sc.end - tMin) / tRange : 1
|
const endT = sc.end ? (sc.end - tMin) / tRange : 1
|
||||||
@@ -333,33 +472,41 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{Array.from({ length: maxPos }, (_, i) => i + 1).map((pos) => (
|
{positionLabels.map((pos) => (
|
||||||
<g key={pos}>
|
<g key={pos}>
|
||||||
<line
|
<line
|
||||||
x1={PL}
|
x1={PL}
|
||||||
x2={W - PR}
|
x2={W - PR}
|
||||||
y1={toY(pos)}
|
y1={toY(pos)}
|
||||||
y2={toY(pos)}
|
y2={toY(pos)}
|
||||||
stroke="var(--border)"
|
className="rs-chart-grid-line"
|
||||||
strokeWidth={0.5}
|
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
x={PL - 4}
|
x={PL - 4}
|
||||||
y={toY(pos) + 4}
|
y={toY(pos) + 4}
|
||||||
textAnchor="end"
|
textAnchor="end"
|
||||||
fill="var(--text-3)"
|
className="rs-chart-axis-label"
|
||||||
fontSize={8}
|
|
||||||
fontFamily="var(--f-mono)"
|
|
||||||
>
|
>
|
||||||
P{pos}
|
P{pos}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{lapTicks.map(tick => (
|
{lapTicks.map((tick) => (
|
||||||
<g key={`lap-${tick.lap}`}>
|
<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} />
|
<line
|
||||||
<text x={toX(tick.t)} y={H - PB + 14} textAnchor="middle" fill="var(--text-3)" fontSize={9} fontFamily="var(--f-mono)">
|
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}
|
L{tick.lap}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
@@ -372,8 +519,9 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
const last = samples[samples.length - 1]
|
const last = samples[samples.length - 1]
|
||||||
const isHovered = hoverDriver === dNum
|
const isHovered = hoverDriver === dNum
|
||||||
const isFaded = hoverDriver !== null && !isHovered
|
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 (
|
return (
|
||||||
<g
|
<g
|
||||||
@@ -394,8 +542,9 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{driverPits.map((p, i) => {
|
{driverPits.map((p, i) => {
|
||||||
const t = (new Date(p.date).getTime() - tMin) / tRange
|
const time = new Date(p.date).getTime()
|
||||||
if (t < 0 || t > 1) return null
|
if (!Number.isFinite(time) || time < tMin || time > tMin + tRange) return null
|
||||||
|
const t = normaliseTime(time)
|
||||||
const pos = getInterpPos(samples, t)
|
const pos = getInterpPos(samples, t)
|
||||||
if (pos === null) return null
|
if (pos === null) return null
|
||||||
return (
|
return (
|
||||||
@@ -415,7 +564,7 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
{last && (
|
{last && (
|
||||||
<text
|
<text
|
||||||
x={toX(last.t) + 6}
|
x={toX(last.t) + 6}
|
||||||
y={toY(last.pos) + 4}
|
y={labelY + 4}
|
||||||
fill={color}
|
fill={color}
|
||||||
fontSize={isHovered ? 11 : 9}
|
fontSize={isHovered ? 11 : 9}
|
||||||
fontFamily="var(--f-mono)"
|
fontFamily="var(--f-mono)"
|
||||||
@@ -449,44 +598,57 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
width={plotW}
|
width={plotW}
|
||||||
height={plotH}
|
height={plotH}
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
|
data-testid="position-chart-interaction"
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerLeave={() => {
|
onPointerLeave={() => {
|
||||||
if (!isPlaying) setScrubTime(null)
|
if (!isPlaying) {
|
||||||
|
clearChapterSelection()
|
||||||
|
setScrubTime(null)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div className="rs-replay-tools" aria-label="Race replay controls">
|
<div className="rs-segmented-control" aria-label="Race replay controls" data-testid="replay-controls">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`rs-tool-btn ${isPlaying ? 'active' : ''}`}
|
className={`rs-segment ${isPlaying ? 'active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setScrubTime((current) => current ?? 0)
|
setScrubTime((current) => current ?? 0)
|
||||||
stopChapterTour()
|
stopChapterTour()
|
||||||
|
clearChapterSelection()
|
||||||
setIsPlaying((current) => !current)
|
setIsPlaying((current) => !current)
|
||||||
}}
|
}}
|
||||||
|
aria-pressed={isPlaying}
|
||||||
>
|
>
|
||||||
{isPlaying ? 'Pause' : 'Play'}
|
{isPlaying ? 'Pause' : 'Play'}
|
||||||
</button>
|
</button>
|
||||||
<div className="rs-speed-group" aria-label="Playback speed">
|
<span className="rs-segment-divider" aria-hidden />
|
||||||
{[1, 10, 30].map((speed) => (
|
{[1, 10, 30].map((speed) => (
|
||||||
<button
|
<button
|
||||||
key={speed}
|
key={speed}
|
||||||
type="button"
|
type="button"
|
||||||
className={`rs-speed-btn ${playbackSpeed === speed ? 'active' : ''}`}
|
className={`rs-segment ${playbackSpeed === speed ? 'active' : ''}`}
|
||||||
onClick={() => setPlaybackSpeed(speed)}
|
onClick={() => setPlaybackSpeed(speed)}
|
||||||
|
aria-pressed={playbackSpeed === speed}
|
||||||
>
|
>
|
||||||
{speed}x
|
{speed}x
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
{mapProbeSettled && mapAvailable && (
|
||||||
|
<>
|
||||||
|
<span className="rs-segment-divider" aria-hidden />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`rs-tool-btn ${mapOpen ? 'active' : ''}`}
|
className={`rs-segment ${mapOpen ? 'active' : ''}`}
|
||||||
onClick={() => setMapOpen((current) => !current)}
|
onClick={() => setMapOpen((current) => !current)}
|
||||||
|
aria-pressed={mapOpen}
|
||||||
|
data-testid="replay-map-toggle"
|
||||||
>
|
>
|
||||||
Map
|
Map
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -502,24 +664,32 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
tRange={chartTiming.tRange}
|
tRange={chartTiming.tRange}
|
||||||
tourActive={chapterTourActive}
|
tourActive={chapterTourActive}
|
||||||
tourChapterIndex={tourChapterIndex}
|
tourChapterIndex={tourChapterIndex}
|
||||||
|
selectedChapterIndex={selectedChapterIndex}
|
||||||
onChapterClick={jumpToChapter}
|
onChapterClick={jumpToChapter}
|
||||||
onTourToggle={toggleChapterTour}
|
onTourToggle={toggleChapterTour}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="rs-replay-shell">
|
<div className={`rs-replay-shell${showMapPanel ? ' rs-replay-shell--split' : ''}`}>
|
||||||
<div className="rs-replay-main">
|
<div className="rs-replay-main">
|
||||||
{hasChartData ? (
|
{hasChartData ? (
|
||||||
chartContent
|
chartContent
|
||||||
) : (
|
) : (
|
||||||
<div className="analysis-notice">
|
<EmptyStateCard
|
||||||
<strong>Lap-by-lap positions not available.</strong> This session does not
|
icon={LineChart}
|
||||||
have ingested position samples in <code>/api/v1/race-hub</code>.
|
title="Lap-by-lap positions not available"
|
||||||
</div>
|
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>
|
</div>
|
||||||
{mapOpen && (
|
{showMapPanel && (
|
||||||
<div className="rs-replay-map-slot">
|
<div className="rs-replay-map-slot" data-testid="replay-map-slot">
|
||||||
{circuitKey > 0 && outlineYear > 0 ? (
|
|
||||||
<ReplayTrackMap
|
<ReplayTrackMap
|
||||||
outline={outlineQuery.data}
|
outline={outlineQuery.data}
|
||||||
replay={replayQuery.data}
|
replay={replayQuery.data}
|
||||||
@@ -529,9 +699,6 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
loading={outlineQuery.isLoading || replayQuery.isLoading}
|
loading={outlineQuery.isLoading || replayQuery.isLoading}
|
||||||
error={outlineQuery.isError || replayQuery.isError}
|
error={outlineQuery.isError || replayQuery.isError}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<div className="rs-replay-map-placeholder">track identity unavailable for replay map</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -542,7 +709,8 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
|
const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
|
||||||
const currentPos = scrubTime !== null ? i + 1 : r.position
|
const currentPos = scrubTime !== null ? i + 1 : r.position
|
||||||
const isWinner = i === 0 && r.position === 1
|
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
|
let currentPoints: number | string = r.points
|
||||||
if (scrubTime !== null) {
|
if (scrubTime !== null) {
|
||||||
@@ -559,9 +727,7 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
onMouseLeave={() => setHoverDriver(null)}
|
onMouseLeave={() => setHoverDriver(null)}
|
||||||
>
|
>
|
||||||
<div className="rs-driver-left">
|
<div className="rs-driver-left">
|
||||||
<div className={`rs-pos-col ${pClass}`}>
|
<div className={`rs-pos-col ${pClass}`}>{currentPos}</div>
|
||||||
{currentPos}
|
|
||||||
</div>
|
|
||||||
<div className="rs-driver-cell">
|
<div className="rs-driver-cell">
|
||||||
<div
|
<div
|
||||||
className="rs-driver-color"
|
className="rs-driver-color"
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Loader2, MapPin, Satellite } from 'lucide-react'
|
||||||
import type { Driver, EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types'
|
import type { Driver, EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types'
|
||||||
import { buildOutlinePath } from '../lib/trackmap'
|
import { buildOutlinePath } from '../lib/trackmap'
|
||||||
import { interpolateReplayCars, replayCarToSvg } from '../lib/replay'
|
import { interpolateReplayCars, replayCarToSvg } from '../lib/replay'
|
||||||
|
import { EmptyStateCard } from './EmptyStateCard'
|
||||||
import '../styles/replay-map.css'
|
import '../styles/replay-map.css'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -60,7 +62,13 @@ export function ReplayTrackMap({
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<section className="replay-map-panel" data-testid="replay-track-map">
|
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||||
<div className="replay-map-empty">loading replay GPS...</div>
|
<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>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -68,7 +76,13 @@ export function ReplayTrackMap({
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<section className="replay-map-panel" data-testid="replay-track-map">
|
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||||
<div className="replay-map-empty">replay GPS unavailable for this session</div>
|
<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>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -76,7 +90,13 @@ export function ReplayTrackMap({
|
|||||||
if (!outline || !outlinePath) {
|
if (!outline || !outlinePath) {
|
||||||
return (
|
return (
|
||||||
<section className="replay-map-panel" data-testid="replay-track-map">
|
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||||
<div className="replay-map-empty">track outline unavailable for this session</div>
|
<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>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -84,7 +104,13 @@ export function ReplayTrackMap({
|
|||||||
if (!replay?.frames?.length || cars.length === 0) {
|
if (!replay?.frames?.length || cars.length === 0) {
|
||||||
return (
|
return (
|
||||||
<section className="replay-map-panel" data-testid="replay-track-map">
|
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||||
<div className="replay-map-empty">historical GPS unavailable for this session</div>
|
<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>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
271
frontend/src/components/RivalryCompare.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import type { ChampHubDriver, ChampionshipHub } from '../types'
|
||||||
|
import { teamColor } from '../utils'
|
||||||
|
import { gapSeries, h2hTally, lastRounds } from '../lib/rivalry'
|
||||||
|
import '../styles/rivalry.css'
|
||||||
|
|
||||||
|
const PAD_L = 48
|
||||||
|
const PAD_T = 16
|
||||||
|
const PLOT_W = 882
|
||||||
|
const PLOT_H = 316
|
||||||
|
const GAP_PLOT_H = 200
|
||||||
|
|
||||||
|
function fmtPts(n: number): string {
|
||||||
|
return Number.isInteger(n) ? String(n) : n.toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Up to ~7 evenly spaced round labels, always including the last round. */
|
||||||
|
function xTicks(n: number, labels: string[], x: (i: number) => number): { x: number; label: string }[] {
|
||||||
|
const step = Math.max(1, Math.ceil(n / 7))
|
||||||
|
const ticks: { x: number; label: string }[] = []
|
||||||
|
for (let i = 0; i < n; i += step) {
|
||||||
|
ticks.push({ x: x(i), label: labels[i] ?? `R${i + 1}` })
|
||||||
|
}
|
||||||
|
const last = labels[n - 1] ?? `R${n}`
|
||||||
|
if (ticks[ticks.length - 1]?.label !== last) {
|
||||||
|
ticks.push({ x: x(n - 1), label: last })
|
||||||
|
}
|
||||||
|
return ticks
|
||||||
|
}
|
||||||
|
|
||||||
|
function polyline(values: number[], x: (i: number) => number, y: (v: number) => number): string {
|
||||||
|
return values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PickerProps {
|
||||||
|
id: 'a' | 'b'
|
||||||
|
label: string
|
||||||
|
drivers: ChampHubDriver[]
|
||||||
|
value: number
|
||||||
|
color: string
|
||||||
|
onChange: (driverNumber: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function DriverPicker({ id, label, drivers, value, color, onChange }: PickerProps) {
|
||||||
|
return (
|
||||||
|
<label className="rivalry-picker">
|
||||||
|
<span className="rivalry-picker-label">{label}</span>
|
||||||
|
<select
|
||||||
|
className="rivalry-picker-select"
|
||||||
|
style={{ borderLeft: `3px solid ${color}` }}
|
||||||
|
value={value}
|
||||||
|
data-testid={`rivalry-pick-${id}`}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{drivers.map((d) => (
|
||||||
|
<option key={d.driver_number} value={d.driver_number}>
|
||||||
|
{d.name_acronym} · {d.full_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RivalryCompare({ hub }: { hub: ChampionshipHub }) {
|
||||||
|
const { drivers } = hub
|
||||||
|
const [pickA, setPickA] = useState<number | null>(null)
|
||||||
|
const [pickB, setPickB] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Default to the top two in the standings; fall back there if a picked
|
||||||
|
// driver disappears (e.g. season switch re-fetches the hub).
|
||||||
|
const a = drivers.find((d) => d.driver_number === pickA) ?? drivers[0]
|
||||||
|
const b = drivers.find((d) => d.driver_number === pickB) ?? drivers[1]
|
||||||
|
|
||||||
|
const tally = useMemo(
|
||||||
|
() => (a && b ? h2hTally(a.round_positions ?? [], b.round_positions ?? [], hub.round_labels) : null),
|
||||||
|
[a, b, hub.round_labels],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (drivers.length < 2) {
|
||||||
|
return (
|
||||||
|
<div className="champ-chart-empty" data-testid="champ-view-rivalry">
|
||||||
|
Need at least two drivers in the standings to compare a rivalry.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorA = teamColor(a.team_colour)
|
||||||
|
const colorB = teamColor(b.team_colour)
|
||||||
|
const sameTeam = a.team_name === b.team_name
|
||||||
|
|
||||||
|
const cumA = a.cumulative ?? []
|
||||||
|
const cumB = b.cumulative ?? []
|
||||||
|
const rounds = Math.min(cumA.length, cumB.length)
|
||||||
|
const gaps = gapSeries(cumA, cumB)
|
||||||
|
const lastGap = gaps[gaps.length - 1] ?? 0
|
||||||
|
const strip = tally ? lastRounds(tally, 5) : []
|
||||||
|
|
||||||
|
const pickers = (
|
||||||
|
<div className="rivalry-pickers">
|
||||||
|
<DriverPicker id="a" label="Driver A" drivers={drivers} value={a.driver_number} color={colorA} onChange={setPickA} />
|
||||||
|
<span className="rivalry-vs mono">vs</span>
|
||||||
|
<DriverPicker id="b" label="Driver B" drivers={drivers} value={b.driver_number} color={colorB} onChange={setPickB} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rounds === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rivalry" data-testid="champ-view-rivalry">
|
||||||
|
{pickers}
|
||||||
|
<div className="champ-chart-empty">
|
||||||
|
No completed rounds yet — the rivalry will appear after the first race.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Points race scales.
|
||||||
|
const peak = Math.max(...cumA.slice(0, rounds), ...cumB.slice(0, rounds), 1)
|
||||||
|
const maxY = Math.max(50, Math.ceil(peak / 50) * 50)
|
||||||
|
const px = (i: number) => (rounds <= 1 ? PAD_L : PAD_L + (i * PLOT_W) / (rounds - 1))
|
||||||
|
const py = (v: number) => PAD_T + PLOT_H - (v / maxY) * PLOT_H
|
||||||
|
const pyGrid = [0, 0.25, 0.5, 0.75, 1].map((f) => ({ y: py(maxY * f), label: Math.round(maxY * f) }))
|
||||||
|
const pxGrid = xTicks(rounds, hub.round_labels, px)
|
||||||
|
|
||||||
|
// Gap scales: symmetric around zero.
|
||||||
|
const maxAbs = Math.max(10, Math.ceil(Math.max(...gaps.map(Math.abs), 1) / 10) * 10)
|
||||||
|
const gy = (v: number) => PAD_T + GAP_PLOT_H / 2 - (v / maxAbs) * (GAP_PLOT_H / 2)
|
||||||
|
const gyGrid = [maxAbs, 0, -maxAbs].map((v) => ({ y: gy(v), label: v > 0 ? `+${v}` : String(v) }))
|
||||||
|
const gxGrid = xTicks(rounds, hub.round_labels, px)
|
||||||
|
|
||||||
|
const gapLeader = lastGap === 0 ? null : lastGap > 0 ? a : b
|
||||||
|
const gapCaption = gapLeader
|
||||||
|
? `${gapLeader.name_acronym} leads by ${fmtPts(Math.abs(lastGap))} pts after ${hub.round_labels[rounds - 1] ?? `R${rounds}`}.`
|
||||||
|
: 'Dead level on points.'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rivalry" data-testid="champ-view-rivalry">
|
||||||
|
{pickers}
|
||||||
|
|
||||||
|
<div className="rivalry-h2h" data-testid="rivalry-h2h">
|
||||||
|
<div className="rivalry-h2h-score">
|
||||||
|
<span className="rivalry-h2h-code mono" style={{ color: colorA }}>
|
||||||
|
{a.name_acronym}
|
||||||
|
</span>
|
||||||
|
<span className="rivalry-h2h-num mono" data-testid="rivalry-h2h-num">
|
||||||
|
{tally ? `${tally.a}–${tally.b}` : '—'}
|
||||||
|
</span>
|
||||||
|
<span className="rivalry-h2h-code mono" style={{ color: colorB }}>
|
||||||
|
{b.name_acronym}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="rivalry-h2h-meta">
|
||||||
|
Race head-to-head · {tally?.rounds.length ?? 0} round{(tally?.rounds.length ?? 0) === 1 ? '' : 's'} counted
|
||||||
|
{tally && tally.skipped > 0 ? ` · ${tally.skipped} skipped` : ''}
|
||||||
|
</span>
|
||||||
|
{strip.length > 0 && (
|
||||||
|
<div className="rivalry-strip" data-testid="rivalry-strip">
|
||||||
|
<span className="rivalry-strip-label">Last {strip.length}</span>
|
||||||
|
{strip.map((r) => (
|
||||||
|
<span className="rivalry-chip" key={r.round} title={`${r.label}: ${a.name_acronym} P${r.posA} · ${b.name_acronym} P${r.posB}`}>
|
||||||
|
<span className="rivalry-chip-round mono">{r.label}</span>
|
||||||
|
<span className="rivalry-chip-winner mono" style={{ color: r.winner === 'a' ? colorA : colorB }}>
|
||||||
|
{r.winner === 'a' ? a.name_acronym : b.name_acronym}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="rivalry-section" data-testid="rivalry-points-race">
|
||||||
|
<div className="champ-chart-head">
|
||||||
|
<span className="champ-chart-title mono">
|
||||||
|
Points race — {a.name_acronym} vs {b.name_acronym}
|
||||||
|
</span>
|
||||||
|
<span className="champ-chart-meta">
|
||||||
|
Rounds 1–{rounds} · {hub.season}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="champ-chart">
|
||||||
|
<svg viewBox="0 0 1000 380" className="champ-chart-svg" preserveAspectRatio="none">
|
||||||
|
{pyGrid.map((g) => (
|
||||||
|
<g key={g.label}>
|
||||||
|
<line x1={PAD_L} y1={g.y} x2={930} y2={g.y} stroke="var(--border)" strokeWidth="1" />
|
||||||
|
<text x={40} y={g.y + 4} textAnchor="end" className="champ-chart-axis">
|
||||||
|
{g.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{pxGrid.map((g, i) => (
|
||||||
|
<text key={i} x={g.x} y={372} textAnchor="middle" className="champ-chart-axis">
|
||||||
|
{g.label}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
{[
|
||||||
|
{ slot: 'a', d: a, color: colorA, dash: '0', values: cumA.slice(0, rounds) },
|
||||||
|
{ slot: 'b', d: b, color: colorB, dash: sameTeam ? '5 4' : '0', values: cumB.slice(0, rounds) },
|
||||||
|
].map((s) => (
|
||||||
|
<g key={s.slot}>
|
||||||
|
<polyline
|
||||||
|
points={polyline(s.values, px, py)}
|
||||||
|
fill="none"
|
||||||
|
stroke={s.color}
|
||||||
|
strokeWidth="2.4"
|
||||||
|
strokeDasharray={s.dash}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<circle cx={px(s.values.length - 1)} cy={py(s.values[s.values.length - 1])} r="3" fill={s.color} />
|
||||||
|
<text
|
||||||
|
x={px(s.values.length - 1) + 8}
|
||||||
|
y={py(s.values[s.values.length - 1]) + 4}
|
||||||
|
fill={s.color}
|
||||||
|
className="champ-chart-label"
|
||||||
|
>
|
||||||
|
{s.d.name_acronym}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rivalry-section" data-testid="rivalry-gap">
|
||||||
|
<div className="champ-chart-head">
|
||||||
|
<span className="champ-chart-title mono">
|
||||||
|
Gap over season — {a.name_acronym} − {b.name_acronym}
|
||||||
|
</span>
|
||||||
|
<span className="champ-chart-meta">{gapCaption}</span>
|
||||||
|
</div>
|
||||||
|
<div className="champ-chart">
|
||||||
|
<svg viewBox="0 0 1000 250" className="champ-chart-svg" preserveAspectRatio="none">
|
||||||
|
{gyGrid.map((g) => (
|
||||||
|
<g key={g.label}>
|
||||||
|
<line
|
||||||
|
x1={PAD_L}
|
||||||
|
y1={g.y}
|
||||||
|
x2={930}
|
||||||
|
y2={g.y}
|
||||||
|
stroke={g.label === '0' ? 'var(--border-2)' : 'var(--border)'}
|
||||||
|
strokeWidth={g.label === '0' ? 1.5 : 1}
|
||||||
|
/>
|
||||||
|
<text x={40} y={g.y + 4} textAnchor="end" className="champ-chart-axis">
|
||||||
|
{g.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{gxGrid.map((g, i) => (
|
||||||
|
<text key={i} x={g.x} y={242} textAnchor="middle" className="champ-chart-axis">
|
||||||
|
{g.label}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
<polyline
|
||||||
|
points={polyline(gaps, px, gy)}
|
||||||
|
fill="none"
|
||||||
|
stroke={colorA}
|
||||||
|
strokeWidth="2.4"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<circle cx={px(gaps.length - 1)} cy={gy(lastGap)} r="3" fill={colorA} />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="rivalry-caption">
|
||||||
|
Above the zero line: {a.name_acronym} ahead. Below: {b.name_acronym} ahead.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -80,7 +80,9 @@ export function RaceControlFeed({ messages, driverInfo }: Props) {
|
|||||||
<section className="live-rc panel-glass">
|
<section className="live-rc panel-glass">
|
||||||
<div className="sec-header sticky-header">
|
<div className="sec-header sticky-header">
|
||||||
<span className="sec-title">Race Control</span>
|
<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>
|
</div>
|
||||||
{latest.length === 0 ? (
|
{latest.length === 0 ? (
|
||||||
<div className="missing-notice">No race control messages in the current live snapshot.</div>
|
<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 (
|
return (
|
||||||
<div className={`live-rc-row${flashClass}`} key={key}>
|
<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.Lap > 0 && <span className="rc-lap">L{message.Lap}</span>}
|
||||||
{message.Flag
|
{message.Flag
|
||||||
? <span className={`rc-flag ${rcFlagClass(message.Flag)}`}>{message.Flag}</span>
|
? <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">
|
<div className="live-banner-meta">
|
||||||
{display.advanceCount && <span>{display.advanceCount} advance</span>}
|
{display.advanceCount && <span>{display.advanceCount} advance</span>}
|
||||||
{atRiskLabel && <span>{atRiskLabel}</span>}
|
{atRiskLabel && <span>{atRiskLabel}</span>}
|
||||||
<span>
|
{display.isRace && (
|
||||||
|
<span data-testid="live-lap-counter">
|
||||||
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
|
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{stateLabel}</span>
|
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{stateLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, Fragment } from 'react'
|
import { useState, useMemo, Fragment } from 'react'
|
||||||
import { teamColor } from '../../utils'
|
import { teamColor } from '../../utils'
|
||||||
import { useAutoAnimate } from '@formkit/auto-animate/react'
|
import { useAutoAnimate } from '@formkit/auto-animate/react'
|
||||||
import type { LiveSessionMeta, LiveStintData } from '../../types'
|
import type { LiveSessionMeta, LiveStintData } from '../../types'
|
||||||
import type { LiveTimingRow } from '../../lib/live'
|
import type { LiveTimingRow } from '../../lib/live'
|
||||||
import {
|
import {
|
||||||
|
bestLapGaps,
|
||||||
driverCode,
|
driverCode,
|
||||||
liveSessionDisplay,
|
liveSessionDisplay,
|
||||||
positionDelta,
|
positionDelta,
|
||||||
@@ -62,6 +63,10 @@ export function TimingTower({
|
|||||||
const isQuali = sessionDisplay.isQualifying || !isRace
|
const isQuali = sessionDisplay.isQualifying || !isRace
|
||||||
const columnCount = 7 + (isRace ? 3 : 0) + (isQuali ? 3 : 0)
|
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 (
|
return (
|
||||||
<div className="scroll-x">
|
<div className="scroll-x">
|
||||||
<table className="data-table live-tower" style={{ minWidth: 760 }}>
|
<table className="data-table live-tower" style={{ minWidth: 760 }}>
|
||||||
@@ -71,7 +76,7 @@ export function TimingTower({
|
|||||||
{isRace && <th>Δ</th>}
|
{isRace && <th>Δ</th>}
|
||||||
<th>Driver</th>
|
<th>Driver</th>
|
||||||
<th>Tyre</th>
|
<th>Tyre</th>
|
||||||
<th>Last Lap</th>
|
<th className={isRace ? undefined : 'hide-mobile'}>Last Lap</th>
|
||||||
<th
|
<th
|
||||||
className="interactive"
|
className="interactive"
|
||||||
onClick={() => setGapMode(g => g === 'interval' ? 'leader' : 'interval')}
|
onClick={() => setGapMode(g => g === 'interval' ? 'leader' : 'interval')}
|
||||||
@@ -81,10 +86,10 @@ export function TimingTower({
|
|||||||
{gapMode === 'interval' && isRace ? 'Interval' : 'Gap to P1'}
|
{gapMode === 'interval' && isRace ? 'Interval' : 'Gap to P1'}
|
||||||
</th>
|
</th>
|
||||||
{isRace && <th>Trend</th>}
|
{isRace && <th>Trend</th>}
|
||||||
{isQuali && <th>S1</th>}
|
{isQuali && <th className="hide-mobile">S1</th>}
|
||||||
{isQuali && <th>S2</th>}
|
{isQuali && <th className="hide-mobile">S2</th>}
|
||||||
{isQuali && <th>S3</th>}
|
{isQuali && <th className="hide-mobile">S3</th>}
|
||||||
<th className="hide-mobile">Best</th>
|
<th className={isRace ? 'hide-mobile' : undefined}>Best</th>
|
||||||
{isRace && <th className="hide-mobile r">Laps</th>}
|
{isRace && <th className="hide-mobile r">Laps</th>}
|
||||||
<th className="r"></th>
|
<th className="r"></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -102,7 +107,15 @@ export function TimingTower({
|
|||||||
driver.Cutoff
|
driver.Cutoff
|
||||||
const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition
|
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 =
|
const intervalAnnotation =
|
||||||
gapMode === 'interval' && isRace && row.Position > 1
|
gapMode === 'interval' && isRace && row.Position > 1
|
||||||
? intervalMeaning(parseIntervalSeconds(gapText))
|
? intervalMeaning(parseIntervalSeconds(gapText))
|
||||||
@@ -153,7 +166,10 @@ export function TimingTower({
|
|||||||
<td>
|
<td>
|
||||||
<span className={`tyre-badge ${tyreClass(row.Tyre)}`}>{tyreLabel(row.Tyre)}</span>
|
<span className={`tyre-badge ${tyreClass(row.Tyre)}`}>{tyreLabel(row.Tyre)}</span>
|
||||||
</td>
|
</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 || '-'}
|
{driver.LastLapTime || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="mono">
|
<td className="mono">
|
||||||
@@ -171,11 +187,14 @@ export function TimingTower({
|
|||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isQuali && <td>{renderSector(0)}</td>}
|
{isQuali && <td className="hide-mobile">{renderSector(0)}</td>}
|
||||||
{isQuali && <td>{renderSector(1)}</td>}
|
{isQuali && <td className="hide-mobile">{renderSector(1)}</td>}
|
||||||
{isQuali && <td>{renderSector(2)}</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 || '-'}
|
{driver.BestLapTime || '-'}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@@ -208,6 +227,16 @@ export function TimingTower({
|
|||||||
<div className="mono">{driver.NumberOfLaps || 0}</div>
|
<div className="mono">{driver.NumberOfLaps || 0}</div>
|
||||||
</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 && (
|
{driver.SpeedTrap && (
|
||||||
<div>
|
<div>
|
||||||
<div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>SPEED TRAP</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) {
|
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>({})
|
const [stints, setStints] = useState<StintHistoryMap>({})
|
||||||
|
|
||||||
// One lap-history update per received snapshot (rows is rebuilt per snapshot).
|
// 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)))
|
setStints((prev) => recordStintSamples(prev, rows.map(stintInputFromRow)))
|
||||||
}, [rows])
|
}, [rows])
|
||||||
|
|
||||||
const isRace = isRaceSession(sessionType)
|
|
||||||
|
|
||||||
const visible = useMemo(
|
const visible = useMemo(
|
||||||
() =>
|
() =>
|
||||||
rows.filter(
|
rows.filter(
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ export function chapterEndScrub(
|
|||||||
return Math.max(0, Math.min(1, (ms - tMin) / tRange))
|
return Math.max(0, Math.min(1, (ms - tMin) / tRange))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Index of the chapter containing the current scrub position, if any. */
|
/** 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(
|
export function activeChapterIndex(
|
||||||
chapters: Chapter[],
|
chapters: Chapter[],
|
||||||
scrubTime: number | null,
|
scrubTime: number | null,
|
||||||
@@ -61,15 +64,13 @@ export function activeChapterIndex(
|
|||||||
tRange: number,
|
tRange: number,
|
||||||
): number | null {
|
): number | null {
|
||||||
if (scrubTime === null || chapters.length === 0 || tRange <= 0) return null
|
if (scrubTime === null || chapters.length === 0 || tRange <= 0) return null
|
||||||
const chartMs = tMin + scrubTime * tRange
|
|
||||||
for (let i = 0; i < chapters.length; i++) {
|
for (let i = 0; i < chapters.length; i++) {
|
||||||
const ch = chapters[i]
|
const start = chapterStartScrub(chapters[i], tMin, tRange)
|
||||||
const startMs = ch.start_time ? new Date(ch.start_time).getTime() : NaN
|
const end = chapterEndScrub(chapters[i], tMin, tRange)
|
||||||
const endRaw = ch.end_time ?? ch.start_time
|
if (start === null || end === null) continue
|
||||||
const endMs = endRaw ? new Date(endRaw).getTime() : NaN
|
const lo = Math.min(start, end)
|
||||||
if (!Number.isNaN(startMs) && !Number.isNaN(endMs) && chartMs >= startMs && chartMs <= endMs) {
|
const hi = Math.max(start, end)
|
||||||
return i
|
if (scrubTime >= lo && scrubTime <= hi) return i
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -80,3 +81,52 @@ export function chapterTourDurations(chapters: Chapter[], totalMs = 90_000): num
|
|||||||
const perChapter = totalMs / chapters.length
|
const perChapter = totalMs / chapters.length
|
||||||
return chapters.map(() => perChapter)
|
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]))
|
||||||
|
}
|
||||||
|
|||||||
@@ -268,6 +268,63 @@ export function driverCode(row: LiveTimingRow): string {
|
|||||||
return row.Info?.Tla || row.RacingNumber
|
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 {
|
export function trackStatusLabel(status: string): string {
|
||||||
return TRACK_STATUS_LABELS[status] || status || 'UNKNOWN'
|
return TRACK_STATUS_LABELS[status] || status || 'UNKNOWN'
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
61
frontend/src/lib/rivalry.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// Pure helpers for the championship rivalry compare view.
|
||||||
|
|
||||||
|
/** One round that counted toward the head-to-head tally. */
|
||||||
|
export interface H2HRound {
|
||||||
|
round: number // 1-based season round
|
||||||
|
label: string
|
||||||
|
posA: number
|
||||||
|
posB: number
|
||||||
|
winner: 'a' | 'b'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface H2HTally {
|
||||||
|
a: number
|
||||||
|
b: number
|
||||||
|
/** Rounds counted in the tally, in season order. */
|
||||||
|
rounds: H2HRound[]
|
||||||
|
/** Rounds skipped because either driver had no finishing position. */
|
||||||
|
skipped: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-round points gap (a − b) across the rounds both cumulative series
|
||||||
|
* cover. Positive values mean driver A is ahead.
|
||||||
|
*/
|
||||||
|
export function gapSeries(a: number[], b: number[]): number[] {
|
||||||
|
const n = Math.min(a.length, b.length)
|
||||||
|
const out: number[] = []
|
||||||
|
for (let i = 0; i < n; i++) out.push(a[i] - b[i])
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Race head-to-head: per round, the lower finishing position wins. Rounds
|
||||||
|
* where either driver has no position (0 / missing) are skipped from the
|
||||||
|
* tally, as are equal positions (defensive — races can't tie).
|
||||||
|
*/
|
||||||
|
export function h2hTally(posA: number[], posB: number[], labels: string[]): H2HTally {
|
||||||
|
const n = Math.max(posA.length, posB.length)
|
||||||
|
const rounds: H2HRound[] = []
|
||||||
|
let a = 0
|
||||||
|
let b = 0
|
||||||
|
let skipped = 0
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const pa = posA[i] ?? 0
|
||||||
|
const pb = posB[i] ?? 0
|
||||||
|
if (pa <= 0 || pb <= 0 || pa === pb) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const winner = pa < pb ? 'a' : 'b'
|
||||||
|
if (winner === 'a') a++
|
||||||
|
else b++
|
||||||
|
rounds.push({ round: i + 1, label: labels[i] ?? `R${i + 1}`, posA: pa, posB: pb, winner })
|
||||||
|
}
|
||||||
|
return { a, b, rounds, skipped }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last n counted rounds of a tally, for the mini strip. */
|
||||||
|
export function lastRounds(tally: H2HTally, n: number): H2HRound[] {
|
||||||
|
return tally.rounds.slice(-n)
|
||||||
|
}
|
||||||
@@ -5,12 +5,13 @@ import { fetchChampionshipHub, fetchSeasons } from '../api'
|
|||||||
import { teamColor } from '../utils'
|
import { teamColor } from '../utils'
|
||||||
import type { ChampHubDriver, ChampionshipHub } from '../types'
|
import type { ChampHubDriver, ChampionshipHub } from '../types'
|
||||||
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
|
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
|
||||||
|
import { RivalryCompare } from '../components/RivalryCompare'
|
||||||
import { Meaning } from '../components/Meaning'
|
import { Meaning } from '../components/Meaning'
|
||||||
import { TeammateH2H } from '../components/TeammateH2H'
|
import { TeammateH2H } from '../components/TeammateH2H'
|
||||||
import { teammatePairs } from '../lib/h2h'
|
import { teammatePairs } from '../lib/h2h'
|
||||||
import { pointsGapMeaning } from '../lib/meaning'
|
import { pointsGapMeaning } from '../lib/meaning'
|
||||||
|
|
||||||
type View = 'drivers' | 'constructors' | 'progression' | 'simulator'
|
type View = 'drivers' | 'constructors' | 'progression' | 'rivalry' | 'simulator'
|
||||||
|
|
||||||
const GOLD = '#ffd700'
|
const GOLD = '#ffd700'
|
||||||
const SILVER = '#c0c0c0'
|
const SILVER = '#c0c0c0'
|
||||||
@@ -216,6 +217,14 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
|||||||
>
|
>
|
||||||
Progression
|
Progression
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`champ-tab${view === 'rivalry' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setView('rivalry')}
|
||||||
|
data-testid="champ-tab-rivalry"
|
||||||
|
>
|
||||||
|
Rivalry
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`champ-tab${view === 'simulator' ? ' is-active' : ''}`}
|
className={`champ-tab${view === 'simulator' ? ' is-active' : ''}`}
|
||||||
@@ -251,6 +260,7 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
|||||||
)}
|
)}
|
||||||
{view === 'constructors' && <ConstructorsView hub={hub} />}
|
{view === 'constructors' && <ConstructorsView hub={hub} />}
|
||||||
{view === 'progression' && <ProgressionView hub={hub} />}
|
{view === 'progression' && <ProgressionView hub={hub} />}
|
||||||
|
{view === 'rivalry' && <RivalryCompare hub={hub} />}
|
||||||
{view === 'simulator' && <ChampionshipSimulator hub={hub} />}
|
{view === 'simulator' && <ChampionshipSimulator hub={hub} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -112,6 +112,30 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.nav-links a:hover { color: var(--text); background: var(--surface-h); }
|
.nav-links a:hover { color: var(--text); background: var(--surface-h); }
|
||||||
.nav-links a.active { color: var(--text); background: var(--surface-2); }
|
.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 {
|
.nav-utility {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--s4);
|
gap: var(--s4);
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-strip-title {
|
.chapter-strip-title {
|
||||||
@@ -44,6 +45,31 @@
|
|||||||
color: var(--text);
|
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 {
|
.chapter-strip-scroll {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--s3);
|
gap: var(--s3);
|
||||||
@@ -51,10 +77,26 @@
|
|||||||
padding-bottom: var(--s2);
|
padding-bottom: var(--s2);
|
||||||
scroll-snap-type: x mandatory;
|
scroll-snap-type: x mandatory;
|
||||||
-webkit-overflow-scrolling: touch;
|
-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 {
|
.chapter-card {
|
||||||
flex: 0 0 min(240px, 72vw);
|
flex: 0 0 min(240px, 72vw);
|
||||||
|
min-height: 88px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--s2);
|
gap: var(--s2);
|
||||||
@@ -64,7 +106,7 @@
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
scroll-snap-align: start;
|
scroll-snap-align: center;
|
||||||
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,10 +187,22 @@
|
|||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-strip-empty {
|
.chapter-strip-empty-card {
|
||||||
font-size: 12px;
|
padding: var(--s6) var(--s5);
|
||||||
color: var(--text-3);
|
margin: 0;
|
||||||
padding: var(--s3) 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,76 +1,9 @@
|
|||||||
.rs-replay-shell {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.8fr);
|
|
||||||
gap: var(--s5);
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rs-replay-main {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rs-replay-tools {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--s3);
|
|
||||||
margin-top: var(--s3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rs-tool-btn,
|
|
||||||
.rs-speed-btn {
|
|
||||||
height: 28px;
|
|
||||||
padding: 0 var(--s4);
|
|
||||||
font-family: var(--f-mono);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-2);
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border-2);
|
|
||||||
border-radius: 2px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rs-tool-btn:hover,
|
|
||||||
.rs-speed-btn:hover {
|
|
||||||
color: var(--text);
|
|
||||||
border-color: var(--text-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rs-tool-btn.active,
|
|
||||||
.rs-speed-btn.active {
|
|
||||||
color: var(--text);
|
|
||||||
border-color: var(--red);
|
|
||||||
background: rgba(230, 36, 41, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rs-speed-group {
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rs-replay-map-slot {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rs-replay-map-placeholder {
|
|
||||||
min-height: 244px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
padding: var(--s5);
|
|
||||||
color: var(--text-3);
|
|
||||||
font-family: var(--f-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
text-align: center;
|
|
||||||
border: 1px dashed var(--border-2);
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.replay-map-panel {
|
.replay-map-panel {
|
||||||
min-height: 244px;
|
min-height: 244px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
border-radius: var(--s2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.replay-map-stage {
|
.replay-map-stage {
|
||||||
@@ -143,19 +76,16 @@
|
|||||||
stroke-width: 1.2;
|
stroke-width: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.replay-map-empty {
|
.replay-map-empty-card {
|
||||||
min-height: 244px;
|
min-height: 244px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-content: center;
|
||||||
padding: var(--s5);
|
padding: var(--s6);
|
||||||
color: var(--text-3);
|
margin: 0;
|
||||||
font-family: var(--f-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
.replay-map-empty-card .empty-state-icon {
|
||||||
.rs-replay-shell {
|
display: block;
|
||||||
grid-template-columns: 1fr;
|
margin: 0 auto var(--s3);
|
||||||
}
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|||||||
134
frontend/src/styles/rivalry.css
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
.rivalry {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-pickers {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s4);
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-vs {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
padding-bottom: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s2);
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-picker-label {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-picker-select {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-picker-select:focus {
|
||||||
|
outline: 2px solid var(--red);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-h2h {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s4) var(--s5);
|
||||||
|
padding: var(--s4) var(--s5);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-h2h-score {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-h2h-code {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-h2h-num {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-h2h-meta {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-strip-label {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin-right: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-chip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1px;
|
||||||
|
padding: var(--s1) var(--s3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-chip-round {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-chip-winner {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-caption {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
@@ -73,6 +73,7 @@ const drivers: ChampHubDriver[] = [
|
|||||||
cumulative: [25, 43, 68, 100],
|
cumulative: [25, 43, 68, 100],
|
||||||
teammate_wins: 4,
|
teammate_wins: 4,
|
||||||
teammate_losses: 1,
|
teammate_losses: 1,
|
||||||
|
round_positions: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
driver_number: 4,
|
driver_number: 4,
|
||||||
@@ -89,6 +90,7 @@ const drivers: ChampHubDriver[] = [
|
|||||||
cumulative: [18, 36, 54, 80],
|
cumulative: [18, 36, 54, 80],
|
||||||
teammate_wins: 3,
|
teammate_wins: 3,
|
||||||
teammate_losses: 2,
|
teammate_losses: 2,
|
||||||
|
round_positions: [],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
|
|||||||
poles: 4,
|
poles: 4,
|
||||||
form: [25, 18, 25, 15, 25],
|
form: [25, 18, 25, 15, 25],
|
||||||
cumulative: [25, 43, 68, 83, 108, 200],
|
cumulative: [25, 43, 68, 83, 108, 200],
|
||||||
|
round_positions: [1, 2, 1, 3, 1, 1],
|
||||||
teammate_wins: 9,
|
teammate_wins: 9,
|
||||||
teammate_losses: 1,
|
teammate_losses: 1,
|
||||||
...over,
|
...over,
|
||||||
@@ -47,6 +48,7 @@ const drivers: ChampHubDriver[] = [
|
|||||||
position: 2,
|
position: 2,
|
||||||
wins: 3,
|
wins: 3,
|
||||||
cumulative: [18, 36, 54, 80, 120, 160],
|
cumulative: [18, 36, 54, 80, 120, 160],
|
||||||
|
round_positions: [2, 1, 2, 2, 2, 2],
|
||||||
}),
|
}),
|
||||||
driver({
|
driver({
|
||||||
driver_number: 16,
|
driver_number: 16,
|
||||||
@@ -58,6 +60,7 @@ const drivers: ChampHubDriver[] = [
|
|||||||
position: 3,
|
position: 3,
|
||||||
wins: 1,
|
wins: 1,
|
||||||
cumulative: [15, 28, 40, 60, 90, 120],
|
cumulative: [15, 28, 40, 60, 90, 120],
|
||||||
|
round_positions: [3, 3, 3, 0, 3, 3],
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -129,6 +132,22 @@ describe('ChampionshipPage', () => {
|
|||||||
expect(screen.getByText('Cumulative points', { exact: false })).toBeInTheDocument()
|
expect(screen.getByText('Cumulative points', { exact: false })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('switches to the rivalry view with the top two drivers preselected', async () => {
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('championship')).toBeInTheDocument())
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('champ-tab-rivalry'))
|
||||||
|
expect(screen.getByTestId('champ-view-rivalry')).toBeInTheDocument()
|
||||||
|
// Default pair is the top two in the standings: VER vs NOR.
|
||||||
|
expect(screen.getByTestId('rivalry-pick-a')).toHaveValue('1')
|
||||||
|
expect(screen.getByTestId('rivalry-pick-b')).toHaveValue('4')
|
||||||
|
// VER beats NOR in rounds 1, 3, 5, 6 → 4–2.
|
||||||
|
expect(screen.getByTestId('rivalry-h2h-num')).toHaveTextContent('4–2')
|
||||||
|
expect(screen.getByTestId('rivalry-points-race')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('rivalry-gap')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('switches to the simulator view and projects standings', async () => {
|
it('switches to the simulator view and projects standings', async () => {
|
||||||
renderPage()
|
renderPage()
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
|
|||||||
poles: 0,
|
poles: 0,
|
||||||
form: [],
|
form: [],
|
||||||
cumulative: [],
|
cumulative: [],
|
||||||
|
round_positions: [],
|
||||||
teammate_wins: 0,
|
teammate_wins: 0,
|
||||||
teammate_losses: 0,
|
teammate_losses: 0,
|
||||||
...over,
|
...over,
|
||||||
|
|||||||
@@ -68,6 +68,25 @@ describe('ChapterStrip', () => {
|
|||||||
expect(screen.getByText('L12–L15')).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', () => {
|
it('highlights the active chapter from scrub time', () => {
|
||||||
const scrub = (new Date('2025-05-25T13:13:00Z').getTime() - tMin) / tRange
|
const scrub = (new Date('2025-05-25T13:13:00Z').getTime() - tMin) / tRange
|
||||||
renderStrip({ scrubTime: scrub })
|
renderStrip({ scrubTime: scrub })
|
||||||
@@ -84,4 +103,26 @@ describe('ChapterStrip', () => {
|
|||||||
expect(index).toBe(1)
|
expect(index).toBe(1)
|
||||||
expect(scrub).toBeCloseTo(0.6, 2)
|
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()
|
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', () => {
|
it('renders the SQ1 cutoff after P17 and marks rows below as at risk', () => {
|
||||||
const sprintRows = Array.from({ length: 22 }, (_, index) =>
|
const sprintRows = Array.from({ length: 22 }, (_, index) =>
|
||||||
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
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()
|
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 () => {
|
it('temporarily omits the track map while live GPS is unavailable', async () => {
|
||||||
renderPage({
|
renderPage({
|
||||||
is_live: true,
|
is_live: true,
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
|
|||||||
poles: 4,
|
poles: 4,
|
||||||
form: [25],
|
form: [25],
|
||||||
cumulative: [200],
|
cumulative: [200],
|
||||||
|
round_positions: [1],
|
||||||
teammate_wins: 9,
|
teammate_wins: 9,
|
||||||
teammate_losses: 1,
|
teammate_losses: 1,
|
||||||
...over,
|
...over,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
|
import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
|
||||||
import type { RaceHub, ReplayFramesResponse, TrackOutline } from '../types'
|
import type { Chapter, RaceHub, ReplayFramesResponse, TrackOutline } from '../types'
|
||||||
|
|
||||||
vi.mock('../api', () => ({
|
vi.mock('../api', () => ({
|
||||||
fetchReplayFrames: vi.fn(),
|
fetchReplayFrames: vi.fn(),
|
||||||
@@ -28,9 +28,35 @@ const replay: ReplayFramesResponse = {
|
|||||||
session_key: 99,
|
session_key: 99,
|
||||||
interval_ms: 5000,
|
interval_ms: 5000,
|
||||||
start_time: '2025-05-25T13:00:00Z',
|
start_time: '2025-05-25T13:00:00Z',
|
||||||
frames: [{ t: 0, cars: { '1': { x: 10, y: 20 } } }],
|
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 = {
|
const raceHub: RaceHub = {
|
||||||
source: 'local',
|
source: 'local',
|
||||||
session_key: 99,
|
session_key: 99,
|
||||||
@@ -103,14 +129,14 @@ const raceHub: RaceHub = {
|
|||||||
race_control: [],
|
race_control: [],
|
||||||
weather: [],
|
weather: [],
|
||||||
laps: [],
|
laps: [],
|
||||||
chapters: [],
|
chapters,
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCanvas() {
|
function renderCanvas(overrides: Partial<RaceHub> = {}) {
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<RaceStoryCanvas data={raceHub} />
|
<RaceStoryCanvas data={{ ...raceHub, ...overrides }} />
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -122,14 +148,144 @@ describe('RaceStoryCanvas replay map', () => {
|
|||||||
mockFetchTrackOutline.mockResolvedValue(outline)
|
mockFetchTrackOutline.mockResolvedValue(outline)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('fetches replay frames lazily when the map panel opens', async () => {
|
it('probes replay frames on mount and opens the map when data is available', async () => {
|
||||||
renderCanvas()
|
renderCanvas()
|
||||||
|
|
||||||
expect(mockFetchReplayFrames).not.toHaveBeenCalled()
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Map' }))
|
|
||||||
|
|
||||||
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalledWith(99, 5000))
|
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalledWith(99, 5000))
|
||||||
expect(mockFetchTrackOutline).toHaveBeenCalledWith(1, 2025)
|
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(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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -44,9 +44,10 @@ const results: EnrichedResult[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
describe('ReplayTrackMap', () => {
|
describe('ReplayTrackMap', () => {
|
||||||
it('renders an empty state when replay frames are missing', () => {
|
it('renders an empty-state card when replay frames are missing', () => {
|
||||||
render(<ReplayTrackMap outline={outline} replay={{ ...replay, frames: [] }} tMs={0} drivers={[]} results={results} />)
|
render(<ReplayTrackMap outline={outline} replay={{ ...replay, frames: [] }} tMs={0} drivers={[]} results={results} />)
|
||||||
expect(screen.getByTestId('replay-track-map')).toHaveTextContent(/historical GPS unavailable/i)
|
expect(screen.getByTestId('replay-map-no-frames')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Historical GPS unavailable')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders car labels from result metadata', () => {
|
it('renders car labels from result metadata', () => {
|
||||||
|
|||||||
114
frontend/src/test/RivalryCompare.test.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { RivalryCompare } from '../components/RivalryCompare'
|
||||||
|
import type { ChampHubDriver, ChampionshipHub } 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: 100,
|
||||||
|
position: 1,
|
||||||
|
wins: 2,
|
||||||
|
podiums: 3,
|
||||||
|
poles: 1,
|
||||||
|
form: [25, 18, 25],
|
||||||
|
cumulative: [25, 43, 68],
|
||||||
|
round_positions: [1, 2, 1],
|
||||||
|
teammate_wins: 3,
|
||||||
|
teammate_losses: 0,
|
||||||
|
...over,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeHub(drivers: ChampHubDriver[]): ChampionshipHub {
|
||||||
|
return {
|
||||||
|
season: 2025,
|
||||||
|
round: 3,
|
||||||
|
total_rounds: 10,
|
||||||
|
rounds_left: 7,
|
||||||
|
last_race: 'Japan GP',
|
||||||
|
round_labels: ['R1', 'R2', 'R3'],
|
||||||
|
drivers,
|
||||||
|
teams: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hub = makeHub([
|
||||||
|
driver({}),
|
||||||
|
driver({
|
||||||
|
driver_number: 4,
|
||||||
|
name_acronym: 'NOR',
|
||||||
|
full_name: 'Lando Norris',
|
||||||
|
team_name: 'McLaren',
|
||||||
|
team_colour: 'ff8000',
|
||||||
|
points: 90,
|
||||||
|
position: 2,
|
||||||
|
cumulative: [18, 43, 61],
|
||||||
|
round_positions: [2, 1, 2],
|
||||||
|
}),
|
||||||
|
driver({
|
||||||
|
driver_number: 16,
|
||||||
|
name_acronym: 'LEC',
|
||||||
|
full_name: 'Charles Leclerc',
|
||||||
|
team_name: 'Ferrari',
|
||||||
|
team_colour: 'e8002d',
|
||||||
|
points: 50,
|
||||||
|
position: 3,
|
||||||
|
cumulative: [15, 28, 40],
|
||||||
|
round_positions: [3, 0, 3],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
describe('RivalryCompare', () => {
|
||||||
|
it('defaults to the top two drivers and shows the H2H score', () => {
|
||||||
|
render(<RivalryCompare hub={hub} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('champ-view-rivalry')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('rivalry-pick-a')).toHaveValue('1')
|
||||||
|
expect(screen.getByTestId('rivalry-pick-b')).toHaveValue('4')
|
||||||
|
// VER wins R1 and R3, NOR wins R2.
|
||||||
|
expect(screen.getByTestId('rivalry-h2h-num')).toHaveTextContent('2–1')
|
||||||
|
expect(screen.getByText('3 rounds counted', { exact: false })).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('rivalry-points-race')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('rivalry-gap')).toBeInTheDocument()
|
||||||
|
// Last gap: 68 − 61 = +7 → VER ahead.
|
||||||
|
expect(screen.getByText('VER leads by 7 pts', { exact: false })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recomputes when a different driver is picked and skips missing rounds', () => {
|
||||||
|
render(<RivalryCompare hub={hub} />)
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId('rivalry-pick-b'), { target: { value: '16' } })
|
||||||
|
|
||||||
|
// VER vs LEC: R2 skipped (LEC has no position), VER wins R1 and R3.
|
||||||
|
expect(screen.getByTestId('rivalry-h2h-num')).toHaveTextContent('2–0')
|
||||||
|
expect(screen.getByText('2 rounds counted · 1 skipped', { exact: false })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an empty state with fewer than two drivers', () => {
|
||||||
|
render(<RivalryCompare hub={makeHub([driver({})])} />)
|
||||||
|
expect(screen.getByTestId('champ-view-rivalry')).toHaveTextContent('at least two drivers')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an empty message when no rounds are completed', () => {
|
||||||
|
const empty = makeHub([
|
||||||
|
driver({ cumulative: [], round_positions: [], form: [] }),
|
||||||
|
driver({
|
||||||
|
driver_number: 4,
|
||||||
|
name_acronym: 'NOR',
|
||||||
|
cumulative: [],
|
||||||
|
round_positions: [],
|
||||||
|
form: [],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
empty.round = 0
|
||||||
|
empty.round_labels = []
|
||||||
|
|
||||||
|
render(<RivalryCompare hub={empty} />)
|
||||||
|
expect(screen.getByTestId('champ-view-rivalry')).toHaveTextContent('No completed rounds yet')
|
||||||
|
})
|
||||||
|
})
|
||||||
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -81,14 +81,20 @@ describe('TyreDegPanel', () => {
|
|||||||
expect(panel).toHaveTextContent('→ ~P2')
|
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={[]} />)
|
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Qualifying" pinned={[]} />)
|
||||||
const panel = screen.getByTestId('tyredeg-panel')
|
const panel = screen.getByTestId('tyredeg-panel')
|
||||||
expect(panel).not.toHaveTextContent('~P')
|
|
||||||
|
|
||||||
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
|
// Practice/qualifying starts collapsed so the Timing Tower stays above the fold.
|
||||||
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
|
||||||
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
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', () => {
|
it('limits rows to the top ten plus pinned drivers', () => {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
activeChapterIndex,
|
activeChapterIndex,
|
||||||
|
chapterBandFill,
|
||||||
chapterKindLabel,
|
chapterKindLabel,
|
||||||
chapterLapRange,
|
chapterLapRange,
|
||||||
chapterStartScrub,
|
chapterStartScrub,
|
||||||
chapterTourDurations,
|
chapterTourDurations,
|
||||||
|
deCollideYPositions,
|
||||||
|
decimatedPositionLabels,
|
||||||
} from '../lib/chapters'
|
} from '../lib/chapters'
|
||||||
import type { Chapter } from '../types'
|
import type { Chapter } from '../types'
|
||||||
|
|
||||||
@@ -57,7 +60,65 @@ describe('chapters lib', () => {
|
|||||||
expect(activeChapterIndex(sampleChapters, scrub, tMin, tRange)).toBe(1)
|
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', () => {
|
it('splits 90s evenly across chapters', () => {
|
||||||
expect(chapterTourDurations(sampleChapters)).toEqual([45_000, 45_000])
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const driver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
|
|||||||
cumulative: [25, 43, 68, 100],
|
cumulative: [25, 43, 68, 100],
|
||||||
teammate_wins: 4,
|
teammate_wins: 4,
|
||||||
teammate_losses: 1,
|
teammate_losses: 1,
|
||||||
|
round_positions: [],
|
||||||
...over,
|
...over,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
|
|||||||
cumulative: [],
|
cumulative: [],
|
||||||
teammate_wins: 9,
|
teammate_wins: 9,
|
||||||
teammate_losses: 1,
|
teammate_losses: 1,
|
||||||
|
round_positions: [],
|
||||||
...over,
|
...over,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
|
bestLapGaps,
|
||||||
compoundClass,
|
compoundClass,
|
||||||
compoundLetter,
|
compoundLetter,
|
||||||
extrapolateClock,
|
extrapolateClock,
|
||||||
|
isLiveSessionActive,
|
||||||
latestRaceControl,
|
latestRaceControl,
|
||||||
liveSessionDisplay,
|
liveSessionDisplay,
|
||||||
loadPinnedDrivers,
|
loadPinnedDrivers,
|
||||||
mergeVisibleSectors,
|
mergeVisibleSectors,
|
||||||
|
parseLapTimeSeconds,
|
||||||
positionDeltaClass,
|
positionDeltaClass,
|
||||||
parseLiveStateEvent,
|
parseLiveStateEvent,
|
||||||
rcFlagClass,
|
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', () => {
|
describe('visible sector display', () => {
|
||||||
it('holds S1 and S2 through temporary blanks while a flying lap is active', () => {
|
it('holds S1 and S2 through temporary blanks while a flying lap is active', () => {
|
||||||
const first = [timingRow('4', 1, {
|
const first = [timingRow('4', 1, {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
|
|||||||
poles: 4,
|
poles: 4,
|
||||||
form: [25],
|
form: [25],
|
||||||
cumulative: [200],
|
cumulative: [200],
|
||||||
|
round_positions: [1],
|
||||||
teammate_wins: 9,
|
teammate_wins: 9,
|
||||||
teammate_losses: 1,
|
teammate_losses: 1,
|
||||||
...over,
|
...over,
|
||||||
|
|||||||
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
71
frontend/src/test/rivalry.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { gapSeries, h2hTally, lastRounds } from '../lib/rivalry'
|
||||||
|
|
||||||
|
describe('gapSeries', () => {
|
||||||
|
it('computes per-round a − b', () => {
|
||||||
|
expect(gapSeries([25, 43, 68], [18, 36, 54])).toEqual([7, 7, 14])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles negative gaps (b ahead)', () => {
|
||||||
|
expect(gapSeries([10, 20], [18, 36])).toEqual([-8, -16])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('truncates to the shorter series', () => {
|
||||||
|
expect(gapSeries([25, 43, 68], [18])).toEqual([7])
|
||||||
|
expect(gapSeries([], [18, 36])).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('h2hTally', () => {
|
||||||
|
it('tallies lower-position wins per round', () => {
|
||||||
|
const t = h2hTally([1, 2, 1], [2, 1, 3], ['R1', 'R2', 'R3'])
|
||||||
|
expect(t.a).toBe(2)
|
||||||
|
expect(t.b).toBe(1)
|
||||||
|
expect(t.skipped).toBe(0)
|
||||||
|
expect(t.rounds.map((r) => r.winner)).toEqual(['a', 'b', 'a'])
|
||||||
|
expect(t.rounds[0]).toEqual({ round: 1, label: 'R1', posA: 1, posB: 2, winner: 'a' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips rounds where either driver has no position', () => {
|
||||||
|
// R2: a missing (0). R3: b missing (0). Only R1 and R4 count.
|
||||||
|
const t = h2hTally([1, 0, 5, 3], [4, 2, 0, 1], ['R1', 'R2', 'R3', 'R4'])
|
||||||
|
expect(t.a).toBe(1)
|
||||||
|
expect(t.b).toBe(1)
|
||||||
|
expect(t.skipped).toBe(2)
|
||||||
|
expect(t.rounds.map((r) => r.round)).toEqual([1, 4])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles arrays of different lengths (missing tail = skipped)', () => {
|
||||||
|
const t = h2hTally([1, 2, 3], [2], ['R1', 'R2', 'R3'])
|
||||||
|
expect(t.a).toBe(1)
|
||||||
|
expect(t.b).toBe(0)
|
||||||
|
expect(t.skipped).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips equal positions defensively and falls back on labels', () => {
|
||||||
|
const t = h2hTally([2, 1], [2, 3], ['R1'])
|
||||||
|
expect(t.a).toBe(1)
|
||||||
|
expect(t.b).toBe(0)
|
||||||
|
expect(t.skipped).toBe(1)
|
||||||
|
expect(t.rounds[0].label).toBe('R2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns an empty tally for empty inputs', () => {
|
||||||
|
const t = h2hTally([], [], [])
|
||||||
|
expect(t).toEqual({ a: 0, b: 0, rounds: [], skipped: 0 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('lastRounds', () => {
|
||||||
|
it('returns the last n counted rounds in order', () => {
|
||||||
|
const t = h2hTally([1, 1, 1, 2, 1, 1, 2], [2, 2, 2, 1, 2, 2, 1], [])
|
||||||
|
const last = lastRounds(t, 5)
|
||||||
|
expect(last).toHaveLength(5)
|
||||||
|
expect(last.map((r) => r.round)).toEqual([3, 4, 5, 6, 7])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns fewer when the tally has fewer counted rounds', () => {
|
||||||
|
const t = h2hTally([1, 0], [2, 0], [])
|
||||||
|
expect(lastRounds(t, 5)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -29,6 +29,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
|
|||||||
poles: 0,
|
poles: 0,
|
||||||
form: [],
|
form: [],
|
||||||
cumulative: [],
|
cumulative: [],
|
||||||
|
round_positions: [],
|
||||||
teammate_wins: 0,
|
teammate_wins: 0,
|
||||||
teammate_losses: 0,
|
teammate_losses: 0,
|
||||||
...over,
|
...over,
|
||||||
|
|||||||
@@ -391,6 +391,7 @@ export interface ChampHubDriver {
|
|||||||
poles: number
|
poles: number
|
||||||
form: number[]
|
form: number[]
|
||||||
cumulative: number[]
|
cumulative: number[]
|
||||||
|
round_positions: number[]
|
||||||
teammate_wins: number
|
teammate_wins: number
|
||||||
teammate_losses: number
|
teammate_losses: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestProcessTopicRaceControlMessages(t *testing.T) {
|
||||||
state := live.NewState()
|
state := live.NewState()
|
||||||
data := json.RawMessage(`{
|
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 {
|
func normalizeSessionStatus(status string) string {
|
||||||
out := make([]rune, 0, len(status))
|
out := make([]rune, 0, len(status))
|
||||||
for _, r := range status {
|
for _, r := range status {
|
||||||
|
|||||||
435
internal/query/context.go
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
package query
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TemporalState describes where the fan is in the current season/weekend.
|
||||||
|
type TemporalState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TemporalNoSeason TemporalState = "no_season"
|
||||||
|
TemporalBetweenWeekends TemporalState = "between_weekends"
|
||||||
|
TemporalPreSession TemporalState = "pre_session"
|
||||||
|
TemporalSessionLive TemporalState = "session_live"
|
||||||
|
TemporalSessionSettling TemporalState = "session_settling"
|
||||||
|
TemporalBetweenSessions TemporalState = "between_sessions"
|
||||||
|
TemporalPostWeekend TemporalState = "post_weekend"
|
||||||
|
TemporalSeasonComplete TemporalState = "season_complete"
|
||||||
|
|
||||||
|
preSessionWindow = 48 * time.Hour
|
||||||
|
postWeekendWindow = 48 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// LiveEvidence is the small, transport-independent subset of FIA state needed
|
||||||
|
// by the context resolver. Active identity is authoritative over the schedule.
|
||||||
|
type LiveEvidence struct {
|
||||||
|
Active bool
|
||||||
|
Final bool
|
||||||
|
MeetingName string
|
||||||
|
CircuitName string
|
||||||
|
SessionName string
|
||||||
|
SessionType string
|
||||||
|
ObservedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContextAvailability is structured source state for a referenced session.
|
||||||
|
type ContextAvailability struct {
|
||||||
|
Schedule string `json:"schedule"`
|
||||||
|
LiveTransport string `json:"live_transport"`
|
||||||
|
LiveSession string `json:"live_session"`
|
||||||
|
Archive string `json:"archive"`
|
||||||
|
LocalAnalysis string `json:"local_analysis"`
|
||||||
|
Freshness string `json:"freshness"`
|
||||||
|
ObservedAt string `json:"observed_at,omitempty"`
|
||||||
|
Limitations []string `json:"limitations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContextSession couples a session identity with its availability contract.
|
||||||
|
type ContextSession struct {
|
||||||
|
Session models.Session `json:"session"`
|
||||||
|
Meeting *models.Meeting `json:"meeting,omitempty"`
|
||||||
|
Availability ContextAvailability `json:"availability"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeekendContext is the canonical local-first previous/current/next model.
|
||||||
|
type WeekendContext struct {
|
||||||
|
Season int `json:"season,omitempty"`
|
||||||
|
TemporalState TemporalState `json:"temporal_state"`
|
||||||
|
PreviousMeeting *models.Meeting `json:"previous_meeting,omitempty"`
|
||||||
|
FocusMeeting *models.Meeting `json:"focus_meeting,omitempty"`
|
||||||
|
NextMeeting *models.Meeting `json:"next_meeting,omitempty"`
|
||||||
|
PreviousCompletedSession *ContextSession `json:"previous_completed_session,omitempty"`
|
||||||
|
ActiveSession *ContextSession `json:"active_session,omitempty"`
|
||||||
|
NextSession *ContextSession `json:"next_session,omitempty"`
|
||||||
|
DefaultAnalysisSession *ContextSession `json:"default_analysis_session,omitempty"`
|
||||||
|
ChampionshipRound int `json:"championship_round"`
|
||||||
|
TotalChampionshipRounds int `json:"total_championship_rounds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type contextCandidate struct {
|
||||||
|
meeting store.Meeting
|
||||||
|
session store.Session
|
||||||
|
start time.Time
|
||||||
|
end time.Time
|
||||||
|
counts store.SessionDatasetCounts
|
||||||
|
complete bool
|
||||||
|
archived bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveWeekendContext computes the canonical context using only the domain
|
||||||
|
// store, the service clock, and optional in-memory FIA evidence.
|
||||||
|
func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext, error) {
|
||||||
|
now := s.now().UTC()
|
||||||
|
out := WeekendContext{TemporalState: TemporalNoSeason}
|
||||||
|
years, err := s.store.ListYears()
|
||||||
|
if err != nil || len(years) == 0 {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.Season = currentLocalSeason(years, now.Year())
|
||||||
|
meetings, err := s.store.ListMeetingsByYear(out.Season)
|
||||||
|
if err != nil {
|
||||||
|
return WeekendContext{}, err
|
||||||
|
}
|
||||||
|
if len(meetings) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
byMeeting := make(map[int][]store.Session, len(meetings))
|
||||||
|
var candidates []contextCandidate
|
||||||
|
for i := range meetings {
|
||||||
|
m := meetings[i]
|
||||||
|
sessions, listErr := s.store.ListSessionsByMeeting(m.MeetingKey)
|
||||||
|
if listErr != nil {
|
||||||
|
return WeekendContext{}, listErr
|
||||||
|
}
|
||||||
|
byMeeting[m.MeetingKey] = sessions
|
||||||
|
applySessionDisplayRange(&meetings[i], sessions)
|
||||||
|
m = meetings[i]
|
||||||
|
for _, sess := range sessions {
|
||||||
|
if m.IsCancelled || sess.IsCancelled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start, _ := parseContextTime(sess.DateStart)
|
||||||
|
end, _ := parseContextTime(sess.DateEnd)
|
||||||
|
if end.IsZero() && !start.IsZero() {
|
||||||
|
end = start.Add(3 * time.Hour)
|
||||||
|
}
|
||||||
|
counts, countErr := s.store.CountSessionDatasets(sess.SessionKey)
|
||||||
|
if countErr != nil {
|
||||||
|
return WeekendContext{}, countErr
|
||||||
|
}
|
||||||
|
archived := evidence.Final && liveMatches(evidence, m, sess)
|
||||||
|
candidates = append(candidates, contextCandidate{
|
||||||
|
meeting: m, session: sess, start: start, end: end, counts: counts,
|
||||||
|
complete: hasMeaningfulAnalysis(counts) || archived, archived: archived,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].start.Before(candidates[j].start) })
|
||||||
|
|
||||||
|
champMeetings := championshipMeetings(meetings, byMeeting)
|
||||||
|
out.TotalChampionshipRounds = len(champMeetings)
|
||||||
|
|
||||||
|
var active *contextCandidate
|
||||||
|
if evidence.Active {
|
||||||
|
for i := range candidates {
|
||||||
|
if liveMatches(evidence, candidates[i].meeting, candidates[i].session) {
|
||||||
|
active = &candidates[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if active == nil {
|
||||||
|
active = syntheticLiveCandidate(evidence, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var previous, next, defaultAnalysis *contextCandidate
|
||||||
|
for i := range candidates {
|
||||||
|
c := &candidates[i]
|
||||||
|
isActive := active != nil && active.session.SessionKey != 0 && c.session.SessionKey == active.session.SessionKey
|
||||||
|
completionEligible := c.start.IsZero() || !c.start.After(now) || c.archived
|
||||||
|
if !isActive && c.complete && completionEligible && (previous == nil || candidateTime(*c).After(candidateTime(*previous))) {
|
||||||
|
previous = c
|
||||||
|
}
|
||||||
|
if !isActive && c.complete && hasMeaningfulAnalysis(c.counts) && (c.start.IsZero() || !c.start.After(now)) && (defaultAnalysis == nil || candidateTime(*c).After(candidateTime(*defaultAnalysis))) {
|
||||||
|
defaultAnalysis = c
|
||||||
|
}
|
||||||
|
if !isActive && !c.start.IsZero() && !c.start.Before(now) && (next == nil || c.start.Before(next.start)) {
|
||||||
|
next = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if previous != nil {
|
||||||
|
out.PreviousCompletedSession = sessionRef(*previous, evidence, now)
|
||||||
|
out.PreviousMeeting = meetingModelByKey(meetings, previous.meeting.MeetingKey)
|
||||||
|
}
|
||||||
|
if defaultAnalysis != nil {
|
||||||
|
out.DefaultAnalysisSession = sessionRef(*defaultAnalysis, evidence, now)
|
||||||
|
}
|
||||||
|
if next != nil {
|
||||||
|
out.NextSession = sessionRef(*next, evidence, now)
|
||||||
|
out.NextMeeting = meetingModelByKey(meetings, next.meeting.MeetingKey)
|
||||||
|
}
|
||||||
|
if active != nil {
|
||||||
|
out.ActiveSession = sessionRef(*active, evidence, now)
|
||||||
|
out.FocusMeeting = out.ActiveSession.Meeting
|
||||||
|
out.TemporalState = TemporalSessionLive
|
||||||
|
} else {
|
||||||
|
out.FocusMeeting = chooseFocusMeeting(meetings, previous, next)
|
||||||
|
out.TemporalState = classifyTemporalState(now, previous, next, candidates, champMeetings, championshipScheduleUnknown(champMeetings, byMeeting))
|
||||||
|
}
|
||||||
|
if out.FocusMeeting != nil {
|
||||||
|
out.ChampionshipRound = championshipRound(champMeetings, int(out.FocusMeeting.MeetingKey))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentLocalSeason(years []int, current int) int {
|
||||||
|
for _, year := range years {
|
||||||
|
if year == current {
|
||||||
|
return year
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, year := range years {
|
||||||
|
if year < current {
|
||||||
|
return year
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return years[len(years)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseContextTime(value string) (time.Time, bool) {
|
||||||
|
if value == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05"} {
|
||||||
|
if parsed, err := time.Parse(layout, value); err == nil {
|
||||||
|
return parsed.UTC(), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasMeaningfulAnalysis(c store.SessionDatasetCounts) bool {
|
||||||
|
return c.Results > 0 || c.Laps > 0 || c.Stints > 0 || c.Positions > 0 || c.RaceControl > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidateTime(c contextCandidate) time.Time {
|
||||||
|
if !c.end.IsZero() {
|
||||||
|
return c.end
|
||||||
|
}
|
||||||
|
return c.start
|
||||||
|
}
|
||||||
|
|
||||||
|
func liveMatches(e LiveEvidence, m store.Meeting, s store.Session) bool {
|
||||||
|
meetingMatch := normalizedContains(e.MeetingName, m.MeetingName) || normalizedContains(e.CircuitName, m.CircuitShortName)
|
||||||
|
sessionMatch := normalizedEqual(e.SessionName, s.SessionName)
|
||||||
|
if normalizeIdentity(e.SessionName) == "" {
|
||||||
|
sessionMatch = normalizedEqual(e.SessionType, s.SessionType)
|
||||||
|
}
|
||||||
|
return meetingMatch && sessionMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedEqual(a, b string) bool {
|
||||||
|
return normalizeIdentity(a) != "" && normalizeIdentity(a) == normalizeIdentity(b)
|
||||||
|
}
|
||||||
|
func normalizedContains(a, b string) bool {
|
||||||
|
a, b = normalizeIdentity(a), normalizeIdentity(b)
|
||||||
|
return a != "" && b != "" && (strings.Contains(a, b) || strings.Contains(b, a))
|
||||||
|
}
|
||||||
|
func normalizeIdentity(v string) string {
|
||||||
|
return strings.Map(func(r rune) rune {
|
||||||
|
if r >= 'A' && r <= 'Z' {
|
||||||
|
return r + ('a' - 'A')
|
||||||
|
}
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func syntheticLiveCandidate(e LiveEvidence, now time.Time) *contextCandidate {
|
||||||
|
return &contextCandidate{meeting: store.Meeting{MeetingName: e.MeetingName, CircuitShortName: e.CircuitName}, session: store.Session{SessionName: e.SessionName, SessionType: e.SessionType, DateStart: now.Format(time.RFC3339)}, start: now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applySessionDisplayRange(m *store.Meeting, sessions []store.Session) {
|
||||||
|
var first, last time.Time
|
||||||
|
for _, sess := range sessions {
|
||||||
|
if sess.IsCancelled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start, ok := parseContextTime(sess.DateStart)
|
||||||
|
if ok && (first.IsZero() || start.Before(first)) {
|
||||||
|
first = start
|
||||||
|
}
|
||||||
|
end, ok := parseContextTime(sess.DateEnd)
|
||||||
|
if !ok {
|
||||||
|
end = start
|
||||||
|
}
|
||||||
|
if !end.IsZero() && (last.IsZero() || end.After(last)) {
|
||||||
|
last = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !first.IsZero() {
|
||||||
|
m.DateStart = first.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if !last.IsZero() {
|
||||||
|
m.DateEnd = last.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingModelByKey(meetings []store.Meeting, key int) *models.Meeting {
|
||||||
|
for _, meeting := range meetings {
|
||||||
|
if meeting.MeetingKey == key {
|
||||||
|
model := meetingToModel(meeting)
|
||||||
|
return &model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionRef(c contextCandidate, evidence LiveEvidence, now time.Time) *ContextSession {
|
||||||
|
session := sessionToModel(c.session)
|
||||||
|
meeting := meetingToModel(c.meeting)
|
||||||
|
availability := ContextAvailability{Schedule: "available", LiveSession: "inactive", Archive: "unavailable", Freshness: "fresh", Limitations: []string{}}
|
||||||
|
availability.LiveTransport = "unknown"
|
||||||
|
if c.session.SessionKey == 0 {
|
||||||
|
availability.Schedule = "unavailable"
|
||||||
|
availability.Limitations = append(availability.Limitations, "schedule_identity_unmatched")
|
||||||
|
}
|
||||||
|
if evidence.Active && liveMatches(evidence, c.meeting, c.session) {
|
||||||
|
availability.LiveTransport = "connected"
|
||||||
|
availability.LiveSession = "active"
|
||||||
|
if !evidence.ObservedAt.IsZero() {
|
||||||
|
availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.archived {
|
||||||
|
availability.Archive = "available"
|
||||||
|
if !evidence.ObservedAt.IsZero() {
|
||||||
|
availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.counts.Results > 0 && (c.counts.Laps+c.counts.Stints+c.counts.Positions+c.counts.RaceControl > 0) {
|
||||||
|
availability.LocalAnalysis = "complete"
|
||||||
|
} else if hasMeaningfulAnalysis(c.counts) || c.counts.Drivers+c.counts.StartingGrid+c.counts.Weather > 0 {
|
||||||
|
availability.LocalAnalysis = "partial"
|
||||||
|
} else if !c.start.IsZero() && c.start.After(now) {
|
||||||
|
availability.LocalAnalysis = "not_applicable"
|
||||||
|
} else {
|
||||||
|
availability.LocalAnalysis = "pending"
|
||||||
|
}
|
||||||
|
return &ContextSession{Session: session, Meeting: &meeting, Availability: availability}
|
||||||
|
}
|
||||||
|
|
||||||
|
func chooseFocusMeeting(meetings []store.Meeting, previous, next *contextCandidate) *models.Meeting {
|
||||||
|
if next != nil {
|
||||||
|
return meetingModelByKey(meetings, next.meeting.MeetingKey)
|
||||||
|
}
|
||||||
|
if previous != nil {
|
||||||
|
return meetingModelByKey(meetings, previous.meeting.MeetingKey)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyTemporalState(now time.Time, previous, next *contextCandidate, candidates []contextCandidate, championship []store.Meeting, scheduleUnknown bool) TemporalState {
|
||||||
|
var latestStarted *contextCandidate
|
||||||
|
for i := range candidates {
|
||||||
|
if !candidates[i].start.IsZero() && !candidates[i].start.After(now) && (latestStarted == nil || candidates[i].start.After(latestStarted.start)) {
|
||||||
|
latestStarted = &candidates[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Once a new meeting enters its preparation window, an ingest gap from an
|
||||||
|
// older meeting must not keep the product stuck in settling.
|
||||||
|
if next != nil && next.start.Sub(now) <= preSessionWindow && (latestStarted == nil || latestStarted.meeting.MeetingKey != next.meeting.MeetingKey) {
|
||||||
|
return TemporalPreSession
|
||||||
|
}
|
||||||
|
if latestStarted != nil && !latestStarted.complete && !latestStarted.end.IsZero() {
|
||||||
|
if now.Before(latestStarted.end) {
|
||||||
|
return TemporalPreSession
|
||||||
|
}
|
||||||
|
return TemporalSessionSettling
|
||||||
|
}
|
||||||
|
if previous != nil && next != nil && previous.meeting.MeetingKey == next.meeting.MeetingKey {
|
||||||
|
return TemporalBetweenSessions
|
||||||
|
}
|
||||||
|
if previous != nil && meetingFinalSession(*previous, candidates) && !candidateTime(*previous).After(now) && now.Sub(candidateTime(*previous)) <= postWeekendWindow {
|
||||||
|
return TemporalPostWeekend
|
||||||
|
}
|
||||||
|
if next != nil && next.start.Sub(now) <= preSessionWindow {
|
||||||
|
return TemporalPreSession
|
||||||
|
}
|
||||||
|
if next == nil && len(championship) > 0 && !scheduleUnknown {
|
||||||
|
return TemporalSeasonComplete
|
||||||
|
}
|
||||||
|
return TemporalBetweenWeekends
|
||||||
|
}
|
||||||
|
|
||||||
|
func championshipScheduleUnknown(meetings []store.Meeting, sessions map[int][]store.Session) bool {
|
||||||
|
for _, meeting := range meetings {
|
||||||
|
for _, session := range sessions[meeting.MeetingKey] {
|
||||||
|
if !session.IsCancelled && isChampionshipRace(session) {
|
||||||
|
if _, ok := parseContextTime(session.DateStart); !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingFinalSession(previous contextCandidate, candidates []contextCandidate) bool {
|
||||||
|
latest := previous.start
|
||||||
|
for _, c := range candidates {
|
||||||
|
if c.meeting.MeetingKey == previous.meeting.MeetingKey && c.start.After(latest) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func championshipMeetings(meetings []store.Meeting, sessions map[int][]store.Session) []store.Meeting {
|
||||||
|
var out []store.Meeting
|
||||||
|
for _, m := range meetings {
|
||||||
|
if m.IsCancelled || isTestMeeting(m) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, sess := range sessions[m.MeetingKey] {
|
||||||
|
if !sess.IsCancelled && isChampionshipRace(sess) {
|
||||||
|
out = append(out, m)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
a, _ := parseContextTime(out[i].DateStart)
|
||||||
|
b, _ := parseContextTime(out[j].DateStart)
|
||||||
|
return a.Before(b)
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTestMeeting(m store.Meeting) bool {
|
||||||
|
n := strings.ToLower(m.MeetingName + " " + m.MeetingOfficialName)
|
||||||
|
return strings.Contains(n, "test")
|
||||||
|
}
|
||||||
|
func isChampionshipRace(s store.Session) bool {
|
||||||
|
n := strings.ToLower(s.SessionName)
|
||||||
|
t := strings.ToLower(s.SessionType)
|
||||||
|
return (n == "race" || t == "race") && !strings.Contains(n, "sprint") && !strings.Contains(t, "sprint")
|
||||||
|
}
|
||||||
|
func championshipRound(meetings []store.Meeting, key int) int {
|
||||||
|
for i, m := range meetings {
|
||||||
|
if m.MeetingKey == key {
|
||||||
|
return i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
353
internal/query/context_test.go
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
package query
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func contextService(t *testing.T, now time.Time) *Service {
|
||||||
|
t.Helper()
|
||||||
|
base := openTestService(t)
|
||||||
|
return NewServiceWithClock(base.store, func() time.Time { return now })
|
||||||
|
}
|
||||||
|
|
||||||
|
func addContextMeeting(t *testing.T, svc *Service, key int, name, start, end string, cancelled bool) {
|
||||||
|
t.Helper()
|
||||||
|
if err := svc.store.UpsertMeeting(store.Meeting{MeetingKey: key, MeetingName: name, MeetingOfficialName: name, CircuitShortName: name, Year: 2026, DateStart: start, DateEnd: end, IsCancelled: cancelled}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addContextSession(t *testing.T, svc *Service, key, meeting int, name, start, end string, cancelled bool) {
|
||||||
|
t.Helper()
|
||||||
|
if err := svc.store.UpsertSession(store.Session{SessionKey: key, MeetingKey: meeting, SessionName: name, SessionType: name, DateStart: start, DateEnd: end, IsCancelled: cancelled}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeContextSession(t *testing.T, svc *Service, key, meeting int) {
|
||||||
|
t.Helper()
|
||||||
|
if err := svc.store.UpsertSessionResult(store.SessionResult{SessionKey: key, MeetingKey: meeting, DriverNumber: 1, Position: 1}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextTemporalStates(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
now string
|
||||||
|
seed func(*testing.T, *Service)
|
||||||
|
evidence LiveEvidence
|
||||||
|
want TemporalState
|
||||||
|
}{
|
||||||
|
{name: "no season", now: "2026-06-01T12:00:00Z", want: TemporalNoSeason},
|
||||||
|
{name: "between weekends", now: "2026-06-10T12:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||||
|
addContextMeeting(t, s, 1, "Monaco Grand Prix", "2026-06-01T09:00:00Z", "2026-06-02T16:00:00Z", false)
|
||||||
|
addContextSession(t, s, 11, 1, "Race", "2026-06-02T14:00:00Z", "2026-06-02T16:00:00Z", false)
|
||||||
|
completeContextSession(t, s, 11, 1)
|
||||||
|
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
|
||||||
|
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
}, want: TemporalBetweenWeekends},
|
||||||
|
{name: "pre session", now: "2026-06-19T12:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||||
|
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
|
||||||
|
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
}, want: TemporalPreSession},
|
||||||
|
{name: "session live overrides schedule", now: "2026-06-20T12:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||||
|
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
|
||||||
|
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
}, evidence: LiveEvidence{Active: true, MeetingName: "Canadian Grand Prix", CircuitName: "Canada Grand Prix", SessionName: "Practice 1", SessionType: "Practice 1"}, want: TemporalSessionLive},
|
||||||
|
{name: "session settling", now: "2026-06-20T11:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||||
|
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
|
||||||
|
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
}, want: TemporalSessionSettling},
|
||||||
|
{name: "between sessions", now: "2026-06-20T11:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||||
|
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
|
||||||
|
completeContextSession(t, s, 21, 2)
|
||||||
|
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
}, want: TemporalBetweenSessions},
|
||||||
|
{name: "post weekend", now: "2026-06-22T18:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||||
|
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
completeContextSession(t, s, 22, 2)
|
||||||
|
}, want: TemporalPostWeekend},
|
||||||
|
{name: "season complete", now: "2026-06-30T12:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||||
|
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
completeContextSession(t, s, 22, 2)
|
||||||
|
}, want: TemporalSeasonComplete},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, tt.now)
|
||||||
|
svc := contextService(t, now)
|
||||||
|
if tt.seed != nil {
|
||||||
|
tt.seed(t, svc)
|
||||||
|
}
|
||||||
|
got, err := svc.ResolveWeekendContext(tt.evidence)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != tt.want {
|
||||||
|
t.Fatalf("state = %s, want %s; context=%+v", got.TemporalState, tt.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextTruthRules(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-10T12:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "Pre-Season Testing", "2026-02-01T00:00:00Z", "2026-02-03T00:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 10, 1, "Race", "2026-02-03T10:00:00Z", "2026-02-03T12:00:00Z", false)
|
||||||
|
addContextMeeting(t, svc, 2, "Cancelled Grand Prix", "2026-03-01T00:00:00Z", "2026-03-03T00:00:00Z", true)
|
||||||
|
addContextSession(t, svc, 20, 2, "Race", "2026-03-03T10:00:00Z", "2026-03-03T12:00:00Z", false)
|
||||||
|
addContextMeeting(t, svc, 3, "British Grand Prix", "2026-07-01T00:00:00Z", "2026-07-05T00:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 30, 3, "Practice 1", "2026-07-03T09:00:00Z", "2026-07-03T10:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 31, 3, "Sprint", "2026-07-04T10:00:00Z", "2026-07-04T11:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 32, 3, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
completeContextSession(t, svc, 32, 3)
|
||||||
|
addContextMeeting(t, svc, 4, "Belgian Grand Prix", "2026-07-16T00:00:00Z", "2026-07-18T00:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 40, 4, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 41, 4, "Cancelled Practice", "2026-07-18T09:00:00Z", "2026-07-18T10:00:00Z", true)
|
||||||
|
addContextSession(t, svc, 42, 4, "Race", "2026-07-19T14:00:00Z", "2026-07-19T16:00:00Z", false)
|
||||||
|
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TotalChampionshipRounds != 2 || got.ChampionshipRound != 2 {
|
||||||
|
t.Fatalf("rounds = %d/%d, want 2/2", got.ChampionshipRound, got.TotalChampionshipRounds)
|
||||||
|
}
|
||||||
|
if got.DefaultAnalysisSession == nil || got.DefaultAnalysisSession.Session.SessionKey != 32 {
|
||||||
|
t.Fatalf("default analysis = %+v, want completed race 32", got.DefaultAnalysisSession)
|
||||||
|
}
|
||||||
|
if got.NextSession == nil || got.NextSession.Session.SessionKey != 40 {
|
||||||
|
t.Fatalf("next = %+v, want 40", got.NextSession)
|
||||||
|
}
|
||||||
|
if got.NextMeeting.DateStart != "2026-07-17T09:00:00Z" || got.NextMeeting.DateEnd != "2026-07-19T16:00:00Z" {
|
||||||
|
t.Fatalf("display range = %s..%s", got.NextMeeting.DateStart, got.NextMeeting.DateEnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextPassedTimeDoesNotCompleteSession(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-05T18:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T00:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.PreviousCompletedSession != nil || got.DefaultAnalysisSession != nil {
|
||||||
|
t.Fatalf("passed schedule was treated complete: %+v", got)
|
||||||
|
}
|
||||||
|
archive, err := svc.ResolveWeekendContext(LiveEvidence{Final: true, MeetingName: "British Grand Prix", CircuitName: "British Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if archive.PreviousCompletedSession == nil || archive.PreviousCompletedSession.Availability.Archive != "available" {
|
||||||
|
t.Fatalf("final archive not used: %+v", archive)
|
||||||
|
}
|
||||||
|
if archive.DefaultAnalysisSession != nil {
|
||||||
|
t.Fatal("archive without local analysis must not become default analysis")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextNeverUsesFutureAnalysis(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T00:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
completeContextSession(t, svc, 11, 1) // bad/preloaded data must not make a future session canonical
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.PreviousCompletedSession != nil || got.DefaultAnalysisSession != nil {
|
||||||
|
t.Fatalf("future analysis selected: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression for the v0.4.0 release blocker (#90). Production held a run
|
||||||
|
// British race (11326) and an unrun Belgian race (11334) two weekends apart.
|
||||||
|
// Analysis must resolve to the race that actually happened, even when the
|
||||||
|
// later meeting carries preloaded rows.
|
||||||
|
func TestResolveWeekendContextPrefersRunRaceOverLaterUnrunRace(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-13T12:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1289, "British Grand Prix", "2026-07-03T11:30:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 11326, 1289, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
completeContextSession(t, svc, 11326, 1289)
|
||||||
|
addContextMeeting(t, svc, 1290, "Belgian Grand Prix", "2026-07-17T11:30:00Z", "2026-07-19T15:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 11334, 1290, "Race", "2026-07-19T13:00:00Z", "2026-07-19T15:00:00Z", false)
|
||||||
|
completeContextSession(t, svc, 11334, 1290)
|
||||||
|
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.DefaultAnalysisSession == nil {
|
||||||
|
t.Fatal("no analysis session resolved")
|
||||||
|
}
|
||||||
|
if key := got.DefaultAnalysisSession.Session.SessionKey; key != 11326 {
|
||||||
|
t.Fatalf("analysis session = %d, want 11326 (British); 11334 is the unrun Belgian race", key)
|
||||||
|
}
|
||||||
|
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Session.SessionKey != 11326 {
|
||||||
|
t.Fatalf("previous completed = %+v, want 11326", got.PreviousCompletedSession)
|
||||||
|
}
|
||||||
|
if got.NextSession == nil || got.NextSession.Session.SessionKey != 11334 {
|
||||||
|
t.Fatalf("next session = %+v, want 11334", got.NextSession)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextSprintWeekendHandoff(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-04T12:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 11, 1, "Sprint", "2026-07-04T10:00:00Z", "2026-07-04T11:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
completeContextSession(t, svc, 11, 1)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != TemporalBetweenSessions || got.PreviousCompletedSession.Session.SessionKey != 11 || got.NextSession.Session.SessionKey != 12 {
|
||||||
|
t.Fatalf("sprint handoff = %+v", got)
|
||||||
|
}
|
||||||
|
if got.TotalChampionshipRounds != 1 {
|
||||||
|
t.Fatalf("sprint created extra championship round: %d", got.TotalChampionshipRounds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextBoundaryTimestamps(t *testing.T) {
|
||||||
|
t.Run("pre-session window is inclusive", func(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-01T09:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 11, 1, "Practice 1", "2026-07-03T09:00:00Z", "2026-07-03T10:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != TemporalPreSession {
|
||||||
|
t.Fatalf("state = %s", got.TemporalState)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("scheduled end enters settling", func(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-03T10:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 11, 1, "Practice 1", "2026-07-03T09:00:00Z", "2026-07-03T10:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != TemporalSessionSettling {
|
||||||
|
t.Fatalf("state = %s", got.TemporalState)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("post-weekend window is inclusive", func(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-07T16:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
completeContextSession(t, svc, 12, 1)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != TemporalPostWeekend {
|
||||||
|
t.Fatalf("state = %s", got.TemporalState)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextPartialFutureSchedule(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "", false)
|
||||||
|
addContextSession(t, svc, 11, 1, "Practice 1", "", "", false)
|
||||||
|
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "", false)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.NextSession == nil || got.NextSession.Session.SessionKey != 12 {
|
||||||
|
t.Fatalf("partial schedule next = %+v", got.NextSession)
|
||||||
|
}
|
||||||
|
if got.FocusMeeting == nil || got.FocusMeeting.DateStart != "2026-07-05T14:00:00Z" || got.FocusMeeting.DateEnd != "2026-07-05T14:00:00Z" {
|
||||||
|
t.Fatalf("partial display range = %+v", got.FocusMeeting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextActiveSessionIsNotCompletedOrDefault(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-05T15:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 10, 1, "Qualifying", "2026-07-04T14:00:00Z", "2026-07-04T15:00:00Z", false)
|
||||||
|
completeContextSession(t, svc, 10, 1)
|
||||||
|
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
completeContextSession(t, svc, 11, 1)
|
||||||
|
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "British Grand Prix", CircuitName: "British Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.ActiveSession == nil || got.ActiveSession.Session.SessionKey != 11 {
|
||||||
|
t.Fatalf("active = %+v", got.ActiveSession)
|
||||||
|
}
|
||||||
|
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Session.SessionKey != 10 {
|
||||||
|
t.Fatalf("previous = %+v, want earlier completed session", got.PreviousCompletedSession)
|
||||||
|
}
|
||||||
|
if got.DefaultAnalysisSession == nil || got.DefaultAnalysisSession.Session.SessionKey != 10 {
|
||||||
|
t.Fatalf("default = %+v, want earlier completed session", got.DefaultAnalysisSession)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextOldIncompleteSessionDoesNotSuppressNextWeekend(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-16T12:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextMeeting(t, svc, 2, "Belgian Grand Prix", "2026-07-17T09:00:00Z", "2026-07-19T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 21, 2, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 22, 2, "Race", "2026-07-19T14:00:00Z", "2026-07-19T16:00:00Z", false)
|
||||||
|
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != TemporalPreSession {
|
||||||
|
t.Fatalf("state = %s, want %s; context=%+v", got.TemporalState, TemporalPreSession, got)
|
||||||
|
}
|
||||||
|
if got.FocusMeeting == nil || got.FocusMeeting.MeetingKey != 2 {
|
||||||
|
t.Fatalf("focus = %+v, want Belgian weekend", got.FocusMeeting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextMissingScheduleDoesNotClaimSeasonComplete(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "", "", false)
|
||||||
|
addContextSession(t, svc, 11, 1, "Race", "", "", false)
|
||||||
|
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != TemporalBetweenWeekends {
|
||||||
|
t.Fatalf("state = %s, want limited %s context", got.TemporalState, TemporalBetweenWeekends)
|
||||||
|
}
|
||||||
|
if got.TotalChampionshipRounds != 1 {
|
||||||
|
t.Fatalf("total rounds = %d, want scheduled round retained", got.TotalChampionshipRounds)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package query
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/chapters"
|
"github.com/AmanTahiliani/box-box/internal/chapters"
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
@@ -12,11 +13,20 @@ import (
|
|||||||
// Service assembles store-backed read models.
|
// Service assembles store-backed read models.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
store *store.Store
|
store *store.Store
|
||||||
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a query service over a domain store.
|
// NewService creates a query service over a domain store.
|
||||||
func NewService(st *store.Store) *Service {
|
func NewService(st *store.Store) *Service {
|
||||||
return &Service{store: st}
|
return NewServiceWithClock(st, time.Now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServiceWithClock creates a query service with an injected clock.
|
||||||
|
func NewServiceWithClock(st *store.Store, now func() time.Time) *Service {
|
||||||
|
if now == nil {
|
||||||
|
now = time.Now
|
||||||
|
}
|
||||||
|
return &Service{store: st, now: now}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnrichedResult is a session result with driver identity fields.
|
// EnrichedResult is a session result with driver identity fields.
|
||||||
|
|||||||
@@ -616,6 +616,7 @@ type champHubDriver struct {
|
|||||||
Poles int `json:"poles"`
|
Poles int `json:"poles"`
|
||||||
Form []float64 `json:"form"` // last 5 races' points
|
Form []float64 `json:"form"` // last 5 races' points
|
||||||
Cumulative []float64 `json:"cumulative"` // running total per completed round
|
Cumulative []float64 `json:"cumulative"` // running total per completed round
|
||||||
|
RoundPositions []int `json:"round_positions"` // finishing position per completed round (0 = no result)
|
||||||
TeammateWins int `json:"teammate_wins"`
|
TeammateWins int `json:"teammate_wins"`
|
||||||
TeammateLosses int `json:"teammate_losses"`
|
TeammateLosses int `json:"teammate_losses"`
|
||||||
}
|
}
|
||||||
@@ -995,6 +996,10 @@ func aggregateChampionshipHub(
|
|||||||
if len(form) > 5 {
|
if len(form) > 5 {
|
||||||
form = form[len(form)-5:]
|
form = form[len(form)-5:]
|
||||||
}
|
}
|
||||||
|
roundPositions := make([]int, completed)
|
||||||
|
for round := 1; round <= completed; round++ {
|
||||||
|
roundPositions[round-1] = a.finishByRound[round]
|
||||||
|
}
|
||||||
info := driverInfo[c.DriverNumber]
|
info := driverInfo[c.DriverNumber]
|
||||||
drivers = append(drivers, champHubDriver{
|
drivers = append(drivers, champHubDriver{
|
||||||
DriverNumber: c.DriverNumber,
|
DriverNumber: c.DriverNumber,
|
||||||
@@ -1009,6 +1014,7 @@ func aggregateChampionshipHub(
|
|||||||
Poles: a.poles,
|
Poles: a.poles,
|
||||||
Form: form,
|
Form: form,
|
||||||
Cumulative: cumulative[c.DriverNumber],
|
Cumulative: cumulative[c.DriverNumber],
|
||||||
|
RoundPositions: roundPositions,
|
||||||
TeammateWins: twins[c.DriverNumber],
|
TeammateWins: twins[c.DriverNumber],
|
||||||
TeammateLosses: tloss[c.DriverNumber],
|
TeammateLosses: tloss[c.DriverNumber],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,20 +17,22 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
|||||||
1: {DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull", TeamColour: "3671c6"},
|
1: {DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull", TeamColour: "3671c6"},
|
||||||
2: {DriverNumber: 2, NameAcronym: "PER", FullName: "Sergio Perez", TeamName: "Red Bull", TeamColour: "3671c6"},
|
2: {DriverNumber: 2, NameAcronym: "PER", FullName: "Sergio Perez", TeamName: "Red Bull", TeamColour: "3671c6"},
|
||||||
3: {DriverNumber: 3, NameAcronym: "HAM", FullName: "Lewis Hamilton", TeamName: "Mercedes", TeamColour: "27f4d2"},
|
3: {DriverNumber: 3, NameAcronym: "HAM", FullName: "Lewis Hamilton", TeamName: "Mercedes", TeamColour: "27f4d2"},
|
||||||
|
4: {DriverNumber: 4, NameAcronym: "NOR", FullName: "Lando Norris", TeamName: "McLaren", TeamColour: "ff8000"},
|
||||||
}
|
}
|
||||||
|
|
||||||
champ := []models.ChampionshipDriver{
|
champ := []models.ChampionshipDriver{
|
||||||
{DriverNumber: 1, PointsCurrent: 50, PositionCurrent: 1, SessionKey: 99},
|
{DriverNumber: 1, PointsCurrent: 50, PositionCurrent: 1, SessionKey: 99},
|
||||||
{DriverNumber: 3, PointsCurrent: 33, PositionCurrent: 2, SessionKey: 99},
|
{DriverNumber: 3, PointsCurrent: 33, PositionCurrent: 2, SessionKey: 99},
|
||||||
{DriverNumber: 2, PointsCurrent: 30, PositionCurrent: 3, SessionKey: 99},
|
{DriverNumber: 2, PointsCurrent: 30, PositionCurrent: 3, SessionKey: 99},
|
||||||
|
{DriverNumber: 4, PointsCurrent: 12, PositionCurrent: 4, SessionKey: 99},
|
||||||
}
|
}
|
||||||
teams := []models.ChampionshipTeam{
|
teams := []models.ChampionshipTeam{
|
||||||
{TeamName: "Red Bull", PointsCurrent: 80, PositionCurrent: 1},
|
{TeamName: "Red Bull", PointsCurrent: 80, PositionCurrent: 1},
|
||||||
{TeamName: "Mercedes", PointsCurrent: 33, PositionCurrent: 2},
|
{TeamName: "Mercedes", PointsCurrent: 33, PositionCurrent: 2},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Round 1: VER P1(25), HAM P2(18), PER P3(15). Pole: VER.
|
// Round 1: VER P1(25), HAM P2(18), PER P3(15). Pole: VER. NOR absent.
|
||||||
// Round 2: VER P1(25), PER P2(18), HAM P3(15). Pole: HAM.
|
// Round 2: VER P1(25), PER P2(18), HAM P3(15), NOR P4(12). Pole: HAM.
|
||||||
races := []meetingRace{
|
races := []meetingRace{
|
||||||
{
|
{
|
||||||
Meeting: models.Meeting{MeetingName: "Bahrain GP"},
|
Meeting: models.Meeting{MeetingName: "Bahrain GP"},
|
||||||
@@ -39,7 +41,7 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Meeting: models.Meeting{MeetingName: "Saudi GP"},
|
Meeting: models.Meeting{MeetingName: "Saudi GP"},
|
||||||
Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(2, 2, 18), raceResult(3, 3, 15)},
|
Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(2, 2, 18), raceResult(3, 3, 15), raceResult(4, 4, 12)},
|
||||||
Grid: []models.StartingGrid{{DriverNumber: 3, Position: 1}},
|
Grid: []models.StartingGrid{{DriverNumber: 3, Position: 1}},
|
||||||
},
|
},
|
||||||
// Round 3: not yet run (no results) — should not count as completed.
|
// Round 3: not yet run (no results) — should not count as completed.
|
||||||
@@ -67,9 +69,9 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
|||||||
t.Errorf("round labels = %v, want [R1 R2]", resp.RoundLabels)
|
t.Errorf("round labels = %v, want [R1 R2]", resp.RoundLabels)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drivers are sorted by official position: VER, HAM, PER.
|
// Drivers are sorted by official position: VER, HAM, PER, NOR.
|
||||||
if len(resp.Drivers) != 3 {
|
if len(resp.Drivers) != 4 {
|
||||||
t.Fatalf("drivers = %d, want 3", len(resp.Drivers))
|
t.Fatalf("drivers = %d, want 4", len(resp.Drivers))
|
||||||
}
|
}
|
||||||
ver := resp.Drivers[0]
|
ver := resp.Drivers[0]
|
||||||
if ver.NameAcronym != "VER" || ver.Position != 1 {
|
if ver.NameAcronym != "VER" || ver.Position != 1 {
|
||||||
@@ -91,6 +93,9 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
|||||||
if len(ver.Cumulative) != 2 || ver.Cumulative[0] != 25 || ver.Cumulative[1] != 50 {
|
if len(ver.Cumulative) != 2 || ver.Cumulative[0] != 25 || ver.Cumulative[1] != 50 {
|
||||||
t.Errorf("VER cumulative = %v, want [25 50]", ver.Cumulative)
|
t.Errorf("VER cumulative = %v, want [25 50]", ver.Cumulative)
|
||||||
}
|
}
|
||||||
|
if len(ver.RoundPositions) != 2 || ver.RoundPositions[0] != 1 || ver.RoundPositions[1] != 1 {
|
||||||
|
t.Errorf("VER round positions = %v, want [1 1]", ver.RoundPositions)
|
||||||
|
}
|
||||||
// VER beat teammate PER in both rounds.
|
// VER beat teammate PER in both rounds.
|
||||||
if ver.TeammateWins != 2 || ver.TeammateLosses != 0 {
|
if ver.TeammateWins != 2 || ver.TeammateLosses != 0 {
|
||||||
t.Errorf("VER h2h = %d-%d, want 2-0", ver.TeammateWins, ver.TeammateLosses)
|
t.Errorf("VER h2h = %d-%d, want 2-0", ver.TeammateWins, ver.TeammateLosses)
|
||||||
@@ -109,6 +114,9 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
|||||||
if per.Poles != 0 {
|
if per.Poles != 0 {
|
||||||
t.Errorf("PER poles = %d, want 0", per.Poles)
|
t.Errorf("PER poles = %d, want 0", per.Poles)
|
||||||
}
|
}
|
||||||
|
if len(per.RoundPositions) != 2 || per.RoundPositions[0] != 3 || per.RoundPositions[1] != 2 {
|
||||||
|
t.Errorf("PER round positions = %v, want [3 2]", per.RoundPositions)
|
||||||
|
}
|
||||||
|
|
||||||
// HAM has no teammate in the data — no h2h recorded.
|
// HAM has no teammate in the data — no h2h recorded.
|
||||||
var ham champHubDriver
|
var ham champHubDriver
|
||||||
@@ -124,6 +132,17 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
|||||||
t.Errorf("HAM poles = %d, want 1", ham.Poles)
|
t.Errorf("HAM poles = %d, want 1", ham.Poles)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOR missed round 1 — position 0 marks the absent round.
|
||||||
|
var nor champHubDriver
|
||||||
|
for _, d := range resp.Drivers {
|
||||||
|
if d.NameAcronym == "NOR" {
|
||||||
|
nor = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(nor.RoundPositions) != 2 || nor.RoundPositions[0] != 0 || nor.RoundPositions[1] != 4 {
|
||||||
|
t.Errorf("NOR round positions = %v, want [0 4]", nor.RoundPositions)
|
||||||
|
}
|
||||||
|
|
||||||
// Teams sorted by position; Red Bull wins = VER(2) + PER(0) = 2.
|
// Teams sorted by position; Red Bull wins = VER(2) + PER(0) = 2.
|
||||||
if len(resp.Teams) != 2 {
|
if len(resp.Teams) != 2 {
|
||||||
t.Fatalf("teams = %d, want 2", len(resp.Teams))
|
t.Fatalf("teams = %d, want 2", len(resp.Teams))
|
||||||
|
|||||||
52
internal/web/context.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/live"
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/query"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleWeekendContext(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
if !s.hasLocalQuery() {
|
||||||
|
writeJSON(w, query.WeekendContext{TemporalState: query.TemporalNoSeason})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state := s.hub.State()
|
||||||
|
evidence := query.LiveEvidence{}
|
||||||
|
if state.IsLive && state.Data != nil {
|
||||||
|
evidence = liveEvidence(state.Data, true, false)
|
||||||
|
} else if state.LastSnapshot != nil && terminalSessionStatus(state.LastSnapshot.SessionStatus) {
|
||||||
|
evidence = liveEvidence(state.LastSnapshot, false, true)
|
||||||
|
if state.LastSnapshotAt != nil {
|
||||||
|
evidence.ObservedAt = *state.LastSnapshotAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context, err := s.query.ResolveWeekendContext(evidence)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, http.StatusInternalServerError, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
func liveEvidence(data *live.LiveStreamData, active, final bool) query.LiveEvidence {
|
||||||
|
return query.LiveEvidence{Active: active, Final: final, MeetingName: data.Session.MeetingName, CircuitName: data.Session.CircuitName, SessionName: data.Session.SessionName, SessionType: data.Session.SessionType}
|
||||||
|
}
|
||||||
|
|
||||||
|
func terminalSessionStatus(status string) bool {
|
||||||
|
normalized := strings.ToLower(strings.Map(func(r rune) rune {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}, status))
|
||||||
|
switch normalized {
|
||||||
|
case "finished", "finalised", "finalized", "ended", "aborted":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
113
internal/web/context_test.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/live"
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/query"
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openContextStore(t *testing.T) *store.Store {
|
||||||
|
t.Helper()
|
||||||
|
st, err := store.Open(filepath.Join(t.TempDir(), "context.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = st.Close() })
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedContextHandler(t *testing.T, st *store.Store) {
|
||||||
|
t.Helper()
|
||||||
|
if err := st.UpsertMeeting(store.Meeting{MeetingKey: 1, MeetingName: "British Grand Prix", CircuitShortName: "Silverstone", Year: 2026, DateStart: "2026-07-03T09:00:00Z", DateEnd: "2026-07-05T16:00:00Z"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertSession(store.Session{SessionKey: 11, MeetingKey: 1, SessionName: "Race", SessionType: "Race", DateStart: "2026-07-05T14:00:00Z", DateEnd: "2026-07-05T16:00:00Z"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeekendContextHandlerWithoutStoreReturnsNoSeason(t *testing.T) {
|
||||||
|
s := NewServer(nil, 0, nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", rr.Code)
|
||||||
|
}
|
||||||
|
var got query.WeekendContext
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != query.TemporalNoSeason {
|
||||||
|
t.Fatalf("state = %s", got.TemporalState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeekendContextHandlerUsesLiveHubIdentityWithoutOpenF1(t *testing.T) {
|
||||||
|
st := openContextStore(t)
|
||||||
|
seedContextHandler(t, st)
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-05T13:55:00Z")
|
||||||
|
s := NewServer(nil, 0, st) // a nil OpenF1 client makes any REST dependency panic
|
||||||
|
s.query = query.NewServiceWithClock(st, func() time.Time { return now })
|
||||||
|
s.hub.applySnapshot(live.LiveStreamData{SessionStatus: "Started", Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", CircuitName: "Silverstone", SessionName: "Race", SessionType: "Race"}}, now)
|
||||||
|
|
||||||
|
handler, err := s.routes()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
var got query.WeekendContext
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != query.TemporalSessionLive || got.ActiveSession == nil || got.ActiveSession.Session.SessionKey != 11 {
|
||||||
|
t.Fatalf("live context = %+v", got)
|
||||||
|
}
|
||||||
|
if got.ActiveSession.Availability.LiveTransport != "connected" || got.ActiveSession.Availability.LiveSession != "active" {
|
||||||
|
t.Fatalf("availability = %+v", got.ActiveSession.Availability)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeekendContextHandlerUsesTerminalArchiveAsCompletionEvidence(t *testing.T) {
|
||||||
|
st := openContextStore(t)
|
||||||
|
seedContextHandler(t, st)
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-05T16:05:00Z")
|
||||||
|
s := NewServer(nil, 0, st)
|
||||||
|
s.query = query.NewServiceWithClock(st, func() time.Time { return now })
|
||||||
|
s.hub.applySnapshot(live.LiveStreamData{SessionStatus: "Finished", Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", CircuitName: "Silverstone", SessionName: "Race", SessionType: "Race"}}, now)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
|
||||||
|
var got query.WeekendContext
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Availability.Archive != "available" {
|
||||||
|
t.Fatalf("archive context = %+v", got)
|
||||||
|
}
|
||||||
|
if got.DefaultAnalysisSession != nil {
|
||||||
|
t.Fatal("archive-only session must not become local default analysis")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTerminalSessionStatus(t *testing.T) {
|
||||||
|
for _, status := range []string{"Finished", "Finalised", "ENDED", "Aborted"} {
|
||||||
|
if !terminalSessionStatus(status) {
|
||||||
|
t.Errorf("%q should be terminal", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, status := range []string{"Started", "Resumed", "Inactive", ""} {
|
||||||
|
if terminalSessionStatus(status) {
|
||||||
|
t.Errorf("%q should not be terminal", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,7 +134,7 @@ func (h *SSEHub) applySnapshot(data live.LiveStreamData, now time.Time) liveStat
|
|||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
if live.SessionStatusIsActive(data.SessionStatus) {
|
if snapshotIsLive(data) {
|
||||||
h.isLive = true
|
h.isLive = true
|
||||||
h.activeSnapshot = &data
|
h.activeSnapshot = &data
|
||||||
if data.PositionUpdated && len(data.Positions) > 0 {
|
if data.PositionUpdated && len(data.Positions) > 0 {
|
||||||
@@ -208,6 +208,40 @@ func (h *SSEHub) stateLocked() liveStatePayload {
|
|||||||
return payload
|
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 {
|
func hasLiveSnapshotData(data live.LiveStreamData) bool {
|
||||||
return len(data.Drivers) > 0 ||
|
return len(data.Drivers) > 0 ||
|
||||||
len(data.DriverInfo) > 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) {
|
func TestHandleLiveStateKeepsArchiveOutOfActiveData(t *testing.T) {
|
||||||
hub := newSSEHub()
|
hub := newSSEHub()
|
||||||
now := time.Date(2026, 7, 4, 14, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 7, 4, 14, 0, 0, 0, time.UTC)
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ func (s *Server) routes() (http.Handler, error) {
|
|||||||
|
|
||||||
// REST API — /api/v1/laps/comparison must be registered before /api/v1/laps
|
// REST API — /api/v1/laps/comparison must be registered before /api/v1/laps
|
||||||
// because Go's ServeMux uses longest-prefix matching.
|
// because Go's ServeMux uses longest-prefix matching.
|
||||||
|
mux.HandleFunc("/api/v1/weekend-context", s.handleWeekendContext)
|
||||||
mux.HandleFunc("/api/v1/race-hub", s.handleRaceHub)
|
mux.HandleFunc("/api/v1/race-hub", s.handleRaceHub)
|
||||||
mux.HandleFunc("/api/v1/seasons", s.handleSeasons)
|
mux.HandleFunc("/api/v1/seasons", s.handleSeasons)
|
||||||
mux.HandleFunc("/api/v1/weekend", s.handleWeekend)
|
mux.HandleFunc("/api/v1/weekend", s.handleWeekend)
|
||||||
|
|||||||
@@ -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.describe('Live Timing (mocked snapshot)', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.route('**/api/v1/live/state', (route) =>
|
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.describe('Live Timing (no session)', () => {
|
||||||
test('shows the empty state when the feed has no snapshot', async ({ page }) => {
|
test('shows the empty state when the feed has no snapshot', async ({ page }) => {
|
||||||
await page.goto('/live')
|
await page.goto('/live')
|
||||||
|
|||||||
@@ -39,7 +39,17 @@ test.describe('Race Hub Weekend Workspace', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole('img', { name: 'Position evolution chart' }),
|
page.getByRole('img', { name: 'Position evolution chart' }),
|
||||||
).toBeVisible()
|
).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 }) => {
|
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()
|
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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
|
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
|
||||||
await page.getByRole('tab', { name: 'Race Story' }).click()
|
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()
|
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)
|
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> {
|
export async function gotoDataLibraryReady(page: Page): Promise<void> {
|
||||||
await page.goto('/admin')
|
await page.goto('/admin')
|
||||||
await expect(page.getByTestId('data-library')).toBeVisible()
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||