Compare commits

..

19 Commits

Author SHA1 Message Date
AmanTahiliani
e8de2068c1 feat(frontend): add next-race preview page (#25)
Assemble /preview from existing schedule, track-outline, results, and
championship hub endpoints with client-side preview.ts helpers, section
loading/empty states, and Vitest coverage for lib + page render paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 17:59:00 -04:00
Aman Tahiliani
1782fd313c Merge pull request #55 from AmanTahiliani/codex/hide-live-track-map
Temporarily hide Live track map while GPS feed is unavailable
2026-07-11 17:47:03 -04:00
Aman Tahiliani
be7eb3c034 Merge pull request #58 from AmanTahiliani/fix/championship-sprint-points-57
fix(championship): include Sprint points (ANT 158->179) - Fixes #57
2026-07-10 23:57:12 -04:00
Aman Tahiliani
71523641ad chore: clean up verification artifacts for repo hygiene
Removes temporary verification screenshots, docker files and verify.html
introduced for PR #58 validation.

- Before/after images and real-app screenshots remain accessible in history
  at e51cabb for PR review but are not needed in main
- Keeps only the actual fix: internal/query/championship.go + test
- Maintains docs/ structure without binary clutter (PRODUCT_ROADMAP, assets, etc.)

Fixes #57 remains: Sprint points now included (ANT 158->179)
2026-07-10 23:53:11 -04:00
Aman Tahiliani
e51cabba62 docs(verify): add real app screenshots with sprint fix - ANT 179, RUS 154, HAM 147
- Running /tmp/boxbox-server --db /tmp/boxbox-verify.db (localChampionshipHub)
- /verify.html shows championship hub table with fixed points
- /api/v1/championship/hub?year=2026&source=local returns 179/154/147
- Captured via google-chrome --headless --screenshot
- Add Dockerfile + docker-compose.yml for future prod-parity verification
2026-07-10 23:50:05 -04:00
Aman Tahiliani
fc6fc36d8f docs: add verification screenshots for sprint fix #57
- before 158 vs after 179
- code fix snippet
- go test passing 25+8=33
- verification output showing 179,154,147 totals
2026-07-10 23:33:57 -04:00
Aman Tahiliani
16d82afad5 fix(championship): include Sprint points in standings
- GetChampionshipInputs now sums Race + Sprint sessions per meeting
- Bahrain 1282 / Saudi 1283 cancelled (Force Majeure) - intentional Round 9/24
- 2026 sprints up to British GP: 11240 Chinese, 11275 Miami, 11286 Canadian, 11321 British
  Totals ANT +21, RUS +26, HAM +18 etc.
- Adds regression test TestGetChampionshipInputsIncludesSprintPoints (25+8=33)
- Fixes #57
2026-07-10 23:32:12 -04:00
AmanTahiliani
91199ff5c0 chore: add F1 live socket probe 2026-07-04 07:22:25 -04:00
AmanTahiliani
144e93ee44 fix: hide live track map while GPS unavailable 2026-07-04 07:17:28 -04:00
Aman Tahiliani
f4f5071e26 Merge pull request #53 from AmanTahiliani/feat/issue-52-fix-stale-live-tab-sessions-and-add-last
Fix stale Live tab sessions and add last-session archive view (#52)
2026-07-04 01:44:21 -04:00
AmanTahiliani
a1d71900d1 fix: separate live archive snapshots
Parse and expose SessionStatus from the official live feed, treating Started/Resumed as active and terminal or missing statuses as inactive archive candidates.

Keep /api/v1/live/state and SSE snapshot data reserved for active sessions while exposing memory-only last_snapshot, last_positions, and last_snapshot_at for the explicit Live tab archive view.
2026-07-04 01:40:58 -04:00
Aman Tahiliani
34b060238a Merge pull request #51 from AmanTahiliani/feat/issue-47-disable-or-repair-agy-headless-harness
Disable or repair agy headless harness path (#47)
2026-07-04 01:17:56 -04:00
Aman Tahiliani
a9920f0dd4 Merge pull request #49 from AmanTahiliani/feat/issue-46-handle-null-reference-laps-in-delta-time
Handle null reference laps in delta-time graph (#46)
2026-07-04 01:17:53 -04:00
Aman Tahiliani
7c8a75f7ca Merge pull request #48 from AmanTahiliani/feat/issue-45-restore-pit-stop-markers-on-strategy-st
Restore pit-stop markers on strategy stint timeline (#45)
2026-07-04 01:17:51 -04:00
Aman Tahiliani
e0174f7bf1 Merge pull request #50 from AmanTahiliani/feat/issue-44-web-track-outline-cache-warming-for-web-o
Web track-outline cache warming for web-only deployments (#44)
2026-07-04 01:17:49 -04:00
AmanTahiliani
2d6fa531f2 fix(compare): gap deltas beyond reference laps 2026-07-04 01:16:01 -04:00
AmanTahiliani
b0fd252096 feat(cli): add track outline cache warmer
Spike: existing TUI prefetch stored outlines under time.Now().Year(), so the CLI uses a new explicit year-aware prefetch path and the TUI wrapper now derives the year from meetings when available.
2026-07-04 01:06:19 -04:00
AmanTahiliani
ad379e0f07 fix(compare): gap delta graph when reference lap is null
Reference-null laps now emit null deltas for all drivers and exclude that
lap window from aligned cumulative totals so later comparisons stay sane.
Challenger-null behavior is unchanged. Compare tab meta documents the policy.

Closes #46

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 01:04:55 -04:00
AmanTahiliani
9ee14e5c89 fix(frontend): restore pit-stop markers on strategy stint timeline (#45)
Wire Race Hub pit_stops into TyreStintTimeline via optional per-row
pitStops laps; render vertical markers at stint boundaries with driver/lap
tooltips. Matches pre-extraction positioning ((lap-1)/totalLaps).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 01:04:13 -04:00
37 changed files with 2943 additions and 142 deletions

View File

@@ -27,6 +27,7 @@ func main() {
ingestMeeting := flag.Int("ingest-meeting", 0, "Ingest meeting metadata and Race Hub datasets for all sessions") ingestMeeting := flag.Int("ingest-meeting", 0, "Ingest meeting metadata and Race Hub datasets for all sessions")
ingestSession := flag.Int("ingest-session", 0, "Ingest Race Hub datasets for a session key") ingestSession := flag.Int("ingest-session", 0, "Ingest Race Hub datasets for a session key")
ingestNews := flag.Bool("ingest-news", false, "Refresh RSS/Atom paddock briefing feeds") ingestNews := flag.Bool("ingest-news", false, "Refresh RSS/Atom paddock briefing feeds")
prefetchTrackOutlines := flag.Int("prefetch-track-outlines", 0, "Warm the web track-outline cache for a season year (for web-only hosts, run before --web so /api/v1/track-outline can serve live maps)")
dryRun := flag.Bool("dry-run", false, "Preview ingestion without writing domain rows") dryRun := flag.Bool("dry-run", false, "Preview ingestion without writing domain rows")
force := flag.Bool("force", false, "Re-ingest datasets even if already tracked in the session_coverage table as completed") force := flag.Bool("force", false, "Re-ingest datasets even if already tracked in the session_coverage table as completed")
coverageYear := flag.Int("coverage", 0, "Show season coverage report for the given year") coverageYear := flag.Int("coverage", 0, "Show season coverage report for the given year")
@@ -76,11 +77,21 @@ func main() {
if *ingestNews { if *ingestNews {
ingestFlags++ ingestFlags++
} }
if *prefetchTrackOutlines != 0 {
ingestFlags++
}
if ingestFlags > 0 { if ingestFlags > 0 {
if ingestFlags > 1 { if ingestFlags > 1 {
fmt.Fprintln(os.Stderr, "box-box: only one of --ingest-year, --backfill-season, --ingest-meeting, --ingest-session, or --ingest-news may be set") fmt.Fprintln(os.Stderr, "box-box: only one of --ingest-year, --backfill-season, --ingest-meeting, --ingest-session, --ingest-news, or --prefetch-track-outlines may be set")
os.Exit(1) os.Exit(1)
} }
if *prefetchTrackOutlines != 0 {
if err := runTrackOutlinePrefetch(client, *prefetchTrackOutlines); err != nil {
fmt.Fprintf(os.Stderr, "box-box track outline prefetch error: %v\n", err)
os.Exit(1)
}
return
}
if *ingestNews { if *ingestNews {
if err := runNewsIngestion(*dryRun, *dbPath); err != nil { if err := runNewsIngestion(*dryRun, *dbPath); err != nil {
fmt.Fprintf(os.Stderr, "box-box ingest error: %v\n", err) fmt.Fprintf(os.Stderr, "box-box ingest error: %v\n", err)
@@ -88,7 +99,7 @@ func main() {
} }
return return
} }
yearVal := *ingestYear yearVal := *ingestYear
if *backfillSeason != 0 { if *backfillSeason != 0 {
yearVal = *backfillSeason yearVal = *backfillSeason
@@ -174,6 +185,35 @@ func runIngestion(client *api.OpenF1Client, year, meetingKey, sessionKey int, fo
return err return err
} }
func runTrackOutlinePrefetch(client *api.OpenF1Client, year int) error {
log.SetOutput(os.Stderr)
fmt.Fprintf(os.Stderr, "track outlines: warming HTTP cache %s for %d\n", api.DefaultCacheDBPath(), year)
meetings, err := client.GetMeetingsForYear(year)
if err != nil {
return fmt.Errorf("fetch meetings for %d: %w", year, err)
}
result := client.PrefetchTrackOutlinesForYear(year, meetings)
fmt.Printf(
"track outlines %d: cached %d/%d unique circuit(s) before, %d/%d after; %d skipped, %d fetched, %d failed\n",
result.Year,
result.CachedBefore,
result.UniqueCircuits,
result.CachedAfter,
result.UniqueCircuits,
result.Skipped,
result.Fetched,
result.Failed,
)
if result.CachedAfter == 0 {
return fmt.Errorf("cached zero track outlines for %d", year)
}
return nil
}
func runNewsIngestion(dryRun bool, dbPath string) error { func runNewsIngestion(dryRun bool, dbPath string) error {
log.SetOutput(os.Stderr) log.SetOutput(os.Stderr)
@@ -275,7 +315,7 @@ func runCoverageReport(year int, dbPath string) error {
} }
fmt.Printf("\n--- Season %d Coverage Report ---\n\n", year) fmt.Printf("\n--- Season %d Coverage Report ---\n\n", year)
fmt.Printf("%-35s | %-5s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s\n", fmt.Printf("%-35s | %-5s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s\n",
"Meeting / Session (Key)", "ID", "DR", "SR", "SG", "ST", "PS", "PO", "RC", "WE", "LA") "Meeting / Session (Key)", "ID", "DR", "SR", "SG", "ST", "PS", "PO", "RC", "WE", "LA")
fmt.Println(strings.Repeat("-", 82)) fmt.Println(strings.Repeat("-", 82))

View File

@@ -2,6 +2,8 @@ import type {
ArticleContent, ArticleContent,
CarDataSample, CarDataSample,
ChampionshipHub, ChampionshipHub,
EnrichedGrid,
EnrichedResult,
LapsComparisonResponse, LapsComparisonResponse,
LiveStateResponse, LiveStateResponse,
LiveSessionMeta, LiveSessionMeta,
@@ -40,7 +42,11 @@ export async function fetchLocalMeetings(year: number): Promise<Meeting[]> {
} }
export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> { export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
const res = await fetch(`/api/v1/meetings?year=${year}&source=openf1`) return fetchMeetings(year, 'openf1')
}
export async function fetchMeetings(year: number, source = 'auto'): Promise<Meeting[]> {
const res = await fetch(`/api/v1/meetings?year=${year}&source=${source}`)
if (!res.ok) { if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`) throw new Error(`API ${res.status}: ${res.statusText}`)
} }
@@ -48,6 +54,32 @@ export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
return Array.isArray(meetings) ? meetings : [] return Array.isArray(meetings) ? meetings : []
} }
export async function fetchResults(sessionKey: number, source = 'auto'): Promise<EnrichedResult[]> {
const res = await fetch(`/api/v1/results?session_key=${sessionKey}&source=${source}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
const results = await res.json()
return Array.isArray(results) ? results : []
}
export async function fetchStartingGrid(sessionKey: number, source = 'auto'): Promise<EnrichedGrid[]> {
const res = await fetch(`/api/v1/grid?session_key=${sessionKey}&source=${source}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
const grid = await res.json()
return Array.isArray(grid) ? grid : []
}
export async function fetchTrackOutline(circuitKey: number, year: number): Promise<TrackOutline | null> {
const res = await fetch(`/api/v1/track-outline?circuit_key=${circuitKey}&year=${year}`)
if (!res.ok) return null
const data = await res.json()
if (data?.error || !Array.isArray(data?.points) || data.points.length < 2) return null
return data as TrackOutline
}
export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise<Session[]> { export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise<Session[]> {
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`) const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
if (!res.ok) { if (!res.ok) {

View File

@@ -229,7 +229,9 @@ export function CompareView({ sessionKey, results, drivers }: Props) {
<div> <div>
<div className="compare-section-title">Race pace</div> <div className="compare-section-title">Race pace</div>
<div className="compare-section-meta"> <div className="compare-section-meta">
Cumulative lap-time delta vs {referenceLabel ?? 'reference'} Cumulative lap-time delta vs {referenceLabel ?? 'reference'}. Deltas are plotted
only where the reference lap is valid; gaps appear when the reference has no lap
time.
</div> </div>
</div> </div>
<SectionState <SectionState

View File

@@ -16,6 +16,9 @@ export function Nav() {
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}> <Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
Race Hub Race Hub
</Link> </Link>
<Link to="/preview" activeProps={{ className: 'active' }}>
Preview
</Link>
<Link to="/championship" activeProps={{ className: 'active' }}> <Link to="/championship" activeProps={{ className: 'active' }}>
Championship Championship
</Link> </Link>

View File

@@ -12,7 +12,7 @@ interface Props {
hasStints: boolean hasStints: boolean
} }
export function StrategyView({ results, stints, pit_stops: _pitStops, hasStints }: Props) { export function StrategyView({ results, stints, pit_stops, hasStints }: Props) {
if (!hasStints) { if (!hasStints) {
return ( return (
<div> <div>
@@ -88,6 +88,10 @@ export function StrategyView({ results, stints, pit_stops: _pitStops, hasStints
lapEnd: s.lap_end, lapEnd: s.lap_end,
isNew: s.tyre_age_at_start === 0, isNew: s.tyre_age_at_start === 0,
})), })),
pitStops: pit_stops
.filter((p) => p.driver_number === driver.driver_number)
.map((p) => p.lap_number)
.sort((a, b) => a - b),
})) }))
return ( return (

View File

@@ -12,6 +12,8 @@ export interface StintTimelineRow {
label: string label: string
color: string color: string
stints: StintTimelineStint[] stints: StintTimelineStint[]
/** Lap numbers where the driver pitted; optional — rows without stops render normally. */
pitStops?: number[]
} }
interface TyreStintTimelineProps { interface TyreStintTimelineProps {
@@ -57,6 +59,14 @@ function stintBarW(stint: StintTimelineStint, totalLaps: number): number {
return Math.max(2, (stintLength(stint) / totalLaps) * BAR_W) return Math.max(2, (stintLength(stint) / totalLaps) * BAR_W)
} }
function pitMarkerX(lapNumber: number, totalLaps: number): number {
return LEFT + ((lapNumber - 1) / totalLaps) * BAR_W
}
function pitMarkerTitle(driverLabel: string, lapNumber: number): string {
return `${driverLabel} pit stop · L${lapNumber}`
}
function axisTicks(totalLaps: number): number[] { function axisTicks(totalLaps: number): number[] {
const ticks: number[] = [] const ticks: number[] = []
for (let lap = 0; lap <= totalLaps; lap += 10) { for (let lap = 0; lap <= totalLaps; lap += 10) {
@@ -130,6 +140,21 @@ export function TyreStintTimeline({ rows, totalLaps }: TyreStintTimelineProps) {
<title>{stintTitle(stint)}</title> <title>{stintTitle(stint)}</title>
</rect> </rect>
))} ))}
{(row.pitStops ?? []).map((lapNumber, pi) => (
<line
key={`pit-${pi}`}
x1={pitMarkerX(lapNumber, safeTotal)}
x2={pitMarkerX(lapNumber, safeTotal)}
y1={BAR_Y - 3}
y2={BAR_Y + BAR_H + 3}
className="stint-timeline__pit-marker"
data-testid="pit-marker"
data-lap={lapNumber}
>
<title>{pitMarkerTitle(row.label, lapNumber)}</title>
</line>
))}
</g> </g>
) )
})} })}

View File

@@ -5,18 +5,20 @@ import { WeatherStrip } from './WeatherStrip'
interface Props { interface Props {
isLive: boolean isLive: boolean
isArchive?: boolean
snapshot: LiveStreamData snapshot: LiveStreamData
rows: LiveTimingRow[] rows: LiveTimingRow[]
connection: 'connected' | 'connecting' | 'disconnected' | 'error' connection: 'connected' | 'connecting' | 'disconnected' | 'error'
now: number now: number
} }
export function SessionBanner({ isLive, snapshot, rows, connection, now }: Props) { export function SessionBanner({ isLive, isArchive = false, snapshot, rows, connection, now }: Props) {
const session = snapshot.Session const session = snapshot.Session
const clock = extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now) const clock = extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now)
const display = liveSessionDisplay(session, rows) const display = liveSessionDisplay(session, rows)
const atRiskLabel = const atRiskLabel =
display.atRiskStart && display.atRiskEnd ? `P${display.atRiskStart}-P${display.atRiskEnd} at risk` : '' display.atRiskStart && display.atRiskEnd ? `P${display.atRiskStart}-P${display.atRiskEnd} at risk` : ''
const stateLabel = isLive ? 'live' : isArchive ? 'archive' : 'stale'
return ( return (
<section className="live-banner"> <section className="live-banner">
@@ -39,7 +41,7 @@ export function SessionBanner({ isLive, snapshot, rows, connection, now }: Props
<span> <span>
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong> L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
</span> </span>
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{isLive ? 'live' : 'stale'}</span> <span className={isLive ? 'live-state live-state-on' : 'live-state'}>{stateLabel}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -21,15 +21,31 @@ export function formatDeltaSeconds(delta: number): string {
return `${sign}${delta.toFixed(1)}s` return `${sign}${delta.toFixed(1)}s`
} }
function buildCumulative(lapTimes: ReadonlyArray<number | null>): number[] { /**
* Cumulative lap time aligned to reference-valid laps only.
* Laps where the reference is null are skipped for every series so later deltas
* do not compare against a frozen baseline while challengers keep accumulating.
*/
function buildAlignedCumulative(
lapTimes: ReadonlyArray<number | null>,
referenceLapTimes: ReadonlyArray<number | null>,
): number[] {
const cumulative: number[] = [] const cumulative: number[] = []
let running = 0 let running = 0
for (const lap of lapTimes) { const length = Math.max(lapTimes.length, referenceLapTimes.length)
if (lap !== null) {
for (let i = 0; i < length; i++) {
if (referenceLapTimes[i] == null) {
cumulative.push(running)
continue
}
const lap = lapTimes[i]
if (lap != null) {
running += lap running += lap
} }
cumulative.push(running) cumulative.push(running)
} }
return cumulative return cumulative
} }
@@ -47,7 +63,8 @@ function resolveReference(
/** /**
* Compute per-lap cumulative time delta for each non-reference driver. * Compute per-lap cumulative time delta for each non-reference driver.
* Positive = behind reference; negative = ahead. * Positive = behind reference; negative = ahead.
* Null laps carry cumulative forward but emit null in deltas (skip when plotting). * Deltas are only emitted where the reference lap is valid; reference-null laps
* gap every series. Challenger-null laps gap only that driver's line.
*/ */
export function computeCumulativeDeltas( export function computeCumulativeDeltas(
series: ReadonlyArray<DeltaSeries>, series: ReadonlyArray<DeltaSeries>,
@@ -56,24 +73,22 @@ export function computeCumulativeDeltas(
const reference = resolveReference(series, referenceLabel) const reference = resolveReference(series, referenceLabel)
if (!reference) return [] if (!reference) return []
const refCumulative = buildCumulative(reference.lapTimes) const refLapTimes = reference.lapTimes
const refCumulative = buildAlignedCumulative(refLapTimes, refLapTimes)
return series return series
.filter((s) => s.label !== reference.label) .filter((s) => s.label !== reference.label)
.map((driver) => { .map((driver) => {
const driverCumulative = buildCumulative(driver.lapTimes) const driverCumulative = buildAlignedCumulative(driver.lapTimes, refLapTimes)
const lapCount = Math.max(driver.lapTimes.length, refCumulative.length) const lapCount = Math.max(driver.lapTimes.length, refCumulative.length)
const deltas: (number | null)[] = [] const deltas: (number | null)[] = []
for (let i = 0; i < lapCount; i++) { for (let i = 0; i < lapCount; i++) {
if (driver.lapTimes[i] === null) { if (refLapTimes[i] == null || driver.lapTimes[i] == null) {
deltas.push(null) deltas.push(null)
continue continue
} }
const refValue = refCumulative[i] ?? refCumulative[refCumulative.length - 1] ?? 0 deltas.push(driverCumulative[i] - refCumulative[i])
const driverValue =
driverCumulative[i] ?? driverCumulative[driverCumulative.length - 1] ?? 0
deltas.push(driverValue - refValue)
} }
return { return {

123
frontend/src/lib/preview.ts Normal file
View File

@@ -0,0 +1,123 @@
import type { ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session } from '../types'
import {
currentAndNextSession,
nextUpcomingMeeting,
sessionStartTime,
sortSessionsByStart,
} from './schedule'
export const RACE_WIN_POINTS = 25
export const SPRINT_WIN_POINTS = 8
export const SPRINT_WEEKEND_MAX_POINTS = RACE_WIN_POINTS + SPRINT_WIN_POINTS
export function pickPreviewMeeting(meetings: Meeting[], now: Date): Meeting | null {
const active = meetings.filter((m) => !m.is_cancelled)
return nextUpcomingMeeting(active, now)
}
export function findPriorYearMeetingByCircuit(
priorYearMeetings: Meeting[],
circuitKey: number | undefined,
): Meeting | null {
if (!circuitKey) return null
return priorYearMeetings.find((m) => m.circuit_key === circuitKey && !m.is_cancelled) ?? null
}
export function findRaceSession(sessions: Session[]): Session | null {
const sorted = sortSessionsByStart(sessions)
return (
sorted.find((s) => {
const type = s.session_type?.toLowerCase() ?? ''
return type.includes('race') && !type.includes('sprint')
}) ?? null
)
}
export function isSprintWeekend(sessions: Session[]): boolean {
return sessions.some((s) => {
const label = `${s.session_type} ${s.session_name}`.toLowerCase()
return /\bsprint\b/.test(label) && !label.includes('sprint qualifying') && !label.includes('sprint shootout')
})
}
export interface PodiumPlace {
position: number
name_acronym: string
full_name: string
team_name: string
team_colour: string
}
export interface PolePosition {
name_acronym: string
full_name: string
team_name: string
team_colour: string
}
export function extractPodium(results: EnrichedResult[]): PodiumPlace[] {
return results
.filter((r) => r.position >= 1 && r.position <= 3 && !r.dns && !r.dsq)
.sort((a, b) => a.position - b.position)
.map((r) => ({
position: r.position,
name_acronym: r.name_acronym,
full_name: r.full_name,
team_name: r.team_name,
team_colour: r.team_colour,
}))
}
export function extractPole(grid: EnrichedGrid[]): PolePosition | null {
const pole = grid.find((g) => g.position === 1)
if (!pole) return null
return {
name_acronym: pole.name_acronym,
full_name: pole.full_name,
team_name: pole.team_name,
team_colour: pole.team_colour,
}
}
export interface TitleFightDriver {
name_acronym: string
full_name: string
team_colour: string
points: number
position: number
gapToLeader: number
gapAfterRaceWin: number
gapAfterSprintWeekendMax: number
}
export function buildTitleFightContext(hub: ChampionshipHub): TitleFightDriver[] {
if (hub.drivers.length === 0) return []
const leader = hub.drivers[0]
return hub.drivers.slice(0, 3).map((d) => ({
name_acronym: d.name_acronym,
full_name: d.full_name,
team_colour: d.team_colour,
points: d.points,
position: d.position,
gapToLeader: leader.points - d.points,
gapAfterRaceWin: leader.points - (d.points + RACE_WIN_POINTS),
gapAfterSprintWeekendMax: leader.points - (d.points + SPRINT_WEEKEND_MAX_POINTS),
}))
}
export function countdownTargetSession(sessions: Session[], now: Date): Session | null {
const sorted = sortSessionsByStart(sessions)
const { next } = currentAndNextSession(sorted, now)
if (next) return next
for (const session of sorted) {
const start = sessionStartTime(session)
if (start && start > now) return session
}
return null
}
export function formatPointsGap(gap: number): string {
if (gap === 0) return 'LEADER'
const sign = gap > 0 ? '+' : ''
return `${sign}${Number.isInteger(gap) ? gap : gap.toFixed(1)}`
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { fetchLiveState, fetchLiveTrackOutline } from '../api' import { fetchLiveState } from '../api'
import type { LivePosition, LiveStreamData } from '../types' import type { LivePosition, LiveStreamData } from '../types'
import { import {
loadPinnedDrivers, loadPinnedDrivers,
@@ -25,14 +25,17 @@ import { PinnedDrivers } from '../components/live/PinnedDrivers'
import { RaceControlFeed } from '../components/live/RaceControlFeed' import { RaceControlFeed } from '../components/live/RaceControlFeed'
import { EventRail } from '../components/live/EventRail' import { EventRail } from '../components/live/EventRail'
import { TeamRadioTicker } from '../components/live/TeamRadioTicker' import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
import { TrackMap } from '../components/live/TrackMap'
import { TyreDegPanel } from '../components/live/TyreDegPanel' import { TyreDegPanel } from '../components/live/TyreDegPanel'
import { Radio } from 'lucide-react' import { Archive, Radio } from 'lucide-react'
type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error' type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
export function LiveTimingPage() { export function LiveTimingPage() {
const [snapshot, setSnapshot] = useState<LiveStreamData | null>(null) const [activeSnapshot, setActiveSnapshot] = useState<LiveStreamData | null>(null)
const [archiveSnapshot, setArchiveSnapshot] = useState<LiveStreamData | null>(null)
const [archivePositions, setArchivePositions] = useState<Record<string, LivePosition>>({})
const [archiveSnapshotAt, setArchiveSnapshotAt] = useState<string | null>(null)
const [archiveMode, setArchiveMode] = useState(false)
const [isLive, setIsLive] = useState(false) const [isLive, setIsLive] = useState(false)
const [streamStatus, setStreamStatus] = useState<StreamStatus>('connecting') const [streamStatus, setStreamStatus] = useState<StreamStatus>('connecting')
const [now, setNow] = useState(Date.now()) const [now, setNow] = useState(Date.now())
@@ -43,6 +46,10 @@ export function LiveTimingPage() {
const [events, setEvents] = useState<LiveEvent[]>([]) const [events, setEvents] = useState<LiveEvent[]>([])
const prevSnapshotRef = useRef<LiveStreamData | null>(null) const prevSnapshotRef = useRef<LiveStreamData | null>(null)
const sessionSigRef = useRef('') const sessionSigRef = useRef('')
const isLiveRef = useRef(false)
const archiveModeRef = useRef(false)
const snapshot = isLive ? activeSnapshot : archiveMode ? archiveSnapshot : null
const hasArchive = Boolean(archiveSnapshot)
const { data, isLoading, isError, error } = useQuery({ const { data, isLoading, isError, error } = useQuery({
queryKey: ['live-state'], queryKey: ['live-state'],
@@ -50,24 +57,31 @@ export function LiveTimingPage() {
staleTime: 5_000, staleTime: 5_000,
}) })
const trackOutlineQuery = useQuery({
queryKey: [
'live-track-outline',
snapshot?.Session?.MeetingName ?? '',
snapshot?.Session?.CircuitName ?? '',
],
queryFn: () => fetchLiveTrackOutline(snapshot!.Session),
enabled: Boolean(snapshot?.Session?.MeetingName || snapshot?.Session?.CircuitName),
staleTime: Infinity,
retry: false,
})
useEffect(() => { useEffect(() => {
if (!data) return if (!data) return
setIsLive(data.is_live) const nextLive = data.is_live && Boolean(data.data)
setSnapshot(data.data) setIsLive(nextLive)
isLiveRef.current = nextLive
if (nextLive && data.data) {
setActiveSnapshot(data.data)
setArchiveMode(false)
return
}
setActiveSnapshot(null)
setArchiveSnapshot(data.last_snapshot ?? null)
setArchivePositions(data.last_positions ?? {})
setArchiveSnapshotAt(data.last_snapshot_at ?? null)
setArchiveMode((current) => current && Boolean(data.last_snapshot))
}, [data]) }, [data])
useEffect(() => {
archiveModeRef.current = archiveMode
if (archiveMode) {
setPositions(archivePositions)
}
}, [archiveMode, archivePositions])
useEffect(() => { useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000) const timer = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(timer) return () => window.clearInterval(timer)
@@ -90,8 +104,23 @@ export function LiveTimingPage() {
events.addEventListener('snapshot', (event) => { events.addEventListener('snapshot', (event) => {
const state = parseLiveStateEvent(event.data) const state = parseLiveStateEvent(event.data)
if (!state || cancelled) return if (!state || cancelled) return
setIsLive(state.is_live) const nextLive = state.is_live && Boolean(state.data)
setSnapshot(state.data) setIsLive(nextLive)
if (nextLive && state.data) {
if (!isLiveRef.current || archiveModeRef.current) {
setPositions({})
}
isLiveRef.current = true
setActiveSnapshot(state.data)
setArchiveMode(false)
} else {
isLiveRef.current = false
setActiveSnapshot(null)
setArchiveSnapshot(state.last_snapshot ?? null)
setArchivePositions(state.last_positions ?? {})
setArchiveSnapshotAt(state.last_snapshot_at ?? null)
setArchiveMode((current) => current && Boolean(state.last_snapshot))
}
setStreamStatus('connected') setStreamStatus('connected')
}) })
@@ -174,6 +203,19 @@ export function LiveTimingPage() {
setPinned((prev) => togglePin(prev, racingNumber)) setPinned((prev) => togglePin(prev, racingNumber))
} }
const handleViewArchive = () => {
if (!archiveSnapshot) return
setIsLive(false)
setArchiveMode(true)
setPositions(archivePositions)
}
const archiveTimestamp = archiveSnapshotAt ? new Date(archiveSnapshotAt) : null
const archiveLabel =
archiveTimestamp && !Number.isNaN(archiveTimestamp.getTime())
? `Archived snapshot from ${archiveTimestamp.toLocaleString()}`
: 'Archived live timing snapshot'
return ( return (
<div className="page live-page" data-testid="live-page"> <div className="page live-page" data-testid="live-page">
{isError && ( {isError && (
@@ -182,12 +224,18 @@ export function LiveTimingPage() {
</div> </div>
)} )}
{streamStatus === 'disconnected' && snapshot && ( {streamStatus === 'disconnected' && snapshot && !archiveMode && (
<div className="live-status-strip live-status-warn"> <div className="live-status-strip live-status-warn">
Stream disconnected showing last received snapshot Stream disconnected showing last received snapshot
</div> </div>
)} )}
{archiveMode && snapshot && (
<div className="live-status-strip live-status-archive" data-testid="live-archive-strip">
{archiveLabel} live updates are paused for this archive view
</div>
)}
{isLoading && !snapshot && ( {isLoading && !snapshot && (
<div className="loading-state">connecting to live timing</div> <div className="loading-state">connecting to live timing</div>
)} )}
@@ -202,22 +250,27 @@ export function LiveTimingPage() {
<p className="empty-state-desc" style={{ color: 'var(--text-2)' }}> <p className="empty-state-desc" style={{ color: 'var(--text-2)' }}>
The telemetry feed is currently offline. <br /><br /> Check the <a href="/" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Command Center</a> for the weekend schedule or explore historical data in the <a href="/race-hub" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Race Hub</a>. The telemetry feed is currently offline. <br /><br /> Check the <a href="/" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Command Center</a> for the weekend schedule or explore historical data in the <a href="/race-hub" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Race Hub</a>.
</p> </p>
{hasArchive && (
<button type="button" className="live-archive-btn" onClick={handleViewArchive}>
<Archive size={15} />
View Last Session
</button>
)}
</div> </div>
)} )}
{snapshot && ( {snapshot && (
<> <>
<SessionBanner isLive={isLive} snapshot={snapshot} rows={rows} connection={streamStatus} now={now} /> <SessionBanner
isLive={isLive}
isArchive={archiveMode}
snapshot={snapshot}
rows={rows}
connection={streamStatus}
now={now}
/>
<TrackStatusBanner status={snapshot.TrackStatus} /> <TrackStatusBanner status={snapshot.TrackStatus} />
<PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} /> <PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} />
<TrackMap
outline={trackOutlineQuery.data}
positions={positions}
telemetry={snapshot.Telemetry}
drivers={snapshot.Drivers}
driverInfo={snapshot.DriverInfo}
loading={trackOutlineQuery.isLoading}
/>
<TyreDegPanel rows={rows} sessionType={snapshot.Session?.SessionType} pinned={pinned} /> <TyreDegPanel rows={rows} sessionType={snapshot.Session?.SessionType} pinned={pinned} />
<div className="live-columns"> <div className="live-columns">
<div className="live-tower-col"> <div className="live-tower-col">

View File

@@ -0,0 +1,442 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
fetchChampionshipHub,
fetchMeetings,
fetchResults,
fetchSeasons,
fetchSessions,
fetchStartingGrid,
fetchTrackOutline,
} from '../api'
import { countryAccent, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
import {
buildTitleFightContext,
countdownTargetSession,
extractPodium,
extractPole,
findPriorYearMeetingByCircuit,
findRaceSession,
formatPointsGap,
isSprintWeekend,
pickPreviewMeeting,
RACE_WIN_POINTS,
SPRINT_WEEKEND_MAX_POINTS,
} from '../lib/preview'
import { formatCountdown, formatSessionScheduleTime, sessionStartTime, sortSessionsByStart } from '../lib/schedule'
import { buildOutlinePath } from '../lib/trackmap'
import { teamColor } from '../utils'
import type { Meeting, Session, TrackOutline } from '../types'
import '../styles/preview.css'
function SectionState({
loading,
error,
empty,
emptyMessage,
children,
}: {
loading?: boolean
error?: Error | null
empty?: boolean
emptyMessage?: string
children: ReactNode
}) {
if (loading) {
return <div className="preview-section-state">Loading</div>
}
if (error) {
return (
<div className="preview-section-state error">
{error instanceof Error ? error.message : 'Failed to load'}
</div>
)
}
if (empty) {
return <div className="preview-section-state">{emptyMessage ?? 'No data available'}</div>
}
return <>{children}</>
}
function TrackOutlineCard({
outline,
loading,
error,
accent,
}: {
outline: TrackOutline | null | undefined
loading: boolean
error: Error | null
accent: string
}) {
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
return (
<section className="preview-card" data-testid="preview-track-card">
<h2 className="preview-card-title">Circuit</h2>
<SectionState
loading={loading}
error={error}
empty={!outline || !outlinePath}
emptyMessage="Track outline unavailable for this circuit"
>
<div className="preview-track-stage" style={{ ['--preview-accent' as string]: accent }}>
<svg className="preview-track-svg" viewBox="0 0 100 100" role="img" aria-label="Circuit outline">
<path className="preview-track-shadow" d={outlinePath} />
<path className="preview-track-outline" d={outlinePath} />
</svg>
</div>
</SectionState>
</section>
)
}
function LastYearCard({
year,
loading,
error,
podium,
pole,
isFirstTime,
}: {
year: number | null
loading: boolean
error: Error | null
podium: ReturnType<typeof extractPodium>
pole: ReturnType<typeof extractPole>
isFirstTime: boolean
}) {
return (
<section className="preview-card" data-testid="preview-last-year-card">
<h2 className="preview-card-title">Last year here</h2>
{year != null && !isFirstTime && <p className="preview-card-sub">{year} race weekend</p>}
<SectionState
loading={loading}
error={error}
empty={isFirstTime || podium.length === 0}
emptyMessage={
isFirstTime
? 'First time on the calendar — no prior race at this circuit'
: 'No race results available for the previous visit'
}
>
<div className="preview-podium">
{podium.map((place) => (
<div key={place.position} className="preview-podium-row">
<span className="preview-podium-pos">P{place.position}</span>
<span className="preview-podium-code" style={{ color: teamColor(place.team_colour) }}>
{place.name_acronym}
</span>
<span className="preview-podium-team">{place.team_name}</span>
</div>
))}
</div>
{pole && (
<p className="preview-pole">
Pole: <strong style={{ color: teamColor(pole.team_colour) }}>{pole.name_acronym}</strong>{' '}
({pole.team_name})
</p>
)}
</SectionState>
</section>
)
}
function TitleFightCard({
loading,
error,
drivers,
sprintWeekend,
season,
}: {
loading: boolean
error: Error | null
drivers: ReturnType<typeof buildTitleFightContext>
sprintWeekend: boolean
season: number | null
}) {
return (
<section className="preview-card preview-card--wide" data-testid="preview-title-fight-card">
<h2 className="preview-card-title">Title fight context</h2>
{season != null && <p className="preview-card-sub">{season} drivers&apos; championship · top 3</p>}
<SectionState
loading={loading}
error={error}
empty={drivers.length === 0}
emptyMessage="Championship standings not available"
>
<table className="preview-title-table">
<thead>
<tr>
<th>Driver</th>
<th>Pts</th>
<th>Gap</th>
<th>If win (+{RACE_WIN_POINTS})</th>
</tr>
</thead>
<tbody>
{drivers.map((d) => (
<tr key={d.name_acronym}>
<td>
<span style={{ color: teamColor(d.team_colour), fontWeight: 600 }}>{d.name_acronym}</span>
</td>
<td className="mono">{d.points}</td>
<td className="mono">{formatPointsGap(d.gapToLeader)}</td>
<td className="mono">
{d.position === 1 ? 'LEADER' : formatPointsGap(d.gapAfterRaceWin)}
</td>
</tr>
))}
</tbody>
</table>
{sprintWeekend && (
<p className="preview-sprint-note" data-testid="preview-sprint-note">
Sprint weekend: up to {SPRINT_WEEKEND_MAX_POINTS} pts available (race win {RACE_WIN_POINTS} + sprint win
8). Max gain would reduce gaps by {SPRINT_WEEKEND_MAX_POINTS} vs a standard race win.
</p>
)}
</SectionState>
</section>
)
}
function PreviewHeader({
meeting,
sessions,
countdownSession,
now,
accent,
}: {
meeting: Meeting
sessions: Session[]
countdownSession: Session | null
now: Date
accent: string
}) {
const countdownTarget = countdownSession ? sessionStartTime(countdownSession) : null
return (
<header
className="preview-header"
data-testid="preview-header"
style={{ ['--preview-accent' as string]: accent }}
>
<div className="preview-header-accent" />
<div className="preview-header-body">
<div className="preview-header-top">
<div>
<h1 className="preview-gp-name">
{countryFlag(meeting) && <span className="preview-gp-flag">{countryFlag(meeting)}</span>}
{meeting.meeting_name}
</h1>
<p className="preview-circuit">
{meeting.circuit_short_name}
{meeting.location ? ` · ${meeting.location}` : ''}
{formatGpDateRange(meeting) ? ` · ${formatGpDateRange(meeting)}` : ''}
</p>
</div>
{countdownTarget && (
<div className="preview-countdown" data-testid="preview-countdown">
<span className="preview-countdown-label">
{countdownSession?.session_name ?? 'Next session'}
</span>
<span className="preview-countdown-value">{formatCountdown(countdownTarget, now)}</span>
</div>
)}
</div>
{sessions.length > 0 && (
<div className="preview-schedule" data-testid="preview-schedule">
{sessions.map((session) => (
<div key={session.session_key} className="preview-schedule-item">
<span className="preview-schedule-name">{session.session_name}</span>
<span className="preview-schedule-time">{formatSessionScheduleTime(session.date_start)}</span>
</div>
))}
</div>
)}
</div>
</header>
)
}
export function RacePreviewPage() {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [])
const nowDate = useMemo(() => new Date(now), [now])
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: fetchSeasons,
})
const latestSeason = seasonsQuery.data?.[0] ?? null
const meetingsQuery = useQuery({
queryKey: ['meetings', latestSeason, 'auto'],
queryFn: () => fetchMeetings(latestSeason!, 'auto'),
enabled: latestSeason != null,
})
const previewMeeting = useMemo(
() => pickPreviewMeeting(meetingsQuery.data ?? [], nowDate),
[meetingsQuery.data, nowDate],
)
const sessionsQuery = useQuery({
queryKey: ['sessions', previewMeeting?.meeting_key, 'auto'],
queryFn: () => fetchSessions(previewMeeting!.meeting_key, 'auto'),
enabled: previewMeeting != null,
})
const sessions = useMemo(
() => sortSessionsByStart(sessionsQuery.data ?? []),
[sessionsQuery.data],
)
const countdownSession = useMemo(() => countdownTargetSession(sessions, nowDate), [sessions, nowDate])
const sprintWeekend = useMemo(() => isSprintWeekend(sessions), [sessions])
const priorYear = previewMeeting ? previewMeeting.year - 1 : null
const priorMeetingsQuery = useQuery({
queryKey: ['meetings', priorYear, 'auto'],
queryFn: () => fetchMeetings(priorYear!, 'auto'),
enabled: priorYear != null && previewMeeting != null,
})
const priorMeeting = useMemo(
() => findPriorYearMeetingByCircuit(priorMeetingsQuery.data ?? [], previewMeeting?.circuit_key),
[priorMeetingsQuery.data, previewMeeting?.circuit_key],
)
const priorSessionsQuery = useQuery({
queryKey: ['sessions', priorMeeting?.meeting_key, 'auto'],
queryFn: () => fetchSessions(priorMeeting!.meeting_key, 'auto'),
enabled: priorMeeting != null,
})
const priorRaceSession = useMemo(
() => findRaceSession(priorSessionsQuery.data ?? []),
[priorSessionsQuery.data],
)
const priorResultsQuery = useQuery({
queryKey: ['results', priorRaceSession?.session_key, 'auto'],
queryFn: () => fetchResults(priorRaceSession!.session_key, 'auto'),
enabled: priorRaceSession != null,
})
const priorGridQuery = useQuery({
queryKey: ['grid', priorRaceSession?.session_key, 'auto'],
queryFn: () => fetchStartingGrid(priorRaceSession!.session_key, 'auto'),
enabled: priorRaceSession != null,
})
const trackOutlineQuery = useQuery({
queryKey: ['track-outline', previewMeeting?.circuit_key, previewMeeting?.year],
queryFn: () => fetchTrackOutline(previewMeeting!.circuit_key!, previewMeeting!.year),
enabled: previewMeeting?.circuit_key != null && previewMeeting.circuit_key > 0,
})
const championshipQuery = useQuery({
queryKey: ['championship-hub', latestSeason],
queryFn: () => fetchChampionshipHub(latestSeason!),
enabled: latestSeason != null,
})
const podium = useMemo(() => extractPodium(priorResultsQuery.data ?? []), [priorResultsQuery.data])
const pole = useMemo(() => extractPole(priorGridQuery.data ?? []), [priorGridQuery.data])
const titleFight = useMemo(
() => (championshipQuery.data ? buildTitleFightContext(championshipQuery.data) : []),
[championshipQuery.data],
)
const accent = countryAccent(previewMeeting)
const isFirstTimeCircuit = priorMeeting == null && priorYear != null && !priorMeetingsQuery.isLoading
if (seasonsQuery.isLoading || meetingsQuery.isLoading) {
return <div className="page loading-state" data-testid="preview-loading">loading preview</div>
}
if (seasonsQuery.isError || meetingsQuery.isError) {
const err = seasonsQuery.error ?? meetingsQuery.error
return (
<div className="page error-box" data-testid="preview-error">
{err instanceof Error ? err.message : 'Failed to load preview'}
</div>
)
}
if (!previewMeeting) {
return (
<div className="preview-page" data-testid="preview-season-over">
<div className="preview-empty">
<span className="preview-empty-eyebrow mono">box-box · race preview</span>
<h1 className="preview-empty-title">Season complete</h1>
<p className="preview-empty-sub">
No upcoming races on the {latestSeason ?? 'current'} calendar. Check back when the next season schedule is
published.
</p>
</div>
{titleFight.length > 0 && (
<TitleFightCard
loading={championshipQuery.isLoading}
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
drivers={titleFight}
sprintWeekend={false}
season={latestSeason}
/>
)}
</div>
)
}
return (
<div className="preview-page" data-testid="preview-page">
<PreviewHeader
meeting={previewMeeting}
sessions={sessions}
countdownSession={countdownSession}
now={nowDate}
accent={accent}
/>
<div className="preview-grid">
<TrackOutlineCard
outline={trackOutlineQuery.data}
loading={trackOutlineQuery.isLoading}
error={trackOutlineQuery.isError ? (trackOutlineQuery.error as Error) : null}
accent={accent}
/>
<LastYearCard
year={priorMeeting?.year ?? priorYear}
loading={priorMeetingsQuery.isLoading || priorSessionsQuery.isLoading || priorResultsQuery.isLoading}
error={
priorMeetingsQuery.isError
? (priorMeetingsQuery.error as Error)
: priorResultsQuery.isError
? (priorResultsQuery.error as Error)
: null
}
podium={podium}
pole={pole}
isFirstTime={isFirstTimeCircuit}
/>
</div>
<TitleFightCard
loading={championshipQuery.isLoading}
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
drivers={titleFight}
sprintWeekend={sprintWeekend}
season={latestSeason}
/>
</div>
)
}

View File

@@ -6,6 +6,7 @@ import { DataLibraryPage } from './pages/DataLibraryPage'
import { LiveTimingPage } from './pages/LiveTimingPage' import { LiveTimingPage } from './pages/LiveTimingPage'
import { BriefingPage } from './pages/BriefingPage' import { BriefingPage } from './pages/BriefingPage'
import { ChampionshipPage } from './pages/ChampionshipPage' import { ChampionshipPage } from './pages/ChampionshipPage'
import { RacePreviewPage } from './pages/RacePreviewPage'
type RaceHubSearch = { type RaceHubSearch = {
session_key?: number session_key?: number
@@ -70,6 +71,12 @@ export const briefingRoute = createRoute({
component: BriefingPage, component: BriefingPage,
}) })
export const previewRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/preview',
component: RacePreviewPage,
})
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
commandCenterRoute, commandCenterRoute,
raceHubRoute, raceHubRoute,
@@ -78,6 +85,7 @@ const routeTree = rootRoute.addChildren([
liveTimingRoute, liveTimingRoute,
championshipRoute, championshipRoute,
briefingRoute, briefingRoute,
previewRoute,
]) ])
export const router = createRouter({ routeTree }) export const router = createRouter({ routeTree })

View File

@@ -808,6 +808,12 @@ a { color: inherit; text-decoration: none; }
color: var(--yellow); color: var(--yellow);
} }
.live-status-archive {
background: rgba(70, 140, 255, 0.08);
border: 1px solid rgba(70, 140, 255, 0.24);
color: #8bb7ff;
}
/* ── Live empty state ── */ /* ── Live empty state ── */
.live-empty-status { .live-empty-status {
display: flex; display: flex;
@@ -815,6 +821,29 @@ a { color: inherit; text-decoration: none; }
margin-bottom: var(--s4); margin-bottom: var(--s4);
} }
.live-archive-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--s2);
margin-top: var(--s5);
min-height: 36px;
padding: 0 var(--s4);
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
color: var(--text);
font-family: var(--f-mono);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.live-archive-btn:hover {
border-color: rgba(255, 255, 255, 0.28);
background: rgba(255, 255, 255, 0.1);
}
.mono { font-family: var(--f-mono); } .mono { font-family: var(--f-mono); }
.live-conn, .live-conn,

View File

@@ -0,0 +1,309 @@
.preview-page {
display: flex;
flex-direction: column;
gap: var(--s6);
padding: var(--s6);
max-width: 1200px;
margin: 0 auto;
}
.preview-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--s4);
min-height: 320px;
text-align: center;
padding: var(--s8);
background: var(--surface);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
}
.preview-empty-eyebrow {
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-3);
}
.preview-empty-title {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
.preview-empty-sub {
color: var(--text-2);
max-width: 36rem;
margin: 0;
}
.preview-header {
display: flex;
background: var(--surface);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
overflow: hidden;
min-height: 140px;
}
.preview-header-accent {
width: 4px;
flex-shrink: 0;
background: var(--preview-accent, var(--gp-accent));
box-shadow: 0 0 12px var(--preview-accent, var(--gp-accent));
}
.preview-header-body {
flex: 1;
padding: var(--s5);
display: flex;
flex-direction: column;
gap: var(--s4);
min-width: 0;
}
.preview-header-top {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: var(--s4);
}
.preview-gp-name {
font-size: 1.4rem;
font-weight: 600;
margin: 0;
display: flex;
align-items: center;
gap: var(--s3);
}
.preview-gp-flag {
font-size: 1.5rem;
line-height: 1;
}
.preview-circuit {
color: var(--text-2);
font-size: 0.9rem;
margin: 0;
}
.preview-countdown {
text-align: right;
}
.preview-countdown-label {
display: block;
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-3);
margin-bottom: var(--s1);
}
.preview-countdown-value {
font-family: var(--mono);
font-size: 1.1rem;
font-variant-numeric: tabular-nums;
color: var(--text-1);
}
.preview-schedule {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--s2);
}
.preview-schedule-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--s2) var(--s3);
background: rgba(255, 255, 255, 0.03);
border-radius: 4px;
font-size: 0.8rem;
}
.preview-schedule-name {
font-weight: 600;
color: var(--text-1);
}
.preview-schedule-time {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--text-2);
}
.preview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--s5);
}
.preview-card {
background: var(--surface);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: var(--s5);
display: flex;
flex-direction: column;
gap: var(--s4);
min-height: 200px;
}
.preview-card-title {
font-size: 0.75rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-3);
margin: 0;
}
.preview-card-sub {
font-size: 0.85rem;
color: var(--text-2);
margin: calc(-1 * var(--s2)) 0 0;
}
.preview-track-stage {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
min-height: 180px;
padding: var(--s4);
}
.preview-track-svg {
width: 100%;
max-width: 280px;
height: auto;
}
.preview-track-outline {
fill: none;
stroke: var(--preview-accent, var(--gp-accent));
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0.9;
}
.preview-track-shadow {
fill: none;
stroke: rgba(0, 0, 0, 0.5);
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
.preview-podium {
display: flex;
flex-direction: column;
gap: var(--s3);
}
.preview-podium-row {
display: flex;
align-items: center;
gap: var(--s3);
font-size: 0.9rem;
}
.preview-podium-pos {
width: 1.5rem;
font-family: var(--mono);
font-weight: 600;
color: var(--text-3);
}
.preview-podium-code {
font-weight: 600;
min-width: 2.5rem;
}
.preview-podium-team {
color: var(--text-2);
font-size: 0.8rem;
}
.preview-pole {
padding-top: var(--s3);
border-top: 1px solid rgba(255, 255, 255, 0.06);
font-size: 0.85rem;
color: var(--text-2);
}
.preview-pole strong {
color: var(--text-1);
}
.preview-title-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.preview-title-table th,
.preview-title-table td {
padding: var(--s2) var(--s3);
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.preview-title-table th {
font-size: 0.7rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
font-weight: 500;
}
.preview-title-table td.mono {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
}
.preview-sprint-note {
font-size: 0.8rem;
color: var(--text-2);
margin: 0;
padding: var(--s3);
background: rgba(255, 255, 255, 0.03);
border-radius: 4px;
}
.preview-section-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-3);
font-size: 0.85rem;
text-align: center;
padding: var(--s5);
}
.preview-section-state.error {
color: var(--red, #e74c3c);
}
@media (max-width: 640px) {
.preview-page {
padding: var(--s4);
}
.preview-header-top {
flex-direction: column;
}
.preview-countdown {
text-align: left;
}
}

View File

@@ -40,6 +40,13 @@
stroke-dasharray: 2 1; stroke-dasharray: 2 1;
} }
.stint-timeline__pit-marker {
stroke: var(--text);
stroke-width: 1.5;
opacity: 0.7;
pointer-events: stroke;
}
.stint-timeline__axis-tick { .stint-timeline__axis-tick {
font-family: var(--f-mono); font-family: var(--f-mono);
font-size: 9px; font-size: 9px;

View File

@@ -0,0 +1,166 @@
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 { LiveTimingPage } from '../pages/LiveTimingPage'
import type { LiveStateResponse, LiveStreamData } from '../types'
vi.mock('../api', () => ({
fetchLiveState: vi.fn(),
fetchLiveTrackOutline: vi.fn(),
}))
import { fetchLiveState, fetchLiveTrackOutline } from '../api'
const mockFetchLiveState = vi.mocked(fetchLiveState)
const mockFetchLiveTrackOutline = vi.mocked(fetchLiveTrackOutline)
class MockEventSource {
onopen: (() => void) | null = null
onerror: (() => void) | null = null
constructor() {
setTimeout(() => this.onopen?.(), 0)
}
addEventListener() {}
close() {}
}
const archivedSnapshot: LiveStreamData = {
Drivers: {
'1': {
RacingNumber: '1',
Position: 1,
PrevPosition: 1,
GapToLeader: '',
Interval: '',
LastLapTime: '1:21.345',
LastLapPB: false,
LastLapOB: false,
BestLapTime: '1:20.987',
BestLapPB: false,
BestLapOB: false,
BestLapNum: 22,
InPit: false,
PitOut: false,
Retired: false,
KnockedOut: false,
Cutoff: false,
OnFlyingLap: false,
NumberOfLaps: 30,
SpeedTrap: '',
Sectors: [],
},
},
DriverInfo: {
'1': {
RacingNumber: '1',
BroadcastName: 'M VERSTAPPEN',
Tla: 'VER',
TeamName: 'Red Bull Racing',
TeamColour: '3671C6',
FirstName: 'Max',
LastName: 'Verstappen',
},
},
Tyres: {
'1': { Compound: 'HARD', New: false, Age: 12 },
},
Telemetry: {},
RCMessages: [],
Weather: {
AirTemp: 22,
TrackTemp: 41,
Humidity: 58,
WindSpeed: 3,
WindDir: 180,
Rainfall: false,
},
Session: {
MeetingName: 'Testonia Grand Prix',
CircuitName: 'Testring',
SessionType: 'Race',
SessionName: 'Race',
Path: '',
},
TeamRadio: [],
SessionStatus: 'Finished',
TrackStatus: '1',
CurrentLap: 57,
TotalLaps: 57,
Clock: '',
ClockRefTime: '',
ClockExtrapolating: false,
Stints: {},
}
function renderPage(response: LiveStateResponse) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
mockFetchLiveState.mockResolvedValue(response)
mockFetchLiveTrackOutline.mockResolvedValue({
circuit_key: 1,
points: [],
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
})
return render(
<QueryClientProvider client={queryClient}>
<LiveTimingPage />
</QueryClientProvider>,
)
}
describe('LiveTimingPage archive mode', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.defineProperty(window, 'EventSource', {
value: MockEventSource,
writable: true,
configurable: true,
})
})
it('keeps archived snapshots behind the View Last Session action', async () => {
renderPage({
is_live: false,
data: null,
last_snapshot: archivedSnapshot,
last_positions: {
'1': { x: 10, y: 20, z: 0, status: 'OnTrack' },
},
last_snapshot_at: '2026-07-04T14:00:00Z',
})
expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active')
expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /view last session/i }))
await waitFor(() => {
expect(screen.getByTestId('live-archive-strip')).toHaveTextContent('Archived snapshot')
})
expect(screen.getByText('Timing Tower')).toBeInTheDocument()
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
expect(screen.getByText('archive')).toBeInTheDocument()
})
it('does not show the archive action when no snapshot is retained', async () => {
renderPage({ is_live: false, data: null })
expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active')
expect(screen.queryByRole('button', { name: /view last session/i })).not.toBeInTheDocument()
})
it('temporarily omits the track map while live GPS is unavailable', async () => {
renderPage({
is_live: true,
data: { ...archivedSnapshot, SessionStatus: 'Started' },
})
expect(await screen.findByText('Timing Tower')).toBeInTheDocument()
expect(screen.queryByText('Track Map')).not.toBeInTheDocument()
expect(mockFetchLiveTrackOutline).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,276 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
import { RacePreviewPage } from '../pages/RacePreviewPage'
import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session, TrackOutline } from '../types'
vi.mock('../api', () => ({
fetchSeasons: vi.fn(),
fetchMeetings: vi.fn(),
fetchSessions: vi.fn(),
fetchResults: vi.fn(),
fetchStartingGrid: vi.fn(),
fetchTrackOutline: vi.fn(),
fetchChampionshipHub: vi.fn(),
}))
import {
fetchSeasons,
fetchMeetings,
fetchSessions,
fetchResults,
fetchStartingGrid,
fetchTrackOutline,
fetchChampionshipHub,
} from '../api'
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchMeetings = vi.mocked(fetchMeetings)
const mockFetchSessions = vi.mocked(fetchSessions)
const mockFetchResults = vi.mocked(fetchResults)
const mockFetchStartingGrid = vi.mocked(fetchStartingGrid)
const mockFetchTrackOutline = vi.mocked(fetchTrackOutline)
const mockFetchChampionshipHub = vi.mocked(fetchChampionshipHub)
const upcomingMeeting: Meeting = {
meeting_key: 100,
meeting_name: 'Monaco',
meeting_official_name: 'Monaco GP',
location: 'Monaco',
country_name: 'Monaco',
country_code: 'MON',
country_flag: '',
circuit_key: 10,
circuit_short_name: 'Monaco',
date_start: '2099-05-22T00:00:00+00:00',
date_end: '2099-05-24T23:59:59+00:00',
year: 2099,
}
const priorMeeting: Meeting = {
...upcomingMeeting,
meeting_key: 90,
year: 2098,
date_start: '2098-05-22T00:00:00+00:00',
date_end: '2098-05-24T23:59:59+00:00',
}
const sessions: Session[] = [
{
session_key: 1,
session_name: 'FP1',
session_type: 'Practice',
meeting_key: 100,
date_start: '2099-05-22T10:00:00+00:00',
date_end: '2099-05-22T11:00:00+00:00',
gmt_offset: '02:00:00',
},
{
session_key: 2,
session_name: 'Sprint',
session_type: 'Sprint',
meeting_key: 100,
date_start: '2099-05-23T10:00:00+00:00',
date_end: '2099-05-23T11:00:00+00:00',
gmt_offset: '02:00:00',
},
{
session_key: 3,
session_name: 'Race',
session_type: 'Race',
meeting_key: 100,
date_start: '2099-05-24T13:00:00+00:00',
date_end: '2099-05-24T15:00:00+00:00',
gmt_offset: '02:00:00',
},
]
const priorRaceSession: Session = {
session_key: 50,
session_name: 'Race',
session_type: 'Race',
meeting_key: 90,
date_start: '2098-05-24T13:00:00+00:00',
date_end: '2098-05-24T15:00:00+00:00',
gmt_offset: '02:00:00',
}
const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
points: 200,
position: 1,
wins: 5,
podiums: 8,
poles: 4,
form: [25],
cumulative: [200],
teammate_wins: 9,
teammate_losses: 1,
...over,
})
const hub: ChampionshipHub = {
season: 2099,
round: 5,
total_rounds: 24,
rounds_left: 19,
last_race: 'Monaco GP',
round_labels: ['R1'],
drivers: [
hubDriver({}),
hubDriver({ driver_number: 4, name_acronym: 'NOR', points: 160, position: 2 }),
hubDriver({ driver_number: 16, name_acronym: 'LEC', points: 120, position: 3 }),
],
teams: [],
}
const results: EnrichedResult[] = [
{
driver_number: 1,
position: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
dnf: false,
dns: false,
dsq: false,
duration: 100,
gap_to_leader: 0,
number_of_laps: 78,
points: 25,
session_key: 50,
meeting_key: 90,
},
]
const grid: EnrichedGrid[] = [
{
driver_number: 4,
position: 1,
name_acronym: 'NOR',
full_name: 'Lando Norris',
team_name: 'McLaren',
team_colour: 'ff8000',
session_key: 50,
meeting_key: 90,
lap_duration: 70,
},
]
const outline: TrackOutline = {
circuit_key: 10,
points: [
{ x: 0.1, y: 0.2 },
{ x: 0.5, y: 0.5 },
{ x: 0.9, y: 0.8 },
],
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
}
function renderPage() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const rootRoute = createRootRoute({
component: () => (
<QueryClientProvider client={queryClient}>
<RacePreviewPage />
</QueryClientProvider>
),
})
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: RacePreviewPage })
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
return render(<RouterProvider router={router} />)
}
describe('RacePreviewPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchSeasons.mockResolvedValue([2099])
mockFetchChampionshipHub.mockResolvedValue(hub)
mockFetchTrackOutline.mockResolvedValue(outline)
mockFetchResults.mockResolvedValue(results)
mockFetchStartingGrid.mockResolvedValue(grid)
})
it('renders upcoming race preview with sections', async () => {
mockFetchMeetings.mockImplementation(async (year: number) => {
if (year === 2099) return [upcomingMeeting]
if (year === 2098) return [priorMeeting]
return []
})
mockFetchSessions.mockImplementation(async (meetingKey: number) => {
if (meetingKey === 100) return sessions
if (meetingKey === 90) return [priorRaceSession]
return []
})
renderPage()
await waitFor(() => {
expect(screen.getByTestId('preview-page')).toBeInTheDocument()
})
await waitFor(() => {
expect(screen.getByTestId('preview-countdown')).toBeInTheDocument()
})
expect(screen.getByTestId('preview-header')).toHaveTextContent('Monaco')
expect(screen.getByTestId('preview-schedule')).toHaveTextContent('FP1')
expect(screen.getByTestId('preview-track-card')).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('VER')
})
expect(screen.getByTestId('preview-title-fight-card')).toHaveTextContent('NOR')
expect(screen.getByTestId('preview-sprint-note')).toBeInTheDocument()
})
it('renders season-over state when no upcoming meeting', async () => {
mockFetchMeetings.mockResolvedValue([
{
...upcomingMeeting,
date_start: '2020-05-22T00:00:00+00:00',
date_end: '2020-05-24T23:59:59+00:00',
},
])
renderPage()
await waitFor(() => {
expect(screen.getByTestId('preview-season-over')).toBeInTheDocument()
})
expect(screen.getByText('Season complete')).toBeInTheDocument()
expect(screen.getByTestId('preview-title-fight-card')).toBeInTheDocument()
expect(screen.queryByTestId('preview-header')).not.toBeInTheDocument()
})
it('shows first-time circuit empty state', async () => {
const newCircuit: Meeting = {
...upcomingMeeting,
meeting_key: 200,
circuit_key: 999,
meeting_name: 'New GP',
}
mockFetchMeetings.mockImplementation(async (year: number) => {
if (year === 2099) return [newCircuit]
if (year === 2098) return [priorMeeting]
return []
})
mockFetchSessions.mockResolvedValue(sessions)
mockFetchTrackOutline.mockResolvedValue(null)
renderPage()
await waitFor(() => {
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('First time on the calendar')
})
})
})

View File

@@ -106,6 +106,17 @@ describe('StrategyView — stints available', () => {
) )
expect(screen.queryByText(/Stints not available/i)).not.toBeInTheDocument() expect(screen.queryByText(/Stints not available/i)).not.toBeInTheDocument()
}) })
it('maps pit_stops into timeline pit markers for the matching driver', () => {
const { container } = render(
<StrategyView results={results} stints={stints} pit_stops={pitStops} hasStints={true} />
)
const markers = container.querySelectorAll('[data-testid="pit-marker"]')
expect(markers).toHaveLength(1)
expect(markers[0]).toHaveAttribute('data-lap', '19')
const titles = [...container.querySelectorAll('title')].map((t) => t.textContent)
expect(titles).toContain('HAM pit stop · L19')
})
}) })
describe('StrategyView — stints missing', () => { describe('StrategyView — stints missing', () => {

View File

@@ -47,7 +47,7 @@ describe('computeCumulativeDeltas', () => {
expect(result[0].deltas[2]).toBeCloseTo(2) expect(result[0].deltas[2]).toBeCloseTo(2)
}) })
it('emits null for missing lap times while carrying cumulative forward', () => { it('emits null for challenger missing lap times while carrying cumulative forward', () => {
const withNull: DeltaSeries = { const withNull: DeltaSeries = {
label: 'NOR', label: 'NOR',
color: '#FF8000', color: '#FF8000',
@@ -60,6 +60,41 @@ describe('computeCumulativeDeltas', () => {
expect(result[0].deltas[2]).toBeCloseTo(-92) expect(result[0].deltas[2]).toBeCloseTo(-92)
}) })
it('gaps all drivers when the reference lap is null and resumes without that window', () => {
const refWithNull: DeltaSeries = {
label: 'VER',
color: '#3671C6',
lapTimes: [90, null, 92],
}
const validChallenger: DeltaSeries = {
label: 'HAM',
color: '#E8002D',
lapTimes: [89, 91, 90],
}
const result = computeCumulativeDeltas([refWithNull, validChallenger])
expect(result[0].deltas[0]).toBeCloseTo(-1)
expect(result[0].deltas[1]).toBeNull()
// Lap 3 excludes the reference-null window for both: (89+90) - (90+92) = -3
expect(result[0].deltas[2]).toBeCloseTo(-3)
})
it('gaps challenger laps beyond a shorter reference series', () => {
const shortReference: DeltaSeries = {
label: 'VER',
color: '#3671C6',
lapTimes: [90, 91],
}
const longerChallenger: DeltaSeries = {
label: 'HAM',
color: '#E8002D',
lapTimes: [89, 92, 90],
}
const result = computeCumulativeDeltas([shortReference, longerChallenger])
expect(result[0].deltas[0]).toBeCloseTo(-1)
expect(result[0].deltas[1]).toBeCloseTo(0)
expect(result[0].deltas[2]).toBeNull()
})
it('returns an empty array when only one series is provided', () => { it('returns an empty array when only one series is provided', () => {
expect(computeCumulativeDeltas([reference])).toEqual([]) expect(computeCumulativeDeltas([reference])).toEqual([])
}) })
@@ -97,6 +132,19 @@ describe('DeltaTimeGraph', () => {
expect(screen.queryByTestId('delta-line-VER')).not.toBeInTheDocument() expect(screen.queryByTestId('delta-line-VER')).not.toBeInTheDocument()
}) })
it('splits polylines at reference-null laps', () => {
const refWithNull: DeltaSeries = {
label: 'VER',
color: '#3671C6',
lapTimes: [90, null, 92],
}
const { container } = render(
<DeltaTimeGraph series={[refWithNull, challenger]} />,
)
const lines = container.querySelectorAll('.delta-graph-driver-line')
expect(lines.length).toBeGreaterThan(1)
})
it('shows a crosshair tooltip on hover', () => { it('shows a crosshair tooltip on hover', () => {
vi.spyOn(SVGSVGElement.prototype, 'getBoundingClientRect').mockReturnValue({ vi.spyOn(SVGSVGElement.prototype, 'getBoundingClientRect').mockReturnValue({
x: 0, x: 0,
@@ -118,4 +166,31 @@ describe('DeltaTimeGraph', () => {
expect(screen.getByText(/Lap 1/)).toBeInTheDocument() expect(screen.getByText(/Lap 1/)).toBeInTheDocument()
vi.restoreAllMocks() vi.restoreAllMocks()
}) })
it('omits tooltip rows on reference-null laps', () => {
vi.spyOn(SVGSVGElement.prototype, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
width: 640,
height: 220,
right: 640,
bottom: 220,
toJSON: () => ({}),
})
const refWithNull: DeltaSeries = {
label: 'VER',
color: '#3671C6',
lapTimes: [90, null, 92],
}
const { container } = render(
<DeltaTimeGraph series={[refWithNull, challenger]} />,
)
const hoverLayer = container.querySelector('.delta-graph-hover-layer')
fireEvent.mouseMove(hoverLayer!, { clientX: 352, clientY: 100 })
expect(screen.getByTestId('delta-crosshair')).toBeInTheDocument()
expect(screen.queryByTestId('delta-tooltip')).not.toBeInTheDocument()
vi.restoreAllMocks()
})
}) })

View File

@@ -163,6 +163,20 @@ describe('live transforms', () => {
expect(parsed?.data?.Drivers['16'].RacingNumber).toBe('16') expect(parsed?.data?.Drivers['16'].RacingNumber).toBe('16')
}) })
it('parses archived EventSource snapshots separately from active data', () => {
const parsed = parseLiveStateEvent(JSON.stringify({
is_live: false,
data: null,
last_snapshot: snapshot,
last_positions: { '16': { x: 1, y: 2, z: 3, status: 'OnTrack' } },
last_snapshot_at: '2026-07-04T14:00:00Z',
}))
expect(parsed?.is_live).toBe(false)
expect(parsed?.data).toBeNull()
expect(parsed?.last_snapshot?.Drivers['16'].RacingNumber).toBe('16')
expect(parsed?.last_positions?.['16'].status).toBe('OnTrack')
})
it('sorts timing rows by live position and includes drivers with metadata only', () => { it('sorts timing rows by live position and includes drivers with metadata only', () => {
const rows = sortLiveTimingRows(snapshot) const rows = sortLiveTimingRows(snapshot)
expect(rows.map((row) => row.RacingNumber)).toEqual(['16', '1', '44']) expect(rows.map((row) => row.RacingNumber)).toEqual(['16', '1', '44'])

View File

@@ -0,0 +1,226 @@
import { describe, it, expect } from 'vitest'
import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session } from '../types'
import {
buildTitleFightContext,
countdownTargetSession,
extractPodium,
extractPole,
findPriorYearMeetingByCircuit,
findRaceSession,
formatPointsGap,
isSprintWeekend,
pickPreviewMeeting,
RACE_WIN_POINTS,
SPRINT_WEEKEND_MAX_POINTS,
} from '../lib/preview'
const meeting = (overrides: Partial<Meeting> = {}): Meeting => ({
meeting_key: 1,
meeting_name: 'Monaco',
meeting_official_name: 'Monaco GP',
location: 'Monaco',
country_name: 'Monaco',
country_code: 'MON',
country_flag: '',
circuit_key: 10,
circuit_short_name: 'Monaco',
date_start: '2026-05-22T00:00:00+00:00',
date_end: '2026-05-24T23:59:59+00:00',
year: 2026,
...overrides,
})
const session = (overrides: Partial<Session> = {}): Session => ({
session_key: 100,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1,
date_start: '2026-05-24T13:00:00+00:00',
date_end: '2026-05-24T15:00:00+00:00',
gmt_offset: '02:00:00',
...overrides,
})
const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
points: 200,
position: 1,
wins: 5,
podiums: 8,
poles: 4,
form: [25],
cumulative: [200],
teammate_wins: 9,
teammate_losses: 1,
...over,
})
describe('preview lib', () => {
it('picks the next upcoming meeting', () => {
const now = new Date('2026-01-01T00:00:00Z')
const meetings = [
meeting({ meeting_key: 1, date_start: '2026-05-22T00:00:00+00:00' }),
meeting({ meeting_key: 2, meeting_name: 'Spain', date_start: '2026-06-12T00:00:00+00:00' }),
]
expect(pickPreviewMeeting(meetings, now)?.meeting_key).toBe(1)
})
it('returns null when no upcoming meeting exists', () => {
const now = new Date('2027-01-01T00:00:00Z')
const meetings = [meeting({ date_start: '2026-05-22T00:00:00+00:00' })]
expect(pickPreviewMeeting(meetings, now)).toBeNull()
})
it('skips cancelled meetings', () => {
const now = new Date('2026-01-01T00:00:00Z')
const meetings = [meeting({ is_cancelled: true })]
expect(pickPreviewMeeting(meetings, now)).toBeNull()
})
it('matches prior-year meeting by circuit_key', () => {
const prior = [
meeting({ meeting_key: 50, year: 2025, circuit_key: 10 }),
meeting({ meeting_key: 51, year: 2025, circuit_key: 20, meeting_name: 'Spain' }),
]
expect(findPriorYearMeetingByCircuit(prior, 10)?.meeting_key).toBe(50)
expect(findPriorYearMeetingByCircuit(prior, 99)).toBeNull()
expect(findPriorYearMeetingByCircuit(prior, undefined)).toBeNull()
})
it('finds the grand prix race session', () => {
const sessions = [
session({ session_key: 1, session_name: 'FP1', session_type: 'Practice', date_start: '2026-05-22T10:00:00+00:00' }),
session({ session_key: 2, session_name: 'Sprint', session_type: 'Sprint', date_start: '2026-05-23T10:00:00+00:00' }),
session({ session_key: 3, session_name: 'Race', session_type: 'Race', date_start: '2026-05-24T13:00:00+00:00' }),
]
expect(findRaceSession(sessions)?.session_key).toBe(3)
})
it('detects sprint weekends', () => {
const sprint = [
session({ session_type: 'Practice' }),
session({ session_name: 'Sprint', session_type: 'Sprint' }),
session({ session_type: 'Race' }),
]
const normal = [session({ session_type: 'Practice' }), session({ session_type: 'Race' })]
expect(isSprintWeekend(sprint)).toBe(true)
expect(isSprintWeekend(normal)).toBe(false)
})
it('extracts podium and pole', () => {
const results: EnrichedResult[] = [
{
driver_number: 1,
position: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
dnf: false,
dns: false,
dsq: false,
duration: 100,
gap_to_leader: 0,
number_of_laps: 78,
points: 25,
session_key: 1,
meeting_key: 1,
},
{
driver_number: 4,
position: 2,
name_acronym: 'NOR',
full_name: 'Lando Norris',
team_name: 'McLaren',
team_colour: 'ff8000',
dnf: false,
dns: false,
dsq: false,
duration: 101,
gap_to_leader: 1,
number_of_laps: 78,
points: 18,
session_key: 1,
meeting_key: 1,
},
{
driver_number: 16,
position: 3,
name_acronym: 'LEC',
full_name: 'Charles Leclerc',
team_name: 'Ferrari',
team_colour: 'e8002d',
dnf: false,
dns: false,
dsq: false,
duration: 102,
gap_to_leader: 2,
number_of_laps: 78,
points: 15,
session_key: 1,
meeting_key: 1,
},
]
const grid: EnrichedGrid[] = [
{
driver_number: 4,
position: 1,
name_acronym: 'NOR',
full_name: 'Lando Norris',
team_name: 'McLaren',
team_colour: 'ff8000',
session_key: 1,
meeting_key: 1,
lap_duration: 70,
},
]
const podium = extractPodium(results)
expect(podium.map((p) => p.name_acronym)).toEqual(['VER', 'NOR', 'LEC'])
expect(extractPole(grid)?.name_acronym).toBe('NOR')
})
it('builds title fight gaps for top 3', () => {
const hub: ChampionshipHub = {
season: 2026,
round: 5,
total_rounds: 24,
rounds_left: 19,
last_race: 'Monaco GP',
round_labels: ['R1'],
drivers: [
hubDriver({ points: 200, position: 1 }),
hubDriver({ driver_number: 4, name_acronym: 'NOR', points: 160, position: 2 }),
hubDriver({ driver_number: 16, name_acronym: 'LEC', points: 120, position: 3 }),
],
teams: [],
}
const rows = buildTitleFightContext(hub)
expect(rows).toHaveLength(3)
expect(rows[0].gapToLeader).toBe(0)
expect(rows[1].gapToLeader).toBe(40)
expect(rows[1].gapAfterRaceWin).toBe(15)
expect(rows[2].gapAfterSprintWeekendMax).toBe(200 - (120 + SPRINT_WEEKEND_MAX_POINTS))
expect(RACE_WIN_POINTS).toBe(25)
})
it('formats points gaps', () => {
expect(formatPointsGap(0)).toBe('LEADER')
expect(formatPointsGap(40)).toBe('+40')
})
it('picks countdown target as next future session', () => {
const sessions = [
session({ session_key: 1, session_name: 'FP1', session_type: 'Practice', date_start: '2026-05-22T10:00:00+00:00', date_end: '2026-05-22T11:00:00+00:00' }),
session({ session_key: 2, session_name: 'Race', session_type: 'Race', date_start: '2026-05-24T13:00:00+00:00', date_end: '2026-05-24T15:00:00+00:00' }),
]
const now = new Date('2026-05-23T12:00:00Z')
expect(countdownTargetSession(sessions, now)?.session_key).toBe(2)
})
})

View File

@@ -71,6 +71,61 @@ describe('TyreStintTimeline', () => {
expect(screen.getByTestId('stint-timeline-empty')).toBeInTheDocument() expect(screen.getByTestId('stint-timeline-empty')).toBeInTheDocument()
expect(screen.getByText(/No stint data/i)).toBeInTheDocument() expect(screen.getByText(/No stint data/i)).toBeInTheDocument()
}) })
it('renders one pit marker per stop at the correct lap position', () => {
const rowsWithPits: StintTimelineRow[] = [
{
label: 'HAM',
color: '#E8002D',
stints: [{ compound: 'SOFT', lapStart: 1, lapEnd: 18 }],
pitStops: [19],
},
{
label: 'VER',
color: '#3671C6',
stints: [
{ compound: 'MEDIUM', lapStart: 1, lapEnd: 30 },
{ compound: 'SOFT', lapStart: 31, lapEnd: 78 },
],
pitStops: [31, 52],
},
]
const { container } = render(
<TyreStintTimeline rows={rowsWithPits} totalLaps={78} />,
)
const markers = container.querySelectorAll('[data-testid="pit-marker"]')
expect(markers).toHaveLength(3)
expect(markers[0]).toHaveAttribute('data-lap', '19')
expect(markers[1]).toHaveAttribute('data-lap', '31')
expect(markers[2]).toHaveAttribute('data-lap', '52')
expect(container.querySelectorAll('.stint-timeline__bar')).toHaveLength(3)
})
it('positions pit markers using lap_number and includes driver in tooltip', () => {
const rows: StintTimelineRow[] = [
{
label: 'HAM',
color: '#E8002D',
stints: [{ compound: 'SOFT', lapStart: 1, lapEnd: 18 }],
pitStops: [19],
},
]
const { container } = render(<TyreStintTimeline rows={rows} totalLaps={78} />)
const marker = container.querySelector('[data-testid="pit-marker"]') as SVGLineElement
expect(marker).toBeTruthy()
// lap 19 → x = 48 + (18/78) * 580 ≈ 181.85
expect(Number(marker.getAttribute('x1'))).toBeCloseTo(181.85, 1)
const titles = [...container.querySelectorAll('title')].map((t) => t.textContent)
expect(titles).toContain('HAM pit stop · L19')
})
it('leaves rows without pit data unchanged', () => {
const { container } = render(
<TyreStintTimeline rows={sampleRows} totalLaps={78} />,
)
expect(container.querySelectorAll('[data-testid="pit-marker"]')).toHaveLength(0)
expect(container.querySelectorAll('.stint-timeline__bar')).toHaveLength(3)
})
}) })
const results: EnrichedResult[] = [ const results: EnrichedResult[] = [

View File

@@ -12,6 +12,7 @@ export interface Meeting {
country_name: string country_name: string
country_code: string country_code: string
country_flag: string country_flag: string
circuit_key?: number
circuit_short_name: string circuit_short_name: string
date_start: string date_start: string
date_end: string date_end: string
@@ -23,6 +24,7 @@ export interface Session {
session_key: number session_key: number
session_name: string session_name: string
session_type: string session_type: string
circuit_key?: number
meeting_key: number meeting_key: number
date_start: string date_start: string
date_end: string date_end: string
@@ -206,6 +208,9 @@ export interface Weekend {
export interface LiveStateResponse { export interface LiveStateResponse {
is_live: boolean is_live: boolean
data: LiveStreamData | null data: LiveStreamData | null
last_snapshot?: LiveStreamData | null
last_positions?: Record<string, LivePosition> | null
last_snapshot_at?: string
} }
export interface LivePosition { export interface LivePosition {
@@ -316,6 +321,7 @@ export interface LiveStreamData {
Weather: LiveWeatherData Weather: LiveWeatherData
Session: LiveSessionMeta Session: LiveSessionMeta
TeamRadio: LiveRadioCapture[] TeamRadio: LiveRadioCapture[]
SessionStatus?: string
TrackStatus: string TrackStatus: string
CurrentLap: number CurrentLap: number
TotalLaps: number TotalLaps: number

View File

@@ -94,6 +94,12 @@ func cacheDBPath() string {
return filepath.Join(".cache", "box-box", "cache.db") return filepath.Join(".cache", "box-box", "cache.db")
} }
// DefaultCacheDBPath returns the HTTP cache database path used by the OpenF1
// client in both TUI and web modes.
func DefaultCacheDBPath() string {
return cacheDBPath()
}
// ttlForURL determines the appropriate TTL based on the URL pattern. // ttlForURL determines the appropriate TTL based on the URL pattern.
// Returns 0 (CacheTTLForever) for historical data that will never change. // Returns 0 (CacheTTLForever) for historical data that will never change.
func ttlForURL(url string) time.Duration { func ttlForURL(url string) time.Duration {

View File

@@ -623,6 +623,19 @@ func (c *OpenF1Client) GetTeamRadio(sessionKey, driverNumber int) ([]models.Team
// to maximise the chance of finding data quickly. // to maximise the chance of finding data quickly.
var candidateDrivers = []int{1, 11, 44, 16, 55, 4, 14, 63, 81, 24} var candidateDrivers = []int{1, 11, 44, 16, 55, 4, 14, 63, 81, 24}
// TrackOutlinePrefetchResult summarizes a season track-outline cache warming
// run. Counts are scoped to the unique non-zero circuit keys in the provided
// meeting list.
type TrackOutlinePrefetchResult struct {
Year int
UniqueCircuits int
CachedBefore int
CachedAfter int
Skipped int
Fetched int
Failed int
}
// PrefetchTrackOutlines fetches GPS location data for every circuit in the // PrefetchTrackOutlines fetches GPS location data for every circuit in the
// provided meeting list and stores it in the cache so the track map tab can // provided meeting list and stores it in the cache so the track map tab can
// render during live sessions when the free-tier API is locked. // render during live sessions when the free-tier API is locked.
@@ -632,28 +645,58 @@ var candidateDrivers = []int{1, 11, 44, 16, 55, 4, 14, 63, 81, 24}
// Errors per-circuit are silently ignored — this is a best-effort operation // Errors per-circuit are silently ignored — this is a best-effort operation
// and must never block or crash the main UI. // and must never block or crash the main UI.
func (c *OpenF1Client) PrefetchTrackOutlines(meetings []models.Meeting) { func (c *OpenF1Client) PrefetchTrackOutlines(meetings []models.Meeting) {
year := time.Now().Year()
for _, m := range meetings {
if m.Year != 0 {
year = m.Year
break
}
}
_ = c.PrefetchTrackOutlinesForYear(year, meetings)
}
// PrefetchTrackOutlinesForYear fetches and caches track outlines for unique
// circuits in the provided meeting list, storing them under the explicit season
// year. Unlike PrefetchTrackOutlines, it returns accounting suitable for CLI
// cache-warming workflows.
func (c *OpenF1Client) PrefetchTrackOutlinesForYear(year int, meetings []models.Meeting) TrackOutlinePrefetchResult {
const maxWorkers = 3 const maxWorkers = 3
year := time.Now().Year() result := TrackOutlinePrefetchResult{Year: year}
uniqueByCircuit := make(map[int]models.Meeting)
// Filter to meetings that need fetching. var unique []models.Meeting
var pending []models.Meeting
for _, m := range meetings { for _, m := range meetings {
if m.CircuitKey == 0 { if m.CircuitKey == 0 {
continue continue
} }
if _, exists := uniqueByCircuit[m.CircuitKey]; exists {
continue
}
uniqueByCircuit[m.CircuitKey] = m
unique = append(unique, m)
}
result.UniqueCircuits = len(unique)
// Filter to meetings that need fetching.
var pending []models.Meeting
for _, m := range unique {
if _, ok := c.cache.GetTrackOutline(m.CircuitKey, year); ok { if _, ok := c.cache.GetTrackOutline(m.CircuitKey, year); ok {
result.CachedBefore++
result.Skipped++
continue // already cached for this season continue // already cached for this season
} }
pending = append(pending, m) pending = append(pending, m)
} }
if len(pending) == 0 { if len(pending) == 0 {
return result.CachedAfter = result.CachedBefore
return result
} }
sem := make(chan struct{}, maxWorkers) sem := make(chan struct{}, maxWorkers)
var wg sync.WaitGroup var wg sync.WaitGroup
var mu sync.Mutex
for _, mtg := range pending { for _, mtg := range pending {
mtg := mtg // capture mtg := mtg // capture
@@ -662,19 +705,34 @@ func (c *OpenF1Client) PrefetchTrackOutlines(meetings []models.Meeting) {
go func() { go func() {
defer wg.Done() defer wg.Done()
defer func() { <-sem }() defer func() { <-sem }()
c.prefetchCircuit(mtg, year) ok := c.prefetchCircuit(mtg, year)
mu.Lock()
if ok {
result.Fetched++
} else {
result.Failed++
}
mu.Unlock()
}() }()
} }
wg.Wait() wg.Wait()
for _, m := range unique {
if _, ok := c.cache.GetTrackOutline(m.CircuitKey, year); ok {
result.CachedAfter++
}
}
return result
} }
// prefetchCircuit fetches the track outline for a single meeting and stores it. // prefetchCircuit fetches the track outline for a single meeting and stores it.
// It prefers completed sessions (past date_end) so the data is full and stable. // It prefers completed sessions (past date_end) so the data is full and stable.
func (c *OpenF1Client) prefetchCircuit(mtg models.Meeting, year int) { func (c *OpenF1Client) prefetchCircuit(mtg models.Meeting, year int) bool {
sessions, err := c.GetSessionsForMeeting(int(mtg.MeetingKey)) sessions, err := c.GetSessionsForMeeting(int(mtg.MeetingKey))
if err != nil || len(sessions) == 0 { if err != nil || len(sessions) == 0 {
return return false
} }
// Pick the best session: prefer a completed race, then any session with // Pick the best session: prefer a completed race, then any session with
@@ -696,7 +754,7 @@ func (c *OpenF1Client) prefetchCircuit(mtg models.Meeting, year int) {
} }
} }
if bestSession == nil { if bestSession == nil {
return return false
} }
// Try candidate drivers in order until we find one with enough points. // Try candidate drivers in order until we find one with enough points.
@@ -706,7 +764,7 @@ func (c *OpenF1Client) prefetchCircuit(mtg models.Meeting, year int) {
continue continue
} }
// Store under the circuit key for this year and stop. // Store under the circuit key for this year and stop.
_ = c.cache.SetTrackOutline(mtg.CircuitKey, year, locs) return c.cache.SetTrackOutline(mtg.CircuitKey, year, locs) == nil
return
} }
return false
} }

View File

@@ -0,0 +1,109 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/AmanTahiliani/box-box/internal/models"
)
func newTrackOutlineTestClient(t *testing.T, srvURL string) *OpenF1Client {
t.Helper()
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
c := NewOpenF1Client(srvURL, 5*time.Second)
c.pacer = &requestPacer{}
t.Cleanup(func() { _ = c.Close() })
return c
}
func TestPrefetchTrackOutlinesForYearSkipsCachedAndWritesLocations(t *testing.T) {
var sessionsByMeeting = map[string][]models.Session{
"202": {
{
SessionKey: 9002,
SessionName: "Race",
MeetingKey: 202,
CircuitKey: 2,
DateEnd: "2026-01-01T12:00:00+00:00",
},
},
}
var sessionsRequested []string
var locationsRequested []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/sessions":
meetingKey := r.URL.Query().Get("meeting_key")
sessionsRequested = append(sessionsRequested, meetingKey)
_ = json.NewEncoder(w).Encode(sessionsByMeeting[meetingKey])
case "/v1/location":
sessionKey := r.URL.Query().Get("session_key")
driverNumber := r.URL.Query().Get("driver_number")
locationsRequested = append(locationsRequested, sessionKey+"/"+driverNumber)
_ = json.NewEncoder(w).Encode(testLocations(9002, 1, 51))
default:
t.Fatalf("unexpected request path %s", r.URL.Path)
}
}))
defer srv.Close()
client := newTrackOutlineTestClient(t, srv.URL)
if err := client.Cache().SetTrackOutline(1, 2026, testLocations(9001, 1, 51)); err != nil {
t.Fatalf("SetTrackOutline() error = %v", err)
}
result := client.PrefetchTrackOutlinesForYear(2026, []models.Meeting{
{MeetingKey: 101, Year: 2026, Circuit: models.Circuit{CircuitKey: 1}},
{MeetingKey: 202, Year: 2026, Circuit: models.Circuit{CircuitKey: 2}},
{MeetingKey: 303, Year: 2026, Circuit: models.Circuit{CircuitKey: 2}},
})
if result.UniqueCircuits != 2 {
t.Fatalf("UniqueCircuits = %d, want 2", result.UniqueCircuits)
}
if result.CachedBefore != 1 || result.Skipped != 1 || result.Fetched != 1 || result.Failed != 0 || result.CachedAfter != 2 {
t.Fatalf("unexpected result: %+v", result)
}
if got, want := len(sessionsRequested), 1; got != want {
t.Fatalf("sessions requested %d time(s), want %d: %v", got, want, sessionsRequested)
}
if sessionsRequested[0] != "202" {
t.Fatalf("requested meeting %s, want 202", sessionsRequested[0])
}
if got, want := len(locationsRequested), 1; got != want {
t.Fatalf("locations requested %d time(s), want %d: %v", got, want, locationsRequested)
}
if locationsRequested[0] != "9002/1" {
t.Fatalf("requested location %s, want 9002/1", locationsRequested[0])
}
locs, ok := client.Cache().GetTrackOutline(2, 2026)
if !ok {
t.Fatal("expected circuit 2 outline to be cached")
}
if len(locs) != 51 {
t.Fatalf("cached %d locations, want 51", len(locs))
}
}
func testLocations(sessionKey, driverNumber, count int) []models.Location {
locs := make([]models.Location, count)
for i := range locs {
locs[i] = models.Location{
Date: "2026-01-01T12:00:" + strconv.Itoa(i%60) + "+00:00",
DriverNumber: driverNumber,
MeetingKey: 202,
SessionKey: sessionKey,
X: float64(i),
Y: float64(i * 2),
}
}
return locs
}

View File

@@ -273,6 +273,40 @@ func TestProcessTopicTrackStatus(t *testing.T) {
} }
} }
func TestProcessTopicSessionStatus(t *testing.T) {
state := live.NewState()
if !state.ProcessTopic("SessionStatus", json.RawMessage(`{"Status": "Finished"}`)) {
t.Fatal("SessionStatus should update state")
}
snap := state.Snapshot()
if snap.SessionStatus != "Finished" {
t.Fatalf("session status = %q, want Finished", snap.SessionStatus)
}
if !snap.SnapshotUpdated {
t.Fatal("SessionStatus should mark snapshot updated")
}
}
func TestSessionStatusIsActive(t *testing.T) {
tests := []struct {
status string
want bool
}{
{"Started", true},
{"Resumed", true},
{"Finished", false},
{"Finalised", false},
{"Ends", false},
{"Aborted", false},
{"", false},
}
for _, tt := range tests {
if got := live.SessionStatusIsActive(tt.status); got != tt.want {
t.Errorf("SessionStatusIsActive(%q) = %v, want %v", tt.status, got, tt.want)
}
}
}
func TestProcessTopicRaceControlMessages(t *testing.T) { func TestProcessTopicRaceControlMessages(t *testing.T) {
state := live.NewState() state := live.NewState()
data := json.RawMessage(`{ data := json.RawMessage(`{

View File

@@ -197,7 +197,7 @@ func connectToF1LegacySignalR(dataChan chan LiveStreamData) error {
return err return err
} }
subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","Position.z","CarData.z","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","TeamRadio","CurrentTyres","TimingAppData","TimingStats"]],"I":1}`) subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","Position.z","CarData.z","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","TeamRadio","CurrentTyres","TimingAppData","TimingStats","SessionStatus"]],"I":1}`)
err = c.WriteMessage(websocket.TextMessage, subscribeMsg) err = c.WriteMessage(websocket.TextMessage, subscribeMsg)
if err != nil { if err != nil {
return err return err

View File

@@ -26,6 +26,7 @@ type State struct {
Weather LiveWeatherData Weather LiveWeatherData
Session LiveSessionMeta Session LiveSessionMeta
TeamRadio []LiveRadioCapture TeamRadio []LiveRadioCapture
SessionStatus string
TrackStatus string TrackStatus string
CurrentLap int CurrentLap int
TotalLaps int TotalLaps int
@@ -93,6 +94,7 @@ func (s *State) Snapshot() LiveStreamData {
Weather: s.Weather, Weather: s.Weather,
Session: s.Session, Session: s.Session,
TeamRadio: cpyRadio, TeamRadio: cpyRadio,
SessionStatus: s.SessionStatus,
TrackStatus: s.TrackStatus, TrackStatus: s.TrackStatus,
CurrentLap: s.CurrentLap, CurrentLap: s.CurrentLap,
TotalLaps: s.TotalLaps, TotalLaps: s.TotalLaps,
@@ -273,6 +275,14 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
s.TrackStatus = ts.Status s.TrackStatus = ts.Status
updated = true updated = true
} }
case "SessionStatus":
var ss struct {
Status string `json:"Status"`
}
if json.Unmarshal(data, &ss) == nil && ss.Status != "" {
s.SessionStatus = ss.Status
updated = true
}
case "RaceControlMessages": case "RaceControlMessages":
var rcm struct { var rcm struct {
Messages json.RawMessage `json:"Messages"` Messages json.RawMessage `json:"Messages"`

View File

@@ -162,6 +162,7 @@ type LiveStreamData struct {
Weather LiveWeatherData Weather LiveWeatherData
Session LiveSessionMeta Session LiveSessionMeta
TeamRadio []LiveRadioCapture TeamRadio []LiveRadioCapture
SessionStatus string
TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC
CurrentLap int CurrentLap int
TotalLaps int TotalLaps int
@@ -173,3 +174,27 @@ type LiveStreamData struct {
PositionUpdated bool `json:"-"` PositionUpdated bool `json:"-"`
SnapshotUpdated bool `json:"-"` SnapshotUpdated bool `json:"-"`
} }
// SessionStatusIsActive reports whether a raw F1 live timing SessionStatus
// value represents an actively running session.
func SessionStatusIsActive(status string) bool {
switch normalizeSessionStatus(status) {
case "started", "resumed":
return true
default:
return false
}
}
func normalizeSessionStatus(status string) string {
out := make([]rune, 0, len(status))
for _, r := range status {
switch {
case r >= 'A' && r <= 'Z':
out = append(out, r+'a'-'A')
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
out = append(out, r)
}
}
return string(out)
}

View File

@@ -26,6 +26,15 @@ type ChampionshipInputs struct {
} }
// GetChampionshipInputs derives championship hub inputs from ingested season data. // GetChampionshipInputs derives championship hub inputs from ingested season data.
//
// 2026 note: Bahrain (meeting 1282 / session 11261) and Saudi Arabia (1283 / 11269)
// are cancelled due to Force Majeure (regional conflict) — OpenF1 returns no results
// for those sessions. This is intentional, which is why 2026 shows Round 9/24 after
// Silverstone instead of Round 11.
//
// Points: F1 Sprint points (SessionName == "Sprint") must be included alongside Race
// points. Both appear as SessionType "Race" in OpenF1 /api/v1/sessions, so we match
// on SessionName. Fix for #57.
func (s *Service) GetChampionshipInputs(year int) (ChampionshipInputs, error) { func (s *Service) GetChampionshipInputs(year int) (ChampionshipInputs, error) {
meetings, err := s.ListMeetingsByYear(year) meetings, err := s.ListMeetingsByYear(year)
if err != nil { if err != nil {
@@ -51,60 +60,101 @@ func (s *Service) GetChampionshipInputs(year int) (ChampionshipInputs, error) {
} }
raceKey := 0 raceKey := 0
sprintKey := 0
for _, sess := range sessions { for _, sess := range sessions {
if strings.EqualFold(sess.SessionName, "Race") { if strings.EqualFold(sess.SessionName, "Race") {
raceKey = sess.SessionKey raceKey = sess.SessionKey
break } else if strings.EqualFold(sess.SessionName, "Sprint") {
sprintKey = sess.SessionKey
} }
} }
if raceKey == 0 { if raceKey == 0 && sprintKey == 0 {
continue continue
} }
results, err := s.store.ListSessionResults(raceKey) // Always build Races slice from the Race session only (for UI),
if err != nil { // but include sprint points for standings.
return ChampionshipInputs{}, err var raceResults []models.SessionResult
} var raceGrid []models.StartingGrid
grid, err := s.store.ListStartingGrid(raceKey)
if err != nil {
return ChampionshipInputs{}, err
}
race := ChampionshipRace{ if raceKey != 0 {
Meeting: meeting, results, err := s.store.ListSessionResults(raceKey)
RaceSessionKey: raceKey, if err != nil {
Results: make([]models.SessionResult, 0, len(results)), return ChampionshipInputs{}, err
Grid: make([]models.StartingGrid, 0, len(grid)), }
} grid, err := s.store.ListStartingGrid(raceKey)
for _, result := range results { if err != nil {
race.Results = append(race.Results, resultToModel(result)) return ChampionshipInputs{}, err
} }
for _, entry := range grid { race := ChampionshipRace{
race.Grid = append(race.Grid, gridToModel(entry)) Meeting: meeting,
} RaceSessionKey: raceKey,
inputs.Races = append(inputs.Races, race) Results: make([]models.SessionResult, 0, len(results)),
Grid: make([]models.StartingGrid, 0, len(grid)),
}
for _, result := range results {
race.Results = append(race.Results, resultToModel(result))
}
for _, entry := range grid {
race.Grid = append(race.Grid, gridToModel(entry))
}
inputs.Races = append(inputs.Races, race)
raceResults = race.Results
if len(results) == 0 { if len(results) > 0 {
continue latestSessionKey = raceKey
} latestMeetingKey = int(meeting.MeetingKey)
drivers, err := s.driversForSession(raceKey)
latestSessionKey = raceKey if err != nil {
latestMeetingKey = int(meeting.MeetingKey) return ChampionshipInputs{}, err
drivers, err := s.driversForSession(raceKey) }
if err != nil { for _, d := range drivers {
return ChampionshipInputs{}, err inputs.DriverMap[d.DriverNumber] = d
} }
for _, d := range drivers { for _, result := range results {
inputs.DriverMap[d.DriverNumber] = d pointsByDriver[result.DriverNumber] += result.Points
} team := inputs.DriverMap[result.DriverNumber].TeamName
if team != "" {
for _, result := range results { pointsByTeam[team] += result.Points
pointsByDriver[result.DriverNumber] += result.Points }
team := inputs.DriverMap[result.DriverNumber].TeamName }
if team != "" {
pointsByTeam[team] += result.Points
} }
} }
// Include sprint points if present.
// Note: 2026 has 4 sprints up to British GP (Chinese GP 11240, Miami 11275,
// Canadian 11286, British 11321) totaling e.g. ANT 21 = 4+3+6+8, RUS 26.
if sprintKey != 0 {
sprintResults, err := s.store.ListSessionResults(sprintKey)
if err != nil {
return ChampionshipInputs{}, err
}
if len(sprintResults) > 0 {
// Ensure driver map includes sprint-only drivers if any.
sprintDrivers, err := s.driversForSession(sprintKey)
if err == nil {
for _, d := range sprintDrivers {
if _, ok := inputs.DriverMap[d.DriverNumber]; !ok {
inputs.DriverMap[d.DriverNumber] = d
}
}
}
for _, result := range sprintResults {
pointsByDriver[result.DriverNumber] += result.Points
team := inputs.DriverMap[result.DriverNumber].TeamName
if team != "" {
pointsByTeam[team] += result.Points
}
}
// If this meeting had no race results (edge), still track latest.
if len(raceResults) == 0 {
latestSessionKey = sprintKey
latestMeetingKey = int(meeting.MeetingKey)
}
}
}
_ = raceResults
_ = raceGrid
} }
inputs.Champ = derivedDriverStandings(pointsByDriver, latestMeetingKey, latestSessionKey) inputs.Champ = derivedDriverStandings(pointsByDriver, latestMeetingKey, latestSessionKey)

View File

@@ -375,3 +375,56 @@ func TestGetWeekendWithSessions(t *testing.T) {
t.Fatalf("first session drivers = %+v, want available", weekend.Sessions[0].Datasets["drivers"]) t.Fatalf("first session drivers = %+v, want available", weekend.Sessions[0].Datasets["drivers"])
} }
} }
func TestGetChampionshipInputsIncludesSprintPoints(t *testing.T) {
// Regression for #57: Race-only aggregation dropped Sprint points.
// Setup: same meeting 1229 has Race (9472) 25pts + Sprint (9473) 8pts => total 33.
svc := openTestService(t)
seedRaceHubData(t, svc.store)
// Add sprint session for same meeting
if err := svc.store.UpsertSession(store.Session{
SessionKey: 9473,
MeetingKey: 1229,
SessionName: "Sprint",
SessionType: "Race", // OpenF1 uses SessionType Race even for Sprint
DateStart: "2025-05-24T13:00:00+00:00",
}); err != nil {
t.Fatalf("UpsertSession sprint error = %v", err)
}
if err := svc.store.UpsertSessionDriver(store.SessionDriver{
SessionKey: 9473, DriverNumber: 1, MeetingKey: 1229, TeamName: "Red Bull Racing",
}); err != nil {
t.Fatalf("UpsertSessionDriver sprint error = %v", err)
}
// Race points 25
if err := svc.store.UpsertSessionResult(store.SessionResult{
SessionKey: 9472, DriverNumber: 1, MeetingKey: 1229, Position: 1, Points: 25,
}); err != nil {
t.Fatalf("UpsertSessionResult race error = %v", err)
}
// Sprint points 8
if err := svc.store.UpsertSessionResult(store.SessionResult{
SessionKey: 9473, DriverNumber: 1, MeetingKey: 1229, Position: 1, Points: 8,
}); err != nil {
t.Fatalf("UpsertSessionResult sprint error = %v", err)
}
inputs, err := svc.GetChampionshipInputs(2025)
if err != nil {
t.Fatalf("GetChampionshipInputs() error = %v", err)
}
if len(inputs.Races) != 1 {
t.Fatalf("Races len = %d, want 1 race entry", len(inputs.Races))
}
if len(inputs.Champ) != 1 {
t.Fatalf("Champ len = %d, want 1", len(inputs.Champ))
}
if inputs.Champ[0].PointsCurrent != 33 {
t.Fatalf("PointsCurrent = %v, want 33 (25 race + 8 sprint)", inputs.Champ[0].PointsCurrent)
}
if len(inputs.Teams) != 1 || inputs.Teams[0].PointsCurrent != 33 {
t.Fatalf("Teams = %+v, want Red Bull 33", inputs.Teams)
}
}

View File

@@ -29,10 +29,21 @@ type SSEHub struct {
deregister chan *sseClient deregister chan *sseClient
broadcast chan sseEvent broadcast chan sseEvent
mu sync.RWMutex mu sync.RWMutex
lastSnapshot *live.LiveStreamData activeSnapshot *live.LiveStreamData
lastPositions map[string]live.LivePositionData activePositions map[string]live.LivePositionData
isLive bool lastSnapshot *live.LiveStreamData
lastPositions map[string]live.LivePositionData
lastSnapshotAt time.Time
isLive bool
}
type liveStatePayload struct {
IsLive bool `json:"is_live"`
Data *live.LiveStreamData `json:"data"`
LastSnapshot *live.LiveStreamData `json:"last_snapshot,omitempty"`
LastPositions map[string]live.LivePositionData `json:"last_positions,omitempty"`
LastSnapshotAt *time.Time `json:"last_snapshot_at,omitempty"`
} }
func newSSEHub() *SSEHub { func newSSEHub() *SSEHub {
@@ -51,19 +62,16 @@ func (h *SSEHub) run() {
case c := <-h.register: case c := <-h.register:
clients[c] = true clients[c] = true
// Send catch-up snapshot so new clients see current state immediately. // Send catch-up snapshot so new clients see current state immediately.
h.mu.RLock() state := h.State()
snap := h.lastSnapshot if state.Data != nil || state.LastSnapshot != nil {
positions := cloneLivePositions(h.lastPositions) if data, err := json.Marshal(state); err == nil {
live := h.isLive
h.mu.RUnlock()
if snap != nil {
if data, err := json.Marshal(map[string]any{"data": snap, "is_live": live}); err == nil {
select { select {
case c.ch <- formatSSEFrame("snapshot", data): case c.ch <- formatSSEFrame("snapshot", data):
default: default:
} }
} }
} }
positions := h.ActivePositions()
if len(positions) > 0 { if len(positions) > 0 {
if data, err := json.Marshal(positions); err == nil { if data, err := json.Marshal(positions); err == nil {
select { select {
@@ -96,11 +104,125 @@ func formatSSEFrame(event string, data []byte) []byte {
return []byte(fmt.Sprintf("event: %s\ndata: %s\n\n", event, data)) return []byte(fmt.Sprintf("event: %s\ndata: %s\n\n", event, data))
} }
// Snapshot returns the latest live data snapshot and whether a session is active. // State returns the active live snapshot and the retained in-memory archive.
func (h *SSEHub) Snapshot() (*live.LiveStreamData, bool) { func (h *SSEHub) State() liveStatePayload {
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()
return h.lastSnapshot, h.isLive payload := liveStatePayload{
IsLive: h.isLive,
}
if h.isLive {
payload.Data = h.activeSnapshot
} else if h.lastSnapshot != nil {
payload.LastSnapshot = h.lastSnapshot
payload.LastPositions = cloneLivePositions(h.lastPositions)
if !h.lastSnapshotAt.IsZero() {
at := h.lastSnapshotAt
payload.LastSnapshotAt = &at
}
}
return payload
}
func (h *SSEHub) ActivePositions() map[string]live.LivePositionData {
h.mu.RLock()
defer h.mu.RUnlock()
return cloneLivePositions(h.activePositions)
}
func (h *SSEHub) applySnapshot(data live.LiveStreamData, now time.Time) liveStatePayload {
h.mu.Lock()
defer h.mu.Unlock()
if live.SessionStatusIsActive(data.SessionStatus) {
h.isLive = true
h.activeSnapshot = &data
if data.PositionUpdated && len(data.Positions) > 0 {
h.activePositions = cloneLivePositions(data.Positions)
}
return liveStatePayload{IsLive: true, Data: h.activeSnapshot}
}
archivePositions := cloneLivePositions(h.activePositions)
if data.PositionUpdated && len(data.Positions) > 0 {
archivePositions = cloneLivePositions(data.Positions)
}
h.isLive = false
h.activeSnapshot = nil
h.activePositions = nil
if hasLiveSnapshotData(data) {
h.lastSnapshot = &data
h.lastSnapshotAt = now
h.lastPositions = archivePositions
}
return h.stateLocked()
}
func (h *SSEHub) applyPositions(data live.LiveStreamData) map[string]live.LivePositionData {
h.mu.Lock()
defer h.mu.Unlock()
if len(data.Positions) == 0 {
return nil
}
positions := cloneLivePositions(data.Positions)
if h.isLive {
h.activePositions = positions
return positions
}
if h.lastSnapshot != nil {
h.lastPositions = positions
}
return nil
}
func (h *SSEHub) deactivate(now time.Time) liveStatePayload {
h.mu.Lock()
defer h.mu.Unlock()
if h.activeSnapshot != nil {
h.lastSnapshot = h.activeSnapshot
h.lastPositions = cloneLivePositions(h.activePositions)
h.lastSnapshotAt = now
}
h.isLive = false
h.activeSnapshot = nil
h.activePositions = nil
return h.stateLocked()
}
func (h *SSEHub) stateLocked() liveStatePayload {
payload := liveStatePayload{IsLive: h.isLive}
if h.isLive {
payload.Data = h.activeSnapshot
return payload
}
if h.lastSnapshot != nil {
payload.LastSnapshot = h.lastSnapshot
payload.LastPositions = cloneLivePositions(h.lastPositions)
if !h.lastSnapshotAt.IsZero() {
at := h.lastSnapshotAt
payload.LastSnapshotAt = &at
}
}
return payload
}
func hasLiveSnapshotData(data live.LiveStreamData) bool {
return len(data.Drivers) > 0 ||
len(data.DriverInfo) > 0 ||
len(data.Tyres) > 0 ||
len(data.Telemetry) > 0 ||
len(data.RCMessages) > 0 ||
len(data.TeamRadio) > 0 ||
len(data.Stints) > 0 ||
data.Session.MeetingName != "" ||
data.Session.SessionName != "" ||
data.Session.SessionType != "" ||
data.TrackStatus != "" ||
data.CurrentLap != 0 ||
data.TotalLaps != 0 ||
data.Clock != ""
} }
// runLiveFeeds launches background goroutines for the F1 SignalR feed and keepalive. // runLiveFeeds launches background goroutines for the F1 SignalR feed and keepalive.
@@ -129,13 +251,8 @@ func (s *Server) signalRLoop() {
log.Printf("web: live feed ended: %v", err) log.Printf("web: live feed ended: %v", err)
} }
s.hub.mu.Lock() state := s.hub.deactivate(time.Now())
s.hub.isLive = false if payload, err := json.Marshal(state); err == nil {
s.hub.lastSnapshot = nil
s.hub.lastPositions = nil
s.hub.mu.Unlock()
if payload, err := json.Marshal(map[string]any{"data": nil, "is_live": false}); err == nil {
s.hub.broadcast <- sseEvent{name: "snapshot", data: payload} s.hub.broadcast <- sseEvent{name: "snapshot", data: payload}
} }
@@ -167,24 +284,20 @@ func (s *Server) connectAndDrain() error {
select { select {
case data := <-dataChan: case data := <-dataChan:
now := time.Now() now := time.Now()
s.hub.mu.Lock()
if data.SnapshotUpdated { if data.SnapshotUpdated {
s.hub.lastSnapshot = &data state := s.hub.applySnapshot(data, now)
} if payload, err := json.Marshal(state); err == nil {
if data.PositionUpdated && len(data.Positions) > 0 {
s.hub.lastPositions = cloneLivePositions(data.Positions)
}
s.hub.isLive = true
s.hub.mu.Unlock()
if data.SnapshotUpdated {
if payload, err := json.Marshal(map[string]any{"data": data, "is_live": true}); err == nil {
s.hub.broadcast <- sseEvent{name: "snapshot", data: payload} s.hub.broadcast <- sseEvent{name: "snapshot", data: payload}
} }
} }
if data.PositionUpdated && len(data.Positions) > 0 && now.Sub(lastPositionBroadcast) >= 250*time.Millisecond { if data.PositionUpdated && len(data.Positions) > 0 && now.Sub(lastPositionBroadcast) >= 250*time.Millisecond {
if payload, err := json.Marshal(data.Positions); err == nil { positions := s.hub.applyPositions(data)
if len(positions) > 0 {
payload, err := json.Marshal(positions)
if err != nil {
continue
}
s.hub.broadcast <- sseEvent{name: "positions", data: payload} s.hub.broadcast <- sseEvent{name: "positions", data: payload}
lastPositionBroadcast = now lastPositionBroadcast = now
} }
@@ -217,11 +330,7 @@ func cloneLivePositions(in map[string]live.LivePositionData) map[string]live.Liv
// handleLiveState returns the current live data snapshot as JSON. // handleLiveState returns the current live data snapshot as JSON.
func (s *Server) handleLiveState(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLiveState(w http.ResponseWriter, r *http.Request) {
snap, isLive := s.hub.Snapshot() writeJSON(w, s.hub.State())
writeJSON(w, map[string]any{
"is_live": isLive,
"data": snap,
})
} }
// handleSSEStream is the persistent SSE endpoint for live data. // handleSSEStream is the persistent SSE endpoint for live data.

View File

@@ -0,0 +1,90 @@
package web
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/AmanTahiliani/box-box/internal/live"
)
func TestSSEHubArchivesTerminalSessionSnapshot(t *testing.T) {
hub := newSSEHub()
now := time.Date(2026, 7, 4, 14, 0, 0, 0, time.UTC)
active := live.LiveStreamData{
SessionStatus: "Started",
Drivers: map[string]live.LiveDriverData{
"1": {RacingNumber: "1", Position: 1},
},
Positions: map[string]live.LivePositionData{
"1": {X: 100, Y: -50, Z: 2, Status: "OnTrack"},
},
PositionUpdated: true,
SnapshotUpdated: true,
}
if state := hub.applySnapshot(active, now); !state.IsLive || state.Data == nil {
t.Fatalf("active state = %+v, want live data", state)
}
terminal := active
terminal.SessionStatus = "Finished"
terminal.Positions = nil
terminal.PositionUpdated = false
state := hub.applySnapshot(terminal, now.Add(time.Minute))
if state.IsLive {
t.Fatal("terminal SessionStatus should not be live")
}
if state.Data != nil {
t.Fatalf("inactive state data = %+v, want nil", state.Data)
}
if state.LastSnapshot == nil || state.LastSnapshot.SessionStatus != "Finished" {
t.Fatalf("last snapshot = %+v, want terminal snapshot", state.LastSnapshot)
}
if got := state.LastPositions["1"]; got.X != 100 || got.Status != "OnTrack" {
t.Fatalf("last positions = %+v, want carried active positions", state.LastPositions)
}
if state.LastSnapshotAt == nil || !state.LastSnapshotAt.Equal(now.Add(time.Minute)) {
t.Fatalf("last snapshot time = %v, want %v", state.LastSnapshotAt, now.Add(time.Minute))
}
}
func TestHandleLiveStateKeepsArchiveOutOfActiveData(t *testing.T) {
hub := newSSEHub()
now := time.Date(2026, 7, 4, 14, 0, 0, 0, time.UTC)
hub.applySnapshot(live.LiveStreamData{
SessionStatus: "Finished",
Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", SessionName: "Race"},
Drivers: map[string]live.LiveDriverData{
"44": {RacingNumber: "44", Position: 1},
},
SnapshotUpdated: true,
}, now)
srv := &Server{hub: hub}
req := httptest.NewRequest(http.MethodGet, "/api/v1/live/state", nil)
rec := httptest.NewRecorder()
srv.handleLiveState(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var resp liveStatePayload
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.IsLive {
t.Fatal("archived snapshot should report is_live=false")
}
if resp.Data != nil {
t.Fatalf("archived snapshot leaked into data: %+v", resp.Data)
}
if resp.LastSnapshot == nil || resp.LastSnapshot.Session.MeetingName != "British Grand Prix" {
t.Fatalf("last snapshot = %+v, want archived race", resp.LastSnapshot)
}
if resp.LastSnapshotAt == nil {
t.Fatal("last_snapshot_at should be present for archived snapshots")
}
}

View File

@@ -0,0 +1,264 @@
#!/usr/bin/env python3
"""Probe the official F1 live timing sockets for GPS position topics.
This intentionally does not call OpenF1 REST. It connects directly to the F1
SignalR endpoints used by the app and reports which topics arrive.
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import random
import socket
import ssl
import struct
import sys
import time
import urllib.parse
import urllib.request
RS = b"\x1e"
CORE_NEGOTIATE = "https://livetiming.formula1.com/signalrcore/negotiate?negotiateVersion=1"
CORE_WS_HOST = "livetiming.formula1.com"
LEGACY_HUB = '[{"name":"Streaming"}]'
TOPICS = [
"Heartbeat",
"TimingData",
"Position",
"Position.z",
"CarData",
"CarData.z",
"DriverList",
"LapCount",
"ExtrapolatedClock",
"TrackStatus",
"RaceControlMessages",
"WeatherData",
"SessionInfo",
"TeamRadio",
"CurrentTyres",
"TimingAppData",
"TimingStats",
"SessionStatus",
"TopThree",
]
class WebSocket:
def __init__(self, host: str, path: str, headers: dict[str, str] | None = None):
self.host = host
self.sock = ssl.create_default_context().wrap_socket(
socket.create_connection((host, 443), timeout=10),
server_hostname=host,
)
key = base64.b64encode(os.urandom(16)).decode("ascii")
request_headers = {
"Host": host,
"Upgrade": "websocket",
"Connection": "Upgrade",
"Sec-WebSocket-Key": key,
"Sec-WebSocket-Version": "13",
"Origin": "https://www.formula1.com",
"User-Agent": "Mozilla/5.0",
}
request_headers.update(headers or {})
lines = [f"GET {path} HTTP/1.1", *[f"{k}: {v}" for k, v in request_headers.items()], "", ""]
self.sock.sendall("\r\n".join(lines).encode("utf-8"))
response = self._read_http_response()
if b" 101 " not in response.split(b"\r\n", 1)[0]:
raise RuntimeError(response.decode("utf-8", "replace"))
def _read_http_response(self) -> bytes:
data = b""
while b"\r\n\r\n" not in data:
data += self.sock.recv(4096)
return data
def send_text(self, text: str) -> None:
payload = text.encode("utf-8")
header = bytearray([0x81])
if len(payload) < 126:
header.append(0x80 | len(payload))
elif len(payload) < 65536:
header.append(0x80 | 126)
header.extend(struct.pack("!H", len(payload)))
else:
header.append(0x80 | 127)
header.extend(struct.pack("!Q", len(payload)))
mask = random.randbytes(4) if hasattr(random, "randbytes") else os.urandom(4)
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
self.sock.sendall(bytes(header) + mask + masked)
def recv_text(self, timeout: float = 5.0) -> str | None:
self.sock.settimeout(timeout)
while True:
first = self.sock.recv(2)
if not first:
return None
opcode = first[0] & 0x0F
masked = bool(first[1] & 0x80)
length = first[1] & 0x7F
if length == 126:
length = struct.unpack("!H", self._read_exact(2))[0]
elif length == 127:
length = struct.unpack("!Q", self._read_exact(8))[0]
mask = self._read_exact(4) if masked else b""
payload = self._read_exact(length)
if masked:
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
if opcode == 0x8:
return None
if opcode == 0x9:
self._send_pong(payload)
continue
if opcode == 0x1:
return payload.decode("utf-8", "replace")
def _send_pong(self, payload: bytes) -> None:
self.sock.sendall(bytes([0x8A, len(payload)]) + payload)
def _read_exact(self, n: int) -> bytes:
chunks = []
remaining = n
while remaining:
chunk = self.sock.recv(remaining)
if not chunk:
raise EOFError("socket closed")
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def close(self) -> None:
self.sock.close()
def http_json(url: str, method: str = "GET", headers: dict[str, str] | None = None) -> tuple[dict, list[str]]:
req = urllib.request.Request(url, method=method, headers=headers or {})
with urllib.request.urlopen(req, timeout=15) as resp:
cookies = resp.headers.get_all("Set-Cookie") or []
return json.loads(resp.read()), cookies
def core_probe(seconds: int) -> None:
print("== SignalR Core ==")
neg, cookies = http_json(
CORE_NEGOTIATE,
method="POST",
headers={"Origin": "https://www.formula1.com", "User-Agent": "Mozilla/5.0", "Content-Length": "0"},
)
token = urllib.parse.quote(neg["connectionToken"], safe="")
ws = WebSocket(
CORE_WS_HOST,
f"/signalrcore?id={token}",
{"Cookie": "; ".join(cookies)} if cookies else None,
)
try:
ws.send_text(json.dumps({"protocol": "json", "version": 1}, separators=(",", ":")) + RS.decode())
print("handshake:", repr(ws.recv_text()))
ws.send_text(
json.dumps(
{"type": 1, "target": "subscribe", "arguments": [TOPICS], "invocationId": "1"},
separators=(",", ":"),
)
+ RS.decode()
)
collect(ws, seconds)
finally:
ws.close()
def legacy_probe(seconds: int, bearer: str | None) -> None:
print("\n== Legacy SignalR ==")
query = urllib.parse.quote(LEGACY_HUB, safe="")
headers = {"User-Agent": "BestHTTP"}
if bearer:
headers["Authorization"] = f"Bearer {bearer}"
neg_url = f"https://livetiming.formula1.com/signalr/negotiate?clientProtocol=1.5&connectionData={query}"
try:
neg, cookies = http_json(neg_url, headers=headers)
except Exception as exc:
print("negotiate failed:", exc)
return
token = urllib.parse.quote(neg["ConnectionToken"], safe="")
path = f"/signalr/connect?clientProtocol=1.5&transport=webSockets&connectionToken={token}&connectionData={query}"
ws_headers = {"User-Agent": "BestHTTP"}
if cookies:
ws_headers["Cookie"] = "; ".join(cookies)
if bearer:
ws_headers["Authorization"] = f"Bearer {bearer}"
ws = WebSocket(CORE_WS_HOST, path, ws_headers)
try:
ws.send_text(json.dumps({"H": "Streaming", "M": "Subscribe", "A": [TOPICS], "I": 1}, separators=(",", ":")))
collect(ws, seconds)
finally:
ws.close()
def collect(ws: WebSocket, seconds: int) -> None:
deadline = time.time() + seconds
seen: dict[str, int] = {}
while time.time() < deadline:
try:
message = ws.recv_text(timeout=min(5, max(1, deadline - time.time())))
except TimeoutError:
continue
if not message:
break
for frame in message.split(RS.decode()):
if not frame:
continue
try:
payload = json.loads(frame)
except json.JSONDecodeError:
continue
record_topics(payload, seen)
print("seen topics:")
for topic in sorted(seen):
print(f" {topic}: {seen[topic]}")
if not any(topic.startswith("Position") for topic in seen):
print(" (no Position / Position.z topics observed)")
def record_topics(payload: dict, seen: dict[str, int]) -> None:
result = payload.get("result") or payload.get("R")
if isinstance(result, dict):
for topic, data in result.items():
seen[topic] = seen.get(topic, 0) + 1
maybe_print_position(topic, data)
for message in payload.get("M") or []:
args = message.get("A") or []
if len(args) >= 2:
topic = args[0]
seen[topic] = seen.get(topic, 0) + 1
maybe_print_position(topic, args[1])
if payload.get("target") == "feed":
args = payload.get("arguments") or []
if len(args) >= 2:
topic = args[0]
seen[topic] = seen.get(topic, 0) + 1
maybe_print_position(topic, args[1])
def maybe_print_position(topic: str, data) -> None:
if topic in {"Position", "Position.z"}:
text = json.dumps(data)[:600] if not isinstance(data, str) else data[:600]
print(f"POSITION TOPIC {topic}: {text}")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--seconds", type=int, default=20)
parser.add_argument("--legacy", action="store_true", help="also probe legacy SignalR")
args = parser.parse_args()
core_probe(args.seconds)
if args.legacy:
legacy_probe(args.seconds, os.environ.get("BOXBOX_F1_LIVE_BEARER_TOKEN") or os.environ.get("F1_LIVE_BEARER_TOKEN"))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -28,6 +28,41 @@ test.describe('Command Center', () => {
await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible() await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible()
}) })
test('archived live snapshot does not mark command center live', async ({ page }) => {
await page.route('**/api/v1/live/state', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
is_live: false,
data: null,
last_snapshot: {
Drivers: { '1': { RacingNumber: '1', Position: 1 } },
DriverInfo: { '1': { RacingNumber: '1', Tla: 'VER', TeamColour: '3671C6' } },
Tyres: {},
RCMessages: [],
Weather: {},
Session: { MeetingName: 'Archived GP', SessionName: 'Race', SessionType: 'Race' },
TeamRadio: [],
SessionStatus: 'Finished',
TrackStatus: '1',
CurrentLap: 57,
TotalLaps: 57,
Clock: '',
ClockRefTime: '',
ClockExtrapolating: false,
Stints: {},
},
last_snapshot_at: '2026-07-04T14:00:00Z',
}),
}),
)
await page.goto('/')
await expect(page.getByTestId('command-center')).toBeVisible()
await expect(page.getByTestId('cc-live-status')).toContainText('No live session')
await expect(page.getByTestId('cc-live-status')).not.toContainText('Live session active')
})
test('existing routes continue to work', async ({ page }) => { test('existing routes continue to work', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`) await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await expect(page.getByTestId('race-hub')).toBeVisible() await expect(page.getByTestId('race-hub')).toBeVisible()

View File

@@ -239,4 +239,39 @@ test.describe('Live Timing (no session)', () => {
await expect(page.getByTestId('live-empty')).toBeVisible() await expect(page.getByTestId('live-empty')).toBeVisible()
await expect(page.getByTestId('live-page')).toContainText('No live session active') await expect(page.getByTestId('live-page')).toContainText('No live session active')
}) })
test('renders an archived snapshot only after View Last Session', async ({ page }) => {
await page.route('**/api/v1/live/state', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
is_live: false,
data: null,
last_snapshot: {
...raceSnapshot.data,
SessionStatus: 'Finished',
},
last_positions: {
'1': { x: 100, y: -50, z: 2, status: 'OnTrack' },
},
last_snapshot_at: '2026-07-04T14:00:00Z',
}),
}),
)
await page.route('**/api/v1/live/stream', (route) =>
route.fulfill({
contentType: 'text/event-stream',
body: 'event: heartbeat\ndata: {}\n\n',
}),
)
await page.goto('/live')
await expect(page.getByTestId('live-empty')).toContainText('No live session active')
await expect(page.getByText('Timing Tower')).toHaveCount(0)
await page.getByRole('button', { name: 'View Last Session' }).click()
await expect(page.getByTestId('live-archive-strip')).toContainText('Archived snapshot')
await expect(page.getByText('Timing Tower')).toBeVisible()
await expect(page.locator('.live-state')).toContainText('archive')
})
}) })