diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 7baae46..01ae7e5 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -2,6 +2,7 @@ import type { ArticleContent, CarDataSample, ChampionshipHub, + DriverSummary, EnrichedGrid, EnrichedResult, LapsComparisonResponse, @@ -108,6 +109,19 @@ export async function fetchChampionshipHub(year?: number): Promise { + const params = new URLSearchParams({ driver_number: String(driverNumber) }) + if (year) params.set('year', String(year)) + const res = await fetch(`/api/v1/driver/summary?${params.toString()}`) + if (!res.ok) { + throw new Error(`API ${res.status}: ${res.statusText}`) + } + return res.json() +} + export async function fetchLiveState(): Promise { const res = await fetch('/api/v1/live/state') if (!res.ok) { diff --git a/frontend/src/lib/driverProfile.ts b/frontend/src/lib/driverProfile.ts new file mode 100644 index 0000000..4b08690 --- /dev/null +++ b/frontend/src/lib/driverProfile.ts @@ -0,0 +1,69 @@ +import type { DriverSummaryRound } from '../types' + +/** One round mapped for the grid-vs-finish chart and results table. */ +export interface RoundDelta { + round: number // 1-based round index within the completed season + label: string // short GP label, e.g. "Bahrain" + grid: number | null + finish: number | null + /** grid − finish: positive = places gained on Sunday, null when unknowable. */ + delta: number | null + points: number + status: 'classified' | 'dnf' | 'dns' | 'dsq' | 'absent' +} + +/** Strip the "Grand Prix" boilerplate so round labels fit chart axes. */ +export function shortGpLabel(meetingName: string): string { + const short = meetingName + .replace(/\s*grand prix\s*/i, ' ') + .replace(/\s+gp\s*$/i, ' ') + .trim() + return short || meetingName +} + +function roundStatus(r: DriverSummaryRound): RoundDelta['status'] { + if (r.dns) return 'dns' + if (r.dsq) return 'dsq' + if (r.dnf) return 'dnf' + if (r.race_position <= 0 && r.grid_position <= 0) return 'absent' + return 'classified' +} + +/** + * Map summary rounds to grid-vs-finish deltas. Positions of 0 mean "no data" + * (driver absent, pit-lane start, …) and become null; a delta is only computed + * when both ends are known and the driver actually started the race. + */ +export function gridFinishDeltas(rounds: DriverSummaryRound[]): RoundDelta[] { + return rounds.map((r, i) => { + const status = roundStatus(r) + const grid = r.grid_position > 0 ? r.grid_position : null + const finish = r.race_position > 0 ? r.race_position : null + const delta = + grid != null && finish != null && status !== 'dns' && status !== 'absent' + ? grid - finish + : null + return { + round: i + 1, + label: shortGpLabel(r.meeting_name), + grid, + finish, + delta, + points: r.points, + status, + } + }) +} + +/** 'P4' for known positions, '—' when unknown. */ +export function formatPosition(pos: number | null): string { + return pos != null && pos > 0 ? `P${pos}` : '—' +} + +/** '+3' (gained), '−2' (lost), '=' (held), '—' (unknown). */ +export function formatDelta(delta: number | null): string { + if (delta == null) return '—' + if (delta > 0) return `+${delta}` + if (delta < 0) return `−${Math.abs(delta)}` + return '=' +} diff --git a/frontend/src/lib/gpIdentity.ts b/frontend/src/lib/gpIdentity.ts index 24cca81..f1dcbeb 100644 --- a/frontend/src/lib/gpIdentity.ts +++ b/frontend/src/lib/gpIdentity.ts @@ -47,7 +47,9 @@ export function countryDecal(meeting: Meeting | undefined | null): string { return name.slice(0, 3).toUpperCase() || '—' } -export function countryFlag(meeting: Meeting | undefined | null): string { +export function countryFlag( + meeting: Pick | undefined | null, +): string { if (meeting?.country_flag && !/^https?:\/\//i.test(meeting.country_flag)) return meeting.country_flag const code = meeting?.country_code?.toUpperCase() const iso2 = code ? CODE3_TO_2[code] : '' diff --git a/frontend/src/pages/ChampionshipPage.tsx b/frontend/src/pages/ChampionshipPage.tsx index c004eb1..b922202 100644 --- a/frontend/src/pages/ChampionshipPage.tsx +++ b/frontend/src/pages/ChampionshipPage.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from 'react' import { useQuery } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' import { fetchChampionshipHub, fetchSeasons } from '../api' import { teamColor } from '../utils' import type { ChampHubDriver, ChampionshipHub } from '../types' @@ -242,6 +243,7 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) { leaderPoints={leader.points} titleMath={titleMath} roundsLeft={hub.rounds_left} + season={hub.season} /> )} {view === 'constructors' && } @@ -271,11 +273,13 @@ function DriversView({ leaderPoints, titleMath, roundsLeft, + season, }: { enriched: EnrichedDriver[] leaderPoints: number titleMath: string roundsLeft: number + season: number }) { const podium = enriched.slice(0, 3) return ( @@ -360,12 +364,19 @@ function DriversView({ P{e.pos} -
+ {e.d.name_acronym} {e.d.full_name} #{e.d.driver_number} -
+ {e.d.team_name} {fmtPts(e.d.points)} diff --git a/frontend/src/pages/DriverProfilePage.tsx b/frontend/src/pages/DriverProfilePage.tsx new file mode 100644 index 0000000..b6294e7 --- /dev/null +++ b/frontend/src/pages/DriverProfilePage.tsx @@ -0,0 +1,369 @@ +import { useQuery } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import { fetchDriverSummary, fetchSeasons } from '../api' +import { teamColor } from '../utils' +import { countryFlag } from '../lib/gpIdentity' +import { + formatDelta, + formatPosition, + gridFinishDeltas, + type RoundDelta, +} from '../lib/driverProfile' +import type { DriverSummary } from '../types' +import '../styles/driver-profile.css' + +interface Props { + driverNumber: number + year?: number +} + +function fmtPts(n: number): string { + return Number.isInteger(n) ? String(n) : n.toFixed(1) +} + +export function DriverProfilePage({ driverNumber, year }: Props) { + // Without an explicit ?year=, default to the latest ingested season; if the + // seasons list is empty/unavailable the backend falls back to the current year. + const seasonsQuery = useQuery({ + queryKey: ['seasons'], + queryFn: fetchSeasons, + enabled: year == null, + }) + const resolvedYear = year ?? seasonsQuery.data?.[0] + const seasonsSettled = year != null || !seasonsQuery.isLoading + + const summaryQuery = useQuery({ + queryKey: ['driver-summary', driverNumber, resolvedYear ?? 'latest'], + queryFn: () => fetchDriverSummary(driverNumber, resolvedYear), + enabled: seasonsSettled && driverNumber > 0, + staleTime: 5 * 60_000, + }) + + if (driverNumber <= 0) { + return
Invalid driver number
+ } + if (!seasonsSettled || summaryQuery.isLoading) { + return
loading driver profile…
+ } + if (summaryQuery.isError) { + return ( +
+ {summaryQuery.error instanceof Error + ? summaryQuery.error.message + : 'Failed to load driver profile'} +
+ ) + } + const summary = summaryQuery.data + if (!summary) { + return
No driver data
+ } + return +} + +function DriverProfileBody({ summary }: { summary: DriverSummary }) { + const color = teamColor(summary.team_colour) + const deltas = gridFinishDeltas(summary.rounds) + + return ( +
+
+
+
+ {summary.name_acronym || `#${summary.driver_number}`} + + #{summary.driver_number} + +
+

{summary.full_name || `Driver ${summary.driver_number}`}

+
+ + {summary.team_name || 'Unknown team'} + {summary.season} +
+
+
+
+
{summary.position > 0 ? `P${summary.position}` : '—'}
+
Championship
+
+
+
{fmtPts(summary.points)}
+
Points
+
+
+
{summary.wins}
+
Wins
+
+
+
{summary.podiums}
+
Podiums
+
+
+
{summary.poles}
+
Poles
+
+
+ + ← Championship + +
+ +
+

Season form

+ {summary.cumulative.length > 0 ? ( +
+ + +
+ ) : ( +
No completed rounds yet.
+ )} +
+ +
+

Quali vs race

+ {deltas.some((d) => d.delta != null) ? ( + + ) : ( +
No grid-vs-finish data yet.
+ )} +
+ +
+

Track by track

+ {summary.rounds.length > 0 ? ( + + ) : ( +
No completed rounds yet.
+ )} +
+
+ ) +} + +const LINE_W = 640 +const LINE_H = 180 +const LINE_PL = 44 +const LINE_PR = 16 +const LINE_PT = 12 +const LINE_PB = 24 + +function CumulativeLine({ + cumulative, + labels, + color, +}: { + cumulative: number[] + labels: string[] + color: string +}) { + const n = cumulative.length + const maxY = Math.max(25, ...cumulative) + const plotW = LINE_W - LINE_PL - LINE_PR + const plotH = LINE_H - LINE_PT - LINE_PB + const x = (i: number) => (n <= 1 ? LINE_PL + plotW / 2 : LINE_PL + (i * plotW) / (n - 1)) + const y = (v: number) => LINE_PT + plotH - (v / maxY) * plotH + const points = cumulative.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ') + + const tickStep = Math.max(1, Math.ceil(n / 8)) + const ticks: { x: number; label: string }[] = [] + for (let i = 0; i < n; i += tickStep) { + ticks.push({ x: x(i), label: labels[i] ?? `R${i + 1}` }) + } + + return ( +
+
Cumulative points
+ + {[0, 0.5, 1].map((f) => { + const v = Math.round(maxY * f) + return ( + + + + {v} + + + ) + })} + {ticks.map((t) => ( + + {t.label} + + ))} + + + +
+ ) +} + +function FormStrip({ form, color }: { form: number[]; color: string }) { + const max = Math.max(25, ...form) + return ( +
+
Last {form.length} races
+
+ {form.map((pts, i) => ( +
+
+ {fmtPts(pts)} +
+ ))} +
+
+ ) +} + +const DELTA_H = 190 +const DELTA_PT = 14 +const DELTA_PB = 30 + +function GridVsRaceChart({ deltas, color }: { deltas: RoundDelta[]; color: string }) { + const maxAbs = Math.max(1, ...deltas.map((d) => Math.abs(d.delta ?? 0))) + const colW = 34 + const w = Math.max(240, deltas.length * colW) + const plotH = DELTA_H - DELTA_PT - DELTA_PB + const zeroY = DELTA_PT + plotH / 2 + const scale = plotH / 2 / maxAbs + + return ( +
+
+ Positions gained (▲) / lost (▼) from grid to flag, per round +
+
+ + + {deltas.map((d, i) => { + const cx = i * colW + colW / 2 + if (d.delta == null) { + return ( + + + · + + + R{d.round} + + + ) + } + const h = Math.abs(d.delta) * scale + const yTop = d.delta >= 0 ? zeroY - h : zeroY + return ( + + {`${d.label}: ${formatPosition(d.grid)} → ${formatPosition(d.finish)} (${formatDelta(d.delta)})`} + = 0 ? 0.95 : 0.45} + /> + = 0 ? yTop - 4 : yTop + h + 11} + textAnchor="middle" + className="dp-delta-val mono" + > + {formatDelta(d.delta)} + + + R{d.round} + + + ) + })} + +
+
+ ) +} + +function statusNote(d: RoundDelta): string { + if (d.status === 'dnf') return 'DNF' + if (d.status === 'dns') return 'DNS' + if (d.status === 'dsq') return 'DSQ' + if (d.status === 'absent') return '—' + return '' +} + +function RoundsTable({ summary, deltas }: { summary: DriverSummary; deltas: RoundDelta[] }) { + return ( +
+ + + + + + + + + + + + + + {summary.rounds.map((r, i) => { + const d = deltas[i] + return ( + + + + + + + + + + ) + })} + +
RndGrand PrixGridRaceΔPts
R{i + 1} + + {r.meeting_name} + {formatPosition(d.grid)}{formatPosition(d.finish)} 0 + ? 'var(--green)' + : d.delta < 0 + ? 'var(--red)' + : 'var(--text-2)', + }} + > + {formatDelta(d.delta)} + {fmtPts(r.points)}{statusNote(d)}
+
+ ) +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 052ec80..c60c792 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -6,12 +6,17 @@ import { DataLibraryPage } from './pages/DataLibraryPage' import { LiveTimingPage } from './pages/LiveTimingPage' import { BriefingPage } from './pages/BriefingPage' import { ChampionshipPage } from './pages/ChampionshipPage' +import { DriverProfilePage } from './pages/DriverProfilePage' import { RacePreviewPage } from './pages/RacePreviewPage' type RaceHubSearch = { session_key?: number } +type DriverProfileSearch = { + year?: number +} + const rootRoute = createRootRoute({ component: () => ( <> @@ -65,6 +70,20 @@ export const championshipRoute = createRoute({ component: ChampionshipPage, }) +export const driverProfileRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/drivers/$driverNumber', + validateSearch: (search: Record): DriverProfileSearch => { + const year = Number(search.year) + return Number.isFinite(year) && year > 0 ? { year } : {} + }, + component: function DriverProfileRoute() { + const { driverNumber } = driverProfileRoute.useParams() + const { year } = driverProfileRoute.useSearch() + return + }, +}) + export const briefingRoute = createRoute({ getParentRoute: () => rootRoute, path: '/briefing', @@ -84,6 +103,7 @@ const routeTree = rootRoute.addChildren([ dataLibraryRoute, liveTimingRoute, championshipRoute, + driverProfileRoute, briefingRoute, previewRoute, ]) diff --git a/frontend/src/styles/driver-profile.css b/frontend/src/styles/driver-profile.css new file mode 100644 index 0000000..956fffd --- /dev/null +++ b/frontend/src/styles/driver-profile.css @@ -0,0 +1,289 @@ +/* Driver profile page (/drivers/$driverNumber) */ + +.dp-page { + max-width: 1100px; + margin: 0 auto; + padding: var(--s6) var(--s5); + display: flex; + flex-direction: column; + gap: var(--s6); +} + +/* --- header --- */ + +.dp-header { + position: relative; + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: var(--s5); + padding: var(--s5); + padding-left: var(--s6); + border: 1px solid var(--border); + border-left: 4px solid var(--red); + border-radius: var(--r2); + background: var(--surface); +} + +.dp-title-row { + display: flex; + align-items: baseline; + gap: var(--s3); +} + +.dp-code { + font-size: 13px; + letter-spacing: 0.12em; + color: var(--text-2); + text-transform: uppercase; +} + +.dp-num { + font-size: 13px; + font-weight: 700; +} + +.dp-name { + margin: var(--s2) 0; + font-size: 30px; + line-height: 1.1; + letter-spacing: -0.01em; +} + +.dp-team { + display: flex; + align-items: center; + gap: var(--s3); + font-size: 13px; + color: var(--text-2); +} + +.dp-team-swatch { + width: 10px; + height: 10px; + border-radius: 2px; +} + +.dp-season { + color: var(--text-3); +} + +.dp-stat-rail { + display: flex; + gap: var(--s5); +} + +.dp-stat { + min-width: 72px; +} + +.dp-stat-value { + font-size: 22px; + font-weight: 700; +} + +.dp-stat-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-3); +} + +.dp-back { + position: absolute; + top: var(--s4); + right: var(--s5); + font-size: 12px; + color: var(--text-3); + text-decoration: none; +} + +.dp-back:hover { + color: var(--text); +} + +/* --- sections --- */ + +.dp-section { + display: flex; + flex-direction: column; + gap: var(--s4); +} + +.dp-section-title { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-2); +} + +.dp-empty { + padding: var(--s5); + border: 1px dashed var(--border); + border-radius: var(--r2); + color: var(--text-3); + font-size: 13px; +} + +/* --- charts --- */ + +.dp-form-grid { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(200px, 1fr); + gap: var(--s5); + align-items: stretch; +} + +@media (max-width: 760px) { + .dp-form-grid { + grid-template-columns: 1fr; + } +} + +.dp-chart { + margin: 0; + padding: var(--s4); + border: 1px solid var(--border); + border-radius: var(--r2); + background: var(--surface); + min-width: 0; +} + +.dp-chart-caption { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-3); + margin-bottom: var(--s3); +} + +.dp-chart-svg { + display: block; + width: 100%; + height: auto; +} + +.dp-chart-scroll { + overflow-x: auto; +} + +.dp-gridline { + stroke: var(--border); + stroke-width: 1; +} + +.dp-zeroline { + stroke: var(--text-3); + stroke-width: 1; + stroke-dasharray: 3 3; +} + +.dp-axis { + fill: var(--text-3); + font-family: var(--f-mono); + font-size: 10px; +} + +.dp-delta-svg { + display: block; +} + +.dp-delta-val { + fill: var(--text-2); + font-size: 10px; +} + +/* --- form strip --- */ + +.dp-form-strip { + padding: var(--s4); + border: 1px solid var(--border); + border-radius: var(--r2); + background: var(--surface); + display: flex; + flex-direction: column; +} + +.dp-form-bars { + flex: 1; + display: flex; + align-items: flex-end; + gap: var(--s3); + min-height: 110px; +} + +.dp-form-bar { + flex: 1; + height: 100%; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: center; + gap: var(--s2); +} + +.dp-form-bar-fill { + width: 100%; + max-width: 34px; + border-radius: 2px 2px 0 0; + opacity: 0.9; +} + +.dp-form-bar-val { + font-size: 11px; + color: var(--text-2); +} + +/* --- rounds table --- */ + +.dp-scroll { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--r2); + background: var(--surface); +} + +.dp-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.dp-table th { + padding: var(--s3) var(--s4); + font-family: var(--f-mono); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-3); + border-bottom: 1px solid var(--border); +} + +.dp-table td { + padding: var(--s3) var(--s4); + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +.dp-table tbody tr:last-child td { + border-bottom: none; +} + +.dp-table th.l, +.dp-table td.l { + text-align: left; +} + +.dp-table th.r, +.dp-table td.r { + text-align: right; +} + +.dp-td-dim { + color: var(--text-3); +} + +.dp-flag { + margin-right: var(--s3); +} diff --git a/frontend/src/test/DriverProfilePage.test.tsx b/frontend/src/test/DriverProfilePage.test.tsx new file mode 100644 index 0000000..29b4841 --- /dev/null +++ b/frontend/src/test/DriverProfilePage.test.tsx @@ -0,0 +1,160 @@ +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 { DriverProfilePage } from '../pages/DriverProfilePage' +import type { DriverSummary } from '../types' + +vi.mock('../api', () => ({ + fetchSeasons: vi.fn(), + fetchDriverSummary: vi.fn(), +})) + +import { fetchSeasons, fetchDriverSummary } from '../api' + +const mockFetchSeasons = vi.mocked(fetchSeasons) +const mockFetchSummary = vi.mocked(fetchDriverSummary) + +const summary: DriverSummary = { + season: 2025, + driver_number: 1, + name_acronym: 'VER', + full_name: 'Max Verstappen', + team_name: 'Red Bull', + team_colour: '3671c6', + headshot_url: '', + points: 50, + position: 1, + wins: 2, + podiums: 2, + poles: 1, + form: [25, 25], + cumulative: [25, 50], + round_labels: ['R1', 'R2'], + rounds: [ + { + meeting_key: 1201, + meeting_name: 'Bahrain Grand Prix', + country_code: 'BHR', + country_name: 'Bahrain', + race_position: 1, + grid_position: 1, + quali_position: 1, + points: 25, + dnf: false, + dns: false, + dsq: false, + }, + { + meeting_key: 1202, + meeting_name: 'Saudi Arabian Grand Prix', + country_code: 'SAU', + country_name: 'Saudi Arabia', + race_position: 1, + grid_position: 4, + quali_position: 4, + points: 25, + dnf: false, + dns: false, + dsq: false, + }, + ], +} + +function renderPage(props: { driverNumber: number; year?: number }) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const rootRoute = createRootRoute({ + component: () => ( + + + + ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => null, + }) + const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) }) + return render() +} + +describe('DriverProfilePage', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFetchSeasons.mockResolvedValue([2025]) + mockFetchSummary.mockResolvedValue(summary) + }) + + it('renders header, form, quali-vs-race, and rounds sections', async () => { + renderPage({ driverNumber: 1, year: 2025 }) + + await waitFor(() => { + expect(screen.getByTestId('driver-profile')).toBeInTheDocument() + }) + + // Header: identity + season stats. + expect(screen.getByTestId('dp-header')).toBeInTheDocument() + expect(screen.getByText('Max Verstappen')).toBeInTheDocument() + expect(screen.getByText('#1')).toBeInTheDocument() + expect(screen.getByText('Red Bull', { exact: false })).toBeInTheDocument() + // 'P1' / '50' also appear in the rounds table and chart axis. + expect(screen.getAllByText('P1').length).toBeGreaterThan(0) + expect(screen.getAllByText('50').length).toBeGreaterThan(0) + + // Season form strip. + expect(screen.getByTestId('dp-form')).toBeInTheDocument() + expect(screen.getByText('Cumulative points')).toBeInTheDocument() + + // Quali-vs-race chart shows the delta from the Saudi round (P4 → P1 = +3). + expect(screen.getByTestId('dp-quali-race')).toBeInTheDocument() + expect(screen.getAllByText('+3').length).toBeGreaterThan(0) + + // Track-by-track table rows with flags. + const rounds = screen.getByTestId('dp-rounds') + expect(rounds).toBeInTheDocument() + expect(screen.getByText('Bahrain Grand Prix')).toBeInTheDocument() + expect(screen.getByText('Saudi Arabian Grand Prix')).toBeInTheDocument() + expect(rounds.textContent).toContain('🇧🇭') + expect(rounds.textContent).toContain('🇸🇦') + + // With an explicit year, the seasons list is not needed. + expect(mockFetchSeasons).not.toHaveBeenCalled() + expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025) + }) + + it('defaults to the latest season when no year is given', async () => { + renderPage({ driverNumber: 1 }) + + await waitFor(() => { + expect(screen.getByTestId('driver-profile')).toBeInTheDocument() + }) + expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025) + }) + + it('shows the API error message on failure', async () => { + mockFetchSummary.mockRejectedValue(new Error('API 404: Not Found')) + renderPage({ driverNumber: 99, year: 2025 }) + + await waitFor(() => { + expect(screen.getByText('API 404: Not Found')).toBeInTheDocument() + }) + }) + + it('shows empty states when no rounds are completed', async () => { + mockFetchSummary.mockResolvedValue({ + ...summary, + form: [], + cumulative: [], + round_labels: [], + rounds: [], + }) + renderPage({ driverNumber: 1, year: 2025 }) + + await waitFor(() => { + expect(screen.getByTestId('driver-profile')).toBeInTheDocument() + }) + expect(screen.getAllByText('No completed rounds yet.').length).toBe(2) + expect(screen.getByText('No grid-vs-finish data yet.')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/test/driverProfile.test.ts b/frontend/src/test/driverProfile.test.ts new file mode 100644 index 0000000..93ebe6e --- /dev/null +++ b/frontend/src/test/driverProfile.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest' +import { + formatDelta, + formatPosition, + gridFinishDeltas, + shortGpLabel, +} from '../lib/driverProfile' +import type { DriverSummaryRound } from '../types' + +function round(over: Partial): DriverSummaryRound { + return { + meeting_key: 1201, + meeting_name: 'Bahrain Grand Prix', + country_code: 'BHR', + country_name: 'Bahrain', + race_position: 1, + grid_position: 1, + quali_position: 1, + points: 25, + dnf: false, + dns: false, + dsq: false, + ...over, + } +} + +describe('gridFinishDeltas', () => { + it('computes positive delta for places gained', () => { + const [d] = gridFinishDeltas([round({ grid_position: 5, race_position: 2, points: 18 })]) + expect(d.grid).toBe(5) + expect(d.finish).toBe(2) + expect(d.delta).toBe(3) + expect(d.points).toBe(18) + expect(d.status).toBe('classified') + expect(d.round).toBe(1) + }) + + it('computes negative delta for places lost', () => { + const [d] = gridFinishDeltas([round({ grid_position: 1, race_position: 4 })]) + expect(d.delta).toBe(-3) + }) + + it('returns null delta when the grid slot is unknown (position 0)', () => { + const [d] = gridFinishDeltas([round({ grid_position: 0, race_position: 6 })]) + expect(d.grid).toBeNull() + expect(d.finish).toBe(6) + expect(d.delta).toBeNull() + }) + + it('flags DNS rounds and never computes a delta for them', () => { + const [d] = gridFinishDeltas([round({ dns: true, grid_position: 8, race_position: 0 })]) + expect(d.status).toBe('dns') + expect(d.delta).toBeNull() + }) + + it('marks rounds the driver did not enter as absent', () => { + const [d] = gridFinishDeltas([round({ grid_position: 0, race_position: 0, points: 0 })]) + expect(d.status).toBe('absent') + expect(d.grid).toBeNull() + expect(d.finish).toBeNull() + expect(d.delta).toBeNull() + }) + + it('still computes the delta for a classified DNF with known positions', () => { + const [d] = gridFinishDeltas([round({ dnf: true, grid_position: 3, race_position: 15 })]) + expect(d.status).toBe('dnf') + expect(d.delta).toBe(-12) + }) + + it('numbers rounds sequentially and shortens labels', () => { + const ds = gridFinishDeltas([ + round({}), + round({ meeting_name: 'Saudi Arabian Grand Prix', meeting_key: 1202 }), + ]) + expect(ds.map((d) => d.round)).toEqual([1, 2]) + expect(ds[1].label).toBe('Saudi Arabian') + }) +}) + +describe('shortGpLabel', () => { + it('strips "Grand Prix" wherever it appears', () => { + expect(shortGpLabel('Bahrain Grand Prix')).toBe('Bahrain') + expect(shortGpLabel('Grand Prix of Monaco')).toBe('of Monaco') + }) + + it('falls back to the original name when stripping empties it', () => { + expect(shortGpLabel('Grand Prix')).toBe('Grand Prix') + }) +}) + +describe('formatPosition / formatDelta', () => { + it('formats positions', () => { + expect(formatPosition(4)).toBe('P4') + expect(formatPosition(null)).toBe('—') + expect(formatPosition(0)).toBe('—') + }) + + it('formats deltas', () => { + expect(formatDelta(3)).toBe('+3') + expect(formatDelta(-2)).toBe('−2') + expect(formatDelta(0)).toBe('=') + expect(formatDelta(null)).toBe('—') + }) +}) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 81d9a56..1d801f0 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -396,6 +396,39 @@ export interface ChampionshipHub { teams: ChampHubTeam[] } +export interface DriverSummaryRound { + meeting_key: number + meeting_name: string + country_code: string + country_name: string + race_position: number + grid_position: number + quali_position?: number + points: number + dnf: boolean + dns: boolean + dsq: boolean +} + +export interface DriverSummary { + season: number + driver_number: number + name_acronym: string + full_name: string + team_name: string + team_colour: string + headshot_url: string + points: number + position: number + wins: number + podiums: number + poles: number + form: number[] + cumulative: number[] + round_labels: string[] + rounds: DriverSummaryRound[] +} + export interface NewsItem { source: string title: string diff --git a/internal/web/api.go b/internal/web/api.go index 01a61bc..7433993 100644 --- a/internal/web/api.go +++ b/internal/web/api.go @@ -797,11 +797,6 @@ func (s *Server) localChampionshipHub(year int) (champHubResponse, bool, error) } func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) { - meetings, err := s.client.GetMeetingsForYear(year) - if err != nil { - return champHubResponse{}, err - } - champ, err := s.client.GetDriverChampionshipForYear(year) if err != nil { return champHubResponse{}, err @@ -816,16 +811,39 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) { driverInfo = buildDriverMapFirst(ds) } + races, incomplete, err := s.fetchSeasonRaces(year) + if err != nil { + return champHubResponse{}, err + } + + resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo) + ttl := champHubTTL(year, time.Now()) + if incomplete { + // Any per-meeting fetch failure (network, rate limit) yields an incomplete + // aggregate: serve it so the page still renders, but cache it only briefly + // so a partial view of the season doesn't stick around for the full TTL. + ttl = champHubIncompleteTTL + } + s.hubCache.put(year, resp, time.Now(), ttl) + return resp, nil +} + +// fetchSeasonRaces returns a season's GP meetings in date order, each bundled +// with its race results and starting grid fetched from OpenF1. incomplete +// reports whether any per-meeting fetch failed, so callers can avoid caching a +// partial view of the season for long. +func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete bool, err error) { + meetings, err := s.client.GetMeetingsForYear(year) + if err != nil { + return nil, false, err + } sort.Slice(meetings, func(i, j int) bool { return meetings[i].DateStart < meetings[j].DateStart }) - // Any per-meeting fetch failure (network, rate limit) yields an incomplete - // aggregate: serve it so the page still renders, but cache it only briefly - // so a partial view of the season doesn't stick around for the full TTL. - var incomplete atomic.Bool - races := fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) { + var failed atomic.Bool + races = fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) { sessions, serr := s.client.GetSessionsForMeeting(int(m.MeetingKey)) if serr != nil { - incomplete.Store(true) + failed.Store(true) return meetingRace{}, false } raceKey := 0 @@ -841,18 +859,11 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) { results, rerr := s.client.GetSessionResult(raceKey) grid, gerr := s.client.GetStartingGrid(raceKey) if rerr != nil || gerr != nil { - incomplete.Store(true) + failed.Store(true) } return meetingRace{Meeting: m, RaceSessionKey: raceKey, Results: results, Grid: grid}, true }) - - resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo) - ttl := champHubTTL(year, time.Now()) - if incomplete.Load() { - ttl = champHubIncompleteTTL - } - s.hubCache.put(year, resp, time.Now(), ttl) - return resp, nil + return races, failed.Load(), nil } // aggregateChampionshipHub is the pure aggregation core (no network) so it can be diff --git a/internal/web/driversummary.go b/internal/web/driversummary.go new file mode 100644 index 0000000..7c87e59 --- /dev/null +++ b/internal/web/driversummary.go @@ -0,0 +1,177 @@ +package web + +import ( + "fmt" + "net/http" + "strconv" + "time" + + "github.com/AmanTahiliani/box-box/internal/models" +) + +// --- /api/v1/driver/summary --- +// Per-driver season summary: championship standing plus per-round race results, +// aggregated server-side from the same sources as the championship hub. Caching +// relies on the OpenF1 client's HTTP cache TTLs — no extra layer here. + +type driverSummaryRound struct { + MeetingKey int `json:"meeting_key"` + MeetingName string `json:"meeting_name"` + CountryCode string `json:"country_code"` + CountryName string `json:"country_name"` + // RacePosition and GridPosition are 0 when the driver has no entry for the + // round (did not enter, or data missing). + RacePosition int `json:"race_position"` + GridPosition int `json:"grid_position"` + // QualiPosition is derived from the starting grid in v1 (full qualifying + // session results are deferred); omitted when unknown. + QualiPosition int `json:"quali_position,omitempty"` + Points float64 `json:"points"` + DNF bool `json:"dnf"` + DNS bool `json:"dns"` + DSQ bool `json:"dsq"` +} + +type driverSummaryResponse struct { + Season int `json:"season"` + DriverNumber int `json:"driver_number"` + NameAcronym string `json:"name_acronym"` + FullName string `json:"full_name"` + TeamName string `json:"team_name"` + TeamColour string `json:"team_colour"` + HeadshotURL string `json:"headshot_url"` + Points float64 `json:"points"` + Position int `json:"position"` + Wins int `json:"wins"` + Podiums int `json:"podiums"` + Poles int `json:"poles"` + Form []float64 `json:"form"` + Cumulative []float64 `json:"cumulative"` + RoundLabels []string `json:"round_labels"` + Rounds []driverSummaryRound `json:"rounds"` +} + +func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) { + driverNumber, err := strconv.Atoi(r.URL.Query().Get("driver_number")) + if err != nil || driverNumber <= 0 { + http.Error(w, "driver_number required", http.StatusBadRequest) + return + } + year, _ := strconv.Atoi(r.URL.Query().Get("year")) + if year == 0 { + year = time.Now().Year() + } + + champ, err := s.client.GetDriverChampionshipForYear(year) + if err != nil { + writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + return + } + var entry *models.ChampionshipDriver + for i := range champ { + if champ[i].DriverNumber == driverNumber { + entry = &champ[i] + break + } + } + if entry == nil { + http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound) + return + } + + driverInfo := map[int]models.Driver{} + if ds, derr := s.client.GetDriversForSession(champ[0].SessionKey); derr == nil { + driverInfo = buildDriverMapFirst(ds) + } + if d, ok := s.championshipDriverInfo(entry.SessionKey, driverNumber, driverInfo); ok { + driverInfo[driverNumber] = d + } + + races, _, err := s.fetchSeasonRaces(year) + if err != nil { + writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + return + } + + resp, ok := aggregateDriverSummary(year, driverNumber, races, champ, driverInfo) + if !ok { + http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound) + return + } + writeJSON(w, resp) +} + +// aggregateDriverSummary is the pure aggregation core (no network) so it can be +// unit-tested with synthetic data. It reuses the championship-hub aggregation +// for the derived season stats (wins, podiums, poles, form, cumulative) and +// adds the per-round result rows. races must be ordered ascending by date and +// contain only GP meetings. Returns ok=false when the driver is not in champ. +func aggregateDriverSummary( + year, driverNumber int, + races []meetingRace, + champ []models.ChampionshipDriver, + driverInfo map[int]models.Driver, +) (driverSummaryResponse, bool) { + hub := aggregateChampionshipHub(year, races, champ, nil, driverInfo) + + var hd *champHubDriver + for i := range hub.Drivers { + if hub.Drivers[i].DriverNumber == driverNumber { + hd = &hub.Drivers[i] + break + } + } + if hd == nil { + return driverSummaryResponse{}, false + } + + rounds := make([]driverSummaryRound, 0, len(races)) + for _, mr := range races { + if len(mr.Results) == 0 { + continue // round not completed yet + } + round := driverSummaryRound{ + MeetingKey: int(mr.Meeting.MeetingKey), + MeetingName: mr.Meeting.MeetingName, + CountryCode: mr.Meeting.CountryCode, + CountryName: mr.Meeting.CountryName, + } + for _, res := range mr.Results { + if res.DriverNumber == driverNumber { + round.RacePosition = res.Position + round.Points = res.Points + round.DNF = res.DNF + round.DNS = res.DNS + round.DSQ = res.DSQ + break + } + } + for _, g := range mr.Grid { + if g.DriverNumber == driverNumber { + round.GridPosition = g.Position + round.QualiPosition = g.Position + break + } + } + rounds = append(rounds, round) + } + + return driverSummaryResponse{ + Season: year, + DriverNumber: hd.DriverNumber, + NameAcronym: hd.NameAcronym, + FullName: hd.FullName, + TeamName: hd.TeamName, + TeamColour: hd.TeamColour, + HeadshotURL: driverInfo[driverNumber].HeadshotURL, + Points: hd.Points, + Position: hd.Position, + Wins: hd.Wins, + Podiums: hd.Podiums, + Poles: hd.Poles, + Form: hd.Form, + Cumulative: hd.Cumulative, + RoundLabels: hub.RoundLabels, + Rounds: rounds, + }, true +} diff --git a/internal/web/driversummary_test.go b/internal/web/driversummary_test.go new file mode 100644 index 0000000..16e1dec --- /dev/null +++ b/internal/web/driversummary_test.go @@ -0,0 +1,136 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/AmanTahiliani/box-box/internal/models" +) + +func driverSummaryFixtures() ([]meetingRace, []models.ChampionshipDriver, map[int]models.Driver) { + driverInfo := map[int]models.Driver{ + 1: {DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull", TeamColour: "3671c6", HeadshotURL: "https://example.com/ver.png"}, + 3: {DriverNumber: 3, NameAcronym: "HAM", FullName: "Lewis Hamilton", TeamName: "Mercedes", TeamColour: "27f4d2"}, + } + + champ := []models.ChampionshipDriver{ + {DriverNumber: 1, PointsCurrent: 50, PositionCurrent: 1, SessionKey: 99}, + {DriverNumber: 3, PointsCurrent: 33, PositionCurrent: 2, SessionKey: 99}, + } + + // Round 1: VER P1 from pole, HAM P2 from P3. + // Round 2: VER P1 from P2, HAM P3 from pole. + // Round 3: not yet run — must not appear in rounds. + races := []meetingRace{ + { + Meeting: models.Meeting{MeetingKey: 1201, MeetingName: "Bahrain GP", CountryCode: "BHR", CountryName: "Bahrain"}, + Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(3, 2, 18)}, + Grid: []models.StartingGrid{{DriverNumber: 1, Position: 1}, {DriverNumber: 3, Position: 3}}, + }, + { + Meeting: models.Meeting{MeetingKey: 1202, MeetingName: "Saudi GP", CountryCode: "SAU", CountryName: "Saudi Arabia"}, + Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(3, 3, 15)}, + Grid: []models.StartingGrid{{DriverNumber: 3, Position: 1}, {DriverNumber: 1, Position: 2}}, + }, + {Meeting: models.Meeting{MeetingKey: 1203, MeetingName: "Australia GP"}}, + } + return races, champ, driverInfo +} + +func TestAggregateDriverSummary(t *testing.T) { + races, champ, driverInfo := driverSummaryFixtures() + + resp, ok := aggregateDriverSummary(2025, 1, races, champ, driverInfo) + if !ok { + t.Fatal("aggregateDriverSummary ok = false, want true") + } + + if resp.Season != 2025 || resp.DriverNumber != 1 { + t.Errorf("season/driver = %d/%d, want 2025/1", resp.Season, resp.DriverNumber) + } + if resp.NameAcronym != "VER" || resp.FullName != "Max Verstappen" || resp.TeamName != "Red Bull" || resp.TeamColour != "3671c6" { + t.Errorf("driver identity = %s %s %s %s", resp.NameAcronym, resp.FullName, resp.TeamName, resp.TeamColour) + } + if resp.HeadshotURL != "https://example.com/ver.png" { + t.Errorf("headshot = %q", resp.HeadshotURL) + } + if resp.Points != 50 || resp.Position != 1 { + t.Errorf("points/position = %v/%d, want 50/1", resp.Points, resp.Position) + } + if resp.Wins != 2 || resp.Podiums != 2 || resp.Poles != 1 { + t.Errorf("wins/podiums/poles = %d/%d/%d, want 2/2/1", resp.Wins, resp.Podiums, resp.Poles) + } + if len(resp.Form) != 2 || resp.Form[0] != 25 || resp.Form[1] != 25 { + t.Errorf("form = %v, want [25 25]", resp.Form) + } + // Cumulative reconciles the last value to the official total. + if len(resp.Cumulative) != 2 || resp.Cumulative[0] != 25 || resp.Cumulative[1] != 50 { + t.Errorf("cumulative = %v, want [25 50]", resp.Cumulative) + } + if len(resp.RoundLabels) != 2 || resp.RoundLabels[0] != "R1" { + t.Errorf("round labels = %v, want [R1 R2]", resp.RoundLabels) + } + + // Only completed rounds appear. + if len(resp.Rounds) != 2 { + t.Fatalf("rounds = %d, want 2", len(resp.Rounds)) + } + r1 := resp.Rounds[0] + if r1.MeetingKey != 1201 || r1.MeetingName != "Bahrain GP" || r1.CountryCode != "BHR" || r1.CountryName != "Bahrain" { + t.Errorf("round 1 meeting = %+v", r1) + } + if r1.RacePosition != 1 || r1.GridPosition != 1 || r1.QualiPosition != 1 || r1.Points != 25 { + t.Errorf("round 1 result = %+v, want P1 from P1 with 25 pts", r1) + } + r2 := resp.Rounds[1] + if r2.RacePosition != 1 || r2.GridPosition != 2 || r2.Points != 25 { + t.Errorf("round 2 result = %+v, want P1 from P2 with 25 pts", r2) + } +} + +func TestAggregateDriverSummaryDriverMissingFromRound(t *testing.T) { + races, champ, driverInfo := driverSummaryFixtures() + // Drop HAM from round 2's results and grid: the round still appears with + // zero-valued positions so round indexing stays aligned with the season. + races[1].Results = []models.SessionResult{raceResult(1, 1, 25)} + races[1].Grid = []models.StartingGrid{{DriverNumber: 1, Position: 2}} + + resp, ok := aggregateDriverSummary(2025, 3, races, champ, driverInfo) + if !ok { + t.Fatal("aggregateDriverSummary ok = false, want true") + } + if len(resp.Rounds) != 2 { + t.Fatalf("rounds = %d, want 2", len(resp.Rounds)) + } + r2 := resp.Rounds[1] + if r2.RacePosition != 0 || r2.GridPosition != 0 || r2.QualiPosition != 0 || r2.Points != 0 { + t.Errorf("round 2 for absent driver = %+v, want zero values", r2) + } +} + +func TestAggregateDriverSummaryUnknownDriver(t *testing.T) { + races, champ, driverInfo := driverSummaryFixtures() + if _, ok := aggregateDriverSummary(2025, 44, races, champ, driverInfo); ok { + t.Error("unknown driver should return ok = false") + } +} + +func TestHandleDriverSummaryBadRequest(t *testing.T) { + srv := testServer(t, nil) + + // Missing, non-numeric, zero, and negative driver_number are all rejected. + for _, url := range []string{ + "/api/v1/driver/summary?year=2025", + "/api/v1/driver/summary?year=2025&driver_number=abc", + "/api/v1/driver/summary?year=2025&driver_number=0", + "/api/v1/driver/summary?year=2025&driver_number=-4", + } { + req := httptest.NewRequest(http.MethodGet, url, nil) + rec := httptest.NewRecorder() + srv.handleDriverSummary(rec, req) + if rec.Code != http.StatusBadRequest { + t.Errorf("%s: status = %d, want 400", url, rec.Code) + } + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 729c44a..68245b3 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -70,6 +70,9 @@ func (s *Server) routes() (http.Handler, error) { mux.HandleFunc("/api/v1/news", s.handleNews) mux.HandleFunc("/api/v1/meetings", s.handleMeetings) mux.HandleFunc("/api/v1/sessions", s.handleSessions) + // /api/v1/driver/summary before /api/v1/drivers: distinct prefixes today, + // but keep the more specific driver/* paths registered first. + mux.HandleFunc("/api/v1/driver/summary", s.handleDriverSummary) mux.HandleFunc("/api/v1/drivers", s.handleDrivers) mux.HandleFunc("/api/v1/results", s.handleResults) mux.HandleFunc("/api/v1/grid", s.handleGrid)