mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Compare commits
1 Commits
feat/issue
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82abbd6b35 |
@@ -11,7 +11,6 @@ import type {
|
|||||||
Meeting,
|
Meeting,
|
||||||
NewsItem,
|
NewsItem,
|
||||||
RaceHub,
|
RaceHub,
|
||||||
ReplayFramesResponse,
|
|
||||||
Session,
|
Session,
|
||||||
TrackOutline,
|
TrackOutline,
|
||||||
Weekend,
|
Weekend,
|
||||||
@@ -82,21 +81,6 @@ export async function fetchTrackOutline(circuitKey: number, year: number): Promi
|
|||||||
return data as TrackOutline
|
return data as TrackOutline
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchReplayFrames(
|
|
||||||
sessionKey: number,
|
|
||||||
intervalMs = 5000,
|
|
||||||
): Promise<ReplayFramesResponse> {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
session_key: String(sessionKey),
|
|
||||||
interval_ms: String(intervalMs),
|
|
||||||
})
|
|
||||||
const res = await fetch(`/api/v1/replay/frames?${params}`)
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
|
||||||
}
|
|
||||||
return res.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise<Session[]> {
|
export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise<Session[]> {
|
||||||
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
|
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import type { Chapter } from '../types'
|
|
||||||
import {
|
|
||||||
activeChapterIndex,
|
|
||||||
chapterKindLabel,
|
|
||||||
chapterLapRange,
|
|
||||||
chapterStartScrub,
|
|
||||||
} from '../lib/chapters'
|
|
||||||
import '../styles/chapters.css'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
chapters: Chapter[]
|
|
||||||
scrubTime: number | null
|
|
||||||
tMin: number
|
|
||||||
tRange: number
|
|
||||||
tourActive: boolean
|
|
||||||
tourChapterIndex: number | null
|
|
||||||
onChapterClick: (index: number, scrub: number) => void
|
|
||||||
onTourToggle: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChapterStrip({
|
|
||||||
chapters,
|
|
||||||
scrubTime,
|
|
||||||
tMin,
|
|
||||||
tRange,
|
|
||||||
tourActive,
|
|
||||||
tourChapterIndex,
|
|
||||||
onChapterClick,
|
|
||||||
onTourToggle,
|
|
||||||
}: Props) {
|
|
||||||
if (chapters.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="chapter-strip" data-testid="chapter-strip">
|
|
||||||
<p className="chapter-strip-empty">No story chapters for this session.</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="chapter-strip" data-testid="chapter-strip">
|
|
||||||
<div className="chapter-strip-header">
|
|
||||||
<span className="chapter-strip-title">Race chapters</span>
|
|
||||||
<div className="chapter-strip-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`chapter-tour-btn ${tourActive ? 'active' : ''}`}
|
|
||||||
onClick={onTourToggle}
|
|
||||||
aria-pressed={tourActive}
|
|
||||||
>
|
|
||||||
{tourActive ? 'Exit 90s' : '90s tour'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="chapter-strip-scroll" role="list" aria-label="Race story chapters">
|
|
||||||
{chapters.map((chapter, index) => {
|
|
||||||
const scrub = chapterStartScrub(chapter, tMin, tRange) ?? index / Math.max(chapters.length - 1, 1)
|
|
||||||
const isActive = tourActive
|
|
||||||
? tourChapterIndex === index
|
|
||||||
: activeIndex === index
|
|
||||||
const headline = chapter.headline || chapter.title
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={`${chapter.kind}-${chapter.start_lap}-${index}`}
|
|
||||||
type="button"
|
|
||||||
role="listitem"
|
|
||||||
className={`chapter-card ${isActive ? (tourActive ? 'tour-active' : 'active') : ''}`}
|
|
||||||
onClick={() => onChapterClick(index, scrub)}
|
|
||||||
aria-current={isActive ? 'true' : undefined}
|
|
||||||
data-testid={`chapter-card-${index}`}
|
|
||||||
>
|
|
||||||
<div className="chapter-card-top">
|
|
||||||
<span className={`chapter-kind chapter-kind--${chapter.kind}`}>
|
|
||||||
{chapterKindLabel(chapter.kind)}
|
|
||||||
</span>
|
|
||||||
<span className="chapter-lap-range">{chapterLapRange(chapter)}</span>
|
|
||||||
</div>
|
|
||||||
<span className="chapter-headline">{headline}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,7 @@
|
|||||||
import { useMemo } from 'react'
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import {
|
import { fetchNews } from '../api'
|
||||||
fetchChampionshipHub,
|
|
||||||
fetchNews,
|
|
||||||
fetchSeasonMeetings,
|
|
||||||
fetchSeasons,
|
|
||||||
} from '../api'
|
|
||||||
import {
|
|
||||||
activeDigestWindow,
|
|
||||||
gpWindows,
|
|
||||||
itemsForWindow,
|
|
||||||
sinceLastLabel,
|
|
||||||
tagColour,
|
|
||||||
tagItems,
|
|
||||||
topTags,
|
|
||||||
} from '../lib/digest'
|
|
||||||
import { timeAgo } from '../utils'
|
import { timeAgo } from '../utils'
|
||||||
import '../styles/digest.css'
|
|
||||||
|
|
||||||
const SOURCE_DISPLAY: Record<string, string> = {
|
const SOURCE_DISPLAY: Record<string, string> = {
|
||||||
'fia': 'FIA',
|
'fia': 'FIA',
|
||||||
@@ -30,53 +14,14 @@ const SOURCE_DISPLAY: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PaddockBriefing() {
|
export function PaddockBriefing() {
|
||||||
const now = useMemo(() => new Date(), [])
|
|
||||||
|
|
||||||
const { data: news, isLoading, isError } = useQuery({
|
const { data: news, isLoading, isError } = useQuery({
|
||||||
queryKey: ['news'],
|
queryKey: ['news'],
|
||||||
queryFn: () => fetchNews(100),
|
queryFn: () => fetchNews(100),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const seasonsQuery = useQuery({
|
|
||||||
queryKey: ['seasons'],
|
|
||||||
queryFn: fetchSeasons,
|
|
||||||
})
|
|
||||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
|
||||||
|
|
||||||
const meetingsQuery = useQuery({
|
|
||||||
queryKey: ['season-meetings', latestSeason],
|
|
||||||
queryFn: () => fetchSeasonMeetings(latestSeason!),
|
|
||||||
enabled: latestSeason != null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const hubQuery = useQuery({
|
|
||||||
queryKey: ['championship-hub', latestSeason],
|
|
||||||
queryFn: () => fetchChampionshipHub(latestSeason!),
|
|
||||||
enabled: latestSeason != null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const meetings = meetingsQuery.data ?? []
|
|
||||||
const hub = hubQuery.data
|
|
||||||
const tagged = useMemo(
|
|
||||||
() => tagItems(news ?? [], hub?.drivers ?? [], hub?.teams ?? []),
|
|
||||||
[news, hub],
|
|
||||||
)
|
|
||||||
|
|
||||||
const windows = useMemo(() => gpWindows(meetings, now), [meetings, now])
|
|
||||||
const activeWindow = useMemo(
|
|
||||||
() => activeDigestWindow(windows, meetings, now),
|
|
||||||
[windows, meetings, now],
|
|
||||||
)
|
|
||||||
const sinceItems = useMemo(
|
|
||||||
() => itemsForWindow(tagged, activeWindow),
|
|
||||||
[tagged, activeWindow],
|
|
||||||
)
|
|
||||||
const sinceTags = useMemo(() => topTags(sinceItems, 4), [sinceItems])
|
|
||||||
const sinceLabel = sinceLastLabel(meetings, now)
|
|
||||||
|
|
||||||
const unreadCount = news?.filter((i) => !i.read_at).length ?? 0
|
const unreadCount = news?.filter((i) => !i.read_at).length ?? 0
|
||||||
const preview = sinceItems.length > 0 ? sinceItems.slice(0, 5) : (tagged.slice(0, 5))
|
const preview = news?.slice(0, 5) ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="cc-briefing" data-testid="paddock-briefing">
|
<section className="cc-briefing" data-testid="paddock-briefing">
|
||||||
@@ -92,27 +37,6 @@ export function PaddockBriefing() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{meetings.length > 0 && (
|
|
||||||
<div className="cc-brief-digest-meta" data-testid="cc-brief-digest-meta">
|
|
||||||
<span>Since {sinceLabel}</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span>{sinceItems.length} items</span>
|
|
||||||
{sinceTags.length > 0 && (
|
|
||||||
<div className="cc-brief-digest-tags">
|
|
||||||
{sinceTags.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag.key}
|
|
||||||
className="cc-brief-tag"
|
|
||||||
style={{ borderColor: tagColour(tag.colour) }}
|
|
||||||
>
|
|
||||||
{tag.label}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading && <div className="briefing-state loading-state">loading…</div>}
|
{isLoading && <div className="briefing-state loading-state">loading…</div>}
|
||||||
{isError && <div className="briefing-state error-box">Failed to load briefing</div>}
|
{isError && <div className="briefing-state error-box">Failed to load briefing</div>}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useState, useMemo, useRef, useCallback } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import type { EnrichedResult, EnrichedGrid, PositionSample, Lap, Session } from '../types'
|
||||||
import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session, Chapter } from '../types'
|
|
||||||
import { fetchReplayFrames, fetchTrackOutline } from '../api'
|
|
||||||
import { ReplayTrackMap } from './ReplayTrackMap'
|
|
||||||
import { ChapterStrip } from './ChapterStrip'
|
|
||||||
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
|
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
|
||||||
import { chapterEndScrub, chapterStartScrub, chapterTourDurations } from '../lib/chapters'
|
|
||||||
|
|
||||||
const CHAPTER_TOUR_MS = 90_000
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: {
|
data: {
|
||||||
@@ -19,181 +12,28 @@ interface Props {
|
|||||||
race_control?: any[]
|
race_control?: any[]
|
||||||
pit_stops?: any[]
|
pit_stops?: any[]
|
||||||
session?: Session
|
session?: Session
|
||||||
meeting?: Meeting
|
|
||||||
drivers?: Driver[]
|
|
||||||
chapters?: Chapter[]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RaceStoryCanvas({ data }: Props) {
|
export function RaceStoryCanvas({ data }: Props) {
|
||||||
const {
|
const { results, starting_grid: grid, positions, datasets, race_control = [], pit_stops = [], laps = [] } = data
|
||||||
results,
|
|
||||||
starting_grid: grid,
|
|
||||||
positions,
|
|
||||||
datasets,
|
|
||||||
race_control = [],
|
|
||||||
pit_stops = [],
|
|
||||||
laps = [],
|
|
||||||
session,
|
|
||||||
meeting,
|
|
||||||
drivers = [],
|
|
||||||
chapters = [],
|
|
||||||
} = data
|
|
||||||
const hasPositions = datasets['positions']?.status === 'available'
|
const hasPositions = datasets['positions']?.status === 'available'
|
||||||
|
|
||||||
const [scrubTime, setScrubTime] = useState<number | null>(null)
|
const [scrubTime, setScrubTime] = useState<number | null>(null)
|
||||||
const [hoverDriver, setHoverDriver] = useState<number | null>(null)
|
const [hoverDriver, setHoverDriver] = useState<number | null>(null)
|
||||||
const [mapOpen, setMapOpen] = useState(false)
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
|
||||||
const [playbackSpeed, setPlaybackSpeed] = useState(10)
|
|
||||||
const [chapterTourActive, setChapterTourActive] = useState(false)
|
|
||||||
const [tourChapterIndex, setTourChapterIndex] = useState<number | null>(null)
|
|
||||||
const svgRef = useRef<SVGSVGElement>(null)
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 })
|
|
||||||
|
|
||||||
// Position Evolution Chart Logic
|
// Position Evolution Chart Logic
|
||||||
const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions])
|
const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions])
|
||||||
const hasChartData = hasPositions && allTimes.length > 0
|
const hasChartData = hasPositions && allTimes.length > 0
|
||||||
const chartTiming = useMemo(() => {
|
|
||||||
if (!hasChartData) return null
|
|
||||||
const tMin = new Date(allTimes[0]).getTime()
|
|
||||||
const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
|
|
||||||
return { tMin, tMax, tRange: Math.max(tMax - tMin, 1) }
|
|
||||||
}, [allTimes, hasChartData])
|
|
||||||
const circuitKey = session?.circuit_key ?? meeting?.circuit_key ?? 0
|
|
||||||
const outlineYear = meeting?.year ?? (session?.date_start ? new Date(session.date_start).getFullYear() : 0)
|
|
||||||
|
|
||||||
const replayQuery = useQuery({
|
|
||||||
queryKey: ['replay-frames', session?.session_key, 5000],
|
|
||||||
queryFn: () => fetchReplayFrames(session!.session_key, 5000),
|
|
||||||
enabled: mapOpen && Boolean(session?.session_key),
|
|
||||||
})
|
|
||||||
const outlineQuery = useQuery({
|
|
||||||
queryKey: ['track-outline', circuitKey, outlineYear],
|
|
||||||
queryFn: () => fetchTrackOutline(circuitKey, outlineYear),
|
|
||||||
enabled: mapOpen && circuitKey > 0 && outlineYear > 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPlaying || !chartTiming) return
|
|
||||||
|
|
||||||
let frame = 0
|
|
||||||
let last = performance.now()
|
|
||||||
const tick = (now: number) => {
|
|
||||||
const delta = now - last
|
|
||||||
last = now
|
|
||||||
setScrubTime((current) => {
|
|
||||||
const next = Math.min(1, (current ?? 0) + (delta * playbackSpeed) / chartTiming.tRange)
|
|
||||||
if (next >= 1) {
|
|
||||||
setIsPlaying(false)
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
frame = requestAnimationFrame(tick)
|
|
||||||
}
|
|
||||||
|
|
||||||
frame = requestAnimationFrame(tick)
|
|
||||||
return () => cancelAnimationFrame(frame)
|
|
||||||
}, [chartTiming, isPlaying, playbackSpeed])
|
|
||||||
|
|
||||||
const stopChapterTour = () => {
|
|
||||||
setChapterTourActive(false)
|
|
||||||
setTourChapterIndex(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const jumpToChapter = (index: number, scrub: number) => {
|
|
||||||
setIsPlaying(false)
|
|
||||||
stopChapterTour()
|
|
||||||
setScrubTime(scrub)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleChapterTour = () => {
|
|
||||||
if (chapterTourActive) {
|
|
||||||
stopChapterTour()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!chartTiming || chapters.length === 0) return
|
|
||||||
setIsPlaying(false)
|
|
||||||
setChapterTourActive(true)
|
|
||||||
setTourChapterIndex(0)
|
|
||||||
const startScrub = chapterStartScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? 0
|
|
||||||
setScrubTime(startScrub)
|
|
||||||
const durations = chapterTourDurations(chapters, CHAPTER_TOUR_MS)
|
|
||||||
tourRef.current = {
|
|
||||||
chapterIndex: 0,
|
|
||||||
startedAt: performance.now(),
|
|
||||||
durationMs: durations[0] ?? CHAPTER_TOUR_MS / chapters.length,
|
|
||||||
startScrub,
|
|
||||||
endScrub: chapterEndScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? startScrub,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!chapterTourActive || !chartTiming || chapters.length === 0) return
|
|
||||||
|
|
||||||
let frame = 0
|
|
||||||
const tick = (now: number) => {
|
|
||||||
const state = tourRef.current
|
|
||||||
const elapsed = now - state.startedAt
|
|
||||||
const progress = Math.min(1, elapsed / Math.max(state.durationMs, 1))
|
|
||||||
const scrub = state.startScrub + (state.endScrub - state.startScrub) * progress
|
|
||||||
setScrubTime(scrub)
|
|
||||||
setTourChapterIndex(state.chapterIndex)
|
|
||||||
|
|
||||||
if (progress >= 1) {
|
|
||||||
const nextIndex = state.chapterIndex + 1
|
|
||||||
if (nextIndex >= chapters.length) {
|
|
||||||
stopChapterTour()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const durations = chapterTourDurations(chapters, CHAPTER_TOUR_MS)
|
|
||||||
const startScrub = chapterStartScrub(chapters[nextIndex], chartTiming.tMin, chartTiming.tRange) ?? 0
|
|
||||||
const endScrub = chapterEndScrub(chapters[nextIndex], chartTiming.tMin, chartTiming.tRange) ?? startScrub
|
|
||||||
tourRef.current = {
|
|
||||||
chapterIndex: nextIndex,
|
|
||||||
startedAt: now,
|
|
||||||
durationMs: durations[nextIndex] ?? CHAPTER_TOUR_MS / chapters.length,
|
|
||||||
startScrub,
|
|
||||||
endScrub,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
frame = requestAnimationFrame(tick)
|
|
||||||
}
|
|
||||||
|
|
||||||
frame = requestAnimationFrame(tick)
|
|
||||||
return () => cancelAnimationFrame(frame)
|
|
||||||
}, [chapterTourActive, chartTiming, chapters])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!chapterTourActive) return
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
stopChapterTour()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', onKeyDown)
|
|
||||||
return () => window.removeEventListener('keydown', onKeyDown)
|
|
||||||
}, [chapterTourActive])
|
|
||||||
|
|
||||||
const replayTMs = useMemo(() => {
|
|
||||||
const replay = replayQuery.data
|
|
||||||
const frames = replay?.frames ?? []
|
|
||||||
if (frames.length === 0) return 0
|
|
||||||
const lastFrameT = frames[frames.length - 1].t
|
|
||||||
const progress = scrubTime ?? 0
|
|
||||||
if (!chartTiming || !replay?.start_time) {
|
|
||||||
return Math.max(0, Math.min(lastFrameT, Math.round(progress * lastFrameT)))
|
|
||||||
}
|
|
||||||
const replayStart = new Date(replay.start_time).getTime()
|
|
||||||
const chartTime = chartTiming.tMin + progress * chartTiming.tRange
|
|
||||||
return Math.max(0, Math.min(lastFrameT, Math.round(chartTime - replayStart)))
|
|
||||||
}, [chartTiming, replayQuery.data, scrubTime])
|
|
||||||
|
|
||||||
let chartContent = null
|
let chartContent = null
|
||||||
let displayResults = results
|
let displayResults = results
|
||||||
|
|
||||||
if (hasChartData && chartTiming) {
|
if (hasChartData) {
|
||||||
const { tMin, tRange } = chartTiming
|
const tMin = new Date(allTimes[0]).getTime()
|
||||||
|
const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
|
||||||
|
const tRange = Math.max(tMax - tMin, 1)
|
||||||
|
|
||||||
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
|
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
|
||||||
for (const p of positions) {
|
for (const p of positions) {
|
||||||
@@ -297,8 +137,6 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
|
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
|
||||||
setIsPlaying(false)
|
|
||||||
stopChapterTour()
|
|
||||||
if (!svgRef.current) return
|
if (!svgRef.current) return
|
||||||
const rect = svgRef.current.getBoundingClientRect()
|
const rect = svgRef.current.getBoundingClientRect()
|
||||||
const x = e.clientX - rect.left
|
const x = e.clientX - rect.left
|
||||||
@@ -450,91 +288,24 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
height={plotH}
|
height={plotH}
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerLeave={() => {
|
onPointerLeave={() => setScrubTime(null)}
|
||||||
if (!isPlaying) setScrubTime(null)
|
|
||||||
}}
|
|
||||||
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div className="rs-replay-tools" aria-label="Race replay controls">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`rs-tool-btn ${isPlaying ? 'active' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
setScrubTime((current) => current ?? 0)
|
|
||||||
stopChapterTour()
|
|
||||||
setIsPlaying((current) => !current)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isPlaying ? 'Pause' : 'Play'}
|
|
||||||
</button>
|
|
||||||
<div className="rs-speed-group" aria-label="Playback speed">
|
|
||||||
{[1, 10, 30].map((speed) => (
|
|
||||||
<button
|
|
||||||
key={speed}
|
|
||||||
type="button"
|
|
||||||
className={`rs-speed-btn ${playbackSpeed === speed ? 'active' : ''}`}
|
|
||||||
onClick={() => setPlaybackSpeed(speed)}
|
|
||||||
>
|
|
||||||
{speed}x
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`rs-tool-btn ${mapOpen ? 'active' : ''}`}
|
|
||||||
onClick={() => setMapOpen((current) => !current)}
|
|
||||||
>
|
|
||||||
Map
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="race-story-canvas">
|
<div className="race-story-canvas">
|
||||||
{hasChartData && chartTiming && chapters.length > 0 && (
|
{hasChartData ? (
|
||||||
<ChapterStrip
|
chartContent
|
||||||
chapters={chapters}
|
) : (
|
||||||
scrubTime={scrubTime}
|
<div className="analysis-notice">
|
||||||
tMin={chartTiming.tMin}
|
<strong>Lap-by-lap positions not available.</strong> This session does not
|
||||||
tRange={chartTiming.tRange}
|
have ingested position samples in <code>/api/v1/race-hub</code>.
|
||||||
tourActive={chapterTourActive}
|
|
||||||
tourChapterIndex={tourChapterIndex}
|
|
||||||
onChapterClick={jumpToChapter}
|
|
||||||
onTourToggle={toggleChapterTour}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="rs-replay-shell">
|
|
||||||
<div className="rs-replay-main">
|
|
||||||
{hasChartData ? (
|
|
||||||
chartContent
|
|
||||||
) : (
|
|
||||||
<div className="analysis-notice">
|
|
||||||
<strong>Lap-by-lap positions not available.</strong> This session does not
|
|
||||||
have ingested position samples in <code>/api/v1/race-hub</code>.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{mapOpen && (
|
)}
|
||||||
<div className="rs-replay-map-slot">
|
|
||||||
{circuitKey > 0 && outlineYear > 0 ? (
|
|
||||||
<ReplayTrackMap
|
|
||||||
outline={outlineQuery.data}
|
|
||||||
replay={replayQuery.data}
|
|
||||||
tMs={replayTMs}
|
|
||||||
drivers={drivers}
|
|
||||||
results={results}
|
|
||||||
loading={outlineQuery.isLoading || replayQuery.isLoading}
|
|
||||||
error={outlineQuery.isError || replayQuery.isError}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="rs-replay-map-placeholder">track identity unavailable for replay map</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{displayResults.length > 0 && (
|
{displayResults.length > 0 && (
|
||||||
<div className="rs-field-list">
|
<div className="rs-field-list">
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react'
|
|
||||||
import type { Driver, EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types'
|
|
||||||
import { buildOutlinePath } from '../lib/trackmap'
|
|
||||||
import { interpolateReplayCars, replayCarToSvg } from '../lib/replay'
|
|
||||||
import '../styles/replay-map.css'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
outline?: TrackOutline | null
|
|
||||||
replay?: ReplayFramesResponse | null
|
|
||||||
tMs: number
|
|
||||||
drivers: Driver[]
|
|
||||||
results: EnrichedResult[]
|
|
||||||
loading?: boolean
|
|
||||||
error?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReplayTrackMap({
|
|
||||||
outline,
|
|
||||||
replay,
|
|
||||||
tMs,
|
|
||||||
drivers,
|
|
||||||
results,
|
|
||||||
loading = false,
|
|
||||||
error = false,
|
|
||||||
}: Props) {
|
|
||||||
const [pinned, setPinned] = useState<string | null>(null)
|
|
||||||
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
|
|
||||||
const driverInfo = useMemo(() => {
|
|
||||||
const info = new Map<string, { label: string; color: string }>()
|
|
||||||
for (const driver of drivers) {
|
|
||||||
info.set(String(driver.driver_number), {
|
|
||||||
label: driver.name_acronym || String(driver.driver_number),
|
|
||||||
color: normalizeColor(driver.team_colour),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for (const result of results) {
|
|
||||||
const key = String(result.driver_number)
|
|
||||||
if (!info.has(key)) {
|
|
||||||
info.set(key, {
|
|
||||||
label: result.name_acronym || key,
|
|
||||||
color: normalizeColor(result.team_colour),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return info
|
|
||||||
}, [drivers, results])
|
|
||||||
|
|
||||||
const cars = useMemo(() => {
|
|
||||||
if (!outline?.bounds || !replay?.frames?.length) return []
|
|
||||||
const positions = interpolateReplayCars(replay.frames, tMs)
|
|
||||||
return Object.entries(positions)
|
|
||||||
.map(([number, car]) => ({
|
|
||||||
number,
|
|
||||||
svg: replayCarToSvg(car, outline.bounds),
|
|
||||||
info: driverInfo.get(number),
|
|
||||||
}))
|
|
||||||
.sort((a, b) => Number(a.number) - Number(b.number))
|
|
||||||
}, [driverInfo, outline?.bounds, replay?.frames, tMs])
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<section className="replay-map-panel" data-testid="replay-track-map">
|
|
||||||
<div className="replay-map-empty">loading replay GPS...</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<section className="replay-map-panel" data-testid="replay-track-map">
|
|
||||||
<div className="replay-map-empty">replay GPS unavailable for this session</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!outline || !outlinePath) {
|
|
||||||
return (
|
|
||||||
<section className="replay-map-panel" data-testid="replay-track-map">
|
|
||||||
<div className="replay-map-empty">track outline unavailable for this session</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!replay?.frames?.length || cars.length === 0) {
|
|
||||||
return (
|
|
||||||
<section className="replay-map-panel" data-testid="replay-track-map">
|
|
||||||
<div className="replay-map-empty">historical GPS unavailable for this session</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="replay-map-panel" data-testid="replay-track-map">
|
|
||||||
<div className="replay-map-stage">
|
|
||||||
<svg className="replay-map-svg" viewBox="0 0 100 100" role="img" aria-label="Replay track map">
|
|
||||||
<path className="replay-map-outline-shadow" d={outlinePath} />
|
|
||||||
<path className="replay-map-outline" d={outlinePath} />
|
|
||||||
{cars.map((car) => {
|
|
||||||
const label = car.info?.label ?? car.number
|
|
||||||
const selected = pinned === car.number
|
|
||||||
return (
|
|
||||||
<g
|
|
||||||
key={car.number}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={`${label} replay position`}
|
|
||||||
className={`replay-car ${selected ? 'replay-car-pinned' : ''}`}
|
|
||||||
transform={`translate(${car.svg.x.toFixed(2)} ${car.svg.y.toFixed(2)})`}
|
|
||||||
onClick={() => setPinned(selected ? null : car.number)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
|
||||||
event.preventDefault()
|
|
||||||
setPinned(selected ? null : car.number)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<title>{label}</title>
|
|
||||||
<circle r="2.7" fill={car.info?.color ?? '#777777'} />
|
|
||||||
<text y="0.85">{label}</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeColor(color: string | undefined): string {
|
|
||||||
const raw = color?.trim()
|
|
||||||
if (!raw) return '#777777'
|
|
||||||
return raw.startsWith('#') ? raw : `#${raw}`
|
|
||||||
}
|
|
||||||
271
frontend/src/components/RivalryCompare.tsx
Normal file
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { teamColor } from '../utils'
|
|
||||||
import '../styles/h2h.css'
|
|
||||||
|
|
||||||
export interface TeammateH2HProps {
|
|
||||||
teamName: string
|
|
||||||
teamColour: string
|
|
||||||
driverATla: string
|
|
||||||
driverBTla: string
|
|
||||||
winsA: number
|
|
||||||
winsB: number
|
|
||||||
/** e.g. "+1" when more than two drivers share the team. */
|
|
||||||
extraNote?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TeammateH2H({
|
|
||||||
teamName,
|
|
||||||
teamColour,
|
|
||||||
driverATla,
|
|
||||||
driverBTla,
|
|
||||||
winsA,
|
|
||||||
winsB,
|
|
||||||
extraNote,
|
|
||||||
}: TeammateH2HProps) {
|
|
||||||
const total = winsA + winsB || 1
|
|
||||||
const pctA = (winsA / total) * 100
|
|
||||||
const color = teamColor(teamColour)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h2h-row" data-testid="teammate-h2h">
|
|
||||||
<div className="h2h-team">
|
|
||||||
<span className="h2h-team-name">{teamName}</span>
|
|
||||||
{extraNote ? <span className="h2h-extra mono">{extraNote}</span> : null}
|
|
||||||
</div>
|
|
||||||
<div className="h2h-bar-wrap">
|
|
||||||
<span className="h2h-tla h2h-tla--left mono">{driverATla}</span>
|
|
||||||
<div className="h2h-bar" data-testid="h2h-bar">
|
|
||||||
<span
|
|
||||||
className="h2h-bar-seg"
|
|
||||||
data-testid="h2h-bar-a"
|
|
||||||
style={{ width: `${pctA}%`, background: color }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="h2h-bar-seg h2h-bar-seg--muted"
|
|
||||||
data-testid="h2h-bar-b"
|
|
||||||
style={{ width: `${100 - pctA}%`, background: color }}
|
|
||||||
/>
|
|
||||||
<span className="h2h-score mono" data-testid="h2h-score">
|
|
||||||
{winsA}–{winsB}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="h2h-tla h2h-tla--right mono">{driverBTla}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import type { Chapter } from '../types'
|
|
||||||
|
|
||||||
export function chapterKindLabel(kind: string): string {
|
|
||||||
switch (kind) {
|
|
||||||
case 'start':
|
|
||||||
return 'GO'
|
|
||||||
case 'safety_car':
|
|
||||||
return 'SC'
|
|
||||||
case 'virtual_safety_car':
|
|
||||||
return 'VSC'
|
|
||||||
case 'red_flag':
|
|
||||||
return 'RF'
|
|
||||||
case 'pit_phase':
|
|
||||||
return 'PIT'
|
|
||||||
case 'decisive_swing':
|
|
||||||
return '▲'
|
|
||||||
case 'finish':
|
|
||||||
return 'FIN'
|
|
||||||
default:
|
|
||||||
return kind.slice(0, 3).toUpperCase()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function chapterLapRange(chapter: Chapter): string {
|
|
||||||
if (chapter.start_lap === chapter.end_lap) {
|
|
||||||
return `L${chapter.start_lap}`
|
|
||||||
}
|
|
||||||
return `L${chapter.start_lap}–L${chapter.end_lap}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalized scrub position (0–1) for a chapter's start time on the chart axis. */
|
|
||||||
export function chapterStartScrub(
|
|
||||||
chapter: Chapter,
|
|
||||||
tMin: number,
|
|
||||||
tRange: number,
|
|
||||||
): number | null {
|
|
||||||
if (!chapter.start_time || tRange <= 0) return null
|
|
||||||
const ms = new Date(chapter.start_time).getTime()
|
|
||||||
if (Number.isNaN(ms)) return null
|
|
||||||
return Math.max(0, Math.min(1, (ms - tMin) / tRange))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalized scrub position (0–1) for a chapter's end time on the chart axis. */
|
|
||||||
export function chapterEndScrub(
|
|
||||||
chapter: Chapter,
|
|
||||||
tMin: number,
|
|
||||||
tRange: number,
|
|
||||||
): number | null {
|
|
||||||
const raw = chapter.end_time ?? chapter.start_time
|
|
||||||
if (!raw || tRange <= 0) return null
|
|
||||||
const ms = new Date(raw).getTime()
|
|
||||||
if (Number.isNaN(ms)) return null
|
|
||||||
return Math.max(0, Math.min(1, (ms - tMin) / tRange))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Index of the chapter containing the current scrub position, if any. */
|
|
||||||
export function activeChapterIndex(
|
|
||||||
chapters: Chapter[],
|
|
||||||
scrubTime: number | null,
|
|
||||||
tMin: number,
|
|
||||||
tRange: number,
|
|
||||||
): number | null {
|
|
||||||
if (scrubTime === null || chapters.length === 0 || tRange <= 0) return null
|
|
||||||
const chartMs = tMin + scrubTime * tRange
|
|
||||||
for (let i = 0; i < chapters.length; i++) {
|
|
||||||
const ch = chapters[i]
|
|
||||||
const startMs = ch.start_time ? new Date(ch.start_time).getTime() : NaN
|
|
||||||
const endRaw = ch.end_time ?? ch.start_time
|
|
||||||
const endMs = endRaw ? new Date(endRaw).getTime() : NaN
|
|
||||||
if (!Number.isNaN(startMs) && !Number.isNaN(endMs) && chartMs >= startMs && chartMs <= endMs) {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Duration in ms each chapter should play during a ~90s tour. */
|
|
||||||
export function chapterTourDurations(chapters: Chapter[], totalMs = 90_000): number[] {
|
|
||||||
if (chapters.length === 0) return []
|
|
||||||
const perChapter = totalMs / chapters.length
|
|
||||||
return chapters.map(() => perChapter)
|
|
||||||
}
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
import { meetingEndTime, meetingStartTime, mostRecentPastMeeting, nextUpcomingMeeting } from './schedule'
|
|
||||||
import type { ChampHubDriver, ChampHubTeam, Meeting, NewsItem } from '../types'
|
|
||||||
|
|
||||||
export interface GPWindow {
|
|
||||||
meeting_key: number
|
|
||||||
meeting_name: string
|
|
||||||
/** Inclusive lower bound: end of previous GP, or open for season opener. */
|
|
||||||
start: Date | null
|
|
||||||
/** Exclusive upper bound: start of next GP, or open after the final round. */
|
|
||||||
end: Date | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DigestTag {
|
|
||||||
kind: 'driver' | 'team'
|
|
||||||
key: string
|
|
||||||
label: string
|
|
||||||
colour: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TaggedNewsItem = NewsItem & { tags: DigestTag[] }
|
|
||||||
|
|
||||||
export const RECENT_BUCKET_KEY = '__recent__'
|
|
||||||
|
|
||||||
interface MatchCandidate {
|
|
||||||
start: number
|
|
||||||
end: number
|
|
||||||
tag: DigestTag
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TagPattern {
|
|
||||||
pattern: string
|
|
||||||
tag: DigestTag
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortMeetings(meetings: Meeting[]): Meeting[] {
|
|
||||||
return [...meetings].sort((a, b) => {
|
|
||||||
const left = meetingStartTime(a)?.getTime() ?? 0
|
|
||||||
const right = meetingStartTime(b)?.getTime() ?? 0
|
|
||||||
if (left !== right) return left - right
|
|
||||||
return a.meeting_name.localeCompare(b.meeting_name)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Derive inter-race windows [prev GP end → next GP start] for each round. */
|
|
||||||
export function gpWindows(meetings: Meeting[], _now: Date): GPWindow[] {
|
|
||||||
const sorted = sortMeetings(meetings)
|
|
||||||
return sorted.map((meeting, index) => ({
|
|
||||||
meeting_key: meeting.meeting_key,
|
|
||||||
meeting_name: meeting.meeting_name,
|
|
||||||
start: index > 0 ? meetingEndTime(sorted[index - 1]) : null,
|
|
||||||
end: index < sorted.length - 1 ? meetingStartTime(sorted[index + 1]) : null,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function itemPublishedAt(item: NewsItem): Date | null {
|
|
||||||
if (!item.published_at) return null
|
|
||||||
const parsed = Date.parse(item.published_at)
|
|
||||||
return Number.isNaN(parsed) ? null : new Date(parsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
function itemInWindow(published: Date, window: GPWindow): boolean {
|
|
||||||
if (window.start && published < window.start) return false
|
|
||||||
if (window.end && published >= window.end) return false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function windowForDate(windows: GPWindow[], published: Date): GPWindow | null {
|
|
||||||
const matches = windows.filter((window) => itemInWindow(published, window))
|
|
||||||
if (matches.length === 0) return null
|
|
||||||
return matches.reduce((best, window) => {
|
|
||||||
const bestStart = best.start?.getTime() ?? Number.NEGATIVE_INFINITY
|
|
||||||
const windowStart = window.start?.getTime() ?? Number.NEGATIVE_INFINITY
|
|
||||||
return windowStart >= bestStart ? window : best
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GroupedDigest {
|
|
||||||
windows: Array<{ window: GPWindow; items: NewsItem[] }>
|
|
||||||
recent: NewsItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bucket news items into GP windows; undated items land in `recent`. */
|
|
||||||
export function groupByWindow(items: NewsItem[], windows: GPWindow[]): GroupedDigest {
|
|
||||||
const buckets = new Map<number, NewsItem[]>()
|
|
||||||
const recent: NewsItem[] = []
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const published = itemPublishedAt(item)
|
|
||||||
if (!published) {
|
|
||||||
recent.push(item)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const match = windowForDate(windows, published)
|
|
||||||
if (!match) {
|
|
||||||
recent.push(item)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const list = buckets.get(match.meeting_key) ?? []
|
|
||||||
list.push(item)
|
|
||||||
buckets.set(match.meeting_key, list)
|
|
||||||
}
|
|
||||||
|
|
||||||
const grouped = windows
|
|
||||||
.map((window) => ({
|
|
||||||
window,
|
|
||||||
items: buckets.get(window.meeting_key) ?? [],
|
|
||||||
}))
|
|
||||||
.filter((entry) => entry.items.length > 0)
|
|
||||||
|
|
||||||
return { windows: grouped, recent }
|
|
||||||
}
|
|
||||||
|
|
||||||
function driverLastName(fullName: string): string | null {
|
|
||||||
const parts = fullName.trim().split(/\s+/).filter(Boolean)
|
|
||||||
if (parts.length < 2) return null
|
|
||||||
return parts[parts.length - 1] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTagPatterns(drivers: ChampHubDriver[], teams: ChampHubTeam[]): TagPattern[] {
|
|
||||||
const patterns: TagPattern[] = []
|
|
||||||
|
|
||||||
for (const team of teams) {
|
|
||||||
if (!team.team_name.trim()) continue
|
|
||||||
patterns.push({
|
|
||||||
pattern: team.team_name.toLowerCase(),
|
|
||||||
tag: {
|
|
||||||
kind: 'team',
|
|
||||||
key: `team:${team.team_name}`,
|
|
||||||
label: team.team_name,
|
|
||||||
colour: team.team_colour,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const driver of drivers) {
|
|
||||||
const colour = driver.team_colour
|
|
||||||
const base = {
|
|
||||||
kind: 'driver' as const,
|
|
||||||
colour,
|
|
||||||
}
|
|
||||||
if (driver.full_name.trim()) {
|
|
||||||
patterns.push({
|
|
||||||
pattern: driver.full_name.toLowerCase(),
|
|
||||||
tag: {
|
|
||||||
...base,
|
|
||||||
key: `driver:${driver.name_acronym}`,
|
|
||||||
label: driver.name_acronym,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const lastName = driverLastName(driver.full_name)
|
|
||||||
if (lastName) {
|
|
||||||
patterns.push({
|
|
||||||
pattern: lastName.toLowerCase(),
|
|
||||||
tag: {
|
|
||||||
...base,
|
|
||||||
key: `driver:${driver.name_acronym}`,
|
|
||||||
label: driver.name_acronym,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (driver.name_acronym.trim()) {
|
|
||||||
patterns.push({
|
|
||||||
pattern: driver.name_acronym.toLowerCase(),
|
|
||||||
tag: {
|
|
||||||
...base,
|
|
||||||
key: `driver:${driver.name_acronym}`,
|
|
||||||
label: driver.name_acronym,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
patterns.sort((a, b) => b.pattern.length - a.pattern.length)
|
|
||||||
return patterns
|
|
||||||
}
|
|
||||||
|
|
||||||
function overlaps(a: MatchCandidate, b: MatchCandidate): boolean {
|
|
||||||
return a.start < b.end && b.start < a.end
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tag items via case-insensitive substring match on title + summary.
|
|
||||||
* Longest pattern wins on overlapping spans (v1 string match — no NLP).
|
|
||||||
*
|
|
||||||
* Known limitations: common-word last names, team names inside other words,
|
|
||||||
* and driver TLAs that appear in unrelated acronyms can false-positive.
|
|
||||||
*/
|
|
||||||
export function tagItems(
|
|
||||||
items: NewsItem[],
|
|
||||||
drivers: ChampHubDriver[],
|
|
||||||
teams: ChampHubTeam[],
|
|
||||||
): TaggedNewsItem[] {
|
|
||||||
const patterns = buildTagPatterns(drivers, teams)
|
|
||||||
|
|
||||||
return items.map((item) => {
|
|
||||||
const haystack = `${item.title} ${item.summary ?? ''}`.toLowerCase()
|
|
||||||
const accepted: MatchCandidate[] = []
|
|
||||||
|
|
||||||
for (const { pattern, tag } of patterns) {
|
|
||||||
if (!pattern) continue
|
|
||||||
let from = 0
|
|
||||||
while (from <= haystack.length - pattern.length) {
|
|
||||||
const index = haystack.indexOf(pattern, from)
|
|
||||||
if (index === -1) break
|
|
||||||
const candidate: MatchCandidate = {
|
|
||||||
start: index,
|
|
||||||
end: index + pattern.length,
|
|
||||||
tag,
|
|
||||||
}
|
|
||||||
if (!accepted.some((match) => overlaps(match, candidate))) {
|
|
||||||
accepted.push(candidate)
|
|
||||||
}
|
|
||||||
from = index + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const tagsByKey = new Map<string, DigestTag>()
|
|
||||||
for (const match of accepted) {
|
|
||||||
tagsByKey.set(match.tag.key, match.tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
tags: [...tagsByKey.values()].sort((a, b) => a.label.localeCompare(b.label)),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function activeDigestWindow(
|
|
||||||
windows: GPWindow[],
|
|
||||||
meetings: Meeting[],
|
|
||||||
now: Date,
|
|
||||||
): GPWindow | null {
|
|
||||||
const next = nextUpcomingMeeting(meetings, now)
|
|
||||||
if (next) {
|
|
||||||
return windows.find((window) => window.meeting_key === next.meeting_key) ?? null
|
|
||||||
}
|
|
||||||
const recent = mostRecentPastMeeting(meetings, now)
|
|
||||||
if (recent) {
|
|
||||||
return windows.find((window) => window.meeting_key === recent.meeting_key) ?? null
|
|
||||||
}
|
|
||||||
return windows[0] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sinceLastLabel(meetings: Meeting[], now: Date): string {
|
|
||||||
const last = mostRecentPastMeeting(meetings, now)
|
|
||||||
if (last) return last.meeting_name
|
|
||||||
const next = nextUpcomingMeeting(meetings, now)
|
|
||||||
if (next) return `Before ${next.meeting_name}`
|
|
||||||
return 'Pre-season'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function itemsForWindow(items: TaggedNewsItem[], window: GPWindow | null): TaggedNewsItem[] {
|
|
||||||
if (!window) return []
|
|
||||||
return items.filter((item) => {
|
|
||||||
const published = itemPublishedAt(item)
|
|
||||||
return published != null && itemInWindow(published, window)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function topTags(items: TaggedNewsItem[], limit = 5): DigestTag[] {
|
|
||||||
const counts = new Map<string, { tag: DigestTag; count: number }>()
|
|
||||||
for (const item of items) {
|
|
||||||
for (const tag of item.tags) {
|
|
||||||
const current = counts.get(tag.key)
|
|
||||||
if (current) current.count += 1
|
|
||||||
else counts.set(tag.key, { tag, count: 1 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...counts.values()]
|
|
||||||
.sort((a, b) => b.count - a.count || a.tag.label.localeCompare(b.tag.label))
|
|
||||||
.slice(0, limit)
|
|
||||||
.map((entry) => entry.tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function filterByTag(items: TaggedNewsItem[], tagKey: string | null): TaggedNewsItem[] {
|
|
||||||
if (!tagKey) return items
|
|
||||||
return items.filter((item) => item.tags.some((tag) => tag.key === tagKey))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function tagColour(colour: string): string {
|
|
||||||
if (!colour) return 'var(--text-3)'
|
|
||||||
return colour.startsWith('#') ? colour : `#${colour}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sortWindowBucketsNewestFirst<T extends { window: GPWindow }>(buckets: T[]): T[] {
|
|
||||||
return [...buckets].sort((a, b) => {
|
|
||||||
const left = a.window.start?.getTime() ?? 0
|
|
||||||
const right = b.window.start?.getTime() ?? 0
|
|
||||||
return right - left
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import type { ChampHubDriver } from '../types'
|
|
||||||
|
|
||||||
export interface TeammatePair {
|
|
||||||
teamName: string
|
|
||||||
teamColour: string
|
|
||||||
driverA: ChampHubDriver
|
|
||||||
driverB: ChampHubDriver
|
|
||||||
/** Drivers on the team beyond the top two (shown as +N). */
|
|
||||||
extraCount: number
|
|
||||||
/** Absolute win gap — smaller means a closer battle. */
|
|
||||||
closeness: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pair teammates per team for H2H display; skip single-driver teams. */
|
|
||||||
export function teammatePairs(drivers: ReadonlyArray<ChampHubDriver>): TeammatePair[] {
|
|
||||||
const byTeam = new Map<string, ChampHubDriver[]>()
|
|
||||||
for (const d of drivers) {
|
|
||||||
const list = byTeam.get(d.team_name) ?? []
|
|
||||||
list.push(d)
|
|
||||||
byTeam.set(d.team_name, list)
|
|
||||||
}
|
|
||||||
|
|
||||||
const pairs: TeammatePair[] = []
|
|
||||||
for (const [teamName, members] of byTeam) {
|
|
||||||
if (members.length < 2) continue
|
|
||||||
|
|
||||||
const sorted = [...members].sort((a, b) => b.points - a.points)
|
|
||||||
const driverA = sorted[0]
|
|
||||||
const driverB = sorted[1]
|
|
||||||
const winsA = driverA.teammate_wins
|
|
||||||
const winsB = driverB.teammate_wins
|
|
||||||
|
|
||||||
pairs.push({
|
|
||||||
teamName,
|
|
||||||
teamColour: driverA.team_colour,
|
|
||||||
driverA,
|
|
||||||
driverB,
|
|
||||||
extraCount: members.length - 2,
|
|
||||||
closeness: Math.abs(winsA - winsB),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return pairs.sort((a, b) => {
|
|
||||||
if (a.closeness !== b.closeness) return a.closeness - b.closeness
|
|
||||||
return a.teamName.localeCompare(b.teamName)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import type { ReplayCarPosition, ReplayFrame, TrackBounds } from '../types'
|
|
||||||
import { canvasToSvg, normalizeRawPoint } from './trackmap'
|
|
||||||
|
|
||||||
export interface ReplayFramePair {
|
|
||||||
previous: ReplayFrame | null
|
|
||||||
next: ReplayFrame | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function lookupReplayFramePair(
|
|
||||||
frames: ReadonlyArray<ReplayFrame>,
|
|
||||||
tMs: number,
|
|
||||||
): ReplayFramePair {
|
|
||||||
if (frames.length === 0) return { previous: null, next: null }
|
|
||||||
if (tMs <= frames[0].t) return { previous: frames[0], next: frames[0] }
|
|
||||||
const last = frames[frames.length - 1]
|
|
||||||
if (tMs >= last.t) return { previous: last, next: last }
|
|
||||||
|
|
||||||
let lo = 0
|
|
||||||
let hi = frames.length - 1
|
|
||||||
while (lo <= hi) {
|
|
||||||
const mid = Math.floor((lo + hi) / 2)
|
|
||||||
const frame = frames[mid]
|
|
||||||
if (frame.t === tMs) return { previous: frame, next: frame }
|
|
||||||
if (frame.t < tMs) {
|
|
||||||
lo = mid + 1
|
|
||||||
} else {
|
|
||||||
hi = mid - 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { previous: frames[Math.max(0, hi)], next: frames[Math.min(frames.length - 1, lo)] }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function interpolateReplayCars(
|
|
||||||
frames: ReadonlyArray<ReplayFrame>,
|
|
||||||
tMs: number,
|
|
||||||
): Record<string, ReplayCarPosition> {
|
|
||||||
const { previous, next } = lookupReplayFramePair(frames, tMs)
|
|
||||||
if (!previous && !next) return {}
|
|
||||||
if (!previous) return next?.cars ?? {}
|
|
||||||
if (!next || previous.t === next.t) return previous.cars
|
|
||||||
|
|
||||||
const span = next.t - previous.t
|
|
||||||
const fraction = span <= 0 ? 0 : clamp01((tMs - previous.t) / span)
|
|
||||||
const cars: Record<string, ReplayCarPosition> = {}
|
|
||||||
const numbers = new Set([...Object.keys(previous.cars), ...Object.keys(next.cars)])
|
|
||||||
|
|
||||||
for (const number of numbers) {
|
|
||||||
const before = previous.cars[number]
|
|
||||||
const after = next.cars[number]
|
|
||||||
if (before && after) {
|
|
||||||
cars[number] = {
|
|
||||||
x: before.x + (after.x - before.x) * fraction,
|
|
||||||
y: before.y + (after.y - before.y) * fraction,
|
|
||||||
}
|
|
||||||
} else if (before) {
|
|
||||||
cars[number] = before
|
|
||||||
} else if (after) {
|
|
||||||
cars[number] = after
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cars
|
|
||||||
}
|
|
||||||
|
|
||||||
export function replayCarToSvg(
|
|
||||||
car: ReplayCarPosition,
|
|
||||||
bounds: TrackBounds,
|
|
||||||
): ReplayCarPosition {
|
|
||||||
return canvasToSvg(normalizeRawPoint({ x: car.x, y: car.y }, bounds))
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp01(value: number): number {
|
|
||||||
if (!Number.isFinite(value)) return 0
|
|
||||||
return Math.min(1, Math.max(0, value))
|
|
||||||
}
|
|
||||||
61
frontend/src/lib/rivalry.ts
Normal file
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)
|
||||||
|
}
|
||||||
@@ -1,33 +1,13 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import { fetchNews, fetchNewsArticle, markNewsRead } from '../api'
|
||||||
fetchChampionshipHub,
|
|
||||||
fetchNews,
|
|
||||||
fetchNewsArticle,
|
|
||||||
fetchSeasonMeetings,
|
|
||||||
fetchSeasons,
|
|
||||||
markNewsRead,
|
|
||||||
} from '../api'
|
|
||||||
import {
|
|
||||||
activeDigestWindow,
|
|
||||||
filterByTag,
|
|
||||||
groupByWindow,
|
|
||||||
gpWindows,
|
|
||||||
itemsForWindow,
|
|
||||||
sinceLastLabel,
|
|
||||||
sortWindowBucketsNewestFirst,
|
|
||||||
tagColour,
|
|
||||||
tagItems,
|
|
||||||
topTags,
|
|
||||||
type DigestTag,
|
|
||||||
type TaggedNewsItem,
|
|
||||||
} from '../lib/digest'
|
|
||||||
import { stripHtml, timeAgo } from '../utils'
|
import { stripHtml, timeAgo } from '../utils'
|
||||||
import type { ArticleContent, NewsItem } from '../types'
|
import type { ArticleContent, NewsItem } from '../types'
|
||||||
import '../styles/digest.css'
|
|
||||||
|
|
||||||
type Category = 'all' | 'official' | 'news' | 'video'
|
type Category = 'all' | 'official' | 'news' | 'video'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 16
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<Category, string> = {
|
const CATEGORY_LABELS: Record<Category, string> = {
|
||||||
all: 'All',
|
all: 'All',
|
||||||
official: 'Official',
|
official: 'Official',
|
||||||
@@ -91,31 +71,6 @@ function CategoryTabs({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TagChip({
|
|
||||||
tag,
|
|
||||||
active,
|
|
||||||
onClick,
|
|
||||||
className = 'digest-tag-chip',
|
|
||||||
}: {
|
|
||||||
tag: DigestTag
|
|
||||||
active?: boolean
|
|
||||||
onClick?: () => void
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${className}${active ? ' active' : ''}`}
|
|
||||||
style={{ '--tag-colour': tagColour(tag.colour) } as React.CSSProperties}
|
|
||||||
onClick={onClick}
|
|
||||||
aria-pressed={active}
|
|
||||||
>
|
|
||||||
<span className="digest-tag-dot" aria-hidden="true" />
|
|
||||||
{tag.label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function OGImage({ url, title }: { url?: string; title: string }) {
|
function OGImage({ url, title }: { url?: string; title: string }) {
|
||||||
const [failed, setFailed] = useState(false)
|
const [failed, setFailed] = useState(false)
|
||||||
const initial = (title[0] ?? '?').toUpperCase()
|
const initial = (title[0] ?? '?').toUpperCase()
|
||||||
@@ -143,15 +98,11 @@ function OGImage({ url, title }: { url?: string; title: string }) {
|
|||||||
function BriefingCard({
|
function BriefingCard({
|
||||||
item,
|
item,
|
||||||
isActive,
|
isActive,
|
||||||
activeTag,
|
|
||||||
onSelect,
|
onSelect,
|
||||||
onTagClick,
|
|
||||||
}: {
|
}: {
|
||||||
item: TaggedNewsItem
|
item: NewsItem
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
activeTag: string | null
|
|
||||||
onSelect: (item: NewsItem) => void
|
onSelect: (item: NewsItem) => void
|
||||||
onTagClick: (tag: DigestTag) => void
|
|
||||||
}) {
|
}) {
|
||||||
const isRead = !!item.read_at
|
const isRead = !!item.read_at
|
||||||
const isVideo = categoryOf(item) === 'video'
|
const isVideo = categoryOf(item) === 'video'
|
||||||
@@ -178,25 +129,6 @@ function BriefingCard({
|
|||||||
{stripHtml(item.og_description || item.summary || '')}
|
{stripHtml(item.og_description || item.summary || '')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{item.tags.length > 0 && (
|
|
||||||
<div className="bp-card-tags">
|
|
||||||
{item.tags.map((tag) => (
|
|
||||||
<button
|
|
||||||
key={tag.key}
|
|
||||||
type="button"
|
|
||||||
className={`bp-card-tag${activeTag === tag.key ? ' active' : ''}`}
|
|
||||||
style={{ '--tag-colour': tagColour(tag.colour) } as React.CSSProperties}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onTagClick(tag)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="digest-tag-dot" aria-hidden="true" />
|
|
||||||
{tag.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
@@ -214,6 +146,7 @@ function ReaderPanel({
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Close on Escape
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose()
|
if (e.key === 'Escape') onClose()
|
||||||
@@ -222,6 +155,7 @@ function ReaderPanel({
|
|||||||
return () => document.removeEventListener('keydown', handler)
|
return () => document.removeEventListener('keydown', handler)
|
||||||
}, [onClose])
|
}, [onClose])
|
||||||
|
|
||||||
|
// Fetch article when item changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!item) {
|
if (!item) {
|
||||||
setArticle(null)
|
setArticle(null)
|
||||||
@@ -241,14 +175,9 @@ function ReaderPanel({
|
|||||||
.catch((err) => { setError(String(err)); setLoading(false) })
|
.catch((err) => { setError(String(err)); setLoading(false) })
|
||||||
}, [item?.url, item ? categoryOf(item) : ''])
|
}, [item?.url, item ? categoryOf(item) : ''])
|
||||||
|
|
||||||
|
// Scroll panel to top when item changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const panel = panelRef.current
|
panelRef.current?.scrollTo({ top: 0 })
|
||||||
if (!panel) return
|
|
||||||
if (typeof panel.scrollTo === 'function') {
|
|
||||||
panel.scrollTo({ top: 0 })
|
|
||||||
} else {
|
|
||||||
panel.scrollTop = 0
|
|
||||||
}
|
|
||||||
}, [item?.url])
|
}, [item?.url])
|
||||||
|
|
||||||
const isOpen = item !== null
|
const isOpen = item !== null
|
||||||
@@ -368,6 +297,7 @@ function ReaderPanel({
|
|||||||
{article && !loading && article.content && (
|
{article && !loading && article.content && (
|
||||||
<div
|
<div
|
||||||
className="bp-reader-body"
|
className="bp-reader-body"
|
||||||
|
// readability strips scripts/iframes; sources are all known news outlets
|
||||||
// eslint-disable-next-line react/no-danger
|
// eslint-disable-next-line react/no-danger
|
||||||
dangerouslySetInnerHTML={{ __html: article.content }}
|
dangerouslySetInnerHTML={{ __html: article.content }}
|
||||||
/>
|
/>
|
||||||
@@ -402,9 +332,8 @@ function ReaderPanel({
|
|||||||
export function BriefingPage() {
|
export function BriefingPage() {
|
||||||
const [activeCategory, setActiveCategory] = useState<Category>('all')
|
const [activeCategory, setActiveCategory] = useState<Category>('all')
|
||||||
const [selectedItem, setSelectedItem] = useState<NewsItem | null>(null)
|
const [selectedItem, setSelectedItem] = useState<NewsItem | null>(null)
|
||||||
const [activeTag, setActiveTag] = useState<string | null>(null)
|
const [page, setPage] = useState(1)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const now = useMemo(() => new Date(), [])
|
|
||||||
|
|
||||||
const { data: allNews = [], isLoading, isError } = useQuery({
|
const { data: allNews = [], isLoading, isError } = useQuery({
|
||||||
queryKey: ['news'],
|
queryKey: ['news'],
|
||||||
@@ -412,74 +341,13 @@ export function BriefingPage() {
|
|||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const seasonsQuery = useQuery({
|
const filtered = allNews.filter(
|
||||||
queryKey: ['seasons'],
|
(item) => activeCategory === 'all' || categoryOf(item) === activeCategory,
|
||||||
queryFn: fetchSeasons,
|
|
||||||
})
|
|
||||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
|
||||||
|
|
||||||
const meetingsQuery = useQuery({
|
|
||||||
queryKey: ['season-meetings', latestSeason],
|
|
||||||
queryFn: () => fetchSeasonMeetings(latestSeason!),
|
|
||||||
enabled: latestSeason != null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const hubQuery = useQuery({
|
|
||||||
queryKey: ['championship-hub', latestSeason],
|
|
||||||
queryFn: () => fetchChampionshipHub(latestSeason!),
|
|
||||||
enabled: latestSeason != null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const meetings = meetingsQuery.data ?? []
|
|
||||||
const hub = hubQuery.data
|
|
||||||
|
|
||||||
const taggedNews = useMemo(
|
|
||||||
() => tagItems(allNews, hub?.drivers ?? [], hub?.teams ?? []),
|
|
||||||
[allNews, hub],
|
|
||||||
)
|
)
|
||||||
|
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
||||||
const categoryFiltered = useMemo(
|
const safePage = Math.min(page, pageCount)
|
||||||
() => taggedNews.filter(
|
const pageStart = (safePage - 1) * PAGE_SIZE
|
||||||
(item) => activeCategory === 'all' || categoryOf(item) === activeCategory,
|
const pagedItems = filtered.slice(pageStart, pageStart + PAGE_SIZE)
|
||||||
),
|
|
||||||
[taggedNews, activeCategory],
|
|
||||||
)
|
|
||||||
|
|
||||||
const tagFiltered = useMemo(
|
|
||||||
() => filterByTag(categoryFiltered, activeTag),
|
|
||||||
[categoryFiltered, activeTag],
|
|
||||||
)
|
|
||||||
|
|
||||||
const windows = useMemo(() => gpWindows(meetings, now), [meetings, now])
|
|
||||||
const grouped = useMemo(() => groupByWindow(tagFiltered, windows), [tagFiltered, windows])
|
|
||||||
const windowSections = useMemo(
|
|
||||||
() => sortWindowBucketsNewestFirst(grouped.windows),
|
|
||||||
[grouped.windows],
|
|
||||||
)
|
|
||||||
|
|
||||||
const activeWindow = useMemo(
|
|
||||||
() => activeDigestWindow(windows, meetings, now),
|
|
||||||
[windows, meetings, now],
|
|
||||||
)
|
|
||||||
|
|
||||||
const sinceLastItems = useMemo(
|
|
||||||
() => itemsForWindow(tagFiltered, activeWindow),
|
|
||||||
[tagFiltered, activeWindow],
|
|
||||||
)
|
|
||||||
|
|
||||||
const sinceLastTags = useMemo(() => topTags(sinceLastItems), [sinceLastItems])
|
|
||||||
const sinceLabel = sinceLastLabel(meetings, now)
|
|
||||||
|
|
||||||
const taggedByUrl = useMemo(() => {
|
|
||||||
const map = new Map<string, TaggedNewsItem>()
|
|
||||||
for (const item of tagFiltered) map.set(item.url, item)
|
|
||||||
return map
|
|
||||||
}, [tagFiltered])
|
|
||||||
|
|
||||||
const recentTagged = useMemo(() => {
|
|
||||||
const urls = new Set(grouped.recent.map((item) => item.url))
|
|
||||||
return tagFiltered.filter((item) => urls.has(item.url))
|
|
||||||
}, [grouped.recent, tagFiltered])
|
|
||||||
|
|
||||||
const counts: Record<Category, number> = {
|
const counts: Record<Category, number> = {
|
||||||
all: allNews.length,
|
all: allNews.length,
|
||||||
@@ -506,14 +374,22 @@ export function BriefingPage() {
|
|||||||
|
|
||||||
const handleClose = useCallback(() => setSelectedItem(null), [])
|
const handleClose = useCallback(() => setSelectedItem(null), [])
|
||||||
|
|
||||||
const handleTagClick = useCallback((tag: DigestTag) => {
|
useEffect(() => {
|
||||||
setActiveTag((current) => (current === tag.key ? null : tag.key))
|
setPage((current) => Math.min(current, pageCount))
|
||||||
setSelectedItem(null)
|
}, [pageCount])
|
||||||
}, [])
|
|
||||||
|
const handlePageChange = useCallback(
|
||||||
|
(nextPage: number) => {
|
||||||
|
setPage(Math.min(Math.max(nextPage, 1), pageCount))
|
||||||
|
setSelectedItem(null)
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
},
|
||||||
|
[pageCount],
|
||||||
|
)
|
||||||
|
|
||||||
const unreadCount = allNews.filter((i) => !i.read_at).length
|
const unreadCount = allNews.filter((i) => !i.read_at).length
|
||||||
const hasDigest = meetings.length > 0
|
const pageEnd = Math.min(pageStart + pagedItems.length, filtered.length)
|
||||||
const showEmpty = tagFiltered.length === 0 && grouped.recent.length === 0
|
const showPagination = filtered.length > PAGE_SIZE
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bp-page" data-testid="briefing-page">
|
<div className="bp-page" data-testid="briefing-page">
|
||||||
@@ -532,106 +408,74 @@ export function BriefingPage() {
|
|||||||
<CategoryTabs
|
<CategoryTabs
|
||||||
active={activeCategory}
|
active={activeCategory}
|
||||||
counts={counts}
|
counts={counts}
|
||||||
onChange={(c) => {
|
onChange={(c) => { setActiveCategory(c); setSelectedItem(null); setPage(1) }}
|
||||||
setActiveCategory(c)
|
|
||||||
setSelectedItem(null)
|
|
||||||
setActiveTag(null)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{hasDigest && (
|
{filtered.length === 0 ? (
|
||||||
<div className="digest-sticky" data-testid="digest-sticky-header">
|
|
||||||
<div className="digest-sticky-head">
|
|
||||||
<h2 className="digest-sticky-title">Since {sinceLabel}</h2>
|
|
||||||
<span className="digest-sticky-count">
|
|
||||||
{sinceLastItems.length} item{sinceLastItems.length === 1 ? '' : 's'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{(sinceLastTags.length > 0 || activeTag) && (
|
|
||||||
<div className="digest-sticky-tags">
|
|
||||||
{sinceLastTags.map((tag) => (
|
|
||||||
<TagChip
|
|
||||||
key={tag.key}
|
|
||||||
tag={tag}
|
|
||||||
active={activeTag === tag.key}
|
|
||||||
onClick={() => handleTagClick(tag)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{activeTag && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="digest-filter-clear"
|
|
||||||
onClick={() => setActiveTag(null)}
|
|
||||||
data-testid="digest-filter-clear"
|
|
||||||
>
|
|
||||||
Clear filter
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showEmpty ? (
|
|
||||||
<div className="bp-empty">
|
<div className="bp-empty">
|
||||||
No {activeCategory !== 'all' ? activeCategory : ''} items available.
|
No {activeCategory !== 'all' ? activeCategory : ''} items available.
|
||||||
{activeTag ? ' Try clearing the tag filter.' : ''}
|
Run <code>box-box --ingest-news</code> to refresh feeds.
|
||||||
{!activeTag && (
|
|
||||||
<>
|
|
||||||
{' '}
|
|
||||||
Run <code>box-box --ingest-news</code> to refresh feeds.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{windowSections.map(({ window, items }) => (
|
{showPagination && (
|
||||||
<section
|
<div className="bp-pagination bp-pagination-top">
|
||||||
key={window.meeting_key}
|
<span className="bp-pagination-meta mono">
|
||||||
className="digest-section"
|
{pageStart + 1}-{pageEnd} of {filtered.length}
|
||||||
data-testid={`digest-window-${window.meeting_key}`}
|
</span>
|
||||||
>
|
<div className="bp-pagination-actions">
|
||||||
<div className="digest-section-head">
|
<button
|
||||||
<h3 className="digest-section-title">{window.meeting_name}</h3>
|
className="bp-page-btn"
|
||||||
<span className="digest-section-count">{items.length}</span>
|
onClick={() => handlePageChange(safePage - 1)}
|
||||||
|
disabled={safePage === 1}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="bp-page-current mono">
|
||||||
|
Page {safePage} / {pageCount}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="bp-page-btn"
|
||||||
|
onClick={() => handlePageChange(safePage + 1)}
|
||||||
|
disabled={safePage === pageCount}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
|
</div>
|
||||||
{items.map((item) => {
|
)}
|
||||||
const tagged = taggedByUrl.get(item.url) ?? { ...item, tags: [] }
|
|
||||||
return (
|
|
||||||
<BriefingCard
|
|
||||||
key={item.url}
|
|
||||||
item={tagged}
|
|
||||||
isActive={selectedItem?.url === item.url}
|
|
||||||
activeTag={activeTag}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
onTagClick={handleTagClick}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{recentTagged.length > 0 && (
|
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
|
||||||
<section className="digest-section digest-recent-section" data-testid="digest-recent">
|
{pagedItems.map((item) => (
|
||||||
<div className="digest-section-head">
|
<BriefingCard
|
||||||
<h3 className="digest-section-title">Recent</h3>
|
key={item.url}
|
||||||
<span className="digest-section-count">{recentTagged.length}</span>
|
item={item}
|
||||||
</div>
|
isActive={selectedItem?.url === item.url}
|
||||||
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
|
onSelect={handleSelect}
|
||||||
{recentTagged.map((item) => (
|
/>
|
||||||
<BriefingCard
|
))}
|
||||||
key={item.url}
|
</div>
|
||||||
item={item}
|
|
||||||
isActive={selectedItem?.url === item.url}
|
{showPagination && (
|
||||||
activeTag={activeTag}
|
<div className="bp-pagination bp-pagination-bottom">
|
||||||
onSelect={handleSelect}
|
<button
|
||||||
onTagClick={handleTagClick}
|
className="bp-page-btn"
|
||||||
/>
|
onClick={() => handlePageChange(safePage - 1)}
|
||||||
))}
|
disabled={safePage === 1}
|
||||||
</div>
|
>
|
||||||
</section>
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="bp-page-current mono">
|
||||||
|
Page {safePage} / {pageCount}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="bp-page-btn"
|
||||||
|
onClick={() => handlePageChange(safePage + 1)}
|
||||||
|
disabled={safePage === pageCount}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ 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 { 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 +215,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' : ''}`}
|
||||||
@@ -245,12 +252,12 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
|||||||
leaderPoints={leader.points}
|
leaderPoints={leader.points}
|
||||||
titleMath={titleMath}
|
titleMath={titleMath}
|
||||||
roundsLeft={hub.rounds_left}
|
roundsLeft={hub.rounds_left}
|
||||||
drivers={drivers}
|
|
||||||
season={hub.season}
|
season={hub.season}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{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>
|
||||||
)
|
)
|
||||||
@@ -276,19 +283,15 @@ function DriversView({
|
|||||||
leaderPoints,
|
leaderPoints,
|
||||||
titleMath,
|
titleMath,
|
||||||
roundsLeft,
|
roundsLeft,
|
||||||
drivers,
|
|
||||||
season,
|
season,
|
||||||
}: {
|
}: {
|
||||||
enriched: EnrichedDriver[]
|
enriched: EnrichedDriver[]
|
||||||
leaderPoints: number
|
leaderPoints: number
|
||||||
titleMath: string
|
titleMath: string
|
||||||
roundsLeft: number
|
roundsLeft: number
|
||||||
drivers: ChampHubDriver[]
|
|
||||||
season: number
|
season: number
|
||||||
}) {
|
}) {
|
||||||
const podium = enriched.slice(0, 3)
|
const podium = enriched.slice(0, 3)
|
||||||
const h2hPairs = useMemo(() => teammatePairs(drivers), [drivers])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="champ-view-drivers">
|
<div data-testid="champ-view-drivers">
|
||||||
<div className="champ-podium">
|
<div className="champ-podium">
|
||||||
@@ -347,29 +350,6 @@ function DriversView({
|
|||||||
<span className="champ-titlemath-text">{titleMath}</span>
|
<span className="champ-titlemath-text">{titleMath}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{h2hPairs.length > 0 && (
|
|
||||||
<section className="h2h-section" data-testid="champ-teammate-battles">
|
|
||||||
<div className="h2h-section-head">
|
|
||||||
<h2 className="h2h-section-title">Teammate battles</h2>
|
|
||||||
<span className="h2h-section-sub">race finishes · closest first</span>
|
|
||||||
</div>
|
|
||||||
<div className="h2h-list">
|
|
||||||
{h2hPairs.map((pair) => (
|
|
||||||
<TeammateH2H
|
|
||||||
key={pair.teamName}
|
|
||||||
teamName={pair.teamName}
|
|
||||||
teamColour={pair.teamColour}
|
|
||||||
driverATla={pair.driverA.name_acronym}
|
|
||||||
driverBTla={pair.driverB.name_acronym}
|
|
||||||
winsA={pair.driverA.teammate_wins}
|
|
||||||
winsB={pair.driverB.teammate_wins}
|
|
||||||
extraNote={pair.extraCount > 0 ? `+${pair.extraCount}` : undefined}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="champ-scroll">
|
<div className="champ-scroll">
|
||||||
<table className="champ-table champ-table-drivers">
|
<table className="champ-table champ-table-drivers">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
.chapter-strip {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--s3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-strip-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--s4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-strip-title {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-strip-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--s2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-tour-btn {
|
|
||||||
font-family: var(--f-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
padding: var(--s1) var(--s3);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--s1);
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text-2);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-tour-btn:hover,
|
|
||||||
.chapter-tour-btn.active {
|
|
||||||
background: var(--surface-h);
|
|
||||||
border-color: var(--border-2);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-strip-scroll {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--s3);
|
|
||||||
overflow-x: auto;
|
|
||||||
padding-bottom: var(--s2);
|
|
||||||
scroll-snap-type: x mandatory;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-card {
|
|
||||||
flex: 0 0 min(240px, 72vw);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--s2);
|
|
||||||
padding: var(--s3) var(--s4);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--s2);
|
|
||||||
background: var(--surface);
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
scroll-snap-align: start;
|
|
||||||
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-card:hover {
|
|
||||||
background: var(--surface-h);
|
|
||||||
border-color: var(--border-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-card.active {
|
|
||||||
border-color: var(--accent, var(--red));
|
|
||||||
background: var(--surface-h);
|
|
||||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, var(--red)) 35%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-card.tour-active {
|
|
||||||
border-color: var(--yellow);
|
|
||||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--yellow) 40%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-card-top {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--s3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-kind {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 28px;
|
|
||||||
height: 20px;
|
|
||||||
padding: 0 var(--s2);
|
|
||||||
border-radius: var(--s1);
|
|
||||||
font-family: var(--f-mono);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text-2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-card.active .chapter-kind {
|
|
||||||
color: var(--text);
|
|
||||||
border-color: var(--border-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-kind--safety_car,
|
|
||||||
.chapter-kind--virtual_safety_car {
|
|
||||||
color: var(--orange, #f90);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-kind--red_flag {
|
|
||||||
color: var(--red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-kind--decisive_swing {
|
|
||||||
color: var(--green, #22c55e);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-kind--finish {
|
|
||||||
color: var(--yellow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-lap-range {
|
|
||||||
font-family: var(--f-mono);
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-3);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-headline {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.35;
|
|
||||||
color: var(--text);
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-strip-empty {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-3);
|
|
||||||
padding: var(--s3) 0;
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
/* ── Calendar-aware paddock digest ── */
|
|
||||||
|
|
||||||
.digest-sticky {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 20;
|
|
||||||
margin-bottom: var(--s5);
|
|
||||||
padding: var(--s4) var(--s5);
|
|
||||||
background: color-mix(in srgb, var(--bg) 92%, transparent);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.digest-sticky-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: var(--s3);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: var(--s3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.digest-sticky-title {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.digest-sticky-count {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-3);
|
|
||||||
font-family: var(--f-mono);
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.digest-sticky-tags {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--s2);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.digest-tag-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--s2);
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid var(--border-2);
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text-2);
|
|
||||||
font-size: 11px;
|
|
||||||
font-family: var(--f-mono);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.1s, color 0.1s, background 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.digest-tag-chip:hover {
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--surface-h);
|
|
||||||
}
|
|
||||||
|
|
||||||
.digest-tag-chip.active {
|
|
||||||
color: var(--text);
|
|
||||||
border-color: var(--tag-colour, var(--red));
|
|
||||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--tag-colour, var(--red)) 35%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.digest-tag-dot {
|
|
||||||
width: 7px;
|
|
||||||
height: 7px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--tag-colour, var(--text-3));
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.digest-filter-clear {
|
|
||||||
padding: 2px 8px;
|
|
||||||
border: 1px dashed var(--border-2);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: none;
|
|
||||||
color: var(--text-3);
|
|
||||||
font-size: 11px;
|
|
||||||
font-family: var(--f-mono);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.digest-filter-clear:hover {
|
|
||||||
color: var(--text);
|
|
||||||
border-color: var(--border-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.digest-section {
|
|
||||||
margin-bottom: var(--s6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.digest-section-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: var(--s3);
|
|
||||||
margin-bottom: var(--s4);
|
|
||||||
padding-bottom: var(--s3);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.digest-section-title {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
margin: 0;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.digest-section-count {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-3);
|
|
||||||
font-family: var(--f-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.digest-recent-section .digest-section-title {
|
|
||||||
color: var(--text-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bp-card-tags {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--s2);
|
|
||||||
margin-top: var(--s2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bp-card-tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text-2);
|
|
||||||
font-size: 10px;
|
|
||||||
font-family: var(--f-mono);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bp-card-tag:hover {
|
|
||||||
color: var(--text);
|
|
||||||
border-color: var(--border-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bp-card-tag.active {
|
|
||||||
border-color: var(--tag-colour, var(--red));
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-brief-digest-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--s2);
|
|
||||||
margin-bottom: var(--s3);
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-3);
|
|
||||||
font-family: var(--f-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-brief-digest-tags {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--s2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-brief-tag {
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text-2);
|
|
||||||
font-family: var(--f-mono);
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
/* Teammate head-to-head bars (issue #23). */
|
|
||||||
|
|
||||||
.h2h-section {
|
|
||||||
margin: 20px 0 24px;
|
|
||||||
padding: 16px 18px;
|
|
||||||
background: var(--surface-1, rgba(255, 255, 255, 0.03));
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h2h-section-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h2h-section-title {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.h2h-section-sub {
|
|
||||||
font-size: 11px;
|
|
||||||
font-family: var(--f-mono);
|
|
||||||
color: var(--text-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.h2h-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h2h-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(100px, 140px) 1fr;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h2h-team {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h2h-team-name {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-2);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h2h-extra {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-3);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h2h-bar-wrap {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h2h-tla {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text);
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h2h-tla--left {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h2h-tla--right {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h2h-bar {
|
|
||||||
position: relative;
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
height: 22px;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.h2h-bar-seg {
|
|
||||||
height: 100%;
|
|
||||||
transition: width 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h2h-bar-seg--muted {
|
|
||||||
opacity: 0.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h2h-score {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #fff;
|
|
||||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.h2h-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h2h-bar-wrap {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
.rs-replay-shell {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.8fr);
|
|
||||||
gap: var(--s5);
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rs-replay-main {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rs-replay-tools {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--s3);
|
|
||||||
margin-top: var(--s3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rs-tool-btn,
|
|
||||||
.rs-speed-btn {
|
|
||||||
height: 28px;
|
|
||||||
padding: 0 var(--s4);
|
|
||||||
font-family: var(--f-mono);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-2);
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border-2);
|
|
||||||
border-radius: 2px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rs-tool-btn:hover,
|
|
||||||
.rs-speed-btn:hover {
|
|
||||||
color: var(--text);
|
|
||||||
border-color: var(--text-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rs-tool-btn.active,
|
|
||||||
.rs-speed-btn.active {
|
|
||||||
color: var(--text);
|
|
||||||
border-color: var(--red);
|
|
||||||
background: rgba(230, 36, 41, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rs-speed-group {
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rs-replay-map-slot {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rs-replay-map-placeholder {
|
|
||||||
min-height: 244px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
padding: var(--s5);
|
|
||||||
color: var(--text-3);
|
|
||||||
font-family: var(--f-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
text-align: center;
|
|
||||||
border: 1px dashed var(--border-2);
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.replay-map-panel {
|
|
||||||
min-height: 244px;
|
|
||||||
height: 100%;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.replay-map-stage {
|
|
||||||
position: relative;
|
|
||||||
min-height: 244px;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.replay-map-svg {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 244px;
|
|
||||||
display: block;
|
|
||||||
background: radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.04), transparent 62%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.replay-map-outline-shadow,
|
|
||||||
.replay-map-outline {
|
|
||||||
fill: none;
|
|
||||||
vector-effect: non-scaling-stroke;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
}
|
|
||||||
|
|
||||||
.replay-map-outline-shadow {
|
|
||||||
stroke: rgba(255, 255, 255, 0.08);
|
|
||||||
stroke-width: 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.replay-map-outline {
|
|
||||||
stroke: var(--text-2);
|
|
||||||
stroke-width: 1.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.replay-car {
|
|
||||||
cursor: pointer;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.replay-car circle {
|
|
||||||
stroke: var(--bg);
|
|
||||||
stroke-width: 0.8;
|
|
||||||
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.55));
|
|
||||||
}
|
|
||||||
|
|
||||||
.replay-car text {
|
|
||||||
fill: #fff;
|
|
||||||
font-family: var(--f-mono);
|
|
||||||
font-size: 2.6px;
|
|
||||||
font-weight: 800;
|
|
||||||
text-anchor: middle;
|
|
||||||
pointer-events: none;
|
|
||||||
paint-order: stroke;
|
|
||||||
stroke: rgba(0, 0, 0, 0.75);
|
|
||||||
stroke-width: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.replay-car:not(.replay-car-pinned) text {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.replay-car:hover text,
|
|
||||||
.replay-car:focus-visible text,
|
|
||||||
.replay-car-pinned text {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.replay-car-pinned circle {
|
|
||||||
stroke: #fff;
|
|
||||||
stroke-width: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.replay-map-empty {
|
|
||||||
min-height: 244px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
padding: var(--s5);
|
|
||||||
color: var(--text-3);
|
|
||||||
font-family: var(--f-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
|
||||||
.rs-replay-shell {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
134
frontend/src/styles/rivalry.css
Normal file
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;
|
||||||
|
}
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
||||||
import { BriefingPage } from '../pages/BriefingPage'
|
|
||||||
import type { ChampHubDriver, ChampHubTeam, ChampionshipHub, Meeting, NewsItem } from '../types'
|
|
||||||
|
|
||||||
vi.mock('../api', () => ({
|
|
||||||
fetchNews: vi.fn(),
|
|
||||||
fetchNewsArticle: vi.fn(),
|
|
||||||
markNewsRead: vi.fn(),
|
|
||||||
fetchSeasons: vi.fn(),
|
|
||||||
fetchSeasonMeetings: vi.fn(),
|
|
||||||
fetchChampionshipHub: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import {
|
|
||||||
fetchNews,
|
|
||||||
fetchNewsArticle,
|
|
||||||
fetchSeasons,
|
|
||||||
fetchSeasonMeetings,
|
|
||||||
fetchChampionshipHub,
|
|
||||||
markNewsRead,
|
|
||||||
} from '../api'
|
|
||||||
|
|
||||||
const mockFetchNews = vi.mocked(fetchNews)
|
|
||||||
const mockFetchNewsArticle = vi.mocked(fetchNewsArticle)
|
|
||||||
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
|
||||||
const mockFetchSeasonMeetings = vi.mocked(fetchSeasonMeetings)
|
|
||||||
const mockFetchHub = vi.mocked(fetchChampionshipHub)
|
|
||||||
const mockMarkNewsRead = vi.mocked(markNewsRead)
|
|
||||||
|
|
||||||
const bahrain: Meeting = {
|
|
||||||
meeting_key: 1,
|
|
||||||
meeting_name: 'Bahrain',
|
|
||||||
meeting_official_name: 'Bahrain GP',
|
|
||||||
location: 'Sakhir',
|
|
||||||
country_name: 'Bahrain',
|
|
||||||
country_code: 'BRN',
|
|
||||||
country_flag: '',
|
|
||||||
circuit_short_name: 'Sakhir',
|
|
||||||
date_start: '2025-03-14T00:00:00+00:00',
|
|
||||||
date_end: '2025-03-16T23:59:59+00:00',
|
|
||||||
year: 2025,
|
|
||||||
}
|
|
||||||
|
|
||||||
const monaco: Meeting = {
|
|
||||||
meeting_key: 2,
|
|
||||||
meeting_name: 'Monaco',
|
|
||||||
meeting_official_name: 'Monaco GP',
|
|
||||||
location: 'Monaco',
|
|
||||||
country_name: 'Monaco',
|
|
||||||
country_code: 'MON',
|
|
||||||
country_flag: '',
|
|
||||||
circuit_short_name: 'Monaco',
|
|
||||||
date_start: '2025-05-23T00:00:00+00:00',
|
|
||||||
date_end: '2025-05-25T23:59:59+00:00',
|
|
||||||
year: 2025,
|
|
||||||
}
|
|
||||||
|
|
||||||
const drivers: ChampHubDriver[] = [
|
|
||||||
{
|
|
||||||
driver_number: 1,
|
|
||||||
name_acronym: 'VER',
|
|
||||||
full_name: 'Max Verstappen',
|
|
||||||
team_name: 'Red Bull',
|
|
||||||
team_colour: '3671c6',
|
|
||||||
points: 100,
|
|
||||||
position: 1,
|
|
||||||
wins: 3,
|
|
||||||
podiums: 5,
|
|
||||||
poles: 2,
|
|
||||||
form: [25, 18, 25],
|
|
||||||
cumulative: [25, 43, 68, 100],
|
|
||||||
teammate_wins: 4,
|
|
||||||
teammate_losses: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
driver_number: 4,
|
|
||||||
name_acronym: 'NOR',
|
|
||||||
full_name: 'Lando Norris',
|
|
||||||
team_name: 'McLaren',
|
|
||||||
team_colour: 'ff8000',
|
|
||||||
points: 80,
|
|
||||||
position: 2,
|
|
||||||
wins: 1,
|
|
||||||
podiums: 4,
|
|
||||||
poles: 1,
|
|
||||||
form: [18, 25, 18],
|
|
||||||
cumulative: [18, 36, 54, 80],
|
|
||||||
teammate_wins: 3,
|
|
||||||
teammate_losses: 2,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const teams: ChampHubTeam[] = [
|
|
||||||
{ team_name: 'Red Bull', team_colour: '3671c6', points: 150, position: 1, wins: 4 },
|
|
||||||
{ team_name: 'McLaren', team_colour: 'ff8000', points: 120, position: 2, wins: 2 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const hub: ChampionshipHub = {
|
|
||||||
season: 2025,
|
|
||||||
round: 2,
|
|
||||||
total_rounds: 2,
|
|
||||||
rounds_left: 0,
|
|
||||||
last_race: 'Monaco GP',
|
|
||||||
round_labels: ['R1', 'R2'],
|
|
||||||
drivers,
|
|
||||||
teams,
|
|
||||||
}
|
|
||||||
|
|
||||||
const newsItems: NewsItem[] = [
|
|
||||||
{
|
|
||||||
source: 'autosport-f1',
|
|
||||||
title: 'Verstappen sets the pace in Bahrain',
|
|
||||||
url: 'https://example.com/bahrain-ver',
|
|
||||||
published_at: '2025-03-15T10:00:00Z',
|
|
||||||
fetched_at: '2025-03-15T11:00:00Z',
|
|
||||||
summary: 'Red Bull dominate opening practice',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: 'racefans-f1',
|
|
||||||
title: 'Norris targets Monaco upgrade',
|
|
||||||
url: 'https://example.com/monaco-nor',
|
|
||||||
published_at: '2025-04-20T10:00:00Z',
|
|
||||||
fetched_at: '2025-04-20T11:00:00Z',
|
|
||||||
summary: 'McLaren bring new floor',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: 'bbc-f1',
|
|
||||||
title: 'Undated paddock rumour',
|
|
||||||
url: 'https://example.com/undated',
|
|
||||||
fetched_at: '2025-04-21T11:00:00Z',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function renderPage() {
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: { queries: { retry: false } },
|
|
||||||
})
|
|
||||||
|
|
||||||
return render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<BriefingPage />
|
|
||||||
</QueryClientProvider>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('BriefingPage digest layout', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
vi.setSystemTime(new Date('2025-04-10T12:00:00Z'))
|
|
||||||
mockFetchSeasons.mockResolvedValue([2025])
|
|
||||||
mockFetchSeasonMeetings.mockResolvedValue([bahrain, monaco])
|
|
||||||
mockFetchHub.mockResolvedValue(hub)
|
|
||||||
mockFetchNews.mockResolvedValue(newsItems)
|
|
||||||
mockFetchNewsArticle.mockResolvedValue({ title: 'Article', content: '<p>Body</p>' })
|
|
||||||
mockMarkNewsRead.mockResolvedValue(undefined)
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders sticky since-last header, GP sections, and recent bucket', async () => {
|
|
||||||
renderPage()
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('digest-sticky-header')).toHaveTextContent('Since Bahrain')
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(screen.getByTestId('digest-window-2')).toHaveTextContent('Monaco')
|
|
||||||
expect(screen.getByTestId('digest-window-1')).toHaveTextContent('Bahrain')
|
|
||||||
expect(screen.getByTestId('digest-recent')).toHaveTextContent('Undated paddock rumour')
|
|
||||||
expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Norris targets Monaco upgrade')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('filters the digest when a tag chip is clicked', async () => {
|
|
||||||
renderPage()
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('digest-sticky-header')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
const sticky = screen.getByTestId('digest-sticky-header')
|
|
||||||
const norTag = within(sticky).getByRole('button', { name: /^NOR$/i })
|
|
||||||
fireEvent.click(norTag)
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByText('Verstappen sets the pace in Bahrain')).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
expect(screen.getByText('Norris targets Monaco upgrade')).toBeInTheDocument()
|
|
||||||
expect(screen.getByTestId('digest-filter-clear')).toBeInTheDocument()
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('digest-filter-clear'))
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('preserves category tabs', async () => {
|
|
||||||
renderPage()
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByRole('tab', { name: /All/i })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
expect(screen.getByRole('tab', { name: /News/i })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -150,61 +169,4 @@ describe('ChampionshipPage', () => {
|
|||||||
})
|
})
|
||||||
expect(screen.getByText('No championship data')).toBeInTheDocument()
|
expect(screen.getByText('No championship data')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders teammate battles section ordered by closest split', async () => {
|
|
||||||
const h2hDrivers: ChampHubDriver[] = [
|
|
||||||
driver({ driver_number: 1, name_acronym: 'VER', team_name: 'Red Bull', points: 200, position: 1 }),
|
|
||||||
driver({
|
|
||||||
driver_number: 4,
|
|
||||||
name_acronym: 'NOR',
|
|
||||||
full_name: 'Lando Norris',
|
|
||||||
team_name: 'McLaren',
|
|
||||||
team_colour: 'ff8000',
|
|
||||||
points: 160,
|
|
||||||
position: 2,
|
|
||||||
teammate_wins: 6,
|
|
||||||
teammate_losses: 5,
|
|
||||||
cumulative: [18, 36, 54, 80, 120, 160],
|
|
||||||
}),
|
|
||||||
driver({
|
|
||||||
driver_number: 81,
|
|
||||||
name_acronym: 'PIA',
|
|
||||||
full_name: 'Oscar Piastri',
|
|
||||||
team_name: 'McLaren',
|
|
||||||
team_colour: 'ff8000',
|
|
||||||
points: 140,
|
|
||||||
position: 3,
|
|
||||||
teammate_wins: 5,
|
|
||||||
teammate_losses: 6,
|
|
||||||
cumulative: [12, 28, 45, 70, 110, 140],
|
|
||||||
}),
|
|
||||||
driver({
|
|
||||||
driver_number: 11,
|
|
||||||
name_acronym: 'PER',
|
|
||||||
full_name: 'Sergio Perez',
|
|
||||||
team_name: 'Red Bull',
|
|
||||||
team_colour: '3671c6',
|
|
||||||
points: 60,
|
|
||||||
position: 4,
|
|
||||||
teammate_wins: 1,
|
|
||||||
teammate_losses: 9,
|
|
||||||
cumulative: [5, 12, 20, 35, 50, 60],
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
mockFetchHub.mockResolvedValue({ ...hub, drivers: h2hDrivers })
|
|
||||||
renderPage()
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('champ-teammate-battles')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(screen.getByText('Teammate battles')).toBeInTheDocument()
|
|
||||||
const rows = screen.getAllByTestId('teammate-h2h')
|
|
||||||
expect(rows).toHaveLength(2)
|
|
||||||
// McLaren 6–5 is closer than Red Bull 9–1 — McLaren row first.
|
|
||||||
expect(rows[0]).toHaveTextContent('McLaren')
|
|
||||||
expect(rows[0]).toHaveTextContent('6–5')
|
|
||||||
expect(rows[1]).toHaveTextContent('Red Bull')
|
|
||||||
expect(rows[1]).toHaveTextContent('9–1')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import { ChapterStrip } from '../components/ChapterStrip'
|
|
||||||
import type { Chapter } from '../types'
|
|
||||||
|
|
||||||
const chapters: Chapter[] = [
|
|
||||||
{
|
|
||||||
kind: 'start',
|
|
||||||
title: 'Start',
|
|
||||||
headline: 'Lights out — the field charges into Turn 1',
|
|
||||||
start_lap: 1,
|
|
||||||
end_lap: 1,
|
|
||||||
start_time: '2025-05-25T13:00:00Z',
|
|
||||||
end_time: '2025-05-25T13:01:00Z',
|
|
||||||
driver_numbers: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: 'safety_car',
|
|
||||||
title: 'Safety Car (L12-L15)',
|
|
||||||
headline: 'Sainz incident brings out the Safety Car — leaders dive for the pits',
|
|
||||||
start_lap: 12,
|
|
||||||
end_lap: 15,
|
|
||||||
start_time: '2025-05-25T13:12:00Z',
|
|
||||||
end_time: '2025-05-25T13:15:00Z',
|
|
||||||
driver_numbers: [55],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const tMin = new Date('2025-05-25T13:00:00Z').getTime()
|
|
||||||
const tMax = new Date('2025-05-25T13:20:00Z').getTime()
|
|
||||||
const tRange = tMax - tMin
|
|
||||||
|
|
||||||
function renderStrip(
|
|
||||||
overrides: Partial<{
|
|
||||||
scrubTime: number | null
|
|
||||||
onChapterClick: (index: number, scrub: number) => void
|
|
||||||
}> = {},
|
|
||||||
) {
|
|
||||||
const onChapterClick = overrides.onChapterClick ?? vi.fn()
|
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<ChapterStrip
|
|
||||||
chapters={chapters}
|
|
||||||
scrubTime={overrides.scrubTime ?? null}
|
|
||||||
tMin={tMin}
|
|
||||||
tRange={tRange}
|
|
||||||
tourActive={false}
|
|
||||||
tourChapterIndex={null}
|
|
||||||
onChapterClick={onChapterClick}
|
|
||||||
onTourToggle={vi.fn()}
|
|
||||||
/>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
)
|
|
||||||
return { onChapterClick }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ChapterStrip', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders chapter headlines and lap ranges', () => {
|
|
||||||
renderStrip()
|
|
||||||
expect(screen.getByTestId('chapter-strip')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Lights out — the field charges into Turn 1')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('L12–L15')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('highlights the active chapter from scrub time', () => {
|
|
||||||
const scrub = (new Date('2025-05-25T13:13:00Z').getTime() - tMin) / tRange
|
|
||||||
renderStrip({ scrubTime: scrub })
|
|
||||||
expect(screen.getByTestId('chapter-card-1')).toHaveClass('active')
|
|
||||||
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('calls click handler to jump scrubber', () => {
|
|
||||||
const onChapterClick = vi.fn<(index: number, scrub: number) => void>()
|
|
||||||
renderStrip({ onChapterClick })
|
|
||||||
fireEvent.click(screen.getByTestId('chapter-card-1'))
|
|
||||||
expect(onChapterClick).toHaveBeenCalledTimes(1)
|
|
||||||
const [index, scrub] = onChapterClick.mock.calls[0]
|
|
||||||
expect(index).toBe(1)
|
|
||||||
expect(scrub).toBeCloseTo(0.6, 2)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
|
|
||||||
import type { RaceHub, ReplayFramesResponse, TrackOutline } from '../types'
|
|
||||||
|
|
||||||
vi.mock('../api', () => ({
|
|
||||||
fetchReplayFrames: vi.fn(),
|
|
||||||
fetchTrackOutline: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { fetchReplayFrames, fetchTrackOutline } from '../api'
|
|
||||||
|
|
||||||
const mockFetchReplayFrames = vi.mocked(fetchReplayFrames)
|
|
||||||
const mockFetchTrackOutline = vi.mocked(fetchTrackOutline)
|
|
||||||
|
|
||||||
const outline: TrackOutline = {
|
|
||||||
circuit_key: 1,
|
|
||||||
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
|
|
||||||
points: [
|
|
||||||
{ x: 0, y: 0 },
|
|
||||||
{ x: 1, y: 0 },
|
|
||||||
{ x: 1, y: 1 },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const replay: ReplayFramesResponse = {
|
|
||||||
session_key: 99,
|
|
||||||
interval_ms: 5000,
|
|
||||||
start_time: '2025-05-25T13:00:00Z',
|
|
||||||
frames: [{ t: 0, cars: { '1': { x: 10, y: 20 } } }],
|
|
||||||
}
|
|
||||||
|
|
||||||
const raceHub: RaceHub = {
|
|
||||||
source: 'local',
|
|
||||||
session_key: 99,
|
|
||||||
datasets: {
|
|
||||||
positions: { status: 'available', source: 'local', count: 2 },
|
|
||||||
},
|
|
||||||
meeting: {
|
|
||||||
meeting_key: 1,
|
|
||||||
meeting_name: 'Monaco Grand Prix',
|
|
||||||
meeting_official_name: 'FORMULA 1 GRAND PRIX DE MONACO 2025',
|
|
||||||
location: 'Monaco',
|
|
||||||
country_name: 'Monaco',
|
|
||||||
country_code: 'MON',
|
|
||||||
country_flag: '',
|
|
||||||
circuit_key: 1,
|
|
||||||
circuit_short_name: 'Monaco',
|
|
||||||
date_start: '2025-05-23T00:00:00Z',
|
|
||||||
date_end: '2025-05-25T00:00:00Z',
|
|
||||||
year: 2025,
|
|
||||||
},
|
|
||||||
session: {
|
|
||||||
session_key: 99,
|
|
||||||
session_name: 'Race',
|
|
||||||
session_type: 'Race',
|
|
||||||
circuit_key: 1,
|
|
||||||
meeting_key: 1,
|
|
||||||
date_start: '2025-05-25T13:00:00Z',
|
|
||||||
date_end: '2025-05-25T15:00:00Z',
|
|
||||||
gmt_offset: '02:00:00',
|
|
||||||
},
|
|
||||||
drivers: [],
|
|
||||||
results: [
|
|
||||||
{
|
|
||||||
driver_number: 1,
|
|
||||||
position: 1,
|
|
||||||
name_acronym: 'VER',
|
|
||||||
full_name: 'Max Verstappen',
|
|
||||||
team_name: 'Red Bull Racing',
|
|
||||||
team_colour: '3671C6',
|
|
||||||
dnf: false,
|
|
||||||
dns: false,
|
|
||||||
dsq: false,
|
|
||||||
duration: null,
|
|
||||||
gap_to_leader: null,
|
|
||||||
number_of_laps: 78,
|
|
||||||
points: 25,
|
|
||||||
session_key: 99,
|
|
||||||
meeting_key: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
starting_grid: [
|
|
||||||
{
|
|
||||||
driver_number: 1,
|
|
||||||
position: 1,
|
|
||||||
name_acronym: 'VER',
|
|
||||||
full_name: 'Max Verstappen',
|
|
||||||
team_name: 'Red Bull Racing',
|
|
||||||
team_colour: '3671C6',
|
|
||||||
session_key: 99,
|
|
||||||
meeting_key: 1,
|
|
||||||
lap_duration: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
stints: [],
|
|
||||||
pit_stops: [],
|
|
||||||
positions: [
|
|
||||||
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:00:00Z', position: 1 },
|
|
||||||
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:05:00Z', position: 1 },
|
|
||||||
],
|
|
||||||
race_control: [],
|
|
||||||
weather: [],
|
|
||||||
laps: [],
|
|
||||||
chapters: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCanvas() {
|
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
||||||
return render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<RaceStoryCanvas data={raceHub} />
|
|
||||||
</QueryClientProvider>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('RaceStoryCanvas replay map', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockFetchReplayFrames.mockResolvedValue(replay)
|
|
||||||
mockFetchTrackOutline.mockResolvedValue(outline)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('fetches replay frames lazily when the map panel opens', async () => {
|
|
||||||
renderCanvas()
|
|
||||||
|
|
||||||
expect(mockFetchReplayFrames).not.toHaveBeenCalled()
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Map' }))
|
|
||||||
|
|
||||||
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalledWith(99, 5000))
|
|
||||||
expect(mockFetchTrackOutline).toHaveBeenCalledWith(1, 2025)
|
|
||||||
expect(await screen.findByTestId('replay-track-map')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { render, screen } from '@testing-library/react'
|
|
||||||
import { ReplayTrackMap } from '../components/ReplayTrackMap'
|
|
||||||
import type { EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types'
|
|
||||||
|
|
||||||
const outline: TrackOutline = {
|
|
||||||
circuit_key: 1,
|
|
||||||
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
|
|
||||||
points: [
|
|
||||||
{ x: 0, y: 0 },
|
|
||||||
{ x: 1, y: 0 },
|
|
||||||
{ x: 1, y: 1 },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const replay: ReplayFramesResponse = {
|
|
||||||
session_key: 99,
|
|
||||||
interval_ms: 5000,
|
|
||||||
start_time: '2025-05-25T13:00:00Z',
|
|
||||||
frames: [
|
|
||||||
{ t: 0, cars: { '1': { x: 0, y: 0 } } },
|
|
||||||
{ t: 5000, cars: { '1': { x: 50, y: 50 } } },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const results: EnrichedResult[] = [
|
|
||||||
{
|
|
||||||
driver_number: 1,
|
|
||||||
position: 1,
|
|
||||||
name_acronym: 'VER',
|
|
||||||
full_name: 'Max Verstappen',
|
|
||||||
team_name: 'Red Bull Racing',
|
|
||||||
team_colour: '3671C6',
|
|
||||||
dnf: false,
|
|
||||||
dns: false,
|
|
||||||
dsq: false,
|
|
||||||
duration: null,
|
|
||||||
gap_to_leader: null,
|
|
||||||
number_of_laps: 78,
|
|
||||||
points: 25,
|
|
||||||
session_key: 99,
|
|
||||||
meeting_key: 1,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
describe('ReplayTrackMap', () => {
|
|
||||||
it('renders an empty state when replay frames are missing', () => {
|
|
||||||
render(<ReplayTrackMap outline={outline} replay={{ ...replay, frames: [] }} tMs={0} drivers={[]} results={results} />)
|
|
||||||
expect(screen.getByTestId('replay-track-map')).toHaveTextContent(/historical GPS unavailable/i)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders car labels from result metadata', () => {
|
|
||||||
render(<ReplayTrackMap outline={outline} replay={replay} tMs={2500} drivers={[]} results={results} />)
|
|
||||||
expect(screen.getByLabelText(/VER replay position/i)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
114
frontend/src/test/RivalryCompare.test.tsx
Normal file
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { render, screen } from '@testing-library/react'
|
|
||||||
import { TeammateH2H } from '../components/TeammateH2H'
|
|
||||||
|
|
||||||
describe('TeammateH2H', () => {
|
|
||||||
it('renders score and TLAs', () => {
|
|
||||||
render(
|
|
||||||
<TeammateH2H
|
|
||||||
teamName="Red Bull"
|
|
||||||
teamColour="3671c6"
|
|
||||||
driverATla="VER"
|
|
||||||
driverBTla="PER"
|
|
||||||
winsA={9}
|
|
||||||
winsB={1}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
expect(screen.getByText('VER')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('PER')).toBeInTheDocument()
|
|
||||||
expect(screen.getByTestId('h2h-score')).toHaveTextContent('9–1')
|
|
||||||
expect(screen.getByText('Red Bull')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sets proportional bar segment widths from wins', () => {
|
|
||||||
render(
|
|
||||||
<TeammateH2H
|
|
||||||
teamName="McLaren"
|
|
||||||
teamColour="ff8000"
|
|
||||||
driverATla="NOR"
|
|
||||||
driverBTla="PIA"
|
|
||||||
winsA={6}
|
|
||||||
winsB={4}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
const barA = screen.getByTestId('h2h-bar-a')
|
|
||||||
const barB = screen.getByTestId('h2h-bar-b')
|
|
||||||
expect(barA).toHaveStyle({ width: '60%' })
|
|
||||||
expect(barB).toHaveStyle({ width: '40%' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows extra driver note when provided', () => {
|
|
||||||
render(
|
|
||||||
<TeammateH2H
|
|
||||||
teamName="Red Bull"
|
|
||||||
teamColour="3671c6"
|
|
||||||
driverATla="VER"
|
|
||||||
driverBTla="PER"
|
|
||||||
winsA={5}
|
|
||||||
winsB={3}
|
|
||||||
extraNote="+1"
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
expect(screen.getByText('+1')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import {
|
|
||||||
activeChapterIndex,
|
|
||||||
chapterKindLabel,
|
|
||||||
chapterLapRange,
|
|
||||||
chapterStartScrub,
|
|
||||||
chapterTourDurations,
|
|
||||||
} from '../lib/chapters'
|
|
||||||
import type { Chapter } from '../types'
|
|
||||||
|
|
||||||
const sampleChapters: Chapter[] = [
|
|
||||||
{
|
|
||||||
kind: 'start',
|
|
||||||
title: 'Start',
|
|
||||||
headline: 'Lights out — the field charges into Turn 1',
|
|
||||||
start_lap: 1,
|
|
||||||
end_lap: 1,
|
|
||||||
start_time: '2025-05-25T13:00:00Z',
|
|
||||||
end_time: '2025-05-25T13:01:00Z',
|
|
||||||
driver_numbers: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: 'safety_car',
|
|
||||||
title: 'Safety Car (L12-L15)',
|
|
||||||
headline: 'Sainz incident brings out the Safety Car — leaders dive for the pits',
|
|
||||||
start_lap: 12,
|
|
||||||
end_lap: 15,
|
|
||||||
start_time: '2025-05-25T13:12:00Z',
|
|
||||||
end_time: '2025-05-25T13:15:00Z',
|
|
||||||
driver_numbers: [55],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
describe('chapters lib', () => {
|
|
||||||
it('labels chapter kinds', () => {
|
|
||||||
expect(chapterKindLabel('safety_car')).toBe('SC')
|
|
||||||
expect(chapterKindLabel('finish')).toBe('FIN')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('formats lap ranges', () => {
|
|
||||||
expect(chapterLapRange(sampleChapters[0])).toBe('L1')
|
|
||||||
expect(chapterLapRange(sampleChapters[1])).toBe('L12–L15')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('maps chapter start time to scrub position', () => {
|
|
||||||
const tMin = new Date('2025-05-25T13:00:00Z').getTime()
|
|
||||||
const tMax = new Date('2025-05-25T13:20:00Z').getTime()
|
|
||||||
const scrub = chapterStartScrub(sampleChapters[1], tMin, tMax - tMin)
|
|
||||||
expect(scrub).toBeCloseTo(0.6, 2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('finds active chapter from scrub time', () => {
|
|
||||||
const tMin = new Date('2025-05-25T13:00:00Z').getTime()
|
|
||||||
const tMax = new Date('2025-05-25T13:20:00Z').getTime()
|
|
||||||
const tRange = tMax - tMin
|
|
||||||
const scrub = (new Date('2025-05-25T13:13:00Z').getTime() - tMin) / tRange
|
|
||||||
expect(activeChapterIndex(sampleChapters, scrub, tMin, tRange)).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('splits 90s evenly across chapters', () => {
|
|
||||||
expect(chapterTourDurations(sampleChapters)).toEqual([45_000, 45_000])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import {
|
|
||||||
activeDigestWindow,
|
|
||||||
filterByTag,
|
|
||||||
groupByWindow,
|
|
||||||
gpWindows,
|
|
||||||
itemsForWindow,
|
|
||||||
sinceLastLabel,
|
|
||||||
sortWindowBucketsNewestFirst,
|
|
||||||
tagItems,
|
|
||||||
topTags,
|
|
||||||
windowForDate,
|
|
||||||
} from '../lib/digest'
|
|
||||||
import type { ChampHubDriver, ChampHubTeam, Meeting, NewsItem } from '../types'
|
|
||||||
|
|
||||||
const meeting = (overrides: Partial<Meeting> = {}): Meeting => ({
|
|
||||||
meeting_key: 1,
|
|
||||||
meeting_name: 'Bahrain',
|
|
||||||
meeting_official_name: 'Bahrain GP',
|
|
||||||
location: 'Sakhir',
|
|
||||||
country_name: 'Bahrain',
|
|
||||||
country_code: 'BRN',
|
|
||||||
country_flag: '',
|
|
||||||
circuit_short_name: 'Sakhir',
|
|
||||||
date_start: '2025-03-14T00:00:00+00:00',
|
|
||||||
date_end: '2025-03-16T23:59:59+00:00',
|
|
||||||
year: 2025,
|
|
||||||
...overrides,
|
|
||||||
})
|
|
||||||
|
|
||||||
const news = (overrides: Partial<NewsItem> = {}): NewsItem => ({
|
|
||||||
source: 'autosport-f1',
|
|
||||||
title: 'Headline',
|
|
||||||
url: `https://example.com/${Math.random()}`,
|
|
||||||
fetched_at: '2025-04-01T12:00:00Z',
|
|
||||||
...overrides,
|
|
||||||
})
|
|
||||||
|
|
||||||
const driver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
|
|
||||||
driver_number: 1,
|
|
||||||
name_acronym: 'VER',
|
|
||||||
full_name: 'Max Verstappen',
|
|
||||||
team_name: 'Red Bull',
|
|
||||||
team_colour: '3671c6',
|
|
||||||
points: 100,
|
|
||||||
position: 1,
|
|
||||||
wins: 3,
|
|
||||||
podiums: 5,
|
|
||||||
poles: 2,
|
|
||||||
form: [25, 18, 25],
|
|
||||||
cumulative: [25, 43, 68, 100],
|
|
||||||
teammate_wins: 4,
|
|
||||||
teammate_losses: 1,
|
|
||||||
...over,
|
|
||||||
})
|
|
||||||
|
|
||||||
const team = (over: Partial<ChampHubTeam>): ChampHubTeam => ({
|
|
||||||
team_name: 'Red Bull',
|
|
||||||
team_colour: '3671c6',
|
|
||||||
points: 150,
|
|
||||||
position: 1,
|
|
||||||
wins: 4,
|
|
||||||
...over,
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('gpWindows', () => {
|
|
||||||
const bahrain = meeting({ meeting_key: 1, meeting_name: 'Bahrain' })
|
|
||||||
const monaco = meeting({
|
|
||||||
meeting_key: 2,
|
|
||||||
meeting_name: 'Monaco',
|
|
||||||
date_start: '2025-05-23T00:00:00+00:00',
|
|
||||||
date_end: '2025-05-25T23:59:59+00:00',
|
|
||||||
})
|
|
||||||
const canada = meeting({
|
|
||||||
meeting_key: 3,
|
|
||||||
meeting_name: 'Canada',
|
|
||||||
date_start: '2025-06-13T00:00:00+00:00',
|
|
||||||
date_end: '2025-06-15T23:59:59+00:00',
|
|
||||||
})
|
|
||||||
|
|
||||||
it('derives inter-race windows mid-season', () => {
|
|
||||||
const now = new Date('2025-04-10T12:00:00Z')
|
|
||||||
const windows = gpWindows([bahrain, monaco, canada], now)
|
|
||||||
|
|
||||||
expect(windows).toHaveLength(3)
|
|
||||||
expect(windows[0].start).toBeNull()
|
|
||||||
expect(windows[0].end?.toISOString()).toBe(new Date('2025-05-23T00:00:00+00:00').toISOString())
|
|
||||||
expect(windows[1].start?.toISOString()).toBe(new Date('2025-03-16T23:59:59+00:00').toISOString())
|
|
||||||
expect(windows[1].end?.toISOString()).toBe(new Date('2025-06-13T00:00:00+00:00').toISOString())
|
|
||||||
expect(windows[2].start?.toISOString()).toBe(new Date('2025-05-25T23:59:59+00:00').toISOString())
|
|
||||||
expect(windows[2].end).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles before the first race of the season', () => {
|
|
||||||
const now = new Date('2025-01-01T00:00:00Z')
|
|
||||||
const windows = gpWindows([bahrain, monaco], now)
|
|
||||||
expect(windows[0].start).toBeNull()
|
|
||||||
expect(activeDigestWindow(windows, [bahrain, monaco], now)?.meeting_name).toBe('Bahrain')
|
|
||||||
expect(sinceLastLabel([bahrain, monaco], now)).toBe('Before Bahrain')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles after the final race of the season', () => {
|
|
||||||
const now = new Date('2025-12-01T00:00:00Z')
|
|
||||||
const windows = gpWindows([bahrain, monaco], now)
|
|
||||||
expect(activeDigestWindow(windows, [bahrain, monaco], now)?.meeting_name).toBe('Monaco')
|
|
||||||
expect(sinceLastLabel([bahrain, monaco], now)).toBe('Monaco')
|
|
||||||
expect(windows[1].end).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('groupByWindow', () => {
|
|
||||||
const bahrain = meeting({ meeting_key: 1 })
|
|
||||||
const monaco = meeting({
|
|
||||||
meeting_key: 2,
|
|
||||||
date_start: '2025-05-23T00:00:00+00:00',
|
|
||||||
date_end: '2025-05-25T23:59:59+00:00',
|
|
||||||
})
|
|
||||||
const windows = gpWindows([bahrain, monaco], new Date('2025-04-10T12:00:00Z'))
|
|
||||||
|
|
||||||
it('buckets dated items into the matching GP window', () => {
|
|
||||||
const items = [
|
|
||||||
news({ url: 'https://a', published_at: '2025-03-10T10:00:00Z', title: 'Pre-Bahrain' }),
|
|
||||||
news({ url: 'https://b', published_at: '2025-04-05T10:00:00Z', title: 'Between races' }),
|
|
||||||
news({ url: 'https://c', published_at: '2025-05-24T10:00:00Z', title: 'Monaco weekend' }),
|
|
||||||
]
|
|
||||||
|
|
||||||
const grouped = groupByWindow(items, windows)
|
|
||||||
const bahrainItems = grouped.windows.find((entry) => entry.window.meeting_key === 1)?.items ?? []
|
|
||||||
const monacoItems = grouped.windows.find((entry) => entry.window.meeting_key === 2)?.items ?? []
|
|
||||||
|
|
||||||
expect(bahrainItems).toHaveLength(1)
|
|
||||||
expect(bahrainItems[0].title).toBe('Pre-Bahrain')
|
|
||||||
expect(monacoItems).toHaveLength(2)
|
|
||||||
expect(monacoItems.map((item) => item.title)).toEqual(['Between races', 'Monaco weekend'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('puts undated items in the recent bucket', () => {
|
|
||||||
const items = [
|
|
||||||
news({ url: 'https://u', title: 'Undated story' }),
|
|
||||||
news({ url: 'https://d', published_at: '2025-04-05T10:00:00Z' }),
|
|
||||||
]
|
|
||||||
const grouped = groupByWindow(items, windows)
|
|
||||||
expect(grouped.recent).toHaveLength(1)
|
|
||||||
expect(grouped.recent[0].url).toBe('https://u')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sorts window buckets newest first for display', () => {
|
|
||||||
const items = [
|
|
||||||
news({ published_at: '2025-03-10T10:00:00Z' }),
|
|
||||||
news({ published_at: '2025-05-24T10:00:00Z' }),
|
|
||||||
]
|
|
||||||
const grouped = groupByWindow(items, windows)
|
|
||||||
const sorted = sortWindowBucketsNewestFirst(grouped.windows)
|
|
||||||
expect(sorted[0].window.meeting_key).toBe(2)
|
|
||||||
expect(sorted[1].window.meeting_key).toBe(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('tagItems', () => {
|
|
||||||
const drivers = [
|
|
||||||
driver({ name_acronym: 'VER', full_name: 'Max Verstappen', team_name: 'Red Bull' }),
|
|
||||||
driver({
|
|
||||||
driver_number: 4,
|
|
||||||
name_acronym: 'NOR',
|
|
||||||
full_name: 'Lando Norris',
|
|
||||||
team_name: 'McLaren',
|
|
||||||
team_colour: 'ff8000',
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
const teams = [
|
|
||||||
team({ team_name: 'Red Bull' }),
|
|
||||||
team({ team_name: 'McLaren', team_colour: 'ff8000' }),
|
|
||||||
]
|
|
||||||
|
|
||||||
it('tags drivers and teams case-insensitively', () => {
|
|
||||||
const [item] = tagItems(
|
|
||||||
[news({ title: 'ver leads mclaren in practice', summary: 'Norris close behind' })],
|
|
||||||
drivers,
|
|
||||||
teams,
|
|
||||||
)
|
|
||||||
const labels = item.tags.map((tag) => tag.label).sort()
|
|
||||||
expect(labels).toEqual(['McLaren', 'NOR', 'VER'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns no tags when nothing matches', () => {
|
|
||||||
const [item] = tagItems(
|
|
||||||
[news({ title: 'Generic paddock update' })],
|
|
||||||
drivers,
|
|
||||||
teams,
|
|
||||||
)
|
|
||||||
expect(item.tags).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('prefers the longest overlapping match', () => {
|
|
||||||
const [item] = tagItems(
|
|
||||||
[news({ title: 'Max Verstappen extends championship lead' })],
|
|
||||||
drivers,
|
|
||||||
teams,
|
|
||||||
)
|
|
||||||
expect(item.tags).toHaveLength(1)
|
|
||||||
expect(item.tags[0].label).toBe('VER')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('matches a driver last name inside prose', () => {
|
|
||||||
const [item] = tagItems(
|
|
||||||
[news({ title: 'Verstappen was untouchable in qualifying' })],
|
|
||||||
drivers,
|
|
||||||
teams,
|
|
||||||
)
|
|
||||||
expect(item.tags.some((tag) => tag.label === 'VER')).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('digest helpers', () => {
|
|
||||||
it('collects top tags by frequency', () => {
|
|
||||||
const tagged = tagItems(
|
|
||||||
[
|
|
||||||
news({ title: 'VER wins', url: 'https://a' }),
|
|
||||||
news({ title: 'VER dominates', url: 'https://b' }),
|
|
||||||
news({ title: 'Norris podium', url: 'https://c' }),
|
|
||||||
],
|
|
||||||
[driver({}), driver({ driver_number: 4, name_acronym: 'NOR', full_name: 'Lando Norris', team_name: 'McLaren' })],
|
|
||||||
[team({}), team({ team_name: 'McLaren', team_colour: 'ff8000' })],
|
|
||||||
)
|
|
||||||
expect(topTags(tagged, 2).map((tag) => tag.label)).toEqual(['VER', 'NOR'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('filters items by tag key', () => {
|
|
||||||
const tagged = tagItems(
|
|
||||||
[
|
|
||||||
news({ title: 'VER wins', url: 'https://a' }),
|
|
||||||
news({ title: 'Neutral headline', url: 'https://b' }),
|
|
||||||
],
|
|
||||||
[driver({})],
|
|
||||||
[team({})],
|
|
||||||
)
|
|
||||||
const filtered = filterByTag(tagged, 'driver:VER')
|
|
||||||
expect(filtered).toHaveLength(1)
|
|
||||||
expect(filtered[0].url).toBe('https://a')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('selects items for the active digest window', () => {
|
|
||||||
const bahrain = meeting({ meeting_key: 1 })
|
|
||||||
const monaco = meeting({
|
|
||||||
meeting_key: 2,
|
|
||||||
date_start: '2025-05-23T00:00:00+00:00',
|
|
||||||
date_end: '2025-05-25T23:59:59+00:00',
|
|
||||||
})
|
|
||||||
const now = new Date('2025-04-10T12:00:00Z')
|
|
||||||
const windows = gpWindows([bahrain, monaco], now)
|
|
||||||
const active = activeDigestWindow(windows, [bahrain, monaco], now)
|
|
||||||
const tagged = tagItems(
|
|
||||||
[
|
|
||||||
news({ published_at: '2025-04-05T10:00:00Z', url: 'https://in' }),
|
|
||||||
news({ published_at: '2025-03-10T10:00:00Z', url: 'https://out' }),
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
const inWindow = itemsForWindow(tagged, active)
|
|
||||||
expect(inWindow).toHaveLength(1)
|
|
||||||
expect(inWindow[0].url).toBe('https://in')
|
|
||||||
expect(windowForDate(windows, new Date('2025-04-05T10:00:00Z'))?.meeting_key).toBe(2)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { teammatePairs } from '../lib/h2h'
|
|
||||||
import type { ChampHubDriver } from '../types'
|
|
||||||
|
|
||||||
function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
|
|
||||||
return {
|
|
||||||
driver_number: 1,
|
|
||||||
name_acronym: 'VER',
|
|
||||||
full_name: 'Max Verstappen',
|
|
||||||
team_name: 'Red Bull',
|
|
||||||
team_colour: '3671c6',
|
|
||||||
points: 200,
|
|
||||||
position: 1,
|
|
||||||
wins: 5,
|
|
||||||
podiums: 8,
|
|
||||||
poles: 4,
|
|
||||||
form: [],
|
|
||||||
cumulative: [],
|
|
||||||
teammate_wins: 9,
|
|
||||||
teammate_losses: 1,
|
|
||||||
...over,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('teammatePairs', () => {
|
|
||||||
it('returns empty for no drivers', () => {
|
|
||||||
expect(teammatePairs([])).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('skips teams with a single driver', () => {
|
|
||||||
const drivers = [
|
|
||||||
driver({ driver_number: 1, team_name: 'Red Bull' }),
|
|
||||||
driver({ driver_number: 44, name_acronym: 'HAM', team_name: 'Mercedes', points: 50 }),
|
|
||||||
]
|
|
||||||
expect(teammatePairs(drivers)).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('pairs two-driver teams', () => {
|
|
||||||
const drivers = [
|
|
||||||
driver({ driver_number: 1, name_acronym: 'VER', points: 200, teammate_wins: 9, teammate_losses: 1 }),
|
|
||||||
driver({
|
|
||||||
driver_number: 11,
|
|
||||||
name_acronym: 'PER',
|
|
||||||
full_name: 'Sergio Perez',
|
|
||||||
points: 60,
|
|
||||||
teammate_wins: 1,
|
|
||||||
teammate_losses: 9,
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
const pairs = teammatePairs(drivers)
|
|
||||||
expect(pairs).toHaveLength(1)
|
|
||||||
expect(pairs[0].teamName).toBe('Red Bull')
|
|
||||||
expect(pairs[0].driverA.name_acronym).toBe('VER')
|
|
||||||
expect(pairs[0].driverB.name_acronym).toBe('PER')
|
|
||||||
expect(pairs[0].extraCount).toBe(0)
|
|
||||||
expect(pairs[0].closeness).toBe(8)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses top two by points for teams with 3+ drivers', () => {
|
|
||||||
const drivers = [
|
|
||||||
driver({ driver_number: 1, name_acronym: 'VER', team_name: 'Red Bull', points: 200 }),
|
|
||||||
driver({
|
|
||||||
driver_number: 11,
|
|
||||||
name_acronym: 'PER',
|
|
||||||
team_name: 'Red Bull',
|
|
||||||
points: 60,
|
|
||||||
teammate_wins: 1,
|
|
||||||
teammate_losses: 9,
|
|
||||||
}),
|
|
||||||
driver({
|
|
||||||
driver_number: 99,
|
|
||||||
name_acronym: 'LAW',
|
|
||||||
full_name: 'Liam Lawson',
|
|
||||||
team_name: 'Red Bull',
|
|
||||||
points: 10,
|
|
||||||
teammate_wins: 0,
|
|
||||||
teammate_losses: 0,
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
const pairs = teammatePairs(drivers)
|
|
||||||
expect(pairs).toHaveLength(1)
|
|
||||||
expect(pairs[0].driverA.name_acronym).toBe('VER')
|
|
||||||
expect(pairs[0].driverB.name_acronym).toBe('PER')
|
|
||||||
expect(pairs[0].extraCount).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sorts by closest battle first', () => {
|
|
||||||
const drivers = [
|
|
||||||
driver({
|
|
||||||
driver_number: 1,
|
|
||||||
name_acronym: 'VER',
|
|
||||||
team_name: 'Red Bull',
|
|
||||||
points: 200,
|
|
||||||
teammate_wins: 10,
|
|
||||||
teammate_losses: 0,
|
|
||||||
}),
|
|
||||||
driver({
|
|
||||||
driver_number: 11,
|
|
||||||
name_acronym: 'PER',
|
|
||||||
team_name: 'Red Bull',
|
|
||||||
points: 60,
|
|
||||||
teammate_wins: 0,
|
|
||||||
teammate_losses: 10,
|
|
||||||
}),
|
|
||||||
driver({
|
|
||||||
driver_number: 4,
|
|
||||||
name_acronym: 'NOR',
|
|
||||||
team_name: 'McLaren',
|
|
||||||
team_colour: 'ff8000',
|
|
||||||
points: 160,
|
|
||||||
teammate_wins: 6,
|
|
||||||
teammate_losses: 5,
|
|
||||||
}),
|
|
||||||
driver({
|
|
||||||
driver_number: 81,
|
|
||||||
name_acronym: 'PIA',
|
|
||||||
full_name: 'Oscar Piastri',
|
|
||||||
team_name: 'McLaren',
|
|
||||||
team_colour: 'ff8000',
|
|
||||||
points: 140,
|
|
||||||
teammate_wins: 5,
|
|
||||||
teammate_losses: 6,
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
const pairs = teammatePairs(drivers)
|
|
||||||
expect(pairs).toHaveLength(2)
|
|
||||||
expect(pairs[0].teamName).toBe('McLaren')
|
|
||||||
expect(pairs[0].closeness).toBe(1)
|
|
||||||
expect(pairs[1].teamName).toBe('Red Bull')
|
|
||||||
expect(pairs[1].closeness).toBe(10)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import type { ReplayFrame, TrackBounds } from '../types'
|
|
||||||
import { interpolateReplayCars, lookupReplayFramePair, replayCarToSvg } from '../lib/replay'
|
|
||||||
|
|
||||||
const frames: ReplayFrame[] = [
|
|
||||||
{ t: 0, cars: { '1': { x: 0, y: 0 } } },
|
|
||||||
{ t: 5000, cars: { '1': { x: 10, y: 20 }, '4': { x: 40, y: 80 } } },
|
|
||||||
{ t: 10000, cars: { '1': { x: 20, y: 40 } } },
|
|
||||||
]
|
|
||||||
|
|
||||||
describe('replay helpers', () => {
|
|
||||||
it('looks up boundary frame pairs', () => {
|
|
||||||
expect(lookupReplayFramePair(frames, -1)).toEqual({ previous: frames[0], next: frames[0] })
|
|
||||||
expect(lookupReplayFramePair(frames, 10000)).toEqual({ previous: frames[2], next: frames[2] })
|
|
||||||
expect(lookupReplayFramePair([], 1000)).toEqual({ previous: null, next: null })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('interpolates cars between frames and keeps one-sided samples visible', () => {
|
|
||||||
const cars = interpolateReplayCars(frames, 2500)
|
|
||||||
expect(cars['1']).toEqual({ x: 5, y: 10 })
|
|
||||||
expect(cars['4']).toEqual({ x: 40, y: 80 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('clamps interpolation outside the replay range', () => {
|
|
||||||
expect(interpolateReplayCars(frames, -500)['1']).toEqual({ x: 0, y: 0 })
|
|
||||||
expect(interpolateReplayCars(frames, 12000)['1']).toEqual({ x: 20, y: 40 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('maps replay GPS through the shared track-map coordinate transform', () => {
|
|
||||||
const bounds: TrackBounds = { minX: 0, maxX: 100, minY: 0, maxY: 200 }
|
|
||||||
expect(replayCarToSvg({ x: 50, y: 50 }, bounds)).toEqual({ x: 50, y: 75 })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
71
frontend/src/test/rivalry.test.ts
Normal file
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,
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ export interface RaceHub {
|
|||||||
export interface Chapter {
|
export interface Chapter {
|
||||||
kind: 'start' | 'safety_car' | 'virtual_safety_car' | 'red_flag' | 'pit_phase' | 'decisive_swing' | 'finish' | string
|
kind: 'start' | 'safety_car' | 'virtual_safety_car' | 'red_flag' | 'pit_phase' | 'decisive_swing' | 'finish' | string
|
||||||
title: string
|
title: string
|
||||||
headline: string
|
|
||||||
start_lap: number
|
start_lap: number
|
||||||
end_lap: number
|
end_lap: number
|
||||||
start_time?: string
|
start_time?: string
|
||||||
@@ -361,23 +360,6 @@ export interface TrackOutline {
|
|||||||
bounds: TrackBounds
|
bounds: TrackBounds
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReplayCarPosition {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReplayFrame {
|
|
||||||
t: number
|
|
||||||
cars: Record<string, ReplayCarPosition>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReplayFramesResponse {
|
|
||||||
session_key: number
|
|
||||||
interval_ms: number
|
|
||||||
start_time: string
|
|
||||||
frames: ReplayFrame[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChampHubDriver {
|
export interface ChampHubDriver {
|
||||||
driver_number: number
|
driver_number: number
|
||||||
name_acronym: string
|
name_acronym: string
|
||||||
@@ -391,6 +373,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ type Lap = models.Lap
|
|||||||
type Chapter struct {
|
type Chapter struct {
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Headline string `json:"headline"`
|
|
||||||
StartLap int `json:"start_lap"`
|
StartLap int `json:"start_lap"`
|
||||||
EndLap int `json:"end_lap"`
|
EndLap int `json:"end_lap"`
|
||||||
StartTime string `json:"start_time,omitempty"`
|
StartTime string `json:"start_time,omitempty"`
|
||||||
|
|||||||
@@ -1,233 +0,0 @@
|
|||||||
package chapters
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DriverIdentityInput carries session driver fields used for headline templates.
|
|
||||||
type DriverIdentityInput struct {
|
|
||||||
DriverNumber int
|
|
||||||
NameAcronym string
|
|
||||||
FullName string
|
|
||||||
TeamName string
|
|
||||||
}
|
|
||||||
|
|
||||||
type driverIdentity struct {
|
|
||||||
display string
|
|
||||||
team string
|
|
||||||
acronym string
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildDriverMap indexes driver identity from session drivers and enriched results.
|
|
||||||
func BuildDriverMap(drivers []models.Driver, results []DriverIdentityInput) map[int]driverIdentity {
|
|
||||||
out := map[int]driverIdentity{}
|
|
||||||
for _, d := range drivers {
|
|
||||||
if d.DriverNumber <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out[d.DriverNumber] = driverIdentity{
|
|
||||||
display: driverDisplayName(d.LastName, d.FullName, d.NameAcronym, d.BroadcastName),
|
|
||||||
team: d.TeamName,
|
|
||||||
acronym: firstNonEmpty(d.NameAcronym, d.BroadcastName),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, r := range results {
|
|
||||||
if r.DriverNumber <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := out[r.DriverNumber]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out[r.DriverNumber] = driverIdentity{
|
|
||||||
display: driverDisplayName("", r.FullName, r.NameAcronym, ""),
|
|
||||||
team: r.TeamName,
|
|
||||||
acronym: r.NameAcronym,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyHeadlines fills Headline on each chapter using deterministic templates.
|
|
||||||
func ApplyHeadlines(chapters []Chapter, drivers map[int]driverIdentity, rc []RaceControl, winnerNumber int) []Chapter {
|
|
||||||
out := make([]Chapter, len(chapters))
|
|
||||||
copy(out, chapters)
|
|
||||||
for i := range out {
|
|
||||||
out[i].Headline = headlineFor(out[i], drivers, rc, winnerNumber)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func headlineFor(ch Chapter, drivers map[int]driverIdentity, rc []RaceControl, winnerNumber int) string {
|
|
||||||
switch ch.Kind {
|
|
||||||
case KindStart:
|
|
||||||
return pickVariant(ch.StartLap,
|
|
||||||
"Lights out — the field charges into Turn 1",
|
|
||||||
"Race start — Lap 1 shuffle at the front",
|
|
||||||
)
|
|
||||||
case KindSafetyCar:
|
|
||||||
if name := driverName(ch, drivers, incidentDriver(rc, ch.StartLap)); name != "" {
|
|
||||||
return pickVariant(ch.StartLap,
|
|
||||||
fmt.Sprintf("%s incident brings out the Safety Car — leaders dive for the pits", name),
|
|
||||||
fmt.Sprintf("Safety Car deployed after %s stops on track", name),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case KindVirtualSafetyCar:
|
|
||||||
if name := driverName(ch, drivers, incidentDriver(rc, ch.StartLap)); name != "" {
|
|
||||||
return pickVariant(ch.StartLap,
|
|
||||||
fmt.Sprintf("%s off track triggers the Virtual Safety Car", name),
|
|
||||||
fmt.Sprintf("Virtual Safety Car — %s loses control on Lap %d", name, ch.StartLap),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return pickVariant(ch.StartLap,
|
|
||||||
fmt.Sprintf("Virtual Safety Car deployed on Lap %d", ch.StartLap),
|
|
||||||
fmt.Sprintf("VSC period — field backs off on L%d–L%d", ch.StartLap, ch.EndLap),
|
|
||||||
)
|
|
||||||
case KindRedFlag:
|
|
||||||
if name := driverName(ch, drivers, incidentDriver(rc, ch.StartLap)); name != "" {
|
|
||||||
return pickVariant(ch.StartLap,
|
|
||||||
fmt.Sprintf("%s crash forces a red flag on Lap %d", name, ch.StartLap),
|
|
||||||
fmt.Sprintf("Red flag after %s incident on Lap %d", name, ch.StartLap),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return pickVariant(ch.StartLap,
|
|
||||||
fmt.Sprintf("Red flag — session halted on Lap %d", ch.StartLap),
|
|
||||||
fmt.Sprintf("Race suspended under red flag on L%d–L%d", ch.StartLap, ch.EndLap),
|
|
||||||
)
|
|
||||||
case KindPitPhase:
|
|
||||||
names := driverNames(ch.DriverNumbers, drivers, 3)
|
|
||||||
if len(names) >= 2 {
|
|
||||||
return pickVariant(ch.StartLap,
|
|
||||||
fmt.Sprintf("Mass pit-window scramble — %s and %s box on L%d–L%d", names[0], names[1], ch.StartLap, ch.EndLap),
|
|
||||||
fmt.Sprintf("Undercut window opens — %s leads the pit rush on L%d–L%d", names[0], ch.StartLap, ch.EndLap),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if len(names) == 1 {
|
|
||||||
return pickVariant(ch.StartLap,
|
|
||||||
fmt.Sprintf("%s pits under green — strategy window opens on L%d", names[0], ch.StartLap),
|
|
||||||
fmt.Sprintf("Pit phase on L%d–L%d — %s among the first to stop", ch.StartLap, ch.EndLap, names[0]),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case KindDecisiveSwing:
|
|
||||||
if len(ch.DriverNumbers) >= 2 {
|
|
||||||
attacker := driverName(ch, drivers, ch.DriverNumbers[0])
|
|
||||||
defender := driverName(ch, drivers, ch.DriverNumbers[1])
|
|
||||||
pos := swingPosition(ch.Title)
|
|
||||||
if attacker != "" && defender != "" && pos > 0 {
|
|
||||||
return pickVariant(ch.StartLap,
|
|
||||||
fmt.Sprintf("%s overtakes %s for P%d", attacker, defender, pos),
|
|
||||||
fmt.Sprintf("%s charges past %s into P%d on Lap %d", attacker, defender, pos, ch.StartLap),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(ch.DriverNumbers) >= 1 {
|
|
||||||
attacker := driverName(ch, drivers, ch.DriverNumbers[0])
|
|
||||||
pos := swingPosition(ch.Title)
|
|
||||||
if attacker != "" && pos > 0 {
|
|
||||||
return pickVariant(ch.StartLap,
|
|
||||||
fmt.Sprintf("%s moves up to P%d on Lap %d", attacker, pos, ch.StartLap),
|
|
||||||
fmt.Sprintf("Decisive swing — %s climbs to P%d", attacker, pos),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case KindFinish:
|
|
||||||
winner := driverName(ch, drivers, winnerNumber)
|
|
||||||
if winner != "" {
|
|
||||||
return pickVariant(ch.StartLap,
|
|
||||||
fmt.Sprintf("%s crosses the line to take the win", winner),
|
|
||||||
fmt.Sprintf("Chequered flag — %s wins the race", winner),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ch.Title
|
|
||||||
}
|
|
||||||
|
|
||||||
func pickVariant(seed int, variants ...string) string {
|
|
||||||
if len(variants) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if seed < 0 {
|
|
||||||
seed = -seed
|
|
||||||
}
|
|
||||||
return variants[seed%len(variants)]
|
|
||||||
}
|
|
||||||
|
|
||||||
func driverName(_ Chapter, drivers map[int]driverIdentity, number int) string {
|
|
||||||
if number <= 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if id, ok := drivers[number]; ok && id.display != "" {
|
|
||||||
return id.display
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func driverNames(numbers []int, drivers map[int]driverIdentity, limit int) []string {
|
|
||||||
out := make([]string, 0, limit)
|
|
||||||
for _, number := range numbers {
|
|
||||||
if name := driverName(Chapter{}, drivers, number); name != "" {
|
|
||||||
out = append(out, name)
|
|
||||||
if len(out) >= limit {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func driverDisplayName(lastName, fullName, acronym, broadcast string) string {
|
|
||||||
if lastName != "" {
|
|
||||||
return lastName
|
|
||||||
}
|
|
||||||
if fullName != "" {
|
|
||||||
parts := strings.Fields(fullName)
|
|
||||||
if len(parts) > 0 {
|
|
||||||
return parts[len(parts)-1]
|
|
||||||
}
|
|
||||||
return fullName
|
|
||||||
}
|
|
||||||
return firstNonEmpty(acronym, broadcast)
|
|
||||||
}
|
|
||||||
|
|
||||||
func incidentDriver(rc []RaceControl, startLap int) int {
|
|
||||||
for _, msg := range rc {
|
|
||||||
if msg.DriverNumber == nil || *msg.DriverNumber <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lap := 0
|
|
||||||
if msg.LapNumber != nil {
|
|
||||||
lap = *msg.LapNumber
|
|
||||||
}
|
|
||||||
if lap < startLap-1 || lap > startLap+1 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
text := upperText(msg.Message, string(msg.Category))
|
|
||||||
if strings.Contains(text, "DEPLOY") ||
|
|
||||||
strings.Contains(text, "CLEAR") ||
|
|
||||||
strings.Contains(text, "ENDING") ||
|
|
||||||
strings.Contains(text, "GREEN") ||
|
|
||||||
strings.Contains(text, "CHEQUER") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return *msg.DriverNumber
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func swingPosition(title string) int {
|
|
||||||
// Title format: "Decisive swing: #16 to P3 (L6)"
|
|
||||||
idx := strings.Index(title, "P")
|
|
||||||
if idx < 0 || idx+1 >= len(title) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
pos := 0
|
|
||||||
for i := idx + 1; i < len(title); i++ {
|
|
||||||
if title[i] < '0' || title[i] > '9' {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
pos = pos*10 + int(title[i]-'0')
|
|
||||||
}
|
|
||||||
return pos
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
package chapters
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestHeadlineStart(t *testing.T) {
|
|
||||||
ch := Chapter{Kind: KindStart, Title: "Start", StartLap: 1, EndLap: 1}
|
|
||||||
got := headlineFor(ch, nil, nil, 0)
|
|
||||||
want := "Race start — Lap 1 shuffle at the front"
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("headline = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHeadlineStartVariantByLap(t *testing.T) {
|
|
||||||
ch := Chapter{Kind: KindStart, Title: "Start", StartLap: 2, EndLap: 2}
|
|
||||||
got := headlineFor(ch, nil, nil, 0)
|
|
||||||
want := "Lights out — the field charges into Turn 1"
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("headline = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHeadlineSafetyCarWithDriver(t *testing.T) {
|
|
||||||
drivers := BuildDriverMap(nil, []DriverIdentityInput{
|
|
||||||
{DriverNumber: 55, NameAcronym: "SAI", FullName: "Carlos Sainz", TeamName: "Ferrari"},
|
|
||||||
})
|
|
||||||
rc := []RaceControl{
|
|
||||||
{
|
|
||||||
DriverNumber: intPtr(55),
|
|
||||||
LapNumber: intPtr(12),
|
|
||||||
Message: "CAR 55 STOPPED ON TRACK",
|
|
||||||
},
|
|
||||||
rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
|
|
||||||
rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
|
|
||||||
}
|
|
||||||
ch := Chapter{
|
|
||||||
Kind: KindSafetyCar,
|
|
||||||
Title: "Safety Car (L12-L15)",
|
|
||||||
StartLap: 12,
|
|
||||||
EndLap: 15,
|
|
||||||
}
|
|
||||||
got := headlineFor(ch, drivers, rc, 0)
|
|
||||||
want := "Sainz incident brings out the Safety Car — leaders dive for the pits"
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("headline = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHeadlineSafetyCarFallbackWithoutDriver(t *testing.T) {
|
|
||||||
ch := Chapter{
|
|
||||||
Kind: KindSafetyCar,
|
|
||||||
Title: "Safety Car (L12-L15)",
|
|
||||||
StartLap: 12,
|
|
||||||
EndLap: 15,
|
|
||||||
}
|
|
||||||
got := headlineFor(ch, nil, nil, 0)
|
|
||||||
if got != ch.Title {
|
|
||||||
t.Fatalf("headline = %q, want fallback %q", got, ch.Title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHeadlineVirtualSafetyCar(t *testing.T) {
|
|
||||||
drivers := BuildDriverMap(nil, []DriverIdentityInput{
|
|
||||||
{DriverNumber: 16, NameAcronym: "LEC", FullName: "Charles Leclerc", TeamName: "Ferrari"},
|
|
||||||
})
|
|
||||||
rc := []RaceControl{
|
|
||||||
{
|
|
||||||
DriverNumber: intPtr(16),
|
|
||||||
LapNumber: intPtr(22),
|
|
||||||
Message: "CAR 16 OFF TRACK",
|
|
||||||
},
|
|
||||||
rc(22, models.CategoryOther, "", "VSC DEPLOYED"),
|
|
||||||
rc(24, models.CategoryOther, "", "VSC ENDING"),
|
|
||||||
}
|
|
||||||
ch := Chapter{
|
|
||||||
Kind: KindVirtualSafetyCar,
|
|
||||||
Title: "Virtual Safety Car (L22-L24)",
|
|
||||||
StartLap: 22,
|
|
||||||
EndLap: 24,
|
|
||||||
}
|
|
||||||
got := headlineFor(ch, drivers, rc, 0)
|
|
||||||
want := "Leclerc off track triggers the Virtual Safety Car"
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("headline = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHeadlineRedFlag(t *testing.T) {
|
|
||||||
drivers := BuildDriverMap(nil, []DriverIdentityInput{
|
|
||||||
{DriverNumber: 63, NameAcronym: "RUS", FullName: "George Russell", TeamName: "Mercedes"},
|
|
||||||
})
|
|
||||||
rc := []RaceControl{
|
|
||||||
{
|
|
||||||
DriverNumber: intPtr(63),
|
|
||||||
LapNumber: intPtr(31),
|
|
||||||
Message: "INCIDENT INVOLVING CAR 63",
|
|
||||||
},
|
|
||||||
rc(31, models.CategoryFlag, models.FlagRed, "RED FLAG"),
|
|
||||||
rc(33, models.CategoryFlag, models.FlagGreen, "GREEN FLAG"),
|
|
||||||
}
|
|
||||||
ch := Chapter{
|
|
||||||
Kind: KindRedFlag,
|
|
||||||
Title: "Red Flag (L31-L33)",
|
|
||||||
StartLap: 31,
|
|
||||||
EndLap: 33,
|
|
||||||
}
|
|
||||||
got := headlineFor(ch, drivers, rc, 0)
|
|
||||||
want := "Red flag after Russell incident on Lap 31"
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("headline = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHeadlinePitPhase(t *testing.T) {
|
|
||||||
drivers := BuildDriverMap(nil, []DriverIdentityInput{
|
|
||||||
{DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull"},
|
|
||||||
{DriverNumber: 44, NameAcronym: "HAM", FullName: "Lewis Hamilton", TeamName: "Mercedes"},
|
|
||||||
{DriverNumber: 16, NameAcronym: "LEC", FullName: "Charles Leclerc", TeamName: "Ferrari"},
|
|
||||||
})
|
|
||||||
ch := Chapter{
|
|
||||||
Kind: KindPitPhase,
|
|
||||||
Title: "Pit phase (L20-L22)",
|
|
||||||
StartLap: 20,
|
|
||||||
EndLap: 22,
|
|
||||||
DriverNumbers: []int{1, 44, 16},
|
|
||||||
}
|
|
||||||
got := headlineFor(ch, drivers, nil, 0)
|
|
||||||
want := "Mass pit-window scramble — Verstappen and Hamilton box on L20–L22"
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("headline = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHeadlineDecisiveSwing(t *testing.T) {
|
|
||||||
drivers := BuildDriverMap(nil, []DriverIdentityInput{
|
|
||||||
{DriverNumber: 16, NameAcronym: "LEC", FullName: "Charles Leclerc", TeamName: "Ferrari"},
|
|
||||||
{DriverNumber: 55, NameAcronym: "SAI", FullName: "Carlos Sainz", TeamName: "Ferrari"},
|
|
||||||
})
|
|
||||||
ch := Chapter{
|
|
||||||
Kind: KindDecisiveSwing,
|
|
||||||
Title: "Decisive swing: #16 to P3 (L6)",
|
|
||||||
StartLap: 6,
|
|
||||||
EndLap: 6,
|
|
||||||
DriverNumbers: []int{16, 55},
|
|
||||||
}
|
|
||||||
got := headlineFor(ch, drivers, nil, 0)
|
|
||||||
want := "Leclerc overtakes Sainz for P3"
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("headline = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHeadlineFinish(t *testing.T) {
|
|
||||||
drivers := BuildDriverMap(nil, []DriverIdentityInput{
|
|
||||||
{DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull"},
|
|
||||||
})
|
|
||||||
ch := Chapter{
|
|
||||||
Kind: KindFinish,
|
|
||||||
Title: "Finish (L57-L58)",
|
|
||||||
StartLap: 57,
|
|
||||||
EndLap: 58,
|
|
||||||
}
|
|
||||||
got := headlineFor(ch, drivers, nil, 1)
|
|
||||||
want := "Chequered flag — Verstappen wins the race"
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("headline = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHeadlineFinishFallbackWithoutWinner(t *testing.T) {
|
|
||||||
ch := Chapter{
|
|
||||||
Kind: KindFinish,
|
|
||||||
Title: "Finish (L57-L58)",
|
|
||||||
StartLap: 57,
|
|
||||||
EndLap: 58,
|
|
||||||
}
|
|
||||||
got := headlineFor(ch, nil, nil, 0)
|
|
||||||
if got != ch.Title {
|
|
||||||
t.Fatalf("headline = %q, want fallback %q", got, ch.Title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyHeadlinesPreservesChapterFields(t *testing.T) {
|
|
||||||
input := []Chapter{
|
|
||||||
{Kind: KindStart, Title: "Start", StartLap: 1, EndLap: 1},
|
|
||||||
}
|
|
||||||
got := ApplyHeadlines(input, nil, nil, 0)
|
|
||||||
if len(got) != 1 || got[0].StartLap != 1 || got[0].Headline == "" {
|
|
||||||
t.Fatalf("ApplyHeadlines = %+v, want headline on start chapter", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func intPtr(v int) *int {
|
|
||||||
return &v
|
|
||||||
}
|
|
||||||
@@ -252,38 +252,10 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hub.Chapters = chapters.Detect(hub.RaceControl, hub.Positions, hub.Laps, totalLaps(hub.Results, hub.Laps))
|
hub.Chapters = chapters.Detect(hub.RaceControl, hub.Positions, hub.Laps, totalLaps(hub.Results, hub.Laps))
|
||||||
hub.Chapters = chapters.ApplyHeadlines(
|
|
||||||
hub.Chapters,
|
|
||||||
chapters.BuildDriverMap(hub.Drivers, driverIdentityInputs(hub.Results)),
|
|
||||||
hub.RaceControl,
|
|
||||||
winnerDriverNumber(hub.Results),
|
|
||||||
)
|
|
||||||
hub.Source = responseSource(hub.Datasets)
|
hub.Source = responseSource(hub.Datasets)
|
||||||
return hub, nil
|
return hub, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func driverIdentityInputs(results []EnrichedResult) []chapters.DriverIdentityInput {
|
|
||||||
out := make([]chapters.DriverIdentityInput, 0, len(results))
|
|
||||||
for _, r := range results {
|
|
||||||
out = append(out, chapters.DriverIdentityInput{
|
|
||||||
DriverNumber: r.DriverNumber,
|
|
||||||
NameAcronym: r.NameAcronym,
|
|
||||||
FullName: r.FullName,
|
|
||||||
TeamName: r.TeamName,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func winnerDriverNumber(results []EnrichedResult) int {
|
|
||||||
for _, r := range results {
|
|
||||||
if r.Position == 1 {
|
|
||||||
return r.DriverNumber
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func totalLaps(results []EnrichedResult, laps []models.Lap) int {
|
func totalLaps(results []EnrichedResult, laps []models.Lap) int {
|
||||||
total := 0
|
total := 0
|
||||||
for _, result := range results {
|
for _, result := range results {
|
||||||
|
|||||||
@@ -614,8 +614,9 @@ type champHubDriver struct {
|
|||||||
Wins int `json:"wins"`
|
Wins int `json:"wins"`
|
||||||
Podiums int `json:"podiums"`
|
Podiums int `json:"podiums"`
|
||||||
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))
|
||||||
|
|||||||
@@ -1,259 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"math"
|
|
||||||
"net/http"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
defaultReplayIntervalMS = 5000
|
|
||||||
maxReplayFrames = 3000
|
|
||||||
replayFetchConcurrency = 4
|
|
||||||
)
|
|
||||||
|
|
||||||
type replayDataClient interface {
|
|
||||||
GetDriversForSession(sessionKey int) ([]models.Driver, error)
|
|
||||||
GetLocation(sessionKey, driverNumber int) ([]models.Location, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type replayFramesResponse struct {
|
|
||||||
SessionKey int `json:"session_key"`
|
|
||||||
Interval int `json:"interval_ms"`
|
|
||||||
StartTime string `json:"start_time"`
|
|
||||||
Frames []replayFrame `json:"frames"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type replayFrame struct {
|
|
||||||
T int64 `json:"t"`
|
|
||||||
Cars map[string]replayCar `json:"cars"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type replayCar struct {
|
|
||||||
X float64 `json:"x"`
|
|
||||||
Y float64 `json:"y"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleReplayFrames(w http.ResponseWriter, r *http.Request) {
|
|
||||||
sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key"))
|
|
||||||
if err != nil || sessionKey == 0 {
|
|
||||||
http.Error(w, "session_key required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
intervalMS := defaultReplayIntervalMS
|
|
||||||
rawInterval := r.URL.Query().Get("interval_ms")
|
|
||||||
if rawInterval != "" {
|
|
||||||
parsed, err := strconv.Atoi(rawInterval)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "invalid interval_ms", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if parsed > intervalMS {
|
|
||||||
intervalMS = parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := assembleReplayFrames(r.Context(), s.client, sessionKey, intervalMS)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
func assembleReplayFrames(ctx context.Context, client replayDataClient, sessionKey, intervalMS int) (replayFramesResponse, error) {
|
|
||||||
if intervalMS < defaultReplayIntervalMS {
|
|
||||||
intervalMS = defaultReplayIntervalMS
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := replayFramesResponse{
|
|
||||||
SessionKey: sessionKey,
|
|
||||||
Interval: intervalMS,
|
|
||||||
Frames: []replayFrame{},
|
|
||||||
}
|
|
||||||
|
|
||||||
drivers, err := client.GetDriversForSession(sessionKey)
|
|
||||||
if err != nil {
|
|
||||||
return resp, err
|
|
||||||
}
|
|
||||||
|
|
||||||
driverNumbers := uniqueDriverNumbers(drivers)
|
|
||||||
if len(driverNumbers) == 0 {
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
series, err := fetchReplayLocationSeries(ctx, client, sessionKey, driverNumbers)
|
|
||||||
if err != nil && len(series) == 0 {
|
|
||||||
return resp, err
|
|
||||||
}
|
|
||||||
|
|
||||||
start, ok := earliestReplayLocationTime(series)
|
|
||||||
if !ok {
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
resp.StartTime = start.Format(time.RFC3339Nano)
|
|
||||||
resp.Frames = snapReplayFrames(series, start, intervalMS)
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func uniqueDriverNumbers(drivers []models.Driver) []int {
|
|
||||||
seen := make(map[int]bool, len(drivers))
|
|
||||||
numbers := make([]int, 0, len(drivers))
|
|
||||||
for _, driver := range drivers {
|
|
||||||
if driver.DriverNumber <= 0 || seen[driver.DriverNumber] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[driver.DriverNumber] = true
|
|
||||||
numbers = append(numbers, driver.DriverNumber)
|
|
||||||
}
|
|
||||||
sort.Ints(numbers)
|
|
||||||
return numbers
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchReplayLocationSeries(ctx context.Context, client replayDataClient, sessionKey int, driverNumbers []int) (map[int][]models.Location, error) {
|
|
||||||
type result struct {
|
|
||||||
driver int
|
|
||||||
locs []models.Location
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
sem := make(chan struct{}, replayFetchConcurrency)
|
|
||||||
results := make(chan result, len(driverNumbers))
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
|
|
||||||
for _, driverNumber := range driverNumbers {
|
|
||||||
driverNumber := driverNumber
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
select {
|
|
||||||
case sem <- struct{}{}:
|
|
||||||
defer func() { <-sem }()
|
|
||||||
case <-ctx.Done():
|
|
||||||
results <- result{driver: driverNumber, err: ctx.Err()}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
locs, err := client.GetLocation(sessionKey, driverNumber)
|
|
||||||
results <- result{driver: driverNumber, locs: locs, err: err}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
close(results)
|
|
||||||
|
|
||||||
series := make(map[int][]models.Location, len(driverNumbers))
|
|
||||||
var joined error
|
|
||||||
for result := range results {
|
|
||||||
if result.err != nil {
|
|
||||||
joined = errors.Join(joined, result.err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(result.locs) > 0 {
|
|
||||||
series[result.driver] = result.locs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return series, joined
|
|
||||||
}
|
|
||||||
|
|
||||||
func earliestReplayLocationTime(series map[int][]models.Location) (time.Time, bool) {
|
|
||||||
var start time.Time
|
|
||||||
for _, locs := range series {
|
|
||||||
for _, loc := range locs {
|
|
||||||
t, err := time.Parse(time.RFC3339Nano, loc.Date)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if start.IsZero() || t.Before(start) {
|
|
||||||
start = t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if start.IsZero() {
|
|
||||||
return time.Time{}, false
|
|
||||||
}
|
|
||||||
return start, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func snapReplayFrames(series map[int][]models.Location, start time.Time, intervalMS int) []replayFrame {
|
|
||||||
type accumulator struct {
|
|
||||||
t int64
|
|
||||||
cars map[string]replayCar
|
|
||||||
nearest map[string]int64
|
|
||||||
}
|
|
||||||
|
|
||||||
interval := int64(intervalMS)
|
|
||||||
framesByIndex := make(map[int]*accumulator)
|
|
||||||
|
|
||||||
for driverNumber, locs := range series {
|
|
||||||
driverKey := strconv.Itoa(driverNumber)
|
|
||||||
for _, loc := range locs {
|
|
||||||
if !isFiniteFloat(loc.X) || !isFiniteFloat(loc.Y) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
t, err := time.Parse(time.RFC3339Nano, loc.Date)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
offset := t.Sub(start).Milliseconds()
|
|
||||||
if offset < 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
index := int((offset + interval/2) / interval)
|
|
||||||
if index < 0 || index >= maxReplayFrames {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
frameT := int64(index) * interval
|
|
||||||
distance := absInt64(offset - frameT)
|
|
||||||
|
|
||||||
acc, ok := framesByIndex[index]
|
|
||||||
if !ok {
|
|
||||||
acc = &accumulator{
|
|
||||||
t: frameT,
|
|
||||||
cars: make(map[string]replayCar),
|
|
||||||
nearest: make(map[string]int64),
|
|
||||||
}
|
|
||||||
framesByIndex[index] = acc
|
|
||||||
}
|
|
||||||
if prev, ok := acc.nearest[driverKey]; ok && prev <= distance {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
acc.nearest[driverKey] = distance
|
|
||||||
acc.cars[driverKey] = replayCar{X: loc.X, Y: loc.Y}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
indexes := make([]int, 0, len(framesByIndex))
|
|
||||||
for index, acc := range framesByIndex {
|
|
||||||
if len(acc.cars) > 0 {
|
|
||||||
indexes = append(indexes, index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Ints(indexes)
|
|
||||||
|
|
||||||
frames := make([]replayFrame, 0, len(indexes))
|
|
||||||
for _, index := range indexes {
|
|
||||||
acc := framesByIndex[index]
|
|
||||||
frames = append(frames, replayFrame{T: acc.t, Cars: acc.cars})
|
|
||||||
}
|
|
||||||
return frames
|
|
||||||
}
|
|
||||||
|
|
||||||
func absInt64(v int64) int64 {
|
|
||||||
if v < 0 {
|
|
||||||
return -v
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
func isFiniteFloat(v float64) bool {
|
|
||||||
return !math.IsNaN(v) && !math.IsInf(v, 0)
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fakeReplayClient struct {
|
|
||||||
drivers []models.Driver
|
|
||||||
locs map[int][]models.Location
|
|
||||||
err error
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
inFlight int
|
|
||||||
maxInFlight int
|
|
||||||
delay time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeReplayClient) GetDriversForSession(sessionKey int) ([]models.Driver, error) {
|
|
||||||
if f.err != nil {
|
|
||||||
return nil, f.err
|
|
||||||
}
|
|
||||||
return f.drivers, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeReplayClient) GetLocation(sessionKey, driverNumber int) ([]models.Location, error) {
|
|
||||||
f.mu.Lock()
|
|
||||||
f.inFlight++
|
|
||||||
if f.inFlight > f.maxInFlight {
|
|
||||||
f.maxInFlight = f.inFlight
|
|
||||||
}
|
|
||||||
f.mu.Unlock()
|
|
||||||
|
|
||||||
if f.delay > 0 {
|
|
||||||
time.Sleep(f.delay)
|
|
||||||
}
|
|
||||||
|
|
||||||
f.mu.Lock()
|
|
||||||
f.inFlight--
|
|
||||||
f.mu.Unlock()
|
|
||||||
|
|
||||||
return f.locs[driverNumber], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAssembleReplayFramesSnapsNearestSamplesAndOmitsEmptyDrivers(t *testing.T) {
|
|
||||||
start := time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC)
|
|
||||||
client := &fakeReplayClient{
|
|
||||||
drivers: []models.Driver{
|
|
||||||
{DriverNumber: 1},
|
|
||||||
{DriverNumber: 4},
|
|
||||||
{DriverNumber: 16},
|
|
||||||
},
|
|
||||||
locs: map[int][]models.Location{
|
|
||||||
1: {
|
|
||||||
{Date: start.Add(1 * time.Second).Format(time.RFC3339Nano), X: 10, Y: 20},
|
|
||||||
{Date: start.Add(4 * time.Second).Format(time.RFC3339Nano), X: 40, Y: 80},
|
|
||||||
{Date: start.Add(6 * time.Second).Format(time.RFC3339Nano), X: 60, Y: 120},
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
{Date: start.Add(5 * time.Second).Format(time.RFC3339Nano), X: 100, Y: 200},
|
|
||||||
},
|
|
||||||
16: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := assembleReplayFrames(context.Background(), client, 99, 5000)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("assembleReplayFrames() error = %v", err)
|
|
||||||
}
|
|
||||||
if resp.SessionKey != 99 || resp.Interval != 5000 {
|
|
||||||
t.Fatalf("response metadata = %+v", resp)
|
|
||||||
}
|
|
||||||
if resp.StartTime != start.Add(1*time.Second).Format(time.RFC3339Nano) {
|
|
||||||
t.Fatalf("start_time = %q", resp.StartTime)
|
|
||||||
}
|
|
||||||
if len(resp.Frames) != 2 {
|
|
||||||
t.Fatalf("frames len = %d, want 2: %+v", len(resp.Frames), resp.Frames)
|
|
||||||
}
|
|
||||||
if _, ok := resp.Frames[0].Cars["16"]; ok {
|
|
||||||
t.Fatalf("empty driver included in frame: %+v", resp.Frames[0].Cars)
|
|
||||||
}
|
|
||||||
if got := resp.Frames[0].Cars["1"]; got.X != 10 || got.Y != 20 {
|
|
||||||
t.Fatalf("frame 0 car 1 = %+v, want first nearest sample", got)
|
|
||||||
}
|
|
||||||
if got := resp.Frames[1].Cars["1"]; got.X != 60 || got.Y != 120 {
|
|
||||||
t.Fatalf("frame 1 car 1 = %+v, want later nearest sample", got)
|
|
||||||
}
|
|
||||||
if got := resp.Frames[1].Cars["4"]; got.X != 100 || got.Y != 200 {
|
|
||||||
t.Fatalf("frame 1 car 4 = %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAssembleReplayFramesCapsFrameCount(t *testing.T) {
|
|
||||||
start := time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC)
|
|
||||||
locs := make([]models.Location, maxReplayFrames+250)
|
|
||||||
for i := range locs {
|
|
||||||
locs[i] = models.Location{
|
|
||||||
Date: start.Add(time.Duration(i*defaultReplayIntervalMS) * time.Millisecond).Format(time.RFC3339Nano),
|
|
||||||
X: float64(i),
|
|
||||||
Y: float64(i * 2),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
client := &fakeReplayClient{
|
|
||||||
drivers: []models.Driver{{DriverNumber: 1}},
|
|
||||||
locs: map[int][]models.Location{1: locs},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("assembleReplayFrames() error = %v", err)
|
|
||||||
}
|
|
||||||
if len(resp.Frames) > maxReplayFrames {
|
|
||||||
t.Fatalf("frames len = %d, want <= %d", len(resp.Frames), maxReplayFrames)
|
|
||||||
}
|
|
||||||
if len(resp.Frames) != maxReplayFrames {
|
|
||||||
t.Fatalf("frames len = %d, want hard cap %d", len(resp.Frames), maxReplayFrames)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAssembleReplayFramesBoundsLocationFanOut(t *testing.T) {
|
|
||||||
drivers := make([]models.Driver, 10)
|
|
||||||
locs := make(map[int][]models.Location, len(drivers))
|
|
||||||
now := time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC)
|
|
||||||
for i := range drivers {
|
|
||||||
number := i + 1
|
|
||||||
drivers[i] = models.Driver{DriverNumber: number}
|
|
||||||
locs[number] = []models.Location{{Date: now.Format(time.RFC3339Nano), X: float64(number), Y: float64(number)}}
|
|
||||||
}
|
|
||||||
client := &fakeReplayClient{
|
|
||||||
drivers: drivers,
|
|
||||||
locs: locs,
|
|
||||||
delay: 5 * time.Millisecond,
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS); err != nil {
|
|
||||||
t.Fatalf("assembleReplayFrames() error = %v", err)
|
|
||||||
}
|
|
||||||
if client.maxInFlight > replayFetchConcurrency {
|
|
||||||
t.Fatalf("max in-flight location calls = %d, want <= %d", client.maxInFlight, replayFetchConcurrency)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleReplayFramesValidatesParamsAndFloorsInterval(t *testing.T) {
|
|
||||||
srv := testServer(t, nil)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/replay/frames", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
srv.handleReplayFrames(rec, req)
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("missing session_key status = %d, want 400", rec.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/replay/frames?session_key=1&interval_ms=nope", nil)
|
|
||||||
rec = httptest.NewRecorder()
|
|
||||||
srv.handleReplayFrames(rec, req)
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("invalid interval status = %d, want 400", rec.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &fakeReplayClient{drivers: []models.Driver{{DriverNumber: 1}}, locs: map[int][]models.Location{
|
|
||||||
1: {{Date: time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC).Format(time.RFC3339Nano), X: 1, Y: 2}},
|
|
||||||
}}
|
|
||||||
resp, err := assembleReplayFrames(context.Background(), client, 99, 1000)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("assembleReplayFrames() error = %v", err)
|
|
||||||
}
|
|
||||||
body, err := json.Marshal(resp)
|
|
||||||
if err != nil || len(body) == 0 {
|
|
||||||
t.Fatalf("marshal response = %d bytes, %v", len(body), err)
|
|
||||||
}
|
|
||||||
if resp.Interval != defaultReplayIntervalMS {
|
|
||||||
t.Fatalf("interval = %d, want floor %d", resp.Interval, defaultReplayIntervalMS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -78,7 +78,6 @@ func (s *Server) routes() (http.Handler, error) {
|
|||||||
mux.HandleFunc("/api/v1/grid", s.handleGrid)
|
mux.HandleFunc("/api/v1/grid", s.handleGrid)
|
||||||
mux.HandleFunc("/api/v1/laps/comparison", s.handleLapsComparison)
|
mux.HandleFunc("/api/v1/laps/comparison", s.handleLapsComparison)
|
||||||
mux.HandleFunc("/api/v1/laps", s.handleLaps)
|
mux.HandleFunc("/api/v1/laps", s.handleLaps)
|
||||||
mux.HandleFunc("/api/v1/replay/frames", s.handleReplayFrames)
|
|
||||||
mux.HandleFunc("/api/v1/weather", s.handleWeather)
|
mux.HandleFunc("/api/v1/weather", s.handleWeather)
|
||||||
mux.HandleFunc("/api/v1/race-control", s.handleRaceControl)
|
mux.HandleFunc("/api/v1/race-control", s.handleRaceControl)
|
||||||
mux.HandleFunc("/api/v1/telemetry", s.handleTelemetry)
|
mux.HandleFunc("/api/v1/telemetry", s.handleTelemetry)
|
||||||
|
|||||||
Reference in New Issue
Block a user