mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
Compare commits
1 Commits
feat/issue
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7212eb9b44 |
@@ -2,8 +2,6 @@ import type {
|
|||||||
ArticleContent,
|
ArticleContent,
|
||||||
CarDataSample,
|
CarDataSample,
|
||||||
ChampionshipHub,
|
ChampionshipHub,
|
||||||
EnrichedGrid,
|
|
||||||
EnrichedResult,
|
|
||||||
LapsComparisonResponse,
|
LapsComparisonResponse,
|
||||||
LiveStateResponse,
|
LiveStateResponse,
|
||||||
LiveSessionMeta,
|
LiveSessionMeta,
|
||||||
@@ -42,11 +40,7 @@ export async function fetchLocalMeetings(year: number): Promise<Meeting[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
|
export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
|
||||||
return fetchMeetings(year, 'openf1')
|
const res = await fetch(`/api/v1/meetings?year=${year}&source=openf1`)
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchMeetings(year: number, source = 'auto'): Promise<Meeting[]> {
|
|
||||||
const res = await fetch(`/api/v1/meetings?year=${year}&source=${source}`)
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
}
|
}
|
||||||
@@ -54,32 +48,6 @@ export async function fetchMeetings(year: number, source = 'auto'): Promise<Meet
|
|||||||
return Array.isArray(meetings) ? meetings : []
|
return Array.isArray(meetings) ? meetings : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchResults(sessionKey: number, source = 'auto'): Promise<EnrichedResult[]> {
|
|
||||||
const res = await fetch(`/api/v1/results?session_key=${sessionKey}&source=${source}`)
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
|
||||||
}
|
|
||||||
const results = await res.json()
|
|
||||||
return Array.isArray(results) ? results : []
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchStartingGrid(sessionKey: number, source = 'auto'): Promise<EnrichedGrid[]> {
|
|
||||||
const res = await fetch(`/api/v1/grid?session_key=${sessionKey}&source=${source}`)
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
|
||||||
}
|
|
||||||
const grid = await res.json()
|
|
||||||
return Array.isArray(grid) ? grid : []
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchTrackOutline(circuitKey: number, year: number): Promise<TrackOutline | null> {
|
|
||||||
const res = await fetch(`/api/v1/track-outline?circuit_key=${circuitKey}&year=${year}`)
|
|
||||||
if (!res.ok) return null
|
|
||||||
const data = await res.json()
|
|
||||||
if (data?.error || !Array.isArray(data?.points) || data.points.length < 2) return null
|
|
||||||
return data as TrackOutline
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise<Session[]> {
|
export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise<Session[]> {
|
||||||
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
|
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -16,9 +16,6 @@ export function Nav() {
|
|||||||
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
||||||
Race Hub
|
Race Hub
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/preview" activeProps={{ className: 'active' }}>
|
|
||||||
Preview
|
|
||||||
</Link>
|
|
||||||
<Link to="/championship" activeProps={{ className: 'active' }}>
|
<Link to="/championship" activeProps={{ className: 'active' }}>
|
||||||
Championship
|
Championship
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
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,442 +0,0 @@
|
|||||||
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,7 +6,6 @@ import { DataLibraryPage } from './pages/DataLibraryPage'
|
|||||||
import { LiveTimingPage } from './pages/LiveTimingPage'
|
import { LiveTimingPage } from './pages/LiveTimingPage'
|
||||||
import { BriefingPage } from './pages/BriefingPage'
|
import { BriefingPage } from './pages/BriefingPage'
|
||||||
import { ChampionshipPage } from './pages/ChampionshipPage'
|
import { ChampionshipPage } from './pages/ChampionshipPage'
|
||||||
import { RacePreviewPage } from './pages/RacePreviewPage'
|
|
||||||
|
|
||||||
type RaceHubSearch = {
|
type RaceHubSearch = {
|
||||||
session_key?: number
|
session_key?: number
|
||||||
@@ -71,12 +70,6 @@ export const briefingRoute = createRoute({
|
|||||||
component: BriefingPage,
|
component: BriefingPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const previewRoute = createRoute({
|
|
||||||
getParentRoute: () => rootRoute,
|
|
||||||
path: '/preview',
|
|
||||||
component: RacePreviewPage,
|
|
||||||
})
|
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
commandCenterRoute,
|
commandCenterRoute,
|
||||||
raceHubRoute,
|
raceHubRoute,
|
||||||
@@ -85,7 +78,6 @@ const routeTree = rootRoute.addChildren([
|
|||||||
liveTimingRoute,
|
liveTimingRoute,
|
||||||
championshipRoute,
|
championshipRoute,
|
||||||
briefingRoute,
|
briefingRoute,
|
||||||
previewRoute,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
export const router = createRouter({ routeTree })
|
export const router = createRouter({ routeTree })
|
||||||
|
|||||||
@@ -1,309 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -156,6 +156,7 @@ describe('CommandCenterPage', () => {
|
|||||||
race_control: [],
|
race_control: [],
|
||||||
weather: [],
|
weather: [],
|
||||||
laps: [],
|
laps: [],
|
||||||
|
chapters: [],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ const raceHub: RaceHub = {
|
|||||||
is_pit_out_lap: false,
|
is_pit_out_lap: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
chapters: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
const weekend: Weekend = {
|
const weekend: Weekend = {
|
||||||
|
|||||||
@@ -1,276 +0,0 @@
|
|||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
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,7 +12,6 @@ export interface Meeting {
|
|||||||
country_name: string
|
country_name: string
|
||||||
country_code: string
|
country_code: string
|
||||||
country_flag: string
|
country_flag: string
|
||||||
circuit_key?: number
|
|
||||||
circuit_short_name: string
|
circuit_short_name: string
|
||||||
date_start: string
|
date_start: string
|
||||||
date_end: string
|
date_end: string
|
||||||
@@ -24,7 +23,6 @@ export interface Session {
|
|||||||
session_key: number
|
session_key: number
|
||||||
session_name: string
|
session_name: string
|
||||||
session_type: string
|
session_type: string
|
||||||
circuit_key?: number
|
|
||||||
meeting_key: number
|
meeting_key: number
|
||||||
date_start: string
|
date_start: string
|
||||||
date_end: string
|
date_end: string
|
||||||
@@ -91,6 +89,17 @@ export interface RaceHub {
|
|||||||
race_control: RaceControlMessage[]
|
race_control: RaceControlMessage[]
|
||||||
weather: WeatherSample[]
|
weather: WeatherSample[]
|
||||||
laps: Lap[]
|
laps: Lap[]
|
||||||
|
chapters: Chapter[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Chapter {
|
||||||
|
kind: 'start' | 'safety_car' | 'virtual_safety_car' | 'red_flag' | 'pit_phase' | 'decisive_swing' | 'finish' | string
|
||||||
|
title: string
|
||||||
|
start_lap: number
|
||||||
|
end_lap: number
|
||||||
|
start_time?: string
|
||||||
|
end_time?: string
|
||||||
|
driver_numbers: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Stint {
|
export interface Stint {
|
||||||
|
|||||||
628
internal/chapters/chapters.go
Normal file
628
internal/chapters/chapters.go
Normal file
@@ -0,0 +1,628 @@
|
|||||||
|
package chapters
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindStart = "start"
|
||||||
|
KindSafetyCar = "safety_car"
|
||||||
|
KindVirtualSafetyCar = "virtual_safety_car"
|
||||||
|
KindRedFlag = "red_flag"
|
||||||
|
KindPitPhase = "pit_phase"
|
||||||
|
KindDecisiveSwing = "decisive_swing"
|
||||||
|
KindFinish = "finish"
|
||||||
|
|
||||||
|
pitPhaseWindowLaps = 3
|
||||||
|
pitPhaseShare = 0.30
|
||||||
|
minPitPhaseStops = 2
|
||||||
|
maxDecisiveSwings = 3
|
||||||
|
decisiveAfterLap = 5
|
||||||
|
structuralPriority = 110
|
||||||
|
flagPriority = 100
|
||||||
|
pitPhasePriority = 50
|
||||||
|
decisivePriority = 40
|
||||||
|
)
|
||||||
|
|
||||||
|
type RaceControl = models.RaceControl
|
||||||
|
type PositionSample = models.Position
|
||||||
|
type Lap = models.Lap
|
||||||
|
|
||||||
|
// Chapter is a deterministic replay segment derived from timing and race-control data.
|
||||||
|
type Chapter struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
StartLap int `json:"start_lap"`
|
||||||
|
EndLap int `json:"end_lap"`
|
||||||
|
StartTime string `json:"start_time,omitempty"`
|
||||||
|
EndTime string `json:"end_time,omitempty"`
|
||||||
|
DriverNumbers []int `json:"driver_numbers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect builds replay chapters from already-loaded race-hub datasets.
|
||||||
|
func Detect(rc []RaceControl, positions []PositionSample, laps []Lap, totalLaps int) []Chapter {
|
||||||
|
totalLaps = normalizeTotalLaps(totalLaps, laps, rc)
|
||||||
|
if totalLaps <= 0 && len(rc) == 0 && len(positions) == 0 && len(laps) == 0 {
|
||||||
|
return []Chapter{}
|
||||||
|
}
|
||||||
|
if totalLaps <= 0 {
|
||||||
|
totalLaps = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
lapIndex := buildLapIndex(laps)
|
||||||
|
chapters := []Chapter{
|
||||||
|
{
|
||||||
|
Kind: KindStart,
|
||||||
|
Title: "Start",
|
||||||
|
StartLap: 1,
|
||||||
|
EndLap: minInt(1, totalLaps),
|
||||||
|
StartTime: lapIndex.lapStart(1),
|
||||||
|
EndTime: lapIndex.lapEnd(1),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
chapters = append(chapters, detectFlagPeriods(rc, lapIndex, totalLaps)...)
|
||||||
|
chapters = append(chapters, detectPitPhases(laps, lapIndex)...)
|
||||||
|
chapters = append(chapters, detectDecisiveSwings(positions, lapIndex, totalLaps)...)
|
||||||
|
chapters = append(chapters, detectFinish(rc, lapIndex, totalLaps))
|
||||||
|
|
||||||
|
return resolveConflicts(chapters)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTotalLaps(totalLaps int, laps []Lap, rc []RaceControl) int {
|
||||||
|
for _, l := range laps {
|
||||||
|
if l.LapNumber > totalLaps {
|
||||||
|
totalLaps = l.LapNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, msg := range rc {
|
||||||
|
if msg.LapNumber != nil && *msg.LapNumber > totalLaps {
|
||||||
|
totalLaps = *msg.LapNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return totalLaps
|
||||||
|
}
|
||||||
|
|
||||||
|
type lapIndex struct {
|
||||||
|
byLap map[int]string
|
||||||
|
events []lapEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
type lapEvent struct {
|
||||||
|
lap int
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildLapIndex(laps []Lap) lapIndex {
|
||||||
|
idx := lapIndex{byLap: map[int]string{}}
|
||||||
|
for _, l := range laps {
|
||||||
|
if l.LapNumber <= 0 || l.DateStart == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := idx.byLap[l.LapNumber]; !ok {
|
||||||
|
idx.byLap[l.LapNumber] = l.DateStart
|
||||||
|
}
|
||||||
|
at, ok := parseTime(l.DateStart)
|
||||||
|
if ok {
|
||||||
|
idx.events = append(idx.events, lapEvent{lap: l.LapNumber, at: at})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(idx.events, func(i, j int) bool {
|
||||||
|
if idx.events[i].at.Equal(idx.events[j].at) {
|
||||||
|
return idx.events[i].lap < idx.events[j].lap
|
||||||
|
}
|
||||||
|
return idx.events[i].at.Before(idx.events[j].at)
|
||||||
|
})
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (idx lapIndex) lapStart(lap int) string {
|
||||||
|
return idx.byLap[lap]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (idx lapIndex) lapEnd(lap int) string {
|
||||||
|
if v := idx.byLap[lap+1]; v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return idx.byLap[lap]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (idx lapIndex) lapForTime(raw string) int {
|
||||||
|
at, ok := parseTime(raw)
|
||||||
|
if !ok || len(idx.events) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
lap := 0
|
||||||
|
for _, event := range idx.events {
|
||||||
|
if event.at.After(at) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lap = event.lap
|
||||||
|
}
|
||||||
|
if lap == 0 {
|
||||||
|
return idx.events[0].lap
|
||||||
|
}
|
||||||
|
return lap
|
||||||
|
}
|
||||||
|
|
||||||
|
type flagState struct {
|
||||||
|
startLap int
|
||||||
|
startTime string
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectFlagPeriods(rc []RaceControl, idx lapIndex, totalLaps int) []Chapter {
|
||||||
|
var chapters []Chapter
|
||||||
|
active := map[string]flagState{}
|
||||||
|
for _, msg := range rc {
|
||||||
|
kind, ok := flagKind(msg)
|
||||||
|
if !ok && greenFlagClear(msg) {
|
||||||
|
for activeKind, st := range active {
|
||||||
|
lap := messageLap(msg, idx)
|
||||||
|
if lap <= 0 {
|
||||||
|
lap = st.startLap
|
||||||
|
}
|
||||||
|
endLap := clampLap(lap, st.startLap, totalLaps)
|
||||||
|
chapters = append(chapters, Chapter{
|
||||||
|
Kind: activeKind,
|
||||||
|
Title: flagTitle(activeKind, st.startLap, endLap),
|
||||||
|
StartLap: st.startLap,
|
||||||
|
EndLap: endLap,
|
||||||
|
StartTime: st.startTime,
|
||||||
|
EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)),
|
||||||
|
})
|
||||||
|
delete(active, activeKind)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lap := messageLap(msg, idx)
|
||||||
|
if lap <= 0 {
|
||||||
|
lap = 1
|
||||||
|
}
|
||||||
|
if flagCleared(msg) {
|
||||||
|
st, ok := active[kind]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
endLap := clampLap(lap, st.startLap, totalLaps)
|
||||||
|
chapters = append(chapters, Chapter{
|
||||||
|
Kind: kind,
|
||||||
|
Title: flagTitle(kind, st.startLap, endLap),
|
||||||
|
StartLap: st.startLap,
|
||||||
|
EndLap: endLap,
|
||||||
|
StartTime: st.startTime,
|
||||||
|
EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)),
|
||||||
|
})
|
||||||
|
delete(active, kind)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if flagStarted(msg) {
|
||||||
|
active[kind] = flagState{
|
||||||
|
startLap: clampLap(lap, 1, totalLaps),
|
||||||
|
startTime: firstNonEmpty(msg.Date, idx.lapStart(lap)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for kind, st := range active {
|
||||||
|
endLap := totalLaps
|
||||||
|
chapters = append(chapters, Chapter{
|
||||||
|
Kind: kind,
|
||||||
|
Title: flagTitle(kind, st.startLap, endLap),
|
||||||
|
StartLap: st.startLap,
|
||||||
|
EndLap: endLap,
|
||||||
|
StartTime: st.startTime,
|
||||||
|
EndTime: idx.lapEnd(endLap),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return chapters
|
||||||
|
}
|
||||||
|
|
||||||
|
func flagKind(msg RaceControl) (string, bool) {
|
||||||
|
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
|
||||||
|
if strings.Contains(text, "VSC") || strings.Contains(text, "VIRTUAL SAFETY CAR") {
|
||||||
|
return KindVirtualSafetyCar, true
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "RED FLAG") || string(msg.Flag) == string(models.FlagRed) {
|
||||||
|
return KindRedFlag, true
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "SAFETY CAR") || msg.Category == models.CategorySafetyCar {
|
||||||
|
return KindSafetyCar, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func flagStarted(msg RaceControl) bool {
|
||||||
|
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
|
||||||
|
if strings.Contains(text, "CLEAR") || strings.Contains(text, "ENDING") || strings.Contains(text, "IN THIS LAP") || strings.Contains(text, "GREEN") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(text, "DEPLOY") ||
|
||||||
|
strings.Contains(text, "RED FLAG") ||
|
||||||
|
strings.Contains(text, "VIRTUAL SAFETY CAR") ||
|
||||||
|
strings.Contains(text, "VSC") ||
|
||||||
|
strings.Contains(text, "SAFETY CAR") ||
|
||||||
|
string(msg.Flag) == string(models.FlagRed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func flagCleared(msg RaceControl) bool {
|
||||||
|
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
|
||||||
|
return strings.Contains(text, "CLEAR") ||
|
||||||
|
strings.Contains(text, "ENDING") ||
|
||||||
|
strings.Contains(text, "IN THIS LAP") ||
|
||||||
|
strings.Contains(text, "GREEN")
|
||||||
|
}
|
||||||
|
|
||||||
|
func greenFlagClear(msg RaceControl) bool {
|
||||||
|
text := upperText(string(msg.Flag), msg.Message)
|
||||||
|
return strings.Contains(text, "GREEN")
|
||||||
|
}
|
||||||
|
|
||||||
|
func flagTitle(kind string, startLap, endLap int) string {
|
||||||
|
name := "Flag period"
|
||||||
|
switch kind {
|
||||||
|
case KindSafetyCar:
|
||||||
|
name = "Safety Car"
|
||||||
|
case KindVirtualSafetyCar:
|
||||||
|
name = "Virtual Safety Car"
|
||||||
|
case KindRedFlag:
|
||||||
|
name = "Red Flag"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s (L%d-L%d)", name, startLap, endLap)
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectPitPhases(laps []Lap, idx lapIndex) []Chapter {
|
||||||
|
type pitOut struct {
|
||||||
|
lap int
|
||||||
|
driver int
|
||||||
|
}
|
||||||
|
var stops []pitOut
|
||||||
|
for _, l := range laps {
|
||||||
|
if l.IsPitOutLap && l.LapNumber > 0 {
|
||||||
|
stops = append(stops, pitOut{lap: l.LapNumber, driver: l.DriverNumber})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(stops) < minPitPhaseStops {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sort.Slice(stops, func(i, j int) bool {
|
||||||
|
if stops[i].lap == stops[j].lap {
|
||||||
|
return stops[i].driver < stops[j].driver
|
||||||
|
}
|
||||||
|
return stops[i].lap < stops[j].lap
|
||||||
|
})
|
||||||
|
needed := int(math.Ceil(float64(len(stops)) * pitPhaseShare))
|
||||||
|
if needed < minPitPhaseStops {
|
||||||
|
needed = minPitPhaseStops
|
||||||
|
}
|
||||||
|
|
||||||
|
var windows []Chapter
|
||||||
|
for i := 0; i < len(stops); i++ {
|
||||||
|
start := stops[i].lap
|
||||||
|
end := start + pitPhaseWindowLaps - 1
|
||||||
|
drivers := map[int]bool{}
|
||||||
|
count := 0
|
||||||
|
for _, stop := range stops {
|
||||||
|
if stop.lap < start || stop.lap > end {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
drivers[stop.driver] = true
|
||||||
|
}
|
||||||
|
if count < needed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ch := Chapter{
|
||||||
|
Kind: KindPitPhase,
|
||||||
|
Title: fmt.Sprintf("Pit phase (L%d-L%d)", start, end),
|
||||||
|
StartLap: start,
|
||||||
|
EndLap: end,
|
||||||
|
StartTime: idx.lapStart(start),
|
||||||
|
EndTime: idx.lapEnd(end),
|
||||||
|
DriverNumbers: sortedDriverNumbers(drivers),
|
||||||
|
}
|
||||||
|
if len(windows) > 0 && ch.StartLap <= windows[len(windows)-1].EndLap+1 {
|
||||||
|
last := &windows[len(windows)-1]
|
||||||
|
if ch.EndLap > last.EndLap {
|
||||||
|
last.EndLap = ch.EndLap
|
||||||
|
last.EndTime = idx.lapEnd(last.EndLap)
|
||||||
|
}
|
||||||
|
drivers := sliceToSet(last.DriverNumbers)
|
||||||
|
for _, driver := range ch.DriverNumbers {
|
||||||
|
drivers[driver] = true
|
||||||
|
}
|
||||||
|
last.DriverNumbers = sortedDriverNumbers(drivers)
|
||||||
|
last.Title = fmt.Sprintf("Pit phase (L%d-L%d)", last.StartLap, last.EndLap)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
windows = append(windows, ch)
|
||||||
|
}
|
||||||
|
return windows
|
||||||
|
}
|
||||||
|
|
||||||
|
type swingCandidate struct {
|
||||||
|
chapter Chapter
|
||||||
|
significance int
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectDecisiveSwings(positions []PositionSample, idx lapIndex, totalLaps int) []Chapter {
|
||||||
|
if len(positions) == 0 || len(idx.events) == 0 || totalLaps <= decisiveAfterLap {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
snapshots := buildPositionSnapshots(positions, idx, totalLaps)
|
||||||
|
if len(snapshots) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
final := snapshots[totalLaps]
|
||||||
|
if len(final) == 0 {
|
||||||
|
for lap := totalLaps - 1; lap >= 1; lap-- {
|
||||||
|
if len(snapshots[lap]) > 0 {
|
||||||
|
final = snapshots[lap]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var candidates []swingCandidate
|
||||||
|
seenDriver := map[int]bool{}
|
||||||
|
for lap := decisiveAfterLap + 1; lap <= totalLaps; lap++ {
|
||||||
|
prev := snapshots[lap-1]
|
||||||
|
curr := snapshots[lap]
|
||||||
|
if len(prev) == 0 || len(curr) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for driver, pos := range curr {
|
||||||
|
prevPos, ok := prev[driver]
|
||||||
|
if !ok || prevPos <= pos || pos > 5 || pos <= 0 || seenDriver[driver] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
finalPos, ok := final[driver]
|
||||||
|
if !ok || finalPos > pos {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
overtaken := driverAtPosition(curr, prevPos, driver)
|
||||||
|
drivers := []int{driver}
|
||||||
|
if overtaken != 0 {
|
||||||
|
drivers = append(drivers, overtaken)
|
||||||
|
}
|
||||||
|
candidates = append(candidates, swingCandidate{
|
||||||
|
chapter: Chapter{
|
||||||
|
Kind: KindDecisiveSwing,
|
||||||
|
Title: fmt.Sprintf("Decisive swing: #%d to P%d (L%d)", driver, pos, lap),
|
||||||
|
StartLap: lap,
|
||||||
|
EndLap: lap,
|
||||||
|
StartTime: idx.lapStart(lap),
|
||||||
|
EndTime: idx.lapEnd(lap),
|
||||||
|
DriverNumbers: drivers,
|
||||||
|
},
|
||||||
|
significance: (prevPos-pos)*10 + (6 - pos),
|
||||||
|
})
|
||||||
|
seenDriver[driver] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(candidates, func(i, j int) bool {
|
||||||
|
if candidates[i].significance == candidates[j].significance {
|
||||||
|
return candidates[i].chapter.StartLap < candidates[j].chapter.StartLap
|
||||||
|
}
|
||||||
|
return candidates[i].significance > candidates[j].significance
|
||||||
|
})
|
||||||
|
if len(candidates) > maxDecisiveSwings {
|
||||||
|
candidates = candidates[:maxDecisiveSwings]
|
||||||
|
}
|
||||||
|
out := make([]Chapter, 0, len(candidates))
|
||||||
|
for _, c := range candidates {
|
||||||
|
out = append(out, c.chapter)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPositionSnapshots(positions []PositionSample, idx lapIndex, totalLaps int) map[int]map[int]int {
|
||||||
|
byLap := map[int][]PositionSample{}
|
||||||
|
for _, p := range positions {
|
||||||
|
if p.Position <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lap := idx.lapForTime(p.Date)
|
||||||
|
if lap <= 0 || lap > totalLaps {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byLap[lap] = append(byLap[lap], p)
|
||||||
|
}
|
||||||
|
last := map[int]int{}
|
||||||
|
snapshots := map[int]map[int]int{}
|
||||||
|
for lap := 1; lap <= totalLaps; lap++ {
|
||||||
|
for _, p := range byLap[lap] {
|
||||||
|
last[p.DriverNumber] = p.Position
|
||||||
|
}
|
||||||
|
if len(last) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cp := make(map[int]int, len(last))
|
||||||
|
for driver, pos := range last {
|
||||||
|
cp[driver] = pos
|
||||||
|
}
|
||||||
|
snapshots[lap] = cp
|
||||||
|
}
|
||||||
|
return snapshots
|
||||||
|
}
|
||||||
|
|
||||||
|
func driverAtPosition(snapshot map[int]int, pos int, exclude int) int {
|
||||||
|
for driver, driverPos := range snapshot {
|
||||||
|
if driver != exclude && driverPos == pos {
|
||||||
|
return driver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectFinish(rc []RaceControl, idx lapIndex, totalLaps int) Chapter {
|
||||||
|
finishLap := totalLaps
|
||||||
|
finishTime := idx.lapEnd(totalLaps)
|
||||||
|
for _, msg := range rc {
|
||||||
|
text := upperText(string(msg.Flag), msg.Message)
|
||||||
|
if strings.Contains(text, "CHEQUER") || string(msg.Flag) == string(models.FlagChequered) {
|
||||||
|
if lap := messageLap(msg, idx); lap > 0 {
|
||||||
|
finishLap = lap
|
||||||
|
}
|
||||||
|
finishTime = firstNonEmpty(msg.Date, finishTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startLap := finishLap - 1
|
||||||
|
if startLap < 1 {
|
||||||
|
startLap = 1
|
||||||
|
}
|
||||||
|
return Chapter{
|
||||||
|
Kind: KindFinish,
|
||||||
|
Title: fmt.Sprintf("Finish (L%d-L%d)", startLap, finishLap),
|
||||||
|
StartLap: startLap,
|
||||||
|
EndLap: finishLap,
|
||||||
|
StartTime: idx.lapStart(startLap),
|
||||||
|
EndTime: finishTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveConflicts(chapters []Chapter) []Chapter {
|
||||||
|
normalized := make([]Chapter, 0, len(chapters))
|
||||||
|
for _, ch := range chapters {
|
||||||
|
if ch.StartLap <= 0 {
|
||||||
|
ch.StartLap = 1
|
||||||
|
}
|
||||||
|
if ch.EndLap <= 0 {
|
||||||
|
ch.EndLap = ch.StartLap
|
||||||
|
}
|
||||||
|
if ch.EndLap < ch.StartLap {
|
||||||
|
ch.EndLap = ch.StartLap
|
||||||
|
}
|
||||||
|
if ch.DriverNumbers == nil {
|
||||||
|
ch.DriverNumbers = []int{}
|
||||||
|
}
|
||||||
|
normalized = append(normalized, ch)
|
||||||
|
}
|
||||||
|
sort.SliceStable(normalized, func(i, j int) bool {
|
||||||
|
if normalized[i].StartLap == normalized[j].StartLap {
|
||||||
|
return priority(normalized[i].Kind) > priority(normalized[j].Kind)
|
||||||
|
}
|
||||||
|
return normalized[i].StartLap < normalized[j].StartLap
|
||||||
|
})
|
||||||
|
|
||||||
|
out := make([]Chapter, 0, len(normalized))
|
||||||
|
for _, ch := range normalized {
|
||||||
|
if len(out) == 0 {
|
||||||
|
out = append(out, ch)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
last := &out[len(out)-1]
|
||||||
|
if ch.StartLap > last.EndLap {
|
||||||
|
out = append(out, ch)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isFlag(last.Kind) && priority(ch.Kind) < priority(last.Kind) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if priority(ch.Kind) > priority(last.Kind) {
|
||||||
|
if last.StartLap < ch.StartLap {
|
||||||
|
last.EndLap = ch.StartLap - 1
|
||||||
|
out = append(out, ch)
|
||||||
|
} else {
|
||||||
|
*last = ch
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ch.EndLap > last.EndLap {
|
||||||
|
ch.StartLap = last.EndLap + 1
|
||||||
|
if ch.StartLap <= ch.EndLap {
|
||||||
|
out = append(out, ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFlag(kind string) bool {
|
||||||
|
return kind == KindSafetyCar || kind == KindVirtualSafetyCar || kind == KindRedFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
func priority(kind string) int {
|
||||||
|
switch kind {
|
||||||
|
case KindStart, KindFinish:
|
||||||
|
return structuralPriority
|
||||||
|
case KindSafetyCar, KindVirtualSafetyCar, KindRedFlag:
|
||||||
|
return flagPriority
|
||||||
|
case KindPitPhase:
|
||||||
|
return pitPhasePriority
|
||||||
|
case KindDecisiveSwing:
|
||||||
|
return decisivePriority
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageLap(msg RaceControl, idx lapIndex) int {
|
||||||
|
if msg.LapNumber != nil && *msg.LapNumber > 0 {
|
||||||
|
return *msg.LapNumber
|
||||||
|
}
|
||||||
|
return idx.lapForTime(msg.Date)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampLap(lap, minLap, maxLap int) int {
|
||||||
|
if lap < minLap {
|
||||||
|
return minLap
|
||||||
|
}
|
||||||
|
if maxLap > 0 && lap > maxLap {
|
||||||
|
return maxLap
|
||||||
|
}
|
||||||
|
return lap
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTime(raw string) (time.Time, bool) {
|
||||||
|
if raw == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
at, err := time.Parse(time.RFC3339, raw)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return at, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func upperText(parts ...string) string {
|
||||||
|
return strings.ToUpper(strings.Join(parts, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedDriverNumbers(drivers map[int]bool) []int {
|
||||||
|
out := make([]int, 0, len(drivers))
|
||||||
|
for driver := range drivers {
|
||||||
|
out = append(out, driver)
|
||||||
|
}
|
||||||
|
sort.Ints(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sliceToSet(values []int) map[int]bool {
|
||||||
|
out := make(map[int]bool, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
out[value] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func minInt(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
218
internal/chapters/chapters_test.go
Normal file
218
internal/chapters/chapters_test.go
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
package chapters
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetectStructuralChapters(t *testing.T) {
|
||||||
|
chapters := Detect(nil, nil, testLaps(10, nil), 10)
|
||||||
|
|
||||||
|
if len(chapters) < 2 {
|
||||||
|
t.Fatalf("chapters len = %d, want at least start and finish", len(chapters))
|
||||||
|
}
|
||||||
|
if got := chapters[0]; got.Kind != KindStart || got.StartLap != 1 || got.EndLap != 1 {
|
||||||
|
t.Fatalf("start chapter = %+v, want L1-L1", got)
|
||||||
|
}
|
||||||
|
got := chapters[len(chapters)-1]
|
||||||
|
if got.Kind != KindFinish || got.StartLap != 9 || got.EndLap != 10 {
|
||||||
|
t.Fatalf("finish chapter = %+v, want L9-L10", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectReturnsEmptyWithoutData(t *testing.T) {
|
||||||
|
if chapters := Detect(nil, nil, nil, 0); len(chapters) != 0 {
|
||||||
|
t.Fatalf("chapters = %+v, want empty", chapters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectFlagPeriods(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
start RaceControl
|
||||||
|
end RaceControl
|
||||||
|
wantKind string
|
||||||
|
wantTitle string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "safety car",
|
||||||
|
start: rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
|
||||||
|
end: rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
|
||||||
|
wantKind: KindSafetyCar,
|
||||||
|
wantTitle: "Safety Car (L12-L15)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "virtual safety car",
|
||||||
|
start: rc(22, models.CategoryOther, "", "VSC DEPLOYED"),
|
||||||
|
end: rc(24, models.CategoryOther, "", "VSC ENDING"),
|
||||||
|
wantKind: KindVirtualSafetyCar,
|
||||||
|
wantTitle: "Virtual Safety Car (L22-L24)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "red flag",
|
||||||
|
start: rc(31, models.CategoryFlag, models.FlagRed, "RED FLAG"),
|
||||||
|
end: rc(33, models.CategoryFlag, models.FlagGreen, "GREEN FLAG"),
|
||||||
|
wantKind: KindRedFlag,
|
||||||
|
wantTitle: "Red Flag (L31-L33)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
chapters := Detect([]RaceControl{tt.start, tt.end}, nil, testLaps(40, nil), 40)
|
||||||
|
got := findKind(chapters, tt.wantKind)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("chapters = %+v, want %s", chapters, tt.wantKind)
|
||||||
|
}
|
||||||
|
if got.StartLap != rcLap(tt.start) || got.EndLap != rcLap(tt.end) || got.Title != tt.wantTitle {
|
||||||
|
t.Fatalf("flag chapter = %+v, want %s", *got, tt.wantTitle)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectPitPhaseFromPitOutLapCluster(t *testing.T) {
|
||||||
|
pitOuts := map[int][]int{
|
||||||
|
5: {1},
|
||||||
|
10: {2},
|
||||||
|
20: {3},
|
||||||
|
21: {4},
|
||||||
|
22: {5},
|
||||||
|
30: {6},
|
||||||
|
35: {7},
|
||||||
|
40: {8},
|
||||||
|
45: {9},
|
||||||
|
50: {10},
|
||||||
|
}
|
||||||
|
chapters := Detect(nil, nil, testLaps(55, pitOuts), 55)
|
||||||
|
|
||||||
|
got := findKind(chapters, KindPitPhase)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("chapters = %+v, want pit phase", chapters)
|
||||||
|
}
|
||||||
|
if got.StartLap != 20 || got.EndLap != 22 {
|
||||||
|
t.Fatalf("pit phase = %+v, want L20-L22", *got)
|
||||||
|
}
|
||||||
|
if len(got.DriverNumbers) != 3 || got.DriverNumbers[0] != 3 || got.DriverNumbers[2] != 5 {
|
||||||
|
t.Fatalf("pit phase drivers = %v, want [3 4 5]", got.DriverNumbers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectDecisiveSwingPersistsToFinish(t *testing.T) {
|
||||||
|
positions := []PositionSample{
|
||||||
|
pos(1, 1, 1),
|
||||||
|
pos(1, 16, 4),
|
||||||
|
pos(1, 55, 3),
|
||||||
|
pos(6, 16, 3),
|
||||||
|
pos(6, 55, 4),
|
||||||
|
pos(8, 44, 5),
|
||||||
|
pos(8, 63, 6),
|
||||||
|
pos(10, 44, 6),
|
||||||
|
}
|
||||||
|
|
||||||
|
chapters := Detect(nil, positions, testLaps(12, nil), 12)
|
||||||
|
got := findKind(chapters, KindDecisiveSwing)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("chapters = %+v, want decisive swing", chapters)
|
||||||
|
}
|
||||||
|
if got.StartLap != 6 || got.EndLap != 6 {
|
||||||
|
t.Fatalf("swing lap = %+v, want L6", *got)
|
||||||
|
}
|
||||||
|
if len(got.DriverNumbers) != 2 || got.DriverNumbers[0] != 16 || got.DriverNumbers[1] != 55 {
|
||||||
|
t.Fatalf("swing drivers = %v, want [16 55]", got.DriverNumbers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectFlagPeriodsWinOverConflictingChapters(t *testing.T) {
|
||||||
|
pitOuts := map[int][]int{
|
||||||
|
12: {1, 2},
|
||||||
|
13: {3, 4},
|
||||||
|
14: {5, 6},
|
||||||
|
}
|
||||||
|
rcs := []RaceControl{
|
||||||
|
rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
|
||||||
|
rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
|
||||||
|
}
|
||||||
|
|
||||||
|
chapters := Detect(rcs, nil, testLaps(20, pitOuts), 20)
|
||||||
|
if got := findKind(chapters, KindSafetyCar); got == nil || got.StartLap != 12 || got.EndLap != 15 {
|
||||||
|
t.Fatalf("chapters = %+v, want safety car L12-L15", chapters)
|
||||||
|
}
|
||||||
|
if got := findKind(chapters, KindPitPhase); got != nil {
|
||||||
|
t.Fatalf("pit phase = %+v, want omitted under safety car", *got)
|
||||||
|
}
|
||||||
|
for i := 1; i < len(chapters); i++ {
|
||||||
|
if chapters[i].StartLap <= chapters[i-1].EndLap {
|
||||||
|
t.Fatalf("chapters overlap at %d: %+v then %+v", i, chapters[i-1], chapters[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rcLap(r RaceControl) int {
|
||||||
|
if r.LapNumber == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *r.LapNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
func findKind(chapters []Chapter, kind string) *Chapter {
|
||||||
|
for i := range chapters {
|
||||||
|
if chapters[i].Kind == kind {
|
||||||
|
return &chapters[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLaps(total int, pitOuts map[int][]int) []Lap {
|
||||||
|
var laps []Lap
|
||||||
|
for lap := 1; lap <= total; lap++ {
|
||||||
|
drivers := []int{1}
|
||||||
|
if pitDrivers := pitOuts[lap]; len(pitDrivers) > 0 {
|
||||||
|
drivers = pitDrivers
|
||||||
|
}
|
||||||
|
for _, driver := range drivers {
|
||||||
|
laps = append(laps, Lap{
|
||||||
|
DriverNumber: driver,
|
||||||
|
LapNumber: lap,
|
||||||
|
DateStart: lapTime(lap),
|
||||||
|
IsPitOutLap: containsDriver(pitOuts[lap], driver),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return laps
|
||||||
|
}
|
||||||
|
|
||||||
|
func rc(lap int, category models.RaceControlCategory, flag models.Flag, message string) RaceControl {
|
||||||
|
return RaceControl{
|
||||||
|
Category: category,
|
||||||
|
Flag: flag,
|
||||||
|
Message: message,
|
||||||
|
LapNumber: &lap,
|
||||||
|
Date: lapTime(lap),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pos(lap int, driver int, position int) PositionSample {
|
||||||
|
return PositionSample{
|
||||||
|
DriverNumber: driver,
|
||||||
|
Position: position,
|
||||||
|
Date: lapTime(lap),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lapTime(lap int) string {
|
||||||
|
minute := lap - 1
|
||||||
|
return fmt.Sprintf("2025-05-25T13:%02d:00Z", minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsDriver(drivers []int, driver int) bool {
|
||||||
|
for _, candidate := range drivers {
|
||||||
|
if candidate == driver {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/chapters"
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
"github.com/AmanTahiliani/box-box/internal/store"
|
"github.com/AmanTahiliani/box-box/internal/store"
|
||||||
)
|
)
|
||||||
@@ -52,6 +53,7 @@ type RaceHub struct {
|
|||||||
RaceControl []models.RaceControl `json:"race_control"`
|
RaceControl []models.RaceControl `json:"race_control"`
|
||||||
Weather []models.Weather `json:"weather"`
|
Weather []models.Weather `json:"weather"`
|
||||||
Laps []models.Lap `json:"laps"`
|
Laps []models.Lap `json:"laps"`
|
||||||
|
Chapters []chapters.Chapter `json:"chapters"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRaceHub loads ingested Race Hub datasets for a session from the local store.
|
// GetRaceHub loads ingested Race Hub datasets for a session from the local store.
|
||||||
@@ -80,6 +82,7 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
|
|||||||
RaceControl: []models.RaceControl{},
|
RaceControl: []models.RaceControl{},
|
||||||
Weather: []models.Weather{},
|
Weather: []models.Weather{},
|
||||||
Laps: []models.Lap{},
|
Laps: []models.Lap{},
|
||||||
|
Chapters: []chapters.Chapter{},
|
||||||
}
|
}
|
||||||
|
|
||||||
sess, err := s.store.GetSession(sessionKey)
|
sess, err := s.store.GetSession(sessionKey)
|
||||||
@@ -248,6 +251,22 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
|
|||||||
hub.Datasets["laps"] = availableLocal(len(hub.Laps))
|
hub.Datasets["laps"] = availableLocal(len(hub.Laps))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hub.Chapters = chapters.Detect(hub.RaceControl, hub.Positions, hub.Laps, totalLaps(hub.Results, hub.Laps))
|
||||||
hub.Source = responseSource(hub.Datasets)
|
hub.Source = responseSource(hub.Datasets)
|
||||||
return hub, nil
|
return hub, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func totalLaps(results []EnrichedResult, laps []models.Lap) int {
|
||||||
|
total := 0
|
||||||
|
for _, result := range results {
|
||||||
|
if result.NumberOfLaps > total {
|
||||||
|
total = result.NumberOfLaps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, lap := range laps {
|
||||||
|
if lap.LapNumber > total {
|
||||||
|
total = lap.LapNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/chapters"
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
"github.com/AmanTahiliani/box-box/internal/query"
|
"github.com/AmanTahiliani/box-box/internal/query"
|
||||||
)
|
)
|
||||||
@@ -42,5 +43,6 @@ func emptyRaceHub(sessionKey int) query.RaceHub {
|
|||||||
Drivers: []models.Driver{},
|
Drivers: []models.Driver{},
|
||||||
Results: []query.EnrichedResult{},
|
Results: []query.EnrichedResult{},
|
||||||
StartingGrid: []query.EnrichedGrid{},
|
StartingGrid: []query.EnrichedGrid{},
|
||||||
|
Chapters: []chapters.Chapter{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,66 @@ func TestHandleRaceHubWithLocalData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleRaceHubIncludesChapters(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
seedRaceHubStore(t, st)
|
||||||
|
sessionKey := 9472
|
||||||
|
meetingKey := 1229
|
||||||
|
|
||||||
|
for lap := 1; lap <= 12; lap++ {
|
||||||
|
if err := st.UpsertLap(store.Lap{
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
DriverNumber: 1,
|
||||||
|
MeetingKey: meetingKey,
|
||||||
|
LapNumber: lap,
|
||||||
|
DateStart: time.Date(2025, 5, 25, 13, lap-1, 0, 0, time.UTC).Format(time.RFC3339),
|
||||||
|
LapDuration: 75,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpsertLap(%d) error = %v", lap, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, sample := range []store.PositionSample{
|
||||||
|
{SessionKey: sessionKey, DriverNumber: 1, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 1},
|
||||||
|
{SessionKey: sessionKey, DriverNumber: 16, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 4},
|
||||||
|
{SessionKey: sessionKey, DriverNumber: 55, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 3},
|
||||||
|
{SessionKey: sessionKey, DriverNumber: 16, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00Z", Position: 3},
|
||||||
|
{SessionKey: sessionKey, DriverNumber: 55, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00Z", Position: 4},
|
||||||
|
} {
|
||||||
|
if err := st.UpsertPositionSample(sample); err != nil {
|
||||||
|
t.Fatalf("UpsertPositionSample() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := testServer(t, st)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/race-hub?session_key=9472", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.handleRaceHub(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hub query.RaceHub
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &hub); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if len(hub.Chapters) == 0 {
|
||||||
|
t.Fatalf("chapters = %+v, want generated chapters", hub.Chapters)
|
||||||
|
}
|
||||||
|
if hub.Chapters[0].Kind != "start" {
|
||||||
|
t.Fatalf("first chapter = %+v, want start", hub.Chapters[0])
|
||||||
|
}
|
||||||
|
foundSwing := false
|
||||||
|
for _, chapter := range hub.Chapters {
|
||||||
|
if chapter.Kind == "decisive_swing" {
|
||||||
|
foundSwing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundSwing {
|
||||||
|
t.Fatalf("chapters = %+v, want decisive_swing", hub.Chapters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleMeetingsSourceLocal(t *testing.T) {
|
func TestHandleMeetingsSourceLocal(t *testing.T) {
|
||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
seedRaceHubStore(t, st)
|
seedRaceHubStore(t, st)
|
||||||
|
|||||||
Reference in New Issue
Block a user