mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
Compare commits
11 Commits
v0.03
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8de2068c1 | ||
|
|
1782fd313c | ||
|
|
be7eb3c034 | ||
|
|
71523641ad | ||
|
|
e51cabba62 | ||
|
|
fc6fc36d8f | ||
|
|
16d82afad5 | ||
|
|
91199ff5c0 | ||
|
|
144e93ee44 | ||
|
|
f4f5071e26 | ||
|
|
a1d71900d1 |
@@ -2,6 +2,8 @@ import type {
|
||||
ArticleContent,
|
||||
CarDataSample,
|
||||
ChampionshipHub,
|
||||
EnrichedGrid,
|
||||
EnrichedResult,
|
||||
LapsComparisonResponse,
|
||||
LiveStateResponse,
|
||||
LiveSessionMeta,
|
||||
@@ -40,7 +42,11 @@ export async function fetchLocalMeetings(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) {
|
||||
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 : []
|
||||
}
|
||||
|
||||
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[]> {
|
||||
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -16,6 +16,9 @@ export function Nav() {
|
||||
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
||||
Race Hub
|
||||
</Link>
|
||||
<Link to="/preview" activeProps={{ className: 'active' }}>
|
||||
Preview
|
||||
</Link>
|
||||
<Link to="/championship" activeProps={{ className: 'active' }}>
|
||||
Championship
|
||||
</Link>
|
||||
|
||||
@@ -5,18 +5,20 @@ import { WeatherStrip } from './WeatherStrip'
|
||||
|
||||
interface Props {
|
||||
isLive: boolean
|
||||
isArchive?: boolean
|
||||
snapshot: LiveStreamData
|
||||
rows: LiveTimingRow[]
|
||||
connection: 'connected' | 'connecting' | 'disconnected' | 'error'
|
||||
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 clock = extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now)
|
||||
const display = liveSessionDisplay(session, rows)
|
||||
const atRiskLabel =
|
||||
display.atRiskStart && display.atRiskEnd ? `P${display.atRiskStart}-P${display.atRiskEnd} at risk` : ''
|
||||
const stateLabel = isLive ? 'live' : isArchive ? 'archive' : 'stale'
|
||||
|
||||
return (
|
||||
<section className="live-banner">
|
||||
@@ -39,7 +41,7 @@ export function SessionBanner({ isLive, snapshot, rows, connection, now }: Props
|
||||
<span>
|
||||
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
|
||||
</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>
|
||||
|
||||
123
frontend/src/lib/preview.ts
Normal file
123
frontend/src/lib/preview.ts
Normal 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)}`
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchLiveState, fetchLiveTrackOutline } from '../api'
|
||||
import { fetchLiveState } from '../api'
|
||||
import type { LivePosition, LiveStreamData } from '../types'
|
||||
import {
|
||||
loadPinnedDrivers,
|
||||
@@ -25,14 +25,17 @@ import { PinnedDrivers } from '../components/live/PinnedDrivers'
|
||||
import { RaceControlFeed } from '../components/live/RaceControlFeed'
|
||||
import { EventRail } from '../components/live/EventRail'
|
||||
import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
|
||||
import { TrackMap } from '../components/live/TrackMap'
|
||||
import { TyreDegPanel } from '../components/live/TyreDegPanel'
|
||||
import { Radio } from 'lucide-react'
|
||||
import { Archive, Radio } from 'lucide-react'
|
||||
|
||||
type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
|
||||
|
||||
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 [streamStatus, setStreamStatus] = useState<StreamStatus>('connecting')
|
||||
const [now, setNow] = useState(Date.now())
|
||||
@@ -43,6 +46,10 @@ export function LiveTimingPage() {
|
||||
const [events, setEvents] = useState<LiveEvent[]>([])
|
||||
const prevSnapshotRef = useRef<LiveStreamData | null>(null)
|
||||
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({
|
||||
queryKey: ['live-state'],
|
||||
@@ -50,24 +57,31 @@ export function LiveTimingPage() {
|
||||
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(() => {
|
||||
if (!data) return
|
||||
setIsLive(data.is_live)
|
||||
setSnapshot(data.data)
|
||||
const nextLive = data.is_live && Boolean(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])
|
||||
|
||||
useEffect(() => {
|
||||
archiveModeRef.current = archiveMode
|
||||
if (archiveMode) {
|
||||
setPositions(archivePositions)
|
||||
}
|
||||
}, [archiveMode, archivePositions])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
@@ -90,8 +104,23 @@ export function LiveTimingPage() {
|
||||
events.addEventListener('snapshot', (event) => {
|
||||
const state = parseLiveStateEvent(event.data)
|
||||
if (!state || cancelled) return
|
||||
setIsLive(state.is_live)
|
||||
setSnapshot(state.data)
|
||||
const nextLive = state.is_live && Boolean(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')
|
||||
})
|
||||
|
||||
@@ -174,6 +203,19 @@ export function LiveTimingPage() {
|
||||
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 (
|
||||
<div className="page live-page" data-testid="live-page">
|
||||
{isError && (
|
||||
@@ -182,12 +224,18 @@ export function LiveTimingPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{streamStatus === 'disconnected' && snapshot && (
|
||||
{streamStatus === 'disconnected' && snapshot && !archiveMode && (
|
||||
<div className="live-status-strip live-status-warn">
|
||||
Stream disconnected — showing last received snapshot
|
||||
</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 && (
|
||||
<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)' }}>
|
||||
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>
|
||||
{hasArchive && (
|
||||
<button type="button" className="live-archive-btn" onClick={handleViewArchive}>
|
||||
<Archive size={15} />
|
||||
View Last Session
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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} />
|
||||
<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} />
|
||||
<div className="live-columns">
|
||||
<div className="live-tower-col">
|
||||
|
||||
442
frontend/src/pages/RacePreviewPage.tsx
Normal file
442
frontend/src/pages/RacePreviewPage.tsx
Normal 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' 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>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { DataLibraryPage } from './pages/DataLibraryPage'
|
||||
import { LiveTimingPage } from './pages/LiveTimingPage'
|
||||
import { BriefingPage } from './pages/BriefingPage'
|
||||
import { ChampionshipPage } from './pages/ChampionshipPage'
|
||||
import { RacePreviewPage } from './pages/RacePreviewPage'
|
||||
|
||||
type RaceHubSearch = {
|
||||
session_key?: number
|
||||
@@ -70,6 +71,12 @@ export const briefingRoute = createRoute({
|
||||
component: BriefingPage,
|
||||
})
|
||||
|
||||
export const previewRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/preview',
|
||||
component: RacePreviewPage,
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
commandCenterRoute,
|
||||
raceHubRoute,
|
||||
@@ -78,6 +85,7 @@ const routeTree = rootRoute.addChildren([
|
||||
liveTimingRoute,
|
||||
championshipRoute,
|
||||
briefingRoute,
|
||||
previewRoute,
|
||||
])
|
||||
|
||||
export const router = createRouter({ routeTree })
|
||||
|
||||
@@ -808,6 +808,12 @@ a { color: inherit; text-decoration: none; }
|
||||
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-status {
|
||||
display: flex;
|
||||
@@ -815,6 +821,29 @@ a { color: inherit; text-decoration: none; }
|
||||
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); }
|
||||
|
||||
.live-conn,
|
||||
|
||||
309
frontend/src/styles/preview.css
Normal file
309
frontend/src/styles/preview.css
Normal 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;
|
||||
}
|
||||
}
|
||||
166
frontend/src/test/LiveTimingPage.test.tsx
Normal file
166
frontend/src/test/LiveTimingPage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
276
frontend/src/test/RacePreviewPage.test.tsx
Normal file
276
frontend/src/test/RacePreviewPage.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -163,6 +163,20 @@ describe('live transforms', () => {
|
||||
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', () => {
|
||||
const rows = sortLiveTimingRows(snapshot)
|
||||
expect(rows.map((row) => row.RacingNumber)).toEqual(['16', '1', '44'])
|
||||
|
||||
226
frontend/src/test/preview.test.ts
Normal file
226
frontend/src/test/preview.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,7 @@ export interface Meeting {
|
||||
country_name: string
|
||||
country_code: string
|
||||
country_flag: string
|
||||
circuit_key?: number
|
||||
circuit_short_name: string
|
||||
date_start: string
|
||||
date_end: string
|
||||
@@ -23,6 +24,7 @@ export interface Session {
|
||||
session_key: number
|
||||
session_name: string
|
||||
session_type: string
|
||||
circuit_key?: number
|
||||
meeting_key: number
|
||||
date_start: string
|
||||
date_end: string
|
||||
@@ -206,6 +208,9 @@ export interface Weekend {
|
||||
export interface LiveStateResponse {
|
||||
is_live: boolean
|
||||
data: LiveStreamData | null
|
||||
last_snapshot?: LiveStreamData | null
|
||||
last_positions?: Record<string, LivePosition> | null
|
||||
last_snapshot_at?: string
|
||||
}
|
||||
|
||||
export interface LivePosition {
|
||||
@@ -316,6 +321,7 @@ export interface LiveStreamData {
|
||||
Weather: LiveWeatherData
|
||||
Session: LiveSessionMeta
|
||||
TeamRadio: LiveRadioCapture[]
|
||||
SessionStatus?: string
|
||||
TrackStatus: string
|
||||
CurrentLap: number
|
||||
TotalLaps: number
|
||||
|
||||
@@ -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) {
|
||||
state := live.NewState()
|
||||
data := json.RawMessage(`{
|
||||
|
||||
@@ -197,7 +197,7 @@ func connectToF1LegacySignalR(dataChan chan LiveStreamData) error {
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -26,6 +26,7 @@ type State struct {
|
||||
Weather LiveWeatherData
|
||||
Session LiveSessionMeta
|
||||
TeamRadio []LiveRadioCapture
|
||||
SessionStatus string
|
||||
TrackStatus string
|
||||
CurrentLap int
|
||||
TotalLaps int
|
||||
@@ -93,6 +94,7 @@ func (s *State) Snapshot() LiveStreamData {
|
||||
Weather: s.Weather,
|
||||
Session: s.Session,
|
||||
TeamRadio: cpyRadio,
|
||||
SessionStatus: s.SessionStatus,
|
||||
TrackStatus: s.TrackStatus,
|
||||
CurrentLap: s.CurrentLap,
|
||||
TotalLaps: s.TotalLaps,
|
||||
@@ -273,6 +275,14 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
|
||||
s.TrackStatus = ts.Status
|
||||
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":
|
||||
var rcm struct {
|
||||
Messages json.RawMessage `json:"Messages"`
|
||||
|
||||
@@ -162,6 +162,7 @@ type LiveStreamData struct {
|
||||
Weather LiveWeatherData
|
||||
Session LiveSessionMeta
|
||||
TeamRadio []LiveRadioCapture
|
||||
SessionStatus string
|
||||
TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC
|
||||
CurrentLap int
|
||||
TotalLaps int
|
||||
@@ -173,3 +174,27 @@ type LiveStreamData struct {
|
||||
PositionUpdated 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)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,15 @@ type ChampionshipInputs struct {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
meetings, err := s.ListMeetingsByYear(year)
|
||||
if err != nil {
|
||||
@@ -51,60 +60,101 @@ func (s *Service) GetChampionshipInputs(year int) (ChampionshipInputs, error) {
|
||||
}
|
||||
|
||||
raceKey := 0
|
||||
sprintKey := 0
|
||||
for _, sess := range sessions {
|
||||
if strings.EqualFold(sess.SessionName, "Race") {
|
||||
raceKey = sess.SessionKey
|
||||
break
|
||||
} else if strings.EqualFold(sess.SessionName, "Sprint") {
|
||||
sprintKey = sess.SessionKey
|
||||
}
|
||||
}
|
||||
if raceKey == 0 {
|
||||
if raceKey == 0 && sprintKey == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
results, err := s.store.ListSessionResults(raceKey)
|
||||
if err != nil {
|
||||
return ChampionshipInputs{}, err
|
||||
}
|
||||
grid, err := s.store.ListStartingGrid(raceKey)
|
||||
if err != nil {
|
||||
return ChampionshipInputs{}, err
|
||||
}
|
||||
// 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
|
||||
|
||||
race := ChampionshipRace{
|
||||
Meeting: meeting,
|
||||
RaceSessionKey: raceKey,
|
||||
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)
|
||||
if raceKey != 0 {
|
||||
results, err := s.store.ListSessionResults(raceKey)
|
||||
if err != nil {
|
||||
return ChampionshipInputs{}, err
|
||||
}
|
||||
grid, err := s.store.ListStartingGrid(raceKey)
|
||||
if err != nil {
|
||||
return ChampionshipInputs{}, err
|
||||
}
|
||||
race := ChampionshipRace{
|
||||
Meeting: meeting,
|
||||
RaceSessionKey: raceKey,
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
|
||||
latestSessionKey = raceKey
|
||||
latestMeetingKey = int(meeting.MeetingKey)
|
||||
drivers, err := s.driversForSession(raceKey)
|
||||
if err != nil {
|
||||
return ChampionshipInputs{}, err
|
||||
}
|
||||
for _, d := range drivers {
|
||||
inputs.DriverMap[d.DriverNumber] = d
|
||||
}
|
||||
|
||||
for _, result := range results {
|
||||
pointsByDriver[result.DriverNumber] += result.Points
|
||||
team := inputs.DriverMap[result.DriverNumber].TeamName
|
||||
if team != "" {
|
||||
pointsByTeam[team] += result.Points
|
||||
if len(results) > 0 {
|
||||
latestSessionKey = raceKey
|
||||
latestMeetingKey = int(meeting.MeetingKey)
|
||||
drivers, err := s.driversForSession(raceKey)
|
||||
if err != nil {
|
||||
return ChampionshipInputs{}, err
|
||||
}
|
||||
for _, d := range drivers {
|
||||
inputs.DriverMap[d.DriverNumber] = d
|
||||
}
|
||||
for _, result := range results {
|
||||
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)
|
||||
|
||||
@@ -375,3 +375,56 @@ func TestGetWeekendWithSessions(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,21 @@ type SSEHub struct {
|
||||
deregister chan *sseClient
|
||||
broadcast chan sseEvent
|
||||
|
||||
mu sync.RWMutex
|
||||
lastSnapshot *live.LiveStreamData
|
||||
lastPositions map[string]live.LivePositionData
|
||||
isLive bool
|
||||
mu sync.RWMutex
|
||||
activeSnapshot *live.LiveStreamData
|
||||
activePositions map[string]live.LivePositionData
|
||||
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 {
|
||||
@@ -51,19 +62,16 @@ func (h *SSEHub) run() {
|
||||
case c := <-h.register:
|
||||
clients[c] = true
|
||||
// Send catch-up snapshot so new clients see current state immediately.
|
||||
h.mu.RLock()
|
||||
snap := h.lastSnapshot
|
||||
positions := cloneLivePositions(h.lastPositions)
|
||||
live := h.isLive
|
||||
h.mu.RUnlock()
|
||||
if snap != nil {
|
||||
if data, err := json.Marshal(map[string]any{"data": snap, "is_live": live}); err == nil {
|
||||
state := h.State()
|
||||
if state.Data != nil || state.LastSnapshot != nil {
|
||||
if data, err := json.Marshal(state); err == nil {
|
||||
select {
|
||||
case c.ch <- formatSSEFrame("snapshot", data):
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
positions := h.ActivePositions()
|
||||
if len(positions) > 0 {
|
||||
if data, err := json.Marshal(positions); err == nil {
|
||||
select {
|
||||
@@ -96,11 +104,125 @@ func formatSSEFrame(event string, data []byte) []byte {
|
||||
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.
|
||||
func (h *SSEHub) Snapshot() (*live.LiveStreamData, bool) {
|
||||
// State returns the active live snapshot and the retained in-memory archive.
|
||||
func (h *SSEHub) State() liveStatePayload {
|
||||
h.mu.RLock()
|
||||
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.
|
||||
@@ -129,13 +251,8 @@ func (s *Server) signalRLoop() {
|
||||
log.Printf("web: live feed ended: %v", err)
|
||||
}
|
||||
|
||||
s.hub.mu.Lock()
|
||||
s.hub.isLive = false
|
||||
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 {
|
||||
state := s.hub.deactivate(time.Now())
|
||||
if payload, err := json.Marshal(state); err == nil {
|
||||
s.hub.broadcast <- sseEvent{name: "snapshot", data: payload}
|
||||
}
|
||||
|
||||
@@ -167,24 +284,20 @@ func (s *Server) connectAndDrain() error {
|
||||
select {
|
||||
case data := <-dataChan:
|
||||
now := time.Now()
|
||||
s.hub.mu.Lock()
|
||||
if data.SnapshotUpdated {
|
||||
s.hub.lastSnapshot = &data
|
||||
}
|
||||
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 {
|
||||
state := s.hub.applySnapshot(data, now)
|
||||
if payload, err := json.Marshal(state); err == nil {
|
||||
s.hub.broadcast <- sseEvent{name: "snapshot", data: payload}
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
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.
|
||||
func (s *Server) handleLiveState(w http.ResponseWriter, r *http.Request) {
|
||||
snap, isLive := s.hub.Snapshot()
|
||||
writeJSON(w, map[string]any{
|
||||
"is_live": isLive,
|
||||
"data": snap,
|
||||
})
|
||||
writeJSON(w, s.hub.State())
|
||||
}
|
||||
|
||||
// handleSSEStream is the persistent SSE endpoint for live data.
|
||||
|
||||
90
internal/web/live_archive_test.go
Normal file
90
internal/web/live_archive_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
264
scripts/debug/f1_live_socket_probe.py
Normal file
264
scripts/debug/f1_live_socket_probe.py
Normal 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())
|
||||
@@ -28,6 +28,41 @@ test.describe('Command Center', () => {
|
||||
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 }) => {
|
||||
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||
|
||||
@@ -239,4 +239,39 @@ test.describe('Live Timing (no session)', () => {
|
||||
await expect(page.getByTestId('live-empty')).toBeVisible()
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user