mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
feat(frontend): add next-race preview page (#25)
Assemble /preview from existing schedule, track-outline, results, and championship hub endpoints with client-side preview.ts helpers, section loading/empty states, and Vitest coverage for lib + page render paths. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
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)}`
|
||||
}
|
||||
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 })
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user