Compare commits

...

9 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
14 changed files with 1843 additions and 65 deletions

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

@@ -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>

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,7 +25,6 @@ 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 { Archive, Radio } from 'lucide-react' import { Archive, Radio } from 'lucide-react'
@@ -58,18 +57,6 @@ 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
const nextLive = data.is_live && Boolean(data.data) const nextLive = data.is_live && Boolean(data.data)
@@ -284,14 +271,6 @@ export function LiveTimingPage() {
/> />
<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

@@ -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

@@ -152,4 +152,15 @@ describe('LiveTimingPage archive mode', () => {
expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active') expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active')
expect(screen.queryByRole('button', { name: /view last session/i })).not.toBeInTheDocument() 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

@@ -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

@@ -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

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,16 +60,24 @@ 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
} }
// Always build Races slice from the Race session only (for UI),
// but include sprint points for standings.
var raceResults []models.SessionResult
var raceGrid []models.StartingGrid
if raceKey != 0 {
results, err := s.store.ListSessionResults(raceKey) results, err := s.store.ListSessionResults(raceKey)
if err != nil { if err != nil {
return ChampionshipInputs{}, err return ChampionshipInputs{}, err
@@ -69,7 +86,6 @@ func (s *Service) GetChampionshipInputs(year int) (ChampionshipInputs, error) {
if err != nil { if err != nil {
return ChampionshipInputs{}, err return ChampionshipInputs{}, err
} }
race := ChampionshipRace{ race := ChampionshipRace{
Meeting: meeting, Meeting: meeting,
RaceSessionKey: raceKey, RaceSessionKey: raceKey,
@@ -83,11 +99,9 @@ func (s *Service) GetChampionshipInputs(year int) (ChampionshipInputs, error) {
race.Grid = append(race.Grid, gridToModel(entry)) race.Grid = append(race.Grid, gridToModel(entry))
} }
inputs.Races = append(inputs.Races, race) inputs.Races = append(inputs.Races, race)
raceResults = race.Results
if len(results) == 0 { if len(results) > 0 {
continue
}
latestSessionKey = raceKey latestSessionKey = raceKey
latestMeetingKey = int(meeting.MeetingKey) latestMeetingKey = int(meeting.MeetingKey)
drivers, err := s.driversForSession(raceKey) drivers, err := s.driversForSession(raceKey)
@@ -97,7 +111,6 @@ func (s *Service) GetChampionshipInputs(year int) (ChampionshipInputs, error) {
for _, d := range drivers { for _, d := range drivers {
inputs.DriverMap[d.DriverNumber] = d inputs.DriverMap[d.DriverNumber] = d
} }
for _, result := range results { for _, result := range results {
pointsByDriver[result.DriverNumber] += result.Points pointsByDriver[result.DriverNumber] += result.Points
team := inputs.DriverMap[result.DriverNumber].TeamName team := inputs.DriverMap[result.DriverNumber].TeamName
@@ -106,6 +119,43 @@ func (s *Service) GetChampionshipInputs(year int) (ChampionshipInputs, error) {
} }
} }
} }
}
// 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)
inputs.Teams = derivedTeamStandings(pointsByTeam, latestMeetingKey, latestSessionKey) inputs.Teams = derivedTeamStandings(pointsByTeam, 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

@@ -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())