diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 1b7d972..7baae46 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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 { } export async function fetchSeasonMeetings(year: number): Promise { - const res = await fetch(`/api/v1/meetings?year=${year}&source=openf1`) + return fetchMeetings(year, 'openf1') +} + +export async function fetchMeetings(year: number, source = 'auto'): Promise { + 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 { return Array.isArray(meetings) ? meetings : [] } +export async function fetchResults(sessionKey: number, source = 'auto'): Promise { + 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 { + 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 { + 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 { const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`) if (!res.ok) { diff --git a/frontend/src/components/Nav.tsx b/frontend/src/components/Nav.tsx index f337c1c..5542858 100644 --- a/frontend/src/components/Nav.tsx +++ b/frontend/src/components/Nav.tsx @@ -16,6 +16,9 @@ export function Nav() { Race Hub + + Preview + Championship diff --git a/frontend/src/lib/preview.ts b/frontend/src/lib/preview.ts new file mode 100644 index 0000000..15eec53 --- /dev/null +++ b/frontend/src/lib/preview.ts @@ -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)}` +} diff --git a/frontend/src/pages/RacePreviewPage.tsx b/frontend/src/pages/RacePreviewPage.tsx new file mode 100644 index 0000000..ca6d68a --- /dev/null +++ b/frontend/src/pages/RacePreviewPage.tsx @@ -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
Loading…
+ } + if (error) { + return ( +
+ {error instanceof Error ? error.message : 'Failed to load'} +
+ ) + } + if (empty) { + return
{emptyMessage ?? 'No data available'}
+ } + 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 ( +
+

Circuit

+ +
+ + + + +
+
+
+ ) +} + +function LastYearCard({ + year, + loading, + error, + podium, + pole, + isFirstTime, +}: { + year: number | null + loading: boolean + error: Error | null + podium: ReturnType + pole: ReturnType + isFirstTime: boolean +}) { + return ( +
+

Last year here

+ {year != null && !isFirstTime &&

{year} race weekend

} + +
+ {podium.map((place) => ( +
+ P{place.position} + + {place.name_acronym} + + {place.team_name} +
+ ))} +
+ {pole && ( +

+ Pole: {pole.name_acronym}{' '} + ({pole.team_name}) +

+ )} +
+
+ ) +} + +function TitleFightCard({ + loading, + error, + drivers, + sprintWeekend, + season, +}: { + loading: boolean + error: Error | null + drivers: ReturnType + sprintWeekend: boolean + season: number | null +}) { + return ( +
+

Title fight context

+ {season != null &&

{season} drivers' championship · top 3

} + + + + + + + + + + + + {drivers.map((d) => ( + + + + + + + ))} + +
DriverPtsGapIf win (+{RACE_WIN_POINTS})
+ {d.name_acronym} + {d.points}{formatPointsGap(d.gapToLeader)} + {d.position === 1 ? 'LEADER' : formatPointsGap(d.gapAfterRaceWin)} +
+ {sprintWeekend && ( +

+ 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. +

+ )} +
+
+ ) +} + +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 ( +
+
+
+
+
+

+ {countryFlag(meeting) && {countryFlag(meeting)}} + {meeting.meeting_name} +

+

+ {meeting.circuit_short_name} + {meeting.location ? ` · ${meeting.location}` : ''} + {formatGpDateRange(meeting) ? ` · ${formatGpDateRange(meeting)}` : ''} +

+
+ {countdownTarget && ( +
+ + {countdownSession?.session_name ?? 'Next session'} + + {formatCountdown(countdownTarget, now)} +
+ )} +
+ {sessions.length > 0 && ( +
+ {sessions.map((session) => ( +
+ {session.session_name} + {formatSessionScheduleTime(session.date_start)} +
+ ))} +
+ )} +
+
+ ) +} + +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
loading preview…
+ } + + if (seasonsQuery.isError || meetingsQuery.isError) { + const err = seasonsQuery.error ?? meetingsQuery.error + return ( +
+ {err instanceof Error ? err.message : 'Failed to load preview'} +
+ ) + } + + if (!previewMeeting) { + return ( +
+
+ box-box · race preview +

Season complete

+

+ No upcoming races on the {latestSeason ?? 'current'} calendar. Check back when the next season schedule is + published. +

+
+ {titleFight.length > 0 && ( + + )} +
+ ) + } + + return ( +
+ + +
+ + + +
+ + +
+ ) +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index a0227d3..052ec80 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -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 }) diff --git a/frontend/src/styles/preview.css b/frontend/src/styles/preview.css new file mode 100644 index 0000000..e83f4bd --- /dev/null +++ b/frontend/src/styles/preview.css @@ -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; + } +} diff --git a/frontend/src/test/RacePreviewPage.test.tsx b/frontend/src/test/RacePreviewPage.test.tsx new file mode 100644 index 0000000..1ceee9d --- /dev/null +++ b/frontend/src/test/RacePreviewPage.test.tsx @@ -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 => ({ + 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: () => ( + + + + ), + }) + const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: RacePreviewPage }) + const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) }) + return render() +} + +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') + }) + }) +}) diff --git a/frontend/src/test/preview.test.ts b/frontend/src/test/preview.test.ts new file mode 100644 index 0000000..9429511 --- /dev/null +++ b/frontend/src/test/preview.test.ts @@ -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_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_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 => ({ + 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) + }) +}) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c0b2fcf..22befb4 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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