Compare commits

...

29 Commits

Author SHA1 Message Date
Aman Tahiliani
c475011c49 Merge pull request #88 from AmanTahiliani/feat/issue-87-live-correct-fp-q-timing-truth-hierarchy
[Live] Correct FP/Q timing truth, hierarchy, and mobile navigation (#87)
2026-07-17 12:08:45 -04:00
AmanTahiliani
5a6323d3b4 fix(live): keep red-flagged sessions active 2026-07-17 12:04:13 -04:00
AmanTahiliani
ed3b8cf628 feat(#87): [Live] Correct FP/Q timing truth, hierarchy, and mobile navigation
Implemented by claude via .agents/dev dispatch.
2026-07-17 11:53:38 -04:00
AmanTahiliani
0a42c05487 fix: align race story cursor to chart 2026-07-12 16:11:26 -04:00
AmanTahiliani
08ff75b469 fix: align race story chart to lap timing 2026-07-12 16:05:04 -04:00
Aman Tahiliani
16c035b037 Merge pull request #69 from AmanTahiliani/feat/issue-68-polish-the-race-story-section-chapter-st
Polish the Race Story section (chapter strip, playback controls, map empty-state, graph) (#68)
2026-07-12 13:44:00 -04:00
AmanTahiliani
1dac73cdc5 fix(race-story): sync chapter highlight and empty-state E2E (#68)
Clamp active-chapter detection to the same scrub bounds as chapter clicks,
keep an explicit selection on click for out-of-window timestamps, point E2E
at the empty-state card, and move Race Story empty-state styles out of app.css.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 13:40:21 -04:00
AmanTahiliani
02a84f67b4 feat(race-story): polish chapter strip, controls, graph, and map UX (#68)
Consolidate the Race Story section into a cohesive timeline surface: custom
chapter-strip scrolling with active-card sync, unified segmented playback
controls, chapter bands on the position graph with decimated axes and
de-collided labels, map toggle gated by an on-mount replay/outline probe,
and shared empty-state cards. Adds vitest coverage and visual snapshots.

Note: replay/outline queries now probe on mount (not only when Map opens) so
the toggle can be hidden before users hit a dead panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 13:23:07 -04:00
Aman Tahiliani
f0a8e4e631 Merge pull request #67 from AmanTahiliani/phase-2
Phase 2 delivery — Replay & Storytelling, Driver & Rivalry Hub, Between-Races
2026-07-12 13:05:53 -04:00
AmanTahiliani
519053d340 docs(phase-2): delivery screenshots for the phase-2 PR
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:51:11 -04:00
AmanTahiliani
e0d7b9981e fix(test): add required round_positions to hub driver fixtures from pre-#24 branches
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:39:45 -04:00
Aman Tahiliani
79acf0579a Merge pull request #66 from AmanTahiliani/feat/issue-24-rivalry-compare-view
Rivalry compare view (#24)
2026-07-12 12:38:00 -04:00
AmanTahiliani
82abbd6b35 feat(#24): Rivalry compare view
Implemented by claude via .agents/dev dispatch.
2026-07-11 18:29:52 -04:00
Aman Tahiliani
faa173578c Merge pull request #65 from AmanTahiliani/feat/issue-23-teammate-h2h-surfacing
Teammate H2H surfacing (#23)
2026-07-11 18:25:51 -04:00
AmanTahiliani
39f1560282 merge phase-2 — keep both DriversView props (drivers for H2H, season for profile links) 2026-07-11 18:25:47 -04:00
Aman Tahiliani
d3f7bab6dc Merge pull request #64 from AmanTahiliani/feat/issue-21-narrative-chapter-headlines
Narrative chapter headlines (#21)
2026-07-11 18:23:54 -04:00
AmanTahiliani
5b112fb012 feat: add narrative chapter headlines and race-hub chapter strip (#21)
Generate deterministic template headlines server-side for each replay chapter
kind, expose Chapter.headline in the race-hub payload, and render a horizontal
chapter strip on the story view with active-chapter highlighting, click-to-jump,
and a 90-second tour mode built on the existing scrubber playback.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 18:23:11 -04:00
Aman Tahiliani
96d741424c Merge pull request #63 from AmanTahiliani/feat/issue-20-combined-position-graph-track-map-scrubb
Combined position-graph + track-map scrubber (#20)
2026-07-11 18:20:04 -04:00
AmanTahiliani
c2617d39bb feat: add replay track map scrubber
Spike: replay location fan-out is bounded with a semaphore at 4 concurrent GetLocation calls, covered by TestAssembleReplayFramesBoundsLocationFanOut.

Adds /api/v1/replay/frames with 5s interval floor, 3000-frame cap, nearest-sample snapping, and empty-driver omission. Wires RaceStoryCanvas to lazy-load replay frames plus cached track outlines when the map panel opens, sharing the existing scrubber/playback state across chart and map.

Tests: HOME=/private/tmp/box-box-home GOCACHE=/private/tmp/box-box-go-cache GOMODCACHE=/Users/aman/go/pkg/mod go test ./internal/web; go build ./... (same env, passed with read-only module stat-cache warning); npm run test; npx tsc --noEmit. Full go test ./... is blocked in this sandbox by existing network/listener-dependent internal/api and internal/news tests.
2026-07-11 18:17:15 -04:00
Aman Tahiliani
beacda278d Merge pull request #62 from AmanTahiliani/feat/issue-26-calendar-aware-briefing-digest
Calendar-aware briefing digest (#26)
2026-07-11 18:15:39 -04:00
AmanTahiliani
f24ec4be94 feat(frontend): calendar-aware paddock briefing digest (#26)
Reframe Briefing into GP-window sections with driver/team tag chips, a sticky
"since last race" header, and client-side digest helpers (gpWindows, groupByWindow,
tagItems). Overlapping inter-race windows resolve to the latest GP; undated items
land in a Recent bucket. Vitest covers digest lib + page render/filter behaviour.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 18:14:55 -04:00
Aman Tahiliani
09104451d0 Merge pull request #61 from AmanTahiliani/feat/issue-22-driver-profile-page
Driver profile page (#22)
2026-07-11 18:13:51 -04:00
AmanTahiliani
f8bd183c26 merge phase-2 (preview page, chapters) — resolve additive import conflicts 2026-07-11 18:13:41 -04:00
AmanTahiliani
01ec1f24e7 feat(#22): Driver profile page
Implemented by claude via .agents/dev dispatch.
2026-07-11 18:12:00 -04:00
AmanTahiliani
3f022a0cf9 fix(agents): quote skill YAML descriptions — strict parsers (cursor CLI) fail on embedded quotes/colons
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 18:10:34 -04:00
Aman Tahiliani
5970649a9c Merge pull request #60 from AmanTahiliani/feat/issue-19-auto-chapter-detection-rc-position-swing
Auto-chapter detection (RC + position swings) (#19)
2026-07-11 18:06:33 -04:00
AmanTahiliani
7212eb9b44 feat: detect race replay chapters
Add deterministic server-side chapter detection for starts, flag periods, pit phases, decisive top-five swings, and finishes, then expose chapters on the race-hub payload.

Spike result: race-hub already loads race control, positions, and laps in one read model. The requested Detect signature does not include pit stops, so pit phases use IsPitOutLap clusters as the local deterministic pit-stop proxy.
2026-07-11 18:04:44 -04:00
Aman Tahiliani
785dccdca2 Merge pull request #59 from AmanTahiliani/feat/issue-25-next-race-preview-page
Next-race preview page (#25)
2026-07-11 17:59:51 -04:00
AmanTahiliani
3cc6157337 feat(championship): surface teammate H2H bars on drivers view (#23)
Add reusable TeammateH2H component and teammatePairs helper that ranks
intra-team battles by closeness, using existing hub teammate_wins data.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 17:58:56 -04:00
97 changed files with 8567 additions and 241 deletions

View File

@@ -1,6 +1,6 @@
---
name: groom
description: Groom a box-box GitHub issue into a Ready spec via a seeded grill-me interrogation. Use when the user asks to groom an issue, for example "/groom <issue-number>" or "$groom <issue-number>" with optional "--lens architect". Single-issue path: asks targeted questions, writes a structured spec into the issue body, sets Effort/Priority, and leaves Stage at Research for approval.
description: 'Groom a box-box GitHub issue into a Ready spec via a seeded grill-me interrogation. Use when the user asks to groom an issue, for example "/groom <issue-number>" or "$groom <issue-number>" with optional "--lens architect". Single-issue path: asks targeted questions, writes a structured spec into the issue body, sets Effort/Priority, and leaves Stage at Research for approval.'
argument-hint: <issue-number> [--lens architect]
---

View File

@@ -1,6 +1,6 @@
---
name: implement
description: Dispatch a Ready box-box issue to a coding harness (claude/codex/opencode/pi/cursor) in an isolated git worktree, run the build gate, and open a PR. Use when supervising implementation via "/implement <issue-number> --harness <name> [--dry-run]" or from any harness terminal with .agents/bin/dev.
description: 'Dispatch a Ready box-box issue to a coding harness (claude/codex/opencode/pi/cursor) in an isolated git worktree, run the build gate, and open a PR. Use when supervising implementation via "/implement <issue-number> --harness <name> [--dry-run]" or from any harness terminal with .agents/bin/dev.'
argument-hint: <issue-number> --harness <claude|codex|opencode|pi|cursor> [--dry-run]
---

View File

@@ -1,6 +1,6 @@
---
name: lens-architect
description: Grill/analyze a box-box issue or epic from a software-architecture perspective and post the findings as a comment. Use standalone as "/lens-architect <issue-number>" for an on-the-fly architecture review, or let /groom compose it via "--lens architect". Reads the base grill + architect persona and focuses on reuse, data flow, seams, testability, and risk.
description: 'Grill/analyze a box-box issue or epic from a software-architecture perspective and post the findings as a comment. Use standalone as "/lens-architect <issue-number>" for an on-the-fly architecture review, or let /groom compose it via "--lens architect". Reads the base grill + architect persona and focuses on reuse, data flow, seams, testability, and risk.'
argument-hint: <issue-number>
---

View File

@@ -1,6 +1,6 @@
---
name: review
description: Locally review a box-box PR against its linked GitHub issue spec, run tests, capture visual screenshots when applicable, create a .review packet, and post a GitHub PR comment. Use when a ticket implementation is ready for independent local review before the human merge gate.
description: 'Locally review a box-box PR against its linked GitHub issue spec, run tests, capture visual screenshots when applicable, create a .review packet, and post a GitHub PR comment. Use when a ticket implementation is ready for independent local review before the human merge gate.'
argument-hint: <pr-number> [--harness <name>] [--publish-screenshots]
---

View File

@@ -1,6 +1,6 @@
---
name: write-spec
description: Render a groomed design into the box-box Ready-spec template and write it into a GitHub issue body, then set Effort and Priority. Called by /groom after a grill session, or run standalone as "/write-spec <issue-number>" to (re)write an issue's spec from agreed decisions. Does not change Stage.
description: 'Render a groomed design into the box-box Ready-spec template and write it into a GitHub issue body, then set Effort and Priority. Called by /groom after a grill session, or run standalone as "/write-spec <issue-number>" to (re)write an issue''s spec from agreed decisions. Does not change Stage.'
argument-hint: <issue-number>
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 683 KiB

View File

@@ -2,6 +2,7 @@ import type {
ArticleContent,
CarDataSample,
ChampionshipHub,
DriverSummary,
EnrichedGrid,
EnrichedResult,
LapsComparisonResponse,
@@ -10,6 +11,7 @@ import type {
Meeting,
NewsItem,
RaceHub,
ReplayFramesResponse,
Session,
TrackOutline,
Weekend,
@@ -80,6 +82,21 @@ export async function fetchTrackOutline(circuitKey: number, year: number): Promi
return data as TrackOutline
}
export async function fetchReplayFrames(
sessionKey: number,
intervalMs = 5000,
): Promise<ReplayFramesResponse> {
const params = new URLSearchParams({
session_key: String(sessionKey),
interval_ms: String(intervalMs),
})
const res = await fetch(`/api/v1/replay/frames?${params}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}
export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise<Session[]> {
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
if (!res.ok) {
@@ -108,6 +125,19 @@ export async function fetchChampionshipHub(year?: number): Promise<ChampionshipH
return res.json()
}
export async function fetchDriverSummary(
driverNumber: number,
year?: number,
): Promise<DriverSummary> {
const params = new URLSearchParams({ driver_number: String(driverNumber) })
if (year) params.set('year', String(year))
const res = await fetch(`/api/v1/driver/summary?${params.toString()}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}
export async function fetchLiveState(): Promise<LiveStateResponse> {
const res = await fetch('/api/v1/live/state')
if (!res.ok) {

View File

@@ -0,0 +1,116 @@
import { useEffect, useRef } from 'react'
import { BookOpen } from 'lucide-react'
import type { Chapter } from '../types'
import {
activeChapterIndex,
chapterKindLabel,
chapterLapRange,
chapterStartScrub,
} from '../lib/chapters'
import { EmptyStateCard } from './EmptyStateCard'
import '../styles/chapters.css'
interface Props {
chapters: Chapter[]
scrubTime: number | null
tMin: number
tRange: number
tourActive: boolean
tourChapterIndex: number | null
/** Explicit selection from a chapter click; wins over scrub-derived active. */
selectedChapterIndex?: number | null
onChapterClick: (index: number, scrub: number) => void
onTourToggle: () => void
}
export function ChapterStrip({
chapters,
scrubTime,
tMin,
tRange,
tourActive,
tourChapterIndex,
selectedChapterIndex = null,
onChapterClick,
onTourToggle,
}: Props) {
const scrollRef = useRef<HTMLDivElement>(null)
const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange)
const highlightedIndex = tourActive
? tourChapterIndex
: (selectedChapterIndex ?? activeIndex)
useEffect(() => {
if (highlightedIndex === null || !scrollRef.current) return
const card = scrollRef.current.querySelector<HTMLElement>(
`[data-testid="chapter-card-${highlightedIndex}"]`,
)
card?.scrollIntoView?.({ behavior: 'smooth', inline: 'center', block: 'nearest' })
}, [highlightedIndex])
if (chapters.length === 0) {
return (
<div className="chapter-strip" data-testid="chapter-strip">
<EmptyStateCard
icon={BookOpen}
title="No story chapters"
hint="This session does not have enough race-control or position data to build narrative chapters."
testId="chapter-strip-empty"
className="chapter-strip-empty-card"
/>
</div>
)
}
return (
<div className="chapter-strip" data-testid="chapter-strip">
<div className="chapter-strip-header">
<span className="chapter-strip-title">Race chapters</span>
<div className="chapter-strip-actions">
<button
type="button"
className={`chapter-tour-btn ${tourActive ? 'active' : ''}`}
onClick={onTourToggle}
aria-pressed={tourActive}
>
{tourActive ? 'Exit 90s' : '90s tour'}
</button>
</div>
</div>
<div className="chapter-strip-scroll-wrap">
<div
ref={scrollRef}
className="chapter-strip-scroll"
role="list"
aria-label="Race story chapters"
>
{chapters.map((chapter, index) => {
const scrub = chapterStartScrub(chapter, tMin, tRange) ?? index / Math.max(chapters.length - 1, 1)
const isActive = highlightedIndex === index
const headline = chapter.headline || chapter.title
return (
<button
key={`${chapter.kind}-${chapter.start_lap}-${index}`}
type="button"
role="listitem"
className={`chapter-card ${isActive ? (tourActive ? 'tour-active' : 'active') : ''}`}
onClick={() => onChapterClick(index, scrub)}
aria-current={isActive ? 'true' : undefined}
data-testid={`chapter-card-${index}`}
>
<div className="chapter-card-top">
<span className={`chapter-kind chapter-kind--${chapter.kind}`}>
{chapterKindLabel(chapter.kind)}
</span>
<span className="chapter-lap-range">{chapterLapRange(chapter)}</span>
</div>
<span className="chapter-headline">{headline}</span>
</button>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,20 @@
import type { LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react'
interface Props {
icon: LucideIcon
title: string
hint: ReactNode
testId?: string
className?: string
}
export function EmptyStateCard({ icon: Icon, title, hint, testId, className = '' }: Props) {
return (
<div className={`empty-state ui-card ${className}`.trim()} data-testid={testId}>
<Icon size={32} className="empty-state-icon" aria-hidden />
<div className="empty-state-title">{title}</div>
<div className="empty-state-desc">{hint}</div>
</div>
)
}

View File

@@ -1,6 +1,17 @@
import { Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { fetchLiveState } from '../api'
import { isLiveSessionActive } from '../lib/live'
export function Nav() {
const { data: liveState } = useQuery({
queryKey: ['live-state'],
queryFn: fetchLiveState,
staleTime: 5_000,
refetchInterval: 30_000,
})
const liveActive = isLiveSessionActive(liveState)
return (
<nav className="app-nav">
<Link to="/" className="nav-logo">
@@ -10,8 +21,16 @@ export function Nav() {
<Link to="/" activeProps={{ className: 'active' }} activeOptions={{ exact: true }}>
Command
</Link>
<Link to="/live" activeProps={{ className: 'active' }}>
<Link
to="/live"
className={liveActive ? 'nav-live nav-live-on' : 'nav-live'}
activeProps={{ className: 'active' }}
data-testid="nav-live"
data-live-active={liveActive ? 'true' : 'false'}
>
{liveActive && <span className="nav-live-dot" aria-hidden="true" />}
Live
{liveActive && <span className="sr-only"> session active</span>}
</Link>
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
Race Hub

View File

@@ -1,7 +1,23 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { fetchNews } from '../api'
import {
fetchChampionshipHub,
fetchNews,
fetchSeasonMeetings,
fetchSeasons,
} from '../api'
import {
activeDigestWindow,
gpWindows,
itemsForWindow,
sinceLastLabel,
tagColour,
tagItems,
topTags,
} from '../lib/digest'
import { timeAgo } from '../utils'
import '../styles/digest.css'
const SOURCE_DISPLAY: Record<string, string> = {
'fia': 'FIA',
@@ -14,14 +30,53 @@ const SOURCE_DISPLAY: Record<string, string> = {
}
export function PaddockBriefing() {
const now = useMemo(() => new Date(), [])
const { data: news, isLoading, isError } = useQuery({
queryKey: ['news'],
queryFn: () => fetchNews(100),
staleTime: 60_000,
})
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: fetchSeasons,
})
const latestSeason = seasonsQuery.data?.[0] ?? null
const meetingsQuery = useQuery({
queryKey: ['season-meetings', latestSeason],
queryFn: () => fetchSeasonMeetings(latestSeason!),
enabled: latestSeason != null,
})
const hubQuery = useQuery({
queryKey: ['championship-hub', latestSeason],
queryFn: () => fetchChampionshipHub(latestSeason!),
enabled: latestSeason != null,
})
const meetings = meetingsQuery.data ?? []
const hub = hubQuery.data
const tagged = useMemo(
() => tagItems(news ?? [], hub?.drivers ?? [], hub?.teams ?? []),
[news, hub],
)
const windows = useMemo(() => gpWindows(meetings, now), [meetings, now])
const activeWindow = useMemo(
() => activeDigestWindow(windows, meetings, now),
[windows, meetings, now],
)
const sinceItems = useMemo(
() => itemsForWindow(tagged, activeWindow),
[tagged, activeWindow],
)
const sinceTags = useMemo(() => topTags(sinceItems, 4), [sinceItems])
const sinceLabel = sinceLastLabel(meetings, now)
const unreadCount = news?.filter((i) => !i.read_at).length ?? 0
const preview = news?.slice(0, 5) ?? []
const preview = sinceItems.length > 0 ? sinceItems.slice(0, 5) : (tagged.slice(0, 5))
return (
<section className="cc-briefing" data-testid="paddock-briefing">
@@ -37,6 +92,27 @@ export function PaddockBriefing() {
</Link>
</div>
{meetings.length > 0 && (
<div className="cc-brief-digest-meta" data-testid="cc-brief-digest-meta">
<span>Since {sinceLabel}</span>
<span>·</span>
<span>{sinceItems.length} items</span>
{sinceTags.length > 0 && (
<div className="cc-brief-digest-tags">
{sinceTags.map((tag) => (
<span
key={tag.key}
className="cc-brief-tag"
style={{ borderColor: tagColour(tag.colour) }}
>
{tag.label}
</span>
))}
</div>
)}
</div>
)}
{isLoading && <div className="briefing-state loading-state">loading</div>}
{isError && <div className="briefing-state error-box">Failed to load briefing</div>}

View File

@@ -1,6 +1,36 @@
import { useState, useMemo, useRef, useCallback } from 'react'
import type { EnrichedResult, EnrichedGrid, PositionSample, Lap, Session } from '../types'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { LineChart } from 'lucide-react'
import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session, Chapter } from '../types'
import { fetchReplayFrames, fetchTrackOutline } from '../api'
import { ReplayTrackMap } from './ReplayTrackMap'
import { ChapterStrip } from './ChapterStrip'
import { EmptyStateCard } from './EmptyStateCard'
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
import {
chapterBandFill,
chapterEndScrub,
chapterStartScrub,
chapterTourDurations,
deCollideYPositions,
decimatedPositionLabels,
} from '../lib/chapters'
import { isReplayMapAvailable } from '../lib/replayMap'
import '../styles/race-story.css'
const CHAPTER_TOUR_MS = 90_000
const CHART_W = 640
const CHART_H = 180
const CHART_PL = 40
const CHART_PR = 48
const CHART_PT = 8
const CHART_PB = 20
interface ChartTiming {
tMin: number
tMax: number
tRange: number
}
interface Props {
data: {
@@ -12,38 +42,256 @@ interface Props {
race_control?: any[]
pit_stops?: any[]
session?: Session
meeting?: Meeting
drivers?: Driver[]
chapters?: Chapter[]
}
}
/**
* Position samples are recorded for the entire session, including the pre-race
* grid period. Use the winning driver's laps to define the race window so
* that the x-axis starts at lights-out rather than at the earliest sample.
*/
function raceChartTiming(
positions: PositionSample[],
laps: Lap[],
results: EnrichedResult[],
): ChartTiming | null {
const positionTimes = positions
.map((position) => new Date(position.date).getTime())
.filter(Number.isFinite)
if (positionTimes.length === 0) return null
const fallbackMin = Math.min(...positionTimes)
const fallbackMax = Math.max(...positionTimes)
const fallback = {
tMin: fallbackMin,
tMax: fallbackMax,
tRange: Math.max(fallbackMax - fallbackMin, 1),
}
const winner = results.find((result) => result.position === 1)
if (!winner) return fallback
const winnerLaps = laps
.filter((lap) => lap.driver_number === winner.driver_number && lap.lap_number > 0)
.map((lap) => ({ ...lap, start: new Date(lap.date_start).getTime() }))
.filter((lap) => Number.isFinite(lap.start))
.sort((a, b) => a.lap_number - b.lap_number)
if (winnerLaps.length === 0) return fallback
const firstLap = winnerLaps[0]
const lastLap = winnerLaps[winnerLaps.length - 1]
const finalLapDuration = lastLap.lap_duration ?? 0
const tMax = lastLap.start + (finalLapDuration > 0 ? finalLapDuration * 1000 : 0)
if (tMax <= firstLap.start) return fallback
return { tMin: firstLap.start, tMax, tRange: tMax - firstLap.start }
}
export function RaceStoryCanvas({ data }: Props) {
const { results, starting_grid: grid, positions, datasets, race_control = [], pit_stops = [], laps = [] } = data
const {
results,
starting_grid: grid,
positions,
datasets,
race_control = [],
pit_stops = [],
laps = [],
session,
meeting,
drivers = [],
chapters = [],
} = data
const hasPositions = datasets['positions']?.status === 'available'
const [scrubTime, setScrubTime] = useState<number | null>(null)
const [hoverDriver, setHoverDriver] = useState<number | null>(null)
const [mapOpen, setMapOpen] = useState(false)
const [isPlaying, setIsPlaying] = useState(false)
const [playbackSpeed, setPlaybackSpeed] = useState(10)
const [chapterTourActive, setChapterTourActive] = useState(false)
const [tourChapterIndex, setTourChapterIndex] = useState<number | null>(null)
const [selectedChapterIndex, setSelectedChapterIndex] = useState<number | null>(null)
const svgRef = useRef<SVGSVGElement>(null)
const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 })
// Position Evolution Chart Logic
const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions])
const hasChartData = hasPositions && allTimes.length > 0
const chartTiming = useMemo(() => raceChartTiming(positions, laps, results), [laps, positions, results])
const hasChartData = hasPositions && chartTiming !== null
const circuitKey = session?.circuit_key ?? meeting?.circuit_key ?? 0
const outlineYear = meeting?.year ?? (session?.date_start ? new Date(session.date_start).getFullYear() : 0)
const canProbeMap = Boolean(session?.session_key) && circuitKey > 0 && outlineYear > 0
const replayQuery = useQuery({
queryKey: ['replay-frames', session?.session_key, 5000],
queryFn: () => fetchReplayFrames(session!.session_key, 5000),
enabled: canProbeMap,
})
const outlineQuery = useQuery({
queryKey: ['track-outline', circuitKey, outlineYear],
queryFn: () => fetchTrackOutline(circuitKey, outlineYear),
enabled: canProbeMap,
})
const mapProbeSettled = !canProbeMap || (!replayQuery.isLoading && !outlineQuery.isLoading)
const mapAvailable = useMemo(
() =>
canProbeMap &&
isReplayMapAvailable(
replayQuery.data,
outlineQuery.data,
replayQuery.isError || outlineQuery.isError,
),
[canProbeMap, outlineQuery.data, outlineQuery.isError, replayQuery.data, replayQuery.isError],
)
const showMapPanel = mapOpen && mapAvailable
useEffect(() => {
if (!mapAvailable && mapOpen) {
setMapOpen(false)
}
}, [mapAvailable, mapOpen])
useEffect(() => {
if (!isPlaying || !chartTiming) return
let frame = 0
let last = performance.now()
const tick = (now: number) => {
const delta = now - last
last = now
setScrubTime((current) => {
const next = Math.min(1, (current ?? 0) + (delta * playbackSpeed) / chartTiming.tRange)
if (next >= 1) {
setIsPlaying(false)
}
return next
})
frame = requestAnimationFrame(tick)
}
frame = requestAnimationFrame(tick)
return () => cancelAnimationFrame(frame)
}, [chartTiming, isPlaying, playbackSpeed])
const stopChapterTour = () => {
setChapterTourActive(false)
setTourChapterIndex(null)
}
const jumpToChapter = (index: number, scrub: number) => {
setIsPlaying(false)
stopChapterTour()
setSelectedChapterIndex(index)
setScrubTime(scrub)
}
const clearChapterSelection = () => {
setSelectedChapterIndex(null)
}
const toggleChapterTour = () => {
if (chapterTourActive) {
stopChapterTour()
return
}
if (!chartTiming || chapters.length === 0) return
setIsPlaying(false)
setSelectedChapterIndex(null)
setChapterTourActive(true)
setTourChapterIndex(0)
const startScrub = chapterStartScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? 0
setScrubTime(startScrub)
const durations = chapterTourDurations(chapters, CHAPTER_TOUR_MS)
tourRef.current = {
chapterIndex: 0,
startedAt: performance.now(),
durationMs: durations[0] ?? CHAPTER_TOUR_MS / chapters.length,
startScrub,
endScrub: chapterEndScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? startScrub,
}
}
useEffect(() => {
if (!chapterTourActive || !chartTiming || chapters.length === 0) return
let frame = 0
const tick = (now: number) => {
const state = tourRef.current
const elapsed = now - state.startedAt
const progress = Math.min(1, elapsed / Math.max(state.durationMs, 1))
const scrub = state.startScrub + (state.endScrub - state.startScrub) * progress
setScrubTime(scrub)
setTourChapterIndex(state.chapterIndex)
if (progress >= 1) {
const nextIndex = state.chapterIndex + 1
if (nextIndex >= chapters.length) {
stopChapterTour()
return
}
const durations = chapterTourDurations(chapters, CHAPTER_TOUR_MS)
const startScrub = chapterStartScrub(chapters[nextIndex], chartTiming.tMin, chartTiming.tRange) ?? 0
const endScrub = chapterEndScrub(chapters[nextIndex], chartTiming.tMin, chartTiming.tRange) ?? startScrub
tourRef.current = {
chapterIndex: nextIndex,
startedAt: now,
durationMs: durations[nextIndex] ?? CHAPTER_TOUR_MS / chapters.length,
startScrub,
endScrub,
}
}
frame = requestAnimationFrame(tick)
}
frame = requestAnimationFrame(tick)
return () => cancelAnimationFrame(frame)
}, [chapterTourActive, chartTiming, chapters])
useEffect(() => {
if (!chapterTourActive) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
stopChapterTour()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [chapterTourActive])
const replayTMs = useMemo(() => {
const replay = replayQuery.data
const frames = replay?.frames ?? []
if (frames.length === 0) return 0
const lastFrameT = frames[frames.length - 1].t
const progress = scrubTime ?? 0
if (!chartTiming || !replay?.start_time) {
return Math.max(0, Math.min(lastFrameT, Math.round(progress * lastFrameT)))
}
const replayStart = new Date(replay.start_time).getTime()
const chartTime = chartTiming.tMin + progress * chartTiming.tRange
return Math.max(0, Math.min(lastFrameT, Math.round(chartTime - replayStart)))
}, [chartTiming, replayQuery.data, scrubTime])
let chartContent = null
let displayResults = results
if (hasChartData) {
const tMin = new Date(allTimes[0]).getTime()
const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
const tRange = Math.max(tMax - tMin, 1)
if (hasChartData && chartTiming) {
const { tMin, tRange } = chartTiming
const normaliseTime = (time: number) => Math.max(0, Math.min(1, (time - tMin) / tRange))
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
for (const p of positions) {
const time = new Date(p.date).getTime()
if (!Number.isFinite(time)) continue
if (!byDriver.has(p.driver_number)) byDriver.set(p.driver_number, [])
byDriver.get(p.driver_number)!.push({
t: (new Date(p.date).getTime() - tMin) / tRange,
t: normaliseTime(time),
pos: p.position,
})
}
const dnfSet = new Set(results.filter(r => r.dnf || r.dns || r.dsq).map(r => r.driver_number))
const dnfSet = new Set(results.filter((r) => r.dnf || r.dns || r.dsq).map((r) => r.driver_number))
for (const [dNum, samples] of byDriver.entries()) {
samples.sort((a, b) => a.t - b.t)
if (samples.length > 0 && !dnfSet.has(dNum)) {
@@ -51,16 +299,16 @@ export function RaceStoryCanvas({ data }: Props) {
}
}
const getInterpPos = (samples: {t: number, pos: number}[], t: number) => {
const getInterpPos = (samples: { t: number; pos: number }[], t: number) => {
if (!samples || samples.length === 0) return null
if (t <= samples[0].t) return samples[0].pos
if (t >= samples[samples.length - 1].t) return samples[samples.length - 1].pos
for (let i = 0; i < samples.length - 1; i++) {
if (samples[i].t <= t && samples[i+1].t >= t) {
const dt = samples[i+1].t - samples[i].t
if (samples[i].t <= t && samples[i + 1].t >= t) {
const dt = samples[i + 1].t - samples[i].t
if (dt === 0) return samples[i].pos
const frac = (t - samples[i].t) / dt
return samples[i].pos + (samples[i+1].pos - samples[i].pos) * frac
return samples[i].pos + (samples[i + 1].pos - samples[i].pos) * frac
}
}
return null
@@ -84,43 +332,46 @@ export function RaceStoryCanvas({ data }: Props) {
const maxPos = Math.max(...positions.map((p) => p.position), results.length, 2)
const colorByDriver = new Map(results.map((r) => [r.driver_number, r.team_colour]))
const acronymByDriver = new Map(results.map((r) => [r.driver_number, r.name_acronym]))
const positionLabels = decimatedPositionLabels(maxPos)
const W = 640
const H = 180
const PL = 40
const PR = 48
const PT = 8
const PB = 20
const W = CHART_W
const H = CHART_H
const PL = CHART_PL
const PR = CHART_PR
const PT = CHART_PT
const PB = CHART_PB
const plotW = W - PL - PR
const plotH = H - PT - PB
const toX = (t: number) => PL + t * plotW
const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH
const winner = results.find(r => r.position === 1)
const winnerLaps = winner ? laps.filter(l => l.driver_number === winner.driver_number) : []
const lapTicks: { lap: number, t: number }[] = []
const winner = results.find((r) => r.position === 1)
const winnerLaps = winner ? laps.filter((l) => l.driver_number === winner.driver_number) : []
const lapTicks: { lap: number; t: number }[] = []
const lapInterval = winnerLaps.length < 30 ? 5 : 10
for (const lap of winnerLaps) {
for (let i = 0; i < winnerLaps.length; i++) {
const lap = winnerLaps[i]
if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) {
const t = (new Date(lap.date_start).getTime() - tMin) / tRange
if (t >= 0 && t <= 1) {
lapTicks.push({ lap: lap.lap_number, t })
}
const lapStart = new Date(lap.date_start).getTime()
const nextLapStart = winnerLaps[i + 1] ? new Date(winnerLaps[i + 1].date_start).getTime() : NaN
const lapEnd = lap.lap_duration && lap.lap_duration > 0
? lapStart + lap.lap_duration * 1000
: nextLapStart
if (Number.isFinite(lapEnd)) lapTicks.push({ lap: lap.lap_number, t: normaliseTime(lapEnd) })
}
}
// Safety Car / VSC periods
const scPeriods: { start: number; end: number | null; type: 'SC' | 'VSC' }[] = []
let activeSC: { start: number; type: 'SC' | 'VSC' } | null = null
const rc = [...race_control].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
for (const msg of rc) {
const t = new Date(msg.date).getTime()
const m = msg.message?.toUpperCase() || ''
const cat = msg.category?.toUpperCase() || ''
if (m.includes('VIRTUAL SAFETY CAR DEPLOYED') || cat === 'VIRTUALSAFETYCAR') {
if (!activeSC) activeSC = { start: t, type: 'VSC' }
} else if (m.includes('SAFETY CAR DEPLOYED') || cat === 'SAFETYCAR') {
@@ -136,23 +387,73 @@ export function RaceStoryCanvas({ data }: Props) {
scPeriods.push({ start: activeSC.start, end: null, type: activeSC.type })
}
const chapterBands = chapters
.map((chapter, index) => {
const startT = chapterStartScrub(chapter, tMin, tRange)
const endT = chapterEndScrub(chapter, tMin, tRange)
if (startT === null) return null
const end = endT ?? startT
return {
key: `${chapter.kind}-${index}`,
start: Math.min(startT, end),
end: Math.max(startT, end),
fill: chapterBandFill(chapter.kind),
}
})
.filter((band): band is NonNullable<typeof band> => band !== null)
const labelCandidates = Array.from(byDriver.entries())
.map(([dNum, samples]) => {
const last = samples[samples.length - 1]
if (!last) return null
return { key: dNum, y: toY(last.pos) }
})
.filter((item): item is { key: number; y: number } => item !== null)
const labelYByDriver = deCollideYPositions(labelCandidates, 12)
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
setIsPlaying(false)
stopChapterTour()
clearChapterSelection()
if (!svgRef.current) return
const rect = svgRef.current.getBoundingClientRect()
const x = e.clientX - rect.left
if (rect.width <= 0) return
// Pointer coordinates are CSS pixels; convert them to the SVG viewBox
// before comparing with the fixed chart margins and plot width.
const x = ((e.clientX - rect.left) / rect.width) * W
const t = Math.max(0, Math.min(1, (x - PL) / plotW))
setScrubTime(t)
}
chartContent = (
<div className="rs-chart-container scroll-x" data-testid="position-chart">
<div
className={`rs-chart-container scroll-x${showMapPanel ? '' : ' rs-chart-container--full'}`}
data-testid="position-chart"
>
<svg
ref={svgRef}
className="rs-position-chart-svg"
viewBox={`0 0 ${W} ${H}`}
style={{ width: '100%', minWidth: 280, maxWidth: W, display: 'block' }}
role="img"
aria-label="Position evolution chart"
>
{chapterBands.map((band) => {
const x1 = toX(Math.max(0, band.start))
const x2 = toX(Math.min(1, band.end))
if (x2 <= PL || x1 >= W - PR) return null
return (
<rect
key={band.key}
x={x1}
y={PT}
width={Math.max(0, x2 - x1)}
height={plotH}
fill={band.fill}
data-testid="chapter-band"
/>
)
})}
{scPeriods.map((sc, i) => {
const startT = (sc.start - tMin) / tRange
const endT = sc.end ? (sc.end - tMin) / tRange : 1
@@ -171,33 +472,41 @@ export function RaceStoryCanvas({ data }: Props) {
)
})}
{Array.from({ length: maxPos }, (_, i) => i + 1).map((pos) => (
{positionLabels.map((pos) => (
<g key={pos}>
<line
x1={PL}
x2={W - PR}
y1={toY(pos)}
y2={toY(pos)}
stroke="var(--border)"
strokeWidth={0.5}
className="rs-chart-grid-line"
/>
<text
x={PL - 4}
y={toY(pos) + 4}
textAnchor="end"
fill="var(--text-3)"
fontSize={8}
fontFamily="var(--f-mono)"
className="rs-chart-axis-label"
>
P{pos}
</text>
</g>
))}
{lapTicks.map(tick => (
{lapTicks.map((tick) => (
<g key={`lap-${tick.lap}`}>
<line x1={toX(tick.t)} x2={toX(tick.t)} y1={H - PB} y2={H - PB + 4} stroke="var(--border)" strokeWidth={1} />
<text x={toX(tick.t)} y={H - PB + 14} textAnchor="middle" fill="var(--text-3)" fontSize={9} fontFamily="var(--f-mono)">
<line
x1={toX(tick.t)}
x2={toX(tick.t)}
y1={H - PB}
y2={H - PB + 4}
className="rs-chart-lap-tick"
/>
<text
x={toX(tick.t)}
y={H - PB + 14}
textAnchor="middle"
className="rs-chart-axis-label"
>
L{tick.lap}
</text>
</g>
@@ -210,11 +519,12 @@ export function RaceStoryCanvas({ data }: Props) {
const last = samples[samples.length - 1]
const isHovered = hoverDriver === dNum
const isFaded = hoverDriver !== null && !isHovered
const labelY = last ? (labelYByDriver.get(dNum) ?? toY(last.pos)) : 0
const driverPits = pit_stops.filter(p => p.driver_number === dNum)
const driverPits = pit_stops.filter((p) => p.driver_number === dNum)
return (
<g
<g
key={dNum}
style={{ opacity: isFaded ? 0.2 : 1, transition: 'opacity 0.2s' }}
onMouseEnter={() => setHoverDriver(dNum)}
@@ -230,30 +540,31 @@ export function RaceStoryCanvas({ data }: Props) {
className="rs-driver-line"
pathLength={1}
/>
{driverPits.map((p, i) => {
const t = (new Date(p.date).getTime() - tMin) / tRange
if (t < 0 || t > 1) return null
const time = new Date(p.date).getTime()
if (!Number.isFinite(time) || time < tMin || time > tMin + tRange) return null
const t = normaliseTime(time)
const pos = getInterpPos(samples, t)
if (pos === null) return null
return (
<circle
key={`pit-${i}`}
cx={toX(t)}
cy={toY(pos)}
r={3}
fill="var(--bg)"
stroke={color}
<circle
key={`pit-${i}`}
cx={toX(t)}
cy={toY(pos)}
r={3}
fill="var(--bg)"
stroke={color}
strokeWidth={2}
className="rs-pit-dot"
/>
)
})}
{last && (
<text
x={toX(last.t) + 6}
y={toY(last.pos) + 4}
y={labelY + 4}
fill={color}
fontSize={isHovered ? 11 : 9}
fontFamily="var(--f-mono)"
@@ -287,25 +598,110 @@ export function RaceStoryCanvas({ data }: Props) {
width={plotW}
height={plotH}
fill="transparent"
data-testid="position-chart-interaction"
onPointerMove={handlePointerMove}
onPointerLeave={() => setScrubTime(null)}
onPointerLeave={() => {
if (!isPlaying) {
clearChapterSelection()
setScrubTime(null)
}
}}
style={{ cursor: 'crosshair', touchAction: 'none' }}
/>
</svg>
<div className="rs-segmented-control" aria-label="Race replay controls" data-testid="replay-controls">
<button
type="button"
className={`rs-segment ${isPlaying ? 'active' : ''}`}
onClick={() => {
setScrubTime((current) => current ?? 0)
stopChapterTour()
clearChapterSelection()
setIsPlaying((current) => !current)
}}
aria-pressed={isPlaying}
>
{isPlaying ? 'Pause' : 'Play'}
</button>
<span className="rs-segment-divider" aria-hidden />
{[1, 10, 30].map((speed) => (
<button
key={speed}
type="button"
className={`rs-segment ${playbackSpeed === speed ? 'active' : ''}`}
onClick={() => setPlaybackSpeed(speed)}
aria-pressed={playbackSpeed === speed}
>
{speed}x
</button>
))}
{mapProbeSettled && mapAvailable && (
<>
<span className="rs-segment-divider" aria-hidden />
<button
type="button"
className={`rs-segment ${mapOpen ? 'active' : ''}`}
onClick={() => setMapOpen((current) => !current)}
aria-pressed={mapOpen}
data-testid="replay-map-toggle"
>
Map
</button>
</>
)}
</div>
</div>
)
}
return (
<div className="race-story-canvas">
{hasChartData ? (
chartContent
) : (
<div className="analysis-notice">
<strong>Lap-by-lap positions not available.</strong> This session does not
have ingested position samples in <code>/api/v1/race-hub</code>.
</div>
{hasChartData && chartTiming && chapters.length > 0 && (
<ChapterStrip
chapters={chapters}
scrubTime={scrubTime}
tMin={chartTiming.tMin}
tRange={chartTiming.tRange}
tourActive={chapterTourActive}
tourChapterIndex={tourChapterIndex}
selectedChapterIndex={selectedChapterIndex}
onChapterClick={jumpToChapter}
onTourToggle={toggleChapterTour}
/>
)}
<div className={`rs-replay-shell${showMapPanel ? ' rs-replay-shell--split' : ''}`}>
<div className="rs-replay-main">
{hasChartData ? (
chartContent
) : (
<EmptyStateCard
icon={LineChart}
title="Lap-by-lap positions not available"
hint={
<>
This session does not have ingested position samples in{' '}
<code>/api/v1/race-hub</code>.
</>
}
testId="race-story-no-positions"
className="race-story-empty-card"
/>
)}
</div>
{showMapPanel && (
<div className="rs-replay-map-slot" data-testid="replay-map-slot">
<ReplayTrackMap
outline={outlineQuery.data}
replay={replayQuery.data}
tMs={replayTMs}
drivers={drivers}
results={results}
loading={outlineQuery.isLoading || replayQuery.isLoading}
error={outlineQuery.isError || replayQuery.isError}
/>
</div>
)}
</div>
{displayResults.length > 0 && (
<div className="rs-field-list">
@@ -313,30 +709,29 @@ export function RaceStoryCanvas({ data }: Props) {
const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
const currentPos = scrubTime !== null ? i + 1 : r.position
const isWinner = i === 0 && r.position === 1
const pClass = currentPos === 1 ? 'rs-pos-p1' : currentPos === 2 ? 'rs-pos-p2' : currentPos === 3 ? 'rs-pos-p3' : ''
const pClass =
currentPos === 1 ? 'rs-pos-p1' : currentPos === 2 ? 'rs-pos-p2' : currentPos === 3 ? 'rs-pos-p3' : ''
let currentPoints: number | string = r.points
if (scrubTime !== null) {
const isSprint = data.session?.session_type?.toLowerCase().includes('sprint')
const ptsArray = isSprint ? [8, 7, 6, 5, 4, 3, 2, 1] : [25, 18, 15, 12, 10, 8, 6, 4, 2, 1]
currentPoints = currentPos <= ptsArray.length ? ptsArray[currentPos - 1] : 0
}
return (
<div
key={r.driver_number}
<div
key={r.driver_number}
className={`rs-driver-row ${hoverDriver === r.driver_number ? 'rs-driver-row-hover' : ''}`}
onMouseEnter={() => setHoverDriver(r.driver_number)}
onMouseLeave={() => setHoverDriver(null)}
>
<div className="rs-driver-left">
<div className={`rs-pos-col ${pClass}`}>
{currentPos}
</div>
<div className={`rs-pos-col ${pClass}`}>{currentPos}</div>
<div className="rs-driver-cell">
<div
className="rs-driver-color"
style={{ background: r.team_colour ? `#${r.team_colour}` : 'var(--border)' }}
<div
className="rs-driver-color"
style={{ background: r.team_colour ? `#${r.team_colour}` : 'var(--border)' }}
/>
<div className="rs-driver-identity">
<span className="rs-driver-name">{r.name_acronym || r.driver_number}</span>
@@ -354,12 +749,12 @@ export function RaceStoryCanvas({ data }: Props) {
</span>
<span className="rs-metric-label">Grid</span>
</div>
<div className="rs-metric" style={{ width: '80px', opacity: scrubTime !== null ? 0.3 : 1 }}>
<span>{isWinner ? formatDuration(r.duration) : formatGap(r.gap_to_leader)}</span>
<span className="rs-metric-label">{isWinner ? 'Time' : 'Gap'}</span>
</div>
<div className="rs-metric" style={{ width: '40px' }}>
<span style={{ color: Number(currentPoints) > 0 ? 'var(--text)' : 'var(--text-3)' }}>
{currentPoints}

View File

@@ -0,0 +1,159 @@
import { useMemo, useState } from 'react'
import { Loader2, MapPin, Satellite } from 'lucide-react'
import type { Driver, EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types'
import { buildOutlinePath } from '../lib/trackmap'
import { interpolateReplayCars, replayCarToSvg } from '../lib/replay'
import { EmptyStateCard } from './EmptyStateCard'
import '../styles/replay-map.css'
interface Props {
outline?: TrackOutline | null
replay?: ReplayFramesResponse | null
tMs: number
drivers: Driver[]
results: EnrichedResult[]
loading?: boolean
error?: boolean
}
export function ReplayTrackMap({
outline,
replay,
tMs,
drivers,
results,
loading = false,
error = false,
}: Props) {
const [pinned, setPinned] = useState<string | null>(null)
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
const driverInfo = useMemo(() => {
const info = new Map<string, { label: string; color: string }>()
for (const driver of drivers) {
info.set(String(driver.driver_number), {
label: driver.name_acronym || String(driver.driver_number),
color: normalizeColor(driver.team_colour),
})
}
for (const result of results) {
const key = String(result.driver_number)
if (!info.has(key)) {
info.set(key, {
label: result.name_acronym || key,
color: normalizeColor(result.team_colour),
})
}
}
return info
}, [drivers, results])
const cars = useMemo(() => {
if (!outline?.bounds || !replay?.frames?.length) return []
const positions = interpolateReplayCars(replay.frames, tMs)
return Object.entries(positions)
.map(([number, car]) => ({
number,
svg: replayCarToSvg(car, outline.bounds),
info: driverInfo.get(number),
}))
.sort((a, b) => Number(a.number) - Number(b.number))
}, [driverInfo, outline?.bounds, replay?.frames, tMs])
if (loading) {
return (
<section className="replay-map-panel" data-testid="replay-track-map">
<EmptyStateCard
icon={Loader2}
title="Loading replay map"
hint="Fetching track outline and historical GPS frames."
testId="replay-map-loading"
className="replay-map-empty-card"
/>
</section>
)
}
if (error) {
return (
<section className="replay-map-panel" data-testid="replay-track-map">
<EmptyStateCard
icon={Satellite}
title="Replay GPS unavailable"
hint="This session does not have ingested location samples for the replay map."
testId="replay-map-error"
className="replay-map-empty-card"
/>
</section>
)
}
if (!outline || !outlinePath) {
return (
<section className="replay-map-panel" data-testid="replay-track-map">
<EmptyStateCard
icon={MapPin}
title="Track outline unavailable"
hint="Circuit GPS outline data is missing for this session."
testId="replay-map-no-outline"
className="replay-map-empty-card"
/>
</section>
)
}
if (!replay?.frames?.length || cars.length === 0) {
return (
<section className="replay-map-panel" data-testid="replay-track-map">
<EmptyStateCard
icon={Satellite}
title="Historical GPS unavailable"
hint="Fewer than two replay frames were returned for this session."
testId="replay-map-no-frames"
className="replay-map-empty-card"
/>
</section>
)
}
return (
<section className="replay-map-panel" data-testid="replay-track-map">
<div className="replay-map-stage">
<svg className="replay-map-svg" viewBox="0 0 100 100" role="img" aria-label="Replay track map">
<path className="replay-map-outline-shadow" d={outlinePath} />
<path className="replay-map-outline" d={outlinePath} />
{cars.map((car) => {
const label = car.info?.label ?? car.number
const selected = pinned === car.number
return (
<g
key={car.number}
role="button"
tabIndex={0}
aria-label={`${label} replay position`}
className={`replay-car ${selected ? 'replay-car-pinned' : ''}`}
transform={`translate(${car.svg.x.toFixed(2)} ${car.svg.y.toFixed(2)})`}
onClick={() => setPinned(selected ? null : car.number)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setPinned(selected ? null : car.number)
}
}}
>
<title>{label}</title>
<circle r="2.7" fill={car.info?.color ?? '#777777'} />
<text y="0.85">{label}</text>
</g>
)
})}
</svg>
</div>
</section>
)
}
function normalizeColor(color: string | undefined): string {
const raw = color?.trim()
if (!raw) return '#777777'
return raw.startsWith('#') ? raw : `#${raw}`
}

View 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>
)
}

View File

@@ -0,0 +1,55 @@
import { teamColor } from '../utils'
import '../styles/h2h.css'
export interface TeammateH2HProps {
teamName: string
teamColour: string
driverATla: string
driverBTla: string
winsA: number
winsB: number
/** e.g. "+1" when more than two drivers share the team. */
extraNote?: string
}
export function TeammateH2H({
teamName,
teamColour,
driverATla,
driverBTla,
winsA,
winsB,
extraNote,
}: TeammateH2HProps) {
const total = winsA + winsB || 1
const pctA = (winsA / total) * 100
const color = teamColor(teamColour)
return (
<div className="h2h-row" data-testid="teammate-h2h">
<div className="h2h-team">
<span className="h2h-team-name">{teamName}</span>
{extraNote ? <span className="h2h-extra mono">{extraNote}</span> : null}
</div>
<div className="h2h-bar-wrap">
<span className="h2h-tla h2h-tla--left mono">{driverATla}</span>
<div className="h2h-bar" data-testid="h2h-bar">
<span
className="h2h-bar-seg"
data-testid="h2h-bar-a"
style={{ width: `${pctA}%`, background: color }}
/>
<span
className="h2h-bar-seg h2h-bar-seg--muted"
data-testid="h2h-bar-b"
style={{ width: `${100 - pctA}%`, background: color }}
/>
<span className="h2h-score mono" data-testid="h2h-score">
{winsA}{winsB}
</span>
</div>
<span className="h2h-tla h2h-tla--right mono">{driverBTla}</span>
</div>
</div>
)
}

View File

@@ -80,7 +80,9 @@ export function RaceControlFeed({ messages, driverInfo }: Props) {
<section className="live-rc panel-glass">
<div className="sec-header sticky-header">
<span className="sec-title">Race Control</span>
{messages.length > 0 && <span className="sec-meta">{messages.length} messages</span>}
<span className="sec-meta" data-testid="rc-timezone">
{messages.length > 0 ? `${messages.length} messages · ` : ''}times UTC
</span>
</div>
{latest.length === 0 ? (
<div className="missing-notice">No race control messages in the current live snapshot.</div>
@@ -102,7 +104,7 @@ export function RaceControlFeed({ messages, driverInfo }: Props) {
return (
<div className={`live-rc-row${flashClass}`} key={key}>
<span className="rc-time">{message.Time || '--:--'}</span>
<span className="rc-time" title="UTC">{message.Time || '--:--'}</span>
{message.Lap > 0 && <span className="rc-lap">L{message.Lap}</span>}
{message.Flag
? <span className={`rc-flag ${rcFlagClass(message.Flag)}`}>{message.Flag}</span>

View File

@@ -38,9 +38,11 @@ export function SessionBanner({ isLive, isArchive = false, snapshot, rows, conne
<div className="live-banner-meta">
{display.advanceCount && <span>{display.advanceCount} advance</span>}
{atRiskLabel && <span>{atRiskLabel}</span>}
<span>
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
</span>
{display.isRace && (
<span data-testid="live-lap-counter">
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
</span>
)}
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{stateLabel}</span>
</div>
</div>

View File

@@ -1,9 +1,10 @@
import { useState, Fragment } from 'react'
import { useState, useMemo, Fragment } from 'react'
import { teamColor } from '../../utils'
import { useAutoAnimate } from '@formkit/auto-animate/react'
import type { LiveSessionMeta, LiveStintData } from '../../types'
import type { LiveTimingRow } from '../../lib/live'
import {
bestLapGaps,
driverCode,
liveSessionDisplay,
positionDelta,
@@ -62,6 +63,10 @@ export function TimingTower({
const isQuali = sessionDisplay.isQualifying || !isRace
const columnCount = 7 + (isRace ? 3 : 0) + (isQuali ? 3 : 0)
// Practice/qualifying: derive a display-only gap to P1 from valid best laps
// when the upstream feed omits GapToLeader. Empty for races (feed is truth).
const practiceGaps = useMemo(() => bestLapGaps(isRace ? [] : rows), [isRace, rows])
return (
<div className="scroll-x">
<table className="data-table live-tower" style={{ minWidth: 760 }}>
@@ -71,9 +76,9 @@ export function TimingTower({
{isRace && <th>Δ</th>}
<th>Driver</th>
<th>Tyre</th>
<th>Last Lap</th>
<th
className="interactive"
<th className={isRace ? undefined : 'hide-mobile'}>Last Lap</th>
<th
className="interactive"
onClick={() => setGapMode(g => g === 'interval' ? 'leader' : 'interval')}
title="Click to toggle Gap vs Interval"
style={{ cursor: 'pointer', textDecoration: 'underline' }}
@@ -81,10 +86,10 @@ export function TimingTower({
{gapMode === 'interval' && isRace ? 'Interval' : 'Gap to P1'}
</th>
{isRace && <th>Trend</th>}
{isQuali && <th>S1</th>}
{isQuali && <th>S2</th>}
{isQuali && <th>S3</th>}
<th className="hide-mobile">Best</th>
{isQuali && <th className="hide-mobile">S1</th>}
{isQuali && <th className="hide-mobile">S2</th>}
{isQuali && <th className="hide-mobile">S3</th>}
<th className={isRace ? 'hide-mobile' : undefined}>Best</th>
{isRace && <th className="hide-mobile r">Laps</th>}
<th className="r"></th>
</tr>
@@ -102,7 +107,15 @@ export function TimingTower({
driver.Cutoff
const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition
const gapText = gapMode === 'interval' && isRace ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader
let gapText: string
if (isRace) {
gapText = gapMode === 'interval' ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader
} else if (driver.GapToLeader) {
gapText = driver.GapToLeader
} else {
const computed = practiceGaps[row.RacingNumber]
gapText = computed ? (computed.isLeader ? '—' : computed.gap) : ''
}
const intervalAnnotation =
gapMode === 'interval' && isRace && row.Position > 1
? intervalMeaning(parseIntervalSeconds(gapText))
@@ -153,7 +166,10 @@ export function TimingTower({
<td>
<span className={`tyre-badge ${tyreClass(row.Tyre)}`}>{tyreLabel(row.Tyre)}</span>
</td>
<td className={driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono'}>
<td className={[
isRace ? '' : 'hide-mobile',
driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono',
].filter(Boolean).join(' ')}>
{driver.LastLapTime || '-'}
</td>
<td className="mono">
@@ -171,11 +187,14 @@ export function TimingTower({
</td>
)}
{isQuali && <td>{renderSector(0)}</td>}
{isQuali && <td>{renderSector(1)}</td>}
{isQuali && <td>{renderSector(2)}</td>}
{isQuali && <td className="hide-mobile">{renderSector(0)}</td>}
{isQuali && <td className="hide-mobile">{renderSector(1)}</td>}
{isQuali && <td className="hide-mobile">{renderSector(2)}</td>}
<td className={`hide-mobile ${driver.BestLapOB ? 'mono lap-ob' : 'mono'}`}>
<td className={[
isRace ? 'hide-mobile' : '',
driver.BestLapOB ? 'mono lap-ob' : 'mono',
].filter(Boolean).join(' ')}>
{driver.BestLapTime || '-'}
</td>
@@ -208,6 +227,16 @@ export function TimingTower({
<div className="mono">{driver.NumberOfLaps || 0}</div>
</div>
)}
{!isRace && driver.Sectors?.some((sec) => sec?.Value) && (
<div data-testid="expanded-sectors">
<div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>SECTORS</div>
<div className="mono" style={{ display: 'flex', gap: '10px' }}>
{renderSector(0)}
{renderSector(1)}
{renderSector(2)}
</div>
</div>
)}
{driver.SpeedTrap && (
<div>
<div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>SPEED TRAP</div>

View File

@@ -52,7 +52,10 @@ function StintSparkline({ seconds }: { seconds: number[] }) {
}
export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
const [collapsed, setCollapsed] = useState(false)
const isRace = isRaceSession(sessionType)
// In practice/qualifying deg trends are secondary — collapse by default so
// the Timing Tower stays above the fold. Races keep it open.
const [collapsed, setCollapsed] = useState(!isRace)
const [stints, setStints] = useState<StintHistoryMap>({})
// One lap-history update per received snapshot (rows is rebuilt per snapshot).
@@ -61,8 +64,6 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
setStints((prev) => recordStintSamples(prev, rows.map(stintInputFromRow)))
}, [rows])
const isRace = isRaceSession(sessionType)
const visible = useMemo(
() =>
rows.filter(

View File

@@ -0,0 +1,132 @@
import type { Chapter } from '../types'
export function chapterKindLabel(kind: string): string {
switch (kind) {
case 'start':
return 'GO'
case 'safety_car':
return 'SC'
case 'virtual_safety_car':
return 'VSC'
case 'red_flag':
return 'RF'
case 'pit_phase':
return 'PIT'
case 'decisive_swing':
return '▲'
case 'finish':
return 'FIN'
default:
return kind.slice(0, 3).toUpperCase()
}
}
export function chapterLapRange(chapter: Chapter): string {
if (chapter.start_lap === chapter.end_lap) {
return `L${chapter.start_lap}`
}
return `L${chapter.start_lap}L${chapter.end_lap}`
}
/** Normalized scrub position (01) 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 (01) for a chapter's end time on the chart axis. */
export function chapterEndScrub(
chapter: Chapter,
tMin: number,
tRange: number,
): number | null {
const raw = chapter.end_time ?? chapter.start_time
if (!raw || tRange <= 0) return null
const ms = new Date(raw).getTime()
if (Number.isNaN(ms)) return null
return Math.max(0, Math.min(1, (ms - tMin) / tRange))
}
/** Index of the chapter containing the current scrub position, if any.
* Uses the same 01 clamped bounds as chapterStartScrub/chapterEndScrub so
* chapters whose timestamps fall outside the position-sample window still
* activate when the scrubber is parked at the clamped edge. */
export function activeChapterIndex(
chapters: Chapter[],
scrubTime: number | null,
tMin: number,
tRange: number,
): number | null {
if (scrubTime === null || chapters.length === 0 || tRange <= 0) return null
for (let i = 0; i < chapters.length; i++) {
const start = chapterStartScrub(chapters[i], tMin, tRange)
const end = chapterEndScrub(chapters[i], tMin, tRange)
if (start === null || end === null) continue
const lo = Math.min(start, end)
const hi = Math.max(start, end)
if (scrubTime >= lo && scrubTime <= hi) return i
}
return null
}
/** Duration in ms each chapter should play during a ~90s tour. */
export function chapterTourDurations(chapters: Chapter[], totalMs = 90_000): number[] {
if (chapters.length === 0) return []
const perChapter = totalMs / chapters.length
return chapters.map(() => perChapter)
}
/** Decimated position axis labels, e.g. P1, P5, P10, P15, P20. */
export function decimatedPositionLabels(maxPos: number): number[] {
if (maxPos <= 1) return [1]
const labels = new Set<number>([1])
for (let pos = 5; pos < maxPos; pos += 5) {
labels.add(pos)
}
labels.add(maxPos)
return [...labels].sort((a, b) => a - b)
}
/** Subtle background fill for chapter time-bands on the position graph. */
export function chapterBandFill(kind: string): string {
switch (kind) {
case 'safety_car':
return 'rgba(255, 153, 0, 0.14)'
case 'virtual_safety_car':
return 'rgba(255, 204, 0, 0.12)'
case 'red_flag':
return 'rgba(230, 36, 41, 0.12)'
case 'pit_phase':
return 'rgba(96, 165, 250, 0.08)'
case 'decisive_swing':
return 'rgba(34, 197, 94, 0.08)'
case 'finish':
return 'rgba(255, 204, 0, 0.06)'
default:
return 'rgba(255, 255, 255, 0.03)'
}
}
/** Minimum vertical spacing between colliding labels (SVG units). */
export function deCollideYPositions(
items: ReadonlyArray<{ key: string | number; y: number }>,
minGap = 12,
): Map<string | number, number> {
if (items.length === 0) return new Map()
const sorted = [...items].sort((a, b) => a.y - b.y)
const adjusted = sorted.map((item) => ({ ...item }))
for (let i = 1; i < adjusted.length; i++) {
const prev = adjusted[i - 1]
const curr = adjusted[i]
if (curr.y - prev.y < minGap) {
curr.y = prev.y + minGap
}
}
return new Map(adjusted.map((item) => [item.key, item.y]))
}

293
frontend/src/lib/digest.ts Normal file
View File

@@ -0,0 +1,293 @@
import { meetingEndTime, meetingStartTime, mostRecentPastMeeting, nextUpcomingMeeting } from './schedule'
import type { ChampHubDriver, ChampHubTeam, Meeting, NewsItem } from '../types'
export interface GPWindow {
meeting_key: number
meeting_name: string
/** Inclusive lower bound: end of previous GP, or open for season opener. */
start: Date | null
/** Exclusive upper bound: start of next GP, or open after the final round. */
end: Date | null
}
export interface DigestTag {
kind: 'driver' | 'team'
key: string
label: string
colour: string
}
export type TaggedNewsItem = NewsItem & { tags: DigestTag[] }
export const RECENT_BUCKET_KEY = '__recent__'
interface MatchCandidate {
start: number
end: number
tag: DigestTag
}
interface TagPattern {
pattern: string
tag: DigestTag
}
function sortMeetings(meetings: Meeting[]): Meeting[] {
return [...meetings].sort((a, b) => {
const left = meetingStartTime(a)?.getTime() ?? 0
const right = meetingStartTime(b)?.getTime() ?? 0
if (left !== right) return left - right
return a.meeting_name.localeCompare(b.meeting_name)
})
}
/** Derive inter-race windows [prev GP end → next GP start] for each round. */
export function gpWindows(meetings: Meeting[], _now: Date): GPWindow[] {
const sorted = sortMeetings(meetings)
return sorted.map((meeting, index) => ({
meeting_key: meeting.meeting_key,
meeting_name: meeting.meeting_name,
start: index > 0 ? meetingEndTime(sorted[index - 1]) : null,
end: index < sorted.length - 1 ? meetingStartTime(sorted[index + 1]) : null,
}))
}
function itemPublishedAt(item: NewsItem): Date | null {
if (!item.published_at) return null
const parsed = Date.parse(item.published_at)
return Number.isNaN(parsed) ? null : new Date(parsed)
}
function itemInWindow(published: Date, window: GPWindow): boolean {
if (window.start && published < window.start) return false
if (window.end && published >= window.end) return false
return true
}
export function windowForDate(windows: GPWindow[], published: Date): GPWindow | null {
const matches = windows.filter((window) => itemInWindow(published, window))
if (matches.length === 0) return null
return matches.reduce((best, window) => {
const bestStart = best.start?.getTime() ?? Number.NEGATIVE_INFINITY
const windowStart = window.start?.getTime() ?? Number.NEGATIVE_INFINITY
return windowStart >= bestStart ? window : best
})
}
export interface GroupedDigest {
windows: Array<{ window: GPWindow; items: NewsItem[] }>
recent: NewsItem[]
}
/** Bucket news items into GP windows; undated items land in `recent`. */
export function groupByWindow(items: NewsItem[], windows: GPWindow[]): GroupedDigest {
const buckets = new Map<number, NewsItem[]>()
const recent: NewsItem[] = []
for (const item of items) {
const published = itemPublishedAt(item)
if (!published) {
recent.push(item)
continue
}
const match = windowForDate(windows, published)
if (!match) {
recent.push(item)
continue
}
const list = buckets.get(match.meeting_key) ?? []
list.push(item)
buckets.set(match.meeting_key, list)
}
const grouped = windows
.map((window) => ({
window,
items: buckets.get(window.meeting_key) ?? [],
}))
.filter((entry) => entry.items.length > 0)
return { windows: grouped, recent }
}
function driverLastName(fullName: string): string | null {
const parts = fullName.trim().split(/\s+/).filter(Boolean)
if (parts.length < 2) return null
return parts[parts.length - 1] ?? null
}
function buildTagPatterns(drivers: ChampHubDriver[], teams: ChampHubTeam[]): TagPattern[] {
const patterns: TagPattern[] = []
for (const team of teams) {
if (!team.team_name.trim()) continue
patterns.push({
pattern: team.team_name.toLowerCase(),
tag: {
kind: 'team',
key: `team:${team.team_name}`,
label: team.team_name,
colour: team.team_colour,
},
})
}
for (const driver of drivers) {
const colour = driver.team_colour
const base = {
kind: 'driver' as const,
colour,
}
if (driver.full_name.trim()) {
patterns.push({
pattern: driver.full_name.toLowerCase(),
tag: {
...base,
key: `driver:${driver.name_acronym}`,
label: driver.name_acronym,
},
})
const lastName = driverLastName(driver.full_name)
if (lastName) {
patterns.push({
pattern: lastName.toLowerCase(),
tag: {
...base,
key: `driver:${driver.name_acronym}`,
label: driver.name_acronym,
},
})
}
}
if (driver.name_acronym.trim()) {
patterns.push({
pattern: driver.name_acronym.toLowerCase(),
tag: {
...base,
key: `driver:${driver.name_acronym}`,
label: driver.name_acronym,
},
})
}
}
patterns.sort((a, b) => b.pattern.length - a.pattern.length)
return patterns
}
function overlaps(a: MatchCandidate, b: MatchCandidate): boolean {
return a.start < b.end && b.start < a.end
}
/**
* Tag items via case-insensitive substring match on title + summary.
* Longest pattern wins on overlapping spans (v1 string match — no NLP).
*
* Known limitations: common-word last names, team names inside other words,
* and driver TLAs that appear in unrelated acronyms can false-positive.
*/
export function tagItems(
items: NewsItem[],
drivers: ChampHubDriver[],
teams: ChampHubTeam[],
): TaggedNewsItem[] {
const patterns = buildTagPatterns(drivers, teams)
return items.map((item) => {
const haystack = `${item.title} ${item.summary ?? ''}`.toLowerCase()
const accepted: MatchCandidate[] = []
for (const { pattern, tag } of patterns) {
if (!pattern) continue
let from = 0
while (from <= haystack.length - pattern.length) {
const index = haystack.indexOf(pattern, from)
if (index === -1) break
const candidate: MatchCandidate = {
start: index,
end: index + pattern.length,
tag,
}
if (!accepted.some((match) => overlaps(match, candidate))) {
accepted.push(candidate)
}
from = index + 1
}
}
const tagsByKey = new Map<string, DigestTag>()
for (const match of accepted) {
tagsByKey.set(match.tag.key, match.tag)
}
return {
...item,
tags: [...tagsByKey.values()].sort((a, b) => a.label.localeCompare(b.label)),
}
})
}
export function activeDigestWindow(
windows: GPWindow[],
meetings: Meeting[],
now: Date,
): GPWindow | null {
const next = nextUpcomingMeeting(meetings, now)
if (next) {
return windows.find((window) => window.meeting_key === next.meeting_key) ?? null
}
const recent = mostRecentPastMeeting(meetings, now)
if (recent) {
return windows.find((window) => window.meeting_key === recent.meeting_key) ?? null
}
return windows[0] ?? null
}
export function sinceLastLabel(meetings: Meeting[], now: Date): string {
const last = mostRecentPastMeeting(meetings, now)
if (last) return last.meeting_name
const next = nextUpcomingMeeting(meetings, now)
if (next) return `Before ${next.meeting_name}`
return 'Pre-season'
}
export function itemsForWindow(items: TaggedNewsItem[], window: GPWindow | null): TaggedNewsItem[] {
if (!window) return []
return items.filter((item) => {
const published = itemPublishedAt(item)
return published != null && itemInWindow(published, window)
})
}
export function topTags(items: TaggedNewsItem[], limit = 5): DigestTag[] {
const counts = new Map<string, { tag: DigestTag; count: number }>()
for (const item of items) {
for (const tag of item.tags) {
const current = counts.get(tag.key)
if (current) current.count += 1
else counts.set(tag.key, { tag, count: 1 })
}
}
return [...counts.values()]
.sort((a, b) => b.count - a.count || a.tag.label.localeCompare(b.tag.label))
.slice(0, limit)
.map((entry) => entry.tag)
}
export function filterByTag(items: TaggedNewsItem[], tagKey: string | null): TaggedNewsItem[] {
if (!tagKey) return items
return items.filter((item) => item.tags.some((tag) => tag.key === tagKey))
}
export function tagColour(colour: string): string {
if (!colour) return 'var(--text-3)'
return colour.startsWith('#') ? colour : `#${colour}`
}
export function sortWindowBucketsNewestFirst<T extends { window: GPWindow }>(buckets: T[]): T[] {
return [...buckets].sort((a, b) => {
const left = a.window.start?.getTime() ?? 0
const right = b.window.start?.getTime() ?? 0
return right - left
})
}

View File

@@ -0,0 +1,69 @@
import type { DriverSummaryRound } from '../types'
/** One round mapped for the grid-vs-finish chart and results table. */
export interface RoundDelta {
round: number // 1-based round index within the completed season
label: string // short GP label, e.g. "Bahrain"
grid: number | null
finish: number | null
/** grid finish: positive = places gained on Sunday, null when unknowable. */
delta: number | null
points: number
status: 'classified' | 'dnf' | 'dns' | 'dsq' | 'absent'
}
/** Strip the "Grand Prix" boilerplate so round labels fit chart axes. */
export function shortGpLabel(meetingName: string): string {
const short = meetingName
.replace(/\s*grand prix\s*/i, ' ')
.replace(/\s+gp\s*$/i, ' ')
.trim()
return short || meetingName
}
function roundStatus(r: DriverSummaryRound): RoundDelta['status'] {
if (r.dns) return 'dns'
if (r.dsq) return 'dsq'
if (r.dnf) return 'dnf'
if (r.race_position <= 0 && r.grid_position <= 0) return 'absent'
return 'classified'
}
/**
* Map summary rounds to grid-vs-finish deltas. Positions of 0 mean "no data"
* (driver absent, pit-lane start, …) and become null; a delta is only computed
* when both ends are known and the driver actually started the race.
*/
export function gridFinishDeltas(rounds: DriverSummaryRound[]): RoundDelta[] {
return rounds.map((r, i) => {
const status = roundStatus(r)
const grid = r.grid_position > 0 ? r.grid_position : null
const finish = r.race_position > 0 ? r.race_position : null
const delta =
grid != null && finish != null && status !== 'dns' && status !== 'absent'
? grid - finish
: null
return {
round: i + 1,
label: shortGpLabel(r.meeting_name),
grid,
finish,
delta,
points: r.points,
status,
}
})
}
/** 'P4' for known positions, '—' when unknown. */
export function formatPosition(pos: number | null): string {
return pos != null && pos > 0 ? `P${pos}` : '—'
}
/** '+3' (gained), '2' (lost), '=' (held), '—' (unknown). */
export function formatDelta(delta: number | null): string {
if (delta == null) return '—'
if (delta > 0) return `+${delta}`
if (delta < 0) return `${Math.abs(delta)}`
return '='
}

View File

@@ -47,7 +47,9 @@ export function countryDecal(meeting: Meeting | undefined | null): string {
return name.slice(0, 3).toUpperCase() || '—'
}
export function countryFlag(meeting: Meeting | undefined | null): string {
export function countryFlag(
meeting: Pick<Meeting, 'country_code' | 'country_flag'> | undefined | null,
): string {
if (meeting?.country_flag && !/^https?:\/\//i.test(meeting.country_flag)) return meeting.country_flag
const code = meeting?.country_code?.toUpperCase()
const iso2 = code ? CODE3_TO_2[code] : ''

47
frontend/src/lib/h2h.ts Normal file
View File

@@ -0,0 +1,47 @@
import type { ChampHubDriver } from '../types'
export interface TeammatePair {
teamName: string
teamColour: string
driverA: ChampHubDriver
driverB: ChampHubDriver
/** Drivers on the team beyond the top two (shown as +N). */
extraCount: number
/** Absolute win gap — smaller means a closer battle. */
closeness: number
}
/** Pair teammates per team for H2H display; skip single-driver teams. */
export function teammatePairs(drivers: ReadonlyArray<ChampHubDriver>): TeammatePair[] {
const byTeam = new Map<string, ChampHubDriver[]>()
for (const d of drivers) {
const list = byTeam.get(d.team_name) ?? []
list.push(d)
byTeam.set(d.team_name, list)
}
const pairs: TeammatePair[] = []
for (const [teamName, members] of byTeam) {
if (members.length < 2) continue
const sorted = [...members].sort((a, b) => b.points - a.points)
const driverA = sorted[0]
const driverB = sorted[1]
const winsA = driverA.teammate_wins
const winsB = driverB.teammate_wins
pairs.push({
teamName,
teamColour: driverA.team_colour,
driverA,
driverB,
extraCount: members.length - 2,
closeness: Math.abs(winsA - winsB),
})
}
return pairs.sort((a, b) => {
if (a.closeness !== b.closeness) return a.closeness - b.closeness
return a.teamName.localeCompare(b.teamName)
})
}

View File

@@ -268,6 +268,63 @@ export function driverCode(row: LiveTimingRow): string {
return row.Info?.Tla || row.RacingNumber
}
/**
* Parse an F1 lap-time string ("1:45.944", "45.944") into total seconds.
* Returns null for empty/invalid values so callers never invent a gap.
*/
export function parseLapTimeSeconds(value: string | null | undefined): number | null {
if (!value) return null
const match = value.trim().match(/^(?:(\d+):)?([0-5]?\d(?:\.\d+)?)$/)
if (!match) return null
const minutes = match[1] ? Number(match[1]) : 0
const seconds = Number(match[2])
if (!Number.isFinite(minutes) || !Number.isFinite(seconds)) return null
return minutes * 60 + seconds
}
export interface BestLapGap {
isLeader: boolean
gap: string
}
/**
* Display-only gap-to-P1 for practice/qualifying, derived from each driver's
* valid best lap. Only drivers with a parseable best lap get an entry, and the
* fastest is flagged as the leader. Never fabricates a gap from a missing or
* invalid lap — the upstream interval remains the source of truth for races.
*/
export function bestLapGaps(rows: ReadonlyArray<LiveTimingRow>): Record<string, BestLapGap> {
let leaderNumber = ''
let best = Infinity
for (const row of rows) {
const seconds = parseLapTimeSeconds(row.Driver.BestLapTime)
if (seconds === null) continue
if (seconds < best) {
best = seconds
leaderNumber = row.RacingNumber
}
}
const out: Record<string, BestLapGap> = {}
if (!Number.isFinite(best)) return out
for (const row of rows) {
const seconds = parseLapTimeSeconds(row.Driver.BestLapTime)
if (seconds === null) continue
out[row.RacingNumber] =
row.RacingNumber === leaderNumber
? { isLeader: true, gap: '' }
: { isLeader: false, gap: `+${(seconds - best).toFixed(3)}` }
}
return out
}
/** True when the live feed reports an in-progress session with timing data. */
export function isLiveSessionActive(
state: { is_live?: boolean; data?: unknown } | null | undefined,
): boolean {
return Boolean(state?.is_live && state.data)
}
export function trackStatusLabel(status: string): string {
return TRACK_STATUS_LABELS[status] || status || 'UNKNOWN'
}

View File

@@ -0,0 +1,75 @@
import type { ReplayCarPosition, ReplayFrame, TrackBounds } from '../types'
import { canvasToSvg, normalizeRawPoint } from './trackmap'
export interface ReplayFramePair {
previous: ReplayFrame | null
next: ReplayFrame | null
}
export function lookupReplayFramePair(
frames: ReadonlyArray<ReplayFrame>,
tMs: number,
): ReplayFramePair {
if (frames.length === 0) return { previous: null, next: null }
if (tMs <= frames[0].t) return { previous: frames[0], next: frames[0] }
const last = frames[frames.length - 1]
if (tMs >= last.t) return { previous: last, next: last }
let lo = 0
let hi = frames.length - 1
while (lo <= hi) {
const mid = Math.floor((lo + hi) / 2)
const frame = frames[mid]
if (frame.t === tMs) return { previous: frame, next: frame }
if (frame.t < tMs) {
lo = mid + 1
} else {
hi = mid - 1
}
}
return { previous: frames[Math.max(0, hi)], next: frames[Math.min(frames.length - 1, lo)] }
}
export function interpolateReplayCars(
frames: ReadonlyArray<ReplayFrame>,
tMs: number,
): Record<string, ReplayCarPosition> {
const { previous, next } = lookupReplayFramePair(frames, tMs)
if (!previous && !next) return {}
if (!previous) return next?.cars ?? {}
if (!next || previous.t === next.t) return previous.cars
const span = next.t - previous.t
const fraction = span <= 0 ? 0 : clamp01((tMs - previous.t) / span)
const cars: Record<string, ReplayCarPosition> = {}
const numbers = new Set([...Object.keys(previous.cars), ...Object.keys(next.cars)])
for (const number of numbers) {
const before = previous.cars[number]
const after = next.cars[number]
if (before && after) {
cars[number] = {
x: before.x + (after.x - before.x) * fraction,
y: before.y + (after.y - before.y) * fraction,
}
} else if (before) {
cars[number] = before
} else if (after) {
cars[number] = after
}
}
return cars
}
export function replayCarToSvg(
car: ReplayCarPosition,
bounds: TrackBounds,
): ReplayCarPosition {
return canvasToSvg(normalizeRawPoint({ x: car.x, y: car.y }, bounds))
}
function clamp01(value: number): number {
if (!Number.isFinite(value)) return 0
return Math.min(1, Math.max(0, value))
}

View File

@@ -0,0 +1,14 @@
import type { ReplayFramesResponse, TrackOutline } from '../types'
/** Whether replay map can render (probe uses existing frames + outline queries). */
export function isReplayMapAvailable(
replay: ReplayFramesResponse | null | undefined,
outline: TrackOutline | null | undefined,
hasError: boolean,
): boolean {
if (hasError) return false
const frames = replay?.frames ?? []
if (frames.length < 2) return false
if (!outline?.points?.length) return false
return true
}

View 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)
}

View File

@@ -1,13 +1,33 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchNews, fetchNewsArticle, markNewsRead } from '../api'
import {
fetchChampionshipHub,
fetchNews,
fetchNewsArticle,
fetchSeasonMeetings,
fetchSeasons,
markNewsRead,
} from '../api'
import {
activeDigestWindow,
filterByTag,
groupByWindow,
gpWindows,
itemsForWindow,
sinceLastLabel,
sortWindowBucketsNewestFirst,
tagColour,
tagItems,
topTags,
type DigestTag,
type TaggedNewsItem,
} from '../lib/digest'
import { stripHtml, timeAgo } from '../utils'
import type { ArticleContent, NewsItem } from '../types'
import '../styles/digest.css'
type Category = 'all' | 'official' | 'news' | 'video'
const PAGE_SIZE = 16
const CATEGORY_LABELS: Record<Category, string> = {
all: 'All',
official: 'Official',
@@ -71,6 +91,31 @@ function CategoryTabs({
)
}
function TagChip({
tag,
active,
onClick,
className = 'digest-tag-chip',
}: {
tag: DigestTag
active?: boolean
onClick?: () => void
className?: string
}) {
return (
<button
type="button"
className={`${className}${active ? ' active' : ''}`}
style={{ '--tag-colour': tagColour(tag.colour) } as React.CSSProperties}
onClick={onClick}
aria-pressed={active}
>
<span className="digest-tag-dot" aria-hidden="true" />
{tag.label}
</button>
)
}
function OGImage({ url, title }: { url?: string; title: string }) {
const [failed, setFailed] = useState(false)
const initial = (title[0] ?? '?').toUpperCase()
@@ -98,11 +143,15 @@ function OGImage({ url, title }: { url?: string; title: string }) {
function BriefingCard({
item,
isActive,
activeTag,
onSelect,
onTagClick,
}: {
item: NewsItem
item: TaggedNewsItem
isActive: boolean
activeTag: string | null
onSelect: (item: NewsItem) => void
onTagClick: (tag: DigestTag) => void
}) {
const isRead = !!item.read_at
const isVideo = categoryOf(item) === 'video'
@@ -129,6 +178,25 @@ function BriefingCard({
{stripHtml(item.og_description || item.summary || '')}
</p>
)}
{item.tags.length > 0 && (
<div className="bp-card-tags">
{item.tags.map((tag) => (
<button
key={tag.key}
type="button"
className={`bp-card-tag${activeTag === tag.key ? ' active' : ''}`}
style={{ '--tag-colour': tagColour(tag.colour) } as React.CSSProperties}
onClick={(e) => {
e.stopPropagation()
onTagClick(tag)
}}
>
<span className="digest-tag-dot" aria-hidden="true" />
{tag.label}
</button>
))}
</div>
)}
</div>
</article>
)
@@ -146,7 +214,6 @@ function ReaderPanel({
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
@@ -155,7 +222,6 @@ function ReaderPanel({
return () => document.removeEventListener('keydown', handler)
}, [onClose])
// Fetch article when item changes
useEffect(() => {
if (!item) {
setArticle(null)
@@ -175,9 +241,14 @@ function ReaderPanel({
.catch((err) => { setError(String(err)); setLoading(false) })
}, [item?.url, item ? categoryOf(item) : ''])
// Scroll panel to top when item changes
useEffect(() => {
panelRef.current?.scrollTo({ top: 0 })
const panel = panelRef.current
if (!panel) return
if (typeof panel.scrollTo === 'function') {
panel.scrollTo({ top: 0 })
} else {
panel.scrollTop = 0
}
}, [item?.url])
const isOpen = item !== null
@@ -297,7 +368,6 @@ function ReaderPanel({
{article && !loading && article.content && (
<div
className="bp-reader-body"
// readability strips scripts/iframes; sources are all known news outlets
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: article.content }}
/>
@@ -332,8 +402,9 @@ function ReaderPanel({
export function BriefingPage() {
const [activeCategory, setActiveCategory] = useState<Category>('all')
const [selectedItem, setSelectedItem] = useState<NewsItem | null>(null)
const [page, setPage] = useState(1)
const [activeTag, setActiveTag] = useState<string | null>(null)
const queryClient = useQueryClient()
const now = useMemo(() => new Date(), [])
const { data: allNews = [], isLoading, isError } = useQuery({
queryKey: ['news'],
@@ -341,13 +412,74 @@ export function BriefingPage() {
staleTime: 60_000,
})
const filtered = allNews.filter(
(item) => activeCategory === 'all' || categoryOf(item) === activeCategory,
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: fetchSeasons,
})
const latestSeason = seasonsQuery.data?.[0] ?? null
const meetingsQuery = useQuery({
queryKey: ['season-meetings', latestSeason],
queryFn: () => fetchSeasonMeetings(latestSeason!),
enabled: latestSeason != null,
})
const hubQuery = useQuery({
queryKey: ['championship-hub', latestSeason],
queryFn: () => fetchChampionshipHub(latestSeason!),
enabled: latestSeason != null,
})
const meetings = meetingsQuery.data ?? []
const hub = hubQuery.data
const taggedNews = useMemo(
() => tagItems(allNews, hub?.drivers ?? [], hub?.teams ?? []),
[allNews, hub],
)
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const safePage = Math.min(page, pageCount)
const pageStart = (safePage - 1) * PAGE_SIZE
const pagedItems = filtered.slice(pageStart, pageStart + PAGE_SIZE)
const categoryFiltered = useMemo(
() => taggedNews.filter(
(item) => activeCategory === 'all' || categoryOf(item) === activeCategory,
),
[taggedNews, activeCategory],
)
const tagFiltered = useMemo(
() => filterByTag(categoryFiltered, activeTag),
[categoryFiltered, activeTag],
)
const windows = useMemo(() => gpWindows(meetings, now), [meetings, now])
const grouped = useMemo(() => groupByWindow(tagFiltered, windows), [tagFiltered, windows])
const windowSections = useMemo(
() => sortWindowBucketsNewestFirst(grouped.windows),
[grouped.windows],
)
const activeWindow = useMemo(
() => activeDigestWindow(windows, meetings, now),
[windows, meetings, now],
)
const sinceLastItems = useMemo(
() => itemsForWindow(tagFiltered, activeWindow),
[tagFiltered, activeWindow],
)
const sinceLastTags = useMemo(() => topTags(sinceLastItems), [sinceLastItems])
const sinceLabel = sinceLastLabel(meetings, now)
const taggedByUrl = useMemo(() => {
const map = new Map<string, TaggedNewsItem>()
for (const item of tagFiltered) map.set(item.url, item)
return map
}, [tagFiltered])
const recentTagged = useMemo(() => {
const urls = new Set(grouped.recent.map((item) => item.url))
return tagFiltered.filter((item) => urls.has(item.url))
}, [grouped.recent, tagFiltered])
const counts: Record<Category, number> = {
all: allNews.length,
@@ -374,22 +506,14 @@ export function BriefingPage() {
const handleClose = useCallback(() => setSelectedItem(null), [])
useEffect(() => {
setPage((current) => Math.min(current, pageCount))
}, [pageCount])
const handlePageChange = useCallback(
(nextPage: number) => {
setPage(Math.min(Math.max(nextPage, 1), pageCount))
setSelectedItem(null)
window.scrollTo({ top: 0, behavior: 'smooth' })
},
[pageCount],
)
const handleTagClick = useCallback((tag: DigestTag) => {
setActiveTag((current) => (current === tag.key ? null : tag.key))
setSelectedItem(null)
}, [])
const unreadCount = allNews.filter((i) => !i.read_at).length
const pageEnd = Math.min(pageStart + pagedItems.length, filtered.length)
const showPagination = filtered.length > PAGE_SIZE
const hasDigest = meetings.length > 0
const showEmpty = tagFiltered.length === 0 && grouped.recent.length === 0
return (
<div className="bp-page" data-testid="briefing-page">
@@ -408,74 +532,106 @@ export function BriefingPage() {
<CategoryTabs
active={activeCategory}
counts={counts}
onChange={(c) => { setActiveCategory(c); setSelectedItem(null); setPage(1) }}
onChange={(c) => {
setActiveCategory(c)
setSelectedItem(null)
setActiveTag(null)
}}
/>
{filtered.length === 0 ? (
{hasDigest && (
<div className="digest-sticky" data-testid="digest-sticky-header">
<div className="digest-sticky-head">
<h2 className="digest-sticky-title">Since {sinceLabel}</h2>
<span className="digest-sticky-count">
{sinceLastItems.length} item{sinceLastItems.length === 1 ? '' : 's'}
</span>
</div>
{(sinceLastTags.length > 0 || activeTag) && (
<div className="digest-sticky-tags">
{sinceLastTags.map((tag) => (
<TagChip
key={tag.key}
tag={tag}
active={activeTag === tag.key}
onClick={() => handleTagClick(tag)}
/>
))}
{activeTag && (
<button
type="button"
className="digest-filter-clear"
onClick={() => setActiveTag(null)}
data-testid="digest-filter-clear"
>
Clear filter
</button>
)}
</div>
)}
</div>
)}
{showEmpty ? (
<div className="bp-empty">
No {activeCategory !== 'all' ? activeCategory : ''} items available.
Run <code>box-box --ingest-news</code> to refresh feeds.
{activeTag ? ' Try clearing the tag filter.' : ''}
{!activeTag && (
<>
{' '}
Run <code>box-box --ingest-news</code> to refresh feeds.
</>
)}
</div>
) : (
<>
{showPagination && (
<div className="bp-pagination bp-pagination-top">
<span className="bp-pagination-meta mono">
{pageStart + 1}-{pageEnd} of {filtered.length}
</span>
<div className="bp-pagination-actions">
<button
className="bp-page-btn"
onClick={() => handlePageChange(safePage - 1)}
disabled={safePage === 1}
>
Previous
</button>
<span className="bp-page-current mono">
Page {safePage} / {pageCount}
</span>
<button
className="bp-page-btn"
onClick={() => handlePageChange(safePage + 1)}
disabled={safePage === pageCount}
>
Next
</button>
{windowSections.map(({ window, items }) => (
<section
key={window.meeting_key}
className="digest-section"
data-testid={`digest-window-${window.meeting_key}`}
>
<div className="digest-section-head">
<h3 className="digest-section-title">{window.meeting_name}</h3>
<span className="digest-section-count">{items.length}</span>
</div>
</div>
)}
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
{items.map((item) => {
const tagged = taggedByUrl.get(item.url) ?? { ...item, tags: [] }
return (
<BriefingCard
key={item.url}
item={tagged}
isActive={selectedItem?.url === item.url}
activeTag={activeTag}
onSelect={handleSelect}
onTagClick={handleTagClick}
/>
)
})}
</div>
</section>
))}
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
{pagedItems.map((item) => (
<BriefingCard
key={item.url}
item={item}
isActive={selectedItem?.url === item.url}
onSelect={handleSelect}
/>
))}
</div>
{showPagination && (
<div className="bp-pagination bp-pagination-bottom">
<button
className="bp-page-btn"
onClick={() => handlePageChange(safePage - 1)}
disabled={safePage === 1}
>
Previous
</button>
<span className="bp-page-current mono">
Page {safePage} / {pageCount}
</span>
<button
className="bp-page-btn"
onClick={() => handlePageChange(safePage + 1)}
disabled={safePage === pageCount}
>
Next
</button>
</div>
{recentTagged.length > 0 && (
<section className="digest-section digest-recent-section" data-testid="digest-recent">
<div className="digest-section-head">
<h3 className="digest-section-title">Recent</h3>
<span className="digest-section-count">{recentTagged.length}</span>
</div>
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
{recentTagged.map((item) => (
<BriefingCard
key={item.url}
item={item}
isActive={selectedItem?.url === item.url}
activeTag={activeTag}
onSelect={handleSelect}
onTagClick={handleTagClick}
/>
))}
</div>
</section>
)}
</>
)}

View File

@@ -1,13 +1,17 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { fetchChampionshipHub, fetchSeasons } from '../api'
import { teamColor } from '../utils'
import type { ChampHubDriver, ChampionshipHub } from '../types'
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
import { RivalryCompare } from '../components/RivalryCompare'
import { Meaning } from '../components/Meaning'
import { TeammateH2H } from '../components/TeammateH2H'
import { teammatePairs } from '../lib/h2h'
import { pointsGapMeaning } from '../lib/meaning'
type View = 'drivers' | 'constructors' | 'progression' | 'simulator'
type View = 'drivers' | 'constructors' | 'progression' | 'rivalry' | 'simulator'
const GOLD = '#ffd700'
const SILVER = '#c0c0c0'
@@ -213,6 +217,14 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
>
Progression
</button>
<button
type="button"
className={`champ-tab${view === 'rivalry' ? ' is-active' : ''}`}
onClick={() => setView('rivalry')}
data-testid="champ-tab-rivalry"
>
Rivalry
</button>
<button
type="button"
className={`champ-tab${view === 'simulator' ? ' is-active' : ''}`}
@@ -242,10 +254,13 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
leaderPoints={leader.points}
titleMath={titleMath}
roundsLeft={hub.rounds_left}
drivers={drivers}
season={hub.season}
/>
)}
{view === 'constructors' && <ConstructorsView hub={hub} />}
{view === 'progression' && <ProgressionView hub={hub} />}
{view === 'rivalry' && <RivalryCompare hub={hub} />}
{view === 'simulator' && <ChampionshipSimulator hub={hub} />}
</div>
)
@@ -271,13 +286,19 @@ function DriversView({
leaderPoints,
titleMath,
roundsLeft,
drivers,
season,
}: {
enriched: EnrichedDriver[]
leaderPoints: number
titleMath: string
roundsLeft: number
drivers: ChampHubDriver[]
season: number
}) {
const podium = enriched.slice(0, 3)
const h2hPairs = useMemo(() => teammatePairs(drivers), [drivers])
return (
<div data-testid="champ-view-drivers">
<div className="champ-podium">
@@ -336,6 +357,29 @@ function DriversView({
<span className="champ-titlemath-text">{titleMath}</span>
</div>
{h2hPairs.length > 0 && (
<section className="h2h-section" data-testid="champ-teammate-battles">
<div className="h2h-section-head">
<h2 className="h2h-section-title">Teammate battles</h2>
<span className="h2h-section-sub">race finishes · closest first</span>
</div>
<div className="h2h-list">
{h2hPairs.map((pair) => (
<TeammateH2H
key={pair.teamName}
teamName={pair.teamName}
teamColour={pair.teamColour}
driverATla={pair.driverA.name_acronym}
driverBTla={pair.driverB.name_acronym}
winsA={pair.driverA.teammate_wins}
winsB={pair.driverB.teammate_wins}
extraNote={pair.extraCount > 0 ? `+${pair.extraCount}` : undefined}
/>
))}
</div>
</section>
)}
<div className="champ-scroll">
<table className="champ-table champ-table-drivers">
<thead>
@@ -360,12 +404,19 @@ function DriversView({
P{e.pos}
</td>
<td>
<div className="champ-drv">
<Link
to="/drivers/$driverNumber"
params={{ driverNumber: String(e.d.driver_number) }}
search={{ year: season }}
className="champ-drv"
style={{ color: 'inherit', textDecoration: 'none' }}
data-testid={`champ-driver-link-${e.d.driver_number}`}
>
<span className="champ-drv-bar" style={{ background: e.color }} />
<span className="champ-drv-code mono">{e.d.name_acronym}</span>
<span className="champ-drv-name">{e.d.full_name}</span>
<span className="champ-drv-num mono">#{e.d.driver_number}</span>
</div>
</Link>
</td>
<td className="champ-td-team">{e.d.team_name}</td>
<td className="r mono champ-td-pts">{fmtPts(e.d.points)}</td>

View File

@@ -0,0 +1,369 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { fetchDriverSummary, fetchSeasons } from '../api'
import { teamColor } from '../utils'
import { countryFlag } from '../lib/gpIdentity'
import {
formatDelta,
formatPosition,
gridFinishDeltas,
type RoundDelta,
} from '../lib/driverProfile'
import type { DriverSummary } from '../types'
import '../styles/driver-profile.css'
interface Props {
driverNumber: number
year?: number
}
function fmtPts(n: number): string {
return Number.isInteger(n) ? String(n) : n.toFixed(1)
}
export function DriverProfilePage({ driverNumber, year }: Props) {
// Without an explicit ?year=, default to the latest ingested season; if the
// seasons list is empty/unavailable the backend falls back to the current year.
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: fetchSeasons,
enabled: year == null,
})
const resolvedYear = year ?? seasonsQuery.data?.[0]
const seasonsSettled = year != null || !seasonsQuery.isLoading
const summaryQuery = useQuery({
queryKey: ['driver-summary', driverNumber, resolvedYear ?? 'latest'],
queryFn: () => fetchDriverSummary(driverNumber, resolvedYear),
enabled: seasonsSettled && driverNumber > 0,
staleTime: 5 * 60_000,
})
if (driverNumber <= 0) {
return <div className="page error-box">Invalid driver number</div>
}
if (!seasonsSettled || summaryQuery.isLoading) {
return <div className="page loading-state">loading driver profile</div>
}
if (summaryQuery.isError) {
return (
<div className="page error-box">
{summaryQuery.error instanceof Error
? summaryQuery.error.message
: 'Failed to load driver profile'}
</div>
)
}
const summary = summaryQuery.data
if (!summary) {
return <div className="page error-box">No driver data</div>
}
return <DriverProfileBody summary={summary} />
}
function DriverProfileBody({ summary }: { summary: DriverSummary }) {
const color = teamColor(summary.team_colour)
const deltas = gridFinishDeltas(summary.rounds)
return (
<div className="dp-page" data-testid="driver-profile">
<header className="dp-header" data-testid="dp-header" style={{ borderLeftColor: color }}>
<div className="dp-identity">
<div className="dp-title-row">
<span className="dp-code mono">{summary.name_acronym || `#${summary.driver_number}`}</span>
<span className="dp-num mono" style={{ color }}>
#{summary.driver_number}
</span>
</div>
<h1 className="dp-name">{summary.full_name || `Driver ${summary.driver_number}`}</h1>
<div className="dp-team">
<span className="dp-team-swatch" style={{ background: color }} />
{summary.team_name || 'Unknown team'}
<span className="dp-season mono">{summary.season}</span>
</div>
</div>
<div className="dp-stat-rail">
<div className="dp-stat">
<div className="dp-stat-value mono">{summary.position > 0 ? `P${summary.position}` : '—'}</div>
<div className="dp-stat-label">Championship</div>
</div>
<div className="dp-stat">
<div className="dp-stat-value mono">{fmtPts(summary.points)}</div>
<div className="dp-stat-label">Points</div>
</div>
<div className="dp-stat">
<div className="dp-stat-value mono">{summary.wins}</div>
<div className="dp-stat-label">Wins</div>
</div>
<div className="dp-stat">
<div className="dp-stat-value mono">{summary.podiums}</div>
<div className="dp-stat-label">Podiums</div>
</div>
<div className="dp-stat">
<div className="dp-stat-value mono">{summary.poles}</div>
<div className="dp-stat-label">Poles</div>
</div>
</div>
<Link to="/championship" className="dp-back mono">
Championship
</Link>
</header>
<section className="dp-section" data-testid="dp-form">
<h2 className="dp-section-title mono">Season form</h2>
{summary.cumulative.length > 0 ? (
<div className="dp-form-grid">
<CumulativeLine
cumulative={summary.cumulative}
labels={summary.round_labels}
color={color}
/>
<FormStrip form={summary.form} color={color} />
</div>
) : (
<div className="dp-empty">No completed rounds yet.</div>
)}
</section>
<section className="dp-section" data-testid="dp-quali-race">
<h2 className="dp-section-title mono">Quali vs race</h2>
{deltas.some((d) => d.delta != null) ? (
<GridVsRaceChart deltas={deltas} color={color} />
) : (
<div className="dp-empty">No grid-vs-finish data yet.</div>
)}
</section>
<section className="dp-section" data-testid="dp-rounds">
<h2 className="dp-section-title mono">Track by track</h2>
{summary.rounds.length > 0 ? (
<RoundsTable summary={summary} deltas={deltas} />
) : (
<div className="dp-empty">No completed rounds yet.</div>
)}
</section>
</div>
)
}
const LINE_W = 640
const LINE_H = 180
const LINE_PL = 44
const LINE_PR = 16
const LINE_PT = 12
const LINE_PB = 24
function CumulativeLine({
cumulative,
labels,
color,
}: {
cumulative: number[]
labels: string[]
color: string
}) {
const n = cumulative.length
const maxY = Math.max(25, ...cumulative)
const plotW = LINE_W - LINE_PL - LINE_PR
const plotH = LINE_H - LINE_PT - LINE_PB
const x = (i: number) => (n <= 1 ? LINE_PL + plotW / 2 : LINE_PL + (i * plotW) / (n - 1))
const y = (v: number) => LINE_PT + plotH - (v / maxY) * plotH
const points = cumulative.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ')
const tickStep = Math.max(1, Math.ceil(n / 8))
const ticks: { x: number; label: string }[] = []
for (let i = 0; i < n; i += tickStep) {
ticks.push({ x: x(i), label: labels[i] ?? `R${i + 1}` })
}
return (
<figure className="dp-chart">
<figcaption className="dp-chart-caption mono">Cumulative points</figcaption>
<svg viewBox={`0 0 ${LINE_W} ${LINE_H}`} className="dp-chart-svg" role="img" aria-label="Cumulative points per round">
{[0, 0.5, 1].map((f) => {
const v = Math.round(maxY * f)
return (
<g key={f}>
<line x1={LINE_PL} y1={y(v)} x2={LINE_W - LINE_PR} y2={y(v)} className="dp-gridline" />
<text x={LINE_PL - 6} y={y(v) + 3} textAnchor="end" className="dp-axis">
{v}
</text>
</g>
)
})}
{ticks.map((t) => (
<text key={t.label} x={t.x} y={LINE_H - 8} textAnchor="middle" className="dp-axis">
{t.label}
</text>
))}
<polyline
points={points}
fill="none"
stroke={color}
strokeWidth="2.2"
strokeLinejoin="round"
strokeLinecap="round"
/>
<circle cx={x(n - 1)} cy={y(cumulative[n - 1])} r="3.2" fill={color} />
</svg>
</figure>
)
}
function FormStrip({ form, color }: { form: number[]; color: string }) {
const max = Math.max(25, ...form)
return (
<div className="dp-form-strip">
<div className="dp-chart-caption mono">Last {form.length} races</div>
<div className="dp-form-bars">
{form.map((pts, i) => (
<div className="dp-form-bar" key={i} title={`${fmtPts(pts)} pts`}>
<div
className="dp-form-bar-fill"
style={{ height: `${Math.max(4, (pts / max) * 100)}%`, background: color }}
/>
<span className="dp-form-bar-val mono">{fmtPts(pts)}</span>
</div>
))}
</div>
</div>
)
}
const DELTA_H = 190
const DELTA_PT = 14
const DELTA_PB = 30
function GridVsRaceChart({ deltas, color }: { deltas: RoundDelta[]; color: string }) {
const maxAbs = Math.max(1, ...deltas.map((d) => Math.abs(d.delta ?? 0)))
const colW = 34
const w = Math.max(240, deltas.length * colW)
const plotH = DELTA_H - DELTA_PT - DELTA_PB
const zeroY = DELTA_PT + plotH / 2
const scale = plotH / 2 / maxAbs
return (
<figure className="dp-chart">
<figcaption className="dp-chart-caption mono">
Positions gained () / lost () from grid to flag, per round
</figcaption>
<div className="dp-chart-scroll">
<svg
viewBox={`0 0 ${w} ${DELTA_H}`}
width={w}
height={DELTA_H}
className="dp-delta-svg"
role="img"
aria-label="Positions gained or lost per round"
>
<line x1={0} y1={zeroY} x2={w} y2={zeroY} className="dp-zeroline" />
{deltas.map((d, i) => {
const cx = i * colW + colW / 2
if (d.delta == null) {
return (
<g key={d.round}>
<text x={cx} y={zeroY - 6} textAnchor="middle" className="dp-axis">
·
</text>
<text x={cx} y={DELTA_H - 8} textAnchor="middle" className="dp-axis">
R{d.round}
</text>
</g>
)
}
const h = Math.abs(d.delta) * scale
const yTop = d.delta >= 0 ? zeroY - h : zeroY
return (
<g key={d.round}>
<title>{`${d.label}: ${formatPosition(d.grid)}${formatPosition(d.finish)} (${formatDelta(d.delta)})`}</title>
<rect
x={cx - 8}
y={yTop}
width={16}
height={Math.max(1.5, h)}
rx={2}
fill={color}
opacity={d.delta >= 0 ? 0.95 : 0.45}
/>
<text
x={cx}
y={d.delta >= 0 ? yTop - 4 : yTop + h + 11}
textAnchor="middle"
className="dp-delta-val mono"
>
{formatDelta(d.delta)}
</text>
<text x={cx} y={DELTA_H - 8} textAnchor="middle" className="dp-axis">
R{d.round}
</text>
</g>
)
})}
</svg>
</div>
</figure>
)
}
function statusNote(d: RoundDelta): string {
if (d.status === 'dnf') return 'DNF'
if (d.status === 'dns') return 'DNS'
if (d.status === 'dsq') return 'DSQ'
if (d.status === 'absent') return '—'
return ''
}
function RoundsTable({ summary, deltas }: { summary: DriverSummary; deltas: RoundDelta[] }) {
return (
<div className="dp-scroll">
<table className="dp-table">
<thead>
<tr>
<th className="l">Rnd</th>
<th className="l">Grand Prix</th>
<th className="r">Grid</th>
<th className="r">Race</th>
<th className="r">Δ</th>
<th className="r">Pts</th>
<th className="l"></th>
</tr>
</thead>
<tbody>
{summary.rounds.map((r, i) => {
const d = deltas[i]
return (
<tr key={r.meeting_key}>
<td className="mono dp-td-dim">R{i + 1}</td>
<td>
<span className="dp-flag" aria-hidden="true">
{countryFlag({ country_code: r.country_code, country_flag: '' })}
</span>
{r.meeting_name}
</td>
<td className="r mono">{formatPosition(d.grid)}</td>
<td className="r mono">{formatPosition(d.finish)}</td>
<td
className="r mono"
style={{
color:
d.delta == null
? 'var(--text-3)'
: d.delta > 0
? 'var(--green)'
: d.delta < 0
? 'var(--red)'
: 'var(--text-2)',
}}
>
{formatDelta(d.delta)}
</td>
<td className="r mono">{fmtPts(r.points)}</td>
<td className="dp-td-dim mono">{statusNote(d)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View File

@@ -6,12 +6,17 @@ import { DataLibraryPage } from './pages/DataLibraryPage'
import { LiveTimingPage } from './pages/LiveTimingPage'
import { BriefingPage } from './pages/BriefingPage'
import { ChampionshipPage } from './pages/ChampionshipPage'
import { DriverProfilePage } from './pages/DriverProfilePage'
import { RacePreviewPage } from './pages/RacePreviewPage'
type RaceHubSearch = {
session_key?: number
}
type DriverProfileSearch = {
year?: number
}
const rootRoute = createRootRoute({
component: () => (
<>
@@ -65,6 +70,20 @@ export const championshipRoute = createRoute({
component: ChampionshipPage,
})
export const driverProfileRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/drivers/$driverNumber',
validateSearch: (search: Record<string, unknown>): DriverProfileSearch => {
const year = Number(search.year)
return Number.isFinite(year) && year > 0 ? { year } : {}
},
component: function DriverProfileRoute() {
const { driverNumber } = driverProfileRoute.useParams()
const { year } = driverProfileRoute.useSearch()
return <DriverProfilePage driverNumber={Number(driverNumber) || 0} year={year} />
},
})
export const briefingRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/briefing',
@@ -84,6 +103,7 @@ const routeTree = rootRoute.addChildren([
dataLibraryRoute,
liveTimingRoute,
championshipRoute,
driverProfileRoute,
briefingRoute,
previewRoute,
])

View File

@@ -112,6 +112,30 @@ a { color: inherit; text-decoration: none; }
.nav-links a:hover { color: var(--text); background: var(--surface-h); }
.nav-links a.active { color: var(--text); background: var(--surface-2); }
/* Live nav gets a pulsing marker only while a session is on air. */
.nav-live { display: inline-flex; align-items: center; gap: 6px; }
.nav-links a.nav-live-on { color: var(--text); font-weight: 600; }
.nav-live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--red);
box-shadow: 0 0 0 0 rgba(225, 6, 0, 0.4);
animation: pulse-live 1.6s infinite;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.nav-utility {
margin-left: auto;
display: flex;

View File

@@ -0,0 +1,208 @@
.chapter-strip {
display: flex;
flex-direction: column;
gap: var(--s3);
}
.chapter-strip-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s4);
flex-wrap: wrap;
}
.chapter-strip-title {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
}
.chapter-strip-actions {
display: flex;
align-items: center;
gap: var(--s2);
}
.chapter-tour-btn {
font-family: var(--f-mono);
font-size: 11px;
padding: var(--s1) var(--s3);
border: 1px solid var(--border);
border-radius: var(--s1);
background: var(--surface);
color: var(--text-2);
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.chapter-tour-btn:hover,
.chapter-tour-btn.active {
background: var(--surface-h);
border-color: var(--border-2);
color: var(--text);
}
.chapter-strip-scroll-wrap {
position: relative;
}
.chapter-strip-scroll-wrap::before,
.chapter-strip-scroll-wrap::after {
content: '';
position: absolute;
top: 0;
bottom: var(--s2);
width: 28px;
pointer-events: none;
z-index: 1;
}
.chapter-strip-scroll-wrap::before {
left: 0;
background: linear-gradient(to right, var(--bg), transparent);
}
.chapter-strip-scroll-wrap::after {
right: 0;
background: linear-gradient(to left, var(--bg), transparent);
}
.chapter-strip-scroll {
display: flex;
gap: var(--s3);
overflow-x: auto;
padding-bottom: var(--s2);
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: var(--border-2) transparent;
}
.chapter-strip-scroll::-webkit-scrollbar {
height: 4px;
}
.chapter-strip-scroll::-webkit-scrollbar-track {
background: transparent;
}
.chapter-strip-scroll::-webkit-scrollbar-thumb {
background: var(--border-2);
border-radius: 999px;
}
.chapter-card {
flex: 0 0 min(240px, 72vw);
min-height: 88px;
display: flex;
flex-direction: column;
gap: var(--s2);
padding: var(--s3) var(--s4);
border: 1px solid var(--border);
border-radius: var(--s2);
background: var(--surface);
text-align: left;
cursor: pointer;
scroll-snap-align: center;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
}
.chapter-card:hover {
background: var(--surface-h);
border-color: var(--border-2);
}
.chapter-card.active {
border-color: var(--accent, var(--red));
background: var(--surface-h);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, var(--red)) 35%, transparent);
}
.chapter-card.tour-active {
border-color: var(--yellow);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--yellow) 40%, transparent);
}
.chapter-card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s3);
}
.chapter-kind {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 20px;
padding: 0 var(--s2);
border-radius: var(--s1);
font-family: var(--f-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
background: var(--bg);
color: var(--text-2);
border: 1px solid var(--border);
}
.chapter-card.active .chapter-kind {
color: var(--text);
border-color: var(--border-2);
}
.chapter-kind--safety_car,
.chapter-kind--virtual_safety_car {
color: var(--orange, #f90);
}
.chapter-kind--red_flag {
color: var(--red);
}
.chapter-kind--decisive_swing {
color: var(--green, #22c55e);
}
.chapter-kind--finish {
color: var(--yellow);
}
.chapter-lap-range {
font-family: var(--f-mono);
font-size: 10px;
color: var(--text-3);
white-space: nowrap;
}
.chapter-headline {
font-size: 13px;
line-height: 1.35;
color: var(--text);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
flex: 1;
}
.chapter-strip-empty-card {
padding: var(--s6) var(--s5);
margin: 0;
}
.chapter-strip-empty-card .empty-state-icon {
display: block;
margin: 0 auto var(--s3);
color: var(--text-3);
}
@media (max-width: 900px) {
.chapter-strip-header {
align-items: flex-start;
}
}

View File

@@ -0,0 +1,178 @@
/* ── Calendar-aware paddock digest ── */
.digest-sticky {
position: sticky;
top: 0;
z-index: 20;
margin-bottom: var(--s5);
padding: var(--s4) var(--s5);
background: color-mix(in srgb, var(--bg) 92%, transparent);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 6px;
}
.digest-sticky-head {
display: flex;
align-items: baseline;
gap: var(--s3);
flex-wrap: wrap;
margin-bottom: var(--s3);
}
.digest-sticky-title {
font-size: 15px;
font-weight: 600;
color: var(--text);
margin: 0;
}
.digest-sticky-count {
font-size: 11px;
color: var(--text-3);
font-family: var(--f-mono);
letter-spacing: 0.04em;
}
.digest-sticky-tags {
display: flex;
flex-wrap: wrap;
gap: var(--s2);
align-items: center;
}
.digest-tag-chip {
display: inline-flex;
align-items: center;
gap: var(--s2);
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border-2);
background: var(--surface);
color: var(--text-2);
font-size: 11px;
font-family: var(--f-mono);
cursor: pointer;
transition: border-color 0.1s, color 0.1s, background 0.1s;
}
.digest-tag-chip:hover {
color: var(--text);
background: var(--surface-h);
}
.digest-tag-chip.active {
color: var(--text);
border-color: var(--tag-colour, var(--red));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--tag-colour, var(--red)) 35%, transparent);
}
.digest-tag-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--tag-colour, var(--text-3));
flex-shrink: 0;
}
.digest-filter-clear {
padding: 2px 8px;
border: 1px dashed var(--border-2);
border-radius: 999px;
background: none;
color: var(--text-3);
font-size: 11px;
font-family: var(--f-mono);
cursor: pointer;
}
.digest-filter-clear:hover {
color: var(--text);
border-color: var(--border-2);
}
.digest-section {
margin-bottom: var(--s6);
}
.digest-section-head {
display: flex;
align-items: baseline;
gap: var(--s3);
margin-bottom: var(--s4);
padding-bottom: var(--s3);
border-bottom: 1px solid var(--border);
}
.digest-section-title {
font-size: 13px;
font-weight: 600;
color: var(--text);
margin: 0;
letter-spacing: 0.02em;
}
.digest-section-count {
font-size: 11px;
color: var(--text-3);
font-family: var(--f-mono);
}
.digest-recent-section .digest-section-title {
color: var(--text-2);
}
.bp-card-tags {
display: flex;
flex-wrap: wrap;
gap: var(--s2);
margin-top: var(--s2);
}
.bp-card-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 6px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text-2);
font-size: 10px;
font-family: var(--f-mono);
cursor: pointer;
}
.bp-card-tag:hover {
color: var(--text);
border-color: var(--border-2);
}
.bp-card-tag.active {
border-color: var(--tag-colour, var(--red));
}
.cc-brief-digest-meta {
display: flex;
align-items: center;
gap: var(--s2);
margin-bottom: var(--s3);
font-size: 11px;
color: var(--text-3);
font-family: var(--f-mono);
}
.cc-brief-digest-tags {
display: flex;
flex-wrap: wrap;
gap: var(--s2);
}
.cc-brief-tag {
font-size: 10px;
padding: 1px 6px;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--text-2);
font-family: var(--f-mono);
}

View File

@@ -0,0 +1,289 @@
/* Driver profile page (/drivers/$driverNumber) */
.dp-page {
max-width: 1100px;
margin: 0 auto;
padding: var(--s6) var(--s5);
display: flex;
flex-direction: column;
gap: var(--s6);
}
/* --- header --- */
.dp-header {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: var(--s5);
padding: var(--s5);
padding-left: var(--s6);
border: 1px solid var(--border);
border-left: 4px solid var(--red);
border-radius: var(--r2);
background: var(--surface);
}
.dp-title-row {
display: flex;
align-items: baseline;
gap: var(--s3);
}
.dp-code {
font-size: 13px;
letter-spacing: 0.12em;
color: var(--text-2);
text-transform: uppercase;
}
.dp-num {
font-size: 13px;
font-weight: 700;
}
.dp-name {
margin: var(--s2) 0;
font-size: 30px;
line-height: 1.1;
letter-spacing: -0.01em;
}
.dp-team {
display: flex;
align-items: center;
gap: var(--s3);
font-size: 13px;
color: var(--text-2);
}
.dp-team-swatch {
width: 10px;
height: 10px;
border-radius: 2px;
}
.dp-season {
color: var(--text-3);
}
.dp-stat-rail {
display: flex;
gap: var(--s5);
}
.dp-stat {
min-width: 72px;
}
.dp-stat-value {
font-size: 22px;
font-weight: 700;
}
.dp-stat-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-3);
}
.dp-back {
position: absolute;
top: var(--s4);
right: var(--s5);
font-size: 12px;
color: var(--text-3);
text-decoration: none;
}
.dp-back:hover {
color: var(--text);
}
/* --- sections --- */
.dp-section {
display: flex;
flex-direction: column;
gap: var(--s4);
}
.dp-section-title {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-2);
}
.dp-empty {
padding: var(--s5);
border: 1px dashed var(--border);
border-radius: var(--r2);
color: var(--text-3);
font-size: 13px;
}
/* --- charts --- */
.dp-form-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(200px, 1fr);
gap: var(--s5);
align-items: stretch;
}
@media (max-width: 760px) {
.dp-form-grid {
grid-template-columns: 1fr;
}
}
.dp-chart {
margin: 0;
padding: var(--s4);
border: 1px solid var(--border);
border-radius: var(--r2);
background: var(--surface);
min-width: 0;
}
.dp-chart-caption {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-3);
margin-bottom: var(--s3);
}
.dp-chart-svg {
display: block;
width: 100%;
height: auto;
}
.dp-chart-scroll {
overflow-x: auto;
}
.dp-gridline {
stroke: var(--border);
stroke-width: 1;
}
.dp-zeroline {
stroke: var(--text-3);
stroke-width: 1;
stroke-dasharray: 3 3;
}
.dp-axis {
fill: var(--text-3);
font-family: var(--f-mono);
font-size: 10px;
}
.dp-delta-svg {
display: block;
}
.dp-delta-val {
fill: var(--text-2);
font-size: 10px;
}
/* --- form strip --- */
.dp-form-strip {
padding: var(--s4);
border: 1px solid var(--border);
border-radius: var(--r2);
background: var(--surface);
display: flex;
flex-direction: column;
}
.dp-form-bars {
flex: 1;
display: flex;
align-items: flex-end;
gap: var(--s3);
min-height: 110px;
}
.dp-form-bar {
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: center;
gap: var(--s2);
}
.dp-form-bar-fill {
width: 100%;
max-width: 34px;
border-radius: 2px 2px 0 0;
opacity: 0.9;
}
.dp-form-bar-val {
font-size: 11px;
color: var(--text-2);
}
/* --- rounds table --- */
.dp-scroll {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--r2);
background: var(--surface);
}
.dp-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.dp-table th {
padding: var(--s3) var(--s4);
font-family: var(--f-mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-3);
border-bottom: 1px solid var(--border);
}
.dp-table td {
padding: var(--s3) var(--s4);
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.dp-table tbody tr:last-child td {
border-bottom: none;
}
.dp-table th.l,
.dp-table td.l {
text-align: left;
}
.dp-table th.r,
.dp-table td.r {
text-align: right;
}
.dp-td-dim {
color: var(--text-3);
}
.dp-flag {
margin-right: var(--s3);
}

130
frontend/src/styles/h2h.css Normal file
View File

@@ -0,0 +1,130 @@
/* Teammate head-to-head bars (issue #23). */
.h2h-section {
margin: 20px 0 24px;
padding: 16px 18px;
background: var(--surface-1, rgba(255, 255, 255, 0.03));
border: 1px solid var(--border);
border-radius: 8px;
}
.h2h-section-head {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 14px;
}
.h2h-section-title {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-2);
}
.h2h-section-sub {
font-size: 11px;
font-family: var(--f-mono);
color: var(--text-3);
}
.h2h-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.h2h-row {
display: grid;
grid-template-columns: minmax(100px, 140px) 1fr;
align-items: center;
gap: 12px;
}
.h2h-team {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.h2h-team-name {
font-size: 12px;
color: var(--text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.h2h-extra {
font-size: 10px;
color: var(--text-3);
flex-shrink: 0;
}
.h2h-bar-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.h2h-tla {
font-size: 11px;
font-weight: 700;
color: var(--text);
flex-shrink: 0;
width: 28px;
}
.h2h-tla--left {
text-align: right;
}
.h2h-tla--right {
text-align: left;
}
.h2h-bar {
position: relative;
flex: 1;
display: flex;
height: 22px;
border-radius: 4px;
overflow: hidden;
background: rgba(255, 255, 255, 0.04);
}
.h2h-bar-seg {
height: 100%;
transition: width 0.2s ease;
}
.h2h-bar-seg--muted {
opacity: 0.35;
}
.h2h-score {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
pointer-events: none;
}
@media (max-width: 640px) {
.h2h-row {
grid-template-columns: 1fr;
gap: 4px;
}
.h2h-bar-wrap {
width: 100%;
}
}

View File

@@ -0,0 +1,157 @@
.race-story-empty-card {
padding: var(--s6) var(--s5);
margin: 0;
}
.race-story-empty-card .empty-state-icon {
display: block;
margin: 0 auto var(--s3);
color: var(--text-3);
}
.rs-replay-shell {
display: grid;
grid-template-columns: 1fr;
gap: var(--s5);
align-items: stretch;
}
.rs-replay-shell--split {
grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.8fr);
}
.rs-replay-main {
min-width: 0;
}
.rs-chart-container {
--rs-chart-max-width: 100%;
}
.rs-chart-container--full {
--rs-chart-max-width: 100%;
}
.rs-segmented-control {
display: inline-flex;
align-items: stretch;
flex-wrap: wrap;
gap: 0;
margin-top: var(--s3);
border: 1px solid var(--border-2);
border-radius: var(--s1);
background: var(--surface);
overflow: hidden;
}
.rs-segment {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 30px;
padding: 0 var(--s4);
font-family: var(--f-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text-3);
background: transparent;
border: none;
border-right: 1px solid var(--border);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.rs-segment:last-child {
border-right: none;
}
.rs-segment:hover:not(:disabled) {
color: var(--text);
background: var(--surface-h);
}
.rs-segment:focus-visible {
outline: 2px solid var(--red);
outline-offset: -2px;
z-index: 1;
}
.rs-segment.active {
color: var(--text);
background: rgba(230, 36, 41, 0.12);
box-shadow: inset 0 -2px 0 var(--red);
}
.rs-segment:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.rs-segment-divider {
width: 1px;
align-self: stretch;
background: var(--border);
flex-shrink: 0;
}
.rs-replay-map-slot {
min-width: 0;
}
.rs-position-chart-svg {
width: 100%;
min-width: 280px;
max-width: var(--rs-chart-max-width, 640px);
display: block;
}
.rs-chart-axis-label {
fill: var(--text-3);
font-family: var(--f-mono);
font-size: 9px;
}
.rs-chart-grid-line {
stroke: var(--border);
stroke-width: 0.5;
}
.rs-chart-lap-tick {
stroke: var(--border);
stroke-width: 1;
}
@media (max-width: 900px) {
.rs-replay-shell--split {
grid-template-columns: 1fr;
}
.rs-segmented-control {
width: 100%;
}
.rs-segment {
flex: 1 1 auto;
}
}
@media (max-width: 560px) {
.rs-segmented-control {
flex-direction: column;
align-items: stretch;
}
.rs-segment {
border-right: none;
border-bottom: 1px solid var(--border);
}
.rs-segment:last-child {
border-bottom: none;
}
.rs-segment-divider {
display: none;
}
}

View File

@@ -0,0 +1,91 @@
.replay-map-panel {
min-height: 244px;
height: 100%;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--s2);
}
.replay-map-stage {
position: relative;
min-height: 244px;
height: 100%;
}
.replay-map-svg {
width: 100%;
height: 100%;
min-height: 244px;
display: block;
background: radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.04), transparent 62%);
}
.replay-map-outline-shadow,
.replay-map-outline {
fill: none;
vector-effect: non-scaling-stroke;
stroke-linecap: round;
stroke-linejoin: round;
}
.replay-map-outline-shadow {
stroke: rgba(255, 255, 255, 0.08);
stroke-width: 5;
}
.replay-map-outline {
stroke: var(--text-2);
stroke-width: 1.8;
}
.replay-car {
cursor: pointer;
outline: none;
}
.replay-car circle {
stroke: var(--bg);
stroke-width: 0.8;
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.55));
}
.replay-car text {
fill: #fff;
font-family: var(--f-mono);
font-size: 2.6px;
font-weight: 800;
text-anchor: middle;
pointer-events: none;
paint-order: stroke;
stroke: rgba(0, 0, 0, 0.75);
stroke-width: 0.7;
}
.replay-car:not(.replay-car-pinned) text {
opacity: 0;
}
.replay-car:hover text,
.replay-car:focus-visible text,
.replay-car-pinned text {
opacity: 1;
}
.replay-car-pinned circle {
stroke: #fff;
stroke-width: 1.2;
}
.replay-map-empty-card {
min-height: 244px;
display: grid;
place-content: center;
padding: var(--s6);
margin: 0;
}
.replay-map-empty-card .empty-state-icon {
display: block;
margin: 0 auto var(--s3);
color: var(--text-3);
}

View 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;
}

View File

@@ -0,0 +1,211 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BriefingPage } from '../pages/BriefingPage'
import type { ChampHubDriver, ChampHubTeam, ChampionshipHub, Meeting, NewsItem } from '../types'
vi.mock('../api', () => ({
fetchNews: vi.fn(),
fetchNewsArticle: vi.fn(),
markNewsRead: vi.fn(),
fetchSeasons: vi.fn(),
fetchSeasonMeetings: vi.fn(),
fetchChampionshipHub: vi.fn(),
}))
import {
fetchNews,
fetchNewsArticle,
fetchSeasons,
fetchSeasonMeetings,
fetchChampionshipHub,
markNewsRead,
} from '../api'
const mockFetchNews = vi.mocked(fetchNews)
const mockFetchNewsArticle = vi.mocked(fetchNewsArticle)
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchSeasonMeetings = vi.mocked(fetchSeasonMeetings)
const mockFetchHub = vi.mocked(fetchChampionshipHub)
const mockMarkNewsRead = vi.mocked(markNewsRead)
const bahrain: Meeting = {
meeting_key: 1,
meeting_name: 'Bahrain',
meeting_official_name: 'Bahrain GP',
location: 'Sakhir',
country_name: 'Bahrain',
country_code: 'BRN',
country_flag: '',
circuit_short_name: 'Sakhir',
date_start: '2025-03-14T00:00:00+00:00',
date_end: '2025-03-16T23:59:59+00:00',
year: 2025,
}
const monaco: Meeting = {
meeting_key: 2,
meeting_name: 'Monaco',
meeting_official_name: 'Monaco GP',
location: 'Monaco',
country_name: 'Monaco',
country_code: 'MON',
country_flag: '',
circuit_short_name: 'Monaco',
date_start: '2025-05-23T00:00:00+00:00',
date_end: '2025-05-25T23:59:59+00:00',
year: 2025,
}
const drivers: ChampHubDriver[] = [
{
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
points: 100,
position: 1,
wins: 3,
podiums: 5,
poles: 2,
form: [25, 18, 25],
cumulative: [25, 43, 68, 100],
teammate_wins: 4,
teammate_losses: 1,
round_positions: [],
},
{
driver_number: 4,
name_acronym: 'NOR',
full_name: 'Lando Norris',
team_name: 'McLaren',
team_colour: 'ff8000',
points: 80,
position: 2,
wins: 1,
podiums: 4,
poles: 1,
form: [18, 25, 18],
cumulative: [18, 36, 54, 80],
teammate_wins: 3,
teammate_losses: 2,
round_positions: [],
},
]
const teams: ChampHubTeam[] = [
{ team_name: 'Red Bull', team_colour: '3671c6', points: 150, position: 1, wins: 4 },
{ team_name: 'McLaren', team_colour: 'ff8000', points: 120, position: 2, wins: 2 },
]
const hub: ChampionshipHub = {
season: 2025,
round: 2,
total_rounds: 2,
rounds_left: 0,
last_race: 'Monaco GP',
round_labels: ['R1', 'R2'],
drivers,
teams,
}
const newsItems: NewsItem[] = [
{
source: 'autosport-f1',
title: 'Verstappen sets the pace in Bahrain',
url: 'https://example.com/bahrain-ver',
published_at: '2025-03-15T10:00:00Z',
fetched_at: '2025-03-15T11:00:00Z',
summary: 'Red Bull dominate opening practice',
},
{
source: 'racefans-f1',
title: 'Norris targets Monaco upgrade',
url: 'https://example.com/monaco-nor',
published_at: '2025-04-20T10:00:00Z',
fetched_at: '2025-04-20T11:00:00Z',
summary: 'McLaren bring new floor',
},
{
source: 'bbc-f1',
title: 'Undated paddock rumour',
url: 'https://example.com/undated',
fetched_at: '2025-04-21T11:00:00Z',
},
]
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
return render(
<QueryClientProvider client={queryClient}>
<BriefingPage />
</QueryClientProvider>,
)
}
describe('BriefingPage digest layout', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.setSystemTime(new Date('2025-04-10T12:00:00Z'))
mockFetchSeasons.mockResolvedValue([2025])
mockFetchSeasonMeetings.mockResolvedValue([bahrain, monaco])
mockFetchHub.mockResolvedValue(hub)
mockFetchNews.mockResolvedValue(newsItems)
mockFetchNewsArticle.mockResolvedValue({ title: 'Article', content: '<p>Body</p>' })
mockMarkNewsRead.mockResolvedValue(undefined)
})
afterEach(() => {
vi.useRealTimers()
})
it('renders sticky since-last header, GP sections, and recent bucket', async () => {
renderPage()
await waitFor(() => {
expect(screen.getByTestId('digest-sticky-header')).toHaveTextContent('Since Bahrain')
})
expect(screen.getByTestId('digest-window-2')).toHaveTextContent('Monaco')
expect(screen.getByTestId('digest-window-1')).toHaveTextContent('Bahrain')
expect(screen.getByTestId('digest-recent')).toHaveTextContent('Undated paddock rumour')
expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
expect(screen.getByText('Norris targets Monaco upgrade')).toBeInTheDocument()
})
it('filters the digest when a tag chip is clicked', async () => {
renderPage()
await waitFor(() => {
expect(screen.getByTestId('digest-sticky-header')).toBeInTheDocument()
})
const sticky = screen.getByTestId('digest-sticky-header')
const norTag = within(sticky).getByRole('button', { name: /^NOR$/i })
fireEvent.click(norTag)
await waitFor(() => {
expect(screen.queryByText('Verstappen sets the pace in Bahrain')).not.toBeInTheDocument()
})
expect(screen.getByText('Norris targets Monaco upgrade')).toBeInTheDocument()
expect(screen.getByTestId('digest-filter-clear')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('digest-filter-clear'))
await waitFor(() => {
expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
})
})
it('preserves category tabs', async () => {
renderPage()
await waitFor(() => {
expect(screen.getByRole('tab', { name: /All/i })).toBeInTheDocument()
})
expect(screen.getByRole('tab', { name: /News/i })).toBeInTheDocument()
})
})

View File

@@ -29,6 +29,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
poles: 4,
form: [25, 18, 25, 15, 25],
cumulative: [25, 43, 68, 83, 108, 200],
round_positions: [1, 2, 1, 3, 1, 1],
teammate_wins: 9,
teammate_losses: 1,
...over,
@@ -47,6 +48,7 @@ const drivers: ChampHubDriver[] = [
position: 2,
wins: 3,
cumulative: [18, 36, 54, 80, 120, 160],
round_positions: [2, 1, 2, 2, 2, 2],
}),
driver({
driver_number: 16,
@@ -58,6 +60,7 @@ const drivers: ChampHubDriver[] = [
position: 3,
wins: 1,
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()
})
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 → 42.
expect(screen.getByTestId('rivalry-h2h-num')).toHaveTextContent('42')
expect(screen.getByTestId('rivalry-points-race')).toBeInTheDocument()
expect(screen.getByTestId('rivalry-gap')).toBeInTheDocument()
})
it('switches to the simulator view and projects standings', async () => {
renderPage()
@@ -150,4 +169,61 @@ describe('ChampionshipPage', () => {
})
expect(screen.getByText('No championship data')).toBeInTheDocument()
})
it('renders teammate battles section ordered by closest split', async () => {
const h2hDrivers: ChampHubDriver[] = [
driver({ driver_number: 1, name_acronym: 'VER', team_name: 'Red Bull', points: 200, position: 1 }),
driver({
driver_number: 4,
name_acronym: 'NOR',
full_name: 'Lando Norris',
team_name: 'McLaren',
team_colour: 'ff8000',
points: 160,
position: 2,
teammate_wins: 6,
teammate_losses: 5,
cumulative: [18, 36, 54, 80, 120, 160],
}),
driver({
driver_number: 81,
name_acronym: 'PIA',
full_name: 'Oscar Piastri',
team_name: 'McLaren',
team_colour: 'ff8000',
points: 140,
position: 3,
teammate_wins: 5,
teammate_losses: 6,
cumulative: [12, 28, 45, 70, 110, 140],
}),
driver({
driver_number: 11,
name_acronym: 'PER',
full_name: 'Sergio Perez',
team_name: 'Red Bull',
team_colour: '3671c6',
points: 60,
position: 4,
teammate_wins: 1,
teammate_losses: 9,
cumulative: [5, 12, 20, 35, 50, 60],
}),
]
mockFetchHub.mockResolvedValue({ ...hub, drivers: h2hDrivers })
renderPage()
await waitFor(() => {
expect(screen.getByTestId('champ-teammate-battles')).toBeInTheDocument()
})
expect(screen.getByText('Teammate battles')).toBeInTheDocument()
const rows = screen.getAllByTestId('teammate-h2h')
expect(rows).toHaveLength(2)
// McLaren 65 is closer than Red Bull 91 — McLaren row first.
expect(rows[0]).toHaveTextContent('McLaren')
expect(rows[0]).toHaveTextContent('65')
expect(rows[1]).toHaveTextContent('Red Bull')
expect(rows[1]).toHaveTextContent('91')
})
})

View File

@@ -17,6 +17,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
poles: 0,
form: [],
cumulative: [],
round_positions: [],
teammate_wins: 0,
teammate_losses: 0,
...over,

View File

@@ -0,0 +1,128 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChapterStrip } from '../components/ChapterStrip'
import type { Chapter } from '../types'
const chapters: Chapter[] = [
{
kind: 'start',
title: 'Start',
headline: 'Lights out — the field charges into Turn 1',
start_lap: 1,
end_lap: 1,
start_time: '2025-05-25T13:00:00Z',
end_time: '2025-05-25T13:01:00Z',
driver_numbers: [],
},
{
kind: 'safety_car',
title: 'Safety Car (L12-L15)',
headline: 'Sainz incident brings out the Safety Car — leaders dive for the pits',
start_lap: 12,
end_lap: 15,
start_time: '2025-05-25T13:12:00Z',
end_time: '2025-05-25T13:15:00Z',
driver_numbers: [55],
},
]
const tMin = new Date('2025-05-25T13:00:00Z').getTime()
const tMax = new Date('2025-05-25T13:20:00Z').getTime()
const tRange = tMax - tMin
function renderStrip(
overrides: Partial<{
scrubTime: number | null
onChapterClick: (index: number, scrub: number) => void
}> = {},
) {
const onChapterClick = overrides.onChapterClick ?? vi.fn()
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={queryClient}>
<ChapterStrip
chapters={chapters}
scrubTime={overrides.scrubTime ?? null}
tMin={tMin}
tRange={tRange}
tourActive={false}
tourChapterIndex={null}
onChapterClick={onChapterClick}
onTourToggle={vi.fn()}
/>
</QueryClientProvider>,
)
return { onChapterClick }
}
describe('ChapterStrip', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders chapter headlines and lap ranges', () => {
renderStrip()
expect(screen.getByTestId('chapter-strip')).toBeInTheDocument()
expect(screen.getByText('Lights out — the field charges into Turn 1')).toBeInTheDocument()
expect(screen.getByText('L12L15')).toBeInTheDocument()
})
it('renders an empty-state card when there are no chapters', () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={queryClient}>
<ChapterStrip
chapters={[]}
scrubTime={null}
tMin={tMin}
tRange={tRange}
tourActive={false}
tourChapterIndex={null}
onChapterClick={vi.fn()}
onTourToggle={vi.fn()}
/>
</QueryClientProvider>,
)
expect(screen.getByTestId('chapter-strip-empty')).toBeInTheDocument()
})
it('highlights the active chapter from scrub time', () => {
const scrub = (new Date('2025-05-25T13:13:00Z').getTime() - tMin) / tRange
renderStrip({ scrubTime: scrub })
expect(screen.getByTestId('chapter-card-1')).toHaveClass('active')
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
})
it('calls click handler to jump scrubber', () => {
const onChapterClick = vi.fn<(index: number, scrub: number) => void>()
renderStrip({ onChapterClick })
fireEvent.click(screen.getByTestId('chapter-card-1'))
expect(onChapterClick).toHaveBeenCalledTimes(1)
const [index, scrub] = onChapterClick.mock.calls[0]
expect(index).toBe(1)
expect(scrub).toBeCloseTo(0.6, 2)
})
it('highlights an explicitly selected chapter even when scrub is outside its raw window', () => {
// Scrub parked at chart start; chapter 1's raw times are mid-race, but selection wins.
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={queryClient}>
<ChapterStrip
chapters={chapters}
scrubTime={0}
tMin={tMin}
tRange={tRange}
tourActive={false}
tourChapterIndex={null}
selectedChapterIndex={1}
onChapterClick={vi.fn()}
onTourToggle={vi.fn()}
/>
</QueryClientProvider>,
)
expect(screen.getByTestId('chapter-card-1')).toHaveClass('active')
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
})
})

View File

@@ -156,6 +156,7 @@ describe('CommandCenterPage', () => {
race_control: [],
weather: [],
laps: [],
chapters: [],
})
})

View File

@@ -0,0 +1,160 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
import { DriverProfilePage } from '../pages/DriverProfilePage'
import type { DriverSummary } from '../types'
vi.mock('../api', () => ({
fetchSeasons: vi.fn(),
fetchDriverSummary: vi.fn(),
}))
import { fetchSeasons, fetchDriverSummary } from '../api'
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchSummary = vi.mocked(fetchDriverSummary)
const summary: DriverSummary = {
season: 2025,
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
headshot_url: '',
points: 50,
position: 1,
wins: 2,
podiums: 2,
poles: 1,
form: [25, 25],
cumulative: [25, 50],
round_labels: ['R1', 'R2'],
rounds: [
{
meeting_key: 1201,
meeting_name: 'Bahrain Grand Prix',
country_code: 'BHR',
country_name: 'Bahrain',
race_position: 1,
grid_position: 1,
quali_position: 1,
points: 25,
dnf: false,
dns: false,
dsq: false,
},
{
meeting_key: 1202,
meeting_name: 'Saudi Arabian Grand Prix',
country_code: 'SAU',
country_name: 'Saudi Arabia',
race_position: 1,
grid_position: 4,
quali_position: 4,
points: 25,
dnf: false,
dns: false,
dsq: false,
},
],
}
function renderPage(props: { driverNumber: number; year?: number }) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const rootRoute = createRootRoute({
component: () => (
<QueryClientProvider client={queryClient}>
<DriverProfilePage driverNumber={props.driverNumber} year={props.year} />
</QueryClientProvider>
),
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => null,
})
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
return render(<RouterProvider router={router} />)
}
describe('DriverProfilePage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchSeasons.mockResolvedValue([2025])
mockFetchSummary.mockResolvedValue(summary)
})
it('renders header, form, quali-vs-race, and rounds sections', async () => {
renderPage({ driverNumber: 1, year: 2025 })
await waitFor(() => {
expect(screen.getByTestId('driver-profile')).toBeInTheDocument()
})
// Header: identity + season stats.
expect(screen.getByTestId('dp-header')).toBeInTheDocument()
expect(screen.getByText('Max Verstappen')).toBeInTheDocument()
expect(screen.getByText('#1')).toBeInTheDocument()
expect(screen.getByText('Red Bull', { exact: false })).toBeInTheDocument()
// 'P1' / '50' also appear in the rounds table and chart axis.
expect(screen.getAllByText('P1').length).toBeGreaterThan(0)
expect(screen.getAllByText('50').length).toBeGreaterThan(0)
// Season form strip.
expect(screen.getByTestId('dp-form')).toBeInTheDocument()
expect(screen.getByText('Cumulative points')).toBeInTheDocument()
// Quali-vs-race chart shows the delta from the Saudi round (P4 → P1 = +3).
expect(screen.getByTestId('dp-quali-race')).toBeInTheDocument()
expect(screen.getAllByText('+3').length).toBeGreaterThan(0)
// Track-by-track table rows with flags.
const rounds = screen.getByTestId('dp-rounds')
expect(rounds).toBeInTheDocument()
expect(screen.getByText('Bahrain Grand Prix')).toBeInTheDocument()
expect(screen.getByText('Saudi Arabian Grand Prix')).toBeInTheDocument()
expect(rounds.textContent).toContain('🇧🇭')
expect(rounds.textContent).toContain('🇸🇦')
// With an explicit year, the seasons list is not needed.
expect(mockFetchSeasons).not.toHaveBeenCalled()
expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025)
})
it('defaults to the latest season when no year is given', async () => {
renderPage({ driverNumber: 1 })
await waitFor(() => {
expect(screen.getByTestId('driver-profile')).toBeInTheDocument()
})
expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025)
})
it('shows the API error message on failure', async () => {
mockFetchSummary.mockRejectedValue(new Error('API 404: Not Found'))
renderPage({ driverNumber: 99, year: 2025 })
await waitFor(() => {
expect(screen.getByText('API 404: Not Found')).toBeInTheDocument()
})
})
it('shows empty states when no rounds are completed', async () => {
mockFetchSummary.mockResolvedValue({
...summary,
form: [],
cumulative: [],
round_labels: [],
rounds: [],
})
renderPage({ driverNumber: 1, year: 2025 })
await waitFor(() => {
expect(screen.getByTestId('driver-profile')).toBeInTheDocument()
})
expect(screen.getAllByText('No completed rounds yet.').length).toBe(2)
expect(screen.getByText('No grid-vs-finish data yet.')).toBeInTheDocument()
})
})

View File

@@ -209,6 +209,32 @@ describe('TimingTower', () => {
expect(screen.getByText('DRS range')).toBeInTheDocument()
})
it('derives a gap to P1 from best laps when the practice feed omits GapToLeader', () => {
const practiceRows = [
makeRow('1', 1, 'VER', { BestLapTime: '1:45.944', GapToLeader: '' }),
makeRow('4', 2, 'NOR', { BestLapTime: '1:46.134', GapToLeader: '' }),
makeRow('16', 3, 'LEC', { BestLapTime: '', GapToLeader: '' }),
]
render(
<TimingTower
rows={practiceRows}
session={{
MeetingName: 'Belgian Grand Prix',
CircuitName: 'Spa',
SessionType: 'Practice',
SessionName: 'Practice 2',
Path: '',
}}
/>,
)
// Leader shows a clear leader marker, not a fabricated gap.
expect(screen.getByText('VER').closest('tr')).toHaveTextContent('—')
expect(screen.getByText('+0.190')).toBeInTheDocument()
// A driver without a valid best lap gets no invented gap.
const lecRow = screen.getByText('LEC').closest('tr')!
expect(lecRow).not.toHaveTextContent('+')
})
it('renders the SQ1 cutoff after P17 and marks rows below as at risk', () => {
const sprintRows = Array.from({ length: 22 }, (_, index) =>
makeRow(String(index + 1), index + 1, `D${index + 1}`),

View File

@@ -153,6 +153,29 @@ describe('LiveTimingPage archive mode', () => {
expect(screen.queryByRole('button', { name: /view last session/i })).not.toBeInTheDocument()
})
it('renders the live session for a red-flag paused snapshot instead of the inactive empty state', async () => {
renderPage({
is_live: true,
data: {
...archivedSnapshot,
SessionStatus: 'Inactive',
TrackStatus: '2',
Clock: '00:03:27',
Session: {
MeetingName: 'Belgian Grand Prix',
CircuitName: 'Spa-Francorchamps',
SessionType: 'Practice',
SessionName: 'Practice 2',
Path: '',
},
},
})
expect(await screen.findByText('Timing Tower')).toBeInTheDocument()
expect(screen.queryByTestId('live-empty')).not.toBeInTheDocument()
expect(screen.queryByTestId('live-archive-strip')).not.toBeInTheDocument()
})
it('temporarily omits the track map while live GPS is unavailable', async () => {
renderPage({
is_live: true,

View File

@@ -155,6 +155,7 @@ const raceHub: RaceHub = {
is_pit_out_lap: false,
},
],
chapters: [],
}
const weekend: Weekend = {

View File

@@ -109,6 +109,7 @@ const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
poles: 4,
form: [25],
cumulative: [200],
round_positions: [1],
teammate_wins: 9,
teammate_losses: 1,
...over,

View File

@@ -0,0 +1,291 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
import type { Chapter, RaceHub, ReplayFramesResponse, TrackOutline } from '../types'
vi.mock('../api', () => ({
fetchReplayFrames: vi.fn(),
fetchTrackOutline: vi.fn(),
}))
import { fetchReplayFrames, fetchTrackOutline } from '../api'
const mockFetchReplayFrames = vi.mocked(fetchReplayFrames)
const mockFetchTrackOutline = vi.mocked(fetchTrackOutline)
const outline: TrackOutline = {
circuit_key: 1,
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
points: [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: 1 },
],
}
const replay: ReplayFramesResponse = {
session_key: 99,
interval_ms: 5000,
start_time: '2025-05-25T13:00:00Z',
frames: [
{ t: 0, cars: { '1': { x: 10, y: 20 } } },
{ t: 5000, cars: { '1': { x: 50, y: 50 } } },
],
}
const chapters: Chapter[] = [
{
kind: 'start',
title: 'Start',
headline: 'Lights out',
start_lap: 1,
end_lap: 1,
start_time: '2025-05-25T13:00:00Z',
end_time: '2025-05-25T13:01:00Z',
driver_numbers: [],
},
{
kind: 'safety_car',
title: 'Safety Car',
headline: 'Incident brings out the Safety Car',
start_lap: 2,
end_lap: 2,
start_time: '2025-05-25T13:05:00Z',
end_time: '2025-05-25T13:05:00Z',
driver_numbers: [],
},
]
const raceHub: RaceHub = {
source: 'local',
session_key: 99,
datasets: {
positions: { status: 'available', source: 'local', count: 2 },
},
meeting: {
meeting_key: 1,
meeting_name: 'Monaco Grand Prix',
meeting_official_name: 'FORMULA 1 GRAND PRIX DE MONACO 2025',
location: 'Monaco',
country_name: 'Monaco',
country_code: 'MON',
country_flag: '',
circuit_key: 1,
circuit_short_name: 'Monaco',
date_start: '2025-05-23T00:00:00Z',
date_end: '2025-05-25T00:00:00Z',
year: 2025,
},
session: {
session_key: 99,
session_name: 'Race',
session_type: 'Race',
circuit_key: 1,
meeting_key: 1,
date_start: '2025-05-25T13:00:00Z',
date_end: '2025-05-25T15:00:00Z',
gmt_offset: '02:00:00',
},
drivers: [],
results: [
{
driver_number: 1,
position: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull Racing',
team_colour: '3671C6',
dnf: false,
dns: false,
dsq: false,
duration: null,
gap_to_leader: null,
number_of_laps: 78,
points: 25,
session_key: 99,
meeting_key: 1,
},
],
starting_grid: [
{
driver_number: 1,
position: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull Racing',
team_colour: '3671C6',
session_key: 99,
meeting_key: 1,
lap_duration: null,
},
],
stints: [],
pit_stops: [],
positions: [
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:00:00Z', position: 1 },
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:05:00Z', position: 1 },
],
race_control: [],
weather: [],
laps: [],
chapters,
}
function renderCanvas(overrides: Partial<RaceHub> = {}) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={queryClient}>
<RaceStoryCanvas data={{ ...raceHub, ...overrides }} />
</QueryClientProvider>,
)
}
describe('RaceStoryCanvas replay map', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchReplayFrames.mockResolvedValue(replay)
mockFetchTrackOutline.mockResolvedValue(outline)
})
it('probes replay frames on mount and opens the map when data is available', async () => {
renderCanvas()
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalledWith(99, 5000))
expect(mockFetchTrackOutline).toHaveBeenCalledWith(1, 2025)
const mapToggle = await screen.findByTestId('replay-map-toggle')
fireEvent.click(mapToggle)
expect(await screen.findByTestId('replay-track-map')).toBeInTheDocument()
expect(screen.getByTestId('replay-map-slot')).toBeInTheDocument()
})
it('hides the map toggle when replay frames are unavailable', async () => {
mockFetchReplayFrames.mockResolvedValue({ ...replay, frames: [] })
renderCanvas()
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalled())
await waitFor(() => expect(screen.queryByTestId('replay-map-toggle')).not.toBeInTheDocument())
})
it('uses full-width chart layout when the map is closed', async () => {
renderCanvas()
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalled())
const chart = screen.getByTestId('position-chart')
expect(chart).toHaveClass('rs-chart-container--full')
expect(screen.queryByTestId('replay-map-slot')).not.toBeInTheDocument()
expect(document.querySelector('.rs-replay-shell--split')).not.toBeInTheDocument()
})
it('uses lap timing rather than pre-race position samples for the chart scale', () => {
const laps = Array.from({ length: 20 }, (_, index) => ({
session_key: 99,
driver_number: 1,
meeting_key: 1,
lap_number: index + 1,
date_start: `2025-05-25T13:${String(index).padStart(2, '0')}:00Z`,
lap_duration: 60,
is_pit_out_lap: false,
}))
renderCanvas({
results: [{ ...raceHub.results[0], number_of_laps: 20 }],
laps,
positions: [
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T12:00:00Z', position: 1 },
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:20:00Z', position: 1 },
],
})
// L10 is the end of the tenth 60s lap, exactly halfway through the race.
const lapTenLabel = screen.getByText('L10')
expect(lapTenLabel.previousElementSibling).toHaveAttribute('x1', '316')
// The pre-race position is retained as the starting grid at the left edge.
expect(document.querySelector('.rs-driver-line')).toHaveAttribute('points', '40,8 592,8 592,8')
})
it('maps cursor positions from rendered pixels to the SVG viewBox', () => {
renderCanvas()
const svg = document.querySelector('.rs-position-chart-svg')!
vi.spyOn(svg, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
width: 1280,
height: 360,
top: 0,
right: 1280,
bottom: 360,
left: 0,
toJSON: () => ({}),
})
const pointerMove = new Event('pointermove', { bubbles: true })
Object.defineProperty(pointerMove, 'clientX', { value: 640 })
fireEvent(screen.getByTestId('position-chart-interaction'), pointerMove)
// 640px is the middle of a 1280px rendered chart, which is x=320 in its 640-unit viewBox.
expect(document.querySelector('.rs-playhead')).toHaveAttribute('x1', '320')
})
it('syncs active chapter highlight when a chapter card is clicked', async () => {
renderCanvas()
fireEvent.click(screen.getByTestId('chapter-card-1'))
await waitFor(() => expect(screen.getByTestId('chapter-card-1')).toHaveClass('active'))
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
})
it('highlights out-of-window chapters after click (clamped scrub + selection)', async () => {
// Position samples start at 13:05; start chapter ends at 13:01 — outside the window.
renderCanvas({
positions: [
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:05:00Z', position: 1 },
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:10:00Z', position: 1 },
],
chapters: [
{
kind: 'start',
title: 'Start',
headline: 'Lights out before samples',
start_lap: 1,
end_lap: 1,
start_time: '2025-05-25T13:00:00Z',
end_time: '2025-05-25T13:01:00Z',
driver_numbers: [],
},
{
kind: 'finish',
title: 'Finish',
headline: 'Flag after samples',
start_lap: 78,
end_lap: 78,
start_time: '2025-05-25T13:20:00Z',
end_time: '2025-05-25T13:21:00Z',
driver_numbers: [],
},
],
})
fireEvent.click(screen.getByTestId('chapter-card-0'))
await waitFor(() => expect(screen.getByTestId('chapter-card-0')).toHaveClass('active'))
expect(screen.getByTestId('chapter-card-1')).not.toHaveClass('active')
fireEvent.click(screen.getByTestId('chapter-card-1'))
await waitFor(() => expect(screen.getByTestId('chapter-card-1')).toHaveClass('active'))
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
})
it('renders the empty-state card when positions are unavailable', () => {
renderCanvas({
datasets: { positions: { status: 'missing', source: 'local', count: 0 } },
positions: [],
chapters: [],
})
expect(screen.getByTestId('race-story-no-positions')).toBeInTheDocument()
expect(screen.getByText('Lap-by-lap positions not available')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ReplayTrackMap } from '../components/ReplayTrackMap'
import type { EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types'
const outline: TrackOutline = {
circuit_key: 1,
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
points: [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: 1 },
],
}
const replay: ReplayFramesResponse = {
session_key: 99,
interval_ms: 5000,
start_time: '2025-05-25T13:00:00Z',
frames: [
{ t: 0, cars: { '1': { x: 0, y: 0 } } },
{ t: 5000, cars: { '1': { x: 50, y: 50 } } },
],
}
const results: EnrichedResult[] = [
{
driver_number: 1,
position: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull Racing',
team_colour: '3671C6',
dnf: false,
dns: false,
dsq: false,
duration: null,
gap_to_leader: null,
number_of_laps: 78,
points: 25,
session_key: 99,
meeting_key: 1,
},
]
describe('ReplayTrackMap', () => {
it('renders an empty-state card when replay frames are missing', () => {
render(<ReplayTrackMap outline={outline} replay={{ ...replay, frames: [] }} tMs={0} drivers={[]} results={results} />)
expect(screen.getByTestId('replay-map-no-frames')).toBeInTheDocument()
expect(screen.getByText('Historical GPS unavailable')).toBeInTheDocument()
})
it('renders car labels from result metadata', () => {
render(<ReplayTrackMap outline={outline} replay={replay} tMs={2500} drivers={[]} results={results} />)
expect(screen.getByLabelText(/VER replay position/i)).toBeInTheDocument()
})
})

View 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('21')
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('20')
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')
})
})

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import { SessionBanner } from '../components/live/SessionBanner'
import type { LiveStreamData } from '../types'
function makeSnapshot(sessionType: string, sessionName: string): LiveStreamData {
return {
Drivers: {},
DriverInfo: {},
Tyres: {},
RCMessages: [],
Weather: { AirTemp: 0, TrackTemp: 0, Humidity: 0, WindSpeed: 0, WindDir: 0, Rainfall: false },
Session: {
MeetingName: 'Belgian Grand Prix',
CircuitName: 'Spa-Francorchamps',
SessionType: sessionType,
SessionName: sessionName,
Path: '',
},
TeamRadio: [],
TrackStatus: '1',
CurrentLap: 0,
TotalLaps: 0,
Clock: '00:45:00',
ClockRefTime: '',
ClockExtrapolating: false,
Stints: {},
}
}
describe('SessionBanner', () => {
it('never renders a race lap counter for a practice session', () => {
render(
<SessionBanner
isLive
snapshot={makeSnapshot('Practice', 'Practice 2')}
rows={[]}
connection="connected"
now={0}
/>,
)
expect(screen.queryByTestId('live-lap-counter')).not.toBeInTheDocument()
// Session identity and clock stay intact.
expect(screen.getByText('Belgian Grand Prix')).toBeInTheDocument()
expect(screen.getByTestId('live-clock')).toHaveTextContent('00:45:00')
})
it('shows the lap counter for a race session', () => {
const snapshot = { ...makeSnapshot('Race', 'Race'), CurrentLap: 12, TotalLaps: 44 }
render(
<SessionBanner
isLive
snapshot={snapshot}
rows={[]}
connection="connected"
now={0}
/>,
)
expect(screen.getByTestId('live-lap-counter')).toHaveTextContent('L12/44')
})
})

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import { TeammateH2H } from '../components/TeammateH2H'
describe('TeammateH2H', () => {
it('renders score and TLAs', () => {
render(
<TeammateH2H
teamName="Red Bull"
teamColour="3671c6"
driverATla="VER"
driverBTla="PER"
winsA={9}
winsB={1}
/>,
)
expect(screen.getByText('VER')).toBeInTheDocument()
expect(screen.getByText('PER')).toBeInTheDocument()
expect(screen.getByTestId('h2h-score')).toHaveTextContent('91')
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()
})
})

View File

@@ -81,14 +81,20 @@ describe('TyreDegPanel', () => {
expect(panel).toHaveTextContent('→ ~P2')
})
it('hides the rejoin estimate outside race sessions and collapses on toggle', () => {
it('collapses by default outside race sessions and hides the rejoin estimate when expanded', () => {
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Qualifying" pinned={[]} />)
const panel = screen.getByTestId('tyredeg-panel')
expect(panel).not.toHaveTextContent('~P')
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
// Practice/qualifying starts collapsed so the Timing Tower stays above the fold.
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
expect(panel).not.toHaveTextContent('~P')
})
it('starts expanded during a race', () => {
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
})
it('limits rows to the top ten plus pinned drivers', () => {

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest'
import {
activeChapterIndex,
chapterBandFill,
chapterKindLabel,
chapterLapRange,
chapterStartScrub,
chapterTourDurations,
deCollideYPositions,
decimatedPositionLabels,
} from '../lib/chapters'
import type { Chapter } from '../types'
const sampleChapters: Chapter[] = [
{
kind: 'start',
title: 'Start',
headline: 'Lights out — the field charges into Turn 1',
start_lap: 1,
end_lap: 1,
start_time: '2025-05-25T13:00:00Z',
end_time: '2025-05-25T13:01:00Z',
driver_numbers: [],
},
{
kind: 'safety_car',
title: 'Safety Car (L12-L15)',
headline: 'Sainz incident brings out the Safety Car — leaders dive for the pits',
start_lap: 12,
end_lap: 15,
start_time: '2025-05-25T13:12:00Z',
end_time: '2025-05-25T13:15:00Z',
driver_numbers: [55],
},
]
describe('chapters lib', () => {
it('labels chapter kinds', () => {
expect(chapterKindLabel('safety_car')).toBe('SC')
expect(chapterKindLabel('finish')).toBe('FIN')
})
it('formats lap ranges', () => {
expect(chapterLapRange(sampleChapters[0])).toBe('L1')
expect(chapterLapRange(sampleChapters[1])).toBe('L12L15')
})
it('maps chapter start time to scrub position', () => {
const tMin = new Date('2025-05-25T13:00:00Z').getTime()
const tMax = new Date('2025-05-25T13:20:00Z').getTime()
const scrub = chapterStartScrub(sampleChapters[1], tMin, tMax - tMin)
expect(scrub).toBeCloseTo(0.6, 2)
})
it('finds active chapter from scrub time', () => {
const tMin = new Date('2025-05-25T13:00:00Z').getTime()
const tMax = new Date('2025-05-25T13:20:00Z').getTime()
const tRange = tMax - tMin
const scrub = (new Date('2025-05-25T13:13:00Z').getTime() - tMin) / tRange
expect(activeChapterIndex(sampleChapters, scrub, tMin, tRange)).toBe(1)
})
it('activates chapters whose timestamps clamp outside the position window', () => {
// Position samples only cover 13:0513:10; chapters sit before/after that window.
const tMin = new Date('2025-05-25T13:05:00Z').getTime()
const tMax = new Date('2025-05-25T13:10:00Z').getTime()
const tRange = tMax - tMin
const outOfWindow: Chapter[] = [
{
kind: 'start',
title: 'Start',
headline: 'Lights out',
start_lap: 1,
end_lap: 1,
start_time: '2025-05-25T13:00:00Z',
end_time: '2025-05-25T13:01:00Z',
driver_numbers: [],
},
{
kind: 'finish',
title: 'Finish',
headline: 'Chequered flag',
start_lap: 78,
end_lap: 78,
start_time: '2025-05-25T13:20:00Z',
end_time: '2025-05-25T13:21:00Z',
driver_numbers: [],
},
]
expect(chapterStartScrub(outOfWindow[0], tMin, tRange)).toBe(0)
expect(chapterStartScrub(outOfWindow[1], tMin, tRange)).toBe(1)
expect(activeChapterIndex(outOfWindow, 0, tMin, tRange)).toBe(0)
expect(activeChapterIndex(outOfWindow, 1, tMin, tRange)).toBe(1)
expect(activeChapterIndex(outOfWindow, 0.5, tMin, tRange)).toBeNull()
})
it('splits 90s evenly across chapters', () => {
expect(chapterTourDurations(sampleChapters)).toEqual([45_000, 45_000])
})
it('decimates position axis labels', () => {
expect(decimatedPositionLabels(22)).toEqual([1, 5, 10, 15, 20, 22])
})
it('returns chapter band fills by kind', () => {
expect(chapterBandFill('safety_car')).toContain('rgba')
expect(chapterBandFill('virtual_safety_car')).toContain('rgba')
})
it('de-collides overlapping label y positions', () => {
const adjusted = deCollideYPositions(
[
{ key: 'a', y: 10 },
{ key: 'b', y: 12 },
{ key: 'c', y: 30 },
],
12,
)
expect(adjusted.get('a')).toBe(10)
expect(adjusted.get('b')).toBe(22)
expect(adjusted.get('c')).toBe(34)
})
})

View File

@@ -0,0 +1,266 @@
import { describe, it, expect } from 'vitest'
import {
activeDigestWindow,
filterByTag,
groupByWindow,
gpWindows,
itemsForWindow,
sinceLastLabel,
sortWindowBucketsNewestFirst,
tagItems,
topTags,
windowForDate,
} from '../lib/digest'
import type { ChampHubDriver, ChampHubTeam, Meeting, NewsItem } from '../types'
const meeting = (overrides: Partial<Meeting> = {}): Meeting => ({
meeting_key: 1,
meeting_name: 'Bahrain',
meeting_official_name: 'Bahrain GP',
location: 'Sakhir',
country_name: 'Bahrain',
country_code: 'BRN',
country_flag: '',
circuit_short_name: 'Sakhir',
date_start: '2025-03-14T00:00:00+00:00',
date_end: '2025-03-16T23:59:59+00:00',
year: 2025,
...overrides,
})
const news = (overrides: Partial<NewsItem> = {}): NewsItem => ({
source: 'autosport-f1',
title: 'Headline',
url: `https://example.com/${Math.random()}`,
fetched_at: '2025-04-01T12:00:00Z',
...overrides,
})
const driver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
points: 100,
position: 1,
wins: 3,
podiums: 5,
poles: 2,
form: [25, 18, 25],
cumulative: [25, 43, 68, 100],
teammate_wins: 4,
teammate_losses: 1,
round_positions: [],
...over,
})
const team = (over: Partial<ChampHubTeam>): ChampHubTeam => ({
team_name: 'Red Bull',
team_colour: '3671c6',
points: 150,
position: 1,
wins: 4,
...over,
})
describe('gpWindows', () => {
const bahrain = meeting({ meeting_key: 1, meeting_name: 'Bahrain' })
const monaco = meeting({
meeting_key: 2,
meeting_name: 'Monaco',
date_start: '2025-05-23T00:00:00+00:00',
date_end: '2025-05-25T23:59:59+00:00',
})
const canada = meeting({
meeting_key: 3,
meeting_name: 'Canada',
date_start: '2025-06-13T00:00:00+00:00',
date_end: '2025-06-15T23:59:59+00:00',
})
it('derives inter-race windows mid-season', () => {
const now = new Date('2025-04-10T12:00:00Z')
const windows = gpWindows([bahrain, monaco, canada], now)
expect(windows).toHaveLength(3)
expect(windows[0].start).toBeNull()
expect(windows[0].end?.toISOString()).toBe(new Date('2025-05-23T00:00:00+00:00').toISOString())
expect(windows[1].start?.toISOString()).toBe(new Date('2025-03-16T23:59:59+00:00').toISOString())
expect(windows[1].end?.toISOString()).toBe(new Date('2025-06-13T00:00:00+00:00').toISOString())
expect(windows[2].start?.toISOString()).toBe(new Date('2025-05-25T23:59:59+00:00').toISOString())
expect(windows[2].end).toBeNull()
})
it('handles before the first race of the season', () => {
const now = new Date('2025-01-01T00:00:00Z')
const windows = gpWindows([bahrain, monaco], now)
expect(windows[0].start).toBeNull()
expect(activeDigestWindow(windows, [bahrain, monaco], now)?.meeting_name).toBe('Bahrain')
expect(sinceLastLabel([bahrain, monaco], now)).toBe('Before Bahrain')
})
it('handles after the final race of the season', () => {
const now = new Date('2025-12-01T00:00:00Z')
const windows = gpWindows([bahrain, monaco], now)
expect(activeDigestWindow(windows, [bahrain, monaco], now)?.meeting_name).toBe('Monaco')
expect(sinceLastLabel([bahrain, monaco], now)).toBe('Monaco')
expect(windows[1].end).toBeNull()
})
})
describe('groupByWindow', () => {
const bahrain = meeting({ meeting_key: 1 })
const monaco = meeting({
meeting_key: 2,
date_start: '2025-05-23T00:00:00+00:00',
date_end: '2025-05-25T23:59:59+00:00',
})
const windows = gpWindows([bahrain, monaco], new Date('2025-04-10T12:00:00Z'))
it('buckets dated items into the matching GP window', () => {
const items = [
news({ url: 'https://a', published_at: '2025-03-10T10:00:00Z', title: 'Pre-Bahrain' }),
news({ url: 'https://b', published_at: '2025-04-05T10:00:00Z', title: 'Between races' }),
news({ url: 'https://c', published_at: '2025-05-24T10:00:00Z', title: 'Monaco weekend' }),
]
const grouped = groupByWindow(items, windows)
const bahrainItems = grouped.windows.find((entry) => entry.window.meeting_key === 1)?.items ?? []
const monacoItems = grouped.windows.find((entry) => entry.window.meeting_key === 2)?.items ?? []
expect(bahrainItems).toHaveLength(1)
expect(bahrainItems[0].title).toBe('Pre-Bahrain')
expect(monacoItems).toHaveLength(2)
expect(monacoItems.map((item) => item.title)).toEqual(['Between races', 'Monaco weekend'])
})
it('puts undated items in the recent bucket', () => {
const items = [
news({ url: 'https://u', title: 'Undated story' }),
news({ url: 'https://d', published_at: '2025-04-05T10:00:00Z' }),
]
const grouped = groupByWindow(items, windows)
expect(grouped.recent).toHaveLength(1)
expect(grouped.recent[0].url).toBe('https://u')
})
it('sorts window buckets newest first for display', () => {
const items = [
news({ published_at: '2025-03-10T10:00:00Z' }),
news({ published_at: '2025-05-24T10:00:00Z' }),
]
const grouped = groupByWindow(items, windows)
const sorted = sortWindowBucketsNewestFirst(grouped.windows)
expect(sorted[0].window.meeting_key).toBe(2)
expect(sorted[1].window.meeting_key).toBe(1)
})
})
describe('tagItems', () => {
const drivers = [
driver({ name_acronym: 'VER', full_name: 'Max Verstappen', team_name: 'Red Bull' }),
driver({
driver_number: 4,
name_acronym: 'NOR',
full_name: 'Lando Norris',
team_name: 'McLaren',
team_colour: 'ff8000',
}),
]
const teams = [
team({ team_name: 'Red Bull' }),
team({ team_name: 'McLaren', team_colour: 'ff8000' }),
]
it('tags drivers and teams case-insensitively', () => {
const [item] = tagItems(
[news({ title: 'ver leads mclaren in practice', summary: 'Norris close behind' })],
drivers,
teams,
)
const labels = item.tags.map((tag) => tag.label).sort()
expect(labels).toEqual(['McLaren', 'NOR', 'VER'])
})
it('returns no tags when nothing matches', () => {
const [item] = tagItems(
[news({ title: 'Generic paddock update' })],
drivers,
teams,
)
expect(item.tags).toEqual([])
})
it('prefers the longest overlapping match', () => {
const [item] = tagItems(
[news({ title: 'Max Verstappen extends championship lead' })],
drivers,
teams,
)
expect(item.tags).toHaveLength(1)
expect(item.tags[0].label).toBe('VER')
})
it('matches a driver last name inside prose', () => {
const [item] = tagItems(
[news({ title: 'Verstappen was untouchable in qualifying' })],
drivers,
teams,
)
expect(item.tags.some((tag) => tag.label === 'VER')).toBe(true)
})
})
describe('digest helpers', () => {
it('collects top tags by frequency', () => {
const tagged = tagItems(
[
news({ title: 'VER wins', url: 'https://a' }),
news({ title: 'VER dominates', url: 'https://b' }),
news({ title: 'Norris podium', url: 'https://c' }),
],
[driver({}), driver({ driver_number: 4, name_acronym: 'NOR', full_name: 'Lando Norris', team_name: 'McLaren' })],
[team({}), team({ team_name: 'McLaren', team_colour: 'ff8000' })],
)
expect(topTags(tagged, 2).map((tag) => tag.label)).toEqual(['VER', 'NOR'])
})
it('filters items by tag key', () => {
const tagged = tagItems(
[
news({ title: 'VER wins', url: 'https://a' }),
news({ title: 'Neutral headline', url: 'https://b' }),
],
[driver({})],
[team({})],
)
const filtered = filterByTag(tagged, 'driver:VER')
expect(filtered).toHaveLength(1)
expect(filtered[0].url).toBe('https://a')
})
it('selects items for the active digest window', () => {
const bahrain = meeting({ meeting_key: 1 })
const monaco = meeting({
meeting_key: 2,
date_start: '2025-05-23T00:00:00+00:00',
date_end: '2025-05-25T23:59:59+00:00',
})
const now = new Date('2025-04-10T12:00:00Z')
const windows = gpWindows([bahrain, monaco], now)
const active = activeDigestWindow(windows, [bahrain, monaco], now)
const tagged = tagItems(
[
news({ published_at: '2025-04-05T10:00:00Z', url: 'https://in' }),
news({ published_at: '2025-03-10T10:00:00Z', url: 'https://out' }),
],
[],
[],
)
const inWindow = itemsForWindow(tagged, active)
expect(inWindow).toHaveLength(1)
expect(inWindow[0].url).toBe('https://in')
expect(windowForDate(windows, new Date('2025-04-05T10:00:00Z'))?.meeting_key).toBe(2)
})
})

View File

@@ -0,0 +1,104 @@
import { describe, it, expect } from 'vitest'
import {
formatDelta,
formatPosition,
gridFinishDeltas,
shortGpLabel,
} from '../lib/driverProfile'
import type { DriverSummaryRound } from '../types'
function round(over: Partial<DriverSummaryRound>): DriverSummaryRound {
return {
meeting_key: 1201,
meeting_name: 'Bahrain Grand Prix',
country_code: 'BHR',
country_name: 'Bahrain',
race_position: 1,
grid_position: 1,
quali_position: 1,
points: 25,
dnf: false,
dns: false,
dsq: false,
...over,
}
}
describe('gridFinishDeltas', () => {
it('computes positive delta for places gained', () => {
const [d] = gridFinishDeltas([round({ grid_position: 5, race_position: 2, points: 18 })])
expect(d.grid).toBe(5)
expect(d.finish).toBe(2)
expect(d.delta).toBe(3)
expect(d.points).toBe(18)
expect(d.status).toBe('classified')
expect(d.round).toBe(1)
})
it('computes negative delta for places lost', () => {
const [d] = gridFinishDeltas([round({ grid_position: 1, race_position: 4 })])
expect(d.delta).toBe(-3)
})
it('returns null delta when the grid slot is unknown (position 0)', () => {
const [d] = gridFinishDeltas([round({ grid_position: 0, race_position: 6 })])
expect(d.grid).toBeNull()
expect(d.finish).toBe(6)
expect(d.delta).toBeNull()
})
it('flags DNS rounds and never computes a delta for them', () => {
const [d] = gridFinishDeltas([round({ dns: true, grid_position: 8, race_position: 0 })])
expect(d.status).toBe('dns')
expect(d.delta).toBeNull()
})
it('marks rounds the driver did not enter as absent', () => {
const [d] = gridFinishDeltas([round({ grid_position: 0, race_position: 0, points: 0 })])
expect(d.status).toBe('absent')
expect(d.grid).toBeNull()
expect(d.finish).toBeNull()
expect(d.delta).toBeNull()
})
it('still computes the delta for a classified DNF with known positions', () => {
const [d] = gridFinishDeltas([round({ dnf: true, grid_position: 3, race_position: 15 })])
expect(d.status).toBe('dnf')
expect(d.delta).toBe(-12)
})
it('numbers rounds sequentially and shortens labels', () => {
const ds = gridFinishDeltas([
round({}),
round({ meeting_name: 'Saudi Arabian Grand Prix', meeting_key: 1202 }),
])
expect(ds.map((d) => d.round)).toEqual([1, 2])
expect(ds[1].label).toBe('Saudi Arabian')
})
})
describe('shortGpLabel', () => {
it('strips "Grand Prix" wherever it appears', () => {
expect(shortGpLabel('Bahrain Grand Prix')).toBe('Bahrain')
expect(shortGpLabel('Grand Prix of Monaco')).toBe('of Monaco')
})
it('falls back to the original name when stripping empties it', () => {
expect(shortGpLabel('Grand Prix')).toBe('Grand Prix')
})
})
describe('formatPosition / formatDelta', () => {
it('formats positions', () => {
expect(formatPosition(4)).toBe('P4')
expect(formatPosition(null)).toBe('—')
expect(formatPosition(0)).toBe('—')
})
it('formats deltas', () => {
expect(formatDelta(3)).toBe('+3')
expect(formatDelta(-2)).toBe('2')
expect(formatDelta(0)).toBe('=')
expect(formatDelta(null)).toBe('—')
})
})

View File

@@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest'
import { teammatePairs } from '../lib/h2h'
import type { ChampHubDriver } from '../types'
function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
return {
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
points: 200,
position: 1,
wins: 5,
podiums: 8,
poles: 4,
form: [],
cumulative: [],
teammate_wins: 9,
teammate_losses: 1,
round_positions: [],
...over,
}
}
describe('teammatePairs', () => {
it('returns empty for no drivers', () => {
expect(teammatePairs([])).toEqual([])
})
it('skips teams with a single driver', () => {
const drivers = [
driver({ driver_number: 1, team_name: 'Red Bull' }),
driver({ driver_number: 44, name_acronym: 'HAM', team_name: 'Mercedes', points: 50 }),
]
expect(teammatePairs(drivers)).toHaveLength(0)
})
it('pairs two-driver teams', () => {
const drivers = [
driver({ driver_number: 1, name_acronym: 'VER', points: 200, teammate_wins: 9, teammate_losses: 1 }),
driver({
driver_number: 11,
name_acronym: 'PER',
full_name: 'Sergio Perez',
points: 60,
teammate_wins: 1,
teammate_losses: 9,
}),
]
const pairs = teammatePairs(drivers)
expect(pairs).toHaveLength(1)
expect(pairs[0].teamName).toBe('Red Bull')
expect(pairs[0].driverA.name_acronym).toBe('VER')
expect(pairs[0].driverB.name_acronym).toBe('PER')
expect(pairs[0].extraCount).toBe(0)
expect(pairs[0].closeness).toBe(8)
})
it('uses top two by points for teams with 3+ drivers', () => {
const drivers = [
driver({ driver_number: 1, name_acronym: 'VER', team_name: 'Red Bull', points: 200 }),
driver({
driver_number: 11,
name_acronym: 'PER',
team_name: 'Red Bull',
points: 60,
teammate_wins: 1,
teammate_losses: 9,
}),
driver({
driver_number: 99,
name_acronym: 'LAW',
full_name: 'Liam Lawson',
team_name: 'Red Bull',
points: 10,
teammate_wins: 0,
teammate_losses: 0,
}),
]
const pairs = teammatePairs(drivers)
expect(pairs).toHaveLength(1)
expect(pairs[0].driverA.name_acronym).toBe('VER')
expect(pairs[0].driverB.name_acronym).toBe('PER')
expect(pairs[0].extraCount).toBe(1)
})
it('sorts by closest battle first', () => {
const drivers = [
driver({
driver_number: 1,
name_acronym: 'VER',
team_name: 'Red Bull',
points: 200,
teammate_wins: 10,
teammate_losses: 0,
}),
driver({
driver_number: 11,
name_acronym: 'PER',
team_name: 'Red Bull',
points: 60,
teammate_wins: 0,
teammate_losses: 10,
}),
driver({
driver_number: 4,
name_acronym: 'NOR',
team_name: 'McLaren',
team_colour: 'ff8000',
points: 160,
teammate_wins: 6,
teammate_losses: 5,
}),
driver({
driver_number: 81,
name_acronym: 'PIA',
full_name: 'Oscar Piastri',
team_name: 'McLaren',
team_colour: 'ff8000',
points: 140,
teammate_wins: 5,
teammate_losses: 6,
}),
]
const pairs = teammatePairs(drivers)
expect(pairs).toHaveLength(2)
expect(pairs[0].teamName).toBe('McLaren')
expect(pairs[0].closeness).toBe(1)
expect(pairs[1].teamName).toBe('Red Bull')
expect(pairs[1].closeness).toBe(10)
})
})

View File

@@ -1,12 +1,15 @@
import { describe, expect, it } from 'vitest'
import {
bestLapGaps,
compoundClass,
compoundLetter,
extrapolateClock,
isLiveSessionActive,
latestRaceControl,
liveSessionDisplay,
loadPinnedDrivers,
mergeVisibleSectors,
parseLapTimeSeconds,
positionDeltaClass,
parseLiveStateEvent,
rcFlagClass,
@@ -279,6 +282,43 @@ describe('live qualifying display', () => {
})
})
describe('practice/qualifying computed gaps', () => {
it('parses lap-time strings into seconds and rejects invalid input', () => {
expect(parseLapTimeSeconds('1:45.944')).toBeCloseTo(105.944, 3)
expect(parseLapTimeSeconds('45.944')).toBeCloseTo(45.944, 3)
expect(parseLapTimeSeconds('')).toBeNull()
expect(parseLapTimeSeconds(undefined)).toBeNull()
expect(parseLapTimeSeconds('-')).toBeNull()
expect(parseLapTimeSeconds('nope')).toBeNull()
})
it('derives a gap to P1 from valid best laps only, flagging the leader', () => {
const gaps = bestLapGaps([
timingRow('1', 1, { BestLapTime: '1:45.944' }),
timingRow('4', 2, { BestLapTime: '1:46.134' }),
timingRow('16', 3, { BestLapTime: '' }),
])
expect(gaps['1']).toEqual({ isLeader: true, gap: '' })
expect(gaps['4']).toEqual({ isLeader: false, gap: '+0.190' })
// No valid best lap → no fabricated gap.
expect(gaps['16']).toBeUndefined()
})
it('returns no gaps when nobody has set a lap', () => {
expect(bestLapGaps([timingRow('1', 1, { BestLapTime: '' })])).toEqual({})
})
})
describe('live session activity', () => {
it('is active only when the feed reports a live session with data', () => {
expect(isLiveSessionActive({ is_live: true, data: snapshot })).toBe(true)
expect(isLiveSessionActive({ is_live: true, data: null })).toBe(false)
expect(isLiveSessionActive({ is_live: false, data: snapshot })).toBe(false)
expect(isLiveSessionActive(null)).toBe(false)
expect(isLiveSessionActive(undefined)).toBe(false)
})
})
describe('visible sector display', () => {
it('holds S1 and S2 through temporary blanks while a flying lap is active', () => {
const first = [timingRow('4', 1, {

View File

@@ -54,6 +54,7 @@ const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
poles: 4,
form: [25],
cumulative: [200],
round_positions: [1],
teammate_wins: 9,
teammate_losses: 1,
...over,

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import type { ReplayFrame, TrackBounds } from '../types'
import { interpolateReplayCars, lookupReplayFramePair, replayCarToSvg } from '../lib/replay'
const frames: ReplayFrame[] = [
{ t: 0, cars: { '1': { x: 0, y: 0 } } },
{ t: 5000, cars: { '1': { x: 10, y: 20 }, '4': { x: 40, y: 80 } } },
{ t: 10000, cars: { '1': { x: 20, y: 40 } } },
]
describe('replay helpers', () => {
it('looks up boundary frame pairs', () => {
expect(lookupReplayFramePair(frames, -1)).toEqual({ previous: frames[0], next: frames[0] })
expect(lookupReplayFramePair(frames, 10000)).toEqual({ previous: frames[2], next: frames[2] })
expect(lookupReplayFramePair([], 1000)).toEqual({ previous: null, next: null })
})
it('interpolates cars between frames and keeps one-sided samples visible', () => {
const cars = interpolateReplayCars(frames, 2500)
expect(cars['1']).toEqual({ x: 5, y: 10 })
expect(cars['4']).toEqual({ x: 40, y: 80 })
})
it('clamps interpolation outside the replay range', () => {
expect(interpolateReplayCars(frames, -500)['1']).toEqual({ x: 0, y: 0 })
expect(interpolateReplayCars(frames, 12000)['1']).toEqual({ x: 20, y: 40 })
})
it('maps replay GPS through the shared track-map coordinate transform', () => {
const bounds: TrackBounds = { minX: 0, maxX: 100, minY: 0, maxY: 200 }
expect(replayCarToSvg({ x: 50, y: 50 }, bounds)).toEqual({ x: 50, y: 75 })
})
})

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { isReplayMapAvailable } from '../lib/replayMap'
import type { ReplayFramesResponse, TrackOutline } from '../types'
const outline: TrackOutline = {
circuit_key: 1,
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
points: [{ x: 0, y: 0 }],
}
const replay: ReplayFramesResponse = {
session_key: 1,
interval_ms: 5000,
start_time: '2025-05-25T13:00:00Z',
frames: [
{ t: 0, cars: {} },
{ t: 5000, cars: {} },
],
}
describe('isReplayMapAvailable', () => {
it('returns true when frames and outline are present', () => {
expect(isReplayMapAvailable(replay, outline, false)).toBe(true)
})
it('returns false when frames are sparse', () => {
expect(isReplayMapAvailable({ ...replay, frames: [{ t: 0, cars: {} }] }, outline, false)).toBe(false)
})
it('returns false on query error', () => {
expect(isReplayMapAvailable(replay, outline, true)).toBe(false)
})
})

View 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)
})
})

View File

@@ -29,6 +29,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
poles: 0,
form: [],
cumulative: [],
round_positions: [],
teammate_wins: 0,
teammate_losses: 0,
...over,

View File

@@ -91,6 +91,18 @@ export interface RaceHub {
race_control: RaceControlMessage[]
weather: WeatherSample[]
laps: Lap[]
chapters: Chapter[]
}
export interface Chapter {
kind: 'start' | 'safety_car' | 'virtual_safety_car' | 'red_flag' | 'pit_phase' | 'decisive_swing' | 'finish' | string
title: string
headline: string
start_lap: number
end_lap: number
start_time?: string
end_time?: string
driver_numbers: number[]
}
export interface Stint {
@@ -349,6 +361,23 @@ export interface TrackOutline {
bounds: TrackBounds
}
export interface ReplayCarPosition {
x: number
y: number
}
export interface ReplayFrame {
t: number
cars: Record<string, ReplayCarPosition>
}
export interface ReplayFramesResponse {
session_key: number
interval_ms: number
start_time: string
frames: ReplayFrame[]
}
export interface ChampHubDriver {
driver_number: number
name_acronym: string
@@ -362,6 +391,7 @@ export interface ChampHubDriver {
poles: number
form: number[]
cumulative: number[]
round_positions: number[]
teammate_wins: number
teammate_losses: number
}
@@ -385,6 +415,39 @@ export interface ChampionshipHub {
teams: ChampHubTeam[]
}
export interface DriverSummaryRound {
meeting_key: number
meeting_name: string
country_code: string
country_name: string
race_position: number
grid_position: number
quali_position?: number
points: number
dnf: boolean
dns: boolean
dsq: boolean
}
export interface DriverSummary {
season: number
driver_number: number
name_acronym: string
full_name: string
team_name: string
team_colour: string
headshot_url: string
points: number
position: number
wins: number
podiums: number
poles: number
form: number[]
cumulative: number[]
round_labels: string[]
rounds: DriverSummaryRound[]
}
export interface NewsItem {
source: string
title: string

View File

@@ -0,0 +1,629 @@
package chapters
import (
"fmt"
"math"
"sort"
"strings"
"time"
"github.com/AmanTahiliani/box-box/internal/models"
)
const (
KindStart = "start"
KindSafetyCar = "safety_car"
KindVirtualSafetyCar = "virtual_safety_car"
KindRedFlag = "red_flag"
KindPitPhase = "pit_phase"
KindDecisiveSwing = "decisive_swing"
KindFinish = "finish"
pitPhaseWindowLaps = 3
pitPhaseShare = 0.30
minPitPhaseStops = 2
maxDecisiveSwings = 3
decisiveAfterLap = 5
structuralPriority = 110
flagPriority = 100
pitPhasePriority = 50
decisivePriority = 40
)
type RaceControl = models.RaceControl
type PositionSample = models.Position
type Lap = models.Lap
// Chapter is a deterministic replay segment derived from timing and race-control data.
type Chapter struct {
Kind string `json:"kind"`
Title string `json:"title"`
Headline string `json:"headline"`
StartLap int `json:"start_lap"`
EndLap int `json:"end_lap"`
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
DriverNumbers []int `json:"driver_numbers"`
}
// Detect builds replay chapters from already-loaded race-hub datasets.
func Detect(rc []RaceControl, positions []PositionSample, laps []Lap, totalLaps int) []Chapter {
totalLaps = normalizeTotalLaps(totalLaps, laps, rc)
if totalLaps <= 0 && len(rc) == 0 && len(positions) == 0 && len(laps) == 0 {
return []Chapter{}
}
if totalLaps <= 0 {
totalLaps = 1
}
lapIndex := buildLapIndex(laps)
chapters := []Chapter{
{
Kind: KindStart,
Title: "Start",
StartLap: 1,
EndLap: minInt(1, totalLaps),
StartTime: lapIndex.lapStart(1),
EndTime: lapIndex.lapEnd(1),
},
}
chapters = append(chapters, detectFlagPeriods(rc, lapIndex, totalLaps)...)
chapters = append(chapters, detectPitPhases(laps, lapIndex)...)
chapters = append(chapters, detectDecisiveSwings(positions, lapIndex, totalLaps)...)
chapters = append(chapters, detectFinish(rc, lapIndex, totalLaps))
return resolveConflicts(chapters)
}
func normalizeTotalLaps(totalLaps int, laps []Lap, rc []RaceControl) int {
for _, l := range laps {
if l.LapNumber > totalLaps {
totalLaps = l.LapNumber
}
}
for _, msg := range rc {
if msg.LapNumber != nil && *msg.LapNumber > totalLaps {
totalLaps = *msg.LapNumber
}
}
return totalLaps
}
type lapIndex struct {
byLap map[int]string
events []lapEvent
}
type lapEvent struct {
lap int
at time.Time
}
func buildLapIndex(laps []Lap) lapIndex {
idx := lapIndex{byLap: map[int]string{}}
for _, l := range laps {
if l.LapNumber <= 0 || l.DateStart == "" {
continue
}
if _, ok := idx.byLap[l.LapNumber]; !ok {
idx.byLap[l.LapNumber] = l.DateStart
}
at, ok := parseTime(l.DateStart)
if ok {
idx.events = append(idx.events, lapEvent{lap: l.LapNumber, at: at})
}
}
sort.Slice(idx.events, func(i, j int) bool {
if idx.events[i].at.Equal(idx.events[j].at) {
return idx.events[i].lap < idx.events[j].lap
}
return idx.events[i].at.Before(idx.events[j].at)
})
return idx
}
func (idx lapIndex) lapStart(lap int) string {
return idx.byLap[lap]
}
func (idx lapIndex) lapEnd(lap int) string {
if v := idx.byLap[lap+1]; v != "" {
return v
}
return idx.byLap[lap]
}
func (idx lapIndex) lapForTime(raw string) int {
at, ok := parseTime(raw)
if !ok || len(idx.events) == 0 {
return 0
}
lap := 0
for _, event := range idx.events {
if event.at.After(at) {
break
}
lap = event.lap
}
if lap == 0 {
return idx.events[0].lap
}
return lap
}
type flagState struct {
startLap int
startTime string
}
func detectFlagPeriods(rc []RaceControl, idx lapIndex, totalLaps int) []Chapter {
var chapters []Chapter
active := map[string]flagState{}
for _, msg := range rc {
kind, ok := flagKind(msg)
if !ok && greenFlagClear(msg) {
for activeKind, st := range active {
lap := messageLap(msg, idx)
if lap <= 0 {
lap = st.startLap
}
endLap := clampLap(lap, st.startLap, totalLaps)
chapters = append(chapters, Chapter{
Kind: activeKind,
Title: flagTitle(activeKind, st.startLap, endLap),
StartLap: st.startLap,
EndLap: endLap,
StartTime: st.startTime,
EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)),
})
delete(active, activeKind)
}
continue
}
if !ok {
continue
}
lap := messageLap(msg, idx)
if lap <= 0 {
lap = 1
}
if flagCleared(msg) {
st, ok := active[kind]
if !ok {
continue
}
endLap := clampLap(lap, st.startLap, totalLaps)
chapters = append(chapters, Chapter{
Kind: kind,
Title: flagTitle(kind, st.startLap, endLap),
StartLap: st.startLap,
EndLap: endLap,
StartTime: st.startTime,
EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)),
})
delete(active, kind)
continue
}
if flagStarted(msg) {
active[kind] = flagState{
startLap: clampLap(lap, 1, totalLaps),
startTime: firstNonEmpty(msg.Date, idx.lapStart(lap)),
}
}
}
for kind, st := range active {
endLap := totalLaps
chapters = append(chapters, Chapter{
Kind: kind,
Title: flagTitle(kind, st.startLap, endLap),
StartLap: st.startLap,
EndLap: endLap,
StartTime: st.startTime,
EndTime: idx.lapEnd(endLap),
})
}
return chapters
}
func flagKind(msg RaceControl) (string, bool) {
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
if strings.Contains(text, "VSC") || strings.Contains(text, "VIRTUAL SAFETY CAR") {
return KindVirtualSafetyCar, true
}
if strings.Contains(text, "RED FLAG") || string(msg.Flag) == string(models.FlagRed) {
return KindRedFlag, true
}
if strings.Contains(text, "SAFETY CAR") || msg.Category == models.CategorySafetyCar {
return KindSafetyCar, true
}
return "", false
}
func flagStarted(msg RaceControl) bool {
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
if strings.Contains(text, "CLEAR") || strings.Contains(text, "ENDING") || strings.Contains(text, "IN THIS LAP") || strings.Contains(text, "GREEN") {
return false
}
return strings.Contains(text, "DEPLOY") ||
strings.Contains(text, "RED FLAG") ||
strings.Contains(text, "VIRTUAL SAFETY CAR") ||
strings.Contains(text, "VSC") ||
strings.Contains(text, "SAFETY CAR") ||
string(msg.Flag) == string(models.FlagRed)
}
func flagCleared(msg RaceControl) bool {
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
return strings.Contains(text, "CLEAR") ||
strings.Contains(text, "ENDING") ||
strings.Contains(text, "IN THIS LAP") ||
strings.Contains(text, "GREEN")
}
func greenFlagClear(msg RaceControl) bool {
text := upperText(string(msg.Flag), msg.Message)
return strings.Contains(text, "GREEN")
}
func flagTitle(kind string, startLap, endLap int) string {
name := "Flag period"
switch kind {
case KindSafetyCar:
name = "Safety Car"
case KindVirtualSafetyCar:
name = "Virtual Safety Car"
case KindRedFlag:
name = "Red Flag"
}
return fmt.Sprintf("%s (L%d-L%d)", name, startLap, endLap)
}
func detectPitPhases(laps []Lap, idx lapIndex) []Chapter {
type pitOut struct {
lap int
driver int
}
var stops []pitOut
for _, l := range laps {
if l.IsPitOutLap && l.LapNumber > 0 {
stops = append(stops, pitOut{lap: l.LapNumber, driver: l.DriverNumber})
}
}
if len(stops) < minPitPhaseStops {
return nil
}
sort.Slice(stops, func(i, j int) bool {
if stops[i].lap == stops[j].lap {
return stops[i].driver < stops[j].driver
}
return stops[i].lap < stops[j].lap
})
needed := int(math.Ceil(float64(len(stops)) * pitPhaseShare))
if needed < minPitPhaseStops {
needed = minPitPhaseStops
}
var windows []Chapter
for i := 0; i < len(stops); i++ {
start := stops[i].lap
end := start + pitPhaseWindowLaps - 1
drivers := map[int]bool{}
count := 0
for _, stop := range stops {
if stop.lap < start || stop.lap > end {
continue
}
count++
drivers[stop.driver] = true
}
if count < needed {
continue
}
ch := Chapter{
Kind: KindPitPhase,
Title: fmt.Sprintf("Pit phase (L%d-L%d)", start, end),
StartLap: start,
EndLap: end,
StartTime: idx.lapStart(start),
EndTime: idx.lapEnd(end),
DriverNumbers: sortedDriverNumbers(drivers),
}
if len(windows) > 0 && ch.StartLap <= windows[len(windows)-1].EndLap+1 {
last := &windows[len(windows)-1]
if ch.EndLap > last.EndLap {
last.EndLap = ch.EndLap
last.EndTime = idx.lapEnd(last.EndLap)
}
drivers := sliceToSet(last.DriverNumbers)
for _, driver := range ch.DriverNumbers {
drivers[driver] = true
}
last.DriverNumbers = sortedDriverNumbers(drivers)
last.Title = fmt.Sprintf("Pit phase (L%d-L%d)", last.StartLap, last.EndLap)
continue
}
windows = append(windows, ch)
}
return windows
}
type swingCandidate struct {
chapter Chapter
significance int
}
func detectDecisiveSwings(positions []PositionSample, idx lapIndex, totalLaps int) []Chapter {
if len(positions) == 0 || len(idx.events) == 0 || totalLaps <= decisiveAfterLap {
return nil
}
snapshots := buildPositionSnapshots(positions, idx, totalLaps)
if len(snapshots) == 0 {
return nil
}
final := snapshots[totalLaps]
if len(final) == 0 {
for lap := totalLaps - 1; lap >= 1; lap-- {
if len(snapshots[lap]) > 0 {
final = snapshots[lap]
break
}
}
}
var candidates []swingCandidate
seenDriver := map[int]bool{}
for lap := decisiveAfterLap + 1; lap <= totalLaps; lap++ {
prev := snapshots[lap-1]
curr := snapshots[lap]
if len(prev) == 0 || len(curr) == 0 {
continue
}
for driver, pos := range curr {
prevPos, ok := prev[driver]
if !ok || prevPos <= pos || pos > 5 || pos <= 0 || seenDriver[driver] {
continue
}
finalPos, ok := final[driver]
if !ok || finalPos > pos {
continue
}
overtaken := driverAtPosition(curr, prevPos, driver)
drivers := []int{driver}
if overtaken != 0 {
drivers = append(drivers, overtaken)
}
candidates = append(candidates, swingCandidate{
chapter: Chapter{
Kind: KindDecisiveSwing,
Title: fmt.Sprintf("Decisive swing: #%d to P%d (L%d)", driver, pos, lap),
StartLap: lap,
EndLap: lap,
StartTime: idx.lapStart(lap),
EndTime: idx.lapEnd(lap),
DriverNumbers: drivers,
},
significance: (prevPos-pos)*10 + (6 - pos),
})
seenDriver[driver] = true
}
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].significance == candidates[j].significance {
return candidates[i].chapter.StartLap < candidates[j].chapter.StartLap
}
return candidates[i].significance > candidates[j].significance
})
if len(candidates) > maxDecisiveSwings {
candidates = candidates[:maxDecisiveSwings]
}
out := make([]Chapter, 0, len(candidates))
for _, c := range candidates {
out = append(out, c.chapter)
}
return out
}
func buildPositionSnapshots(positions []PositionSample, idx lapIndex, totalLaps int) map[int]map[int]int {
byLap := map[int][]PositionSample{}
for _, p := range positions {
if p.Position <= 0 {
continue
}
lap := idx.lapForTime(p.Date)
if lap <= 0 || lap > totalLaps {
continue
}
byLap[lap] = append(byLap[lap], p)
}
last := map[int]int{}
snapshots := map[int]map[int]int{}
for lap := 1; lap <= totalLaps; lap++ {
for _, p := range byLap[lap] {
last[p.DriverNumber] = p.Position
}
if len(last) == 0 {
continue
}
cp := make(map[int]int, len(last))
for driver, pos := range last {
cp[driver] = pos
}
snapshots[lap] = cp
}
return snapshots
}
func driverAtPosition(snapshot map[int]int, pos int, exclude int) int {
for driver, driverPos := range snapshot {
if driver != exclude && driverPos == pos {
return driver
}
}
return 0
}
func detectFinish(rc []RaceControl, idx lapIndex, totalLaps int) Chapter {
finishLap := totalLaps
finishTime := idx.lapEnd(totalLaps)
for _, msg := range rc {
text := upperText(string(msg.Flag), msg.Message)
if strings.Contains(text, "CHEQUER") || string(msg.Flag) == string(models.FlagChequered) {
if lap := messageLap(msg, idx); lap > 0 {
finishLap = lap
}
finishTime = firstNonEmpty(msg.Date, finishTime)
}
}
startLap := finishLap - 1
if startLap < 1 {
startLap = 1
}
return Chapter{
Kind: KindFinish,
Title: fmt.Sprintf("Finish (L%d-L%d)", startLap, finishLap),
StartLap: startLap,
EndLap: finishLap,
StartTime: idx.lapStart(startLap),
EndTime: finishTime,
}
}
func resolveConflicts(chapters []Chapter) []Chapter {
normalized := make([]Chapter, 0, len(chapters))
for _, ch := range chapters {
if ch.StartLap <= 0 {
ch.StartLap = 1
}
if ch.EndLap <= 0 {
ch.EndLap = ch.StartLap
}
if ch.EndLap < ch.StartLap {
ch.EndLap = ch.StartLap
}
if ch.DriverNumbers == nil {
ch.DriverNumbers = []int{}
}
normalized = append(normalized, ch)
}
sort.SliceStable(normalized, func(i, j int) bool {
if normalized[i].StartLap == normalized[j].StartLap {
return priority(normalized[i].Kind) > priority(normalized[j].Kind)
}
return normalized[i].StartLap < normalized[j].StartLap
})
out := make([]Chapter, 0, len(normalized))
for _, ch := range normalized {
if len(out) == 0 {
out = append(out, ch)
continue
}
last := &out[len(out)-1]
if ch.StartLap > last.EndLap {
out = append(out, ch)
continue
}
if isFlag(last.Kind) && priority(ch.Kind) < priority(last.Kind) {
continue
}
if priority(ch.Kind) > priority(last.Kind) {
if last.StartLap < ch.StartLap {
last.EndLap = ch.StartLap - 1
out = append(out, ch)
} else {
*last = ch
}
continue
}
if ch.EndLap > last.EndLap {
ch.StartLap = last.EndLap + 1
if ch.StartLap <= ch.EndLap {
out = append(out, ch)
}
}
}
return out
}
func isFlag(kind string) bool {
return kind == KindSafetyCar || kind == KindVirtualSafetyCar || kind == KindRedFlag
}
func priority(kind string) int {
switch kind {
case KindStart, KindFinish:
return structuralPriority
case KindSafetyCar, KindVirtualSafetyCar, KindRedFlag:
return flagPriority
case KindPitPhase:
return pitPhasePriority
case KindDecisiveSwing:
return decisivePriority
default:
return 0
}
}
func messageLap(msg RaceControl, idx lapIndex) int {
if msg.LapNumber != nil && *msg.LapNumber > 0 {
return *msg.LapNumber
}
return idx.lapForTime(msg.Date)
}
func clampLap(lap, minLap, maxLap int) int {
if lap < minLap {
return minLap
}
if maxLap > 0 && lap > maxLap {
return maxLap
}
return lap
}
func parseTime(raw string) (time.Time, bool) {
if raw == "" {
return time.Time{}, false
}
at, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}, false
}
return at, true
}
func upperText(parts ...string) string {
return strings.ToUpper(strings.Join(parts, " "))
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func sortedDriverNumbers(drivers map[int]bool) []int {
out := make([]int, 0, len(drivers))
for driver := range drivers {
out = append(out, driver)
}
sort.Ints(out)
return out
}
func sliceToSet(values []int) map[int]bool {
out := make(map[int]bool, len(values))
for _, value := range values {
out[value] = true
}
return out
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -0,0 +1,218 @@
package chapters
import (
"fmt"
"testing"
"github.com/AmanTahiliani/box-box/internal/models"
)
func TestDetectStructuralChapters(t *testing.T) {
chapters := Detect(nil, nil, testLaps(10, nil), 10)
if len(chapters) < 2 {
t.Fatalf("chapters len = %d, want at least start and finish", len(chapters))
}
if got := chapters[0]; got.Kind != KindStart || got.StartLap != 1 || got.EndLap != 1 {
t.Fatalf("start chapter = %+v, want L1-L1", got)
}
got := chapters[len(chapters)-1]
if got.Kind != KindFinish || got.StartLap != 9 || got.EndLap != 10 {
t.Fatalf("finish chapter = %+v, want L9-L10", got)
}
}
func TestDetectReturnsEmptyWithoutData(t *testing.T) {
if chapters := Detect(nil, nil, nil, 0); len(chapters) != 0 {
t.Fatalf("chapters = %+v, want empty", chapters)
}
}
func TestDetectFlagPeriods(t *testing.T) {
tests := []struct {
name string
start RaceControl
end RaceControl
wantKind string
wantTitle string
}{
{
name: "safety car",
start: rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
end: rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
wantKind: KindSafetyCar,
wantTitle: "Safety Car (L12-L15)",
},
{
name: "virtual safety car",
start: rc(22, models.CategoryOther, "", "VSC DEPLOYED"),
end: rc(24, models.CategoryOther, "", "VSC ENDING"),
wantKind: KindVirtualSafetyCar,
wantTitle: "Virtual Safety Car (L22-L24)",
},
{
name: "red flag",
start: rc(31, models.CategoryFlag, models.FlagRed, "RED FLAG"),
end: rc(33, models.CategoryFlag, models.FlagGreen, "GREEN FLAG"),
wantKind: KindRedFlag,
wantTitle: "Red Flag (L31-L33)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
chapters := Detect([]RaceControl{tt.start, tt.end}, nil, testLaps(40, nil), 40)
got := findKind(chapters, tt.wantKind)
if got == nil {
t.Fatalf("chapters = %+v, want %s", chapters, tt.wantKind)
}
if got.StartLap != rcLap(tt.start) || got.EndLap != rcLap(tt.end) || got.Title != tt.wantTitle {
t.Fatalf("flag chapter = %+v, want %s", *got, tt.wantTitle)
}
})
}
}
func TestDetectPitPhaseFromPitOutLapCluster(t *testing.T) {
pitOuts := map[int][]int{
5: {1},
10: {2},
20: {3},
21: {4},
22: {5},
30: {6},
35: {7},
40: {8},
45: {9},
50: {10},
}
chapters := Detect(nil, nil, testLaps(55, pitOuts), 55)
got := findKind(chapters, KindPitPhase)
if got == nil {
t.Fatalf("chapters = %+v, want pit phase", chapters)
}
if got.StartLap != 20 || got.EndLap != 22 {
t.Fatalf("pit phase = %+v, want L20-L22", *got)
}
if len(got.DriverNumbers) != 3 || got.DriverNumbers[0] != 3 || got.DriverNumbers[2] != 5 {
t.Fatalf("pit phase drivers = %v, want [3 4 5]", got.DriverNumbers)
}
}
func TestDetectDecisiveSwingPersistsToFinish(t *testing.T) {
positions := []PositionSample{
pos(1, 1, 1),
pos(1, 16, 4),
pos(1, 55, 3),
pos(6, 16, 3),
pos(6, 55, 4),
pos(8, 44, 5),
pos(8, 63, 6),
pos(10, 44, 6),
}
chapters := Detect(nil, positions, testLaps(12, nil), 12)
got := findKind(chapters, KindDecisiveSwing)
if got == nil {
t.Fatalf("chapters = %+v, want decisive swing", chapters)
}
if got.StartLap != 6 || got.EndLap != 6 {
t.Fatalf("swing lap = %+v, want L6", *got)
}
if len(got.DriverNumbers) != 2 || got.DriverNumbers[0] != 16 || got.DriverNumbers[1] != 55 {
t.Fatalf("swing drivers = %v, want [16 55]", got.DriverNumbers)
}
}
func TestDetectFlagPeriodsWinOverConflictingChapters(t *testing.T) {
pitOuts := map[int][]int{
12: {1, 2},
13: {3, 4},
14: {5, 6},
}
rcs := []RaceControl{
rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
}
chapters := Detect(rcs, nil, testLaps(20, pitOuts), 20)
if got := findKind(chapters, KindSafetyCar); got == nil || got.StartLap != 12 || got.EndLap != 15 {
t.Fatalf("chapters = %+v, want safety car L12-L15", chapters)
}
if got := findKind(chapters, KindPitPhase); got != nil {
t.Fatalf("pit phase = %+v, want omitted under safety car", *got)
}
for i := 1; i < len(chapters); i++ {
if chapters[i].StartLap <= chapters[i-1].EndLap {
t.Fatalf("chapters overlap at %d: %+v then %+v", i, chapters[i-1], chapters[i])
}
}
}
func rcLap(r RaceControl) int {
if r.LapNumber == nil {
return 0
}
return *r.LapNumber
}
func findKind(chapters []Chapter, kind string) *Chapter {
for i := range chapters {
if chapters[i].Kind == kind {
return &chapters[i]
}
}
return nil
}
func testLaps(total int, pitOuts map[int][]int) []Lap {
var laps []Lap
for lap := 1; lap <= total; lap++ {
drivers := []int{1}
if pitDrivers := pitOuts[lap]; len(pitDrivers) > 0 {
drivers = pitDrivers
}
for _, driver := range drivers {
laps = append(laps, Lap{
DriverNumber: driver,
LapNumber: lap,
DateStart: lapTime(lap),
IsPitOutLap: containsDriver(pitOuts[lap], driver),
})
}
}
return laps
}
func rc(lap int, category models.RaceControlCategory, flag models.Flag, message string) RaceControl {
return RaceControl{
Category: category,
Flag: flag,
Message: message,
LapNumber: &lap,
Date: lapTime(lap),
}
}
func pos(lap int, driver int, position int) PositionSample {
return PositionSample{
DriverNumber: driver,
Position: position,
Date: lapTime(lap),
}
}
func lapTime(lap int) string {
minute := lap - 1
return fmt.Sprintf("2025-05-25T13:%02d:00Z", minute)
}
func containsDriver(drivers []int, driver int) bool {
for _, candidate := range drivers {
if candidate == driver {
return true
}
}
return false
}

View File

@@ -0,0 +1,233 @@
package chapters
import (
"fmt"
"strings"
"github.com/AmanTahiliani/box-box/internal/models"
)
// DriverIdentityInput carries session driver fields used for headline templates.
type DriverIdentityInput struct {
DriverNumber int
NameAcronym string
FullName string
TeamName string
}
type driverIdentity struct {
display string
team string
acronym string
}
// BuildDriverMap indexes driver identity from session drivers and enriched results.
func BuildDriverMap(drivers []models.Driver, results []DriverIdentityInput) map[int]driverIdentity {
out := map[int]driverIdentity{}
for _, d := range drivers {
if d.DriverNumber <= 0 {
continue
}
out[d.DriverNumber] = driverIdentity{
display: driverDisplayName(d.LastName, d.FullName, d.NameAcronym, d.BroadcastName),
team: d.TeamName,
acronym: firstNonEmpty(d.NameAcronym, d.BroadcastName),
}
}
for _, r := range results {
if r.DriverNumber <= 0 {
continue
}
if _, ok := out[r.DriverNumber]; ok {
continue
}
out[r.DriverNumber] = driverIdentity{
display: driverDisplayName("", r.FullName, r.NameAcronym, ""),
team: r.TeamName,
acronym: r.NameAcronym,
}
}
return out
}
// ApplyHeadlines fills Headline on each chapter using deterministic templates.
func ApplyHeadlines(chapters []Chapter, drivers map[int]driverIdentity, rc []RaceControl, winnerNumber int) []Chapter {
out := make([]Chapter, len(chapters))
copy(out, chapters)
for i := range out {
out[i].Headline = headlineFor(out[i], drivers, rc, winnerNumber)
}
return out
}
func headlineFor(ch Chapter, drivers map[int]driverIdentity, rc []RaceControl, winnerNumber int) string {
switch ch.Kind {
case KindStart:
return pickVariant(ch.StartLap,
"Lights out — the field charges into Turn 1",
"Race start — Lap 1 shuffle at the front",
)
case KindSafetyCar:
if name := driverName(ch, drivers, incidentDriver(rc, ch.StartLap)); name != "" {
return pickVariant(ch.StartLap,
fmt.Sprintf("%s incident brings out the Safety Car — leaders dive for the pits", name),
fmt.Sprintf("Safety Car deployed after %s stops on track", name),
)
}
case KindVirtualSafetyCar:
if name := driverName(ch, drivers, incidentDriver(rc, ch.StartLap)); name != "" {
return pickVariant(ch.StartLap,
fmt.Sprintf("%s off track triggers the Virtual Safety Car", name),
fmt.Sprintf("Virtual Safety Car — %s loses control on Lap %d", name, ch.StartLap),
)
}
return pickVariant(ch.StartLap,
fmt.Sprintf("Virtual Safety Car deployed on Lap %d", ch.StartLap),
fmt.Sprintf("VSC period — field backs off on L%dL%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%dL%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%dL%d", names[0], names[1], ch.StartLap, ch.EndLap),
fmt.Sprintf("Undercut window opens — %s leads the pit rush on L%dL%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%dL%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
}

View File

@@ -0,0 +1,199 @@
package chapters
import (
"testing"
"github.com/AmanTahiliani/box-box/internal/models"
)
func TestHeadlineStart(t *testing.T) {
ch := Chapter{Kind: KindStart, Title: "Start", StartLap: 1, EndLap: 1}
got := headlineFor(ch, nil, nil, 0)
want := "Race start — Lap 1 shuffle at the front"
if got != want {
t.Fatalf("headline = %q, want %q", got, want)
}
}
func TestHeadlineStartVariantByLap(t *testing.T) {
ch := Chapter{Kind: KindStart, Title: "Start", StartLap: 2, EndLap: 2}
got := headlineFor(ch, nil, nil, 0)
want := "Lights out — the field charges into Turn 1"
if got != want {
t.Fatalf("headline = %q, want %q", got, want)
}
}
func TestHeadlineSafetyCarWithDriver(t *testing.T) {
drivers := BuildDriverMap(nil, []DriverIdentityInput{
{DriverNumber: 55, NameAcronym: "SAI", FullName: "Carlos Sainz", TeamName: "Ferrari"},
})
rc := []RaceControl{
{
DriverNumber: intPtr(55),
LapNumber: intPtr(12),
Message: "CAR 55 STOPPED ON TRACK",
},
rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
}
ch := Chapter{
Kind: KindSafetyCar,
Title: "Safety Car (L12-L15)",
StartLap: 12,
EndLap: 15,
}
got := headlineFor(ch, drivers, rc, 0)
want := "Sainz incident brings out the Safety Car — leaders dive for the pits"
if got != want {
t.Fatalf("headline = %q, want %q", got, want)
}
}
func TestHeadlineSafetyCarFallbackWithoutDriver(t *testing.T) {
ch := Chapter{
Kind: KindSafetyCar,
Title: "Safety Car (L12-L15)",
StartLap: 12,
EndLap: 15,
}
got := headlineFor(ch, nil, nil, 0)
if got != ch.Title {
t.Fatalf("headline = %q, want fallback %q", got, ch.Title)
}
}
func TestHeadlineVirtualSafetyCar(t *testing.T) {
drivers := BuildDriverMap(nil, []DriverIdentityInput{
{DriverNumber: 16, NameAcronym: "LEC", FullName: "Charles Leclerc", TeamName: "Ferrari"},
})
rc := []RaceControl{
{
DriverNumber: intPtr(16),
LapNumber: intPtr(22),
Message: "CAR 16 OFF TRACK",
},
rc(22, models.CategoryOther, "", "VSC DEPLOYED"),
rc(24, models.CategoryOther, "", "VSC ENDING"),
}
ch := Chapter{
Kind: KindVirtualSafetyCar,
Title: "Virtual Safety Car (L22-L24)",
StartLap: 22,
EndLap: 24,
}
got := headlineFor(ch, drivers, rc, 0)
want := "Leclerc off track triggers the Virtual Safety Car"
if got != want {
t.Fatalf("headline = %q, want %q", got, want)
}
}
func TestHeadlineRedFlag(t *testing.T) {
drivers := BuildDriverMap(nil, []DriverIdentityInput{
{DriverNumber: 63, NameAcronym: "RUS", FullName: "George Russell", TeamName: "Mercedes"},
})
rc := []RaceControl{
{
DriverNumber: intPtr(63),
LapNumber: intPtr(31),
Message: "INCIDENT INVOLVING CAR 63",
},
rc(31, models.CategoryFlag, models.FlagRed, "RED FLAG"),
rc(33, models.CategoryFlag, models.FlagGreen, "GREEN FLAG"),
}
ch := Chapter{
Kind: KindRedFlag,
Title: "Red Flag (L31-L33)",
StartLap: 31,
EndLap: 33,
}
got := headlineFor(ch, drivers, rc, 0)
want := "Red flag after Russell incident on Lap 31"
if got != want {
t.Fatalf("headline = %q, want %q", got, want)
}
}
func TestHeadlinePitPhase(t *testing.T) {
drivers := BuildDriverMap(nil, []DriverIdentityInput{
{DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull"},
{DriverNumber: 44, NameAcronym: "HAM", FullName: "Lewis Hamilton", TeamName: "Mercedes"},
{DriverNumber: 16, NameAcronym: "LEC", FullName: "Charles Leclerc", TeamName: "Ferrari"},
})
ch := Chapter{
Kind: KindPitPhase,
Title: "Pit phase (L20-L22)",
StartLap: 20,
EndLap: 22,
DriverNumbers: []int{1, 44, 16},
}
got := headlineFor(ch, drivers, nil, 0)
want := "Mass pit-window scramble — Verstappen and Hamilton box on L20L22"
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
}

View File

@@ -307,6 +307,28 @@ func TestSessionStatusIsActive(t *testing.T) {
}
}
func TestSessionStatusIsTerminal(t *testing.T) {
tests := []struct {
status string
want bool
}{
{"Finished", true},
{"Finalised", true},
{"Finalized", true},
{"Ends", true},
{"Aborted", true},
{"Started", false},
{"Resumed", false},
{"Inactive", false},
{"", false},
}
for _, tt := range tests {
if got := live.SessionStatusIsTerminal(tt.status); got != tt.want {
t.Errorf("SessionStatusIsTerminal(%q) = %v, want %v", tt.status, got, tt.want)
}
}
}
func TestProcessTopicRaceControlMessages(t *testing.T) {
state := live.NewState()
data := json.RawMessage(`{

View File

@@ -186,6 +186,19 @@ func SessionStatusIsActive(status string) bool {
}
}
// SessionStatusIsTerminal reports whether a raw F1 live timing SessionStatus
// value represents a session that has ended and will not resume. A temporarily
// inactive session (e.g. a red-flag pause reported as "Inactive") is neither
// active nor terminal.
func SessionStatusIsTerminal(status string) bool {
switch normalizeSessionStatus(status) {
case "finished", "finalised", "finalized", "ends", "aborted":
return true
default:
return false
}
}
func normalizeSessionStatus(status string) string {
out := make([]rune, 0, len(status))
for _, r := range status {

View File

@@ -4,6 +4,7 @@ import (
"database/sql"
"errors"
"github.com/AmanTahiliani/box-box/internal/chapters"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/store"
)
@@ -52,6 +53,7 @@ type RaceHub struct {
RaceControl []models.RaceControl `json:"race_control"`
Weather []models.Weather `json:"weather"`
Laps []models.Lap `json:"laps"`
Chapters []chapters.Chapter `json:"chapters"`
}
// GetRaceHub loads ingested Race Hub datasets for a session from the local store.
@@ -80,6 +82,7 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
RaceControl: []models.RaceControl{},
Weather: []models.Weather{},
Laps: []models.Lap{},
Chapters: []chapters.Chapter{},
}
sess, err := s.store.GetSession(sessionKey)
@@ -248,6 +251,50 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
hub.Datasets["laps"] = availableLocal(len(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)
return hub, nil
}
func driverIdentityInputs(results []EnrichedResult) []chapters.DriverIdentityInput {
out := make([]chapters.DriverIdentityInput, 0, len(results))
for _, r := range results {
out = append(out, chapters.DriverIdentityInput{
DriverNumber: r.DriverNumber,
NameAcronym: r.NameAcronym,
FullName: r.FullName,
TeamName: r.TeamName,
})
}
return out
}
func winnerDriverNumber(results []EnrichedResult) int {
for _, r := range results {
if r.Position == 1 {
return r.DriverNumber
}
}
return 0
}
func totalLaps(results []EnrichedResult, laps []models.Lap) int {
total := 0
for _, result := range results {
if result.NumberOfLaps > total {
total = result.NumberOfLaps
}
}
for _, lap := range laps {
if lap.LapNumber > total {
total = lap.LapNumber
}
}
return total
}

View File

@@ -614,8 +614,9 @@ type champHubDriver struct {
Wins int `json:"wins"`
Podiums int `json:"podiums"`
Poles int `json:"poles"`
Form []float64 `json:"form"` // last 5 races' points
Cumulative []float64 `json:"cumulative"` // running total per completed round
Form []float64 `json:"form"` // last 5 races' points
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"`
TeammateLosses int `json:"teammate_losses"`
}
@@ -797,11 +798,6 @@ func (s *Server) localChampionshipHub(year int) (champHubResponse, bool, error)
}
func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
meetings, err := s.client.GetMeetingsForYear(year)
if err != nil {
return champHubResponse{}, err
}
champ, err := s.client.GetDriverChampionshipForYear(year)
if err != nil {
return champHubResponse{}, err
@@ -816,16 +812,39 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
driverInfo = buildDriverMapFirst(ds)
}
races, incomplete, err := s.fetchSeasonRaces(year)
if err != nil {
return champHubResponse{}, err
}
resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo)
ttl := champHubTTL(year, time.Now())
if incomplete {
// Any per-meeting fetch failure (network, rate limit) yields an incomplete
// aggregate: serve it so the page still renders, but cache it only briefly
// so a partial view of the season doesn't stick around for the full TTL.
ttl = champHubIncompleteTTL
}
s.hubCache.put(year, resp, time.Now(), ttl)
return resp, nil
}
// fetchSeasonRaces returns a season's GP meetings in date order, each bundled
// with its race results and starting grid fetched from OpenF1. incomplete
// reports whether any per-meeting fetch failed, so callers can avoid caching a
// partial view of the season for long.
func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete bool, err error) {
meetings, err := s.client.GetMeetingsForYear(year)
if err != nil {
return nil, false, err
}
sort.Slice(meetings, func(i, j int) bool { return meetings[i].DateStart < meetings[j].DateStart })
// Any per-meeting fetch failure (network, rate limit) yields an incomplete
// aggregate: serve it so the page still renders, but cache it only briefly
// so a partial view of the season doesn't stick around for the full TTL.
var incomplete atomic.Bool
races := fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) {
var failed atomic.Bool
races = fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) {
sessions, serr := s.client.GetSessionsForMeeting(int(m.MeetingKey))
if serr != nil {
incomplete.Store(true)
failed.Store(true)
return meetingRace{}, false
}
raceKey := 0
@@ -841,18 +860,11 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
results, rerr := s.client.GetSessionResult(raceKey)
grid, gerr := s.client.GetStartingGrid(raceKey)
if rerr != nil || gerr != nil {
incomplete.Store(true)
failed.Store(true)
}
return meetingRace{Meeting: m, RaceSessionKey: raceKey, Results: results, Grid: grid}, true
})
resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo)
ttl := champHubTTL(year, time.Now())
if incomplete.Load() {
ttl = champHubIncompleteTTL
}
s.hubCache.put(year, resp, time.Now(), ttl)
return resp, nil
return races, failed.Load(), nil
}
// aggregateChampionshipHub is the pure aggregation core (no network) so it can be
@@ -984,6 +996,10 @@ func aggregateChampionshipHub(
if 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]
drivers = append(drivers, champHubDriver{
DriverNumber: c.DriverNumber,
@@ -998,6 +1014,7 @@ func aggregateChampionshipHub(
Poles: a.poles,
Form: form,
Cumulative: cumulative[c.DriverNumber],
RoundPositions: roundPositions,
TeammateWins: twins[c.DriverNumber],
TeammateLosses: tloss[c.DriverNumber],
})

View File

@@ -17,20 +17,22 @@ func TestAggregateChampionshipHub(t *testing.T) {
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"},
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{
{DriverNumber: 1, PointsCurrent: 50, PositionCurrent: 1, SessionKey: 99},
{DriverNumber: 3, PointsCurrent: 33, PositionCurrent: 2, SessionKey: 99},
{DriverNumber: 2, PointsCurrent: 30, PositionCurrent: 3, SessionKey: 99},
{DriverNumber: 4, PointsCurrent: 12, PositionCurrent: 4, SessionKey: 99},
}
teams := []models.ChampionshipTeam{
{TeamName: "Red Bull", PointsCurrent: 80, PositionCurrent: 1},
{TeamName: "Mercedes", PointsCurrent: 33, PositionCurrent: 2},
}
// Round 1: VER P1(25), HAM P2(18), PER P3(15). Pole: VER.
// Round 2: VER P1(25), PER P2(18), HAM P3(15). Pole: HAM.
// 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), NOR P4(12). Pole: HAM.
races := []meetingRace{
{
Meeting: models.Meeting{MeetingName: "Bahrain GP"},
@@ -39,7 +41,7 @@ func TestAggregateChampionshipHub(t *testing.T) {
},
{
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}},
},
// 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)
}
// Drivers are sorted by official position: VER, HAM, PER.
if len(resp.Drivers) != 3 {
t.Fatalf("drivers = %d, want 3", len(resp.Drivers))
// Drivers are sorted by official position: VER, HAM, PER, NOR.
if len(resp.Drivers) != 4 {
t.Fatalf("drivers = %d, want 4", len(resp.Drivers))
}
ver := resp.Drivers[0]
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 {
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.
if ver.TeammateWins != 2 || ver.TeammateLosses != 0 {
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 {
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.
var ham champHubDriver
@@ -124,6 +132,17 @@ func TestAggregateChampionshipHub(t *testing.T) {
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.
if len(resp.Teams) != 2 {
t.Fatalf("teams = %d, want 2", len(resp.Teams))

View File

@@ -0,0 +1,177 @@
package web
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/AmanTahiliani/box-box/internal/models"
)
// --- /api/v1/driver/summary ---
// Per-driver season summary: championship standing plus per-round race results,
// aggregated server-side from the same sources as the championship hub. Caching
// relies on the OpenF1 client's HTTP cache TTLs — no extra layer here.
type driverSummaryRound struct {
MeetingKey int `json:"meeting_key"`
MeetingName string `json:"meeting_name"`
CountryCode string `json:"country_code"`
CountryName string `json:"country_name"`
// RacePosition and GridPosition are 0 when the driver has no entry for the
// round (did not enter, or data missing).
RacePosition int `json:"race_position"`
GridPosition int `json:"grid_position"`
// QualiPosition is derived from the starting grid in v1 (full qualifying
// session results are deferred); omitted when unknown.
QualiPosition int `json:"quali_position,omitempty"`
Points float64 `json:"points"`
DNF bool `json:"dnf"`
DNS bool `json:"dns"`
DSQ bool `json:"dsq"`
}
type driverSummaryResponse struct {
Season int `json:"season"`
DriverNumber int `json:"driver_number"`
NameAcronym string `json:"name_acronym"`
FullName string `json:"full_name"`
TeamName string `json:"team_name"`
TeamColour string `json:"team_colour"`
HeadshotURL string `json:"headshot_url"`
Points float64 `json:"points"`
Position int `json:"position"`
Wins int `json:"wins"`
Podiums int `json:"podiums"`
Poles int `json:"poles"`
Form []float64 `json:"form"`
Cumulative []float64 `json:"cumulative"`
RoundLabels []string `json:"round_labels"`
Rounds []driverSummaryRound `json:"rounds"`
}
func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
driverNumber, err := strconv.Atoi(r.URL.Query().Get("driver_number"))
if err != nil || driverNumber <= 0 {
http.Error(w, "driver_number required", http.StatusBadRequest)
return
}
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
if year == 0 {
year = time.Now().Year()
}
champ, err := s.client.GetDriverChampionshipForYear(year)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
return
}
var entry *models.ChampionshipDriver
for i := range champ {
if champ[i].DriverNumber == driverNumber {
entry = &champ[i]
break
}
}
if entry == nil {
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
return
}
driverInfo := map[int]models.Driver{}
if ds, derr := s.client.GetDriversForSession(champ[0].SessionKey); derr == nil {
driverInfo = buildDriverMapFirst(ds)
}
if d, ok := s.championshipDriverInfo(entry.SessionKey, driverNumber, driverInfo); ok {
driverInfo[driverNumber] = d
}
races, _, err := s.fetchSeasonRaces(year)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
return
}
resp, ok := aggregateDriverSummary(year, driverNumber, races, champ, driverInfo)
if !ok {
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
return
}
writeJSON(w, resp)
}
// aggregateDriverSummary is the pure aggregation core (no network) so it can be
// unit-tested with synthetic data. It reuses the championship-hub aggregation
// for the derived season stats (wins, podiums, poles, form, cumulative) and
// adds the per-round result rows. races must be ordered ascending by date and
// contain only GP meetings. Returns ok=false when the driver is not in champ.
func aggregateDriverSummary(
year, driverNumber int,
races []meetingRace,
champ []models.ChampionshipDriver,
driverInfo map[int]models.Driver,
) (driverSummaryResponse, bool) {
hub := aggregateChampionshipHub(year, races, champ, nil, driverInfo)
var hd *champHubDriver
for i := range hub.Drivers {
if hub.Drivers[i].DriverNumber == driverNumber {
hd = &hub.Drivers[i]
break
}
}
if hd == nil {
return driverSummaryResponse{}, false
}
rounds := make([]driverSummaryRound, 0, len(races))
for _, mr := range races {
if len(mr.Results) == 0 {
continue // round not completed yet
}
round := driverSummaryRound{
MeetingKey: int(mr.Meeting.MeetingKey),
MeetingName: mr.Meeting.MeetingName,
CountryCode: mr.Meeting.CountryCode,
CountryName: mr.Meeting.CountryName,
}
for _, res := range mr.Results {
if res.DriverNumber == driverNumber {
round.RacePosition = res.Position
round.Points = res.Points
round.DNF = res.DNF
round.DNS = res.DNS
round.DSQ = res.DSQ
break
}
}
for _, g := range mr.Grid {
if g.DriverNumber == driverNumber {
round.GridPosition = g.Position
round.QualiPosition = g.Position
break
}
}
rounds = append(rounds, round)
}
return driverSummaryResponse{
Season: year,
DriverNumber: hd.DriverNumber,
NameAcronym: hd.NameAcronym,
FullName: hd.FullName,
TeamName: hd.TeamName,
TeamColour: hd.TeamColour,
HeadshotURL: driverInfo[driverNumber].HeadshotURL,
Points: hd.Points,
Position: hd.Position,
Wins: hd.Wins,
Podiums: hd.Podiums,
Poles: hd.Poles,
Form: hd.Form,
Cumulative: hd.Cumulative,
RoundLabels: hub.RoundLabels,
Rounds: rounds,
}, true
}

View File

@@ -0,0 +1,136 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/AmanTahiliani/box-box/internal/models"
)
func driverSummaryFixtures() ([]meetingRace, []models.ChampionshipDriver, map[int]models.Driver) {
driverInfo := map[int]models.Driver{
1: {DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull", TeamColour: "3671c6", HeadshotURL: "https://example.com/ver.png"},
3: {DriverNumber: 3, NameAcronym: "HAM", FullName: "Lewis Hamilton", TeamName: "Mercedes", TeamColour: "27f4d2"},
}
champ := []models.ChampionshipDriver{
{DriverNumber: 1, PointsCurrent: 50, PositionCurrent: 1, SessionKey: 99},
{DriverNumber: 3, PointsCurrent: 33, PositionCurrent: 2, SessionKey: 99},
}
// Round 1: VER P1 from pole, HAM P2 from P3.
// Round 2: VER P1 from P2, HAM P3 from pole.
// Round 3: not yet run — must not appear in rounds.
races := []meetingRace{
{
Meeting: models.Meeting{MeetingKey: 1201, MeetingName: "Bahrain GP", CountryCode: "BHR", CountryName: "Bahrain"},
Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(3, 2, 18)},
Grid: []models.StartingGrid{{DriverNumber: 1, Position: 1}, {DriverNumber: 3, Position: 3}},
},
{
Meeting: models.Meeting{MeetingKey: 1202, MeetingName: "Saudi GP", CountryCode: "SAU", CountryName: "Saudi Arabia"},
Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(3, 3, 15)},
Grid: []models.StartingGrid{{DriverNumber: 3, Position: 1}, {DriverNumber: 1, Position: 2}},
},
{Meeting: models.Meeting{MeetingKey: 1203, MeetingName: "Australia GP"}},
}
return races, champ, driverInfo
}
func TestAggregateDriverSummary(t *testing.T) {
races, champ, driverInfo := driverSummaryFixtures()
resp, ok := aggregateDriverSummary(2025, 1, races, champ, driverInfo)
if !ok {
t.Fatal("aggregateDriverSummary ok = false, want true")
}
if resp.Season != 2025 || resp.DriverNumber != 1 {
t.Errorf("season/driver = %d/%d, want 2025/1", resp.Season, resp.DriverNumber)
}
if resp.NameAcronym != "VER" || resp.FullName != "Max Verstappen" || resp.TeamName != "Red Bull" || resp.TeamColour != "3671c6" {
t.Errorf("driver identity = %s %s %s %s", resp.NameAcronym, resp.FullName, resp.TeamName, resp.TeamColour)
}
if resp.HeadshotURL != "https://example.com/ver.png" {
t.Errorf("headshot = %q", resp.HeadshotURL)
}
if resp.Points != 50 || resp.Position != 1 {
t.Errorf("points/position = %v/%d, want 50/1", resp.Points, resp.Position)
}
if resp.Wins != 2 || resp.Podiums != 2 || resp.Poles != 1 {
t.Errorf("wins/podiums/poles = %d/%d/%d, want 2/2/1", resp.Wins, resp.Podiums, resp.Poles)
}
if len(resp.Form) != 2 || resp.Form[0] != 25 || resp.Form[1] != 25 {
t.Errorf("form = %v, want [25 25]", resp.Form)
}
// Cumulative reconciles the last value to the official total.
if len(resp.Cumulative) != 2 || resp.Cumulative[0] != 25 || resp.Cumulative[1] != 50 {
t.Errorf("cumulative = %v, want [25 50]", resp.Cumulative)
}
if len(resp.RoundLabels) != 2 || resp.RoundLabels[0] != "R1" {
t.Errorf("round labels = %v, want [R1 R2]", resp.RoundLabels)
}
// Only completed rounds appear.
if len(resp.Rounds) != 2 {
t.Fatalf("rounds = %d, want 2", len(resp.Rounds))
}
r1 := resp.Rounds[0]
if r1.MeetingKey != 1201 || r1.MeetingName != "Bahrain GP" || r1.CountryCode != "BHR" || r1.CountryName != "Bahrain" {
t.Errorf("round 1 meeting = %+v", r1)
}
if r1.RacePosition != 1 || r1.GridPosition != 1 || r1.QualiPosition != 1 || r1.Points != 25 {
t.Errorf("round 1 result = %+v, want P1 from P1 with 25 pts", r1)
}
r2 := resp.Rounds[1]
if r2.RacePosition != 1 || r2.GridPosition != 2 || r2.Points != 25 {
t.Errorf("round 2 result = %+v, want P1 from P2 with 25 pts", r2)
}
}
func TestAggregateDriverSummaryDriverMissingFromRound(t *testing.T) {
races, champ, driverInfo := driverSummaryFixtures()
// Drop HAM from round 2's results and grid: the round still appears with
// zero-valued positions so round indexing stays aligned with the season.
races[1].Results = []models.SessionResult{raceResult(1, 1, 25)}
races[1].Grid = []models.StartingGrid{{DriverNumber: 1, Position: 2}}
resp, ok := aggregateDriverSummary(2025, 3, races, champ, driverInfo)
if !ok {
t.Fatal("aggregateDriverSummary ok = false, want true")
}
if len(resp.Rounds) != 2 {
t.Fatalf("rounds = %d, want 2", len(resp.Rounds))
}
r2 := resp.Rounds[1]
if r2.RacePosition != 0 || r2.GridPosition != 0 || r2.QualiPosition != 0 || r2.Points != 0 {
t.Errorf("round 2 for absent driver = %+v, want zero values", r2)
}
}
func TestAggregateDriverSummaryUnknownDriver(t *testing.T) {
races, champ, driverInfo := driverSummaryFixtures()
if _, ok := aggregateDriverSummary(2025, 44, races, champ, driverInfo); ok {
t.Error("unknown driver should return ok = false")
}
}
func TestHandleDriverSummaryBadRequest(t *testing.T) {
srv := testServer(t, nil)
// Missing, non-numeric, zero, and negative driver_number are all rejected.
for _, url := range []string{
"/api/v1/driver/summary?year=2025",
"/api/v1/driver/summary?year=2025&driver_number=abc",
"/api/v1/driver/summary?year=2025&driver_number=0",
"/api/v1/driver/summary?year=2025&driver_number=-4",
} {
req := httptest.NewRequest(http.MethodGet, url, nil)
rec := httptest.NewRecorder()
srv.handleDriverSummary(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("%s: status = %d, want 400", url, rec.Code)
}
}
}

View File

@@ -134,7 +134,7 @@ func (h *SSEHub) applySnapshot(data live.LiveStreamData, now time.Time) liveStat
h.mu.Lock()
defer h.mu.Unlock()
if live.SessionStatusIsActive(data.SessionStatus) {
if snapshotIsLive(data) {
h.isLive = true
h.activeSnapshot = &data
if data.PositionUpdated && len(data.Positions) > 0 {
@@ -208,6 +208,40 @@ func (h *SSEHub) stateLocked() liveStatePayload {
return payload
}
// snapshotIsLive reports whether the current live-timing snapshot represents an
// ongoing session that should be surfaced as live.
//
// An actively running session (Started/Resumed) is always live. A non-terminal
// but temporarily inactive session — e.g. a red-flag pause where SessionStatus
// drops to "Inactive" while the session is still in progress — is also live when
// the snapshot itself carries live evidence: a session clock with time remaining
// plus session metadata. This keeps the paused track state visible instead of
// collapsing to the inactive empty state.
//
// Terminal sessions (Finished/Finalised/Ends/Aborted) are never live. A generic
// inactive/no-session stale snapshot is not live either: the predicate keys off
// the current snapshot's clock and session, so old metadata alone is not enough.
func snapshotIsLive(data live.LiveStreamData) bool {
if live.SessionStatusIsActive(data.SessionStatus) {
return true
}
if live.SessionStatusIsTerminal(data.SessionStatus) {
return false
}
return clockHasTimeRemaining(data.Clock) && data.Session.SessionName != ""
}
// clockHasTimeRemaining reports whether an "HH:MM:SS" session clock has any time
// left. Empty or all-zero clocks (a spent or absent session) return false.
func clockHasTimeRemaining(clock string) bool {
for _, r := range clock {
if r >= '1' && r <= '9' {
return true
}
}
return false
}
func hasLiveSnapshotData(data live.LiveStreamData) bool {
return len(data.Drivers) > 0 ||
len(data.DriverInfo) > 0 ||

View File

@@ -51,6 +51,101 @@ func TestSSEHubArchivesTerminalSessionSnapshot(t *testing.T) {
}
}
func TestSnapshotIsLive(t *testing.T) {
redFlag := live.LiveStreamData{
SessionStatus: "Inactive",
TrackStatus: "2",
Clock: "00:03:27",
Session: live.LiveSessionMeta{MeetingName: "Belgian Grand Prix", SessionName: "Practice 2", SessionType: "Practice"},
}
tests := []struct {
name string
data live.LiveStreamData
want bool
}{
{"started", live.LiveStreamData{SessionStatus: "Started"}, true},
{"resumed", live.LiveStreamData{SessionStatus: "Resumed"}, true},
{"red flag inactive with clock and session", redFlag, true},
{"terminal finished with clock", func() live.LiveStreamData {
d := redFlag
d.SessionStatus = "Finished"
return d
}(), false},
{"inactive spent clock", func() live.LiveStreamData {
d := redFlag
d.Clock = "00:00:00"
return d
}(), false},
{"inactive no clock", func() live.LiveStreamData {
d := redFlag
d.Clock = ""
return d
}(), false},
{"inactive no session metadata", func() live.LiveStreamData {
d := redFlag
d.Session = live.LiveSessionMeta{}
return d
}(), false},
}
for _, tt := range tests {
if got := snapshotIsLive(tt.data); got != tt.want {
t.Errorf("%s: snapshotIsLive = %v, want %v", tt.name, got, tt.want)
}
}
}
func TestSSEHubKeepsRedFlagSessionLive(t *testing.T) {
hub := newSSEHub()
now := time.Date(2026, 7, 26, 14, 0, 0, 0, time.UTC)
redFlag := live.LiveStreamData{
SessionStatus: "Inactive",
TrackStatus: "2",
Clock: "00:03:27",
Session: live.LiveSessionMeta{MeetingName: "Belgian Grand Prix", SessionName: "Practice 2", SessionType: "Practice"},
SnapshotUpdated: true,
}
state := hub.applySnapshot(redFlag, now)
if !state.IsLive {
t.Fatal("red-flag paused session should report is_live=true")
}
if state.Data == nil {
t.Fatal("red-flag paused session must still send the active snapshot")
}
if state.Data.TrackStatus != "2" {
t.Fatalf("track status = %q, want the paused state \"2\"", state.Data.TrackStatus)
}
if state.LastSnapshot != nil {
t.Fatalf("red-flag session should not be archived: %+v", state.LastSnapshot)
}
}
func TestSSEHubStaleInactiveSnapshotNotLive(t *testing.T) {
hub := newSSEHub()
now := time.Date(2026, 7, 26, 14, 0, 0, 0, time.UTC)
// Inactive snapshot with session metadata but no remaining clock: this is a
// stale/no-live-evidence snapshot and must not be surfaced as live.
stale := live.LiveStreamData{
SessionStatus: "Inactive",
Clock: "00:00:00",
Session: live.LiveSessionMeta{MeetingName: "Belgian Grand Prix", SessionName: "Practice 2"},
SnapshotUpdated: true,
}
state := hub.applySnapshot(stale, now)
if state.IsLive {
t.Fatal("stale inactive snapshot should report is_live=false")
}
if state.Data != nil {
t.Fatalf("stale inactive snapshot leaked into active data: %+v", state.Data)
}
if state.LastSnapshot == nil {
t.Fatal("stale inactive snapshot with metadata should be archived")
}
}
func TestHandleLiveStateKeepsArchiveOutOfActiveData(t *testing.T) {
hub := newSSEHub()
now := time.Date(2026, 7, 4, 14, 0, 0, 0, time.UTC)

View File

@@ -4,6 +4,7 @@ import (
"net/http"
"strconv"
"github.com/AmanTahiliani/box-box/internal/chapters"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/query"
)
@@ -42,5 +43,6 @@ func emptyRaceHub(sessionKey int) query.RaceHub {
Drivers: []models.Driver{},
Results: []query.EnrichedResult{},
StartingGrid: []query.EnrichedGrid{},
Chapters: []chapters.Chapter{},
}
}

View File

@@ -119,6 +119,66 @@ func TestHandleRaceHubWithLocalData(t *testing.T) {
}
}
func TestHandleRaceHubIncludesChapters(t *testing.T) {
st := openTestStore(t)
seedRaceHubStore(t, st)
sessionKey := 9472
meetingKey := 1229
for lap := 1; lap <= 12; lap++ {
if err := st.UpsertLap(store.Lap{
SessionKey: sessionKey,
DriverNumber: 1,
MeetingKey: meetingKey,
LapNumber: lap,
DateStart: time.Date(2025, 5, 25, 13, lap-1, 0, 0, time.UTC).Format(time.RFC3339),
LapDuration: 75,
}); err != nil {
t.Fatalf("UpsertLap(%d) error = %v", lap, err)
}
}
for _, sample := range []store.PositionSample{
{SessionKey: sessionKey, DriverNumber: 1, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 1},
{SessionKey: sessionKey, DriverNumber: 16, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 4},
{SessionKey: sessionKey, DriverNumber: 55, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 3},
{SessionKey: sessionKey, DriverNumber: 16, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00Z", Position: 3},
{SessionKey: sessionKey, DriverNumber: 55, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00Z", Position: 4},
} {
if err := st.UpsertPositionSample(sample); err != nil {
t.Fatalf("UpsertPositionSample() error = %v", err)
}
}
srv := testServer(t, st)
req := httptest.NewRequest(http.MethodGet, "/api/v1/race-hub?session_key=9472", nil)
rec := httptest.NewRecorder()
srv.handleRaceHub(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var hub query.RaceHub
if err := json.Unmarshal(rec.Body.Bytes(), &hub); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(hub.Chapters) == 0 {
t.Fatalf("chapters = %+v, want generated chapters", hub.Chapters)
}
if hub.Chapters[0].Kind != "start" {
t.Fatalf("first chapter = %+v, want start", hub.Chapters[0])
}
foundSwing := false
for _, chapter := range hub.Chapters {
if chapter.Kind == "decisive_swing" {
foundSwing = true
}
}
if !foundSwing {
t.Fatalf("chapters = %+v, want decisive_swing", hub.Chapters)
}
}
func TestHandleMeetingsSourceLocal(t *testing.T) {
st := openTestStore(t)
seedRaceHubStore(t, st)

259
internal/web/replay.go Normal file
View File

@@ -0,0 +1,259 @@
package web
import (
"context"
"errors"
"math"
"net/http"
"sort"
"strconv"
"sync"
"time"
"github.com/AmanTahiliani/box-box/internal/models"
)
const (
defaultReplayIntervalMS = 5000
maxReplayFrames = 3000
replayFetchConcurrency = 4
)
type replayDataClient interface {
GetDriversForSession(sessionKey int) ([]models.Driver, error)
GetLocation(sessionKey, driverNumber int) ([]models.Location, error)
}
type replayFramesResponse struct {
SessionKey int `json:"session_key"`
Interval int `json:"interval_ms"`
StartTime string `json:"start_time"`
Frames []replayFrame `json:"frames"`
}
type replayFrame struct {
T int64 `json:"t"`
Cars map[string]replayCar `json:"cars"`
}
type replayCar struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
func (s *Server) handleReplayFrames(w http.ResponseWriter, r *http.Request) {
sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key"))
if err != nil || sessionKey == 0 {
http.Error(w, "session_key required", http.StatusBadRequest)
return
}
intervalMS := defaultReplayIntervalMS
rawInterval := r.URL.Query().Get("interval_ms")
if rawInterval != "" {
parsed, err := strconv.Atoi(rawInterval)
if err != nil {
http.Error(w, "invalid interval_ms", http.StatusBadRequest)
return
}
if parsed > intervalMS {
intervalMS = parsed
}
}
resp, err := assembleReplayFrames(r.Context(), s.client, sessionKey, intervalMS)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
return
}
writeJSON(w, resp)
}
func assembleReplayFrames(ctx context.Context, client replayDataClient, sessionKey, intervalMS int) (replayFramesResponse, error) {
if intervalMS < defaultReplayIntervalMS {
intervalMS = defaultReplayIntervalMS
}
resp := replayFramesResponse{
SessionKey: sessionKey,
Interval: intervalMS,
Frames: []replayFrame{},
}
drivers, err := client.GetDriversForSession(sessionKey)
if err != nil {
return resp, err
}
driverNumbers := uniqueDriverNumbers(drivers)
if len(driverNumbers) == 0 {
return resp, nil
}
series, err := fetchReplayLocationSeries(ctx, client, sessionKey, driverNumbers)
if err != nil && len(series) == 0 {
return resp, err
}
start, ok := earliestReplayLocationTime(series)
if !ok {
return resp, nil
}
resp.StartTime = start.Format(time.RFC3339Nano)
resp.Frames = snapReplayFrames(series, start, intervalMS)
return resp, nil
}
func uniqueDriverNumbers(drivers []models.Driver) []int {
seen := make(map[int]bool, len(drivers))
numbers := make([]int, 0, len(drivers))
for _, driver := range drivers {
if driver.DriverNumber <= 0 || seen[driver.DriverNumber] {
continue
}
seen[driver.DriverNumber] = true
numbers = append(numbers, driver.DriverNumber)
}
sort.Ints(numbers)
return numbers
}
func fetchReplayLocationSeries(ctx context.Context, client replayDataClient, sessionKey int, driverNumbers []int) (map[int][]models.Location, error) {
type result struct {
driver int
locs []models.Location
err error
}
sem := make(chan struct{}, replayFetchConcurrency)
results := make(chan result, len(driverNumbers))
var wg sync.WaitGroup
for _, driverNumber := range driverNumbers {
driverNumber := driverNumber
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
results <- result{driver: driverNumber, err: ctx.Err()}
return
}
locs, err := client.GetLocation(sessionKey, driverNumber)
results <- result{driver: driverNumber, locs: locs, err: err}
}()
}
wg.Wait()
close(results)
series := make(map[int][]models.Location, len(driverNumbers))
var joined error
for result := range results {
if result.err != nil {
joined = errors.Join(joined, result.err)
continue
}
if len(result.locs) > 0 {
series[result.driver] = result.locs
}
}
return series, joined
}
func earliestReplayLocationTime(series map[int][]models.Location) (time.Time, bool) {
var start time.Time
for _, locs := range series {
for _, loc := range locs {
t, err := time.Parse(time.RFC3339Nano, loc.Date)
if err != nil {
continue
}
if start.IsZero() || t.Before(start) {
start = t
}
}
}
if start.IsZero() {
return time.Time{}, false
}
return start, true
}
func snapReplayFrames(series map[int][]models.Location, start time.Time, intervalMS int) []replayFrame {
type accumulator struct {
t int64
cars map[string]replayCar
nearest map[string]int64
}
interval := int64(intervalMS)
framesByIndex := make(map[int]*accumulator)
for driverNumber, locs := range series {
driverKey := strconv.Itoa(driverNumber)
for _, loc := range locs {
if !isFiniteFloat(loc.X) || !isFiniteFloat(loc.Y) {
continue
}
t, err := time.Parse(time.RFC3339Nano, loc.Date)
if err != nil {
continue
}
offset := t.Sub(start).Milliseconds()
if offset < 0 {
continue
}
index := int((offset + interval/2) / interval)
if index < 0 || index >= maxReplayFrames {
continue
}
frameT := int64(index) * interval
distance := absInt64(offset - frameT)
acc, ok := framesByIndex[index]
if !ok {
acc = &accumulator{
t: frameT,
cars: make(map[string]replayCar),
nearest: make(map[string]int64),
}
framesByIndex[index] = acc
}
if prev, ok := acc.nearest[driverKey]; ok && prev <= distance {
continue
}
acc.nearest[driverKey] = distance
acc.cars[driverKey] = replayCar{X: loc.X, Y: loc.Y}
}
}
indexes := make([]int, 0, len(framesByIndex))
for index, acc := range framesByIndex {
if len(acc.cars) > 0 {
indexes = append(indexes, index)
}
}
sort.Ints(indexes)
frames := make([]replayFrame, 0, len(indexes))
for _, index := range indexes {
acc := framesByIndex[index]
frames = append(frames, replayFrame{T: acc.t, Cars: acc.cars})
}
return frames
}
func absInt64(v int64) int64 {
if v < 0 {
return -v
}
return v
}
func isFiniteFloat(v float64) bool {
return !math.IsNaN(v) && !math.IsInf(v, 0)
}

181
internal/web/replay_test.go Normal file
View File

@@ -0,0 +1,181 @@
package web
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/AmanTahiliani/box-box/internal/models"
)
type fakeReplayClient struct {
drivers []models.Driver
locs map[int][]models.Location
err error
mu sync.Mutex
inFlight int
maxInFlight int
delay time.Duration
}
func (f *fakeReplayClient) GetDriversForSession(sessionKey int) ([]models.Driver, error) {
if f.err != nil {
return nil, f.err
}
return f.drivers, nil
}
func (f *fakeReplayClient) GetLocation(sessionKey, driverNumber int) ([]models.Location, error) {
f.mu.Lock()
f.inFlight++
if f.inFlight > f.maxInFlight {
f.maxInFlight = f.inFlight
}
f.mu.Unlock()
if f.delay > 0 {
time.Sleep(f.delay)
}
f.mu.Lock()
f.inFlight--
f.mu.Unlock()
return f.locs[driverNumber], nil
}
func TestAssembleReplayFramesSnapsNearestSamplesAndOmitsEmptyDrivers(t *testing.T) {
start := time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC)
client := &fakeReplayClient{
drivers: []models.Driver{
{DriverNumber: 1},
{DriverNumber: 4},
{DriverNumber: 16},
},
locs: map[int][]models.Location{
1: {
{Date: start.Add(1 * time.Second).Format(time.RFC3339Nano), X: 10, Y: 20},
{Date: start.Add(4 * time.Second).Format(time.RFC3339Nano), X: 40, Y: 80},
{Date: start.Add(6 * time.Second).Format(time.RFC3339Nano), X: 60, Y: 120},
},
4: {
{Date: start.Add(5 * time.Second).Format(time.RFC3339Nano), X: 100, Y: 200},
},
16: {},
},
}
resp, err := assembleReplayFrames(context.Background(), client, 99, 5000)
if err != nil {
t.Fatalf("assembleReplayFrames() error = %v", err)
}
if resp.SessionKey != 99 || resp.Interval != 5000 {
t.Fatalf("response metadata = %+v", resp)
}
if resp.StartTime != start.Add(1*time.Second).Format(time.RFC3339Nano) {
t.Fatalf("start_time = %q", resp.StartTime)
}
if len(resp.Frames) != 2 {
t.Fatalf("frames len = %d, want 2: %+v", len(resp.Frames), resp.Frames)
}
if _, ok := resp.Frames[0].Cars["16"]; ok {
t.Fatalf("empty driver included in frame: %+v", resp.Frames[0].Cars)
}
if got := resp.Frames[0].Cars["1"]; got.X != 10 || got.Y != 20 {
t.Fatalf("frame 0 car 1 = %+v, want first nearest sample", got)
}
if got := resp.Frames[1].Cars["1"]; got.X != 60 || got.Y != 120 {
t.Fatalf("frame 1 car 1 = %+v, want later nearest sample", got)
}
if got := resp.Frames[1].Cars["4"]; got.X != 100 || got.Y != 200 {
t.Fatalf("frame 1 car 4 = %+v", got)
}
}
func TestAssembleReplayFramesCapsFrameCount(t *testing.T) {
start := time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC)
locs := make([]models.Location, maxReplayFrames+250)
for i := range locs {
locs[i] = models.Location{
Date: start.Add(time.Duration(i*defaultReplayIntervalMS) * time.Millisecond).Format(time.RFC3339Nano),
X: float64(i),
Y: float64(i * 2),
}
}
client := &fakeReplayClient{
drivers: []models.Driver{{DriverNumber: 1}},
locs: map[int][]models.Location{1: locs},
}
resp, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS)
if err != nil {
t.Fatalf("assembleReplayFrames() error = %v", err)
}
if len(resp.Frames) > maxReplayFrames {
t.Fatalf("frames len = %d, want <= %d", len(resp.Frames), maxReplayFrames)
}
if len(resp.Frames) != maxReplayFrames {
t.Fatalf("frames len = %d, want hard cap %d", len(resp.Frames), maxReplayFrames)
}
}
func TestAssembleReplayFramesBoundsLocationFanOut(t *testing.T) {
drivers := make([]models.Driver, 10)
locs := make(map[int][]models.Location, len(drivers))
now := time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC)
for i := range drivers {
number := i + 1
drivers[i] = models.Driver{DriverNumber: number}
locs[number] = []models.Location{{Date: now.Format(time.RFC3339Nano), X: float64(number), Y: float64(number)}}
}
client := &fakeReplayClient{
drivers: drivers,
locs: locs,
delay: 5 * time.Millisecond,
}
if _, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS); err != nil {
t.Fatalf("assembleReplayFrames() error = %v", err)
}
if client.maxInFlight > replayFetchConcurrency {
t.Fatalf("max in-flight location calls = %d, want <= %d", client.maxInFlight, replayFetchConcurrency)
}
}
func TestHandleReplayFramesValidatesParamsAndFloorsInterval(t *testing.T) {
srv := testServer(t, nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/replay/frames", nil)
rec := httptest.NewRecorder()
srv.handleReplayFrames(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("missing session_key status = %d, want 400", rec.Code)
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/replay/frames?session_key=1&interval_ms=nope", nil)
rec = httptest.NewRecorder()
srv.handleReplayFrames(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("invalid interval status = %d, want 400", rec.Code)
}
client := &fakeReplayClient{drivers: []models.Driver{{DriverNumber: 1}}, locs: map[int][]models.Location{
1: {{Date: time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC).Format(time.RFC3339Nano), X: 1, Y: 2}},
}}
resp, err := assembleReplayFrames(context.Background(), client, 99, 1000)
if err != nil {
t.Fatalf("assembleReplayFrames() error = %v", err)
}
body, err := json.Marshal(resp)
if err != nil || len(body) == 0 {
t.Fatalf("marshal response = %d bytes, %v", len(body), err)
}
if resp.Interval != defaultReplayIntervalMS {
t.Fatalf("interval = %d, want floor %d", resp.Interval, defaultReplayIntervalMS)
}
}

View File

@@ -70,11 +70,15 @@ func (s *Server) routes() (http.Handler, error) {
mux.HandleFunc("/api/v1/news", s.handleNews)
mux.HandleFunc("/api/v1/meetings", s.handleMeetings)
mux.HandleFunc("/api/v1/sessions", s.handleSessions)
// /api/v1/driver/summary before /api/v1/drivers: distinct prefixes today,
// but keep the more specific driver/* paths registered first.
mux.HandleFunc("/api/v1/driver/summary", s.handleDriverSummary)
mux.HandleFunc("/api/v1/drivers", s.handleDrivers)
mux.HandleFunc("/api/v1/results", s.handleResults)
mux.HandleFunc("/api/v1/grid", s.handleGrid)
mux.HandleFunc("/api/v1/laps/comparison", s.handleLapsComparison)
mux.HandleFunc("/api/v1/laps", s.handleLaps)
mux.HandleFunc("/api/v1/replay/frames", s.handleReplayFrames)
mux.HandleFunc("/api/v1/weather", s.handleWeather)
mux.HandleFunc("/api/v1/race-control", s.handleRaceControl)
mux.HandleFunc("/api/v1/telemetry", s.handleTelemetry)

View File

@@ -147,6 +147,58 @@ const sprintQualifyingSnapshot = {
},
}
// Practice: the feed sets best laps but no GapToLeader/Interval, and there is
// no race lap-total concept. Exercises computed gaps, the non-race banner, the
// collapsed-by-default tyre panel, and the 390px core-field layout.
const practiceSnapshot = {
is_live: true,
data: {
...raceSnapshot.data,
Drivers: Object.fromEntries(
Array.from({ length: 10 }, (_, index) => {
const num = String(index + 1)
return [
num,
driver(num, index + 1, '', '', {
// Distinct, increasing best laps; P1 fastest, +0.200s per position.
BestLapTime: `1:${(45.9 + index * 0.2).toFixed(3).padStart(6, '0')}`,
LastLapTime: '1:46.500',
NumberOfLaps: 12,
Sectors: index === 0
? [
{ Value: '28.500', PersonalFastest: true, OverallFastest: false },
{ Value: '52.100', PersonalFastest: false, OverallFastest: false },
{ Value: '25.344', PersonalFastest: false, OverallFastest: false },
]
: [],
}),
]
}),
),
DriverInfo: Object.fromEntries(
Array.from({ length: 10 }, (_, index) => {
const num = String(index + 1)
return [num, info(num, `D${index + 1}`, 'Driver', String(index + 1), 'Test Team', index % 2 ? 'FF8000' : '27F4D2')]
}),
),
Tyres: Object.fromEntries(
Array.from({ length: 10 }, (_, index) => [String(index + 1), { Compound: 'SOFT', New: false, Age: index % 4 }]),
),
Session: {
MeetingName: 'Belgian Grand Prix',
CircuitName: 'Spa-Francorchamps',
SessionType: 'Practice',
SessionName: 'Practice 2',
},
TrackStatus: '1',
CurrentLap: 0,
TotalLaps: 0,
Clock: '00:45:00',
ClockRefTime: '2026-07-17T13:00:00Z',
ClockExtrapolating: false,
},
}
test.describe('Live Timing (mocked snapshot)', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/v1/live/state', (route) =>
@@ -233,6 +285,94 @@ test.describe('Live Timing (mocked Sprint Qualifying)', () => {
})
})
test.describe('Live Timing (mocked Practice)', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/v1/live/state', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(practiceSnapshot) }),
)
await page.route('**/api/v1/live/stream', (route) =>
route.fulfill({ contentType: 'text/event-stream', body: 'event: heartbeat\ndata: {}\n\n' }),
)
await page.goto('/live')
})
test('derives a gap to P1 from best laps and marks the leader', async ({ page }) => {
const tower = page.locator('.live-tower')
await expect(tower).toBeVisible()
// P1 shows a leader marker, not a fabricated gap.
await expect(tower.locator('tbody tr').first()).toContainText('—')
// P2 shows the computed +0.200 delta.
await expect(tower).toContainText('+0.200')
})
test('omits the race lap counter and keeps the session clock for practice', async ({ page }) => {
await expect(page.getByTestId('live-lap-counter')).toHaveCount(0)
await expect(page.getByTestId('live-clock')).toContainText('00:45:00')
await expect(page.locator('.live-banner')).not.toContainText('L-/-')
})
test('collapses the tyre panel by default so the tower is above the fold', async ({ page }) => {
await expect(page.getByTestId('tyredeg-panel')).toBeVisible()
await expect(page.getByTestId('tyredeg-row')).toHaveCount(0)
await expect(page.locator('.live-tower')).toBeVisible()
})
test('labels Race Control times as UTC', async ({ page }) => {
await expect(page.getByTestId('rc-timezone')).toContainText('UTC')
})
test('marks Live as active in the primary navigation', async ({ page }) => {
await expect(page.getByTestId('nav-live')).toHaveAttribute('data-live-active', 'true')
await expect(page.getByTestId('nav-live').locator('.nav-live-dot')).toBeVisible()
})
})
test.describe('Live Timing (390px practice viewport)', () => {
test.use({ viewport: { width: 390, height: 844 } })
test.beforeEach(async ({ page }) => {
await page.route('**/api/v1/live/state', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(practiceSnapshot) }),
)
await page.route('**/api/v1/live/stream', (route) =>
route.fulfill({ contentType: 'text/event-stream', body: 'event: heartbeat\ndata: {}\n\n' }),
)
await page.goto('/live')
})
test('shows the five core fields with no horizontal overflow', async ({ page }) => {
const tower = page.locator('.live-tower')
await expect(tower).toBeVisible()
// Core comparison fields are present in the header.
await expect(tower.locator('thead')).toContainText('Pos')
await expect(tower.locator('thead')).toContainText('Driver')
await expect(tower.locator('thead')).toContainText('Tyre')
await expect(tower.locator('thead')).toContainText('Best')
await expect(tower.locator('thead')).toContainText('Gap to P1')
// Sectors are hidden from the initial mobile tower (reachable via expand).
const firstSector = tower.locator('thead th', { hasText: 'S1' })
await expect(firstSector).toBeHidden()
// The document must not scroll horizontally.
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
)
expect(overflow).toBeLessThanOrEqual(0)
})
test('exposes sectors through the row detail interaction', async ({ page }) => {
// Sector columns are not in the initial mobile tower...
await expect(page.locator('.live-tower thead th', { hasText: 'S1' })).toBeHidden()
// ...but the P1 row's sectors are reachable by expanding the row.
await page.locator('.live-tower tbody tr', { hasText: 'D1' }).first().click()
const sectors = page.getByTestId('expanded-sectors')
await expect(sectors).toBeVisible()
await expect(sectors).toContainText('28.500')
})
})
test.describe('Live Timing (no session)', () => {
test('shows the empty state when the feed has no snapshot', async ({ page }) => {
await page.goto('/live')

View File

@@ -39,7 +39,17 @@ test.describe('Race Hub Weekend Workspace', () => {
await expect(
page.getByRole('img', { name: 'Position evolution chart' }),
).toBeVisible()
await expect(page.getByText('Lap-by-lap positions not available.')).not.toBeVisible()
await expect(page.getByTestId('race-story-no-positions')).not.toBeVisible()
})
test('Race Story highlights a chapter card when clicked', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await page.getByRole('tab', { name: 'Race Story' }).click()
const firstCard = page.getByTestId('chapter-card-0')
await expect(firstCard).toBeVisible()
await firstCard.click()
await expect(firstCard).toHaveClass(/active/)
})
test('strategy tab renders stint chart when stints are available', async ({ page }) => {
@@ -58,13 +68,15 @@ test.describe('Race Hub Weekend Workspace', () => {
await expect(page.locator('[data-testid="strategy-chart"]')).not.toBeVisible()
})
test('Race Story shows missing notice when positions are unavailable', async ({
test('Race Story shows empty-state card when positions are unavailable', async ({
page,
}) => {
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
await page.getByRole('tab', { name: 'Race Story' }).click()
await expect(page.getByText('Lap-by-lap positions not available.')).toBeVisible()
const empty = page.getByTestId('race-story-no-positions')
await expect(empty).toBeVisible()
await expect(empty.getByText('Lap-by-lap positions not available')).toBeVisible()
await expect(page.locator('[data-testid="position-chart"]')).not.toBeVisible()
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -36,6 +36,14 @@ export async function gotoRaceHubReady(page: Page, sessionKey = FULL_SESSION): P
await waitForScreenshotReady(page)
}
export async function gotoRaceStoryReady(page: Page, sessionKey = FULL_SESSION): Promise<void> {
await page.goto(`/race-hub?session_key=${sessionKey}`)
await expect(page.getByTestId('race-hub')).toBeVisible()
await page.getByRole('tab', { name: 'Race Story' }).click()
await expect(page.getByTestId('position-chart')).toBeVisible()
await waitForScreenshotReady(page)
}
export async function gotoDataLibraryReady(page: Page): Promise<void> {
await page.goto('/admin')
await expect(page.getByTestId('data-library')).toBeVisible()

View File

@@ -0,0 +1,9 @@
import { test } from '@playwright/test'
import { gotoRaceStoryReady, screenshotPage } from './helpers'
test.describe('Race Story visual regression', () => {
test('race-story', async ({ page }) => {
await gotoRaceStoryReady(page)
await screenshotPage(page, 'race-story')
})
})